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 @@
11included:
22 - ios/T3Code
33 - modules/t3-composer-editor/ios
4 - modules/t3-native-controls/ios
45 - modules/t3-terminal/ios
56 - modules/t3-review-diff/ios
67
apps/mobile/app.config.ts+14-1
......@@ -17,6 +17,7 @@ const VARIANT_CONFIG: Record<
1717 readonly iosIcon: string;
1818 readonly iosBundleIdentifier: string;
1919 readonly androidPackage: string;
20 readonly relyingParty?: string;
2021 }
2122> = {
2223 development: {
......@@ -25,6 +26,7 @@ const VARIANT_CONFIG: Record<
2526 iosIcon: "./assets/icon-composer-dev.icon",
2627 iosBundleIdentifier: "com.t3tools.t3code.dev",
2728 androidPackage: "com.t3tools.t3code.dev",
29 relyingParty: "clerk.t3.codes",
2830 },
2931 preview: {
3032 appName: "T3 Code Preview",
......@@ -32,6 +34,7 @@ const VARIANT_CONFIG: Record<
3234 iosIcon: "./assets/icon-composer-prod.icon",
3335 iosBundleIdentifier: "com.t3tools.t3code.preview",
3436 androidPackage: "com.t3tools.t3code.preview",
37 relyingParty: "clerk.t3.codes",
3538 },
3639 production: {
3740 appName: "T3 Code",
......@@ -39,6 +42,7 @@ const VARIANT_CONFIG: Record<
3942 iosIcon: "./assets/icon-composer-prod.icon",
4043 iosBundleIdentifier: "com.t3tools.t3code",
4144 androidPackage: "com.t3tools.t3code",
45 relyingParty: "clerk.t3.codes",
4246 },
4347};
4448
......@@ -77,6 +81,14 @@ const config: ExpoConfig = {
7781 icon: variant.iosIcon,
7882 supportsTablet: true,
7983 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 ],
8092 infoPlist: {
8193 NSAppTransportSecurity: {
8294 NSAllowsArbitraryLoads: true,
......@@ -101,7 +113,6 @@ const config: ExpoConfig = {
101113 favicon: "./assets/favicon.png",
102114 },
103115 plugins: [
104 "expo-router",
105116 "expo-font",
106117 "expo-secure-store",
107118 ["@clerk/expo", { theme: "./clerk-theme.json" }],
......@@ -139,6 +150,7 @@ const config: ExpoConfig = {
139150 },
140151 },
141152 ],
153 "./plugins/withIosCocoaPodsUuidCache.cjs",
142154 [
143155 "expo-widgets",
144156 {
......@@ -155,6 +167,7 @@ const config: ExpoConfig = {
155167 ],
156168 },
157169 ],
170 "./plugins/withIosSceneLifecycle.cjs",
158171 "./plugins/withAndroidCleartextTraffic.cjs",
159172 ],
160173 extra: {
apps/mobile/global.css+18-18
......@@ -199,24 +199,24 @@
199199 --font-bold: "DMSans_700Bold";
200200
201201 /* Keep this scale aligned with src/lib/typography.ts for native style props. */
202 --text-3xs: 10px;
203 --text-3xs--line-height: 13px;
204 --text-2xs: 11px;
205 --text-2xs--line-height: 15px;
206 --text-xs: 12px;
207 --text-xs--line-height: 16px;
208 --text-sm: 13px;
209 --text-sm--line-height: 18px;
210 --text-base: 15px;
211 --text-base--line-height: 22px;
212 --text-lg: 17px;
213 --text-lg--line-height: 22px;
214 --text-xl: 20px;
215 --text-xl--line-height: 26px;
216 --text-2xl: 24px;
217 --text-2xl--line-height: 30px;
218 --text-3xl: 28px;
219 --text-3xl--line-height: 34px;
202 --text-3xs: 11px;
203 --text-3xs--line-height: 14px;
204 --text-2xs: 12px;
205 --text-2xs--line-height: 16px;
206 --text-xs: 13px;
207 --text-xs--line-height: 17px;
208 --text-sm: 14px;
209 --text-sm--line-height: 19px;
210 --text-base: 16px;
211 --text-base--line-height: 23px;
212 --text-lg: 18px;
213 --text-lg--line-height: 23px;
214 --text-xl: 21px;
215 --text-xl--line-height: 28px;
216 --text-2xl: 26px;
217 --text-2xl--line-height: 32px;
218 --text-3xl: 30px;
219 --text-3xl--line-height: 36px;
220220}
221221
222222/* ─── Custom utilities ──────────────────────────────────────────────── */
apps/mobile/index.ts+10-1
......@@ -1,2 +1,11 @@
1import { registerRootComponent } from "expo";
12import "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 {
4747 "onComposerSelectionChange",
4848 "onComposerFocus",
4949 "onComposerBlur",
50 "onComposerSubmit",
5051 "onComposerPasteImages",
5152 "onComposerContentSizeChange"
5253 )
apps/mobile/modules/t3-composer-editor/ios/T3ComposerEditorView.swift+22
......@@ -63,6 +63,24 @@ private final class ComposerTextView: UITextView {
6363
6464 var onPasteImages: (([String]) -> Void)?
6565 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
6785 override func canPerformAction(_ action: Selector, withSender sender: Any?) -> Bool {
6886 if action == #selector(paste(_:)) {
......@@ -299,6 +317,7 @@ public final class T3ComposerEditorView: ExpoView, UITextViewDelegate {
299317 let onComposerSelectionChange = EventDispatcher()
300318 let onComposerFocus = EventDispatcher()
301319 let onComposerBlur = EventDispatcher()
320 let onComposerSubmit = EventDispatcher()
302321 let onComposerPasteImages = EventDispatcher()
303322 let onComposerContentSizeChange = EventDispatcher()
304323
......@@ -320,6 +339,9 @@ public final class T3ComposerEditorView: ExpoView, UITextViewDelegate {
320339 textView.onAttributedMutation = { [weak self] in
321340 self?.emitTextChange()
322341 }
342 textView.onSubmit = { [weak self] in
343 self?.onComposerSubmit([:])
344 }
323345 addSubview(textView)
324346
325347 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 {
2626 return `${node.type}:${node.beg ?? index}:${node.end ?? index}`;
2727}
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
2938function nodeText(node: MarkdownNode): string {
3039 if (node.content !== undefined) {
3140 return node.content;
......@@ -161,8 +170,8 @@ function HighlightedCodeText(props: {
161170 style={{
162171 color: props.textStyle.codeColor,
163172 fontFamily: "ui-monospace",
164 fontSize: 12,
165 lineHeight: 18,
173 fontSize: codeBlockFontSize(props.textStyle),
174 lineHeight: codeBlockLineHeight(props.textStyle),
166175 }}
167176 >
168177 {props.content}
......@@ -196,8 +205,8 @@ function HighlightedCodeText(props: {
196205 style={{
197206 color: props.textStyle.codeColor,
198207 fontFamily: "ui-monospace",
199 fontSize: 12,
200 lineHeight: 18,
208 fontSize: codeBlockFontSize(props.textStyle),
209 lineHeight: codeBlockLineHeight(props.textStyle),
201210 }}
202211 >
203212 {keyedLines.map((line, lineIndex) => (
......@@ -264,7 +273,7 @@ function NativeCodeBlock(props: {
264273 flex: 1,
265274 color: props.textStyle.mutedColor,
266275 fontFamily: "ui-monospace",
267 fontSize: 12,
276 fontSize: codeBlockFontSize(props.textStyle),
268277 }}
269278 >
270279 {languageLabel}
apps/mobile/modules/t3-markdown-text/src/NativeMarkdownSelectableText.ios.tsx+21-4
......@@ -32,11 +32,25 @@ function runKeySignature(run: NativeMarkdownTextRun): string {
3232 ].join(":");
3333}
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
3549function runStyle(run: NativeMarkdownTextRun, textStyle: NativeMarkdownTextStyle): TextStyle {
3650 const isFile = run.fileIcon != null;
3751 const isSkill = run.skillName != null;
3852 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);
4054 const isHeading = run.role === "heading";
4155 const isCodeBlock = run.role === "code-block" || run.role === "code-language";
4256 const hasParagraphStyle = run.headIndent !== undefined;
......@@ -88,7 +102,7 @@ function runStyle(run: NativeMarkdownTextRun, textStyle: NativeMarkdownTextStyle
88102 : isHeading
89103 ? headingFontSize
90104 : run.role === "code-language"
91 ? 11
105 ? Math.max(10, Math.round(textStyle.fontSize * 0.73))
92106 : run.code || isCodeBlock
93107 ? Math.max(12, textStyle.fontSize - 2)
94108 : textStyle.fontSize,
......@@ -98,9 +112,9 @@ function runStyle(run: NativeMarkdownTextRun, textStyle: NativeMarkdownTextStyle
98112 : run.role === "list-break"
99113 ? textStyle.lineHeight + (run.spacing ?? 0)
100114 : isHeading
101 ? Math.max(headingFontSize + 6, 20)
115 ? Math.max(headingFontSize + 6, textStyle.lineHeight + 2)
102116 : isCodeBlock
103 ? 18
117 ? Math.max(16, textStyle.lineHeight - 2)
104118 : textStyle.lineHeight,
105119 fontStyle: run.italic ? "italic" : "normal",
106120 fontWeight: isHeading || run.bold || isFile || isSkill ? "700" : "400",
......@@ -148,6 +162,9 @@ export function NativeMarkdownSelectableText(props: {
148162 // color-only child update can otherwise leave the previous appearance cached.
149163 const appearanceKey = [
150164 colorScheme ?? "unspecified",
165 props.textStyle.fontSize,
166 props.textStyle.lineHeight,
167 props.textStyle.headingFontSizes?.join(","),
151168 props.textStyle.color,
152169 props.textStyle.strongColor,
153170 props.textStyle.mutedColor,
apps/mobile/modules/t3-markdown-text/src/SelectableMarkdownText.types.ts+1
......@@ -16,6 +16,7 @@ export interface NativeMarkdownTextStyle {
1616 readonly fontFamily: string;
1717 readonly headingFontFamily: string;
1818 readonly boldFontFamily: string;
19 readonly headingFontSizes?: ReadonlyArray<number>;
1920}
2021
2122export 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 {
55 Name("T3ReviewDiffSurface")
66
77 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
208 Prop("tokensResetKey") { (view: T3ReviewDiffView, tokensResetKey: String) in
219 view.setTokensResetKey(tokensResetKey)
2210 }
2311
12 Prop("contentResetKey") { (view: T3ReviewDiffView, contentResetKey: String) in
13 view.setContentResetKey(contentResetKey)
14 }
15
2416 Prop("collapsedFileIdsJson") { (view: T3ReviewDiffView, collapsedFileIdsJson: String) in
2517 view.setCollapsedFileIdsJson(collapsedFileIdsJson)
2618 }
......@@ -61,7 +53,42 @@ public class T3ReviewDiffModule: Module {
6153 view.setInitialRowIndex(initialRowIndex)
6254 }
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 }
6592 }
6693 }
6794}
apps/mobile/modules/t3-review-diff/ios/T3ReviewDiffView.swift+270-60
......@@ -1,7 +1,7 @@
11import ExpoModulesCore
22import UIKit
33
4private struct ReviewDiffNativeRow: Decodable {
4private struct ReviewDiffNativeRow: Decodable, Sendable {
55 let kind: String
66 let id: String
77 let fileId: String?
......@@ -21,18 +21,18 @@ private struct ReviewDiffNativeRow: Decodable {
2121 let commentSectionTitle: String?
2222}
2323
24private struct ReviewDiffNativeWordDiffRange: Decodable {
24private struct ReviewDiffNativeWordDiffRange: Decodable, Sendable {
2525 let start: Int
2626 let end: Int
2727}
2828
29private struct ReviewDiffNativeToken: Decodable {
29private struct ReviewDiffNativeToken: Decodable, Sendable {
3030 let content: String
3131 let color: String?
3232 let fontStyle: Int?
3333}
3434
35private struct ReviewDiffNativeTokenPatch: Decodable {
35private struct ReviewDiffNativeTokenPatch: Decodable, Sendable {
3636 let resetKey: String?
3737 let chunkIndex: Int?
3838 let tokensByRowId: [String: [ReviewDiffNativeToken]]?
......@@ -312,6 +312,10 @@ private struct ReviewDiffNativeStyle {
312312}
313313
314314public final class T3ReviewDiffView: ExpoView, UIScrollViewDelegate {
315 private let payloadDecodeQueue = DispatchQueue(
316 label: "com.t3tools.review-diff.payload-decode",
317 qos: .userInitiated
318 )
315319 private let scrollView = UIScrollView()
316320 private let contentView = ReviewDiffContentView()
317321 private var rows: [ReviewDiffNativeRow] = []
......@@ -323,14 +327,25 @@ public final class T3ReviewDiffView: ExpoView, UIScrollViewDelegate {
323327 private var lastMetricsDebugKey = ""
324328 private var lastVisibleRangeDebugKey = ""
325329 private var tokensResetKey = ""
330 private var contentResetKey = ""
326331 private var initialRowIndex: Int?
327332 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
329340 let onDebug = EventDispatcher()
341 let onVisibleFileChange = EventDispatcher()
330342 let onToggleFile = EventDispatcher()
331343 let onToggleViewedFile = EventDispatcher()
332344 let onPressLine = EventDispatcher()
333345 let onToggleComment = EventDispatcher()
346 let onPullToRefresh = EventDispatcher()
347
348 private let pullRefreshControl = UIRefreshControl()
334349
335350 public required init(appContext: AppContext? = nil) {
336351 super.init(appContext: appContext)
......@@ -345,6 +360,8 @@ public final class T3ReviewDiffView: ExpoView, UIScrollViewDelegate {
345360 scrollView.showsVerticalScrollIndicator = true
346361 scrollView.showsHorizontalScrollIndicator = false
347362 scrollView.backgroundColor = contentView.theme.background
363 pullRefreshControl.addTarget(self, action: #selector(handlePullToRefresh), for: .valueChanged)
364 scrollView.refreshControl = pullRefreshControl
348365 addSubview(scrollView)
349366
350367 contentView.backgroundColor = contentView.theme.background
......@@ -379,6 +396,13 @@ public final class T3ReviewDiffView: ExpoView, UIScrollViewDelegate {
379396 updateViewportFrame()
380397 }
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
382406 public func scrollViewDidEndDragging(_ scrollView: UIScrollView, willDecelerate decelerate: Bool) {
383407 guard !decelerate else {
384408 return
......@@ -396,77 +420,125 @@ public final class T3ReviewDiffView: ExpoView, UIScrollViewDelegate {
396420 }
397421
398422 func setRowsJson(_ rowsJson: String) {
399 guard let data = rowsJson.data(using: .utf8) else {
400 return
401 }
423 rowsDecodeGeneration += 1
424 let generation = rowsDecodeGeneration
402425
403 do {
404 rows = try JSONDecoder().decode([ReviewDiffNativeRow].self, from: data)
405 contentView.rows = rows
406 hasAppliedInitialRowIndex = false
407 emitDebug("rows-decoded", [
408 "rows": rows.count,
409 "firstKind": rows.first?.kind ?? "none",
410 ])
411 updateContentMetrics()
412 } catch {
413 rows = []
414 contentView.rows = []
415 hasAppliedInitialRowIndex = false
416 updateContentMetrics()
417 emitDebug("rows-decode-failed", [
418 "error": error.localizedDescription,
419 ])
426 payloadDecodeQueue.async { [weak self] in
427 guard let data = rowsJson.data(using: .utf8) else {
428 return
429 }
430
431 do {
432 let decodedRows = try JSONDecoder().decode([ReviewDiffNativeRow].self, from: data)
433 DispatchQueue.main.async { [weak self] in
434 guard let self, generation == self.rowsDecodeGeneration else {
435 return
436 }
437 self.rows = decodedRows
438 self.contentView.rows = decodedRows
439 self.hasAppliedInitialRowIndex = false
440 self.lastVisibleFileId = nil
441 self.emitDebug("rows-decoded", [
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 }
420463 }
421464 }
422465
423466 func setTokensJson(_ tokensJson: String) {
424 guard let data = tokensJson.data(using: .utf8) else {
425 return
426 }
467 tokensDecodeGeneration += 1
468 let generation = tokensDecodeGeneration
427469
428 do {
429 contentView.tokensByRowId = try JSONDecoder().decode(
430 [String: [ReviewDiffNativeToken]].self,
431 from: data
432 )
433 } catch {
434 contentView.tokensByRowId = [:]
435 emitDebug("tokens-decode-failed", [
436 "error": error.localizedDescription,
437 ])
470 payloadDecodeQueue.async { [weak self] in
471 guard let data = tokensJson.data(using: .utf8) else {
472 return
473 }
474
475 do {
476 let decodedTokens = try JSONDecoder().decode(
477 [String: [ReviewDiffNativeToken]].self,
478 from: data
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 }
438496 }
439497 }
440498
441499 func setTokensPatchJson(_ tokensPatchJson: String) {
442 guard let data = tokensPatchJson.data(using: .utf8) else {
443 return
444 }
500 let contentResetKey = self.contentResetKey
445501
446 do {
447 let patch = try JSONDecoder().decode(ReviewDiffNativeTokenPatch.self, from: data)
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 {
502 payloadDecodeQueue.async { [weak self] in
503 guard let data = tokensPatchJson.data(using: .utf8) else {
455504 return
456505 }
457506
458 contentView.mergeTokensByRowId(tokensByRowId)
459 if let chunkIndex = patch.chunkIndex, chunkIndex < 5 || chunkIndex.isMultiple(of: 10) {
460 emitDebug("tokens-patch-decoded", [
461 "chunkIndex": chunkIndex,
462 "rows": tokensByRowId.count,
463 "totalRows": contentView.tokensByRowId.count,
464 ])
507 do {
508 let patch = try JSONDecoder().decode(ReviewDiffNativeTokenPatch.self, from: data)
509 DispatchQueue.main.async { [weak self] in
510 guard let self else {
511 return
512 }
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 }
465541 }
466 } catch {
467 emitDebug("tokens-patch-decode-failed", [
468 "error": error.localizedDescription,
469 ])
470542 }
471543 }
472544
......@@ -482,6 +554,27 @@ public final class T3ReviewDiffView: ExpoView, UIScrollViewDelegate {
482554 ])
483555 }
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
485578 func setCollapsedFileIdsJson(_ collapsedFileIdsJson: String) {
486579 let nextCollapsedFileIds = decodeFileIdSet(collapsedFileIdsJson)
487580 let changedFileIds = contentView.collapsedFileIds.symmetricDifference(nextCollapsedFileIds)
......@@ -573,6 +666,8 @@ public final class T3ReviewDiffView: ExpoView, UIScrollViewDelegate {
573666 contentView.invalidateVisibleViewport()
574667 contentView.setNeedsDisplay()
575668 applyInitialRowIndexIfNeeded()
669 applyPendingScrollIfNeeded()
670 emitVisibleFileIfNeeded()
576671
577672 let debugKey = "\(rows.count):\(Int(bounds.width)):\(Int(bounds.height)):\(Int(height))"
578673 if debugKey != lastMetricsDebugKey {
......@@ -596,11 +691,41 @@ public final class T3ReviewDiffView: ExpoView, UIScrollViewDelegate {
596691 }
597692
598693 private func finishVerticalScroll() {
694 isProgrammaticScrollActive = false
599695 contentView.isVerticalScrollActive = false
600696 updateViewportFrame()
601697 emitVisibleRange(reason: "scroll-end")
602698 }
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
604729 private func emitVisibleRange(reason: String) {
605730 guard let range = contentView.currentVisibleRowRange() else {
606731 return
......@@ -670,6 +795,33 @@ public final class T3ReviewDiffView: ExpoView, UIScrollViewDelegate {
670795 applyInitialRowIndexIfNeeded()
671796 }
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
673825 private func applyStyle() {
674826 contentView.style = ReviewDiffNativeStyle
675827 .resolve(stylePayload)
......@@ -686,6 +838,38 @@ public final class T3ReviewDiffView: ExpoView, UIScrollViewDelegate {
686838 )
687839 contentView.verticalOffset = scrollView.contentOffset.y
688840 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 }
689873 }
690874
691875 private func applyInitialRowIndexIfNeeded() {
......@@ -1255,6 +1439,32 @@ private final class ReviewDiffContentView: UIView, UIGestureRecognizerDelegate {
12551439 return rowOffsets[rowIndex]
12561440 }
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
12581468 private func fileHeaderRowIndex(forFileId fileId: String) -> Int? {
12591469 fileHeaderRowIndices.first { rowIndex in
12601470 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() {
77 override fun definition() = ModuleDefinition {
88 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
1016 View(T3TerminalView::class) {
1117 Prop("terminalKey") { view: T3TerminalView, terminalKey: String ->
1218 view.terminalKey = terminalKey
......@@ -20,6 +26,10 @@ class T3TerminalModule : Module() {
2026 view.fontSize = fontSize.toFloat()
2127 }
2228
29 Prop("focusRequest") { view: T3TerminalView, focusRequest: Double ->
30 view.focusRequest = focusRequest
31 }
32
2333 Prop("appearanceScheme") { view: T3TerminalView, appearanceScheme: String ->
2434 view.appearanceScheme = appearanceScheme
2535 }
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
6262
6363 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
6574 var backgroundColorHex: String = "#24292E"
6675 set(value) {
6776 field = value
......@@ -113,11 +122,19 @@ class T3TerminalView(context: Context, appContext: AppContext) : ExpoView(contex
113122 }
114123 inputView.setOnKeyListener { _, keyCode, event ->
115124 if (event.action != android.view.KeyEvent.ACTION_DOWN) return@setOnKeyListener false
116 when (keyCode) {
117 android.view.KeyEvent.KEYCODE_DEL -> {
125 when {
126 keyCode == android.view.KeyEvent.KEYCODE_DEL -> {
118127 onInput(mapOf("data" to "\u007F"))
119128 true
120129 }
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 }
121138 else -> false
122139 }
123140 }
apps/mobile/modules/t3-terminal/ios/T3TerminalModule.swift+10
......@@ -4,6 +4,12 @@ public class T3TerminalModule: Module {
44 public func definition() -> ModuleDefinition {
55 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
713 View(T3TerminalView.self) {
814 Prop("terminalKey") { (view: T3TerminalView, terminalKey: String) in
915 view.terminalKey = terminalKey
......@@ -17,6 +23,10 @@ public class T3TerminalModule: Module {
1723 view.fontSize = CGFloat(fontSize)
1824 }
1925
26 Prop("focusRequest") { (view: T3TerminalView, focusRequest: Double) in
27 view.focusRequest = focusRequest
28 }
29
2030 Prop("appearanceScheme") { (view: T3TerminalView, appearanceScheme: String) in
2131 view.appearanceScheme = appearanceScheme
2232 }
apps/mobile/modules/t3-terminal/ios/T3TerminalView.swift+128
......@@ -22,13 +22,129 @@ private enum GhosttyRuntime {
2222 }
2323}
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
25122private final class TerminalInputField: UITextField {
26123 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
28134 override func deleteBackward() {
29135 onDeleteBackward?()
30136 super.deleteBackward()
31137 }
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 }
32148}
33149
34150private enum TerminalAppearanceScheme: String {
......@@ -108,6 +224,15 @@ public final class T3TerminalView: ExpoView, UITextFieldDelegate {
108224 }
109225 }
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
111236 var appearanceScheme: String = TerminalAppearanceScheme.dark.rawValue {
112237 didSet {
113238 guard oldValue != appearanceScheme else { return }
......@@ -167,6 +292,9 @@ public final class T3TerminalView: ExpoView, UITextFieldDelegate {
167292 inputField.onDeleteBackward = { [weak self] in
168293 self?.emitInput("\u{7F}")
169294 }
295 inputField.onInsert = { [weak self] data in
296 self?.emitInput(data)
297 }
170298
171299 focusTapGesture.addTarget(self, action: #selector(handleViewportTap))
172300 terminalViewport.addGestureRecognizer(focusTapGesture)
apps/mobile/package.json+23-20
......@@ -5,7 +5,7 @@
55 "main": "index.ts",
66 "scripts": {
77 "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",
99 "start": "expo start",
1010 "start:dev": "APP_VARIANT=development expo start",
1111 "start:preview": "APP_VARIANT=preview expo start",
......@@ -43,12 +43,16 @@
4343 "@clerk/expo": "catalog:",
4444 "@effect/atom-react": "catalog:",
4545 "@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",
4748 "@legendapp/list": "3.2.0",
4849 "@noble/curves": "catalog:",
4950 "@noble/hashes": "catalog:",
5051 "@pierre/diffs": "catalog:",
5152 "@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",
5256 "@shikijs/core": "4.2.0",
5357 "@shikijs/engine-javascript": "4.2.0",
5458 "@shikijs/langs": "4.2.0",
......@@ -62,38 +66,37 @@
6266 "clsx": "^2.1.1",
6367 "diff": "8.0.3",
6468 "effect": "catalog:",
65 "expo": "^56.0.0",
66 "expo-asset": "~56.0.15",
67 "expo-auth-session": "~56.0.12",
68 "expo-build-properties": "~56.0.15",
69 "expo-camera": "~56.0.7",
70 "expo-clipboard": "~56.0.3",
71 "expo-constants": "~56.0.16",
69 "expo": "~56.0.12",
70 "expo-asset": "~56.0.17",
71 "expo-auth-session": "~56.0.14",
72 "expo-build-properties": "~56.0.19",
73 "expo-camera": "~56.0.8",
74 "expo-clipboard": "~56.0.4",
75 "expo-constants": "~56.0.18",
7276 "expo-crypto": "~56.0.4",
73 "expo-dev-client": "~56.0.16",
74 "expo-file-system": "~56.0.7",
75 "expo-font": "~56.0.5",
77 "expo-dev-client": "~56.0.20",
78 "expo-file-system": "~56.0.8",
79 "expo-font": "~56.0.7",
7680 "expo-glass-effect": "~56.0.4",
7781 "expo-haptics": "~56.0.3",
78 "expo-image-picker": "~56.0.14",
79 "expo-linking": "~56.0.12",
82 "expo-image-picker": "~56.0.18",
83 "expo-linking": "~56.0.14",
8084 "expo-network": "~56.0.5",
81 "expo-notifications": "~56.0.14",
85 "expo-notifications": "~56.0.18",
8286 "expo-paste-input": "^0.1.15",
83 "expo-router": "~56.2.7",
8487 "expo-secure-store": "~56.0.4",
8588 "expo-splash-screen": "~56.0.10",
86 "expo-symbols": "~56.0.5",
87 "expo-updates": "~56.0.17",
89 "expo-symbols": "~56.0.6",
90 "expo-updates": "~56.0.19",
8891 "expo-web-browser": "~56.0.5",
89 "expo-widgets": "~56.0.15",
92 "expo-widgets": "~56.0.19",
9093 "punycode": "^2.3.1",
9194 "react": "19.2.3",
9295 "react-dom": "19.2.3",
9396 "react-native": "0.85.3",
9497 "react-native-gesture-handler": "~2.31.1",
9598 "react-native-image-viewing": "^0.2.2",
96 "react-native-keyboard-controller": "1.21.7",
99 "react-native-keyboard-controller": "1.21.6",
97100 "react-native-nitro-markdown": "^0.5.0",
98101 "react-native-nitro-modules": "0.35.9",
99102 "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 @@
1import type { Ref } from "react";
21import {
32 Text as RNText,
43 TextInput as RNTextInput,
......@@ -21,7 +20,7 @@ export function AppText({ className, ...props }: AppTextProps) {
2120
2221export type AppTextInputProps = RNTextInputProps & {
2322 readonly className?: string;
24 readonly ref?: Ref<RNTextInput>;
23 readonly ref?: React.Ref<RNTextInput>;
2524};
2625
2726/**
......@@ -40,7 +39,7 @@ export function AppTextInput({
4039 <RNTextInput
4140 ref={ref}
4241 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",
4443 className,
4544 )}
4645 placeholderTextColor={placeholderTextColor ?? placeholderColor}
apps/mobile/src/components/ControlPill.tsx+1-1
......@@ -75,7 +75,7 @@ export function ControlPill(props: {
7575}
7676
7777export function ControlPillMenu(
78 props: Omit<ComponentProps<typeof MenuView>, "children" | "style" | "themeVariant"> & {
78 props: Omit<ComponentProps<typeof MenuView>, "children" | "themeVariant"> & {
7979 readonly children: ReactNode;
8080 },
8181) {
apps/mobile/src/components/EmptyState.tsx+23-1
......@@ -7,11 +7,33 @@ export function EmptyState(props: {
77 readonly detail: string;
88 readonly actionLabel?: string;
99 readonly onAction?: () => void;
10 readonly variant?: "card" | "plain";
1011}) {
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
1133 return (
1234 <View className="rounded-[22px] border border-border bg-card p-5">
1335 <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">
1537 {props.detail}
1638 </Text>
1739 {props.actionLabel && props.onAction ? (
apps/mobile/src/components/ErrorBanner.tsx+1-1
......@@ -4,7 +4,7 @@ import { AppText as Text } from "./AppText";
44export function ErrorBanner(props: { readonly message: string }) {
55 return (
66 <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">
88 {props.message}
99 </Text>
1010 </View>
apps/mobile/src/features/agent-awareness/notificationNavigation.ts+4-4
......@@ -1,12 +1,12 @@
11import { useEffect, useRef } from "react";
22import * as Notifications from "expo-notifications";
3import { useRouter } from "expo-router";
3import { useLinkTo } from "@react-navigation/native";
44
55import { routeAgentNotificationResponseOnce } from "./notificationPayload";
66import { consumeLastAgentNotificationResponse } from "./notificationResponseConsumer";
77
88export function useAgentNotificationNavigation(): void {
9 const router = useRouter();
9 const linkTo = useLinkTo();
1010 const handledResponseIds = useRef(new Set<string>());
1111
1212 useEffect(() => {
......@@ -14,7 +14,7 @@ export function useAgentNotificationNavigation(): void {
1414 routeAgentNotificationResponseOnce({
1515 handledResponseIds: handledResponseIds.current,
1616 response,
17 navigate: (deepLink) => router.push(deepLink as never),
17 navigate: linkTo,
1818 });
1919 };
2020
......@@ -28,5 +28,5 @@ export function useAgentNotificationNavigation(): void {
2828 return () => {
2929 subscription.remove();
3030 };
31 }, [router]);
31 }, [linkTo]);
3232}
apps/mobile/src/features/archive/ArchivedThreadsRouteScreen.tsx+1-1
......@@ -1,7 +1,7 @@
11import type { EnvironmentId } from "@t3tools/contracts";
22import * as Arr from "effect/Array";
33import * as Order from "effect/Order";
4import { useFocusEffect } from "expo-router";
4import { useFocusEffect } from "@react-navigation/native";
55import { useCallback, useMemo, useState } from "react";
66
77import { useSavedRemoteConnections } from "../../state/use-remote-environment-registry";
apps/mobile/src/features/archive/ArchivedThreadsScreen.tsx+361-245
......@@ -2,23 +2,22 @@ import type {
22 EnvironmentProject,
33 EnvironmentThreadShell,
44} from "@t3tools/client-runtime/state/shell";
5import { LegendList } from "@legendapp/list/react-native";
56import type { EnvironmentId } from "@t3tools/contracts";
67import type { MenuAction } from "@react-native-menu/menu";
7import * as Haptics from "expo-haptics";
8import { Stack } from "expo-router";
8import { NativeHeaderToolbar, NativeStackScreenOptions } from "../../native/StackHeader";
99import { SymbolView } from "expo-symbols";
10import { useCallback, useRef } from "react";
10import { useCallback, useMemo, useRef, type ComponentProps } from "react";
1111import {
1212 ActivityIndicator,
13 Platform,
1314 Pressable,
1415 RefreshControl,
15 ScrollView,
1616 useWindowDimensions,
1717 View,
1818} from "react-native";
19import ReanimatedSwipeable, {
20 type SwipeableMethods,
21} from "react-native-gesture-handler/ReanimatedSwipeable";
19import { Gesture, GestureDetector } from "react-native-gesture-handler";
20import type { SwipeableMethods } from "react-native-gesture-handler/ReanimatedSwipeable";
2221
2322import { AppText as Text } from "../../components/AppText";
2423import { ControlPillMenu } from "../../components/ControlPill";
......@@ -26,11 +25,8 @@ import { EmptyState } from "../../components/EmptyState";
2625import { ProjectFavicon } from "../../components/ProjectFavicon";
2726import { relativeTime } from "../../lib/time";
2827import { useThemeColor } from "../../lib/useThemeColor";
29import {
30 THREAD_SWIPE_ACTIONS_WIDTH,
31 THREAD_SWIPE_SPRING,
32 ThreadSwipeActions,
33} from "../home/thread-swipe-actions";
28import { ThreadSwipeable } from "../home/thread-swipe-actions";
29import { createNativeMailSearchToolbarItem } from "../layout/native-mail-search-toolbar";
3430import type { ArchivedThreadGroup, ArchivedThreadSortOrder } from "./archivedThreadList";
3531
3632export interface ArchivedThreadsHeaderEnvironment {
......@@ -52,84 +48,185 @@ const THREAD_ACTIONS: MenuAction[] = [
5248 },
5349];
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
5567function ArchivedThreadsHeader(props: {
5668 readonly environments: ReadonlyArray<ArchivedThreadsHeaderEnvironment>;
5769 readonly selectedEnvironmentId: EnvironmentId | null;
5870 readonly sortOrder: ArchivedThreadSortOrder;
5971 readonly onEnvironmentChange: (environmentId: EnvironmentId | null) => void;
72 readonly onRefresh: () => void;
6073 readonly onSearchQueryChange: (query: string) => void;
6174 readonly onSortOrderChange: (sortOrder: ArchivedThreadSortOrder) => void;
6275}) {
76 const { width } = useWindowDimensions();
6377 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
65125 return (
66126 <>
67 <Stack.Screen
127 {/* Static header config (glass preset + title) lives in Stack.tsx; only
128 dynamic toolbar/search wiring is set here. */}
129 <NativeStackScreenOptions
68130 options={{
69 title: "Archived Threads",
70 headerSearchBarOptions: {
71 autoCapitalize: "none",
72 hideNavigationBar: false,
73 obscureBackground: false,
74 placeholder: "Search archived threads",
75 placement: "stacked",
76 onChangeText: (event) => {
77 props.onSearchQueryChange(event.nativeEvent.text);
78 },
79 onCancelButtonPress: () => {
80 props.onSearchQueryChange("");
81 },
82 },
131 unstable_headerToolbarItems: usesCompactMailToolbar
132 ? () => [
133 createNativeMailSearchToolbarItem({
134 composeButtonId: "archived-refresh",
135 composeSystemImageName: "arrow.clockwise",
136 filterMenu: archiveFilterMenu,
137 filterButtonId: "archived-filter",
138 filterSystemImageName: hasCustomFilter
139 ? "line.3.horizontal.decrease.circle.fill"
140 : "line.3.horizontal.decrease",
141 onComposePress: props.onRefresh,
142 onSearchTextChange: props.onSearchQueryChange,
143 placeholder: "Search",
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 },
83170 }}
84171 />
85172
86 <Stack.Toolbar placement="right">
87 <Stack.Toolbar.Menu
88 accessibilityLabel="Filter and sort archived threads"
89 icon={
90 hasCustomFilter
91 ? "line.3.horizontal.decrease.circle.fill"
92 : "line.3.horizontal.decrease.circle"
93 }
94 separateBackground
95 title="Archived thread options"
96 >
97 <Stack.Toolbar.Menu title="Environment">
98 <Stack.Toolbar.Label>Environment</Stack.Toolbar.Label>
99 <Stack.Toolbar.MenuAction
100 isOn={props.selectedEnvironmentId === null}
101 onPress={() => props.onEnvironmentChange(null)}
102 >
103 <Stack.Toolbar.Label>All environments</Stack.Toolbar.Label>
104 </Stack.Toolbar.MenuAction>
105 {props.environments.map((environment) => (
106 <Stack.Toolbar.MenuAction
107 key={environment.environmentId}
108 isOn={props.selectedEnvironmentId === environment.environmentId}
109 onPress={() => props.onEnvironmentChange(environment.environmentId)}
173 {usesCompactMailToolbar ? null : (
174 <NativeHeaderToolbar placement="right">
175 {usesNativeChrome ? (
176 <NativeHeaderToolbar.Button
177 accessibilityLabel="Refresh archived threads"
178 icon="arrow.clockwise"
179 onPress={props.onRefresh}
180 separateBackground
181 />
182 ) : null}
183 <NativeHeaderToolbar.Menu
184 accessibilityLabel="Filter and sort archived threads"
185 icon={
186 hasCustomFilter
187 ? "line.3.horizontal.decrease.circle.fill"
188 : "line.3.horizontal.decrease.circle"
189 }
190 separateBackground
191 title="Archived thread options"
192 >
193 <NativeHeaderToolbar.Menu title="Environment">
194 <NativeHeaderToolbar.Label>Environment</NativeHeaderToolbar.Label>
195 <NativeHeaderToolbar.MenuAction
196 isOn={props.selectedEnvironmentId === null}
197 onPress={() => props.onEnvironmentChange(null)}
110198 >
111 <Stack.Toolbar.Label>{environment.label}</Stack.Toolbar.Label>
112 </Stack.Toolbar.MenuAction>
113 ))}
114 </Stack.Toolbar.Menu>
199 <NativeHeaderToolbar.Label>All environments</NativeHeaderToolbar.Label>
200 </NativeHeaderToolbar.MenuAction>
201 {props.environments.map((environment) => (
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">
117 <Stack.Toolbar.Label>Sort by archived date</Stack.Toolbar.Label>
118 <Stack.Toolbar.MenuAction
119 isOn={props.sortOrder === "newest"}
120 onPress={() => props.onSortOrderChange("newest")}
121 >
122 <Stack.Toolbar.Label>Newest first</Stack.Toolbar.Label>
123 </Stack.Toolbar.MenuAction>
124 <Stack.Toolbar.MenuAction
125 isOn={props.sortOrder === "oldest"}
126 onPress={() => props.onSortOrderChange("oldest")}
127 >
128 <Stack.Toolbar.Label>Oldest first</Stack.Toolbar.Label>
129 </Stack.Toolbar.MenuAction>
130 </Stack.Toolbar.Menu>
131 </Stack.Toolbar.Menu>
132 </Stack.Toolbar>
212 <NativeHeaderToolbar.Menu title="Sort by archived date">
213 <NativeHeaderToolbar.Label>Sort by archived date</NativeHeaderToolbar.Label>
214 <NativeHeaderToolbar.MenuAction
215 isOn={props.sortOrder === "newest"}
216 onPress={() => props.onSortOrderChange("newest")}
217 >
218 <NativeHeaderToolbar.Label>Newest first</NativeHeaderToolbar.Label>
219 </NativeHeaderToolbar.MenuAction>
220 <NativeHeaderToolbar.MenuAction
221 isOn={props.sortOrder === "oldest"}
222 onPress={() => props.onSortOrderChange("oldest")}
223 >
224 <NativeHeaderToolbar.Label>Oldest first</NativeHeaderToolbar.Label>
225 </NativeHeaderToolbar.MenuAction>
226 </NativeHeaderToolbar.Menu>
227 </NativeHeaderToolbar.Menu>
228 </NativeHeaderToolbar>
229 )}
133230 </>
134231 );
135232}
......@@ -164,30 +261,25 @@ function ProjectGroupLabel(props: {
164261
165262function ArchivedThreadRow(props: {
166263 readonly environmentLabel: string | null;
264 readonly isFirst: boolean;
167265 readonly isLast: boolean;
168266 readonly onDelete: () => void;
169267 readonly onSwipeableClose: (methods: SwipeableMethods) => void;
170268 readonly onSwipeableWillOpen: (methods: SwipeableMethods) => void;
269 readonly simultaneousSwipeGesture?: ComponentProps<
270 typeof ThreadSwipeable
271 >["simultaneousWithExternalGesture"];
171272 readonly onUnarchive: () => void;
172273 readonly thread: EnvironmentThreadShell;
173274}) {
174 const swipeableRef = useRef<SwipeableMethods | null>(null);
175 const fullSwipeArmedRef = useRef(false);
176275 const { width: windowWidth } = useWindowDimensions();
177276 const cardColor = useThemeColor("--color-card");
178277 const iconColor = useThemeColor("--color-icon-subtle");
179278 const separatorColor = useThemeColor("--color-separator");
180 const fullSwipeThreshold = Math.max(THREAD_SWIPE_ACTIONS_WIDTH + 44, (windowWidth - 32) * 0.58);
181279 const timestamp = relativeTime(props.thread.archivedAt ?? props.thread.updatedAt);
182280 const subtitle = [props.environmentLabel, props.thread.branch].filter((part): part is string =>
183281 Boolean(part),
184282 );
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 }, []);
191283 const handleMenuAction = useCallback(
192284 (event: { nativeEvent: { event: string } }) => {
193285 if (event.nativeEvent.event === "unarchive") {
......@@ -200,114 +292,84 @@ function ArchivedThreadRow(props: {
200292 );
201293
202294 return (
203 <ReanimatedSwipeable
204 ref={swipeableRef}
205 animationOptions={THREAD_SWIPE_SPRING}
206 childrenContainerStyle={{ backgroundColor: cardColor }}
207 containerStyle={{ backgroundColor: cardColor }}
208 dragOffsetFromRightEdge={8}
209 enableTrackpadTwoFingerGesture
210 friction={1}
211 onSwipeableClose={() => {
212 fullSwipeArmedRef.current = false;
213 if (swipeableRef.current) {
214 props.onSwipeableClose(swipeableRef.current);
215 }
216 }}
217 onSwipeableOpenStartDrag={() => {
218 if (swipeableRef.current) {
219 props.onSwipeableWillOpen(swipeableRef.current);
220 }
295 <ThreadSwipeable
296 backgroundColor={cardColor}
297 fullSwipeWidth={windowWidth - 32}
298 onDelete={props.onDelete}
299 onSwipeableClose={props.onSwipeableClose}
300 onSwipeableWillOpen={props.onSwipeableWillOpen}
301 primaryAction={{
302 accessibilityLabel: `Unarchive ${props.thread.title}`,
303 icon: "arrow.uturn.backward",
304 label: "Unarchive",
305 onPress: props.onUnarchive,
221306 }}
222 onSwipeableWillOpen={() => {
223 const methods = swipeableRef.current;
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}
307 simultaneousWithExternalGesture={props.simultaneousSwipeGesture}
308 threadTitle={props.thread.title}
253309 >
254 <View
255 className="flex-row items-center gap-3 bg-card px-4 py-3"
256 style={{
257 borderBottomColor: separatorColor,
258 borderBottomWidth: props.isLast ? 0 : 1,
259 }}
260 >
261 <View className="h-[34px] w-[34px] items-center justify-center rounded-[11px] bg-subtle">
262 <SymbolView name="archivebox.fill" size={15} tintColor={iconColor} type="monochrome" />
263 </View>
264
265 <View className="min-w-0 flex-1 gap-1">
266 <View className="flex-row items-center gap-2">
267 <Text
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>
310 {() => (
311 <View
312 className="flex-row items-center gap-3 bg-card px-4 py-3"
313 style={{
314 borderBottomColor: separatorColor,
315 borderBottomWidth: props.isLast ? 0 : 1,
316 borderTopLeftRadius: props.isFirst ? 20 : 0,
317 borderTopRightRadius: props.isFirst ? 20 : 0,
318 borderBottomLeftRadius: props.isLast ? 20 : 0,
319 borderBottomRightRadius: props.isLast ? 20 : 0,
320 }}
321 >
322 <View className="h-[34px] w-[34px] items-center justify-center rounded-[11px] bg-subtle">
323 <SymbolView name="archivebox.fill" size={15} tintColor={iconColor} type="monochrome" />
279324 </View>
280 {subtitle.length > 0 ? (
281 <View className="flex-row items-center gap-1.5">
282 <SymbolView
283 name="arrow.triangle.branch"
284 size={10}
285 tintColor={iconColor}
286 type="monochrome"
287 />
325
326 <View className="min-w-0 flex-1 gap-1">
327 <View className="flex-row items-center gap-2">
288328 <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"
290330 numberOfLines={1}
291 style={{ fontFamily: "monospace" }}
292331 >
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}
294339 </Text>
295340 </View>
296 ) : null}
297 </View>
341 {subtitle.length > 0 ? (
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}>
300 <Pressable
301 accessibilityLabel={`Actions for ${props.thread.title}`}
302 accessibilityRole="button"
303 className="h-8 w-8 items-center justify-center rounded-full active:bg-subtle"
304 hitSlop={6}
305 >
306 <SymbolView name="ellipsis" size={16} tintColor={iconColor} type="monochrome" />
307 </Pressable>
308 </ControlPillMenu>
309 </View>
310 </ReanimatedSwipeable>
360 <ControlPillMenu actions={THREAD_ACTIONS} onPressAction={handleMenuAction}>
361 <Pressable
362 accessibilityLabel={`Actions for ${props.thread.title}`}
363 accessibilityRole="button"
364 className="h-8 w-8 items-center justify-center rounded-full active:bg-subtle"
365 hitSlop={6}
366 >
367 <SymbolView name="ellipsis" size={16} tintColor={iconColor} type="monochrome" />
368 </Pressable>
369 </ControlPillMenu>
370 </View>
371 )}
372 </ThreadSwipeable>
311373 );
312374}
313375
......@@ -317,7 +379,7 @@ function ArchiveError(props: { readonly message: string; readonly onRetry: () =>
317379 <Text className="text-base font-t3-bold text-danger-foreground">
318380 Could not load every archive
319381 </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>
321383 <Pressable className="mt-3 self-start active:opacity-60" onPress={props.onRetry}>
322384 <Text className="text-sm font-t3-bold text-danger-foreground">Try again</Text>
323385 </Pressable>
......@@ -340,8 +402,41 @@ export function ArchivedThreadsScreen(props: {
340402 readonly onSortOrderChange: (sortOrder: ArchivedThreadSortOrder) => void;
341403 readonly onUnarchiveThread: (thread: EnvironmentThreadShell) => void;
342404}) {
405 const { onDeleteThread, onUnarchiveThread } = props;
343406 const openSwipeableRef = useRef<SwipeableMethods | null>(null);
407 const archiveScrollGesture = useMemo(() => Gesture.Native(), []);
344408 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]);
345440 const handleSwipeableWillOpen = useCallback((methods: SwipeableMethods) => {
346441 if (openSwipeableRef.current && openSwipeableRef.current !== methods) {
347442 openSwipeableRef.current.close();
......@@ -355,82 +450,103 @@ export function ArchivedThreadsScreen(props: {
355450 }, []);
356451 const isInitialLoad = props.isLoading && props.groups.length === 0 && props.error === null;
357452 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
359507 return (
360508 <View className="flex-1 bg-sheet">
361509 <ArchivedThreadsHeader
362510 environments={props.environments}
363511 onEnvironmentChange={props.onEnvironmentChange}
512 onRefresh={props.onRefresh}
364513 onSearchQueryChange={props.onSearchQueryChange}
365514 onSortOrderChange={props.onSortOrderChange}
366515 selectedEnvironmentId={props.selectedEnvironmentId}
367516 sortOrder={props.sortOrder}
368517 />
369518
370 <ScrollView
371 className="flex-1"
372 contentContainerStyle={{ gap: 20, paddingBottom: 32, paddingHorizontal: 16, paddingTop: 8 }}
373 contentInsetAdjustmentBehavior="automatic"
374 keyboardDismissMode="on-drag"
375 keyboardShouldPersistTaps="handled"
376 onScrollBeginDrag={() => openSwipeableRef.current?.close()}
377 refreshControl={
378 <RefreshControl
379 onRefresh={props.onRefresh}
380 refreshing={props.isLoading && !isInitialLoad}
381 tintColor={String(refreshTint)}
382 />
383 }
384 showsVerticalScrollIndicator={false}
385 >
386 {props.error ? <ArchiveError message={props.error} onRetry={props.onRefresh} /> : null}
387
388 {isInitialLoad ? (
389 <View className="items-center py-16">
390 <ActivityIndicator color={refreshTint} />
391 <Text className="mt-3 text-sm text-foreground-muted">Loading archive…</Text>
392 </View>
393 ) : props.groups.length === 0 ? (
394 <EmptyState
395 detail={
396 isFiltered
397 ? "Try another search or environment."
398 : "Threads you archive will appear here."
399 }
400 title={isFiltered ? "No matching threads" : "No archived threads"}
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>
519 <GestureDetector gesture={archiveScrollGesture}>
520 <LegendList
521 className="flex-1"
522 contentContainerStyle={{
523 paddingBottom: 32,
524 paddingHorizontal: 16,
525 paddingTop: 4,
526 }}
527 contentInsetAdjustmentBehavior="automatic"
528 data={listItems}
529 estimatedItemSize={62}
530 getItemType={(item) => item.kind}
531 keyboardDismissMode="on-drag"
532 keyboardShouldPersistTaps="handled"
533 keyExtractor={(item) => item.key}
534 ListEmptyComponent={listEmptyComponent}
535 ListHeaderComponent={
536 props.error ? <ArchiveError message={props.error} onRetry={props.onRefresh} /> : null
537 }
538 onScrollBeginDrag={() => openSwipeableRef.current?.close()}
539 refreshControl={
540 <RefreshControl
541 onRefresh={props.onRefresh}
542 refreshing={props.isLoading && !isInitialLoad}
543 tintColor={String(refreshTint)}
544 />
545 }
546 renderItem={renderListItem}
547 showsVerticalScrollIndicator={false}
548 />
549 </GestureDetector>
434550 </View>
435551 );
436552}
apps/mobile/src/features/cloud/CloudWaitlistEnrollment.tsx+11-43
......@@ -2,7 +2,6 @@ import { useWaitlist } from "@clerk/expo";
22import { ActivityIndicator, Pressable, StyleSheet, Text, TextInput, View } from "react-native";
33import { useState } from "react";
44
5import { MOBILE_TYPOGRAPHY } from "../../lib/typography";
65import { useThemeColor } from "../../lib/useThemeColor";
76import { CloudWaitlistJoinRejectedError, joinCloudWaitlist } from "./cloudWaitlistJoin";
87
......@@ -36,8 +35,10 @@ export function CloudWaitlistEnrollment(props: { readonly onSignIn: () => void }
3635 if (waitlist.id) {
3736 return (
3837 <View style={styles.content}>
39 <Text style={[styles.title, { color: colors.foreground }]}>You are on the waitlist</Text>
40 <Text style={[styles.body, styles.confirmationBody, { color: colors.secondaryForeground }]}>
38 <Text className="text-center font-t3-bold text-xl text-foreground">
39 You are on the waitlist
40 </Text>
41 <Text className="text-center font-sans text-base text-foreground-secondary">
4142 We will email you when your T3 Cloud access is ready.
4243 </Text>
4344 <SignInAction onPress={props.onSignIn} />
......@@ -47,17 +48,18 @@ export function CloudWaitlistEnrollment(props: { readonly onSignIn: () => void }
4748
4849 return (
4950 <View style={styles.content}>
50 <Text style={[styles.body, { color: colors.secondaryForeground }]}>
51 <Text className="font-sans text-base text-foreground-secondary">
5152 Enter your email and we will let you know when access is ready.
5253 </Text>
5354
5455 <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>
5657 <TextInput
5758 accessibilityLabel="Email address"
5859 autoCapitalize="none"
5960 autoComplete="email"
6061 autoCorrect={false}
62 className="font-sans text-lg text-foreground"
6163 keyboardType="email-address"
6264 onChangeText={(value) => {
6365 setEmailAddress(value);
......@@ -73,7 +75,6 @@ export function CloudWaitlistEnrollment(props: { readonly onSignIn: () => void }
7375 backgroundColor: colors.input,
7476 borderColor:
7577 fieldError || requestError ? colors.dangerForeground : colors.inputBorder,
76 color: colors.foreground,
7778 },
7879 ]}
7980 textContentType="emailAddress"
......@@ -82,7 +83,7 @@ export function CloudWaitlistEnrollment(props: { readonly onSignIn: () => void }
8283 {fieldError || requestError ? (
8384 <Text
8485 accessibilityLiveRegion="polite"
85 style={[styles.error, { color: colors.dangerForeground }]}
86 className="font-sans text-sm text-danger-foreground"
8687 selectable
8788 >
8889 {fieldError ?? requestError}
......@@ -107,7 +108,7 @@ export function CloudWaitlistEnrollment(props: { readonly onSignIn: () => void }
107108 ]}
108109 >
109110 {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">
111112 {isSubmitting ? "Joining" : "Join the waitlist"}
112113 </Text>
113114 </Pressable>
......@@ -118,12 +119,11 @@ export function CloudWaitlistEnrollment(props: { readonly onSignIn: () => void }
118119}
119120
120121function SignInAction(props: { readonly onPress: () => void }) {
121 const colors = useCloudWaitlistColors();
122122 return (
123123 <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>
125125 <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>
127127 </Pressable>
128128 </View>
129129 );
......@@ -132,35 +132,18 @@ function SignInAction(props: { readonly onPress: () => void }) {
132132function useCloudWaitlistColors() {
133133 return {
134134 dangerForeground: String(useThemeColor("--color-danger-foreground")),
135 foreground: String(useThemeColor("--color-foreground")),
136135 input: String(useThemeColor("--color-input")),
137136 inputBorder: String(useThemeColor("--color-input-border")),
138137 placeholder: String(useThemeColor("--color-placeholder")),
139138 primary: String(useThemeColor("--color-primary")),
140139 primaryForeground: String(useThemeColor("--color-primary-foreground")),
141 secondaryForeground: String(useThemeColor("--color-foreground-secondary")),
142140 };
143141}
144142
145143const 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 },
154144 content: {
155145 gap: 18,
156146 },
157 confirmationBody: {
158 textAlign: "center",
159 },
160 error: {
161 fontFamily: "DMSans_400Regular",
162 ...MOBILE_TYPOGRAPHY.footnote,
163 },
164147 field: {
165148 gap: 8,
166149 },
......@@ -168,16 +151,10 @@ const styles = StyleSheet.create({
168151 borderCurve: "continuous",
169152 borderRadius: 16,
170153 borderWidth: 1,
171 fontFamily: "DMSans_400Regular",
172 fontSize: MOBILE_TYPOGRAPHY.headline.fontSize,
173154 minHeight: 54,
174155 paddingHorizontal: 16,
175156 paddingVertical: 14,
176157 },
177 label: {
178 fontFamily: "DMSans_700Bold",
179 ...MOBILE_TYPOGRAPHY.footnote,
180 },
181158 primaryButton: {
182159 alignItems: "center",
183160 borderRadius: 999,
......@@ -195,13 +172,4 @@ const styles = StyleSheet.create({
195172 justifyContent: "center",
196173 paddingTop: 4,
197174 },
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 },
207175});
apps/mobile/src/features/cloud/dpop.ts+41-9
......@@ -8,12 +8,7 @@ import * as Schema from "effect/Schema";
88import * as ExpoCrypto from "expo-crypto";
99import * as SecureStore from "expo-secure-store";
1010import { p256 } from "@noble/curves/nist";
11import {
12 computeDpopAccessTokenHash,
13 computeDpopJwkThumbprint,
14 DpopPublicJwk,
15 normalizeDpopHtu,
16} from "@t3tools/shared/dpop";
11import { DpopPublicJwk, normalizeDpopHtu } from "@t3tools/shared/dpopCommon";
1712import * as Layer from "effect/Layer";
1813
1914export class CloudDpopError extends Data.TaggedError("CloudDpopError")<{
......@@ -107,6 +102,40 @@ function sha256Digest(
107102 );
108103}
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
110139function secureRandomBytes(
111140 byteCount: number,
112141 message: string,
......@@ -153,7 +182,7 @@ export function generateDpopProofKeyPair(): Effect.Effect<
153182 try: () => publicJwkFromUncompressedPublicKey(p256.getPublicKey(privateKey, false)),
154183 catch: cloudDpopError("Generated DPoP public key is invalid."),
155184 });
156 const thumbprint = computeDpopJwkThumbprint(publicJwk);
185 const thumbprint = yield* computeDpopJwkThumbprintEffect(publicJwk);
157186 return {
158187 privateJwk: privateJwkFromPrivateKey(privateKey, publicJwk),
159188 publicJwk,
......@@ -189,9 +218,10 @@ export function loadOrCreateDpopProofKeyPair(): Effect.Effect<
189218 },
190219 catch: cloudDpopError("Stored DPoP proof key is invalid."),
191220 });
221 const thumbprint = yield* computeDpopJwkThumbprintEffect(restored.publicJwk);
192222 return {
193223 ...restored,
194 thumbprint: computeDpopJwkThumbprint(restored.publicJwk),
224 thumbprint,
195225 };
196226 }
197227 const generated = yield* generateDpopProofKeyPair();
......@@ -243,7 +273,9 @@ export function createDpopProof(input: {
243273 Effect.map(Encoding.encodeBase64Url),
244274 Effect.mapError(cloudDpopError("Could not encode DPoP proof header.")),
245275 );
246 const ath = input.accessToken ? computeDpopAccessTokenHash(input.accessToken) : null;
276 const ath = input.accessToken
277 ? yield* computeDpopAccessTokenHashEffect(input.accessToken)
278 : null;
247279 const payload = yield* encodeDpopJwtPayloadJson({
248280 htm: input.method.toUpperCase(),
249281 htu,
apps/mobile/src/features/connection/ConnectionEnvironmentRow.tsx+4-4
......@@ -76,16 +76,16 @@ export function ConnectionEnvironmentRow(props: {
7676 />
7777
7878 <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}>
8080 {props.environment.environmentLabel}
8181 </Text>
82 <Text className="text-xs leading-[16px] text-foreground-muted" numberOfLines={1}>
82 <Text className="text-xs text-foreground-muted" numberOfLines={1}>
8383 {props.environment.displayUrl}
8484 </Text>
8585 {statusLabel ? (
8686 <Text
8787 className={cn(
88 "text-xs leading-[16px]",
88 "text-xs",
8989 hasConnectionFailure ? "text-rose-500 dark:text-rose-400" : "text-foreground-muted",
9090 )}
9191 numberOfLines={props.expanded ? undefined : 1}
......@@ -134,7 +134,7 @@ export function ConnectionEnvironmentRow(props: {
134134 className="gap-3 px-4 pb-4"
135135 >
136136 {props.environment.isRelayManaged ? (
137 <Text className="text-sm leading-[18px] text-foreground-muted">
137 <Text className="text-sm text-foreground-muted">
138138 Managed by T3 Cloud. Tunnel details update automatically.
139139 </Text>
140140 ) : (
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: {
7373 />
7474 )}
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">
7777 {noticeTitle(props.connection.phase, props.environmentLabel)}
7878 </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">
8080 {noticeDetail(props.connection.phase, props.resourceName, props.connection.error)}
8181 {props.connection.traceId ? (
8282 <>
apps/mobile/src/features/diffs/nativeReviewDiffSurface.test.ts+22-2
......@@ -33,11 +33,14 @@ describe("resolveNativeReviewDiffView", () => {
3333 expect(expoMocks.requireNativeView).not.toHaveBeenCalled();
3434 });
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 () => {
3737 setExpoViewConfigAvailable();
3838 expoMocks.requireNativeView.mockReturnValue(nativeView);
3939 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);
4144 expect(expoMocks.requireNativeView).toHaveBeenCalledWith("T3ReviewDiffSurface");
4245 });
4346
......@@ -78,3 +81,20 @@ describe("resolveNativeReviewDiffView", () => {
7881 expect(consoleError).toHaveBeenCalledTimes(1);
7982 });
8083});
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 @@
1import type { ComponentType } from "react";
1import {
2 createElement,
3 useEffect,
4 useImperativeHandle,
5 useRef,
6 type ComponentType,
7 type Ref,
8} from "react";
29import type { NativeSyntheticEvent, ViewProps } from "react-native";
310import { requireNativeView } from "expo";
411
512import { NativeViewResolutionError } from "../../native/nativeViewResolutionError";
613
714const NATIVE_REVIEW_DIFF_MODULE_NAME = "T3ReviewDiffSurface";
15const NATIVE_REVIEW_DIFF_PAYLOAD_RETRY_FRAMES = 60;
816
917interface ExpoGlobalWithViewConfig {
1018 readonly expo?: {
......@@ -103,6 +111,7 @@ export interface NativeReviewDiffViewProps extends ViewProps {
103111 readonly tokensJson?: string;
104112 readonly tokensPatchJson?: string;
105113 readonly tokensResetKey?: string;
114 readonly contentResetKey?: string;
106115 readonly collapsedFileIdsJson?: string;
107116 readonly viewedFileIdsJson?: string;
108117 readonly selectedRowIdsJson?: string;
......@@ -113,7 +122,13 @@ export interface NativeReviewDiffViewProps extends ViewProps {
113122 readonly rowHeight: number;
114123 readonly contentWidth: number;
115124 readonly initialRowIndex?: number;
125 readonly refreshing?: boolean;
126 readonly nativeViewRef?: Ref<NativeReviewDiffViewHandle>;
127 readonly onPullToRefresh?: (event: NativeSyntheticEvent<Record<string, never>>) => void;
116128 readonly onDebug?: (event: NativeSyntheticEvent<Record<string, unknown>>) => void;
129 readonly onVisibleFileChange?: (
130 event: NativeSyntheticEvent<{ readonly fileId?: string | null }>,
131 ) => void;
117132 readonly onToggleFile?: (event: NativeSyntheticEvent<{ readonly fileId?: string }>) => void;
118133 readonly onToggleViewedFile?: (event: NativeSyntheticEvent<{ readonly fileId?: string }>) => void;
119134 readonly onPressLine?: (
......@@ -129,18 +144,130 @@ export interface NativeReviewDiffViewProps extends ViewProps {
129144 readonly onToggleComment?: (event: NativeSyntheticEvent<{ readonly commentId?: string }>) => void;
130145}
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;
133168let 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
135235function getExpoViewConfig(moduleName: string) {
136236 return (globalThis as typeof globalThis & ExpoGlobalWithViewConfig).expo?.getViewConfig?.(
137237 moduleName,
138238 );
139239}
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
141268export function resolveNativeReviewDiffView(): ComponentType<NativeReviewDiffViewProps> | null {
142 if (cachedNativeReviewDiffView) {
143 return cachedNativeReviewDiffView;
269 if (cachedNativeReviewDiffRawView) {
270 return NativeReviewDiffView;
144271 }
145272
146273 if (nativeReviewDiffViewResolutionFailed) {
......@@ -152,7 +279,7 @@ export function resolveNativeReviewDiffView(): ComponentType<NativeReviewDiffVie
152279 }
153280
154281 try {
155 cachedNativeReviewDiffView = requireNativeView<NativeReviewDiffViewProps>(
282 cachedNativeReviewDiffRawView = requireNativeView<NativeReviewDiffRawViewProps>(
156283 NATIVE_REVIEW_DIFF_MODULE_NAME,
157284 );
158285 } catch (cause) {
......@@ -166,5 +293,5 @@ export function resolveNativeReviewDiffView(): ComponentType<NativeReviewDiffVie
166293 return null;
167294 }
168295
169 return cachedNativeReviewDiffView ?? null;
296 return cachedNativeReviewDiffRawView ? NativeReviewDiffView : null;
170297}
apps/mobile/src/features/files/FileMarkdownPreview.tsx+51-7
......@@ -1,15 +1,19 @@
1import { useCallback, useMemo } from "react";
1import { useCallback, useMemo, useState } from "react";
22import {
33 Markdown,
44 type CustomRenderers,
55 type NodeStyleOverrides,
66 type PartialMarkdownTheme,
77} 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
1010import { tryOpenExternalUrl } from "../../lib/openExternalUrl";
11import { MOBILE_TYPOGRAPHY } from "../../lib/typography";
11import {
12 resolveMarkdownFontSizes,
13 resolveNativeMarkdownTypography,
14} from "../../lib/appearancePreferences";
1215import { useThemeColor } from "../../lib/useThemeColor";
16import { useAppearancePreferences } from "../settings/appearance/AppearancePreferencesProvider";
1317import {
1418 hasNativeSelectableMarkdownText,
1519 SelectableMarkdownText,
......@@ -24,6 +28,15 @@ interface MarkdownPreviewStyles {
2428}
2529
2630function 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 );
2740 const body = String(useThemeColor("--color-md-body"));
2841 const strong = String(useThemeColor("--color-md-strong"));
2942 const link = String(useThemeColor("--color-md-link"));
......@@ -74,7 +87,8 @@ function useMarkdownPreviewStyles(): MarkdownPreviewStyles {
7487 text: {
7588 color: body,
7689 fontFamily: "DMSans_400Regular",
77 ...MOBILE_TYPOGRAPHY.body,
90 fontSize: markdownFontSizes.m,
91 lineHeight: markdownFontSizes.bodyLineHeight,
7892 },
7993 heading: {
8094 color: strong,
......@@ -124,7 +138,9 @@ function useMarkdownPreviewStyles(): MarkdownPreviewStyles {
124138 skillTextColor: codeText,
125139 quoteMarkerColor: blockquoteBorder,
126140 dividerColor: horizontalRule,
127 ...MOBILE_TYPOGRAPHY.body,
141 fontSize: nativeMarkdownTypography.fontSize,
142 lineHeight: nativeMarkdownTypography.lineHeight,
143 headingFontSizes: nativeMarkdownTypography.headingFontSizes,
128144 fontFamily: "DMSans_400Regular",
129145 headingFontFamily: "DMSans_700Bold",
130146 boldFontFamily: "DMSans_700Bold",
......@@ -138,18 +154,46 @@ function useMarkdownPreviewStyles(): MarkdownPreviewStyles {
138154 codeText,
139155 horizontalRule,
140156 link,
157 markdownFontSizes,
158 nativeMarkdownTypography,
141159 strong,
142160 ]);
143161}
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]);
146179 const styles = useMarkdownPreviewStyles();
147180 const onLinkPress = useCallback((href: string) => {
148181 void tryOpenExternalUrl(href, "markdown-link");
149182 }, []);
150183
151184 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 >
153197 <View className="mx-auto w-full max-w-[760px]">
154198 {hasNativeSelectableMarkdownText() ? (
155199 <SelectableMarkdownText
apps/mobile/src/features/files/FileTreeBrowser.tsx+149-55
......@@ -1,7 +1,15 @@
11import type { ProjectEntry } from "@t3tools/contracts";
22import { SymbolView } from "expo-symbols";
3import { memo, useCallback, useEffect, useMemo, useState } from "react";
4import { ActivityIndicator, FlatList, Pressable, RefreshControl, View } from "react-native";
3import { memo, useCallback, useEffect, useMemo, useRef, useState } from "react";
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
614import { AppText as Text } from "../../components/AppText";
715import { PierreEntryIcon } from "../../components/PierreEntryIcon";
......@@ -11,9 +19,25 @@ import {
1119 buildFileTree,
1220 defaultExpandedTreePaths,
1321 flattenFileTree,
22 type FileTreeNode,
1423 type VisibleFileTreeNode,
1524} 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
1741function ancestorPaths(path: string): ReadonlyArray<string> {
1842 const parts = path.split("/").filter(Boolean);
1943 const ancestors: string[] = [];
......@@ -25,19 +49,24 @@ function ancestorPaths(path: string): ReadonlyArray<string> {
2549
2650const FileTreeRow = memo(function FileTreeRow(props: {
2751 readonly item: VisibleFileTreeNode;
28 readonly selectedPath: string | null;
52 readonly selected: boolean;
2953 readonly expanded: boolean;
3054 readonly iconColor: string;
3155 readonly onPressDirectory: (path: string) => void;
56 readonly onPreviewFile?: (path: string) => void;
3257 readonly onPressFile: (path: string) => void;
3358}) {
3459 const { node, depth } = props.item;
35 const selected = node.kind === "file" && node.path === props.selectedPath;
3660
3761 return (
3862 <Pressable
3963 accessibilityRole="button"
4064 accessibilityLabel={node.path}
65 onPressIn={() => {
66 if (node.kind === "file") {
67 props.onPreviewFile?.(node.path);
68 }
69 }}
4170 onPress={() => {
4271 if (node.kind === "directory") {
4372 props.onPressDirectory(node.path);
......@@ -47,7 +76,7 @@ const FileTreeRow = memo(function FileTreeRow(props: {
4776 }}
4877 className={cn(
4978 "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",
5180 )}
5281 style={{ paddingLeft: 8 + depth * 18 }}
5382 >
......@@ -64,8 +93,10 @@ const FileTreeRow = memo(function FileTreeRow(props: {
6493 <PierreEntryIcon path={node.path} kind={node.kind} size={17} />
6594 <Text
6695 className={cn(
67 "min-w-0 flex-1 text-sm leading-[19px]",
68 selected ? "font-t3-bold text-foreground" : "font-t3-medium text-foreground-secondary",
96 "min-w-0 flex-1 text-sm leading-normal",
97 props.selected
98 ? "font-t3-bold text-foreground"
99 : "font-t3-medium text-foreground-secondary",
69100 )}
70101 numberOfLines={1}
71102 >
......@@ -86,13 +117,30 @@ export function FileTreeBrowser(props: {
86117 readonly isPending: boolean;
87118 readonly searchQuery: string;
88119 readonly selectedPath: string | null;
120 readonly onPreviewFile?: (path: string) => void;
89121 readonly onRefresh: () => void;
90122 readonly onSelectFile: (path: string) => void;
91123}) {
92124 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;
93133 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]);
96144 const defaultExpanded = useMemo(() => defaultExpandedTreePaths(tree), [tree]);
97145 const visibleNodes = useMemo(
98146 () =>
......@@ -114,17 +162,30 @@ export function FileTreeBrowser(props: {
114162 }, [defaultExpanded]);
115163
116164 useEffect(() => {
117 if (!props.selectedPath) {
165 if (!controlledSelectedPath) {
118166 return;
119167 }
120168 setExpandedPaths((current) => {
169 const ancestors = ancestorPaths(controlledSelectedPath);
170 if (ancestors.every((ancestor) => current.has(ancestor))) {
171 return current;
172 }
121173 const next = new Set(current);
122 for (const ancestor of ancestorPaths(props.selectedPath ?? "")) {
174 for (const ancestor of ancestors) {
123175 next.add(ancestor);
124176 }
125177 return next;
126178 });
127 }, [props.selectedPath]);
179 }, [controlledSelectedPath]);
180
181 useEffect(
182 () => () => {
183 if (pendingSelectionTimeoutRef.current !== null) {
184 clearTimeout(pendingSelectionTimeoutRef.current);
185 }
186 },
187 [],
188 );
128189
129190 const toggleDirectory = useCallback((path: string) => {
130191 setExpandedPaths((current) => {
......@@ -137,53 +198,86 @@ export function FileTreeBrowser(props: {
137198 return next;
138199 });
139200 }, []);
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.
141247 return (
142 <View className="flex-1 bg-sheet">
143 {props.error && props.entries.length === 0 ? (
248 <FlatList
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={
144266 <View className="px-4 py-5">
145 <Text className="text-sm font-t3-bold text-foreground">Files unavailable</Text>
146 <Text className="mt-1 text-xs leading-[18px] text-foreground-muted">{props.error}</Text>
147 </View>
148 ) : (
149 <FlatList
150 data={visibleNodes}
151 keyExtractor={(item) => item.node.path}
152 contentInsetAdjustmentBehavior="automatic"
153 keyboardDismissMode="on-drag"
154 keyboardShouldPersistTaps="handled"
155 contentContainerStyle={{ paddingVertical: 8 }}
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 />
267 {props.isPending ? (
268 <ActivityIndicator size="small" />
269 ) : (
270 <>
271 <Text className="text-sm font-t3-bold text-foreground">No files found</Text>
272 <Text className="mt-1 text-xs leading-normal text-foreground-muted">
273 {props.searchQuery.trim().length > 0
274 ? "Try a different search."
275 : "The workspace file index is empty."}
276 </Text>
277 </>
168278 )}
169 ListEmptyComponent={
170 <View className="px-4 py-5">
171 {props.isPending ? (
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>
279 </View>
280 }
281 />
188282 );
189283}
apps/mobile/src/features/files/SourceFileSurface.tsx+98-65
......@@ -1,8 +1,15 @@
11import { useAtomValue } from "@effect/atom-react";
22import { AsyncResult } from "effect/unstable/reactivity";
33import type { ComponentType } from "react";
4import { memo, useCallback, useEffect, useMemo, useRef } from "react";
5import { FlatList, ScrollView, Text as NativeText, useColorScheme, View } from "react-native";
4import { memo, useCallback, useEffect, useMemo, useRef, useState } from "react";
5import {
6 FlatList,
7 ScrollView,
8 Text as NativeText,
9 useColorScheme,
10 useWindowDimensions,
11 View,
12} from "react-native";
613
714import { AppText as Text } from "../../components/AppText";
815import { LoadingStrip } from "../../components/LoadingStrip";
......@@ -11,71 +18,62 @@ import {
1118 resolveNativeReviewDiffView,
1219} from "../diffs/nativeReviewDiffSurface";
1320import { createNativeReviewDiffTheme } from "../review/nativeReviewDiffAdapter";
14import {
15 REVIEW_DIFF_LINE_HEIGHT,
16 REVIEW_MONO_FONT_FAMILY,
17 renderVisibleWhitespace,
18} from "../review/reviewDiffRendering";
21import { REVIEW_MONO_FONT_FAMILY, renderVisibleWhitespace } from "../review/reviewDiffRendering";
1922import type { ReviewHighlightedToken } from "../review/shikiReviewHighlighter";
2023import { cn } from "../../lib/cn";
21import { MOBILE_CODE_SURFACE } from "../../lib/typography";
24import type { ResolvedMobileCodeSurface } from "../../lib/appearancePreferences";
25import { useAppearanceCodeSurface } from "../settings/appearance/useAppearanceCodeSurface";
2226import {
23 buildNativeSourceRows,
2427 buildNativeSourceTokens,
2528 NATIVE_SOURCE_CONTENT_WIDTH,
26 NATIVE_SOURCE_ROW_HEIGHT,
27 NATIVE_SOURCE_STYLE,
2829 nativeSourceRowId,
2930} from "./nativeSourceFileAdapter";
31import { prepareSourceFileDocument } from "./source-file-document";
3032import { 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
3634interface SourceFileSurfaceProps {
3735 readonly contents: string;
3836 readonly path: string;
3937 readonly initialLine?: number | null;
38 /** Enables native pull-to-refresh on the source surface. */
39 readonly onRefresh?: () => Promise<void> | void;
4040}
4141
4242type SourceHighlightStatus = "highlighting" | "ready" | "error";
4343
44function splitSourceLines(contents: string): ReadonlyArray<string> {
45 return contents.replace(/\r\n?/g, "\n").split("\n");
46}
47
4844const HighlightedSourceLine = memo(function HighlightedSourceLine(props: {
45 readonly codeSurface: ResolvedMobileCodeSurface;
4946 readonly index: number;
5047 readonly line: string;
5148 readonly tokens: ReadonlyArray<ReviewHighlightedToken> | null;
5249 readonly highlighted: boolean;
50 readonly wordBreak: boolean;
5351}) {
5452 return (
5553 <View
5654 className={cn("flex-row", props.highlighted && "bg-primary/10")}
57 style={{ minHeight: SOURCE_LINE_HEIGHT }}
55 style={{ minHeight: props.codeSurface.rowHeight }}
5856 >
5957 <NativeText
6058 className="select-none pr-3 text-right text-foreground-tertiary"
6159 style={{
62 width: SOURCE_LINE_NUMBER_WIDTH,
60 width: props.codeSurface.gutterWidth,
6361 fontFamily: REVIEW_MONO_FONT_FAMILY,
64 fontSize: MOBILE_CODE_SURFACE.lineNumberFontSize,
65 lineHeight: MOBILE_CODE_SURFACE.rowHeight,
62 fontSize: props.codeSurface.lineNumberFontSize,
63 lineHeight: props.codeSurface.rowHeight,
6664 }}
6765 >
6866 {props.index + 1}
6967 </NativeText>
7068 <NativeText
7169 selectable
72 numberOfLines={1}
73 className="font-normal text-foreground"
70 numberOfLines={props.wordBreak ? undefined : 1}
71 className="flex-1 font-normal text-foreground"
7472 style={{
7573 fontFamily: REVIEW_MONO_FONT_FAMILY,
76 fontSize: MOBILE_CODE_SURFACE.fontSize,
77 lineHeight: MOBILE_CODE_SURFACE.rowHeight,
78 minWidth: 320,
74 fontSize: props.codeSurface.fontSize,
75 lineHeight: props.codeSurface.rowHeight,
76 minWidth: props.wordBreak ? undefined : 320,
7977 }}
8078 >
8179 {props.tokens && props.tokens.length > 0
......@@ -119,11 +117,8 @@ const HighlightedSourceLine = memo(function HighlightedSourceLine(props: {
119117function useSourceFileModel(props: SourceFileSurfaceProps) {
120118 const colorScheme = useColorScheme();
121119 const theme: "dark" | "light" = colorScheme === "dark" ? "dark" : "light";
122 const normalizedContents = useMemo(
123 () => props.contents.replace(/\r\n?/g, "\n"),
124 [props.contents],
125 );
126 const lines = useMemo(() => splitSourceLines(normalizedContents), [normalizedContents]);
120 const document = useMemo(() => prepareSourceFileDocument(props.contents), [props.contents]);
121 const { contents: normalizedContents, lines, rowsJson } = document;
127122 const targetIndex =
128123 props.initialLine !== null && props.initialLine !== undefined && props.initialLine > 0
129124 ? Math.min(Math.floor(props.initialLine) - 1, Math.max(0, lines.length - 1))
......@@ -140,7 +135,7 @@ function useSourceFileModel(props: SourceFileSurfaceProps) {
140135 ? "ready"
141136 : "highlighting";
142137
143 return { lines, status, targetIndex, theme, tokens };
138 return { lines, rowsJson, status, targetIndex, theme, tokens };
144139}
145140
146141function SourceHighlightStatusView(props: { readonly status: SourceHighlightStatus }) {
......@@ -162,39 +157,63 @@ function NativeSourceFileSurface(
162157 readonly NativeView: ComponentType<NativeReviewDiffViewProps>;
163158 },
164159) {
165 const { NativeView } = props;
166 const { lines, status, targetIndex, theme, tokens } = useSourceFileModel(props);
167 const rowsJson = useMemo(() => JSON.stringify(buildNativeSourceRows(lines)), [lines]);
160 const { NativeView, onRefresh } = props;
161 const { codeSurface, codeWordBreak, nativeSourceStyle } = useAppearanceCodeSurface();
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]);
168176 const tokensJson = useMemo(() => JSON.stringify(buildNativeSourceTokens(tokens)), [tokens]);
169177 const selectedRowIdsJson = useMemo(
170178 () => JSON.stringify(targetIndex === null ? [] : [nativeSourceRowId(targetIndex)]),
171179 [targetIndex],
172180 );
173181 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
175187 return (
176 <View className="relative flex-1 bg-card">
188 <View className="relative flex-1 bg-sheet">
177189 <SourceHighlightStatusView status={status} />
178190 <NativeView
179 key={props.path}
180191 collapsable={false}
181192 testID="source-native-code-view"
182193 style={{ flex: 1 }}
183194 appearanceScheme={theme}
184 contentWidth={NATIVE_SOURCE_CONTENT_WIDTH}
195 contentResetKey={props.path}
196 contentWidth={contentWidth}
185197 initialRowIndex={targetIndex ?? -1}
186 rowHeight={NATIVE_SOURCE_ROW_HEIGHT}
198 rowHeight={nativeSourceStyle.rowHeight ?? codeSurface.rowHeight}
187199 rowsJson={rowsJson}
188200 selectedRowIdsJson={selectedRowIdsJson}
189 styleJson={NATIVE_SOURCE_STYLE_JSON}
201 styleJson={styleJson}
190202 themeJson={themeJson}
191203 tokensJson={tokensJson}
204 {...(onRefresh
205 ? {
206 refreshing: isPullRefreshing,
207 onPullToRefresh: () => void handlePullToRefresh(),
208 }
209 : {})}
192210 />
193211 </View>
194212 );
195213}
196214
197215function JavaScriptSourceFileSurface(props: SourceFileSurfaceProps) {
216 const { codeSurface, codeWordBreak } = useAppearanceCodeSurface();
198217 const { lines, status, targetIndex, tokens } = useSourceFileModel(props);
199218 const listRef = useRef<FlatList<string>>(null);
200219
......@@ -211,39 +230,53 @@ function JavaScriptSourceFileSurface(props: SourceFileSurfaceProps) {
211230 const renderLine = useCallback(
212231 ({ item, index }: { item: string; index: number }) => (
213232 <HighlightedSourceLine
233 codeSurface={codeSurface}
214234 index={index}
215235 line={item}
216236 tokens={tokens?.[index] ?? null}
217237 highlighted={index === targetIndex}
238 wordBreak={codeWordBreak}
218239 />
219240 ),
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 />
221268 );
222269
223270 return (
224 <View className="relative flex-1 bg-card">
271 <View className="relative flex-1 bg-sheet">
225272 <SourceHighlightStatusView status={status} />
226 <ScrollView horizontal bounces={false} className="flex-1">
227 <FlatList
228 ref={listRef}
229 data={lines}
230 keyExtractor={(_line, index) => String(index)}
231 initialNumToRender={80}
232 maxToRenderPerBatch={80}
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>
273 {codeWordBreak ? (
274 list
275 ) : (
276 <ScrollView horizontal bounces={false} className="flex-1">
277 {list}
278 </ScrollView>
279 )}
247280 </View>
248281 );
249282}
apps/mobile/src/features/files/ThreadFilesRouteScreen.tsx+327-308
......@@ -1,8 +1,15 @@
1import Stack from "expo-router/stack";
2import { SymbolView } from "expo-symbols";
3import { useLocalSearchParams, useRouter } from "expo-router";
4import { useCallback, useMemo, useRef, useState } from "react";
5import { ActivityIndicator, Pressable, ScrollView, Text as RNText, View } from "react-native";
1import { NativeHeaderToolbar, NativeStackScreenOptions } from "../../native/StackHeader";
2import { StackActions, useNavigation, type StaticScreenProps } from "@react-navigation/native";
3import { useCallback, useEffect, useMemo, useRef, useState } from "react";
4import {
5 ActivityIndicator,
6 Platform,
7 Pressable,
8 ScrollView,
9 useColorScheme,
10 View,
11} from "react-native";
12import { useSafeAreaInsets } from "react-native-safe-area-context";
613import Svg, { Defs, LinearGradient, Rect, Stop } from "react-native-svg";
714import {
815 EnvironmentId,
......@@ -12,22 +19,31 @@ import {
1219} from "@t3tools/contracts";
1320
1421import { AppText as Text } from "../../components/AppText";
15import { CopyTextButton } from "../../components/CopyTextButton";
1622import { EmptyState } from "../../components/EmptyState";
1723import { LoadingScreen } from "../../components/LoadingScreen";
1824import { cn } from "../../lib/cn";
25import { resolveFileSelectionNavigationAction } from "../../lib/adaptive-navigation";
26import { copyTextWithHaptic } from "../../lib/copyTextWithHaptic";
1927import { tryOpenExternalUrl } from "../../lib/openExternalUrl";
20import { buildThreadFilesNavigation } from "../../lib/routes";
21import { MOBILE_TYPOGRAPHY } from "../../lib/typography";
2228import { useThemeColor } from "../../lib/useThemeColor";
2329import { useThreadSelection } from "../../state/use-thread-selection";
2430import { useSelectedThreadWorktree } from "../../state/use-selected-thread-worktree";
2531import { useEnvironmentQuery } from "../../state/query";
2632import { 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";
2740import { ReviewHighlighterProvider } from "../review/ReviewHighlighterProvider";
41import { ThreadRouteScreen } from "../threads/ThreadRouteScreen";
2842import { FileMarkdownPreview } from "./FileMarkdownPreview";
2943import { FileTreeBrowser } from "./FileTreeBrowser";
44import { preloadWorkspaceFileContents } from "./preload-workspace-file";
3045import { SourceFileSurface } from "./SourceFileSurface";
46import { ThreadFileNavigatorPane } from "./thread-file-navigator-pane";
3147import { WorkspaceFileImagePreview } from "./WorkspaceFileImagePreview";
3248import { WorkspaceFileWebPreview } from "./WorkspaceFileWebPreview";
3349import {
......@@ -72,196 +88,6 @@ function defaultViewMode(path: string | null): FileViewMode {
7288 : "source";
7389}
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
26591function FileContent(props: {
26692 readonly activeMode: FileViewMode;
26793 readonly previewUri: string | null;
......@@ -270,6 +96,7 @@ function FileContent(props: {
27096 readonly relativePath: string;
27197 readonly initialLine: number | null;
27298 readonly truncated: boolean;
99 readonly onRefresh?: () => Promise<void> | void;
273100}) {
274101 const isMarkdown = isMarkdownPreviewFile(props.relativePath);
275102 const isBrowserFile = isBrowserPreviewFile(props.relativePath);
......@@ -293,7 +120,7 @@ function FileContent(props: {
293120
294121 if (props.fileError && props.fileContents === null) {
295122 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">
297124 <EmptyState title="File unavailable" detail={props.fileError} />
298125 </View>
299126 );
......@@ -301,7 +128,7 @@ function FileContent(props: {
301128
302129 if (props.fileContents === null) {
303130 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">
305132 <ActivityIndicator />
306133 <Text className="text-center text-sm text-foreground-muted">Loading file...</Text>
307134 </View>
......@@ -309,35 +136,47 @@ function FileContent(props: {
309136 }
310137
311138 return (
312 <View className="flex-1 bg-card">
139 <View className="flex-1 bg-sheet">
313140 {props.truncated ? (
314141 <View className="border-b border-amber-200 bg-amber-50 px-4 py-2 dark:border-amber-900/60 dark:bg-amber-950/40">
315142 <Text className="text-2xs font-t3-bold uppercase text-amber-700 dark:text-amber-300">
316143 Partial file
317144 </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">
319146 Preview limited to the first 1 MB of a truncated file.
320147 </Text>
321148 </View>
322149 ) : null}
323150 {props.activeMode === "preview" && isMarkdown ? (
324 <FileMarkdownPreview markdown={props.fileContents} />
151 <FileMarkdownPreview markdown={props.fileContents} onRefresh={props.onRefresh} />
325152 ) : (
326153 <SourceFileSurface
327154 contents={props.fileContents}
328155 path={props.relativePath}
329156 initialLine={props.initialLine}
157 onRefresh={props.onRefresh}
330158 />
331159 )}
332160 </View>
333161 );
334162}
335163
336function useThreadFilesWorkspace() {
337 const params = useLocalSearchParams<{
338 environmentId?: string | string[];
339 threadId?: string | string[];
340 }>();
164type ThreadFilesRouteScreenProps = StaticScreenProps<{
165 readonly environmentId: string;
166 readonly threadId: string;
167}>;
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}) {
341180 const routeEnvironmentId = firstRouteParam(params.environmentId);
342181 const routeThreadId = firstRouteParam(params.threadId);
343182 const { selectedThread, selectedThreadProject } = useThreadSelection();
......@@ -364,7 +203,7 @@ function useThreadFilesWorkspace() {
364203function FilesUnavailable() {
365204 return (
366205 <View className="flex-1 items-center justify-center bg-sheet px-6">
367 <Stack.Screen options={{ title: "Files" }} />
206 <NativeStackScreenOptions options={{ title: "Files" }} />
368207 <EmptyState
369208 title="Files unavailable"
370209 detail="This thread does not have an active workspace path."
......@@ -373,40 +212,6 @@ function FilesUnavailable() {
373212 );
374213}
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
410215function FilesToolbarBottomFade() {
411216 const sheetColor = String(useThemeColor("--color-sheet"));
412217
......@@ -442,12 +247,20 @@ function FilesToolbarBottomFade() {
442247 );
443248}
444249
445export function ThreadFilesTreeScreen() {
446 const router = useRouter();
250export function ThreadFilesTreeScreen(props: ThreadFilesRouteScreenProps) {
251 useAdaptiveWorkspacePaneRole("inspector");
252 const navigation = useNavigation();
253 const { fileInspector, layout, panes, showAuxiliaryPane, togglePrimarySidebar } =
254 useAdaptiveWorkspaceLayout();
447255 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);
449262 const entriesQuery = useEnvironmentQuery(
450 environmentId !== null && cwd !== null
263 environmentId !== null && cwd !== null && !fileInspector.supported
451264 ? projectEnvironment.listEntries({
452265 environmentId,
453266 input: { cwd },
......@@ -455,18 +268,87 @@ export function ThreadFilesTreeScreen() {
455268 : null,
456269 );
457270 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
459286 const handleSelectFile = useCallback(
460287 (path: string) => {
461288 if (environmentId === null || threadId === null) {
462289 return;
463290 }
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);
465304 },
466 [environmentId, router, threadId],
305 [environmentId, fileInspector.supported, navigation, threadId],
467306 );
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
469342 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 }
470352 return <LoadingScreen message="Opening files..." messagePlacement="above-spinner" />;
471353 }
472354
......@@ -474,62 +356,95 @@ export function ThreadFilesTreeScreen() {
474356 return <FilesUnavailable />;
475357 }
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
477371 return (
478 <View className="flex-1 bg-sheet">
479 <Stack.Screen
372 <>
373 {/* Static header config (glass preset, title, contentStyle) lives in Stack.tsx.
374 Only genuinely dynamic options are set here. */}
375 <NativeStackScreenOptions
480376 options={{
481 title: "Files",
482 headerShown: true,
483 headerTransparent: true,
484 headerStyle: { backgroundColor: "transparent" },
485 headerShadowVisible: false,
486 headerTitle: () => <FilesHeaderTitle projectName={projectName} />,
487 headerSearchBarOptions: {
488 allowToolbarIntegration: true,
489 autoCapitalize: "none",
490 hideNavigationBar: false,
491 placeholder: "Search files",
492 onChangeText: (event) => {
493 setSearchQuery(event.nativeEvent.text);
494 },
495 onCancelButtonPress: () => {
496 setSearchQuery("");
497 },
498 },
377 unstable_headerSubtitle:
378 Platform.OS === "ios" && projectName.length > 0 ? projectName : undefined,
379 // No refresh button: the list already supports pull-to-refresh.
380 unstable_headerToolbarItems: usesCompactMailToolbar
381 ? () => [
382 createNativeMailSearchToolbarItem({
383 onSearchTextChange: setSearchQuery,
384 placeholder: "Search files",
385 searchTextChangeId: "files-search-text",
386 }),
387 ]
388 : undefined,
389 headerSearchBarOptions: usesCompactMailToolbar
390 ? undefined
391 : {
392 allowToolbarIntegration: true,
393 autoCapitalize: "none",
394 hideNavigationBar: false,
395 placeholder: "Search files",
396 onChangeText: (event) => {
397 setSearchQuery(event.nativeEvent.text);
398 },
399 onCancelButtonPress: () => {
400 setSearchQuery("");
401 },
402 },
499403 }}
500404 />
501 <Stack.Toolbar placement="right">
502 <Stack.Toolbar.Button
503 accessibilityLabel="Refresh files"
504 icon="arrow.clockwise"
505 onPress={entriesQuery.refresh}
506 />
507 </Stack.Toolbar>
508 <Stack.Toolbar placement="bottom">
509 <Stack.Toolbar.SearchBarSlot />
510 </Stack.Toolbar>
405 {layout.usesSplitView ? (
406 <NativeHeaderToolbar placement="left">
407 <NativeHeaderToolbar.Button
408 accessibilityLabel={panes.primarySidebarVisible ? "Maximize files" : "Show threads"}
409 icon={
410 panes.primarySidebarVisible ? "arrow.up.left.and.arrow.down.right" : "sidebar.left"
411 }
412 onPress={togglePrimarySidebar}
413 separateBackground
414 />
415 </NativeHeaderToolbar>
416 ) : null}
417 {usesCompactMailToolbar ? null : (
418 <NativeHeaderToolbar placement="bottom">
419 <NativeHeaderToolbar.SearchBarSlot />
420 </NativeHeaderToolbar>
421 )}
511422 <FileTreeBrowser
512423 entries={entriesData?.entries ?? []}
513424 error={entriesQuery.error}
514425 isPending={entriesQuery.isPending}
515426 searchQuery={searchQuery}
516427 selectedPath={null}
428 onPreviewFile={handlePreviewFile}
517429 onRefresh={entriesQuery.refresh}
518430 onSelectFile={handleSelectFile}
519431 />
520432 <FilesToolbarBottomFade />
521 </View>
433 </>
522434 );
523435}
524436
525export function ThreadFileScreen() {
526 const params = useLocalSearchParams<{
527 line?: string | string[];
528 path?: string | string[];
529 }>();
437export function ThreadFileScreen(props: ThreadFileRouteScreenProps) {
438 useAdaptiveWorkspacePaneRole("inspector");
439 const navigation = useNavigation();
440 const { fileInspector, panes, toggleAuxiliaryPane } = useAdaptiveWorkspaceLayout();
441 const iconColor = useThemeColor("--color-icon");
442 const params = props.route.params;
530443 const relativePath = normalizeRoutePath(params.path);
531444 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 );
533448 const [modeOverride, setModeOverride] = useState<{
534449 readonly path: string;
535450 readonly mode: FileViewMode;
......@@ -568,6 +483,43 @@ export function ThreadFileScreen() {
568483 );
569484 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
571523 if (selectedThread === null || environmentId === null || threadId === null) {
572524 return <LoadingScreen message="Opening file..." messagePlacement="above-spinner" />;
573525 }
......@@ -579,38 +531,104 @@ export function ThreadFileScreen() {
579531 if (relativePath === null) {
580532 return (
581533 <View className="flex-1 items-center justify-center bg-sheet px-6">
582 <Stack.Screen options={{ title: "Files" }} />
534 <NativeStackScreenOptions options={{ title: "Files" }} />
583535 <EmptyState title="File unavailable" detail="This file path is invalid." />
584536 </View>
585537 );
586538 }
587539
540 const parentDir = relativePath.split("/").slice(0, -1).join("/");
541 const headerSubtitle = [projectName, parentDir].filter(Boolean).join(" · ");
542
588543 return (
589544 <ReviewHighlighterProvider>
590545 <View className="flex-1 bg-sheet">
591 <Stack.Screen options={{ title: basename(relativePath) }} />
592 <Stack.Toolbar placement="right">
593 <Stack.Toolbar.Button
594 icon="arrow.clockwise"
595 onPress={() => {
596 if (resolvedActiveMode === "preview" && (isBrowserFile || isImageFile)) {
597 setPreviewRevision((current) => current + 1);
598 return;
599 }
600 fileQuery.refresh();
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 });
546 <NativeStackScreenOptions
547 options={{
548 // Static header config lives in Stack.tsx (SOLID_HEADER_OPTIONS: solid
549 // sheet-colored header — this route's content scrolls internally, so
550 // there is nothing for glass to sample). Only dynamic values here.
551 headerTintColor: iconColor,
552 headerTitle: basename(relativePath),
553 title: basename(relativePath),
554 unstable_headerSubtitle:
555 Platform.OS === "ios" && headerSubtitle.length > 0 ? headerSubtitle : undefined,
612556 }}
613557 />
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>
614632 <FileContent
615633 activeMode={resolvedActiveMode}
616634 previewUri={previewUri}
......@@ -619,6 +637,7 @@ export function ThreadFileScreen() {
619637 initialLine={targetLine}
620638 relativePath={relativePath}
621639 truncated={fileData?.truncated ?? false}
640 onRefresh={() => fileQuery.refresh()}
622641 />
623642 </View>
624643 </ReviewHighlighterProvider>
apps/mobile/src/features/files/WorkspaceFileWebPreview.tsx+1-1
......@@ -24,7 +24,7 @@ export function WorkspaceFileWebPreview(props: { readonly uri: string | null })
2424 {loadError ? (
2525 <View className="border-b border-border bg-card px-4 py-2">
2626 <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>
2828 </View>
2929 ) : null}
3030 <WebView
apps/mobile/src/features/files/nativeSourceFileAdapter.ts+26-14
......@@ -3,26 +3,38 @@ import type {
33 NativeReviewDiffStyle,
44 NativeReviewDiffToken,
55} from "../diffs/nativeReviewDiffSurface";
6import type { ResolvedMobileCodeSurface } from "../../lib/appearancePreferences";
7import { resolveMobileCodeSurface } from "../../lib/appearancePreferences";
68import { MOBILE_CODE_SURFACE, MOBILE_TYPOGRAPHY } from "../../lib/typography";
79import type { SourceHighlightTokens } from "./sourceHighlightingState";
810
911export const NATIVE_SOURCE_ROW_HEIGHT = MOBILE_CODE_SURFACE.rowHeight;
1012export const NATIVE_SOURCE_CONTENT_WIDTH = 32_000;
1113
12export const NATIVE_SOURCE_STYLE: NativeReviewDiffStyle = {
13 rowHeight: NATIVE_SOURCE_ROW_HEIGHT,
14 contentWidth: NATIVE_SOURCE_CONTENT_WIDTH,
15 changeBarWidth: 0,
16 gutterWidth: MOBILE_CODE_SURFACE.gutterWidth,
17 codePadding: MOBILE_CODE_SURFACE.codePadding,
18 textVerticalInset: MOBILE_CODE_SURFACE.textVerticalInset,
19 codeFontSize: MOBILE_CODE_SURFACE.fontSize,
20 codeFontWeight: "regular",
21 lineNumberFontSize: MOBILE_CODE_SURFACE.lineNumberFontSize,
22 lineNumberFontWeight: "regular",
23 emptyStateFontSize: MOBILE_TYPOGRAPHY.label.fontSize,
24 emptyStateFontWeight: "medium",
25};
14export const NATIVE_SOURCE_STYLE: NativeReviewDiffStyle = createNativeSourceStyle(
15 resolveMobileCodeSurface(MOBILE_CODE_SURFACE.fontSize),
16);
17
18export function createNativeSourceStyle(
19 codeSurface: ResolvedMobileCodeSurface,
20): NativeReviewDiffStyle {
21 return {
22 rowHeight: codeSurface.rowHeight,
23 contentWidth: NATIVE_SOURCE_CONTENT_WIDTH,
24 changeBarWidth: 0,
25 gutterWidth: codeSurface.gutterWidth,
26 codePadding: codeSurface.codePadding,
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
2739const 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", () => {
8888 expect(AsyncResult.isSuccess(registry.get(atom))).toBe(true);
8989 });
9090 firstUnmount();
91 await new Promise((resolve) => setTimeout(resolve, 25));
91 await new Promise((resolve) => setTimeout(resolve, 100));
9292
9393 const secondUnmount = registry.mount(atom);
9494 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 {
33 SidebarProjectGroupingMode,
44 SidebarThreadSortOrder,
55} 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 { Stack } from "expo-router";
12import { Text as RNText, View } from "react-native";
6import { NativeHeaderToolbar, NativeStackScreenOptions } from "../../native/StackHeader";
7import { useCallback, useRef } from "react";
8import { Platform } from "react-native";
9import type { SearchBarCommands } from "react-native-screens";
1310
11import { nativeHeaderScrollEdgeEffects } from "../../native/StackHeader";
1412import { 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";
1616import 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 {
19 readonly environmentId: EnvironmentId;
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];
28export type HomeHeaderEnvironment = HomeListFilterMenuEnvironment;
29const HEADER_SCROLL_EDGE_EFFECTS = nativeHeaderScrollEdgeEffects(Platform.OS, Platform.Version);
6030
6131export function HomeHeader(props: {
6232 readonly environments: ReadonlyArray<HomeHeaderEnvironment>;
......@@ -72,174 +42,170 @@ export function HomeHeader(props: {
7242 readonly onOpenSettings: () => void;
7343 readonly onStartNewTask: () => void;
7444}) {
45 const searchBarRef = useRef<SearchBarCommands>(null);
7546 const iconColor = useThemeColor("--color-icon");
76 const mutedColor = useThemeColor("--color-foreground-muted");
77 const subtleColor = useThemeColor("--color-subtle");
78 const hasCustomListOptions =
79 props.selectedEnvironmentId !== null ||
80 props.projectSortOrder !== DEFAULT_SIDEBAR_PROJECT_SORT_ORDER ||
81 props.threadSortOrder !== DEFAULT_SIDEBAR_THREAD_SORT_ORDER ||
82 props.projectGroupingMode !== DEFAULT_SIDEBAR_PROJECT_GROUPING_MODE;
47 const hasCustomListOptions = hasCustomHomeListOptions(props);
48 const focusSearch = useCallback(() => {
49 searchBarRef.current?.focus();
50 return searchBarRef.current !== null;
51 }, []);
52 useHardwareKeyboardCommand("focusSearch", focusSearch);
53 const filterMenu = buildHomeListFilterMenu(props);
8354
8455 return (
8556 <>
86 <Stack.Screen
57 <NativeStackScreenOptions
8758 options={{
88 headerShown: true,
89 headerTransparent: true,
90 headerStyle: { backgroundColor: "transparent" },
91 headerShadowVisible: false,
59 // Static header config (glass, title, fonts) lives in Stack.tsx
60 // (GLASS_HEADER_OPTIONS). Only dynamic values are set here.
9261 headerTintColor: iconColor,
93 headerTitle: "",
94 headerSearchBarOptions: {
95 placeholder: "Search threads",
96 hideNavigationBar: false,
97 onChangeText: (event) => {
98 props.onSearchQueryChange(event.nativeEvent.text);
99 },
100 onCancelButtonPress: () => {
101 props.onSearchQueryChange("");
102 },
103 allowToolbarIntegration: true,
104 },
62 unstable_headerRightItems:
63 Platform.OS === "ios"
64 ? () => [
65 withNativeGlassHeaderItem({
66 accessibilityLabel: "Open settings",
67 icon: { name: "ellipsis", type: "sfSymbol" } as const,
68 identifier: "home-settings",
69 label: "",
70 onPress: props.onOpenSettings,
71 type: "button",
72 }),
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 },
105108 }}
106109 />
107110
108 <Stack.Toolbar placement="left">
109 <Stack.Toolbar.View hidesSharedBackground>
110 <View
111 style={{
112 width: 128,
113 height: 32,
114 flexDirection: "row",
115 alignItems: "center",
116 gap: 8,
117 }}
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>
111 {Platform.OS === "ios" ? null : (
112 <NativeHeaderToolbar placement="right">
113 <NativeHeaderToolbar.Button
114 accessibilityLabel="Open settings"
115 icon="gearshape"
116 onPress={props.onOpenSettings}
117 separateBackground
118 />
119 </NativeHeaderToolbar>
120 )}
183121
184 <Stack.Toolbar.Menu title="Sort projects">
185 <Stack.Toolbar.Label>Sort projects</Stack.Toolbar.Label>
186 {PROJECT_SORT_OPTIONS.map((option) => (
187 <Stack.Toolbar.MenuAction
188 key={option.value}
189 isOn={props.projectSortOrder === option.value}
190 onPress={() => props.onProjectSortOrderChange(option.value)}
191 >
192 <Stack.Toolbar.Label>{option.label}</Stack.Toolbar.Label>
193 </Stack.Toolbar.MenuAction>
194 ))}
195 </Stack.Toolbar.Menu>
122 {Platform.OS === "ios" ? null : (
123 <NativeHeaderToolbar placement="bottom">
124 <NativeHeaderToolbar.Menu
125 accessibilityLabel="Filter and sort threads"
126 icon={
127 hasCustomListOptions
128 ? "line.3.horizontal.decrease.circle.fill"
129 : "line.3.horizontal.decrease.circle"
130 }
131 title="Thread list options"
132 separateBackground
133 >
134 <NativeHeaderToolbar.MenuAction onPress={props.onOpenSettings}>
135 <NativeHeaderToolbar.Label>Settings</NativeHeaderToolbar.Label>
136 </NativeHeaderToolbar.MenuAction>
196137
197 <Stack.Toolbar.Menu title="Sort threads">
198 <Stack.Toolbar.Label>Sort threads</Stack.Toolbar.Label>
199 {THREAD_SORT_OPTIONS.map((option) => (
200 <Stack.Toolbar.MenuAction
201 key={option.value}
202 isOn={props.threadSortOrder === option.value}
203 onPress={() => props.onThreadSortOrderChange(option.value)}
138 <NativeHeaderToolbar.Menu title="Environment">
139 <NativeHeaderToolbar.Label>Environment</NativeHeaderToolbar.Label>
140 <NativeHeaderToolbar.MenuAction
141 isOn={props.selectedEnvironmentId === null}
142 onPress={() => props.onEnvironmentChange(null)}
143 subtitle="Show threads from every environment"
204144 >
205 <Stack.Toolbar.Label>{option.label}</Stack.Toolbar.Label>
206 </Stack.Toolbar.MenuAction>
207 ))}
208 </Stack.Toolbar.Menu>
145 <NativeHeaderToolbar.Label>All environments</NativeHeaderToolbar.Label>
146 </NativeHeaderToolbar.MenuAction>
147 {props.environments.map((environment) => (
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">
211 <Stack.Toolbar.Label>Group projects</Stack.Toolbar.Label>
212 {PROJECT_GROUPING_OPTIONS.map((option) => (
213 <Stack.Toolbar.MenuAction
214 key={option.value}
215 isOn={props.projectGroupingMode === option.value}
216 onPress={() => props.onProjectGroupingModeChange(option.value)}
217 subtitle={option.subtitle}
218 >
219 <Stack.Toolbar.Label>{option.label}</Stack.Toolbar.Label>
220 </Stack.Toolbar.MenuAction>
221 ))}
222 </Stack.Toolbar.Menu>
223 </Stack.Toolbar.Menu>
158 <NativeHeaderToolbar.Menu title="Sort projects">
159 <NativeHeaderToolbar.Label>Sort projects</NativeHeaderToolbar.Label>
160 {PROJECT_SORT_OPTIONS.map((option) => (
161 <NativeHeaderToolbar.MenuAction
162 key={option.value}
163 isOn={props.projectSortOrder === option.value}
164 onPress={() => props.onProjectSortOrderChange(option.value)}
165 >
166 <NativeHeaderToolbar.Label>{option.label}</NativeHeaderToolbar.Label>
167 </NativeHeaderToolbar.MenuAction>
168 ))}
169 </NativeHeaderToolbar.Menu>
224170
225 <Stack.Toolbar.Button
226 accessibilityLabel="Open settings"
227 icon="gearshape"
228 onPress={props.onOpenSettings}
229 separateBackground
230 />
231 </Stack.Toolbar>
171 <NativeHeaderToolbar.Menu title="Sort threads">
172 <NativeHeaderToolbar.Label>Sort threads</NativeHeaderToolbar.Label>
173 {THREAD_SORT_OPTIONS.map((option) => (
174 <NativeHeaderToolbar.MenuAction
175 key={option.value}
176 isOn={props.threadSortOrder === option.value}
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">
234 <Stack.Toolbar.SearchBarSlot />
235 <Stack.Toolbar.Spacer width={8} sharesBackground={false} />
236 <Stack.Toolbar.Button
237 accessibilityLabel="New task"
238 icon="square.and.pencil"
239 onPress={props.onStartNewTask}
240 separateBackground
241 />
242 </Stack.Toolbar>
184 <NativeHeaderToolbar.Menu title="Group projects">
185 <NativeHeaderToolbar.Label>Group projects</NativeHeaderToolbar.Label>
186 {PROJECT_GROUPING_OPTIONS.map((option) => (
187 <NativeHeaderToolbar.MenuAction
188 key={option.value}
189 isOn={props.projectGroupingMode === option.value}
190 onPress={() => props.onProjectGroupingModeChange(option.value)}
191 subtitle={option.subtitle}
192 >
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 )}
243209 </>
244210 );
245211}
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 @@
1import {
2 LegendList,
3 type LegendListRef,
4 type LegendListRenderItemProps,
5} from "@legendapp/list/react-native";
16import {
27 type EnvironmentProject,
38 type EnvironmentThreadShell,
......@@ -7,36 +12,35 @@ import type {
712 SidebarProjectGroupingMode,
813 SidebarThreadSortOrder,
914} from "@t3tools/contracts";
10import * as Haptics from "expo-haptics";
11import { SymbolView } from "expo-symbols";
1215import { useCallback, useMemo, useRef, useState } from "react";
13import { ActivityIndicator, Pressable, ScrollView, useWindowDimensions, View } from "react-native";
14import 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";
16import { ActivityIndicator, Platform, View } from "react-native";
17import type { SwipeableMethods } from "react-native-gesture-handler/ReanimatedSwipeable";
2418import { useSafeAreaInsets } from "react-native-safe-area-context";
2519import { useThemeColor } from "../../lib/useThemeColor";
2620
27import { AppText as Text } from "../../components/AppText";
2821import { EmptyState } from "../../components/EmptyState";
29import { ProjectFavicon } from "../../components/ProjectFavicon";
3022import type { WorkspaceState } from "../../state/workspaceModel";
3123import type { SavedRemoteConnection } from "../../lib/connection";
32import { relativeTime } from "../../lib/time";
33import { threadStatusTone } from "../threads/threadPresentation";
34import { buildHomeThreadGroups, type HomeProjectSortOrder } from "./homeThreadList";
24import { scopedProjectKey } from "../../lib/scopedEntities";
3525import {
36 THREAD_SWIPE_ACTIONS_WIDTH,
37 THREAD_SWIPE_SPRING,
38 ThreadSwipeActions,
39} from "./thread-swipe-actions";
26 ThreadListGroupHeader,
27 ThreadListRow,
28 ThreadListShowMoreRow,
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
4145/* ─── Types ──────────────────────────────────────────────────────────── */
4246
......@@ -45,63 +49,31 @@ interface HomeScreenProps {
4549 readonly threads: ReadonlyArray<EnvironmentThreadShell>;
4650 readonly catalogState: WorkspaceState;
4751 readonly savedConnectionsById: Readonly<Record<string, SavedRemoteConnection>>;
52 readonly environments: ReadonlyArray<HomeListFilterMenuEnvironment>;
4853 readonly searchQuery: string;
4954 readonly selectedEnvironmentId: EnvironmentId | null;
5055 readonly projectSortOrder: HomeProjectSortOrder;
5156 readonly threadSortOrder: SidebarThreadSortOrder;
5257 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;
5363 readonly onAddConnection: () => void;
5464 readonly onOpenEnvironments: () => void;
65 readonly onOpenSettings: () => void;
66 readonly onStartNewTask: () => void;
5567 readonly onSelectThread: (thread: EnvironmentThreadShell) => void;
5668 readonly onArchiveThread: (thread: EnvironmentThreadShell) => void;
5769 readonly onDeleteThread: (thread: EnvironmentThreadShell) => void;
5870}
5971
60/* ─── Status indicator colors ────────────────────────────────────────── */
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}
72/* ─── Layout constants ───────────────────────────────────────────────── */
7673
77const COLLAPSED_THREAD_LIMIT = 6;
78const THREAD_LAYOUT_TRANSITION = LinearTransition.duration(220).easing(Easing.out(Easing.cubic));
79
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}
74const ESTIMATED_THREAD_ROW_HEIGHT = 72;
75/** Height of the floating custom header on non-iOS platforms. */
76const CUSTOM_HEADER_HEIGHT = 78;
10577
10678function deriveEmptyState(props: {
10779 readonly catalogState: WorkspaceState;
......@@ -166,287 +138,28 @@ function deriveEmptyState(props: {
166138 };
167139}
168140
169/* ─── Project group header ───────────────────────────────────────────── */
170
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 );
141function HomeTopContentSpacer(props: { readonly topInset: number }) {
142 return <View style={{ height: props.topInset + CUSTOM_HEADER_HEIGHT }} />;
383143}
384144
385145/* ─── 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
439147export 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());
441151 const openSwipeableRef = useRef<SwipeableMethods | null>(null);
152 const listRef = useRef<LegendListRef | null>(null);
442153 const insets = useSafeAreaInsets();
443154 const accentColor = useThemeColor("--color-icon-muted");
444155
445 const toggleExpanded = useCallback((key: string) => {
446 setExpandedProjects((prev) => {
447 const next = new Set(prev);
448 if (next.has(key)) next.delete(key);
449 else next.add(key);
156 const updateGroupDisplay = useCallback((key: string, action: HomeGroupDisplayAction) => {
157 setGroupDisplayStates((previous) => {
158 const next = new Map(previous);
159 next.set(
160 key,
161 nextGroupDisplayState(previous.get(key) ?? DEFAULT_GROUP_DISPLAY_STATE, action),
162 );
450163 return next;
451164 });
452165 }, []);
......@@ -464,6 +177,13 @@ export function HomeScreen(props: HomeScreenProps) {
464177 }
465178 }, []);
466179
180 const handleScrollBeginDrag = useCallback(() => {
181 openSwipeableRef.current?.close();
182 }, []);
183 const { swipeEnabled, scrollGateHandlers } = useSwipeableScrollGate({
184 onScrollBeginDrag: handleScrollBeginDrag,
185 });
186
467187 const projectGroups = useMemo(
468188 () =>
469189 buildHomeThreadGroups({
......@@ -486,6 +206,94 @@ export function HomeScreen(props: HomeScreenProps) {
486206 ],
487207 );
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
489297 /* Empty states */
490298 const hasAnyThreads = props.threads.some((thread) => thread.archivedAt === null);
491299 const hasResults = projectGroups.length > 0;
......@@ -494,127 +302,121 @@ export function HomeScreen(props: HomeScreenProps) {
494302 ? null
495303 : (props.savedConnectionsById[props.selectedEnvironmentId]?.environmentLabel ??
496304 "this environment");
497 const hasSearchQuery = props.searchQuery.trim().length > 0;
498 const shouldShowConnectionStatus =
499 props.catalogState.networkStatus === "offline" ||
500 props.catalogState.hasConnectingEnvironment ||
501 (props.catalogState.hasLoadedShellSnapshot && !props.catalogState.hasReadyEnvironment);
305 const shouldShowConnectionStatus = shouldShowWorkspaceConnectionStatus(props.catalogState);
502306 const emptyState = deriveEmptyState({
503307 catalogState: props.catalogState,
504308 projectCount: props.projects.length,
505309 });
506
507 return (
508 <View className="flex-1 bg-screen">
509 <ScrollView
510 contentInsetAdjustmentBehavior="automatic"
511 showsVerticalScrollIndicator={false}
512 keyboardDismissMode="on-drag"
513 keyboardShouldPersistTaps="handled"
514 onScrollBeginDrag={() => openSwipeableRef.current?.close()}
515 className="flex-1"
516 contentContainerStyle={{
517 paddingHorizontal: 16,
518 paddingTop: 8,
519 paddingBottom: 24,
520 gap: 20,
310 const connectionStatus =
311 shouldShowConnectionStatus && Platform.OS !== "ios" ? (
312 <View
313 className="absolute left-0 right-0 items-center"
314 style={{ bottom: Math.max(insets.bottom, 18) + 76 }}
315 >
316 <WorkspaceConnectionStatus state={props.catalogState} onPress={props.onOpenEnvironments} />
317 </View>
318 ) : null;
319
320 if (!hasAnyThreads) {
321 return (
322 <View
323 className="flex-1 items-center justify-center bg-screen px-8"
324 style={{
325 paddingBottom: Math.max(insets.bottom, 24),
326 paddingTop: Platform.OS === "ios" ? insets.top + 72 : insets.top,
521327 }}
522328 >
523 {!hasAnyThreads ? (
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 ? (
329 <View className="w-full max-w-[430px]">
545330 <EmptyState
546 title="No threads yet"
547 detail="Create a task to start a new coding session."
331 title={emptyState.title}
332 detail={emptyState.detail}
333 actionLabel={!props.catalogState.hasReadyEnvironment ? "Add environment" : undefined}
334 onAction={!props.catalogState.hasReadyEnvironment ? props.onAddConnection : undefined}
335 variant="plain"
548336 />
549 ) : (
550 projectGroups.map((group) => {
551 const isExpanded = expandedProjects.has(group.key);
552 const visibleThreads = isExpanded
553 ? group.threads
554 : group.threads.slice(0, COLLAPSED_THREAD_LIMIT);
337 {emptyState.loading ? (
338 <View className="mt-4 items-center">
339 <ActivityIndicator color={accentColor} />
340 </View>
341 ) : null}
342 </View>
343 {connectionStatus}
344 </View>
345 );
346 }
555347
556 return (
557 <Animated.View
558 key={group.key}
559 collapsable={false}
560 exiting={threadRowExit}
561 layout={THREAD_LAYOUT_TRANSITION}
562 style={{ overflow: "hidden" }}
563 >
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}
348 const listHeader = (
349 <>
350 {Platform.OS === "ios" ? null : <HomeTopContentSpacer topInset={insets.top} />}
351
352 {shouldShowConnectionStatus && Platform.OS === "ios" ? (
353 <View style={{ paddingBottom: 16 }}>
354 <WorkspaceConnectionStatus
355 state={props.catalogState}
614356 onPress={props.onOpenEnvironments}
357 variant="sidebar"
615358 />
616359 </View>
617360 ) : 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}
618420 </View>
619421 );
620422}
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 @@
11import { SymbolView } from "expo-symbols";
2import type { ComponentProps } from "react";
3import type { ColorValue } from "react-native";
2import * as Haptics from "expo-haptics";
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";
420import { 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";
624import Animated, {
725 Extrapolation,
826 interpolate,
......@@ -26,6 +44,231 @@ export const THREAD_SWIPE_SPRING = {
2644 stiffness: 330,
2745};
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
29272function SwipeActionButton(props: {
30273 readonly accessibilityLabel: string;
31274 readonly backgroundColor: string;
......@@ -179,12 +422,7 @@ export function ThreadSwipeActions(props: {
179422 readonly fullSwipeThreshold: number;
180423 readonly onDelete: () => void;
181424 readonly onFullSwipeArmedChange: (armed: boolean) => void;
182 readonly primaryAction: {
183 readonly accessibilityLabel: string;
184 readonly icon: ComponentProps<typeof SymbolView>["name"];
185 readonly label: string;
186 readonly onPress: () => void;
187 };
425 readonly primaryAction: ThreadSwipePrimaryAction;
188426 readonly swipeableMethods: SwipeableMethods;
189427 readonly threadTitle: string;
190428 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 {
2222 isFilesystemBrowseQuery,
2323} from "@t3tools/client-runtime/state/projects";
2424import { CommandId, type EnvironmentId, ProjectId } from "@t3tools/contracts";
25import { useLocalSearchParams, useRouter } from "expo-router";
25import { StackActions, useNavigation } from "@react-navigation/native";
2626import { SymbolView } from "expo-symbols";
2727import { useCallback, useEffect, useMemo, useState, type ReactNode } from "react";
2828import { ActivityIndicator, Alert, Pressable, ScrollView, View } from "react-native";
......@@ -168,9 +168,9 @@ function ListRow(props: {
168168 {props.icon}
169169 </View>
170170 <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>
172172 {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}>
174174 {props.subtitle}
175175 </Text>
176176 ) : null}
......@@ -215,7 +215,7 @@ function ProjectPathInput(props: {
215215}) {
216216 return (
217217 <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"
219219 value={props.value}
220220 onChangeText={props.onChangeText}
221221 autoCapitalize="none"
......@@ -250,35 +250,31 @@ function useSelectedEnvironment(): {
250250 readonly selectedEnvironment: EnvironmentOption | null;
251251 readonly setSelectedEnvironmentId: (environmentId: EnvironmentId) => void;
252252} {
253 const router = useRouter();
254 const params = useLocalSearchParams<{ environmentId?: string }>();
253 const [selectedEnvironmentId, setSelectedEnvironmentId] = useState<EnvironmentId | null>(null);
255254 const environmentOptions = useEnvironmentOptions();
256 const requestedEnvironmentId = stringParam(params.environmentId) as EnvironmentId | null;
257255 const selectedEnvironment =
258 environmentOptions.find(
259 (environment) => environment.environmentId === requestedEnvironmentId,
260 ) ??
256 environmentOptions.find((environment) => environment.environmentId === selectedEnvironmentId) ??
261257 environmentOptions[0] ??
262258 null;
263259
264260 return {
265261 environmentOptions,
266262 selectedEnvironment,
267 setSelectedEnvironmentId: (environmentId) => router.setParams({ environmentId }),
263 setSelectedEnvironmentId,
268264 };
269265}
270266
271267function EmptyEnvironmentState() {
272 const router = useRouter();
268 const navigation = useNavigation();
273269
274270 return (
275271 <View className="items-center gap-3 rounded-2xl bg-card px-5 py-8">
276272 <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">
278274 Add an environment before adding a project.
279275 </Text>
280276 <Pressable
281 onPress={() => router.replace("/connections/new")}
277 onPress={() => navigation.dispatch(StackActions.replace("ConnectionsNew"))}
282278 className="mt-1 rounded-full bg-primary px-4 py-2.5 active:opacity-70"
283279 >
284280 <Text className="text-sm font-t3-bold text-primary-foreground">Add environment</Text>
......@@ -294,7 +290,7 @@ function SourceControlRow(props: {
294290 readonly hint: string;
295291 readonly isFirst: boolean;
296292}) {
297 const router = useRouter();
293 const navigation = useNavigation();
298294 const iconColor = useThemeColor("--color-icon");
299295 const title =
300296 props.source === "url" ? "Git URL" : `${addProjectRemoteSourceLabel(props.source)} repository`;
......@@ -322,8 +318,8 @@ function SourceControlRow(props: {
322318 icon={icon}
323319 isFirst={props.isFirst}
324320 onPress={() =>
325 router.push({
326 pathname: "/new/add-project/repository",
321 navigation.navigate("NewTaskSheet", {
322 screen: "AddProjectRepository",
327323 params: {
328324 environmentId: props.selectedEnvironmentId,
329325 source: props.source,
......@@ -335,7 +331,7 @@ function SourceControlRow(props: {
335331}
336332
337333export function AddProjectSourceScreen() {
338 const router = useRouter();
334 const navigation = useNavigation();
339335 const accentColor = useThemeColor("--color-icon-muted");
340336 const iconColor = useThemeColor("--color-icon");
341337 const { environmentOptions, selectedEnvironment, setSelectedEnvironmentId } =
......@@ -409,9 +405,11 @@ export function AddProjectSourceScreen() {
409405 }
410406 isFirst
411407 onPress={() =>
412 router.push({
413 pathname: "/new/add-project/local",
414 params: { environmentId: selectedEnvironment.environmentId },
408 navigation.navigate("NewTaskSheet", {
409 screen: "AddProjectLocal",
410 params: {
411 environmentId: selectedEnvironment.environmentId,
412 },
415413 })
416414 }
417415 />
......@@ -440,7 +438,7 @@ export function AddProjectSourceScreen() {
440438}
441439
442440function useCreateProject(environment: EnvironmentOption | null) {
443 const router = useRouter();
441 const navigation = useNavigation();
444442 const createProject = useAtomCommand(projectEnvironment.create, { reportFailure: false });
445443 const projects = useProjects();
446444
......@@ -455,14 +453,13 @@ function useCreateProject(environment: EnvironmentOption | null) {
455453 });
456454 if (existing) {
457455 Alert.alert("Project already exists", existing.title);
458 router.replace({
459 pathname: "/new/draft",
460 params: {
456 navigation.dispatch(
457 StackActions.replace("NewTaskDraft", {
461458 environmentId: existing.environmentId,
462459 projectId: existing.id,
463460 title: existing.title,
464 },
465 });
461 }),
462 );
466463 return;
467464 }
468465
......@@ -480,24 +477,24 @@ function useCreateProject(environment: EnvironmentOption | null) {
480477 if (AsyncResult.isFailure(result)) {
481478 return result;
482479 }
483 router.replace({
484 pathname: "/new/draft",
485 params: {
480 navigation.dispatch(
481 StackActions.replace("NewTaskDraft", {
486482 environmentId: environment.environmentId,
487483 projectId,
488484 title: inferProjectTitleFromPath(workspaceRoot),
489 },
490 });
485 }),
486 );
491487 return result;
492488 },
493 [createProject, environment, projects, router],
489 [createProject, environment, projects, navigation],
494490 );
495491}
496492
497function useEnvironmentFromParam(): EnvironmentOption | null {
498 const params = useLocalSearchParams<{ environmentId?: string }>();
493function useEnvironmentFromParam(
494 environmentIdParam: string | string[] | undefined,
495): EnvironmentOption | null {
499496 const environmentOptions = useEnvironmentOptions();
500 const environmentId = stringParam(params.environmentId) as EnvironmentId | null;
497 const environmentId = stringParam(environmentIdParam) as EnvironmentId | null;
501498 return (
502499 environmentOptions.find((environment) => environment.environmentId === environmentId) ??
503500 environmentOptions[0] ??
......@@ -505,14 +502,16 @@ function useEnvironmentFromParam(): EnvironmentOption | null {
505502 );
506503}
507504
508export function AddProjectRepositoryScreen() {
505export function AddProjectRepositoryScreen(props: {
506 readonly environmentId?: string | string[];
507 readonly source?: string | string[];
508}) {
509509 const lookupRepositoryQuery = useAtomQueryRunner(sourceControlEnvironment.repository, {
510510 reportFailure: false,
511511 });
512 const router = useRouter();
513 const params = useLocalSearchParams<{ environmentId?: string; source?: string }>();
514 const environment = useEnvironmentFromParam();
515 const source = sourceFromParam(params.source);
512 const navigation = useNavigation();
513 const environment = useEnvironmentFromParam(props.environmentId);
514 const source = sourceFromParam(props.source);
516515 const [repositoryInput, setRepositoryInput] = useState("");
517516 const [isSubmitting, setIsSubmitting] = useState(false);
518517 const [error, setError] = useState<string | null>(null);
......@@ -524,8 +523,8 @@ export function AddProjectRepositoryScreen() {
524523 const provider = addProjectRemoteSourceProvider(source);
525524 if (!provider) {
526525 const remoteUrl = repositoryInput.trim();
527 router.push({
528 pathname: "/new/add-project/destination",
526 navigation.navigate("NewTaskSheet", {
527 screen: "AddProjectDestination",
529528 params: {
530529 environmentId: environment.environmentId,
531530 source,
......@@ -548,8 +547,8 @@ export function AddProjectRepositoryScreen() {
548547 setError(errorMessage(Cause.squash(result.cause)));
549548 } else {
550549 const repository = result.value;
551 router.push({
552 pathname: "/new/add-project/destination",
550 navigation.navigate("NewTaskSheet", {
551 screen: "AddProjectDestination",
553552 params: {
554553 environmentId: environment.environmentId,
555554 source,
......@@ -559,13 +558,13 @@ export function AddProjectRepositoryScreen() {
559558 });
560559 }
561560 setIsSubmitting(false);
562 }, [environment, isSubmitting, lookupRepositoryQuery, repositoryInput, router, source]);
561 }, [environment, isSubmitting, lookupRepositoryQuery, repositoryInput, navigation, source]);
563562
564563 return (
565564 <AddProjectShell>
566565 {error ? <ErrorBanner message={error} /> : null}
567566 <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"
569568 value={repositoryInput}
570569 onChangeText={setRepositoryInput}
571570 autoCapitalize="none"
......@@ -681,8 +680,8 @@ function FolderBrowser(props: {
681680 );
682681}
683682
684export function AddProjectLocalFolderScreen() {
685 const environment = useEnvironmentFromParam();
683export function AddProjectLocalFolderScreen(props: { readonly environmentId?: string | string[] }) {
684 const environment = useEnvironmentFromParam(props.environmentId);
686685 const createProject = useCreateProject(environment);
687686 const [pathInput, setPathInput] = useState(() =>
688687 getAddProjectInitialQuery(environment?.baseDirectory),
......@@ -745,19 +744,18 @@ export function AddProjectLocalFolderScreen() {
745744 );
746745}
747746
748export function AddProjectDestinationScreen() {
747export function AddProjectDestinationScreen(props: {
748 readonly environmentId?: string | string[];
749 readonly remoteUrl?: string | string[];
750 readonly repositoryTitle?: string | string[];
751}) {
749752 const cloneRepository = useAtomCommand(sourceControlEnvironment.cloneRepository, {
750753 reportFailure: false,
751754 });
752 const params = useLocalSearchParams<{
753 environmentId?: string;
754 remoteUrl?: string;
755 repositoryTitle?: string;
756 }>();
757 const environment = useEnvironmentFromParam();
755 const environment = useEnvironmentFromParam(props.environmentId);
758756 const createProject = useCreateProject(environment);
759 const remoteUrl = stringParam(params.remoteUrl);
760 const repositoryTitle = stringParam(params.repositoryTitle);
757 const remoteUrl = stringParam(props.remoteUrl);
758 const repositoryTitle = stringParam(props.repositoryTitle);
761759 const [pathInput, setPathInput] = useState(() =>
762760 getAddProjectInitialQuery(environment?.baseDirectory),
763761 );
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 @@
1import { useLocalSearchParams, useRouter } from "expo-router";
1import { useNavigation, type StaticScreenProps } from "@react-navigation/native";
22import { SymbolView } from "expo-symbols";
33import { TextInputWrapper } from "expo-paste-input";
44import type { EnvironmentId, ThreadId } from "@t3tools/contracts";
......@@ -25,10 +25,10 @@ import {
2525 getSelectedReviewCommentLines,
2626 useReviewCommentTarget,
2727} from "./reviewCommentSelection";
28import { useAppearanceCodeSurface } from "../settings/appearance/useAppearanceCodeSurface";
2829import {
2930 changeTone,
3031 DiffTokenText,
31 REVIEW_DIFF_LINE_HEIGHT,
3232 REVIEW_MONO_FONT_FAMILY,
3333 ReviewChangeBar,
3434} from "./reviewDiffRendering";
......@@ -40,17 +40,20 @@ import {
4040
4141const REVIEW_COMMENT_PREVIEW_MAX_LINES = 5;
4242
43export function ReviewCommentComposerSheet() {
44 const router = useRouter();
43type ReviewCommentComposerSheetProps = StaticScreenProps<{
44 readonly environmentId: EnvironmentId;
45 readonly threadId: ThreadId;
46}>;
47
48export function ReviewCommentComposerSheet(props: ReviewCommentComposerSheetProps) {
49 const navigation = useNavigation();
4550 const insets = useSafeAreaInsets();
4651 const { width } = useWindowDimensions();
4752 const colorScheme = useColorScheme();
4853 const iconTint = String(useThemeColor("--color-icon"));
4954 const target = useReviewCommentTarget();
50 const { environmentId, threadId } = useLocalSearchParams<{
51 environmentId: EnvironmentId;
52 threadId: ThreadId;
53 }>();
55 const { codeSurface } = useAppearanceCodeSurface();
56 const { environmentId, threadId } = props.route.params;
5457 const [commentText, setCommentText] = useState("");
5558 const [highlightedLinesById, setHighlightedLinesById] = useState<
5659 Record<string, ReadonlyArray<ReviewHighlightedToken>>
......@@ -78,14 +81,14 @@ export function ReviewCommentComposerSheet() {
7881 ? `Lines ${firstNumber}-${lastNumber}`
7982 : `${selectedLines.length} lines selected`;
8083 const previewHeight = Math.max(
81 Math.min(selectedLines.length, REVIEW_COMMENT_PREVIEW_MAX_LINES) * REVIEW_DIFF_LINE_HEIGHT,
82 REVIEW_DIFF_LINE_HEIGHT,
84 Math.min(selectedLines.length, REVIEW_COMMENT_PREVIEW_MAX_LINES) * codeSurface.rowHeight,
85 codeSurface.rowHeight,
8386 );
8487 const previewViewportWidth = Math.max(width - 40, 280);
8588 const dismissComposer = useCallback(() => {
8689 clearReviewCommentTarget();
87 router.dismiss();
88 }, [router]);
90 navigation.goBack();
91 }, [navigation]);
8992 const handleNativePaste = useNativePaste((uris) => {
9093 void (async () => {
9194 try {
......@@ -167,7 +170,7 @@ export function ReviewCommentComposerSheet() {
167170 {!target ? (
168171 <View className="rounded-[22px] border border-border bg-card px-4 py-5">
169172 <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">
171174 Select a diff line or range first.
172175 </Text>
173176 </View>
......@@ -178,7 +181,7 @@ export function ReviewCommentComposerSheet() {
178181 {selectionLabel}
179182 </Text>
180183 <Text
181 className="font-mono text-xs leading-[17px] text-foreground-muted"
184 className="font-mono text-xs leading-snug text-foreground-muted"
182185 ellipsizeMode="middle"
183186 numberOfLines={2}
184187 >
......@@ -211,9 +214,9 @@ export function ReviewCommentComposerSheet() {
211214 <View
212215 key={line.id}
213216 className={cn("flex-row items-start", changeTone(line.change))}
214 style={{ height: REVIEW_DIFF_LINE_HEIGHT }}
217 style={{ height: codeSurface.rowHeight }}
215218 >
216 <ReviewChangeBar change={line.change} />
219 <ReviewChangeBar change={line.change} height={codeSurface.rowHeight} />
217220 <Text
218221 className="w-9 py-1 pr-1 text-right text-2xs font-t3-medium text-foreground-muted"
219222 style={{ fontFamily: REVIEW_MONO_FONT_FAMILY }}
......@@ -225,6 +228,8 @@ export function ReviewCommentComposerSheet() {
225228 fallback={line.content}
226229 tokens={highlightedLinesById[line.id] ?? null}
227230 change={line.change}
231 fontSize={codeSurface.fontSize}
232 lineHeight={codeSurface.rowHeight}
228233 />
229234 </View>
230235 </View>
apps/mobile/src/features/review/ReviewSheet.tsx+478-182
......@@ -1,14 +1,30 @@
11import type { EnvironmentId, ThreadId } from "@t3tools/contracts";
2import { useLocalSearchParams } from "expo-router";
3import Stack from "expo-router/stack";
2import { useNavigation, type StaticScreenProps } from "@react-navigation/native";
3import {
4 NativeHeaderToolbar,
5 NativeStackScreenOptions,
6 nativeHeaderScrollEdgeEffects,
7} from "../../native/StackHeader";
8import { Screen, ScreenStack, ScreenStackHeaderConfig } from "react-native-screens";
49import { 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";
621import {
722 ActivityIndicator,
23 FlatList,
24 Platform,
825 Pressable,
926 ScrollView,
1027 type NativeSyntheticEvent,
11 Text as NativeText,
1228 StyleSheet,
1329 useColorScheme,
1430 View,
......@@ -20,23 +36,38 @@ import { environmentCatalog } from "../../connection/catalog";
2036import { useEnvironmentPresentation } from "../../state/presentation";
2137import { useAtomCommand } from "../../state/use-atom-command";
2238import { useThemeColor } from "../../lib/useThemeColor";
23import { MOBILE_TYPOGRAPHY } from "../../lib/typography";
2439import { useThreadDraftForThread } from "../../state/use-thread-composer-state";
2540import { 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";
2654import { useReviewCacheForThread } from "./reviewState";
27import { resolveNativeReviewDiffView } from "../diffs/nativeReviewDiffSurface";
2855import {
29 NATIVE_REVIEW_DIFF_CONTENT_WIDTH,
30 NATIVE_REVIEW_DIFF_ROW_HEIGHT,
31} from "./nativeReviewDiffAdapter";
56 type NativeReviewDiffViewHandle,
57 resolveNativeReviewDiffView,
58} from "../diffs/nativeReviewDiffSurface";
59import { NATIVE_REVIEW_DIFF_CONTENT_WIDTH } from "./nativeReviewDiffAdapter";
60import { useAppearanceCodeSurface } from "../settings/appearance/useAppearanceCodeSurface";
3261import { useReviewDiffData } from "./useReviewDiffData";
62import { useReviewDiffPrewarming } from "./useReviewDiffPrewarming";
3363import { useReviewFileVisibility } from "./reviewFileVisibility";
3464import { useReviewSections } from "./useReviewSections";
3565import { useNativeReviewDiffBridge } from "./useNativeReviewDiffBridge";
3666import { useReviewCommentSelectionController } from "./useReviewCommentSelectionController";
3767import { resolveReviewAvailability } from "./reviewAvailability";
68import { resolveSelectedReviewFileId } from "./reviewPaneSelection";
69import { buildReviewSectionMenu } from "./review-section-menu";
3870
39const IOS_NAV_BAR_HEIGHT = 44;
4071const REVIEW_HEADER_SPACING = 0;
4172
4273const ReviewNotice = memo(function ReviewNotice(props: { readonly notice: string }) {
......@@ -45,7 +76,7 @@ const ReviewNotice = memo(function ReviewNotice(props: { readonly notice: string
4576 <Text className="text-xs font-t3-bold uppercase text-amber-700 dark:text-amber-300">
4677 Partial diff
4778 </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">
4980 {props.notice}
5081 </Text>
5182 </View>
......@@ -110,36 +141,244 @@ function ReviewSelectionActionBar(props: {
110141 );
111142}
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();
114337 const insets = useSafeAreaInsets();
115338 const colorScheme = useColorScheme();
116 const headerForeground = String(useThemeColor("--color-foreground"));
117 const headerMuted = String(useThemeColor("--color-foreground-muted"));
118339 const headerIcon = String(useThemeColor("--color-icon"));
119 const { environmentId, threadId } = useLocalSearchParams<{
120 environmentId: EnvironmentId;
121 threadId: ThreadId;
122 }>();
340 const { environmentId, threadId } = props.route.params;
123341 const environment = useEnvironmentPresentation(environmentId);
124342 const retryEnvironment = useAtomCommand(environmentCatalog.retryNow, "environment retry");
125343 const isEnvironmentReady = environment.presentation?.connection.phase === "connected";
126344 const { draftMessage } = useThreadDraftForThread({ environmentId, threadId });
127345 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);
128363 const selectedTheme = colorScheme === "dark" ? "dark" : "light";
129 const topContentInset = insets.top + IOS_NAV_BAR_HEIGHT;
130 const {
131 error,
132 loadingGitDiffs,
133 loadingTurnIds,
134 reviewSections,
135 selectedSection,
136 refreshSelectedSection,
137 selectSection,
138 } = useReviewSections({
139 enabled: isEnvironmentReady,
140 environmentId,
141 threadId,
142 reviewCache,
364 // With a solid (non-overlay) header the content lays out below the header
365 // natively, so no manual top inset is needed.
366 const topContentInset = 0;
367
368 useEffect(() => {
369 showAuxiliaryPane("inspector");
370 }, [environmentId, showAuxiliaryPane, threadId]);
371 const { error, reviewSections, selectedSection, refreshSelectedSection, selectSection } =
372 useReviewSections({
373 enabled: isEnvironmentReady,
374 environmentId,
375 threadId,
376 reviewCache,
377 });
378 useReviewDiffPrewarming({
379 threadKey: reviewCache.threadKey,
380 sections: reviewSections,
381 selectedSectionId: selectedSection?.id ?? null,
143382 });
144383 const { headerDiffSummary, nativeReviewDiffData, parsedDiff, pendingReviewCommentCount } =
145384 useReviewDiffData({
......@@ -148,6 +387,18 @@ export function ReviewSheet() {
148387 draftMessage,
149388 });
150389 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);
151402 const reviewFiles = parsedDiff.kind === "files" ? parsedDiff.files : [];
152403 const fileVisibility = useReviewFileVisibility({
153404 threadKey: reviewCache.threadKey,
......@@ -179,6 +430,43 @@ export function ReviewSheet() {
179430 canHighlight: parsedDiff.kind === "files",
180431 });
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
182470 const handleNativeToggleFile = useCallback(
183471 (event: NativeSyntheticEvent<{ readonly fileId?: string }>) => {
184472 const { fileId } = event.nativeEvent;
......@@ -203,6 +491,7 @@ export function ReviewSheet() {
203491 parsedDiff.kind === "files" || parsedDiff.kind === "raw" ? parsedDiff.notice : null;
204492 const hasCachedSelectedDiff = selectedSection?.diff != null;
205493 const hasAnyCachedDiff = reviewSections.some((section) => section.diff != null);
494 const sectionMenu = useMemo(() => buildReviewSectionMenu(reviewSections), [reviewSections]);
206495 const { showConnectionNotice, showSectionToolbar } = resolveReviewAvailability({
207496 hasEnvironmentPresentation: environment.isReady,
208497 isEnvironmentConnected: isEnvironmentReady,
......@@ -212,6 +501,22 @@ export function ReviewSheet() {
212501 const handleRetryEnvironment = useCallback(() => {
213502 void retryEnvironment(environmentId);
214503 }, [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
216521 const listHeader = useMemo(() => {
217522 const children: ReactElement[] = [];
......@@ -220,7 +525,7 @@ export function ReviewSheet() {
220525 children.push(
221526 <View key="review-error" className="border-b border-border bg-card px-4 py-3">
222527 <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>
224529 </View>,
225530 );
226531 }
......@@ -235,134 +540,117 @@ export function ReviewSheet() {
235540
236541 return <>{children}</>;
237542 }, [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
239554 return (
240555 <>
241 <Stack.Screen
556 <NativeStackScreenOptions
242557 options={{
243 headerTransparent: true,
244 headerShadowVisible: false,
558 // Static header config lives in Stack.tsx (SOLID_HEADER_OPTIONS — the native
559 // diff scrolls internally, nothing for glass to sample). Only dynamic values
560 // here.
245561 headerTintColor: headerIcon,
246 headerStyle: {
247 backgroundColor: "transparent",
248 },
249 headerTitle: () => (
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 ),
562 headerTitle: headerTitleText,
563 title: headerTitleText,
564 unstable_headerSubtitle:
565 Platform.OS === "ios" && headerSubtitle.length > 0 ? headerSubtitle : undefined,
337566 }}
338567 />
339568
340 {showSectionToolbar ? (
341 <Stack.Toolbar placement="right">
342 <Stack.Toolbar.Menu icon="ellipsis.circle" title="Select diff" separateBackground>
343 {reviewSections.map((section) => (
344 <Stack.Toolbar.MenuAction
345 key={section.id}
346 icon={section.id === selectedSection?.id ? "checkmark" : "circle"}
347 onPress={() => selectSection(section.id)}
348 subtitle={section.subtitle ?? undefined}
349 >
350 <Stack.Toolbar.Label>{section.title}</Stack.Toolbar.Label>
351 </Stack.Toolbar.MenuAction>
352 ))}
353 <Stack.Toolbar.MenuAction
354 icon="arrow.clockwise"
355 disabled={
356 loadingGitDiffs ||
357 (selectedSection?.kind === "turn" && loadingTurnIds[selectedSection.id] === true)
569 <WorkspaceSidebarToolbar>
570 <NativeHeaderToolbar.Button
571 accessibilityLabel="Back to chat"
572 icon="chevron.left"
573 onPress={handleReturnToThread}
574 />
575 </WorkspaceSidebarToolbar>
576
577 {showSectionToolbar || panes.supportsAuxiliaryPane || gitMenuAvailable ? (
578 <NativeHeaderToolbar placement="right">
579 {panes.supportsAuxiliaryPane ? (
580 <NativeHeaderToolbar.Button
581 accessibilityLabel={
582 panes.auxiliaryPaneVisible ? "Hide changed files" : "Show changed files"
358583 }
359 onPress={() => void refreshSelectedSection()}
360 subtitle="Reload current diff"
361 >
362 <Stack.Toolbar.Label>Refresh</Stack.Toolbar.Label>
363 </Stack.Toolbar.MenuAction>
364 </Stack.Toolbar.Menu>
365 </Stack.Toolbar>
584 icon="sidebar.right"
585 onPress={toggleAuxiliaryPane}
586 separateBackground
587 />
588 ) : null}
589 {gitMenuAvailable && selectedThread !== null ? (
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>
366654 ) : null}
367655
368656 <View className="flex-1 bg-sheet">
......@@ -386,34 +674,42 @@ export function ReviewSheet() {
386674 className="flex-1"
387675 style={{
388676 backgroundColor: nativeBridge.theme.background,
389 paddingTop: topContentInset + REVIEW_HEADER_SPACING,
390677 }}
391678 >
392 {listHeader}
393 <View className="flex-1" collapsable={false}>
394 <NativeReviewDiffView
395 key={`${reviewCache.threadKey}:${selectedSection.id}`}
396 collapsable={false}
397 testID="review-native-diff-view"
398 style={StyleSheet.absoluteFill}
399 appearanceScheme={selectedTheme}
400 collapsedFileIdsJson={nativeBridge.collapsedFileIdsJson}
401 collapsedCommentIdsJson={nativeBridge.collapsedCommentIdsJson}
402 contentWidth={NATIVE_REVIEW_DIFF_CONTENT_WIDTH}
403 rowHeight={NATIVE_REVIEW_DIFF_ROW_HEIGHT}
404 rowsJson={nativeBridge.rowsJson}
405 selectedRowIdsJson={nativeBridge.selectedRowIdsJson}
406 styleJson={nativeBridge.styleJson}
407 themeJson={nativeBridge.themeJson}
408 tokensPatchJson={nativeBridge.tokensPatchJson}
409 tokensResetKey={nativeBridge.tokensResetKey}
410 viewedFileIdsJson={nativeBridge.viewedFileIdsJson}
411 onDebug={nativeBridge.onDebug}
412 onPressLine={commentSelection.onPressLine}
413 onToggleComment={nativeBridge.onToggleComment}
414 onToggleFile={handleNativeToggleFile}
415 onToggleViewedFile={handleNativeToggleViewedFile}
416 />
679 <View
680 className="min-w-0 flex-1"
681 style={{ paddingTop: topContentInset + REVIEW_HEADER_SPACING }}
682 >
683 {listHeader}
684 <View className="min-w-0 flex-1" collapsable={false}>
685 <NativeReviewDiffView
686 collapsable={false}
687 testID="review-native-diff-view"
688 refreshing={isPullRefreshing}
689 onPullToRefresh={() => void handlePullToRefresh()}
690 style={StyleSheet.absoluteFill}
691 appearanceScheme={selectedTheme}
692 collapsedFileIdsJson={nativeBridge.collapsedFileIdsJson}
693 collapsedCommentIdsJson={nativeBridge.collapsedCommentIdsJson}
694 contentResetKey={`${reviewCache.threadKey}:${selectedSection.id}`}
695 contentWidth={NATIVE_REVIEW_DIFF_CONTENT_WIDTH}
696 nativeViewRef={nativeReviewDiffViewRef}
697 rowHeight={nativeReviewDiffStyle.rowHeight}
698 rowsJson={nativeBridge.rowsJson}
699 selectedRowIdsJson={nativeBridge.selectedRowIdsJson}
700 styleJson={nativeBridge.styleJson}
701 themeJson={nativeBridge.themeJson}
702 tokensPatchJson={nativeBridge.tokensPatchJson}
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>
417713 </View>
418714 </View>
419715 ) : (
......@@ -432,7 +728,7 @@ export function ReviewSheet() {
432728 {!selectedSection ? (
433729 <View className="border-b border-border bg-card px-4 py-5">
434730 <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">
436732 This thread has no ready turn diffs and the worktree diff is empty.
437733 </Text>
438734 </View>
......@@ -444,17 +740,17 @@ export function ReviewSheet() {
444740 ) : parsedDiff.kind === "empty" ? (
445741 <View className="border-b border-border bg-card px-4 py-5">
446742 <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">
448744 {selectedSection.subtitle ?? "This diff is empty."}
449745 </Text>
450746 </View>
451747 ) : parsedDiff.kind === "raw" ? (
452748 <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">
454750 {parsedDiff.reason}
455751 </Text>
456752 <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">
458754 {parsedDiff.text}
459755 </Text>
460756 </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 {
55} from "../diffs/nativeReviewDiffTypes";
66import * as Arr from "effect/Array";
77import { pipe } from "effect/Function";
8import type { ResolvedMobileCodeSurface } from "../../lib/appearancePreferences";
9import { resolveMobileCodeSurface } from "../../lib/appearancePreferences";
810import { MOBILE_CODE_SURFACE } from "../../lib/typography";
911import { getPierreTerminalTheme, type TerminalAppearanceScheme } from "../terminal/terminalTheme";
1012import { computeWordAltDiffRanges } from "./reviewWordDiffs";
......@@ -22,38 +24,44 @@ const NATIVE_REVIEW_MAX_WORD_DIFF_COVERAGE = 0.45;
2224export const NATIVE_REVIEW_DIFF_ROW_HEIGHT = MOBILE_CODE_SURFACE.rowHeight;
2325export const NATIVE_REVIEW_DIFF_CONTENT_WIDTH = 2_800;
2426
25export const NATIVE_REVIEW_DIFF_STYLE = {
26 rowHeight: NATIVE_REVIEW_DIFF_ROW_HEIGHT,
27 contentWidth: NATIVE_REVIEW_DIFF_CONTENT_WIDTH,
28 changeBarWidth: 4,
29 gutterWidth: MOBILE_CODE_SURFACE.gutterWidth,
30 codePadding: MOBILE_CODE_SURFACE.codePadding,
31 textVerticalInset: MOBILE_CODE_SURFACE.textVerticalInset,
32 fileHeaderHeight: 56,
33 fileHeaderHorizontalMargin: 8,
34 fileHeaderVerticalMargin: 6,
35 fileHeaderCornerRadius: 10,
36 fileHeaderHorizontalPadding: 10,
37 fileHeaderPathRightPadding: 118,
38 fileHeaderCountColumnWidth: 38,
39 fileHeaderCountGap: 5,
40 codeFontSize: MOBILE_CODE_SURFACE.fontSize,
41 codeFontWeight: "regular",
42 lineNumberFontSize: MOBILE_CODE_SURFACE.lineNumberFontSize,
43 lineNumberFontWeight: "regular",
44 hunkFontSize: 11,
45 hunkFontWeight: "medium",
46 fileHeaderFontSize: 11,
47 fileHeaderFontWeight: "semibold",
48 fileHeaderMetaFontSize: 10,
49 fileHeaderMetaFontWeight: "semibold",
50 fileHeaderSubtextFontSize: 11,
51 fileHeaderSubtextFontWeight: "medium",
52 fileHeaderStatusFontSize: 9,
53 fileHeaderStatusFontWeight: "bold",
54 emptyStateFontSize: 12,
55 emptyStateFontWeight: "medium",
56} as const;
27export const NATIVE_REVIEW_DIFF_STYLE = createNativeReviewDiffStyle(
28 resolveMobileCodeSurface(MOBILE_CODE_SURFACE.fontSize),
29);
30
31export function createNativeReviewDiffStyle(codeSurface: ResolvedMobileCodeSurface) {
32 return {
33 rowHeight: codeSurface.rowHeight,
34 contentWidth: NATIVE_REVIEW_DIFF_CONTENT_WIDTH,
35 changeBarWidth: 4,
36 gutterWidth: codeSurface.gutterWidth,
37 codePadding: codeSurface.codePadding,
38 textVerticalInset: codeSurface.textVerticalInset,
39 fileHeaderHeight: 56,
40 fileHeaderHorizontalMargin: 8,
41 fileHeaderVerticalMargin: 6,
42 fileHeaderCornerRadius: 10,
43 fileHeaderHorizontalPadding: 10,
44 fileHeaderPathRightPadding: 118,
45 fileHeaderCountColumnWidth: 38,
46 fileHeaderCountGap: 5,
47 codeFontSize: codeSurface.fontSize,
48 codeFontWeight: "regular",
49 lineNumberFontSize: codeSurface.lineNumberFontSize,
50 lineNumberFontWeight: "regular",
51 hunkFontSize: 11,
52 hunkFontWeight: "medium",
53 fileHeaderFontSize: 11,
54 fileHeaderFontWeight: "semibold",
55 fileHeaderMetaFontSize: 10,
56 fileHeaderMetaFontWeight: "semibold",
57 fileHeaderSubtextFontSize: 11,
58 fileHeaderSubtextFontWeight: "medium",
59 fileHeaderStatusFontSize: 9,
60 fileHeaderStatusFontWeight: "bold",
61 emptyStateFontSize: 12,
62 emptyStateFontWeight: "medium",
63 } as const;
64}
5765
5866export interface NativeReviewDiffData {
5967 readonly rows: ReadonlyArray<NativeReviewDiffRow>;
......@@ -75,6 +83,33 @@ export interface BuildNativeReviewDiffDataInput {
7583 readonly comments?: ReadonlyArray<ReviewInlineComment>;
7684}
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
78113export function createNativeReviewDiffTheme(
79114 scheme: TerminalAppearanceScheme,
80115): NativeReviewDiffTheme {
......@@ -83,10 +118,12 @@ export function createNativeReviewDiffTheme(
83118
84119 if (scheme === "dark") {
85120 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",
87124 text: terminalTheme.foreground,
88125 mutedText: terminalTheme.mutedForeground,
89 headerBackground: terminalTheme.background,
126 headerBackground: "#0e0e0e",
90127 border: terminalTheme.border,
91128 hunkBackground: "#071f28",
92129 hunkText: terminalBlue ?? "#009fff",
......@@ -100,10 +137,12 @@ export function createNativeReviewDiffTheme(
100137 }
101138
102139 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",
104143 text: "#070707",
105144 mutedText: terminalTheme.mutedForeground,
106 headerBackground: "#ffffff",
145 headerBackground: "#f2f2f7",
107146 border: terminalTheme.border,
108147 hunkBackground: "#e0f2ff",
109148 hunkText: terminalBlue ?? "#009fff",
......@@ -430,3 +469,26 @@ export function buildNativeReviewDiffData(
430469 deletions: parsedDiff.deletions,
431470 };
432471}
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({
1313});
1414
1515export const REVIEW_DIFF_LINE_HEIGHT = MOBILE_CODE_SURFACE.rowHeight;
16const REVIEW_DELETE_STRIPE_COUNT = REVIEW_DIFF_LINE_HEIGHT / 2;
1716
1817export function renderVisibleWhitespace(value: string): string {
1918 const expandedTabs = value.replace(/\t/g, " ");
......@@ -38,12 +37,16 @@ function diffHighlightColor(change: ReviewRenderableLineRow["change"]): string |
3837 return undefined;
3938}
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;
4245 if (props.change === "delete") {
4346 return (
44 <View className="w-[5px] overflow-hidden" style={{ height: REVIEW_DIFF_LINE_HEIGHT }}>
47 <View className="w-[5px] overflow-hidden" style={{ height }}>
4548 <View>
46 {Array.from({ length: REVIEW_DELETE_STRIPE_COUNT }, (_, index) => (
49 {Array.from({ length: Math.ceil(height / 2) }, (_, index) => (
4750 <View key={index}>
4851 <View className="w-[5px] bg-rose-400" style={{ height: 1 }} />
4952 <View style={{ height: 1 }} />
......@@ -55,7 +58,7 @@ export function ReviewChangeBar(props: { readonly change: ReviewRenderableLineRo
5558 }
5659
5760 return (
58 <View className="w-[5px] overflow-hidden" style={{ height: REVIEW_DIFF_LINE_HEIGHT }}>
61 <View className="w-[5px] overflow-hidden" style={{ height }}>
5962 <View className={cn("h-full w-[5px] flex-1", changeBarTone(props.change))} />
6063 </View>
6164 );
......@@ -66,7 +69,11 @@ export function DiffTokenText(props: {
6669 readonly fallback: string;
6770 readonly change?: ReviewRenderableLineRow["change"];
6871 readonly className?: string;
72 readonly fontSize?: number;
73 readonly lineHeight?: number;
6974}) {
75 const fontSize = props.fontSize ?? MOBILE_CODE_SURFACE.fontSize;
76 const lineHeight = props.lineHeight ?? MOBILE_CODE_SURFACE.rowHeight;
7077 if (!props.tokens || props.tokens.length === 0) {
7178 return (
7279 <NativeText
......@@ -75,8 +82,8 @@ export function DiffTokenText(props: {
7582 className={cn("font-normal text-foreground", props.className)}
7683 style={{
7784 fontFamily: REVIEW_MONO_FONT_FAMILY,
78 fontSize: MOBILE_CODE_SURFACE.fontSize,
79 lineHeight: MOBILE_CODE_SURFACE.rowHeight,
85 fontSize,
86 lineHeight,
8087 }}
8188 >
8289 {renderVisibleWhitespace(props.fallback || " ")}
......@@ -91,8 +98,8 @@ export function DiffTokenText(props: {
9198 className={cn("font-normal text-foreground", props.className)}
9299 style={{
93100 fontFamily: REVIEW_MONO_FONT_FAMILY,
94 fontSize: MOBILE_CODE_SURFACE.fontSize,
95 lineHeight: MOBILE_CODE_SURFACE.rowHeight,
101 fontSize,
102 lineHeight,
96103 }}
97104 >
98105 {(() => {
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";
22import type { NativeSyntheticEvent } from "react-native";
33
44import { type NativeReviewDiffHighlightScheme } from "../diffs/nativeReviewDiffHighlighter";
5import {
6 createNativeReviewDiffTheme,
7 NATIVE_REVIEW_DIFF_STYLE,
8 type NativeReviewDiffData,
9} from "./nativeReviewDiffAdapter";
5import { createNativeReviewDiffTheme, type NativeReviewDiffData } from "./nativeReviewDiffAdapter";
6import { useAppearanceCodeSurface } from "../settings/appearance/useAppearanceCodeSurface";
107import { useNativeReviewDiffHighlighting } from "./useNativeReviewDiffHighlighting";
8import { buildNativeReviewTokensResetKey } from "./reviewDiffBridgeKeys";
119
12export function hashReviewDiffKey(diff: string | null | undefined): string {
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}
10export { buildNativeReviewTokensResetKey, hashReviewDiffKey } from "./reviewDiffBridgeKeys";
4211
4312export function useNativeReviewDiffBridge(input: {
4413 readonly threadKey: string | null;
......@@ -62,6 +31,7 @@ export function useNativeReviewDiffBridge(input: {
6231 threadKey,
6332 viewedFileIds,
6433 } = input;
34 const { nativeReviewDiffStyle } = useAppearanceCodeSurface();
6535 const [collapsedCommentIds, setCollapsedCommentIds] = useState<ReadonlySet<string>>(
6636 () => new Set(),
6737 );
......@@ -76,7 +46,7 @@ export function useNativeReviewDiffBridge(input: {
7646 [collapsedCommentIds],
7747 );
7848 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]);
8050 const tokensResetKey = useMemo(
8151 () =>
8252 buildNativeReviewTokensResetKey({
apps/mobile/src/features/review/useReviewCommentSelectionController.ts+6-7
......@@ -1,12 +1,11 @@
11import { useCallback, useEffect, useMemo, useState } from "react";
22import type { NativeSyntheticEvent } from "react-native";
3import { useRouter } from "expo-router";
3import { useNavigation } from "@react-navigation/native";
44import * as Arr from "effect/Array";
55import { pipe } from "effect/Function";
66import * as Result from "effect/Result";
77
88import type { EnvironmentId, ThreadId } from "@t3tools/contracts";
9
109import {
1110 buildReviewCommentTarget,
1211 clearReviewCommentTarget,
......@@ -34,7 +33,7 @@ export function useReviewCommentSelectionController(input: {
3433 readonly nativeReviewDiffData: NativeReviewDiffData;
3534}) {
3635 const { environmentId, nativeReviewDiffData, selectedSection, threadId } = input;
37 const { push } = useRouter();
36 const navigation = useNavigation();
3837 const activeCommentTarget = useReviewCommentTarget();
3938 const [pendingNativeCommentSelection, setPendingNativeCommentSelection] =
4039 useState<PendingNativeCommentSelection | null>(null);
......@@ -44,11 +43,11 @@ export function useReviewCommentSelectionController(input: {
4443 return;
4544 }
4645
47 push({
48 pathname: "/threads/[environmentId]/[threadId]/review-comment",
49 params: { environmentId, threadId },
46 navigation.navigate("ThreadReviewComment", {
47 environmentId,
48 threadId,
5049 });
51 }, [environmentId, push, threadId]);
50 }, [environmentId, navigation, threadId]);
5251
5352 const selectedRowIds = useMemo(() => {
5453 if (
apps/mobile/src/features/review/useReviewDiffData.ts+11-9
......@@ -1,11 +1,13 @@
11import { useEffect, useMemo } from "react";
22
33import { countReviewCommentContexts, parseReviewInlineComments } from "./reviewCommentSelection";
4import { buildNativeReviewDiffData } from "./nativeReviewDiffAdapter";
4import { getCachedNativeReviewDiffData } from "./nativeReviewDiffAdapter";
55import { markReviewEvent, measureReviewWork } from "./reviewPerf";
66import { getCachedReviewParsedDiff } from "./reviewState";
77import type { ReviewParsedDiff, ReviewSectionItem } from "./reviewModel";
88
9const EMPTY_INLINE_REVIEW_COMMENTS = Object.freeze([]);
10
911function isReviewDiffDebugLoggingEnabled(): boolean {
1012 return typeof __DEV__ !== "undefined" ? __DEV__ : false;
1113}
......@@ -43,6 +45,7 @@ export function useReviewDiffData(input: {
4345 readonly draftMessage: string;
4446}) {
4547 const { draftMessage, selectedSection, threadKey } = input;
48 const selectedSectionId = selectedSection?.id ?? null;
4649 const parsedDiff = useMemo(
4750 () =>
4851 measureReviewWork("parse-diff", () =>
......@@ -59,17 +62,16 @@ export function useReviewDiffData(input: {
5962 () => parseReviewInlineComments(draftMessage),
6063 [draftMessage],
6164 );
62 const selectedSectionInlineComments = useMemo(
63 () =>
64 selectedSection
65 ? inlineReviewComments.filter((comment) => comment.sectionId === selectedSection.id)
66 : [],
67 [inlineReviewComments, selectedSection],
68 );
65 const selectedSectionInlineComments = useMemo(() => {
66 if (!selectedSectionId || inlineReviewComments.length === 0) {
67 return EMPTY_INLINE_REVIEW_COMMENTS;
68 }
69 return inlineReviewComments.filter((comment) => comment.sectionId === selectedSectionId);
70 }, [inlineReviewComments, selectedSectionId]);
6971 const nativeReviewDiffData = useMemo(
7072 () =>
7173 measureReviewWork("build-native-diff-data", () =>
72 buildNativeReviewDiffData({
74 getCachedNativeReviewDiffData({
7375 parsedDiff,
7476 comments: selectedSectionInlineComments,
7577 }),
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 {
1212
1313import { AppText as Text } from "../../components/AppText";
1414import { MOBILE_TYPOGRAPHY } from "../../lib/typography";
15import { resolveNativeTerminalSurfaceView } from "./nativeTerminalModule";
15import {
16 getNativeTerminalHardwareKeyRevision,
17 resolveNativeTerminalSurfaceView,
18} from "./nativeTerminalModule";
1619import {
1720 buildGhosttyThemeConfig,
1821 getPierreTerminalTheme,
......@@ -92,9 +95,9 @@ const FallbackTerminalSurface = memo(function FallbackTerminalSurface(props: Ter
9295 >
9396 <View style={{ flex: 1, paddingHorizontal: 10, paddingVertical: 8 }}>
9497 <Text
98 className="text-2xs"
9599 style={{
96100 color: theme.mutedForeground,
97 fontSize: MOBILE_TYPOGRAPHY.caption.fontSize,
98101 paddingBottom: 8,
99102 }}
100103 >
......@@ -137,11 +140,11 @@ const FallbackTerminalSurface = memo(function FallbackTerminalSurface(props: Ter
137140 placeholder="type and press return"
138141 placeholderTextColor={theme.mutedForeground}
139142 returnKeyType="send"
143 className="text-sm"
140144 style={{
141145 color: theme.foreground,
142146 flex: 1,
143147 fontFamily: "Menlo",
144 fontSize: MOBILE_TYPOGRAPHY.footnote.fontSize,
145148 padding: 0,
146149 }}
147150 onSubmitEditing={(event) => {
......@@ -163,10 +166,10 @@ const FallbackTerminalSurface = memo(function FallbackTerminalSurface(props: Ter
163166 onPress={() => props.onInput("\u0003")}
164167 >
165168 <Text
169 className="text-2xs"
166170 style={{
167171 color: theme.foreground,
168172 fontFamily: "DMSans_700Bold",
169 fontSize: MOBILE_TYPOGRAPHY.caption.fontSize,
170173 }}
171174 >
172175 Ctrl-C
......@@ -179,7 +182,6 @@ const FallbackTerminalSurface = memo(function FallbackTerminalSurface(props: Ter
179182
180183export const TerminalSurface = memo(function TerminalSurface(props: TerminalSurfaceProps) {
181184 const fontSize = props.fontSize ?? MOBILE_TYPOGRAPHY.label.fontSize;
182 const keyboardInputRef = useRef<TextInput>(null);
183185 const appearanceScheme = useColorScheme() === "light" ? "light" : "dark";
184186 const theme = props.theme ?? getPierreTerminalTheme(appearanceScheme);
185187 const { onInput, onResize } = props;
......@@ -190,15 +192,23 @@ export const TerminalSurface = memo(function TerminalSurface(props: TerminalSurf
190192 terminalDebugLog("native:surface", {
191193 terminalKey: props.terminalKey,
192194 native: hasNativeSurface,
195 // null = installed binary predates native hardware-key handling (rebuild needed).
196 hardwareKeyRevision: getNativeTerminalHardwareKeyRevision(),
193197 bufferLen: props.buffer.length,
194198 isRunning: props.isRunning,
195199 });
196200 }, [hasNativeSurface, props.buffer.length, props.isRunning, props.terminalKey]);
197201 const handleNativeInput = useCallback(
198202 (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 });
199209 onInput(event.nativeEvent.data);
200210 },
201 [onInput],
211 [onInput, props.isRunning],
202212 );
203213 const handleNativeResize = useCallback(
204214 (event: NativeSyntheticEvent<TerminalResizeEvent>) => {
......@@ -210,33 +220,13 @@ export const TerminalSurface = memo(function TerminalSurface(props: TerminalSurf
210220 [onResize],
211221 );
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
234223 if (NativeTerminalSurfaceView) {
235224 return (
236225 <View style={props.style}>
237226 <NativeTerminalSurfaceView
238227 appearanceScheme={appearanceScheme}
239228 backgroundColor={theme.background}
229 focusRequest={props.isRunning ? (props.keyboardFocusRequest ?? 0) : 0}
240230 foregroundColor={theme.foreground}
241231 mutedForegroundColor={theme.mutedForeground}
242232 terminalKey={props.terminalKey}
......@@ -247,23 +237,6 @@ export const TerminalSurface = memo(function TerminalSurface(props: TerminalSurf
247237 onInput={handleNativeInput}
248238 onResize={handleNativeResize}
249239 />
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 />
267240 </View>
268241 );
269242 }
apps/mobile/src/features/terminal/ThreadTerminalRouteScreen.tsx+98-165
......@@ -1,9 +1,10 @@
11import { DEFAULT_TERMINAL_ID, EnvironmentId, ThreadId } from "@t3tools/contracts";
22import { type KnownTerminalSession } from "@t3tools/client-runtime/state/terminal";
33import { 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";
56import { 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";
78import {
89 KeyboardController,
910 KeyboardEvents,
......@@ -24,8 +25,13 @@ import { useEnvironmentPresentation } from "../../state/presentation";
2425import { terminalEnvironment } from "../../state/terminal";
2526import { useAtomCommand } from "../../state/use-atom-command";
2627import { useWorkspaceState } from "../../state/workspace";
27import { buildThreadTerminalNavigation } from "../../lib/routes";
28import { MOBILE_TYPOGRAPHY } from "../../lib/typography";
28import {
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";
2935import {
3036 useAttachedTerminalSession,
3137 useKnownTerminalSessions,
......@@ -33,9 +39,9 @@ import {
3339import { useThreadSelection } from "../../state/use-thread-selection";
3440import { useSelectedThreadDetail } from "../../state/use-thread-detail";
3541import { EnvironmentConnectionNotice } from "../connection/EnvironmentConnectionNotice";
42import { useAdaptiveWorkspaceLayout } from "../layout/AdaptiveWorkspaceLayout";
3643import { TerminalSurface } from "./NativeTerminalSurface";
3744import { getPierreTerminalTheme } from "./terminalTheme";
38import { loadPreferences, savePreferencesPatch } from "../../lib/storage";
3945import { terminalDebugLog } from "./terminalDebugLog";
4046import {
4147 getTerminalBufferReplayKey,
......@@ -55,19 +61,7 @@ import {
5561 resolveTerminalSessionLabel,
5662 type TerminalMenuSession,
5763} from "./terminalMenu";
58import {
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";
64import { cacheTerminalGridSize, getCachedTerminalGridSize } from "./terminalUiState";
7165
7266const DEFAULT_TERMINAL_COLS = 80;
7367const DEFAULT_TERMINAL_ROWS = 24;
......@@ -153,19 +147,22 @@ function pickRunningTerminalSessionForBootstrap(
153147 );
154148}
155149
156export function ThreadTerminalRouteScreen() {
157 const router = useRouter();
150type ThreadTerminalRouteScreenProps = StaticScreenProps<{
151 readonly environmentId: string;
152 readonly threadId: string;
153 readonly terminalId?: string;
154}>;
155
156export function ThreadTerminalRouteScreen(props: ThreadTerminalRouteScreenProps) {
157 const navigation = useNavigation();
158158 const writeTerminal = useAtomCommand(terminalEnvironment.write, "terminal write");
159159 const resizeTerminal = useAtomCommand(terminalEnvironment.resize, "terminal resize");
160160 const clearTerminal = useAtomCommand(terminalEnvironment.clear, "terminal clear");
161161 const retryEnvironment = useAtomCommand(environmentCatalog.retryNow, "environment retry");
162162 const appearanceScheme = useColorScheme() === "light" ? "light" : "dark";
163163 const { state: workspaceState } = useWorkspaceState();
164 const params = useLocalSearchParams<{
165 environmentId?: string | string[];
166 threadId?: string | string[];
167 terminalId?: string | string[];
168 }>();
164 const { layout, panes, togglePrimarySidebar } = useAdaptiveWorkspaceLayout();
165 const params = props.route.params;
169166 const { selectedThread, selectedThreadProject, selectedEnvironmentConnection } =
170167 useThreadSelection();
171168 const selectedThreadDetail = useSelectedThreadDetail();
......@@ -179,7 +176,12 @@ export function ThreadTerminalRouteScreen() {
179176 const isEnvironmentReady = environment.presentation?.connection.phase === "connected";
180177 const requestedTerminalId = firstRouteParam(params.terminalId);
181178 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;
183185 const cachedRouteGridSize =
184186 routeEnvironmentId && routeThreadId
185187 ? getCachedTerminalGridSize({
......@@ -239,7 +241,6 @@ export function ThreadTerminalRouteScreen() {
239241 rows: DEFAULT_TERMINAL_ROWS,
240242 },
241243 );
242 const [fontSize, setFontSize] = useState(cachedFontSize ?? DEFAULT_TERMINAL_FONT_SIZE);
243244 const [keyboardFocusRequest, setKeyboardFocusRequest] = useState(0);
244245 const [isAccessoryDismissed, setIsAccessoryDismissed] = useState(false);
245246 const bufferReplayTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
......@@ -247,9 +248,6 @@ export function ThreadTerminalRouteScreen() {
247248 const lastBufferReplayKeyRef = useRef<string | null>(null);
248249 const sentInitialInputKeyRef = useRef<string | null>(null);
249250 const [readyBufferReplayKey, setReadyBufferReplayKey] = useState<string | null>(null);
250 const [hasResolvedFontPreference, setHasResolvedFontPreference] = useState(
251 cachedFontSize !== null,
252 );
253251 /** Default grid is always valid for attach; onResize refines cols/rows. Requiring a cached size blocked bootstrap for new terminal routes. */
254252 const [hasMeasuredSurface, setHasMeasuredSurface] = useState(true);
255253 const [pendingModifierState, setPendingModifierState] = useState<{
......@@ -404,24 +402,10 @@ export function ThreadTerminalRouteScreen() {
404402 );
405403
406404 const terminalTheme = getPierreTerminalTheme(appearanceScheme);
405 const usesNativeHeaderGlass = Platform.OS === "ios";
407406 const pendingModifier =
408407 pendingModifierState.terminalId === terminalId ? pendingModifierState.value : null;
409 const headerTitle = useMemo(() => {
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 ]);
408 const headerSubtitle = selectedThreadProject?.title ?? "";
425409 const terminalToolbarActions = useMemo<ReadonlyArray<TerminalToolbarAction>>(() => {
426410 const modifierActions: ReadonlyArray<TerminalToolbarAction> =
427411 hostPlatform === "mac"
......@@ -546,8 +530,14 @@ export function ThreadTerminalRouteScreen() {
546530 if (!shouldRedirectToRunningTerminal || !selectedThread || !runningSession) {
547531 return;
548532 }
549 router.replace(buildThreadTerminalNavigation(selectedThread, runningSession.target.terminalId));
550 }, [router, runningSession, selectedThread, shouldRedirectToRunningTerminal]);
533 navigation.dispatch(
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
552542 useEffect(() => {
553543 const initialInput = pendingLaunch?.initialInput;
......@@ -637,42 +627,6 @@ export function ThreadTerminalRouteScreen() {
637627 setHasMeasuredSurface(true);
638628 }, [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
676630 const writeInput = useCallback(
677631 (data: string) => {
678632 if (!selectedThread || !isRunning) {
......@@ -772,9 +726,15 @@ export function ThreadTerminalRouteScreen() {
772726 return;
773727 }
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 );
776736 },
777 [router, selectedThread, terminalId],
737 [navigation, selectedThread, terminalId],
778738 );
779739
780740 const handleOpenNewTerminal = useCallback(() => {
......@@ -782,30 +742,25 @@ export function ThreadTerminalRouteScreen() {
782742 return;
783743 }
784744
785 router.replace(
786 buildThreadTerminalNavigation(
787 selectedThread,
788 nextOpenTerminalId({
745 navigation.dispatch(
746 StackActions.replace("ThreadTerminal", {
747 environmentId: String(selectedThread.environmentId),
748 threadId: String(selectedThread.id),
749 terminalId: nextOpenTerminalId({
789750 listedTerminalIds: terminalMenuSessions.map((session) => session.terminalId),
790751 activeRouteTerminalId: terminalId,
791752 }),
792 ),
753 }),
793754 );
794 }, [router, selectedThread, terminalId, terminalMenuSessions]);
795
796 const adjustFontSize = useCallback((delta: number) => {
797 setTimeout(() => {
798 setFontSize((current) => cacheTerminalFontSize(current + delta));
799 }, 0);
800 }, []);
755 }, [navigation, selectedThread, terminalId, terminalMenuSessions]);
801756
802757 const handleDecreaseFontSize = useCallback(() => {
803 adjustFontSize(-TERMINAL_FONT_SIZE_STEP);
804 }, [adjustFontSize]);
758 setTerminalFontSize(stepTerminalFontSize(fontSize, -1));
759 }, [fontSize, setTerminalFontSize]);
805760
806761 const handleIncreaseFontSize = useCallback(() => {
807 adjustFontSize(TERMINAL_FONT_SIZE_STEP);
808 }, [adjustFontSize]);
762 setTerminalFontSize(stepTerminalFontSize(fontSize, 1));
763 }, [fontSize, setTerminalFontSize]);
809764
810765 const handleClearTerminal = useCallback(() => {
811766 if (!selectedThread) {
......@@ -898,80 +853,58 @@ export function ThreadTerminalRouteScreen() {
898853
899854 return (
900855 <>
901 <Stack.Screen
856 <NativeStackScreenOptions
902857 options={{
903 headerShown: true,
904 headerBackButtonDisplayMode: "minimal",
905 headerBackTitle: "",
906 headerShadowVisible: false,
907 headerStyle: { backgroundColor: terminalTheme.background },
908 headerTintColor: terminalTheme.foreground,
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 ),
858 // Static header config lives in Stack.tsx (SOLID_HEADER_OPTIONS — the pty
859 // scrolls internally, nothing for glass to sample). Default title/subtitle
860 // styling, like every other page.
861 title: "Terminal",
862 unstable_headerSubtitle:
863 usesNativeHeaderGlass && headerSubtitle.length > 0 ? headerSubtitle : undefined,
944864 }}
945865 />
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
947880 {isEnvironmentReady ? (
948 <Stack.Toolbar placement="right">
949 <Stack.Toolbar.Menu icon="terminal" title="Terminal options" separateBackground>
950 <Stack.Toolbar.Label>
881 <NativeHeaderToolbar placement="right">
882 <NativeHeaderToolbar.Menu icon="terminal" title="Terminal options" separateBackground>
883 <NativeHeaderToolbar.Label>
951884 {getTerminalStatusLabel({
952885 status: terminal.status,
953886 hasRunningSubprocess: terminal.hasRunningSubprocess,
954887 })}
955 </Stack.Toolbar.Label>
956 <Stack.Toolbar.Menu icon="textformat.size" inline title="Text size">
957 <Stack.Toolbar.Label>Text size</Stack.Toolbar.Label>
958 <Stack.Toolbar.MenuAction
888 </NativeHeaderToolbar.Label>
889 <NativeHeaderToolbar.Menu icon="textformat.size" inline title="Text size">
890 <NativeHeaderToolbar.Label>Text size</NativeHeaderToolbar.Label>
891 <NativeHeaderToolbar.MenuAction
959892 disabled={fontSize <= MIN_TERMINAL_FONT_SIZE}
960893 discoverabilityLabel="Decrease terminal text size"
961894 onPress={handleDecreaseFontSize}
962895 >
963 <Stack.Toolbar.Label>{`A- ${Math.max(MIN_TERMINAL_FONT_SIZE, fontSize - TERMINAL_FONT_SIZE_STEP).toFixed(1)} pt`}</Stack.Toolbar.Label>
964 </Stack.Toolbar.MenuAction>
965 <Stack.Toolbar.MenuAction
896 <NativeHeaderToolbar.Label>{`A- ${Math.max(MIN_TERMINAL_FONT_SIZE, fontSize - TERMINAL_FONT_SIZE_STEP).toFixed(1)} pt`}</NativeHeaderToolbar.Label>
897 </NativeHeaderToolbar.MenuAction>
898 <NativeHeaderToolbar.MenuAction
966899 disabled={fontSize >= MAX_TERMINAL_FONT_SIZE}
967900 discoverabilityLabel="Increase terminal text size"
968901 onPress={handleIncreaseFontSize}
969902 >
970 <Stack.Toolbar.Label>{`A+ ${Math.min(MAX_TERMINAL_FONT_SIZE, fontSize + TERMINAL_FONT_SIZE_STEP).toFixed(1)} pt`}</Stack.Toolbar.Label>
971 </Stack.Toolbar.MenuAction>
972 </Stack.Toolbar.Menu>
903 <NativeHeaderToolbar.Label>{`A+ ${Math.min(MAX_TERMINAL_FONT_SIZE, fontSize + TERMINAL_FONT_SIZE_STEP).toFixed(1)} pt`}</NativeHeaderToolbar.Label>
904 </NativeHeaderToolbar.MenuAction>
905 </NativeHeaderToolbar.Menu>
973906 {terminalMenuSessions.map((session) => (
974 <Stack.Toolbar.MenuAction
907 <NativeHeaderToolbar.MenuAction
975908 key={session.terminalId}
976909 icon={session.terminalId === terminalId ? "checkmark" : "terminal"}
977910 onPress={() => handleSelectTerminal(session.terminalId)}
......@@ -982,18 +915,18 @@ export function ThreadTerminalRouteScreen() {
982915 .filter(Boolean)
983916 .join(" · ")}
984917 >
985 <Stack.Toolbar.Label>{session.displayLabel}</Stack.Toolbar.Label>
986 </Stack.Toolbar.MenuAction>
918 <NativeHeaderToolbar.Label>{session.displayLabel}</NativeHeaderToolbar.Label>
919 </NativeHeaderToolbar.MenuAction>
987920 ))}
988 <Stack.Toolbar.MenuAction
921 <NativeHeaderToolbar.MenuAction
989922 icon="plus"
990923 onPress={handleOpenNewTerminal}
991924 subtitle={`Start another shell in ${basename(selectedThreadProject.workspaceRoot) ?? "this workspace"}`}
992925 >
993 <Stack.Toolbar.Label>Open new terminal</Stack.Toolbar.Label>
994 </Stack.Toolbar.MenuAction>
995 </Stack.Toolbar.Menu>
996 </Stack.Toolbar>
926 <NativeHeaderToolbar.Label>Open new terminal</NativeHeaderToolbar.Label>
927 </NativeHeaderToolbar.MenuAction>
928 </NativeHeaderToolbar.Menu>
929 </NativeHeaderToolbar>
997930 ) : null}
998931
999932 <View style={{ flex: 1, backgroundColor: terminalTheme.background }}>
apps/mobile/src/features/terminal/nativeTerminalModule.ts+21-1
......@@ -1,6 +1,6 @@
11import type { ComponentType } from "react";
22import type { NativeSyntheticEvent, ViewProps } from "react-native";
3import { requireNativeView } from "expo";
3import { requireNativeView, requireOptionalNativeModule } from "expo";
44
55import { NativeViewResolutionError } from "../../native/nativeViewResolutionError";
66
......@@ -23,6 +23,7 @@ interface TerminalResizeEvent {
2323
2424export interface NativeTerminalSurfaceProps extends ViewProps {
2525 readonly appearanceScheme?: "light" | "dark";
26 readonly focusRequest?: number;
2627 readonly themeConfig?: string;
2728 readonly backgroundColor?: string;
2829 readonly foregroundColor?: string;
......@@ -74,6 +75,25 @@ export function resolveNativeTerminalSurfaceView(): ComponentType<NativeTerminal
7475 return cachedNativeTerminalSurfaceView ?? null;
7576}
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
7797export function hasNativeTerminalSurface() {
7898 return resolveNativeTerminalSurfaceView() !== null;
7999}
apps/mobile/src/features/terminal/terminalPreferences.ts+1-1
......@@ -1,4 +1,4 @@
1export const DEFAULT_TERMINAL_FONT_SIZE = 10;
1export const DEFAULT_TERMINAL_FONT_SIZE = 10.5;
22export const TERMINAL_FONT_SIZE_STEP = 0.5;
33export const MIN_TERMINAL_FONT_SIZE = 6;
44export 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
77
88import { AppText as Text } from "../../components/AppText";
99import { PierreEntryIcon } from "../../components/PierreEntryIcon";
10import { MOBILE_TYPOGRAPHY } from "../../lib/typography";
11
1210export type ComposerCommandItem =
1311 | {
1412 readonly id: string;
......@@ -165,11 +163,11 @@ const CommandRow = memo(function CommandRow(props: {
165163 </Text>
166164 {props.item.description ? (
167165 <Text
166 className="text-xs"
168167 numberOfLines={1}
169168 style={{
170169 flex: 1,
171170 minWidth: 0,
172 fontSize: MOBILE_TYPOGRAPHY.label.fontSize,
173171 color: "#a1a1aa",
174172 }}
175173 >
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 @@
1import { Stack, useRouter } from "expo-router";
1import { NativeStackScreenOptions } from "../../native/StackHeader";
2import { StackActions, useNavigation } from "@react-navigation/native";
23import { useCallback, useEffect, useMemo, useRef } from "react";
34import { Alert, InteractionManager, View, useColorScheme } from "react-native";
45import { KeyboardAvoidingView, useKeyboardState } from "react-native-keyboard-controller";
......@@ -29,9 +30,8 @@ import {
2930 providerOptionsConfigurationLabel,
3031 resolveProviderOptionDescriptors,
3132} from "../../lib/providerOptions";
32import { buildThreadRoutePath } from "../../lib/routes";
3333import { scopedProjectKey } from "../../lib/scopedEntities";
34import { MOBILE_TYPOGRAPHY } from "../../lib/typography";
34import { useScaledTextRole } from "../settings/appearance/useScaledTextRole";
3535import { getComposerDraftSnapshot } from "../../state/use-composer-drafts";
3636import { useProjects } from "../../state/entities";
3737import { branchBadgeLabel, useNewTaskFlow } from "./new-task-flow-provider";
......@@ -58,7 +58,7 @@ export function NewTaskDraftScreen(props: {
5858 const projects = useProjects();
5959 const createProjectThread = useCreateProjectThread();
6060 const flow = useNewTaskFlow();
61 const router = useRouter();
61 const navigation = useNavigation();
6262 const insets = useSafeAreaInsets();
6363 const colorScheme = useColorScheme();
6464 const isKeyboardVisible = useKeyboardState((state) => state.isVisible);
......@@ -68,6 +68,7 @@ export function NewTaskDraftScreen(props: {
6868 const loadedBranchesProjectKeyRef = useRef<string | null>(null);
6969
7070 const borderColor = useThemeColor("--color-border");
71 const bodyText = useScaledTextRole("body");
7172 const sheetFadeOpaque = colorScheme === "dark" ? "rgba(14,14,14,0.98)" : "rgba(242,242,247,0.98)";
7273 const sheetFadeTransparent = colorScheme === "dark" ? "rgba(14,14,14,0)" : "rgba(242,242,247,0)";
7374
......@@ -101,13 +102,13 @@ export function NewTaskDraftScreen(props: {
101102 return;
102103 }
103104
104 router.replace("/new");
105 navigation.dispatch(StackActions.replace("NewTask"));
105106 }, [
106107 logicalProjects,
107108 projects,
108109 props.initialProjectRef?.environmentId,
109110 props.initialProjectRef?.projectId,
110 router,
111 navigation,
111112 selectedProject,
112113 setProject,
113114 ]);
......@@ -429,20 +430,25 @@ export function NewTaskDraftScreen(props: {
429430
430431 flow.setPrompt("");
431432 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 );
433439 }
434440
435441 if (!selectedProject) {
436442 return (
437443 <View className="flex-1 bg-sheet">
438 <Stack.Screen options={{ title: "Loading task" }} />
444 <NativeStackScreenOptions options={{ title: "Loading task" }} />
439445 </View>
440446 );
441447 }
442448
443449 return (
444450 <View className="flex-1 bg-sheet">
445 <Stack.Screen options={{ title: selectedProject.title }} />
451 <NativeStackScreenOptions options={{ title: selectedProject.title }} />
446452
447453 <KeyboardAvoidingView automaticOffset behavior="padding" style={{ flex: 1 }}>
448454 <View style={{ flex: 1, minHeight: 0, paddingHorizontal: 20, paddingTop: 8 }}>
......@@ -457,7 +463,7 @@ export function NewTaskDraftScreen(props: {
457463 onPasteImages={(uris) => void handleNativePasteImages(uris)}
458464 placeholder={`Describe a coding task in ${selectedProject.title}`}
459465 style={{ flex: 1, minHeight: 0 }}
460 textStyle={MOBILE_TYPOGRAPHY.composer}
466 textStyle={bodyText}
461467 />
462468 </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) {
2323 {props.approval.requestKind}
2424 </Text>
2525 {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">
2727 {props.approval.detail}
2828 </Text>
2929 ) : null}
apps/mobile/src/features/threads/PendingUserInputCard.tsx+1-1
......@@ -39,7 +39,7 @@ export function PendingUserInputCard(props: PendingUserInputCardProps) {
3939 <Text className="font-t3-bold text-xs uppercase tracking-[1px] text-neutral-500 dark:text-neutral-500">
4040 {question.header}
4141 </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">
4343 {question.question}
4444 </Text>
4545 <View className="flex-row flex-wrap gap-2.5">
apps/mobile/src/features/threads/ThreadComposer.tsx+51-17
......@@ -26,6 +26,7 @@ import {
2626} from "react-native";
2727import ImageViewing from "react-native-image-viewing";
2828import { useThemeColor } from "../../lib/useThemeColor";
29import { scopedThreadKey } from "../../lib/scopedEntities";
2930
3031import { AppText as Text } from "../../components/AppText";
3132import { ComposerAttachmentStrip } from "../../components/ComposerAttachmentStrip";
......@@ -44,7 +45,7 @@ import { ControlPill, ControlPillMenu } from "../../components/ControlPill";
4445import { ProviderIcon } from "../../components/ProviderIcon";
4546import type { DraftComposerImageAttachment } from "../../lib/composerImages";
4647import { buildModelOptions, groupByProvider } from "../../lib/modelOptions";
47import { MOBILE_TYPOGRAPHY } from "../../lib/typography";
48import { useScaledTextRole } from "../settings/appearance/useScaledTextRole";
4849import type { RemoteClientConnectionState } from "../../lib/connection";
4950import {
5051 insertRankedSearchResult,
......@@ -76,10 +77,17 @@ export interface ThreadComposerProps {
7677 readonly draftMessage: string;
7778 readonly draftAttachments: ReadonlyArray<DraftComposerImageAttachment>;
7879 readonly placeholder: string;
80 readonly contentMaxWidth?: number;
7981 readonly bottomInset?: number;
8082 readonly connectionState: RemoteClientConnectionState;
8183 readonly connectionError: string | null;
8284 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;
8391 readonly selectedThread: OrchestrationThreadShell;
8492 readonly serverConfig: T3ServerConfig | null;
8593 readonly queueCount: number;
......@@ -139,11 +147,17 @@ function ComposerSurface(props: {
139147 );
140148}
141149
150type ComposerStatusPillState = {
151 readonly kind: "unavailable" | "reconnecting" | "syncing";
152 readonly label: string;
153};
154
142155function composerConnectionStatus(input: {
143156 readonly connectionError: string | null;
144157 readonly connectionState: RemoteClientConnectionState;
145158 readonly environmentLabel: string | null;
146}): { readonly kind: "unavailable" | "reconnecting"; readonly label: string } | null {
159 readonly threadSyncPhase?: "loading" | "syncing" | null;
160}): ComposerStatusPillState | null {
147161 const environmentLabel = input.environmentLabel ?? "Environment";
148162
149163 switch (input.connectionState) {
......@@ -168,15 +182,27 @@ function composerConnectionStatus(input: {
168182 case "available":
169183 return { kind: "unavailable", label: `${environmentLabel} is not connected` };
170184 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:
171197 return null;
172198 }
173199}
174200
175201const ComposerConnectionStatusPill = memo(function ComposerConnectionStatusPill(props: {
176202 readonly onPress: () => void;
177 readonly status: { readonly kind: "unavailable" | "reconnecting"; readonly label: string };
203 readonly status: ComposerStatusPillState;
178204}) {
179 const isReconnecting = props.status.kind === "reconnecting";
205 const isReconnecting = props.status.kind !== "unavailable";
180206
181207 return (
182208 <View className="items-center pb-2">
......@@ -191,7 +217,7 @@ const ComposerConnectionStatusPill = memo(function ComposerConnectionStatusPill(
191217 <View className="h-2 w-2 rounded-full bg-red-500" />
192218 )}
193219 <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"
195221 numberOfLines={1}
196222 >
197223 {props.status.label}
......@@ -204,10 +230,12 @@ const ComposerConnectionStatusPill = memo(function ComposerConnectionStatusPill(
204230export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposerProps) {
205231 const isDarkMode = useColorScheme() === "dark";
206232 const foregroundColor = useThemeColor("--color-foreground");
233 const bodyText = useScaledTextRole("body");
207234 const fallbackInputRef = useRef<ComposerEditorHandle>(null);
208235 const inputRef = props.editorRef ?? fallbackInputRef;
209236 const [isFocused, setIsFocused] = useState(false);
210237 const wasExpandedBeforePreviewRef = useRef(false);
238 const inFlightThreadIdsRef = useRef(new Set<string>());
211239 const { onExpandedChange } = props;
212240
213241 const [previewImageUri, setPreviewImageUri] = useState<string | null>(null);
......@@ -254,6 +282,7 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer
254282 connectionError: props.connectionError,
255283 connectionState: props.connectionState,
256284 environmentLabel: props.environmentLabel,
285 threadSyncPhase: props.threadSyncPhase,
257286 });
258287 const toolbarFadeOpaque = isDarkMode ? "rgba(0,0,0,0.95)" : "rgba(255,255,255,0.95)";
259288 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
447476 // ── Handle command selection ──────────────────────────────
448477 const { onChangeDraftMessage, onUpdateInteractionMode, draftMessage, onSendMessage } = props;
449478
450 const handleSend = useCallback(() => {
451 void onSendMessage();
452 }, [onSendMessage]);
479 const handleSend = useCallback(async () => {
480 const threadKey = scopedThreadKey(props.environmentId, props.selectedThread.id);
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]);
453489 const handleCommandSelect = useCallback(
454490 (item: ComposerCommandItem) => {
455491 if (!composerTrigger) return;
......@@ -629,7 +665,10 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer
629665 : "linear-gradient(to bottom, rgba(255,255,255,0) 0%, rgba(255,255,255,0.85) 40%, rgba(255,255,255,0.95) 100%)",
630666 }}
631667 >
632 <View className="w-full" style={{ position: "relative" }}>
668 <View
669 className="w-full"
670 style={{ alignSelf: "center", maxWidth: props.contentMaxWidth, position: "relative" }}
671 >
633672 {composerTrigger && composerMenuItems.length > 0 ? (
634673 <View
635674 style={{
......@@ -702,6 +741,7 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer
702741 placeholder={props.placeholder}
703742 onFocus={handleFocus}
704743 onBlur={handleBlur}
744 onSubmit={handleSend}
705745 scrollEnabled={isExpanded}
706746 contentInsetVertical={isExpanded ? 0 : 6}
707747 style={
......@@ -717,7 +757,7 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer
717757 }
718758 }
719759 textStyle={{
720 ...MOBILE_TYPOGRAPHY.composer,
760 ...bodyText,
721761 color: foregroundColor,
722762 fontFamily: "DMSans_400Regular",
723763 }}
......@@ -827,13 +867,7 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer
827867
828868 {/* Queue count */}
829869 {props.queueCount > 0 ? (
830 <Text
831 className="text-foreground-muted"
832 style={{
833 ...MOBILE_TYPOGRAPHY.label,
834 paddingTop: 8,
835 }}
836 >
870 <Text className="text-xs text-foreground-muted" style={{ paddingTop: 8 }}>
837871 {props.queueCount} queued message{props.queueCount === 1 ? "" : "s"} will send
838872 automatically.
839873 </Text>
apps/mobile/src/features/threads/ThreadDetailScreen.tsx+62-31
......@@ -1,4 +1,5 @@
11import { type EnvironmentConnectionPhase } from "@t3tools/client-runtime/connection";
2import type { EnvironmentThreadStatus } from "@t3tools/client-runtime/state/threads";
23import { useKeyboardChatComposerInset, useKeyboardScrollToEnd } from "@legendapp/list/keyboard";
34import type { LegendListRef } from "@legendapp/list/react-native";
45import type {
......@@ -15,7 +16,6 @@ import type {
1516} from "@t3tools/contracts";
1617import { formatElapsed } from "@t3tools/shared/orchestrationTiming";
1718import * as Haptics from "expo-haptics";
18import { useHeaderHeight } from "expo-router/build/react-navigation/elements";
1919import { memo, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
2020import { View, type GestureResponderEvent } from "react-native";
2121import { Gesture, GestureDetector } from "react-native-gesture-handler";
......@@ -27,7 +27,7 @@ import { AppText as Text } from "../../components/AppText";
2727import type { ComposerEditorHandle } from "../../components/ComposerEditor";
2828import type { StatusTone } from "../../components/StatusPill";
2929import type { DraftComposerImageAttachment } from "../../lib/composerImages";
30import type { LayoutVariant } from "../../lib/layout";
30import { CHAT_CONTENT_MAX_WIDTH, type LayoutVariant } from "../../lib/layout";
3131import { scopedThreadKey } from "../../lib/scopedEntities";
3232import type {
3333 PendingApproval,
......@@ -62,6 +62,8 @@ export interface ThreadDetailScreenProps {
6262 readonly draftMessage: string;
6363 readonly draftAttachments: ReadonlyArray<DraftComposerImageAttachment>;
6464 readonly connectionStateLabel: EnvironmentConnectionPhase;
65 /** Message sync status for the selected thread (drives the composer status pill). */
66 readonly threadSyncStatus?: EnvironmentThreadStatus;
6567 readonly activeThreadBusy: boolean;
6668 readonly environmentId: EnvironmentId;
6769 readonly projectWorkspaceRoot: string | null;
......@@ -69,6 +71,8 @@ export interface ThreadDetailScreenProps {
6971 readonly selectedThreadQueueCount: number;
7072 readonly serverConfig: T3ServerConfig | null;
7173 readonly layoutVariant?: LayoutVariant;
74 readonly usesAutomaticContentInsets?: boolean;
75 readonly onHeaderMaterialVisibilityChange?: (visible: boolean) => void;
7276 readonly onOpenDrawer: () => void;
7377 readonly onOpenConnectionEditor: () => void;
7478 readonly onChangeDraftMessage: (value: string) => void;
......@@ -207,7 +211,6 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread
207211 const { onOpenDrawer } = props;
208212
209213 const insets = useSafeAreaInsets();
210 const headerHeight = useHeaderHeight();
211214 const agentLabel = `${props.selectedThread.modelSelection.instanceId} agent`;
212215 const selectedThreadKey = scopedThreadKey(props.environmentId, props.selectedThread.id);
213216 const composerEditorRef = useRef<ComposerEditorHandle>(null);
......@@ -220,6 +223,22 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread
220223 const [anchorMessageId, setAnchorMessageId] = useState<MessageId | null>(null);
221224 const composerBottomInset = composerExpanded ? 0 : Math.max(insets.bottom, 12);
222225 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 })();
223242 const selectedThreadFeed = props.selectedThreadFeed;
224243 const composerChrome = composerExpanded ? COMPOSER_EXPANDED_CHROME : COMPOSER_COLLAPSED_CHROME;
225244 const composerOverlapHeight = composerChrome + composerBottomInset;
......@@ -234,6 +253,7 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread
234253 const showContent = props.showContent ?? true;
235254 const layoutVariant = props.layoutVariant ?? "compact";
236255 const isSplitLayout = layoutVariant === "split";
256 const contentMaxWidth = isSplitLayout ? CHAT_CONTENT_MAX_WIDTH : undefined;
237257 const selectedInstanceId = props.selectedThread.modelSelection.instanceId;
238258 useStreamingHaptics(props.selectedThread.id, props.selectedThreadFeed);
239259 const selectedProviderSkills = useMemo(
......@@ -382,9 +402,12 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread
382402 freeze={freeze}
383403 anchorMessageId={anchorMessageId}
384404 contentInsetEndAdjustment={contentInsetEndAdjustment}
385 contentTopInset={headerHeight}
405 contentTopInset={0}
386406 contentBottomInset={estimatedOverlayHeight}
407 contentMaxWidth={contentMaxWidth}
387408 layoutVariant={layoutVariant}
409 usesAutomaticContentInsets={props.usesAutomaticContentInsets}
410 onHeaderMaterialVisibilityChange={props.onHeaderMaterialVisibilityChange}
388411 skills={selectedProviderSkills}
389412 />
390413 </View>
......@@ -398,42 +421,50 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread
398421 style={{ position: "absolute", bottom: 0, left: 0, right: 0 }}
399422 offset={{ closed: 0, opened: 0 }}
400423 >
401 <View ref={composerOverlayRef} onLayout={onComposerLayout} style={{ paddingTop: 8 }}>
402 {props.activeWorkStartedAt ? (
403 <WorkingDurationPill startedAt={props.activeWorkStartedAt} />
404 ) : null}
405
406 {props.activePendingApproval || props.activePendingUserInput ? (
407 <View className="gap-3 px-4 pb-3" style={{ flexShrink: 0 }}>
408 {props.activePendingApproval ? (
409 <PendingApprovalCard
410 approval={props.activePendingApproval}
411 respondingApprovalId={props.respondingApprovalId}
412 onRespond={props.onRespondToApproval}
413 />
414 ) : null}
415 {props.activePendingUserInput ? (
416 <PendingUserInputCard
417 pendingUserInput={props.activePendingUserInput}
418 drafts={props.activePendingUserInputDrafts}
419 answers={props.activePendingUserInputAnswers}
420 respondingUserInputId={props.respondingUserInputId}
421 onSelectOption={props.onSelectUserInputOption}
422 onChangeCustomAnswer={props.onChangeUserInputCustomAnswer}
423 onSubmit={props.onSubmitUserInput}
424 />
425 ) : null}
426 </View>
427 ) : null}
424 <View
425 ref={composerOverlayRef}
426 onLayout={onComposerLayout}
427 style={{ width: "100%", paddingTop: 8 }}
428 >
429 <View style={{ alignSelf: "center", maxWidth: contentMaxWidth, width: "100%" }}>
430 {props.activeWorkStartedAt ? (
431 <WorkingDurationPill startedAt={props.activeWorkStartedAt} />
432 ) : null}
433
434 {props.activePendingApproval || props.activePendingUserInput ? (
435 <View className="gap-3 px-4 pb-3" style={{ flexShrink: 0 }}>
436 {props.activePendingApproval ? (
437 <PendingApprovalCard
438 approval={props.activePendingApproval}
439 respondingApprovalId={props.respondingApprovalId}
440 onRespond={props.onRespondToApproval}
441 />
442 ) : null}
443 {props.activePendingUserInput ? (
444 <PendingUserInputCard
445 pendingUserInput={props.activePendingUserInput}
446 drafts={props.activePendingUserInputDrafts}
447 answers={props.activePendingUserInputAnswers}
448 respondingUserInputId={props.respondingUserInputId}
449 onSelectOption={props.onSelectUserInputOption}
450 onChangeCustomAnswer={props.onChangeUserInputCustomAnswer}
451 onSubmit={props.onSubmitUserInput}
452 />
453 ) : null}
454 </View>
455 ) : null}
456 </View>
428457
429458 <ThreadComposer
430459 editorRef={composerEditorRef}
431460 draftMessage={props.draftMessage}
432461 draftAttachments={props.draftAttachments}
433462 placeholder="Ask the repo agent, or run a command…"
463 contentMaxWidth={contentMaxWidth}
434464 connectionState={props.connectionStateLabel}
435465 connectionError={props.connectionError}
436466 environmentLabel={props.environmentLabel}
467 threadSyncPhase={threadSyncPhase}
437468 selectedThread={props.selectedThread}
438469 serverConfig={props.serverConfig}
439470 queueCount={props.selectedThreadQueueCount}
apps/mobile/src/features/threads/ThreadFeed.tsx+176-107
......@@ -4,7 +4,7 @@ import { type LegendListRef } from "@legendapp/list/react-native";
44import type { EnvironmentId, MessageId, ThreadId, TurnId } from "@t3tools/contracts";
55import { CHAT_LIST_ANCHOR_OFFSET, resolveChatListAnchoredEndSpace } from "@t3tools/shared/chatList";
66import { SymbolView } from "expo-symbols";
7import { useRouter } from "expo-router";
7import { useNavigation } from "@react-navigation/native";
88import {
99 memo,
1010 useCallback,
......@@ -25,6 +25,10 @@ import {
2525 ActivityIndicator,
2626 Image,
2727 Linking,
28 Platform,
29 type LayoutChangeEvent,
30 type NativeScrollEvent,
31 type NativeSyntheticEvent,
2832 Pressable,
2933 ScrollView,
3034 StyleSheet,
......@@ -37,7 +41,7 @@ import {
3741import { TouchableOpacity } from "react-native-gesture-handler";
3842import ImageViewing from "react-native-image-viewing";
3943import { useSafeAreaInsets } from "react-native-safe-area-context";
40import type { SharedValue } from "react-native-reanimated";
44import { type SharedValue } from "react-native-reanimated";
4145import { useThemeColor } from "../../lib/useThemeColor";
4246import { copyTextWithHaptic } from "../../lib/copyTextWithHaptic";
4347import {
......@@ -58,14 +62,16 @@ import {
5862 buildNativeReviewDiffData,
5963 createNativeReviewDiffTheme,
6064 NATIVE_REVIEW_DIFF_CONTENT_WIDTH,
61 NATIVE_REVIEW_DIFF_ROW_HEIGHT,
62 NATIVE_REVIEW_DIFF_STYLE,
6365} from "../review/nativeReviewDiffAdapter";
6466import { buildReviewParsedDiff } from "../review/reviewModel";
6567import { cn } from "../../lib/cn";
66import type { LayoutVariant } from "../../lib/layout";
67import { buildThreadFilesNavigation } from "../../lib/routes";
68import { MOBILE_CODE_SURFACE, MOBILE_TYPOGRAPHY } from "../../lib/typography";
68import { deriveCenteredContentHorizontalPadding, type LayoutVariant } from "../../lib/layout";
69import {
70 resolveMarkdownFontSizes,
71 resolveNativeMarkdownTypography,
72} from "../../lib/appearancePreferences";
73import { useAppearancePreferences } from "../settings/appearance/AppearancePreferencesProvider";
74import { useAppearanceCodeSurface } from "../settings/appearance/useAppearanceCodeSurface";
6975import { markdownFileIconSource } from "@t3tools/mobile-markdown-text/file-icons";
7076import { resolveMarkdownLinkPresentation } from "@t3tools/mobile-markdown-text/links";
7177import {
......@@ -82,7 +88,6 @@ const MESSAGE_TIME_FORMATTER = new Intl.DateTimeFormat(undefined, {
8288 hour: "numeric",
8389 minute: "2-digit",
8490});
85
8691function formatMessageTime(input: string): string {
8792 const timestamp = Date.parse(input);
8893 if (Number.isNaN(timestamp)) {
......@@ -105,7 +110,10 @@ export interface ThreadFeedProps {
105110 readonly contentInsetEndAdjustment: SharedValue<number>;
106111 readonly contentTopInset?: number;
107112 readonly contentBottomInset?: number;
113 readonly contentMaxWidth?: number;
108114 readonly layoutVariant?: LayoutVariant;
115 readonly usesAutomaticContentInsets?: boolean;
116 readonly onHeaderMaterialVisibilityChange?: (visible: boolean) => void;
109117 readonly skills?: ReadonlyArray<SelectableMarkdownSkill>;
110118}
111119
......@@ -273,6 +281,15 @@ function useReviewCommentColors(): ReviewCommentColors {
273281
274282function useMarkdownStyles(onLinkPress: (href: string) => void): MarkdownStyleSets {
275283 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 );
276293 const colors = MARKDOWN_COLORS[colorScheme === "dark" ? "dark" : "light"];
277294 const inlineSkillForeground = String(useThemeColor("--color-inline-skill-foreground"));
278295
......@@ -316,14 +333,14 @@ function useMarkdownStyles(onLinkPress: (href: string) => void): MarkdownStyleSe
316333 xl: 16,
317334 },
318335 fontSizes: {
319 s: 13,
320 m: 15,
321 h1: 20,
322 h2: 18,
323 h3: 16,
324 h4: 14,
325 h5: 14,
326 h6: 14,
336 s: markdownFontSizes.s,
337 m: markdownFontSizes.m,
338 h1: markdownFontSizes.h1,
339 h2: markdownFontSizes.h2,
340 h3: markdownFontSizes.h3,
341 h4: markdownFontSizes.h4,
342 h5: markdownFontSizes.h5,
343 h6: markdownFontSizes.h6,
327344 },
328345 fontFamilies: {
329346 regular: "DMSans_400Regular",
......@@ -345,7 +362,7 @@ function useMarkdownStyles(onLinkPress: (href: string) => void): MarkdownStyleSe
345362 list: { marginTop: 4, marginBottom: 8 },
346363 list_item: { marginTop: 0, marginBottom: 4 },
347364 task_list_item: { marginTop: 0, marginBottom: 4 },
348 text: { lineHeight: 22 },
365 text: { lineHeight: markdownFontSizes.bodyLineHeight },
349366 bold: {
350367 fontWeight: "700",
351368 color: markdownStrongColor,
......@@ -454,7 +471,8 @@ function useMarkdownStyles(onLinkPress: (href: string) => void): MarkdownStyleSe
454471 marginRight: 5,
455472 color: inlineTextColor,
456473 fontFamily: "DMSans_400Regular",
457 ...MOBILE_TYPOGRAPHY.body,
474 fontSize: markdownFontSizes.m,
475 lineHeight: markdownFontSizes.bodyLineHeight,
458476 textAlign: ordered ? "right" : "center",
459477 }}
460478 >
......@@ -475,8 +493,8 @@ function useMarkdownStyles(onLinkPress: (href: string) => void): MarkdownStyleSe
475493 style={{
476494 color: inlineCodeTextColor,
477495 fontFamily: "ui-monospace",
478 fontSize: MOBILE_TYPOGRAPHY.label.fontSize,
479 lineHeight: 22,
496 fontSize: markdownFontSizes.codeBlockFontSize,
497 lineHeight: markdownFontSizes.bodyLineHeight,
480498 }}
481499 >
482500 {value}
......@@ -512,7 +530,7 @@ function useMarkdownStyles(onLinkPress: (href: string) => void): MarkdownStyleSe
512530 style={{
513531 color: markdownBodyColor,
514532 fontFamily: "ui-monospace",
515 fontSize: MOBILE_TYPOGRAPHY.label.fontSize,
533 fontSize: markdownFontSizes.codeBlockFontSize,
516534 opacity: 0.7,
517535 textTransform: "uppercase",
518536 }}
......@@ -532,8 +550,8 @@ function useMarkdownStyles(onLinkPress: (href: string) => void): MarkdownStyleSe
532550 style={{
533551 color: blockTextColor,
534552 fontFamily: "ui-monospace",
535 fontSize: MOBILE_TYPOGRAPHY.label.fontSize,
536 lineHeight: 18,
553 fontSize: markdownFontSizes.codeBlockFontSize,
554 lineHeight: markdownFontSizes.codeBlockLineHeight,
537555 }}
538556 >
539557 {content}
......@@ -612,7 +630,9 @@ function useMarkdownStyles(onLinkPress: (href: string) => void): MarkdownStyleSe
612630 skillTextColor: "#f0abfc",
613631 quoteMarkerColor: markdownUserBodyColor,
614632 dividerColor: markdownUserBodyColor,
615 ...MOBILE_TYPOGRAPHY.body,
633 fontSize: nativeMarkdownTypography.fontSize,
634 lineHeight: nativeMarkdownTypography.lineHeight,
635 headingFontSizes: nativeMarkdownTypography.headingFontSizes,
616636 fontFamily: "DMSans_400Regular",
617637 headingFontFamily: "DMSans_700Bold",
618638 boldFontFamily: "DMSans_700Bold",
......@@ -641,14 +661,16 @@ function useMarkdownStyles(onLinkPress: (href: string) => void): MarkdownStyleSe
641661 skillTextColor: inlineSkillForeground,
642662 quoteMarkerColor: markdownBlockquoteBorder,
643663 dividerColor: markdownHrColor,
644 ...MOBILE_TYPOGRAPHY.body,
664 fontSize: nativeMarkdownTypography.fontSize,
665 lineHeight: nativeMarkdownTypography.lineHeight,
666 headingFontSizes: nativeMarkdownTypography.headingFontSizes,
645667 fontFamily: "DMSans_400Regular",
646668 headingFontFamily: "DMSans_700Bold",
647669 boldFontFamily: "DMSans_700Bold",
648670 },
649671 },
650672 };
651 }, [colors, inlineSkillForeground, onLinkPress]);
673 }, [colors, inlineSkillForeground, markdownFontSizes, nativeMarkdownTypography, onLinkPress]);
652674}
653675
654676function renderFeedEntry(
......@@ -924,6 +946,7 @@ const ReviewCommentCard = memo(function ReviewCommentCard(props: {
924946 readonly comment: ReviewInlineComment;
925947 readonly colors: ReviewCommentColors;
926948}) {
949 const { codeSurface, nativeReviewDiffStyle } = useAppearanceCodeSurface();
927950 const colorScheme = useColorScheme();
928951 const appearanceScheme = colorScheme === "light" ? "light" : "dark";
929952 const NativeReviewDiffView = resolveNativeReviewDiffView();
......@@ -946,18 +969,21 @@ const ReviewCommentCard = memo(function ReviewCommentCard(props: {
946969 () => JSON.stringify(nativeReviewDiffTheme),
947970 [nativeReviewDiffTheme],
948971 );
949 const nativeStyleJson = useMemo(() => JSON.stringify(NATIVE_REVIEW_DIFF_STYLE), []);
972 const nativeStyleJson = useMemo(
973 () => JSON.stringify(nativeReviewDiffStyle),
974 [nativeReviewDiffStyle],
975 );
950976 const nativeDiffHeight = useMemo(
951977 () =>
952978 Math.min(
953979 360,
954980 Math.max(
955981 112,
956 compactNativeRows.length * NATIVE_REVIEW_DIFF_ROW_HEIGHT +
957 NATIVE_REVIEW_DIFF_STYLE.fileHeaderVerticalMargin,
982 compactNativeRows.length * nativeReviewDiffStyle.rowHeight +
983 nativeReviewDiffStyle.fileHeaderVerticalMargin,
958984 ),
959985 ),
960 [compactNativeRows.length],
986 [compactNativeRows.length, nativeReviewDiffStyle],
961987 );
962988 const shouldRenderNativeDiff = NativeReviewDiffView != null && compactNativeRows.length > 0;
963989
......@@ -987,7 +1013,7 @@ const ReviewCommentCard = memo(function ReviewCommentCard(props: {
9871013 </View>
9881014 <View className="min-w-0 flex-1">
9891015 <Text
990 className="font-mono text-xs leading-[16px]"
1016 className="font-mono text-xs"
9911017 numberOfLines={1}
9921018 style={{ color: props.colors.text }}
9931019 >
......@@ -1010,7 +1036,7 @@ const ReviewCommentCard = memo(function ReviewCommentCard(props: {
10101036 style={StyleSheet.absoluteFill}
10111037 appearanceScheme={appearanceScheme}
10121038 contentWidth={NATIVE_REVIEW_DIFF_CONTENT_WIDTH}
1013 rowHeight={NATIVE_REVIEW_DIFF_ROW_HEIGHT}
1039 rowHeight={nativeReviewDiffStyle.rowHeight}
10141040 rowsJson={nativeRowsJson}
10151041 styleJson={nativeStyleJson}
10161042 themeJson={nativeThemeJson}
......@@ -1032,8 +1058,8 @@ const ReviewCommentCard = memo(function ReviewCommentCard(props: {
10321058 style={{
10331059 color: props.colors.text,
10341060 fontFamily: "ui-monospace",
1035 fontSize: MOBILE_CODE_SURFACE.fontSize,
1036 lineHeight: MOBILE_CODE_SURFACE.rowHeight,
1061 fontSize: codeSurface.fontSize,
1062 lineHeight: codeSurface.rowHeight,
10371063 }}
10381064 >
10391065 {props.comment.diff.trim()}
......@@ -1042,11 +1068,7 @@ const ReviewCommentCard = memo(function ReviewCommentCard(props: {
10421068 ) : null}
10431069 {props.comment.text.length > 0 ? (
10441070 <View className="border-t px-3 py-3" style={{ borderColor: props.colors.border }}>
1045 <Text
1046 selectable
1047 className="text-base leading-[21px]"
1048 style={{ color: props.colors.text }}
1049 >
1071 <Text selectable className="text-base leading-snug" style={{ color: props.colors.text }}>
10501072 {props.comment.text}
10511073 </Text>
10521074 </View>
......@@ -1087,7 +1109,6 @@ function ThreadFeedPlaceholder(props: {
10871109 readonly bottomInset: number;
10881110 readonly detail: string;
10891111 readonly horizontalPadding: number;
1090 readonly loading?: boolean;
10911112 readonly title: string;
10921113 readonly topInset: number;
10931114}) {
......@@ -1104,9 +1125,8 @@ function ThreadFeedPlaceholder(props: {
11041125 }}
11051126 >
11061127 <View className="max-w-[320px] items-center gap-2">
1107 {props.loading ? <ActivityIndicator style={{ marginBottom: 6 }} /> : null}
11081128 <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">
11101130 {props.detail}
11111131 </Text>
11121132 </View>
......@@ -1115,13 +1135,17 @@ function ThreadFeedPlaceholder(props: {
11151135}
11161136
11171137export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) {
1118 const router = useRouter();
1138 const navigation = useNavigation();
11191139 const copyFeedbackTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
11201140 const foldSettleFrameRef = useRef<number | null>(null);
11211141 const foldSettleSecondFrameRef = useRef<number | null>(null);
11221142 const disclosureAnchorKeyRef = useRef<string | null>(null);
1143 const headerMaterialVisibleRef = useRef(false);
11231144 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 );
11251149 const [disclosureToggleSettling, setDisclosureToggleSettling] = useState(false);
11261150 const [interactionState, setInteractionState] = useState<{
11271151 readonly copiedRowId: string | null;
......@@ -1140,12 +1164,19 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) {
11401164 headers?: Record<string, string>;
11411165 } | null>(null);
11421166 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);
11441173 const userBubbleMaxWidth = contentWidth * 0.85;
11451174 const reviewCommentBubbleWidth = Math.min(Math.max(280, contentWidth * 0.85), contentWidth);
11461175 const insets = useSafeAreaInsets();
11471176 const topContentInset = props.contentTopInset ?? insets.top + 44;
11481177 const bottomContentInset = props.contentBottomInset ?? 18;
1178 const usesNativeAutomaticInsets =
1179 props.usesAutomaticContentInsets === true && Platform.OS === "ios";
11491180
11501181 const iconSubtleColor = useThemeColor("--color-icon-subtle");
11511182 const userBubbleColor = useThemeColor("--color-user-bubble");
......@@ -1159,13 +1190,12 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) {
11591190 );
11601191 if (relativePath) {
11611192 void Haptics.selectionAsync();
1162 router.push(
1163 buildThreadFilesNavigation(
1164 { environmentId: props.environmentId, threadId: props.threadId },
1165 relativePath,
1166 presentation.line,
1167 ),
1168 );
1193 navigation.navigate("ThreadFile", {
1194 environmentId: String(props.environmentId),
1195 threadId: String(props.threadId),
1196 path: relativePath.split("/").filter((segment) => segment.length > 0),
1197 ...(presentation.line ? { line: String(presentation.line) } : {}),
1198 });
11691199 }
11701200 return;
11711201 }
......@@ -1174,7 +1204,7 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) {
11741204 void Linking.openURL(presentation.href);
11751205 }
11761206 },
1177 [props.environmentId, props.threadId, props.workspaceRoot, router],
1207 [props.environmentId, props.threadId, props.workspaceRoot, navigation],
11781208 );
11791209 const markdownStyles = useMarkdownStyles(onMarkdownLinkPress);
11801210 const reviewCommentColors = useReviewCommentColors();
......@@ -1188,6 +1218,7 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) {
11881218 markdownStyles,
11891219 reviewCommentColors,
11901220 userBubbleColor,
1221 viewportWidth,
11911222 }),
11921223 [
11931224 copiedRowId,
......@@ -1196,8 +1227,34 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) {
11961227 markdownStyles,
11971228 reviewCommentColors,
11981229 userBubbleColor,
1230 viewportWidth,
11991231 ],
12001232 );
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
12011258 const expandedWorkGroupIds = useMemo(() => {
12021259 const ids = new Set<string>();
12031260 for (const [groupId, expanded] of Object.entries(expandedWorkGroups)) {
......@@ -1217,6 +1274,7 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) {
12171274 ),
12181275 [expandedTurnIds, expandedWorkGroupIds, props.feed, props.latestTurn],
12191276 );
1277
12201278 const anchoredEndSpace = useMemo(
12211279 () =>
12221280 resolveChatListAnchoredEndSpace(
......@@ -1424,19 +1482,6 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) {
14241482 ],
14251483 );
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
14401485 if (props.contentPresentation.kind === "unavailable") {
14411486 return (
14421487 <ThreadFeedPlaceholder
......@@ -1451,48 +1496,72 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) {
14511496
14521497 return (
14531498 <>
1454 <View style={{ flex: 1 }}>
1455 <KeyboardAwareLegendList
1456 ref={props.listRef}
1457 key={props.threadId}
1458 style={{ flex: 1 }}
1459 automaticallyAdjustsScrollIndicatorInsets={false}
1460 scrollIndicatorInsets={{ top: topContentInset, bottom: 0 }}
1461 {...(anchoredEndSpace ? { anchoredEndSpace } : {})}
1462 contentInsetEndAdjustment={props.contentInsetEndAdjustment}
1463 freeze={props.freeze}
1464 maintainScrollAtEnd={
1465 disclosureToggleSettling
1466 ? false
1467 : {
1468 animated: false,
1469 on: {
1470 dataChange: true,
1471 itemLayout: true,
1472 layout: true,
1473 },
1499 <View style={{ flex: 1 }} onLayout={handleViewportLayout}>
1500 <View style={{ flex: 1 }}>
1501 <KeyboardAwareLegendList
1502 ref={props.listRef}
1503 // The empty↔filled key remounts the list when messages first
1504 // arrive. LegendList's maintainScrollAtEnd calls scrollToEnd(),
1505 // which is blind to UIKit's adjustedContentInset — inserting into
1506 // an already-attached list under a transparent header can pin
1507 // short content at offset 0 (one header-height too high). A fresh
1508 // mount positions during attach, where UIKit applies the inset.
1509 key={`${props.threadId}:${props.feed.length === 0 ? "empty" : "filled"}`}
1510 style={{ flex: 1 }}
1511 contentInsetAdjustmentBehavior={usesNativeAutomaticInsets ? "automatic" : "never"}
1512 automaticallyAdjustsScrollIndicatorInsets={usesNativeAutomaticInsets}
1513 {...(usesNativeAutomaticInsets
1514 ? {
1515 // Do NOT pass a manual `contentInset` here. Like the Home
1516 // ScrollView, we rely purely on `contentInsetAdjustmentBehavior:
1517 // "automatic"` so UIKit derives the top inset from the transparent
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 },
14741523 }
1475 }
1476 maintainVisibleContentPosition={maintainVisibleContentPosition}
1477 data={presentedFeed}
1478 extraData={listAppearanceData}
1479 renderItem={renderItem}
1480 keyExtractor={(entry) => entry.id}
1481 getItemType={(entry) =>
1482 entry.type === "message" ? `message:${entry.message.role}` : entry.type
1483 }
1484 keyboardShouldPersistTaps="always"
1485 keyboardDismissMode="none"
1486 keyboardLiftBehavior="whenAtEnd"
1487 estimatedItemSize={180}
1488 initialScrollAtEnd
1489 ListHeaderComponent={<View style={{ height: topContentInset }} />}
1490 contentContainerStyle={{
1491 paddingTop: 12,
1492 paddingHorizontal: horizontalPadding,
1493 }}
1494 />
1495 {props.feed.length === 0 ? (
1524 : { scrollIndicatorInsets: { top: topContentInset, bottom: 0 } })}
1525 {...(anchoredEndSpace ? { anchoredEndSpace } : {})}
1526 contentInsetEndAdjustment={props.contentInsetEndAdjustment}
1527 freeze={props.freeze}
1528 maintainScrollAtEnd={
1529 disclosureToggleSettling
1530 ? false
1531 : {
1532 animated: false,
1533 on: {
1534 dataChange: true,
1535 itemLayout: true,
1536 layout: true,
1537 },
1538 }
1539 }
1540 maintainVisibleContentPosition={maintainVisibleContentPosition}
1541 data={presentedFeed}
1542 extraData={listAppearanceData}
1543 renderItem={renderItem}
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" ? (
14961565 <View pointerEvents="none" style={StyleSheet.absoluteFill}>
14971566 <ThreadFeedPlaceholder
14981567 title="No conversation yet"
apps/mobile/src/features/threads/ThreadGitControls.tsx+382-122
......@@ -1,20 +1,19 @@
1import type {
1import {
22 EnvironmentId,
3 GitRunStackedActionResult,
4 ProjectScript,
3 type GitRunStackedActionResult,
4 type ProjectScript,
55 ThreadId,
6 VcsStatusResult,
6 type VcsStatusResult,
77} from "@t3tools/contracts";
88import {
99 type GitActionRequestInput,
1010 requiresDefaultBranchConfirmation,
1111 resolveQuickAction,
1212} from "@t3tools/client-runtime/state/vcs";
13import { useLocalSearchParams, useRouter } from "expo-router";
14import Stack from "expo-router/stack";
13import { useNavigation } from "@react-navigation/native";
14import { NativeHeaderToolbar } from "../../native/StackHeader";
1515import { useCallback, useMemo } from "react";
1616import { Alert } from "react-native";
17import { buildThreadFilesNavigation, buildThreadReviewRoutePath } from "../../lib/routes";
1817import { tryOpenExternalUrl } from "../../lib/openExternalUrl";
1918import {
2019 basename,
......@@ -65,25 +64,52 @@ function compactMenuStatus(gitStatus: VcsStatusResult | null): string {
6564 return parts.join(" · ");
6665}
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;
6984 readonly currentBranch: string | null;
7085 readonly gitStatus: VcsStatusResult | null;
7186 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 };
7298 readonly canOpenTerminal: boolean;
7399 readonly canOpenFiles: boolean;
74100 readonly projectScripts: ReadonlyArray<ProjectScript>;
75101 readonly terminalSessions: ReadonlyArray<TerminalMenuSession>;
102 readonly showActionControls?: boolean;
103 readonly showDirectFileControl?: boolean;
76104 readonly onOpenTerminal: (terminalId?: string | null) => void;
77105 readonly onOpenNewTerminal: () => void;
78106 readonly onRunProjectScript: (script: ProjectScript) => Promise<void>;
79 readonly onPull: () => Promise<void>;
80 readonly onRunAction: (input: GitActionRequestInput) => Promise<GitRunStackedActionResult | null>;
81}) {
82 const router = useRouter();
83 const { environmentId, threadId } = useLocalSearchParams<{
84 environmentId: EnvironmentId;
85 threadId: ThreadId;
86 }>();
107};
108
109function useThreadGitControlModel(props: ThreadGitMenuProps) {
110 const navigation = useNavigation();
111 const environmentId = props.environmentId;
112 const threadId = props.threadId;
87113 const { gitStatus, gitOperationLabel, onPull, onRunAction } = props;
88114
89115 const currentBranchLabel = gitStatus?.refName ?? props.currentBranch ?? "Detached HEAD";
......@@ -109,7 +135,7 @@ export function ThreadGitControls(props: {
109135 ? (quickAction.hint ?? "This action is unavailable.")
110136 : null;
111137
112 const quickActionIcon = (() => {
138 const quickActionIcon: QuickActionIcon = (() => {
113139 if (quickAction.kind === "run_pull") return "arrow.down.circle";
114140 if (quickAction.kind === "open_pr") return "arrow.up.right.circle";
115141 if (quickAction.kind === "run_action") {
......@@ -147,24 +173,21 @@ export function ThreadGitControls(props: {
147173 !input.featureBranch &&
148174 requiresDefaultBranchConfirmation(input.action, isDefaultRef)
149175 ) {
150 router.push({
151 pathname: "/threads/[environmentId]/[threadId]/git-confirm",
152 params: {
153 environmentId,
154 threadId,
155 confirmAction: confirmableAction,
156 branchName,
157 includesCommit: String(
158 input.action === "commit_push" || input.action === "commit_push_pr",
159 ),
160 },
176 navigation.navigate("GitConfirm", {
177 environmentId: String(environmentId),
178 threadId: String(threadId),
179 confirmAction: confirmableAction,
180 branchName,
181 includesCommit: String(
182 input.action === "commit_push" || input.action === "commit_push_pr",
183 ),
161184 });
162185 return;
163186 }
164187
165188 await onRunAction(input);
166189 },
167 [environmentId, gitStatus, isDefaultRef, onRunAction, router, threadId],
190 [environmentId, gitStatus, isDefaultRef, onRunAction, navigation, threadId],
168191 );
169192
170193 const runQuickAction = useCallback(async () => {
......@@ -181,102 +204,339 @@ export function ThreadGitControls(props: {
181204 }
182205 }, [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
184417 return (
185 <Stack.Toolbar placement="right">
186 <Stack.Toolbar.Menu icon="terminal" disabled={!props.canOpenTerminal} separateBackground>
187 {props.projectScripts.length > 0 ? (
188 props.projectScripts.map((script) => (
189 <Stack.Toolbar.MenuAction
190 key={script.id}
191 icon={projectScriptMenuIcon(script.icon)}
192 onPress={() => void props.onRunProjectScript(script)}
193 subtitle={script.command}
418 <NativeHeaderToolbar placement="right">
419 {showActionControls && props.auxiliaryPaneControl ? (
420 <NativeHeaderToolbar.Button
421 accessibilityLabel={props.auxiliaryPaneControl.accessibilityLabel}
422 icon="sidebar.right"
423 onPress={props.auxiliaryPaneControl.onPress}
424 separateBackground
425 />
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"
194452 >
195 <Stack.Toolbar.Label>{projectScriptMenuLabel(script)}</Stack.Toolbar.Label>
196 </Stack.Toolbar.MenuAction>
197 ))
198 ) : (
199 <Stack.Toolbar.MenuAction
200 icon="play"
201 disabled
202 onPress={() => {}}
203 subtitle="This project has no saved scripts yet"
204 >
205 <Stack.Toolbar.Label>No project scripts</Stack.Toolbar.Label>
206 </Stack.Toolbar.MenuAction>
207 )}
208 {props.terminalSessions.map((session) => (
209 <Stack.Toolbar.MenuAction
210 key={session.terminalId}
211 icon="terminal"
212 onPress={() => props.onOpenTerminal(session.terminalId)}
213 subtitle={[
214 getTerminalStatusLabel({
215 status: session.status,
216 hasRunningSubprocess: session.hasRunningSubprocess,
217 }),
218 basename(session.cwd),
219 ]
220 .filter(Boolean)
221 .join(" · ")}
453 <NativeHeaderToolbar.Label>No project scripts</NativeHeaderToolbar.Label>
454 </NativeHeaderToolbar.MenuAction>
455 )}
456 {props.terminalSessions.map((session) => (
457 <NativeHeaderToolbar.MenuAction
458 key={session.terminalId}
459 icon="terminal"
460 onPress={() => props.onOpenTerminal(session.terminalId)}
461 subtitle={[
462 getTerminalStatusLabel({
463 status: session.status,
464 hasRunningSubprocess: session.hasRunningSubprocess,
465 }),
466 basename(session.cwd),
467 ]
468 .filter(Boolean)
469 .join(" · ")}
470 >
471 <NativeHeaderToolbar.Label>{session.displayLabel}</NativeHeaderToolbar.Label>
472 </NativeHeaderToolbar.MenuAction>
473 ))}
474 <NativeHeaderToolbar.MenuAction
475 icon="plus"
476 onPress={props.onOpenNewTerminal}
477 subtitle="Start another shell for this thread"
222478 >
223 <Stack.Toolbar.Label>{session.displayLabel}</Stack.Toolbar.Label>
224 </Stack.Toolbar.MenuAction>
225 ))}
226 <Stack.Toolbar.MenuAction
227 icon="plus"
228 onPress={props.onOpenNewTerminal}
229 subtitle="Start another shell for this thread"
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"
479 <NativeHeaderToolbar.Label>Open new terminal</NativeHeaderToolbar.Label>
480 </NativeHeaderToolbar.MenuAction>
481 </NativeHeaderToolbar.Menu>
482 ) : null}
483 {showActionControls && props.showDirectFileControl ? (
484 <NativeHeaderToolbar.Button
485 accessibilityLabel="Open files"
261486 disabled={!props.canOpenFiles}
262 onPress={() => router.push(buildThreadFilesNavigation({ environmentId, threadId }))}
263 subtitle="Browse this workspace"
264 >
265 <Stack.Toolbar.Label>Files</Stack.Toolbar.Label>
266 </Stack.Toolbar.MenuAction>
267 <Stack.Toolbar.MenuAction
268 icon="ellipsis.circle"
269 onPress={() =>
270 router.push({
271 pathname: "/threads/[environmentId]/[threadId]/git",
272 params: { environmentId, threadId },
273 })
274 }
275 subtitle="Commit, files, branches"
276 >
277 <Stack.Toolbar.Label>More</Stack.Toolbar.Label>
278 </Stack.Toolbar.MenuAction>
279 </Stack.Toolbar.Menu>
280 </Stack.Toolbar>
487 icon="folder"
488 onPress={model.openFiles}
489 separateBackground
490 />
491 ) : null}
492 {showActionControls ? <ThreadGitMenu {...props} /> : null}
493 </NativeHeaderToolbar>
494 );
495}
496
497/**
498 * The standalone git actions menu (branch status, quick commit/push action,
499 * review, more). Rendered inside a NativeHeaderToolbar by both the thread
500 * chat header and the review screen's toolbar.
501 */
502export function ThreadGitMenu(props: ThreadGitMenuProps) {
503 const model = useThreadGitControlModel(props);
504
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>
281541 );
282542}
apps/mobile/src/features/threads/ThreadNavigationDrawer.tsx+7-34
......@@ -8,8 +8,6 @@ import {
88 useWindowDimensions,
99 View,
1010} from "react-native";
11import * as Arr from "effect/Array";
12import * as Order from "effect/Order";
1311import { Gesture, GestureDetector } from "react-native-gesture-handler";
1412import { useSafeAreaInsets } from "react-native-safe-area-context";
1513import Animated, {
......@@ -23,22 +21,11 @@ import { useThemeColor } from "../../lib/useThemeColor";
2321import { AppText as Text } from "../../components/AppText";
2422import { StatusPill } from "../../components/StatusPill";
2523import { useProjects, useThreadShells } from "../../state/entities";
26import { groupProjectsByRepository } from "../../lib/repositoryGroups";
2724import { scopedThreadKey } from "../../lib/scopedEntities";
2825import { relativeTime } from "../../lib/time";
29import { threadStatusTone } from "./threadPresentation";
30import { EnvironmentThreadShell } from "@t3tools/client-runtime/state/shell";
31
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);
26import { resolveThreadStatus } from "./threadPresentation";
27import type { EnvironmentThreadShell } from "@t3tools/client-runtime/state/shell";
28import { buildThreadNavigationGroups } from "./thread-navigation-groups";
4229
4330export function ThreadNavigationDrawer(props: {
4431 readonly visible: boolean;
......@@ -192,24 +179,9 @@ function ThreadNavigationDrawerContent(props: {
192179}) {
193180 const projects = useProjects();
194181 const threads = useThreadShells();
195 const repositoryGroups = useMemo(
196 () => groupProjectsByRepository({ projects, threads }),
197 [projects, threads],
198 );
199182 const groupedThreads = useMemo(
200 () =>
201 repositoryGroups.map((group) => {
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],
183 () => buildThreadNavigationGroups({ projects, threads }),
184 [projects, threads],
213185 );
214186
215187 return (
......@@ -239,6 +211,7 @@ function ThreadNavigationDrawerContent(props: {
239211 group.threads.map((thread, index) => {
240212 const threadKey = scopedThreadKey(thread.environmentId, thread.id);
241213 const selected = props.selectedThreadKey === threadKey;
214 const status = resolveThreadStatus(thread);
242215
243216 return (
244217 <Pressable
......@@ -268,7 +241,7 @@ function ThreadNavigationDrawerContent(props: {
268241 {relativeTime(thread.updatedAt ?? thread.createdAt)}
269242 </Text>
270243 </View>
271 <StatusPill {...threadStatusTone(thread)} />
244 {status ? <StatusPill {...status} /> : null}
272245 </View>
273246 </Pressable>
274247 );
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 @@
1import { Stack, useLocalSearchParams, useRouter } from "expo-router";
2import { useCallback, useMemo, useState } from "react";
1import { NativeStackScreenOptions } from "../../native/StackHeader";
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";
39import * as Option from "effect/Option";
4import { EnvironmentId, type ProjectScript } from "@t3tools/contracts";
10import { EnvironmentId, ThreadId, type ProjectScript } from "@t3tools/contracts";
511import { 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";
714import { useWorkspaceState } from "../../state/workspace";
815import { useThemeColor } from "../../lib/useThemeColor";
916import { useEnvironmentQuery } from "../../state/query";
......@@ -12,7 +19,6 @@ import { vcsEnvironment } from "../../state/vcs";
1219
1320import { EmptyState } from "../../components/EmptyState";
1421import { LoadingScreen } from "../../components/LoadingScreen";
15import { buildThreadRoutePath, buildThreadTerminalNavigation } from "../../lib/routes";
1622import { scopedThreadKey } from "../../lib/scopedEntities";
1723import { MOBILE_TYPOGRAPHY } from "../../lib/typography";
1824import { connectionTone } from "../connection/connectionTone";
......@@ -37,7 +43,12 @@ import {
3743} from "../terminal/terminalLaunchContext";
3844import { terminalDebugLog } from "../terminal/terminalDebugLog";
3945import { ThreadDetailScreen } from "./ThreadDetailScreen";
40import { ThreadGitControls } from "./ThreadGitControls";
46import {
47 ThreadGitControls,
48 useThreadGitCenterHeaderItems,
49 useThreadGitRightHeaderItems,
50} from "./ThreadGitControls";
51import { GitOverviewSheet } from "./git/GitOverviewSheet";
4152import { ThreadNavigationDrawer } from "./ThreadNavigationDrawer";
4253import { useAtomCommand } from "../../state/use-atom-command";
4354import { useSelectedThreadGitActions } from "../../state/use-selected-thread-git-actions";
......@@ -47,6 +58,30 @@ import { useSelectedThreadWorktree } from "../../state/use-selected-thread-workt
4758import { useThreadComposerState } from "../../state/use-thread-composer-state";
4859import { threadEnvironment } from "../../state/threads";
4960import { 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
5186function firstRouteParam(value: string | string[] | undefined): string | null {
5287 if (Array.isArray(value)) {
......@@ -60,13 +95,99 @@ function OpeningThreadLoadingScreen() {
6095 return <LoadingScreen message="Opening thread…" messagePlacement="above-spinner" />;
6196}
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) {
64129 const { state: workspaceState } = useWorkspaceState();
65130 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();
66187 const { onReconnectEnvironment } = useRemoteConnections();
67188 const { selectedThread, selectedThreadProject, selectedEnvironmentConnection } =
68189 useThreadSelection();
69 const selectedThreadDetailState = useSelectedThreadDetailState();
190 const selectedThreadDetailState = props.selectedThreadDetailState;
70191 const selectedThreadDetail = Option.getOrNull(selectedThreadDetailState.data);
71192 const { selectedThreadCwd } = useSelectedThreadWorktree();
72193 const composer = useThreadComposerState();
......@@ -74,15 +195,72 @@ export function ThreadRouteScreen() {
74195 const gitActions = useSelectedThreadGitActions();
75196 const requests = useSelectedThreadRequests();
76197 const interruptThreadTurn = useAtomCommand(threadEnvironment.interruptTurn, "thread interrupt");
77 const router = useRouter();
78 const params = useLocalSearchParams<{
79 environmentId?: string | string[];
80 threadId?: string | string[];
81 }>();
198 const navigation = useNavigation();
199 const params = props.route.params;
82200 const [drawerVisible, setDrawerVisible] = useState(false);
83201 const environmentIdRaw = firstRouteParam(params.environmentId);
84202 const environmentId = environmentIdRaw ? EnvironmentId.make(environmentIdRaw) : null;
85203 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 );
86264 const routeEnvironmentRuntime = useRemoteEnvironmentRuntime(environmentId);
87265 const routeConnectionState =
88266 routeEnvironmentRuntime?.connectionState ?? (environmentId ? "available" : connectionState);
......@@ -101,10 +279,14 @@ export function ThreadRouteScreen() {
101279 );
102280
103281 /* ─── Native header theming ──────────────────────────────────────── */
104 const iconColor = String(useThemeColor("--color-icon"));
105282 const foregroundColor = String(useThemeColor("--color-foreground"));
106 const secondaryFg = String(useThemeColor("--color-foreground-secondary"));
107
283 const usesNativeHeaderGlass = Platform.OS === "ios";
284 const headerSubtitle = [
285 selectedThreadProject?.title ?? null,
286 selectedEnvironmentConnection?.environmentLabel ?? null,
287 ]
288 .filter(Boolean)
289 .join(" · ");
108290 /* ─── Git status for native header trigger ───────────────────────── */
109291 const gitStatus = useEnvironmentQuery(
110292 selectedThread !== null && selectedThreadCwd !== null
......@@ -145,12 +327,134 @@ export function ThreadRouteScreen() {
145327 const gitActionProgress = useGitActionProgress(gitActionProgressTarget);
146328
147329 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();
149379 }, []);
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
151455 const handleOpenConnectionEditor = useCallback(() => {
152 void router.push("/connections");
153 }, [router]);
456 void navigation.navigate("Connections");
457 }, [navigation]);
154458 const handleStopThread = useCallback(() => {
155459 if (
156460 !selectedThread ||
......@@ -182,9 +486,13 @@ export function ThreadRouteScreen() {
182486 return;
183487 }
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 });
186494 },
187 [router, selectedThread, selectedThreadProject?.workspaceRoot],
495 [navigation, selectedThread, selectedThreadProject?.workspaceRoot],
188496 );
189497
190498 const handleOpenNewTerminal = useCallback(() => {
......@@ -201,8 +509,12 @@ export function ThreadRouteScreen() {
201509 const nextId = nextOpenTerminalId({
202510 listedTerminalIds: terminalMenuSessions.map((session) => session.terminalId),
203511 });
204 void router.push(buildThreadTerminalNavigation(selectedThread, nextId));
205 }, [router, selectedThread, selectedThreadProject?.workspaceRoot, terminalMenuSessions]);
512 void navigation.navigate("ThreadTerminal", {
513 environmentId: String(selectedThread.environmentId),
514 threadId: String(selectedThread.id),
515 terminalId: nextId,
516 });
517 }, [navigation, selectedThread, selectedThreadProject?.workspaceRoot, terminalMenuSessions]);
206518
207519 const handleRunProjectScript = useCallback(
208520 async (script: ProjectScript) => {
......@@ -259,48 +571,97 @@ export function ThreadRouteScreen() {
259571 worktreePath: preferredWorktreePath,
260572 });
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 });
263579 },
264580 [
265 router,
581 navigation,
266582 selectedThread,
267583 selectedThreadDetailWorktreePath,
268584 selectedThreadProject,
269585 terminalMenuSessions,
270586 ],
271587 );
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
273659 if (!environmentId || !threadId) {
274660 return <OpeningThreadLoadingScreen />;
275661 }
276662
277663 if (!selectedThread) {
278 const stillHydrating =
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 );
664 return <OpeningThreadLoadingScreen />;
304665 }
305666
306667 const selectedThreadKey = scopedThreadKey(selectedThread.environmentId, selectedThread.id);
......@@ -311,74 +672,9 @@ export function ThreadRouteScreen() {
311672 connectionState: routeConnectionState,
312673 });
313674 const serverConfig = routeEnvironmentRuntime?.serverConfig ?? null;
314
315 const headerSubtitle = [
316 selectedThreadProject?.title ?? null,
317 selectedEnvironmentConnection?.environmentLabel ?? null,
318 ]
319 .filter(Boolean)
320 .join(" · ");
321
322 return (
675 const renderThreadRouteBody = (showActionControls: boolean) => (
323676 <>
324 <Stack.Screen
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 />
677 <ThreadGitControls {...threadGitControlProps} showActionControls={showActionControls} />
382678
383679 <GitActionProgressOverlay progress={gitActionProgress} onDismiss={dismissGitActionResult} />
384680
......@@ -400,11 +696,14 @@ export function ThreadRouteScreen() {
400696 draftMessage={composer.draftMessage}
401697 draftAttachments={composer.draftAttachments}
402698 connectionStateLabel={routeConnectionState}
699 threadSyncStatus={selectedThreadDetailState.status}
403700 activeThreadBusy={composer.activeThreadBusy}
404701 environmentId={selectedThread.environmentId}
405702 projectWorkspaceRoot={selectedThreadProject?.workspaceRoot ?? null}
406703 threadCwd={selectedThreadCwd}
407704 selectedThreadQueueCount={composer.selectedThreadQueueCount}
705 layoutVariant={layout.variant}
706 usesAutomaticContentInsets={usesNativeHeaderGlass}
408707 onOpenDrawer={handleOpenDrawer}
409708 onOpenConnectionEditor={handleOpenConnectionEditor}
410709 onChangeDraftMessage={composer.onChangeDraftMessage}
......@@ -424,16 +723,57 @@ export function ThreadRouteScreen() {
424723 onSubmitUserInput={requests.onSubmitUserInput}
425724 />
426725
427 <ThreadNavigationDrawer
428 visible={drawerVisible}
429 selectedThreadKey={selectedThreadKey}
430 onClose={() => setDrawerVisible(false)}
431 onSelectThread={(thread) => {
432 router.replace(buildThreadRoutePath(thread));
433 }}
434 onStartNewTask={() => router.push("/new")}
435 />
726 {layout.usesSplitView ? null : (
727 <ThreadNavigationDrawer
728 visible={drawerVisible}
729 selectedThreadKey={selectedThreadKey}
730 onClose={() => setDrawerVisible(false)}
731 onSelectThread={(thread) => {
732 navigation.dispatch(
733 StackActions.replace("Thread", {
734 environmentId: String(thread.environmentId),
735 threadId: String(thread.id),
736 }),
737 );
738 }}
739 onStartNewTask={() => navigation.navigate("NewTaskSheet", { screen: "NewTask" })}
740 />
741 )}
436742 </View>
437743 </>
438744 );
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 );
439779}
apps/mobile/src/features/threads/git/GitBranchesSheet.tsx+11-6
......@@ -1,5 +1,5 @@
11import { sanitizeFeatureBranchName } from "@t3tools/shared/git";
2import { useRouter } from "expo-router";
2import { useNavigation, type StaticScreenProps } from "@react-navigation/native";
33import { useState } from "react";
44import { Pressable, ScrollView, View } from "react-native";
55import { useSafeAreaInsets } from "react-native-safe-area-context";
......@@ -14,8 +14,13 @@ import { useSelectedThreadWorktree } from "../../../state/use-selected-thread-wo
1414import { vcsEnvironment } from "../../../state/vcs";
1515import { SheetActionButton } from "./gitSheetComponents";
1616
17export function GitBranchesSheet() {
18 const router = useRouter();
17type GitBranchesSheetProps = StaticScreenProps<{
18 readonly environmentId: string;
19 readonly threadId: string;
20}>;
21
22export function GitBranchesSheet(_props: GitBranchesSheetProps) {
23 const navigation = useNavigation();
1924 const insets = useSafeAreaInsets();
2025 const { selectedThread } = useThreadSelection();
2126 const { selectedThreadCwd, selectedThreadWorktreePath } = useSelectedThreadWorktree();
......@@ -96,7 +101,7 @@ export function GitBranchesSheet() {
96101 if (branch.length === 0) return;
97102 void gitActions.onCreateSelectedThreadBranch(branch).then(() => {
98103 setNewBranchName("");
99 router.dismiss();
104 navigation.goBack();
100105 });
101106 }}
102107 />
......@@ -146,7 +151,7 @@ export function GitBranchesSheet() {
146151 if (baseBranch.length === 0 || newBranch.length === 0) return;
147152 void gitActions.onCreateSelectedThreadWorktree({ baseBranch, newBranch }).then(() => {
148153 setWorktreeBranchName("");
149 router.dismiss();
154 navigation.goBack();
150155 });
151156 }}
152157 />
......@@ -188,7 +193,7 @@ export function GitBranchesSheet() {
188193 }}
189194 onPress={() => {
190195 void gitActions.onCheckoutSelectedThreadBranch(branch.name).then(() => {
191 router.dismiss();
196 navigation.goBack();
192197 });
193198 }}
194199 >
apps/mobile/src/features/threads/git/GitCommitSheet.tsx+15-10
......@@ -1,4 +1,4 @@
1import { useRouter } from "expo-router";
1import { useNavigation, type StaticScreenProps } from "@react-navigation/native";
22import { useCallback, useState } from "react";
33import { Pressable, ScrollView, View, useColorScheme } from "react-native";
44import { useSafeAreaInsets } from "react-native-safe-area-context";
......@@ -13,8 +13,13 @@ import { useSelectedThreadWorktree } from "../../../state/use-selected-thread-wo
1313import { vcsEnvironment } from "../../../state/vcs";
1414import { SheetActionButton } from "./gitSheetComponents";
1515
16export function GitCommitSheet() {
17 const router = useRouter();
16type GitCommitSheetProps = StaticScreenProps<{
17 readonly environmentId: string;
18 readonly threadId: string;
19}>;
20
21export function GitCommitSheet(_props: GitCommitSheetProps) {
22 const navigation = useNavigation();
1823 const insets = useSafeAreaInsets();
1924 const isDarkMode = useColorScheme() === "dark";
2025 const { selectedThread } = useThreadSelection();
......@@ -55,7 +60,7 @@ export function GitCommitSheet() {
5560 const runCommitAction = useCallback(
5661 async (featureBranch: boolean) => {
5762 const commitMessage = dialogCommitMessage.trim();
58 router.dismiss();
63 navigation.goBack();
5964 await gitActions.onRunSelectedThreadGitAction({
6065 action: "commit",
6166 featureBranch,
......@@ -63,7 +68,7 @@ export function GitCommitSheet() {
6368 ...(!allSelected ? { filePaths: selectedFiles.map((file) => file.path) } : {}),
6469 });
6570 },
66 [allSelected, dialogCommitMessage, gitActions, router, selectedFiles],
71 [allSelected, dialogCommitMessage, gitActions, navigation, selectedFiles],
6772 );
6873
6974 return (
......@@ -86,7 +91,7 @@ export function GitCommitSheet() {
8691 </View>
8792 {isDefaultRef ? (
8893 <Text
89 className="text-xs leading-[18px]"
94 className="text-xs leading-normal"
9095 style={{ color: isDarkMode ? "#fbbf24" : "#b45309" }}
9196 >
9297 Warning: this is the default branch.
......@@ -98,7 +103,7 @@ export function GitCommitSheet() {
98103 <View className="flex-row items-center justify-between gap-3">
99104 <View className="gap-1">
100105 <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">
102107 {selectedFiles.length} selected · +{selectedInsertions} / -{selectedDeletions}
103108 </Text>
104109 </View>
......@@ -123,7 +128,7 @@ export function GitCommitSheet() {
123128 </View>
124129
125130 {allFiles.length === 0 ? (
126 <Text className="text-foreground-secondary text-sm leading-[19px]">
131 <Text className="text-foreground-secondary text-sm leading-normal">
127132 No changed files are available to commit.
128133 </Text>
129134 ) : !isEditingFiles ? (
......@@ -142,7 +147,7 @@ export function GitCommitSheet() {
142147 </View>
143148 ))}
144149 {selectedFiles.length > selectedFilePreview.length ? (
145 <Text className="text-foreground-muted text-xs leading-[17px]">
150 <Text className="text-foreground-muted text-xs leading-snug">
146151 +{selectedFiles.length - selectedFilePreview.length} more files
147152 </Text>
148153 ) : null}
......@@ -182,7 +187,7 @@ export function GitCommitSheet() {
182187 {file.path}
183188 </Text>
184189 {!included ? (
185 <Text className="text-foreground-muted text-2xs leading-[16px]">
190 <Text className="text-foreground-muted text-2xs leading-normal">
186191 Excluded from this commit
187192 </Text>
188193 ) : null}
apps/mobile/src/features/threads/git/GitConfirmSheet.tsx+30-15
......@@ -2,7 +2,7 @@ import { resolveDefaultBranchActionDialogCopy } from "@t3tools/client-runtime/st
22import { resolveAutoFeatureBranchName } from "@t3tools/shared/git";
33import * as Arr from "effect/Array";
44import * as Result from "effect/Result";
5import { useLocalSearchParams, useRouter } from "expo-router";
5import { StackActions, useNavigation, type StaticScreenProps } from "@react-navigation/native";
66import { useCallback, useMemo } from "react";
77import { View } from "react-native";
88import { useSafeAreaInsets } from "react-native-safe-area-context";
......@@ -12,19 +12,23 @@ import { useSelectedThreadGitActions } from "../../../state/use-selected-thread-
1212import { useSelectedThreadGitState } from "../../../state/use-selected-thread-git-state";
1313import { SheetActionButton } from "./gitSheetComponents";
1414
15export function GitConfirmSheet() {
16 const router = useRouter();
15type GitConfirmSheetProps = StaticScreenProps<{
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();
1727 const insets = useSafeAreaInsets();
1828 const gitState = useSelectedThreadGitState();
1929 const gitActions = useSelectedThreadGitActions();
2030
21 const params = useLocalSearchParams<{
22 confirmAction?: string;
23 branchName?: string;
24 includesCommit?: string;
25 commitMessage?: string;
26 filePaths?: string;
27 }>();
31 const params = props.route.params;
2832
2933 const confirmAction = params.confirmAction as
3034 | "push"
......@@ -34,6 +38,8 @@ export function GitConfirmSheet() {
3438 | undefined;
3539 const branchName = params.branchName ?? "";
3640 const includesCommit = params.includesCommit === "true";
41 const environmentId = params.environmentId ?? "";
42 const threadId = params.threadId ?? "";
3743
3844 const copy = useMemo(
3945 () =>
......@@ -49,17 +55,17 @@ export function GitConfirmSheet() {
4955
5056 const continuePendingAction = useCallback(async () => {
5157 if (!confirmAction) return;
52 router.dismissAll();
58 navigation.dispatch(StackActions.replace("Thread", { environmentId, threadId }));
5359 await gitActions.onRunSelectedThreadGitAction({
5460 action: confirmAction,
5561 ...(params.commitMessage ? { commitMessage: params.commitMessage } : {}),
5662 ...(params.filePaths ? { filePaths: params.filePaths.split(",") } : {}),
5763 });
58 }, [confirmAction, gitActions, params, router]);
64 }, [confirmAction, environmentId, gitActions, params, navigation, threadId]);
5965
6066 const movePendingActionToFeatureBranch = useCallback(async () => {
6167 if (!confirmAction) return;
62 router.dismissAll();
68 navigation.dispatch(StackActions.replace("Thread", { environmentId, threadId }));
6369
6470 if (includesCommit) {
6571 await gitActions.onRunSelectedThreadGitAction({
......@@ -82,7 +88,16 @@ export function GitConfirmSheet() {
8288 );
8389 await gitActions.onCreateSelectedThreadBranch(newBranchName);
8490 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
87102 return (
88103 <View collapsable={false} className="flex-1 bg-sheet">
......@@ -98,7 +113,7 @@ export function GitConfirmSheet() {
98113 <Text className="text-center text-3xl font-t3-bold">
99114 {copy?.title ?? "Run action on default branch?"}
100115 </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">
102117 {copy?.description ?? "Choose how to continue."}
103118 </Text>
104119 </View>
apps/mobile/src/features/threads/git/GitOverviewSheet.tsx+246-97
......@@ -4,17 +4,18 @@ import {
44 getGitActionDisabledReason,
55 requiresDefaultBranchConfirmation,
66} from "@t3tools/client-runtime/state/vcs";
7import type { EnvironmentId, ThreadId } from "@t3tools/contracts";
8import { useLocalSearchParams, useRouter } from "expo-router";
7import { EnvironmentId, ThreadId } from "@t3tools/contracts";
8import { useNavigation, type StaticScreenProps } from "@react-navigation/native";
99import { SymbolView } from "expo-symbols";
10import { useCallback, useEffect, useMemo } from "react";
11import { Alert, Pressable, ScrollView, View } from "react-native";
10import { useCallback, useEffect, useMemo, useState } from "react";
11import { Alert, Platform, Pressable, RefreshControl, ScrollView, View } from "react-native";
12import { Screen, ScreenStack, ScreenStackHeaderConfig } from "react-native-screens";
1213import { useSafeAreaInsets } from "react-native-safe-area-context";
1314import { useThemeColor } from "../../../lib/useThemeColor";
1415
1516import { AppText as Text } from "../../../components/AppText";
17import { nativeHeaderScrollEdgeEffects } from "../../../native/StackHeader";
1618import { tryOpenExternalUrl } from "../../../lib/openExternalUrl";
17import { buildThreadReviewRoutePath } from "../../../lib/routes";
1819import { useEnvironmentQuery } from "../../../state/query";
1920import { useThreadSelection } from "../../../state/use-thread-selection";
2021import { useSelectedThreadGitActions } from "../../../state/use-selected-thread-git-actions";
......@@ -23,13 +24,23 @@ import { useSelectedThreadWorktree } from "../../../state/use-selected-thread-wo
2324import { vcsEnvironment } from "../../../state/vcs";
2425import { MetaCard, SheetListRow, menuItemIconName, statusSummary } from "./gitSheetComponents";
2526
26export function GitOverviewSheet() {
27 const router = useRouter();
27const HEADER_SCROLL_EDGE_EFFECTS = nativeHeaderScrollEdgeEffects(Platform.OS, Platform.Version);
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();
2839 const insets = useSafeAreaInsets();
29 const { environmentId, threadId } = useLocalSearchParams<{
30 environmentId: EnvironmentId;
31 threadId: ThreadId;
32 }>();
40 const presentation = props.presentation ?? "sheet";
41 const isInspector = presentation === "inspector";
42 const environmentId = EnvironmentId.make(props.route.params.environmentId);
43 const threadId = ThreadId.make(props.route.params.threadId);
3344 const { selectedThread } = useThreadSelection();
3445 const { selectedThreadCwd, selectedThreadWorktreePath } = useSelectedThreadWorktree();
3546 const gitState = useSelectedThreadGitState();
......@@ -37,6 +48,8 @@ export function GitOverviewSheet() {
3748
3849 const iconColor = useThemeColor("--color-icon");
3950 const borderColor = useThemeColor("--color-border");
51 const foregroundColor = String(useThemeColor("--color-foreground"));
52 const sheetColor = String(useThemeColor("--color-sheet"));
4053
4154 const gitStatus = useEnvironmentQuery(
4255 selectedThread !== null && selectedThreadCwd !== null
......@@ -48,6 +61,7 @@ export function GitOverviewSheet() {
4861 );
4962
5063 const currentBranchLabel = gitStatus.data?.refName ?? selectedThread?.branch ?? "Detached HEAD";
64 const currentStatusSummary = statusSummary(gitStatus.data);
5165 const currentWorktreePath = selectedThreadWorktreePath;
5266 const gitOperationLabel = gitState.gitOperationLabel;
5367 const busy = gitOperationLabel !== null;
......@@ -105,25 +119,24 @@ export function GitOverviewSheet() {
105119 !input.featureBranch &&
106120 requiresDefaultBranchConfirmation(input.action, isDefaultRef)
107121 ) {
108 router.push({
109 pathname: "/threads/[environmentId]/[threadId]/git-confirm",
110 params: {
111 environmentId,
112 threadId,
113 confirmAction: confirmableAction,
114 branchName,
115 includesCommit: String(
116 input.action === "commit_push" || input.action === "commit_push_pr",
117 ),
118 },
122 navigation.navigate("GitConfirm", {
123 environmentId: String(environmentId),
124 threadId: String(threadId),
125 confirmAction: confirmableAction,
126 branchName,
127 includesCommit: String(
128 input.action === "commit_push" || input.action === "commit_push_pr",
129 ),
119130 });
120131 return;
121132 }
122133
123 router.dismiss();
134 if (!isInspector) {
135 navigation.goBack();
136 }
124137 await gitActions.onRunSelectedThreadGitAction(input);
125138 },
126 [environmentId, gitActions, gitStatus.data, isDefaultRef, router, threadId],
139 [environmentId, gitActions, gitStatus.data, isDefaultRef, isInspector, navigation, threadId],
127140 );
128141
129142 const onPressMenuItem = useCallback(
......@@ -134,9 +147,9 @@ export function GitOverviewSheet() {
134147 return;
135148 }
136149 if (item.dialogAction === "commit") {
137 router.push({
138 pathname: "/threads/[environmentId]/[threadId]/git/commit",
139 params: { environmentId, threadId },
150 navigation.navigate("GitCommit", {
151 environmentId: String(environmentId),
152 threadId: String(threadId),
140153 });
141154 return;
142155 }
......@@ -148,14 +161,209 @@ export function GitOverviewSheet() {
148161 await runActionWithPrompt({ action: "create_pr" });
149162 }
150163 },
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>
152279 );
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
154348 return (
155 <View collapsable={false} className="flex-1 bg-sheet">
156 <View style={{ minHeight: 16, paddingTop: 8 }} />
349 <View
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 >
159367 <Pressable
160368 className="absolute right-3 top-4 h-9 w-9 items-center justify-center rounded-full bg-subtle"
161369 style={{ zIndex: 1, opacity: busy ? 0.45 : 1 }}
......@@ -174,76 +382,17 @@ export function GitOverviewSheet() {
174382 className="text-xs font-t3-bold uppercase text-foreground-muted"
175383 style={{ letterSpacing: 1 }}
176384 >
177 Branch
385 {isInspector ? "Repository" : "Branch"}
386 </Text>
387 <Text className={isInspector ? "pr-10 text-xl font-t3-bold" : "text-3xl font-t3-bold"}>
388 {currentBranchLabel}
178389 </Text>
179 <Text className="text-3xl font-t3-bold">{currentBranchLabel}</Text>
180 <Text className="text-foreground-secondary text-sm font-medium leading-[19px]">
181 {statusSummary(gitStatus.data)}
390 <Text className="text-foreground-secondary text-sm font-medium leading-normal">
391 {currentStatusSummary}
182392 </Text>
183393 </View>
184394
185 <ScrollView
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>
395 {content}
247396 </View>
248397 );
249398}
apps/mobile/src/features/threads/git/gitSheetComponents.tsx+1-1
......@@ -104,7 +104,7 @@ export function SheetListRow(props: {
104104 <View className="flex-1 gap-0.5">
105105 <Text className="text-foreground text-base font-t3-bold">{props.title}</Text>
106106 {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>
108108 ) : null}
109109 </View>
110110 <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: {
138138 />
139139 </View>
140140
141 <Text
142 className="min-w-0 flex-1 text-xs leading-4 text-foreground"
143 numberOfLines={1}
144 >
141 <Text className="min-w-0 flex-1 text-xs text-foreground" numberOfLines={1}>
145142 <Text
146143 className={cn(
147144 "font-t3-medium text-foreground",
......@@ -202,7 +199,7 @@ export function ThreadWorkLog(props: {
202199 >
203200 <Text
204201 selectable
205 className="text-2xs leading-[17px] text-foreground-muted"
202 className="text-2xs leading-normal text-foreground-muted"
206203 style={{ fontFamily: "ui-monospace" }}
207204 >
208205 {row.fullDetail}
apps/mobile/src/features/threads/threadContentPresentation.ts+7-1
......@@ -32,7 +32,13 @@ export function projectThreadContentPresentation(input: {
3232 detail: input.detailError,
3333 };
3434 }
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.
3642 return { kind: "loading" };
3743 }
3844 return {
apps/mobile/src/features/threads/threadPresentation.ts+103-18
......@@ -1,4 +1,5 @@
11import type { StatusTone } from "../../components/StatusPill";
2import type { OrchestrationLatestTurn, OrchestrationSession } from "@t3tools/contracts";
23import { EnvironmentThreadShell } from "@t3tools/client-runtime/state/shell";
34
45export function threadSortValue(thread: EnvironmentThreadShell): number {
......@@ -6,39 +7,123 @@ export function threadSortValue(thread: EnvironmentThreadShell): number {
67 return Number.isNaN(candidate) ? 0 : candidate;
78}
89
9export function threadStatusTone(thread: EnvironmentThreadShell): StatusTone {
10 const status = thread.session?.status;
11 if (status === "running") {
10export type ThreadStatusKind =
11 | "pending-approval"
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) {
1265 return {
13 label: "Running",
14 pillClassName: "bg-orange-500/12 dark:bg-orange-500/16",
15 textClassName: "text-orange-700 dark:text-orange-300",
66 kind: "awaiting-input",
67 label: "Awaiting Input",
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,
1673 };
1774 }
18 if (status === "ready") {
75
76 if (thread.session?.status === "running") {
1977 return {
20 label: "Ready",
21 pillClassName: "bg-emerald-500/12 dark:bg-emerald-500/16",
22 textClassName: "text-emerald-700 dark:text-emerald-300",
78 kind: "working",
79 label: "Working",
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,
2385 };
2486 }
25 if (status === "starting") {
87
88 if (thread.session?.status === "starting") {
2689 return {
27 label: "Starting",
90 kind: "connecting",
91 label: "Connecting",
2892 pillClassName: "bg-sky-500/12 dark:bg-sky-500/16",
2993 textClassName: "text-sky-700 dark:text-sky-300",
94 iconColor: "#0a84ff",
95 iconBackground: "rgba(10,132,255,0.22)",
96 pulse: true,
3097 };
3198 }
32 if (status === "error") {
99
100 if (thread.session?.status === "error" || thread.latestTurn?.state === "error") {
33101 return {
102 kind: "error",
34103 label: "Error",
35104 pillClassName: "bg-rose-500/12 dark:bg-rose-500/16",
36105 textClassName: "text-rose-700 dark:text-rose-300",
106 iconColor: "#ff453a",
107 iconBackground: "rgba(255,69,58,0.22)",
108 pulse: false,
37109 };
38110 }
39 return {
40 label: "Idle",
41 pillClassName: "bg-neutral-500/10 dark:bg-neutral-500/16",
42 textClassName: "text-neutral-600 dark:text-neutral-300",
43 };
111
112 const hasPlanReadyPrompt =
113 thread.interactionMode === "plan" &&
114 isLatestTurnSettled(thread.latestTurn, thread.session) &&
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;
44129}
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 {
22 return Math.min(Math.max(value, min), max);
33}
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
532export type LayoutVariant = "compact" | "split";
633
734export interface Layout {
......@@ -11,10 +38,25 @@ export interface Layout {
1138 readonly shellPadding: number;
1239}
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
1457export function deriveLayout(input: { readonly width: number; readonly height: number }): Layout {
1558 const { width, height } = input;
16 const shortestEdge = Math.min(width, height);
17 const wideEnoughForSplit = width >= 900 || (width >= 700 && shortestEdge >= 700);
59 const wideEnoughForSplit = width >= SPLIT_LAYOUT_MIN_WIDTH && height >= SPLIT_LAYOUT_MIN_HEIGHT;
1860
1961 if (!wideEnoughForSplit) {
2062 return {
......@@ -28,7 +70,187 @@ export function deriveLayout(input: { readonly width: number; readonly height: n
2870 return {
2971 variant: "split",
3072 usesSplitView: true,
31 listPaneWidth: clamp(Math.round(width * 0.34), 320, 420),
32 shellPadding: width >= 1180 ? 20 : 14,
73 listPaneWidth: clamp(
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,
33180 };
34181}
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
5959
6060export interface Preferences {
6161 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;
6370}
6471
6572async function readStorageItem(key: MobileStorageKeyValue): Promise<string | null> {
......@@ -153,15 +160,31 @@ export async function loadPreferences(): Promise<Preferences> {
153160
154161 const preferences: {
155162 liveActivitiesEnabled?: boolean;
156 terminalFontSize?: number;
163 baseFontSize?: number;
164 terminalFontSize?: number | null;
165 markdownFontSize?: number;
166 codeFontSize?: number | null;
167 codeWordBreak?: boolean;
157168 } = {};
158169
159170 if (typeof parsed.liveActivitiesEnabled === "boolean") {
160171 preferences.liveActivitiesEnabled = parsed.liveActivitiesEnabled;
161172 }
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) {
163177 preferences.terminalFontSize = parsed.terminalFontSize;
164178 }
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
166189 return preferences;
167190}
apps/mobile/src/lib/typography.test.ts+4-7
......@@ -3,21 +3,18 @@ import { describe, expect, it } from "vite-plus/test";
33import { MOBILE_CODE_SURFACE, MOBILE_TYPOGRAPHY } from "./typography";
44
55describe("mobile typography", () => {
6 it("uses the intentional compact mobile font scale", () => {
6 it("uses the intentional mobile font scale anchored at a 16pt body", () => {
77 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,
99 ]);
10 });
11
12 it("uses a compact shared style for editable composer text", () => {
13 expect(MOBILE_TYPOGRAPHY.composer).toEqual({ fontSize: 14, lineHeight: 20 });
10 expect(MOBILE_TYPOGRAPHY.body).toEqual({ fontSize: 16, lineHeight: 23 });
1411 });
1512
1613 it("uses caption-sized code with a compact readable row height", () => {
1714 expect(MOBILE_CODE_SURFACE).toMatchObject({
1815 fontSize: MOBILE_TYPOGRAPHY.caption.fontSize,
1916 lineNumberFontSize: MOBILE_TYPOGRAPHY.micro.fontSize,
20 rowHeight: 20,
17 rowHeight: 22,
2118 });
2219 });
2320});
apps/mobile/src/lib/typography.ts+10-11
......@@ -1,19 +1,18 @@
11export const MOBILE_TYPOGRAPHY = {
2 micro: { fontSize: 10, lineHeight: 13 },
3 caption: { fontSize: 11, lineHeight: 15 },
4 label: { fontSize: 12, lineHeight: 16 },
5 footnote: { fontSize: 13, lineHeight: 18 },
6 composer: { fontSize: 14, lineHeight: 20 },
7 body: { fontSize: 15, lineHeight: 22 },
8 headline: { fontSize: 17, lineHeight: 22 },
9 title: { fontSize: 20, lineHeight: 26 },
10 largeTitle: { fontSize: 24, lineHeight: 30 },
11 display: { fontSize: 28, lineHeight: 34 },
2 micro: { fontSize: 11, lineHeight: 14 },
3 caption: { fontSize: 12, lineHeight: 16 },
4 label: { fontSize: 13, lineHeight: 17 },
5 footnote: { fontSize: 14, lineHeight: 19 },
6 body: { fontSize: 16, lineHeight: 23 },
7 headline: { fontSize: 18, lineHeight: 23 },
8 title: { fontSize: 21, lineHeight: 28 },
9 largeTitle: { fontSize: 26, lineHeight: 32 },
10 display: { fontSize: 30, lineHeight: 36 },
1211} as const;
1312
1413/** Shared geometry for dense, horizontally scrolling code surfaces. */
1514export const MOBILE_CODE_SURFACE = {
16 rowHeight: 20,
15 rowHeight: 22,
1716 gutterWidth: 46,
1817 codePadding: 7,
1918 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";
1414
1515import { markdownFileIconSource } from "@t3tools/mobile-markdown-text/file-icons";
1616import { resolveMarkdownFileIcon } from "@t3tools/mobile-markdown-text/links";
17import { MOBILE_TYPOGRAPHY } from "../lib/typography";
1817import { useThemeColor } from "../lib/useThemeColor";
18import { useScaledTextRole } from "../features/settings/appearance/useScaledTextRole";
1919import {
2020 acknowledgeComposerNativeEvent,
2121 isComposerNativeEcho,
......@@ -69,6 +69,7 @@ interface NativeComposerEditorProps extends ViewProps {
6969 readonly onComposerPasteImages?: (event: NativePasteImagesEvent) => void;
7070 readonly onComposerFocus?: () => void;
7171 readonly onComposerBlur?: () => void;
72 readonly onComposerSubmit?: () => void;
7273}
7374
7475const NativeView = requireNativeView<NativeComposerEditorProps>(NATIVE_MODULE_NAME);
......@@ -93,6 +94,7 @@ export function ComposerEditor({
9394 onPasteImages,
9495 onFocus,
9596 onBlur,
97 onSubmit,
9698 contentInsetVertical = 0,
9799 ...props
98100}: ComposerEditorProps) {
......@@ -105,6 +107,7 @@ export function ComposerEditor({
105107 { eventCount: 0, value: props.value, selection: selection ?? null },
106108 ]);
107109 const confirmedTokensRef = useRef(collectComposerInlineTokens(props.value));
110 const bodyText = useScaledTextRole("body");
108111 const textColor = useThemeColor("--color-foreground");
109112 const placeholderColor = useThemeColor("--color-placeholder");
110113 const chipBackground = useThemeColor("--color-subtle");
......@@ -230,12 +233,12 @@ export function ComposerEditor({
230233 fontSize={
231234 typeof resolvedTextStyle.fontSize === "number"
232235 ? resolvedTextStyle.fontSize
233 : MOBILE_TYPOGRAPHY.composer.fontSize
236 : bodyText.fontSize
234237 }
235238 lineHeight={
236239 typeof resolvedTextStyle.lineHeight === "number"
237240 ? resolvedTextStyle.lineHeight
238 : MOBILE_TYPOGRAPHY.composer.lineHeight
241 : bodyText.lineHeight
239242 }
240243 contentInsetVertical={contentInsetVertical}
241244 editable={props.editable ?? true}
......@@ -270,6 +273,7 @@ export function ComposerEditor({
270273 onComposerPasteImages={(event) => onPasteImages?.(event.nativeEvent.uris)}
271274 onComposerFocus={onFocus}
272275 onComposerBlur={onBlur}
276 onComposerSubmit={onSubmit}
273277 />
274278 );
275279}
apps/mobile/src/native/T3ComposerEditor.tsx+3-2
......@@ -2,8 +2,8 @@ import { TextInputWrapper } from "expo-paste-input";
22import { useImperativeHandle, useRef } from "react";
33import { TextInput, type TextInput as RNTextInput } from "react-native";
44
5import { MOBILE_TYPOGRAPHY } from "../lib/typography";
65import { useThemeColor } from "../lib/useThemeColor";
6import { useScaledTextRole } from "../features/settings/appearance/useScaledTextRole";
77import { useNativePaste } from "../lib/useNativePaste";
88import type { ComposerEditorProps } from "./T3ComposerEditor.types";
99
......@@ -18,6 +18,7 @@ export function ComposerEditor({
1818 ...props
1919}: ComposerEditorProps) {
2020 const inputRef = useRef<RNTextInput>(null);
21 const bodyText = useScaledTextRole("body");
2122 const foregroundColor = useThemeColor("--color-foreground");
2223 const placeholderColor = useThemeColor("--color-placeholder");
2324 const handlePaste = useNativePaste((uris) => onPasteImages?.(uris));
......@@ -48,7 +49,7 @@ export function ComposerEditor({
4849 minHeight: 0,
4950 color: foregroundColor,
5051 fontFamily: "DMSans_400Regular",
51 ...MOBILE_TYPOGRAPHY.composer,
52 ...bodyText,
5253 paddingVertical: contentInsetVertical,
5354 },
5455 textStyle,
apps/mobile/src/native/T3ComposerEditor.types.ts+2
......@@ -35,4 +35,6 @@ export interface ComposerEditorProps {
3535 readonly onPasteImages?: (uris: ReadonlyArray<string>) => void;
3636 readonly onFocus?: () => void;
3737 readonly onBlur?: () => void;
38 /** Invoked by the native editor when Command-Return is pressed on a hardware keyboard. */
39 readonly onSubmit?: () => void;
3840}
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 @@
1import { useLocalSearchParams } from "expo-router";
2import { useMemo } from "react";
3import { EnvironmentId, ThreadId, type ScopedProjectRef } from "@t3tools/contracts";
1import { useRoute, type RouteProp } from "@react-navigation/native";
2import { useMemo, useRef } from "react";
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
513import { useProject, useThreadShell } from "../state/entities";
14import { useEnvironmentThread } from "../state/threads";
615import {
716 useRemoteEnvironmentRuntime,
817 useSavedRemoteConnection,
918} from "./use-remote-environment-registry";
19type ThreadSelectionRouteParams = {
20 readonly environmentId?: string | string[];
21 readonly threadId?: string | string[];
22};
1023
1124function firstRouteParam(value: string | string[] | undefined): string | null {
1225 if (Array.isArray(value)) {
......@@ -16,14 +29,48 @@ function firstRouteParam(value: string | string[] | undefined): string | null {
1629 return value ?? null;
1730}
1831
19export function useThreadSelection() {
20 const params = useLocalSearchParams<{
21 environmentId?: string | string[];
22 threadId?: string | string[];
23 }>();
24 const selectedThreadRef = useMemo(() => {
25 const environmentId = firstRouteParam(params.environmentId);
26 const threadId = firstRouteParam(params.threadId);
32function latestUserMessageAt(thread: OrchestrationThread): OrchestrationThread["updatedAt"] | null {
33 for (let index = thread.messages.length - 1; index >= 0; index -= 1) {
34 const message = thread.messages[index];
35 if (message?.role === "user") {
36 return message.createdAt;
37 }
38 }
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);
2774 if (!environmentId || !threadId) {
2875 return null;
2976 }
......@@ -32,8 +79,26 @@ export function useThreadSelection() {
3279 environmentId: EnvironmentId.make(environmentId),
3380 threadId: ThreadId.make(threadId),
3481 };
35 }, [params.environmentId, params.threadId]);
36 const selectedThread = useThreadShell(selectedThreadRef);
82 }, [routeParams.environmentId, routeParams.threadId]);
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 );
37102 const selectedProjectRef = useMemo<ScopedProjectRef | null>(
38103 () =>
39104 selectedThread === null
......@@ -49,11 +114,27 @@ export function useThreadSelection() {
49114 const selectedEnvironmentConnection = useSavedRemoteConnection(selectedEnvironmentId);
50115 const selectedEnvironmentRuntime = useRemoteEnvironmentRuntime(selectedEnvironmentId);
51116
52 return {
53 selectedThreadRef,
54 selectedThread,
55 selectedThreadProject,
56 selectedEnvironmentConnection,
57 selectedEnvironmentRuntime,
58 };
117 return useMemo(
118 () => ({
119 selectedThreadRef,
120 selectedThread,
121 selectedThreadProject,
122 selectedEnvironmentConnection,
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);
59140}
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";
1313
1414import type { EnvironmentThread, EnvironmentThreadShell } from "./models.ts";
1515import { scopeThread } from "./models.ts";
16import { EMPTY_ENVIRONMENT_THREAD_STATE, type EnvironmentThreadState } from "./threads.ts";
16import { EMPTY_ENVIRONMENT_THREAD_STATE, type EnvironmentThreadState } from "./threadState.ts";
1717import { parseThreadKey, threadKey } from "./entities.ts";
1818import { 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";
2222import { applyThreadDetailEvent } from "./threadReducer.ts";
2323import { THREAD_STATE_IDLE_TTL_MS } from "./threadRetention.ts";
2424import { followStreamInEnvironment } from "./runtime.ts";
25
26export type EnvironmentThreadStatus = "empty" | "cached" | "synchronizing" | "live" | "deleted";
27
28export interface EnvironmentThreadState {
29 readonly data: Option.Option<OrchestrationThread>;
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};
25import {
26 EMPTY_ENVIRONMENT_THREAD_STATE,
27 type EnvironmentThreadState,
28 type EnvironmentThreadStatus,
29} from "./threadState.ts";
3930
4031function statusWithoutLiveData(data: Option.Option<OrchestrationThread>): EnvironmentThreadStatus {
4132 return Option.isSome(data) ? "cached" : "empty";
......@@ -254,3 +245,4 @@ export * from "./threadCommands.ts";
254245export * from "./threadDetail.ts";
255246export * from "./threadReducer.ts";
256247export * from "./threadShell.ts";
248export * from "./threadState.ts";
packages/shared/src/dpop.ts+9-22
......@@ -5,22 +5,20 @@ import * as Option from "effect/Option";
55import * as Result from "effect/Result";
66import * as Schema from "effect/Schema";
77
8import { DpopPublicJwk as DpopPublicJwkSchema, normalizeDpopHtu } from "./dpopCommon.ts";
9import type { DpopPublicJwk as DpopPublicJwkType } from "./dpopCommon.ts";
810import { stableStringify } from "./relaySigning.ts";
911
1012const DPOP_TYP = "dpop+jwt";
1113const DPOP_ALG = "ES256";
1214const DEFAULT_MAX_AGE_SECONDS = 300;
1315
14export const DpopPublicJwk = Schema.Struct({
15 kty: Schema.Literal("EC"),
16 crv: Schema.Literal("P-256"),
17 x: Schema.String.check(Schema.isNonEmpty()),
18 y: Schema.String.check(Schema.isNonEmpty()),
19});
20export type DpopPublicJwk = typeof DpopPublicJwk.Type;
16export const DpopPublicJwk = DpopPublicJwkSchema;
17export type DpopPublicJwk = DpopPublicJwkType;
18export { normalizeDpopHtu };
2119
2220const DpopJwtHeaderPublicJwk = Schema.Struct({
23 ...DpopPublicJwk.fields,
21 ...DpopPublicJwkSchema.fields,
2422 d: Schema.optionalKey(Schema.Never),
2523});
2624
......@@ -68,7 +66,7 @@ function decodeBase64UrlDpopJwtPayload(value: string) {
6866 return decodeDpopJwtPayloadJson(Result.getOrThrow(Encoding.decodeBase64UrlString(value)));
6967}
7068
71function dpopThumbprintInput(jwk: DpopPublicJwk): string {
69function dpopThumbprintInput(jwk: DpopPublicJwkType): string {
7270 return stableStringify({
7371 crv: jwk.crv,
7472 kty: jwk.kty,
......@@ -77,18 +75,7 @@ function dpopThumbprintInput(jwk: DpopPublicJwk): string {
7775 });
7876}
7977
80export function normalizeDpopHtu(url: string): string | null {
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 {
78export function computeDpopJwkThumbprint(jwk: DpopPublicJwkType): string {
9279 return Encoding.encodeBase64Url(sha256(new TextEncoder().encode(dpopThumbprintInput(jwk))));
9380}
9481
......@@ -96,7 +83,7 @@ export function computeDpopAccessTokenHash(accessToken: string): string {
9683 return Encoding.encodeBase64Url(sha256(new TextEncoder().encode(accessToken)));
9784}
9885
99function publicKeyBytesFromJwk(jwk: DpopPublicJwk): Uint8Array {
86function publicKeyBytesFromJwk(jwk: DpopPublicJwkType): Uint8Array {
10087 const x = base64UrlToBytes(jwk.x);
10188 const y = base64UrlToBytes(jwk.y);
10289 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:
3535 version: 0.2.1
3636
3737overrides:
38 '@clerk/backend': 3.8.3-snapshot.v20260622234151
39 '@clerk/clerk-js': 6.21.0-snapshot.v20260622234151
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
38 '@clerk/backend': 3.8.4
39 '@clerk/clerk-js': 6.22.0
4540 '@clerk/clerk-js>@base-org/account': '-'
4641 '@clerk/clerk-js>@coinbase/wallet-sdk': '-'
4742 '@clerk/clerk-js>@solana/wallet-adapter-base': '-'
4843 '@clerk/clerk-js>@solana/wallet-adapter-react': '-'
4944 '@clerk/clerk-js>@solana/wallet-standard': '-'
5045 '@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
5151 '@effect/atom-react': 4.0.0-beta.78
5252 '@effect/platform-bun': 4.0.0-beta.78
5353 '@effect/platform-node': 4.0.0-beta.78
......@@ -56,7 +56,7 @@ overrides:
5656 '@effect/sql-sqlite-bun': 4.0.0-beta.78
5757 '@effect/vitest': 4.0.0-beta.78
5858 '@effect/vitest>vitest': '-'
59 '@expo/metro-config': 56.0.13
59 '@expo/metro-config': 56.0.14
6060 '@pierre/diffs>@shikijs/transformers': ^4.2.0
6161 '@types/node': 24.12.4
6262 effect: 4.0.0-beta.78
......@@ -69,21 +69,36 @@ patchedDependencies:
6969 '@effect/vitest@4.0.0-beta.78':
7070 hash: 42b87cc47e70d74e62496e7a8261b3fd298ecad4464d209348ab04b96f853a5f
7171 path: patches/@effect__vitest@4.0.0-beta.78.patch
72 '@expo/metro-config@56.0.13':
72 '@expo/metro-config@56.0.14':
7373 hash: 8cb08b5bb7051ed9d2dbe46a2c293c5a1e17f1bd6ddf30de27909e18c921ff46
74 path: patches/@expo%2Fmetro-config@56.0.13.patch
74 path: patches/@expo%2Fmetro-config@56.0.14.patch
7575 '@ff-labs/fff-node@0.9.4':
7676 hash: 2b16019ce7ab61aec6478dd02f79ef468cc1d5c51e9d00764f7d2ab8167210c8
7777 path: patches/@ff-labs__fff-node@0.9.4.patch
7878 '@pierre/diffs@1.3.0-beta.5':
7979 hash: 7cb6da88544119adda056b2f46f43956f99326227732da0b345081e285a6c53a
8080 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
8187 effect@4.0.0-beta.78:
8288 hash: c502bc684210b707dfceb87d8fe6ad6843395af6e19cfc02cd65854898bde2c5
8389 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
8496 react-native-nitro-modules@0.35.9:
8597 hash: 825622aae63a8fb5b904f3c77908a0e216261d727ea171709f2c0b6088422675
8698 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
88103importers:
89104
......@@ -111,11 +126,11 @@ importers:
111126 apps/desktop:
112127 dependencies:
113128 '@clerk/electron':
114 specifier: 0.0.2-snapshot.v20260622234151
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)
129 specifier: 0.0.5
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)
116131 '@clerk/electron-passkeys':
117 specifier: 0.0.2-snapshot.v20260622234151
118 version: 0.0.2-snapshot.v20260622234151
132 specifier: 0.0.3
133 version: 0.0.3
119134 '@effect/platform-node':
120135 specifier: 4.0.0-beta.78
121136 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:
192207 dependencies:
193208 '@callstack/liquid-glass':
194209 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)
196211 '@clerk/expo':
197 specifier: 3.5.3-snapshot.v20260622234151
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)
212 specifier: 3.6.2
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)
199214 '@effect/atom-react':
200215 specifier: 4.0.0-beta.78
201216 version: 4.0.0-beta.78(effect@4.0.0-beta.78(patch_hash=c502bc684210b707dfceb87d8fe6ad6843395af6e19cfc02cd65854898bde2c5))(react@19.2.3)(scheduler@0.27.0)
202217 '@expo-google-fonts/dm-sans':
203218 specifier: ^0.4.2
204219 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)
205223 '@expo/ui':
206 specifier: ~56.0.8
207 version: 56.0.15(961c4aa6f32829b318e3c87ef20ad401)
224 specifier: ~56.0.18
225 version: 56.0.18(19413efe5eaad64848598eedfe3a0fd3)
208226 '@legendapp/list':
209227 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)
211229 '@noble/curves':
212230 specifier: 'catalog:'
213231 version: 1.9.1
......@@ -219,7 +237,16 @@ importers:
219237 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)
220238 '@react-native-menu/menu':
221239 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)
223250 '@shikijs/core':
224251 specifier: 4.2.0
225252 version: 4.2.0
......@@ -240,7 +267,7 @@ importers:
240267 version: link:../../packages/contracts
241268 '@t3tools/mobile-markdown-text':
242269 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)
244271 '@t3tools/mobile-review-diff-native':
245272 specifier: file:./modules/t3-review-diff
246273 version: file:apps/mobile/modules/t3-review-diff
......@@ -260,80 +287,77 @@ importers:
260287 specifier: 4.0.0-beta.78
261288 version: 4.0.0-beta.78(patch_hash=c502bc684210b707dfceb87d8fe6ad6843395af6e19cfc02cd65854898bde2c5)
262289 expo:
263 specifier: ^56.0.0
264 version: 56.0.8(63f7aade424ad9e7b1154b679fa2a14d)
290 specifier: ~56.0.12
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)
265292 expo-asset:
266 specifier: ~56.0.15
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)
293 specifier: ~56.0.17
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)
268295 expo-auth-session:
269 specifier: ~56.0.12
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)
296 specifier: ~56.0.14
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)
271298 expo-build-properties:
272 specifier: ~56.0.15
273 version: 56.0.16(expo@56.0.8)
299 specifier: ~56.0.19
300 version: 56.0.19(expo@56.0.12)
274301 expo-camera:
275 specifier: ~56.0.7
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)
302 specifier: ~56.0.8
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)
277304 expo-clipboard:
278 specifier: ~56.0.3
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)
305 specifier: ~56.0.4
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)
280307 expo-constants:
281 specifier: ~56.0.16
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))
308 specifier: ~56.0.18
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))
283310 expo-crypto:
284311 specifier: ~56.0.4
285 version: 56.0.4(expo@56.0.8)
312 version: 56.0.4(expo@56.0.12)
286313 expo-dev-client:
287 specifier: ~56.0.16
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))
314 specifier: ~56.0.20
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))
289316 expo-file-system:
290 specifier: ~56.0.7
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))
317 specifier: ~56.0.8
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))
292319 expo-font:
293 specifier: ~56.0.5
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)
320 specifier: ~56.0.7
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)
295322 expo-glass-effect:
296323 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)
298325 expo-haptics:
299326 specifier: ~56.0.3
300 version: 56.0.3(expo@56.0.8)
327 version: 56.0.3(expo@56.0.12)
301328 expo-image-picker:
302 specifier: ~56.0.14
303 version: 56.0.15(expo@56.0.8)
329 specifier: ~56.0.18
330 version: 56.0.18(expo@56.0.12)
304331 expo-linking:
305 specifier: ~56.0.12
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)
332 specifier: ~56.0.14
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)
307334 expo-network:
308335 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)
310337 expo-notifications:
311 specifier: ~56.0.14
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)
338 specifier: ~56.0.18
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)
313340 expo-paste-input:
314341 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)
316 expo-router:
317 specifier: ~56.2.7
318 version: 56.2.8(5bfdf39b8f760e4dc2d5c3acffc97310)
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)
319343 expo-secure-store:
320344 specifier: ~56.0.4
321 version: 56.0.4(expo@56.0.8)
345 version: 56.0.4(expo@56.0.12)
322346 expo-splash-screen:
323347 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)
325349 expo-symbols:
326 specifier: ~56.0.5
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)
350 specifier: ~56.0.6
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)
328352 expo-updates:
329 specifier: ~56.0.17
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)
353 specifier: ~56.0.19
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)
331355 expo-web-browser:
332356 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))
334358 expo-widgets:
335 specifier: ~56.0.15
336 version: 56.0.16(961c4aa6f32829b318e3c87ef20ad401)
359 specifier: ~56.0.19
360 version: 56.0.19(19413efe5eaad64848598eedfe3a0fd3)
337361 punycode:
338362 specifier: ^2.3.1
339363 version: 2.3.1
......@@ -345,43 +369,43 @@ importers:
345369 version: 19.2.3(react@19.2.3)
346370 react-native:
347371 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)
349373 react-native-gesture-handler:
350374 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)
352376 react-native-image-viewing:
353377 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)
355379 react-native-keyboard-controller:
356 specifier: 1.21.7
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)
380 specifier: 1.21.6
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)
358382 react-native-nitro-markdown:
359383 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)
361385 react-native-nitro-modules:
362386 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)
364388 react-native-reanimated:
365389 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)
367391 react-native-safe-area-context:
368392 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)
370394 react-native-screens:
371395 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)
373397 react-native-shiki-engine:
374398 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)
376400 react-native-svg:
377401 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)
379403 react-native-webview:
380404 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)
382406 react-native-worklets:
383407 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)
385409 shiki:
386410 specifier: 4.2.0
387411 version: 4.2.0
......@@ -390,7 +414,7 @@ importers:
390414 version: 3.6.0
391415 uniwind:
392416 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)
394418 devDependencies:
395419 '@effect/vitest':
396420 specifier: 4.0.0-beta.78
......@@ -403,7 +427,7 @@ importers:
403427 version: 19.2.16
404428 babel-preset-expo:
405429 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)
407431 tailwindcss:
408432 specifier: ^4.0.0
409433 version: 4.3.0
......@@ -481,11 +505,11 @@ importers:
481505 specifier: ^1.4.1
482506 version: 1.5.0(@types/react@19.2.16)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
483507 '@clerk/electron':
484 specifier: 0.0.2-snapshot.v20260622234151
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)
508 specifier: 0.0.5
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)
486510 '@clerk/react':
487 specifier: 6.11.0-snapshot.v20260622234151
488 version: 6.11.0-snapshot.v20260622234151(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
511 specifier: 6.11.1
512 version: 6.11.1(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
489513 '@dnd-kit/core':
490514 specifier: ^6.3.1
491515 version: 6.3.1(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
......@@ -635,8 +659,8 @@ importers:
635659 infra/relay:
636660 dependencies:
637661 '@clerk/backend':
638 specifier: 3.8.3-snapshot.v20260622234151
639 version: 3.8.3-snapshot.v20260622234151(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
662 specifier: 3.8.4
663 version: 3.8.4(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
640664 '@effect/sql-pg':
641665 specifier: 4.0.0-beta.78
642666 version: 4.0.0-beta.78(effect@4.0.0-beta.78(patch_hash=c502bc684210b707dfceb87d8fe6ad6843395af6e19cfc02cd65854898bde2c5))
......@@ -1650,43 +1674,43 @@ packages:
16501674 resolution: {integrity: sha512-EYlRokl8szrP9Z25qT5aepMdBjzBvHF9ZEhzIiUBc9guz/T31EqRgvD0QSgZcpE93xiwrr+OkB4nz0BZyF6fSA==}
16511675 engines: {node: '>= 20.12.0'}
16521676
1653 '@clerk/backend@3.8.3-snapshot.v20260622234151':
1654 resolution: {integrity: sha512-B5goX0n/5pibc4dMQOfMmn4mPq7eqtrbNV20tOqO9qMPzFA+TKAj9SnB0oJvFsZAXKO6+/n16uYPbqeM2PN6CA==}
1677 '@clerk/backend@3.8.4':
1678 resolution: {integrity: sha512-3G+kEu8kalqQokJ/n0IynhBh2i6TtiilRzuAg5UWMburkK658/elrG0Uqsapd5yKhmkNuvizHwOPWwKAMxP2EQ==}
16551679 engines: {node: '>=20.9.0'}
16561680
1657 '@clerk/clerk-js@6.21.0-snapshot.v20260622234151':
1658 resolution: {integrity: sha512-mRNn6H8GbeEkcCIzZ03WZ9c1Uy8znf70okYmmeJKzK72gsdwnrxEfbu3DYE/5yRbX6lvL7ugWaNTT3DvPEbBzg==}
1681 '@clerk/clerk-js@6.22.0':
1682 resolution: {integrity: sha512-4kN1J1GHUv7utZn1UunfOi10ZVyHOVY+d7tMGXoBxrckpb4vT/rFYep8uf8Xc6Q5QNEmi1/J0DMxZqnS3xy+ww==}
16591683 engines: {node: '>=20.9.0'}
16601684
1661 '@clerk/electron-passkeys-darwin-arm64@0.0.2-snapshot.v20260622234151':
1662 resolution: {integrity: sha512-NZjIVGitAf0yyQvs1WXIAYOle9RjGsExpfwje2U20POTnNueFy56M0g39UTFQXJ42saTZ0zauLD8sd0cHqpOjA==}
1685 '@clerk/electron-passkeys-darwin-arm64@0.0.3':
1686 resolution: {integrity: sha512-oAlrd+GLqP0oREP3hNRc6yNZhXGaQgJss9iCWRQqXjpw1yZD1ZU+L+E2Gfg84vMj/NSUhxbU0Djy0RXhmeUwgg==}
16631687 cpu: [arm64]
16641688 os: [darwin]
16651689
1666 '@clerk/electron-passkeys-darwin-x64@0.0.2-snapshot.v20260622234151':
1667 resolution: {integrity: sha512-gGAwinVoIxa9lefCJjhf5D6oA8uq1sXQ/JwqffkBPJrvczYyAv9FYuQ1/ihji3jTplC0/bpTuIrwBkBCzwj5ug==}
1690 '@clerk/electron-passkeys-darwin-x64@0.0.3':
1691 resolution: {integrity: sha512-0UPAWQEni7o8gjWwd2sP97fPshJmT2w5b8eV/jOPVSP8NqIcFCm5yIFSEx1SfB53LGfzB01A4iEGi2pxiCHqyQ==}
16681692 cpu: [x64]
16691693 os: [darwin]
16701694
1671 '@clerk/electron-passkeys-win32-arm64-msvc@0.0.2-snapshot.v20260622234151':
1672 resolution: {integrity: sha512-anBFktMTAF7of37nLQsqQuW489w563J75l4QolWtblKbrBjlwFsEif95uj3vlQj0qWVVRWpAcxD21oD6wdJcwQ==}
1695 '@clerk/electron-passkeys-win32-arm64-msvc@0.0.3':
1696 resolution: {integrity: sha512-bbRvifm9A/gfPkwC1UyMyF9sYGEs0aen7J2kCcaOIW3K0PXcAbHtsR/gnx4dT0bjFdd21vSa2zlIdERvHc1BiA==}
16731697 cpu: [arm64]
16741698 os: [win32]
16751699
1676 '@clerk/electron-passkeys-win32-x64-msvc@0.0.2-snapshot.v20260622234151':
1677 resolution: {integrity: sha512-Pq4FOklTJNpyMTJpxY+/gq6CukvWHFJfGYjuz985cPHup1OSsVmuO/GYil36vh7Qbe8vDivT91wNe73DbEOcVw==}
1700 '@clerk/electron-passkeys-win32-x64-msvc@0.0.3':
1701 resolution: {integrity: sha512-gFaVqKlOKTF2SJ3lMwkZ0oonycQn93p1qSrp5kzzeZBeQ+tGB3gQnjXecrhQy21AzDUyWqVJJ8KrP2HRAwifWg==}
16781702 cpu: [x64]
16791703 os: [win32]
16801704
1681 '@clerk/electron-passkeys@0.0.2-snapshot.v20260622234151':
1682 resolution: {integrity: sha512-UvBweTg9+FCbygxARV7RvJ4/lcQgLN+gJC6Lj++cLmbxpgRwRAgR9xXRlKV6dVg1p5k81a4DcFNMu/oR6zBdlQ==}
1705 '@clerk/electron-passkeys@0.0.3':
1706 resolution: {integrity: sha512-OHhIe88qDL+FxyBalXdXNHAS5eEramr6Rerp+6iNkfkjqT8rx4hHNmfpmjg5/T1/am8QfknbOBZkqoXZlCrjPg==}
16831707 engines: {node: '>=20.9.0'}
16841708
1685 '@clerk/electron@0.0.2-snapshot.v20260622234151':
1686 resolution: {integrity: sha512-T0LUJeAPaAZxpk13Q14b9LSVKKio/X/zFo67hgdr35MaOChsn4T6lQ/rEL7laScQBduskcs1yyjr3e9ndy5ojg==}
1709 '@clerk/electron@0.0.5':
1710 resolution: {integrity: sha512-usPkmcKsSBuqs9zUctVZLlde3Fhu+pYJ+8fHZPL7aPYfupIe0qvsWOgJIb6GXSrEshcBg104MdSzuVT2w7Dzxg==}
16871711 engines: {node: '>=20.9.0'}
16881712 peerDependencies:
1689 '@clerk/electron-passkeys': 0.0.2-snapshot.v20260622234151
1713 '@clerk/electron-passkeys': 0.0.3
16901714 electron: '>=28'
16911715 electron-store: ^8.2.0
16921716 react: ^18.0.0 || ~19.0.3 || ~19.1.4 || ~19.2.3 || ~19.3.0-0
......@@ -1699,11 +1723,11 @@ packages:
16991723 react-dom:
17001724 optional: true
17011725
1702 '@clerk/expo@3.5.3-snapshot.v20260622234151':
1703 resolution: {integrity: sha512-qeKTJYA7cTe5oCmRVCT4A2QEPRp2af0ox0VdXzIhWRqRVFNF5bnsZRNKE2e4QnsC16NYLsGHLR5uQGC15qBiug==}
1726 '@clerk/expo@3.6.2':
1727 resolution: {integrity: sha512-8h4j62YLEtKlAq6eptfwB6jauOy7QQA+lMqRCY00n9PL1oQG7Y+sTGDg6ylX66O/VG2YOsqnBdlZcNUgt3xd2A==}
17041728 engines: {node: '>=20.9.0'}
17051729 peerDependencies:
1706 '@clerk/expo-passkeys': 1.1.9-snapshot.v20260622234151
1730 '@clerk/expo-passkeys': '>=0.0.6'
17071731 expo: '>=53 <57'
17081732 expo-apple-authentication: '>=7.0.0'
17091733 expo-auth-session: '>=5'
......@@ -1735,15 +1759,15 @@ packages:
17351759 react-dom:
17361760 optional: true
17371761
1738 '@clerk/react@6.11.0-snapshot.v20260622234151':
1739 resolution: {integrity: sha512-yF4jQFJqEHAqZCpOtjQ/Kg9yqZlEG6vW2vmVR5kVwgESkpOR1KPJIEMU8o3b6W86RKQai4lt3CWtY+o7CJsDyw==}
1762 '@clerk/react@6.11.1':
1763 resolution: {integrity: sha512-iEWckisZa/V3siCFlvGTNg7Yj+kndRx7HKBZ/QMSd6uEU9kd2tZ8Ymvd4GFPCpClwcki4h7jC750FujN656Jqg==}
17401764 engines: {node: '>=20.9.0'}
17411765 peerDependencies:
17421766 react: ^18.0.0 || ~19.0.3 || ~19.1.4 || ~19.2.3 || ~19.3.0-0
17431767 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':
1746 resolution: {integrity: sha512-OYu+hO0GHiHwYTwSFe/GCHmuQlyTHyNa7fJ8vrmtUyML09mf+dayRFeUnxgkwOYhGriyq40t1zxdVx53Td73xg==}
1769 '@clerk/shared@4.22.0':
1770 resolution: {integrity: sha512-GZ56kzUB2UBb8MCF+Eo8WIey+W4RP1tAkyOL+geiHxrQxJlUcgD+xalY2YPJLH8WVFwtOnfIl8KPEo0M0e/DRg==}
17471771 engines: {node: '>=20.9.0'}
17481772 peerDependencies:
17491773 react: ^18.0.0 || ~19.0.3 || ~19.1.4 || ~19.2.3 || ~19.3.0-0
......@@ -2387,8 +2411,8 @@ packages:
23872411 '@expo-google-fonts/material-symbols@0.4.38':
23882412 resolution: {integrity: sha512-IJkBtN1o8u9BW5fvSii1MyHPQ7Q0HxbWcVBvOrOzgMLpVtZw7R2w94wBTVR7kZwv3w1JNTESMmLA5Sqn1+Z36A==}
23892413
2390 '@expo/cli@56.1.13':
2391 resolution: {integrity: sha512-7n5VzlBr7TKW0BgWgpEopWy+v8buPhMvbSEsuXD+bI1YIJBopkfWAub0qTvlc357E8wWOvV5MJXYyoeRvoOjoQ==}
2414 '@expo/cli@56.1.16':
2415 resolution: {integrity: sha512-VBQn0mqAwc67b9Cn0RVXyeodghomAx5xGRhA/bXaQzuxDjMQk0zIOb6pXMZX7yiIwJW66UZt/zQiJNSv6aWJYw==}
23922416 hasBin: true
23932417 peerDependencies:
23942418 expo: '*'
......@@ -2406,9 +2430,15 @@ packages:
24062430 '@expo/config-plugins@56.0.8':
24072431 resolution: {integrity: sha512-phTuyBhgVLfqUHMjQkAfRtbyoY6yTxoKja1awtpVnEkoJDxPJuXx1KX5uvq1eZtt4bJQ08OBJ6P95INqRSHpRg==}
24082432
2433 '@expo/config-plugins@56.0.9':
2434 resolution: {integrity: sha512-/6a/S9USwx8OC9tGjHxbviLFiBHyueN3aoNWMLvWDEJoZ1CIVW800ZBzwXq/FYNK2qzcN1LxFmQtzD1zeFQKNA==}
2435
24092436 '@expo/config-types@56.0.5':
24102437 resolution: {integrity: sha512-GsAHO/MwW9ZRdgnmyfRXqVGLCP/zejD6rWnp5OROp8mBGRObKm4HfrjlUyT1skjMwCj1OrURx9ZfIc6yeBAkIA==}
24112438
2439 '@expo/config-types@56.0.6':
2440 resolution: {integrity: sha512-4Y6Aum5J4Re5NnxGVofRNe1aDwUBOmWhQYkynZsqzRtX/zEA1ADUeyHXuEckv9YD9djiyT7bKtLt5gKL3mA6VQ==}
2441
24122442 '@expo/config@56.0.9':
24132443 resolution: {integrity: sha512-/lqFeWGSrhpKJVP8tTN8LjuoIe8u8q2w7FzBL0C+wHgl+WM8l1qUIEYWy/sMvsG/NbpUIUsDHJRhQvOkU58eIw==}
24142444
......@@ -2437,18 +2467,18 @@ packages:
24372467 resolution: {integrity: sha512-9HnnIbzwTTdbwSjNLXTk0fPm9ZwMJ7c1/31tsni8HZ8Q62KzYCyspahH+V365vg5J6lr001DzNwBxVWSaYCQLg==}
24382468 engines: {node: '>=20.12.0'}
24392469
2440 '@expo/expo-modules-macros-plugin@0.0.9':
2441 resolution: {integrity: sha512-odai6D7ng/gA7At8ukFcWcauNEeDdyVqzVPbQxDkyU2NTJ4kgphA4I5iigS5C4LXFicSIzEt2nzdlLM8sjsTdA==}
2470 '@expo/expo-modules-macros-plugin@0.2.2':
2471 resolution: {integrity: sha512-4IMzPDIo/VOXREQjsJtliSfqYVZvfzU2SLFS/9sKMWF848S8CHx+e/E+Vf0TcMvpWCCKX5umyqxb13KJJ+YUzg==}
24422472
2443 '@expo/fingerprint@0.19.3':
2444 resolution: {integrity: sha512-w9Au2IVrtc0Ct+WRa05DVHGNHXYq6VyPZWuFP+5x055OeZ5q6k5Yg+aJ1gfShmjdOhDftRcsvmWmTdTZlWaTZw==}
2473 '@expo/fingerprint@0.19.4':
2474 resolution: {integrity: sha512-PsowRlO8+S7JlO8go7yhNEXp7sqlsWDE2AlCwoss7zH0dcajXFo74Fy0KdXEc4UXK7kKoHD37oDgsZ8aHSLr7A==}
24452475 hasBin: true
24462476
24472477 '@expo/image-utils@0.10.1':
24482478 resolution: {integrity: sha512-YDeefvmYdihS7Wp3ESDUVnOgOSWmj2Cczm9lVNDdm4MqQLdAKm/LPYg83HtFQPfefRlAxyHrQR/O9kIXN9C1Wg==}
24492479
2450 '@expo/inline-modules@0.0.10':
2451 resolution: {integrity: sha512-DKEfq877UTAmL/gOT5aFhlLNDg/EVmTSca7JQMKDGR6mjFSAcrmQf4GJNsi6ztiaqj6cTnIfoSz0hAYdnr6RJQ==}
2480 '@expo/inline-modules@0.0.12':
2481 resolution: {integrity: sha512-SNIZr/HWfIQPTZBwmukItxpc7ws1SgMUywYq1dnQvDknQDjJcuWAasIRFUjsK15yQ1xb4G5CP7VHtbN3V4lENg==}
24522482
24532483 '@expo/json-file@10.2.0':
24542484 resolution: {integrity: sha512-S6XzKe3R9GQeHiUPXc3xJjOv2VJhOEwFYf7xdC2z2cUqt3kZJ9mSO877sNQloVdnW/SUCtPY3bexlM7nwq+CAQ==}
......@@ -2456,16 +2486,16 @@ packages:
24562486 '@expo/local-build-cache-provider@56.0.8':
24572487 resolution: {integrity: sha512-UsuXwpNi57MNhzZ3be4XThc8xW6nzk3Wu37s1+2qcfZGeJcMLKDFfwO6n8YXeIiGlCsOi0Ee1rsTdgjrKt/YJQ==}
24582488
2459 '@expo/log-box@56.0.12':
2460 resolution: {integrity: sha512-budE6AGmJbpOJfGSOz+JVP3+FevElT82IEIg+ukQ4gZpW/dGO7QX1unFjanKdSaYgudBwJ4FCFGMwWhW/1tXVQ==}
2489 '@expo/log-box@56.0.13':
2490 resolution: {integrity: sha512-QWRZSpWPyjkDLVQio4R7oAzg/Av2MOt/DciFkfjr8qQ3qxGVn1Rt1oHP/80hvcWDcHFV7N6PqpyxRXw6nbxzKQ==}
24612491 peerDependencies:
24622492 '@expo/dom-webview': ^56.0.5
24632493 expo: '*'
24642494 react: '*'
24652495 react-native: '*'
24662496
2467 '@expo/metro-config@56.0.13':
2468 resolution: {integrity: sha512-OPyNYiex/6Ms8zT2POdIZsLhcAZYk7O+yJvpz5uG/4QRA7aiESfCy1I+0YHewMlR4P1YQeyxIrfTurs6m9xfZA==}
2497 '@expo/metro-config@56.0.14':
2498 resolution: {integrity: sha512-O3CIHruaTJhswPAf/nf3i8QQ3f2jl+mEwSea1eb3khuplabdy/wTQz+JvHN8VGUFyg7JKwUGU1QfO6T3JiSQqA==}
24692499 peerDependencies:
24702500 expo: '*'
24712501 peerDependenciesMeta:
......@@ -2475,10 +2505,10 @@ packages:
24752505 '@expo/metro-file-map@56.0.3':
24762506 resolution: {integrity: sha512-5OGW3z8LgEYgMJOR7F3pC8llFLkb1fVqwAewbCl6S4Vkha8AFQMwOjT+9Wbka+V4rmpljpGqOnMhF4xZbD961w==}
24772507
2478 '@expo/metro-runtime@56.0.13':
2479 resolution: {integrity: sha512-aMaFa/RPYm2iQoyYOB5q8AxDmWvf4E2yFbZ6rmBIQWaIPDdixGVUlLQeV8DlDAfZ/j+pNYO7l5M+774WbgkTgg==}
2508 '@expo/metro-runtime@56.0.15':
2509 resolution: {integrity: sha512-WIWeVsL6kCSB57oYZdUA4MTkH7c67UFMIjdNoQzKXwxZYwBFE/xL2cGPDC3z8RWt0femzJTVxAVZUOW/hiqRzA==}
24802510 peerDependencies:
2481 '@expo/log-box': ^56.0.12
2511 '@expo/log-box': ^56.0.13
24822512 expo: '*'
24832513 react: '*'
24842514 react-dom: '*'
......@@ -2500,8 +2530,8 @@ packages:
25002530 '@expo/plist@0.7.0':
25012531 resolution: {integrity: sha512-vrpryU1GoqSIRNqRB2D3IjXDmzNYfiQpEF6AH/xknlD7eiYmEDt3mb26V7cLcedcPG8PY/1xWHdBXVQJfEAh6Q==}
25022532
2503 '@expo/prebuild-config@56.0.14':
2504 resolution: {integrity: sha512-JHdMqR7Mf5ApLC50ZwTL0Z86ezrHOMYwoSHcWT6Pha/+1TcC+/J+i7vjhP06wGXQ2Kvjt74p/3mKg2Pd12KjhQ==}
2533 '@expo/prebuild-config@56.0.16':
2534 resolution: {integrity: sha512-ce9ENfPWO4WUWUVQz0OaqL3KYZ7YofP8O35ncnn7CHCaKwQ7BqxcCGJbh+qvP1UjlWeNB3CjHPrXXJ3bnZwlJw==}
25052535
25062536 '@expo/require-utils@56.1.3':
25072537 resolution: {integrity: sha512-KyLeOn/zzQSvuPpV5YhB/FPKnpQytno4luN918bGdPDssLBoS3N/0UbC3W0rJAn9kSFu+XpfR81eABRVsSdfgQ==}
......@@ -2511,15 +2541,15 @@ packages:
25112541 typescript:
25122542 optional: true
25132543
2514 '@expo/router-server@56.0.12':
2515 resolution: {integrity: sha512-RqKV2/Z8BH/z8l0ngSpG6//5xxJPaF5dTQvSfPQ0nrvCjikGMeIvyj3B9BeLnmZZhxb3gBtXqrj3irAoiIp2aQ==}
2544 '@expo/router-server@56.0.14':
2545 resolution: {integrity: sha512-2UCTtZfcq1ZPgp3wk8/+sq9DvFI9UxrPr1jcEKMAF2DGAJLosnpc8GWNNg2hkjt6SHUOdFHIPxujWPYyho2y3A==}
25162546 peerDependencies:
2517 '@expo/metro-runtime': ^56.0.13
2547 '@expo/metro-runtime': ^56.0.15
25182548 expo: '*'
2519 expo-constants: ^56.0.16
2520 expo-font: ^56.0.5
2549 expo-constants: ^56.0.18
2550 expo-font: ^56.0.6
25212551 expo-router: '*'
2522 expo-server: ^56.0.4
2552 expo-server: ^56.0.5
25232553 react: '*'
25242554 react-dom: '*'
25252555 react-server-dom-webpack: ~19.0.1 || ~19.1.2 || ~19.2.1
......@@ -2546,8 +2576,8 @@ packages:
25462576 '@expo/sudo-prompt@9.3.2':
25472577 resolution: {integrity: sha512-HHQigo3rQWKMDzYDLkubN5WQOYXJJE2eNqIQC2axC2iO3mHdwnIR7FgZVvHWtBwAdzBgAP0ECp8KqS8TiMKvgw==}
25482578
2549 '@expo/ui@56.0.15':
2550 resolution: {integrity: sha512-PFZBzztQGCp2bRFP8wIOb5ntP2ORH2GdQkJMSJcDOd4NldoWMe1pFqv7PdthjNlaaTHHTTHK+RsQrz+M6z6isw==}
2579 '@expo/ui@56.0.18':
2580 resolution: {integrity: sha512-2XgH5obigGtXm8zlb/V3g87NSiIcBcJ1xoQOEQYPoExL1DCNsHzaIecTh1XG/f/45ardo4OZNJwpbfYJ9X3qrQ==}
25512581 peerDependencies:
25522582 '@babel/core': '*'
25532583 expo: '*'
......@@ -2566,8 +2596,10 @@ packages:
25662596 react-native-worklets:
25672597 optional: true
25682598
2569 '@expo/ws-tunnel@1.0.6':
2570 resolution: {integrity: sha512-nDRbLmSrJar7abvUjp3smDwH8HcbZcoOEa5jVPUv9/9CajgmWw20JNRwTuBRzWIWIkEJDkz20GoNA+tSwUqk0Q==}
2599 '@expo/ws-tunnel@2.0.0':
2600 resolution: {integrity: sha512-j+JfTRdCk820J9dU0sA2SqshQIKFOMo7ED84w9MJFcebfbNQgsLztEY/SABDkGnjatrW4xGqnUhVRxSBVyCkXw==}
2601 peerDependencies:
2602 ws: ^8.0.0
25712603
25722604 '@expo/xcpretty@4.4.4':
25732605 resolution: {integrity: sha512-4aQzz9vgxcNXFfo/iyNgDDYfsU5XGKKxWxZopw0cVotHiW+U8IJbIxMaxsINs6bHhtkG3StKNPcOrn3eBuxKPw==}
......@@ -3842,6 +3874,41 @@ packages:
38423874 '@types/react':
38433875 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
38453912 '@rolldown/binding-android-arm64@1.0.0-rc.17':
38463913 resolution: {integrity: sha512-s70pVGhw4zqGeFnXWvAzJDlvxhlRollagdCCKRgOsgUOH3N1l0LIxf83AtGzmb5SiVM4Hjl5HyarMRfdfj3DaQ==}
38473914 engines: {node: ^20.19.0 || >=22.12.0}
......@@ -5456,6 +5523,21 @@ packages:
54565523 expo-widgets:
54575524 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
54595541 badgin@1.2.3:
54605542 resolution: {integrity: sha512-NQGA7LcfCpSzIbGRbkgjgdWkjy7HI+Th5VLxTJfW5EeaAf3fnS+xWQaQOCYiny+q6QSvxqoSO04vCx+4u++EJw==}
54615543
......@@ -6384,26 +6466,26 @@ packages:
63846466 peerDependencies:
63856467 expo: '*'
63866468
6387 expo-asset@56.0.15:
6388 resolution: {integrity: sha512-BHGi2IAOPQTcOelkUdcz1WIknfCTRjkcpYHX1azjMwgYenrVC+J5qcqJGaC8eUOWLCRtkRJWGnmFQRYtLU1nUQ==}
6469 expo-asset@56.0.17:
6470 resolution: {integrity: sha512-GFN5j+8SPkyv0nfsiFHewmdB/D0tL237TsBE/gSfFOFy/J3a52py7IulcSqkA3sQE/u/UlD5BmvP5ssS4//nUg==}
63896471 peerDependencies:
63906472 expo: '*'
63916473 react: '*'
63926474 react-native: '*'
63936475
6394 expo-auth-session@56.0.13:
6395 resolution: {integrity: sha512-LR8Suq8BHKRFBUcAKTMmZufCcDcr0sQa8rIYit1r7kshrqAy9glIUU4aqHt8tflW/ISN0x1vU+HU8AQaackM0A==}
6476 expo-auth-session@56.0.14:
6477 resolution: {integrity: sha512-b6URDBKXVWBjHwypnbCPW6A3PrwYyFqzLXtTrrpTGpmDlsxk7xuz6wIA77sBNziz3hMxt11Nu70iY0fJZYT4jA==}
63966478 peerDependencies:
63976479 react: '*'
63986480 react-native: '*'
63996481
6400 expo-build-properties@56.0.16:
6401 resolution: {integrity: sha512-C3avazYP2fR8efJBBmhx8yITjIRDaIe3ULPk0YfACP61QfnWC9u3LxaDNNaiIvYfZ+CLne30W+nS5F6pdgO/8g==}
6482 expo-build-properties@56.0.19:
6483 resolution: {integrity: sha512-InoviXcxWosNp4cC7L3SWoiY99Xr2HdgN+LYHb6mUm/BBVxy1mIMrZR+3PJ2gwDZzW6EJNDz8ioASWGHBTmzpA==}
64026484 peerDependencies:
64036485 expo: '*'
64046486
6405 expo-camera@56.0.7:
6406 resolution: {integrity: sha512-c8z+UheidFintQyP9XLEDP43aK4PS/o9+TFLW0zEOjdqkYCBgoWq6Mw/Ps62kjBeftFY7xrp5ZLITbenNvbTaw==}
6487 expo-camera@56.0.8:
6488 resolution: {integrity: sha512-UDOpUUMisFRmCv1XQV1MJCKGAH2CsIC1Rs6P9Bbc6JLVmbxEKAd5dK68y6cScOdWURxVfJ0PRcjYnSuc8ayyIQ==}
64076489 peerDependencies:
64086490 expo: '*'
64096491 react: '*'
......@@ -6413,15 +6495,15 @@ packages:
64136495 react-native-web:
64146496 optional: true
64156497
6416 expo-clipboard@56.0.3:
6417 resolution: {integrity: sha512-8mCdhmAomm0yBIonJFjAhKUXvSkc2avdNh4+rBwoe7DSWF2AC4w3uy+pa419rvVFbTyVxOBmh83UHAbUwD6qAg==}
6498 expo-clipboard@56.0.4:
6499 resolution: {integrity: sha512-qb4DYlkiowHYHaUYVT2FN9nk/nI1xShXOUYsI7J9dVpQCOHcGFjCBPX1VAvEW4Ye4/Aagd6IuhOVAq/+scBOiA==}
64186500 peerDependencies:
64196501 expo: '*'
64206502 react: '*'
64216503 react-native: '*'
64226504
6423 expo-constants@56.0.16:
6424 resolution: {integrity: sha512-6tsiN+gmTUPp/atyA+uY9Tg8VOdXdmb4s/3TVGolfn6A/oCAraw1pcPZX5XllyD+xUguxB6eBSFAT8494hZVMA==}
6505 expo-constants@56.0.18:
6506 resolution: {integrity: sha512-8AMtbDGl/WVPnWlmbpGmvcdnNCy9E4PFnwdVwj600vljkMDPSxcAcjw8GVXEPk3PpZ+ngTqsrkltWyj0UKYAxw==}
64256507 peerDependencies:
64266508 expo: '*'
64276509 react-native: '*'
......@@ -6431,13 +6513,13 @@ packages:
64316513 peerDependencies:
64326514 expo: '*'
64336515
6434 expo-dev-client@56.0.18:
6435 resolution: {integrity: sha512-pTfDcYTOvrs4vCgAaM+vP2OEO93oGkczgGpTAzCY7ZTIvtPhpekJURHBxfOnKvfn97IF3Hk+8J9tMozsNDj0Gw==}
6516 expo-dev-client@56.0.20:
6517 resolution: {integrity: sha512-KebW4r8HhIiRrPzs6ZqVhp/so8buyglAO1h4No0Ibr5C2XRnlIoGWCN4zC6rW7IsI3iKUXcofLAQV9OjoxjiwQ==}
64366518 peerDependencies:
64376519 expo: '*'
64386520
6439 expo-dev-launcher@56.0.18:
6440 resolution: {integrity: sha512-7acFJlkAbp3cMz7Uy787todMR/3A/Row2EOPD21RRoetvzJe4DTm9s7RwJ8PDtyNyued9rooD4+Q6rD8ijpTgw==}
6521 expo-dev-launcher@56.0.20:
6522 resolution: {integrity: sha512-cTuC3GkPl9CTwO3CKnVmEm9qoQ0WairhwvTh6qMlg+zr/QU/tdiU++uDBX67hf9+FuxQOkWGp5khFNosT+0cIg==}
64416523 peerDependencies:
64426524 expo: '*'
64436525 react-native: '*'
......@@ -6447,8 +6529,8 @@ packages:
64476529 peerDependencies:
64486530 expo: '*'
64496531
6450 expo-dev-menu@56.0.16:
6451 resolution: {integrity: sha512-aVgoe+YGhrQnpwiB5BRI7G+uQnGHMUij32bBnEVdc6eJrVZCStxQlV9NeFbbXxrDhLJt6OSqbCHbLR+XToWUUA==}
6532 expo-dev-menu@56.0.17:
6533 resolution: {integrity: sha512-OofRkOOZnaDriSav3JDN4NP2lsLt2eOa/Ryptr5nMD62SwnFyK4R6n6PkPVaDU3LSsZqndAJHmN6inS+oziayQ==}
64526534 peerDependencies:
64536535 expo: '*'
64546536 react-native: '*'
......@@ -6456,14 +6538,14 @@ packages:
64566538 expo-eas-client@56.0.1:
64576539 resolution: {integrity: sha512-r8h0ZIExacCrSRgY+ARfhMvFqosLHLJt1L7jyhvabfr1DN/ZDKDsYbovss2tzkpEUZGxZ3BPcB5epCwUsBBdOA==}
64586540
6459 expo-file-system@56.0.7:
6460 resolution: {integrity: sha512-dcKzo8ShPloM7jgfnMcJStgQebhP8owVjCkNI/aX6NMFV1CYB8bxKGMdnzJ3mXk5nfaiW+F/lSKr2UIJ02WAUA==}
6541 expo-file-system@56.0.8:
6542 resolution: {integrity: sha512-NrH41/8snGIBSbYicwVLB4txPdgCATd7ZYhMAGS3YJZ9GbnduhlAoV4/YCbGayjrbpE9bJb/6wegPL/zmvRMnQ==}
64616543 peerDependencies:
64626544 expo: '*'
64636545 react-native: '*'
64646546
6465 expo-font@56.0.5:
6466 resolution: {integrity: sha512-WLoDu9hlEgPRKXJRR01HFLJ6Z2tFcORX/WFPRYBndmYc5kjQrFGH/j4BRaF3aBRPyYEAUXiUJybNLXkKCwEXQw==}
6547 expo-font@56.0.7:
6548 resolution: {integrity: sha512-hpU/vRwPzsby9lPGkA4blDqLIIXYzoWnCZHr6PxvcWbY/uPObAiyhh6q+e0WYsB65SthK+PLH95jEnVag7fwEg==}
64676549 peerDependencies:
64686550 expo: '*'
64696551 react: '*'
......@@ -6486,8 +6568,8 @@ packages:
64866568 peerDependencies:
64876569 expo: '*'
64886570
6489 expo-image-picker@56.0.15:
6490 resolution: {integrity: sha512-7zmrUcqiNKwGGGL114gC+1aaquhmMbaXCNSHjUcoINdBzvIEVMJDm+DNCxkpftEYeb17X3shqInI4lCgJNVO5g==}
6571 expo-image-picker@56.0.18:
6572 resolution: {integrity: sha512-sCjQ8M27bhGUv2vUavIE+uWdYo79b2D7Q5h9B66BSDZ+Rd8YyLVSf7vYGfIzQ7nMVoENZ6c4xo/JiDkEeQ9iTg==}
64916573 peerDependencies:
64926574 expo: '*'
64936575
......@@ -6500,8 +6582,8 @@ packages:
65006582 expo: '*'
65016583 react: '*'
65026584
6503 expo-linking@56.0.13:
6504 resolution: {integrity: sha512-38YrpTh6xdiDxmYSDIUffDqev1hIcEggw2fZ3IZhNp2DVLF1xvqsbO6hJD1fuBKN8P34B3Ggc9Yy26fkqdfCOA==}
6585 expo-linking@56.0.14:
6586 resolution: {integrity: sha512-IvVQHWC+Cj4fK5qD3iEVYqpU2a4rLW0IpAAlGJ4MH+H1fyZiHh3eN6qg2WmoclOEPfYATSuEa+dQT6wfgVpXlQ==}
65056587 peerDependencies:
65066588 react: '*'
65076589 react-native: '*'
......@@ -6511,12 +6593,12 @@ packages:
65116593 peerDependencies:
65126594 expo: '*'
65136595
6514 expo-modules-autolinking@56.0.14:
6515 resolution: {integrity: sha512-9ugtZkheNPYDkW4DZopY1rH2BCbUICaafUEPxRgbLDR5UNRF5K3cdHMIMEt8pxZPq2+eX4wCm+6pbSvdY/DPHg==}
6596 expo-modules-autolinking@56.0.16:
6597 resolution: {integrity: sha512-9JnL4N46P8ubDpDIfWolDn7nxU2j1rY67xY/dNVuyH0m+HG+r/JI16VYtjIf4COpZtEuFo4D3h3MBeFzGucMnw==}
65166598 hasBin: true
65176599
6518 expo-modules-core@56.0.14:
6519 resolution: {integrity: sha512-dl1TlYRm1k7xk9QeAyDoMfFE2p6rNyzHUcH5ArcGwUzO8YKku+Z2tQ8+kG7zLe3OhfMoJcFR/czrFy7vGSVI6w==}
6600 expo-modules-core@56.0.17:
6601 resolution: {integrity: sha512-5J8whnT7Ccp+BrFClLmpF76omBqn95VZExroTm01Dgjm4vpty1Rb7U3we+ZUceNHtRd07Lw30u7FNfDgIhEbRQ==}
65206602 peerDependencies:
65216603 react: '*'
65226604 react-native: '*'
......@@ -6525,8 +6607,8 @@ packages:
65256607 react-native-worklets:
65266608 optional: true
65276609
6528 expo-modules-jsi@56.0.7:
6529 resolution: {integrity: sha512-iBAj4Xeh/8HT201VVxFlmf+VBfmtQV1ZUoJdLQQENm0+j9gnD2QswZLJyNo3CmNNXl46esJeLR5lpGpYZts/zA==}
6610 expo-modules-jsi@56.0.10:
6611 resolution: {integrity: sha512-fHZcFpYO/o62GYa6fJyAQJZcAShzhoN0iMMDzbr7vD3ewET6e1vAlTonbEakN9F0VHEgBFJ4NREy87uwVcpCuA==}
65306612 peerDependencies:
65316613 react-native: '*'
65326614
......@@ -6536,8 +6618,8 @@ packages:
65366618 expo: '*'
65376619 react: '*'
65386620
6539 expo-notifications@56.0.15:
6540 resolution: {integrity: sha512-F+OasAePiVnHaPNKI9JAYV8fg8bdBwo7Mh9R3ydBp8S21fRQyxKOSgJvj8fX/HoPFFIC6V2B+y1LJbG5Ovh/Fg==}
6621 expo-notifications@56.0.18:
6622 resolution: {integrity: sha512-HHnrwyCLC5srFojcHYS2KskbNroy9o2fwPKdyhjrdjjrBu4sNRKm4LepcuZjDy98cZKEm89WIPW8O45vut8Rgw==}
65416623 peerDependencies:
65426624 expo: '*'
65436625 react: '*'
......@@ -6550,15 +6632,15 @@ packages:
65506632 react: '*'
65516633 react-native: '*'
65526634
6553 expo-router@56.2.8:
6554 resolution: {integrity: sha512-l387I/ddPY/5SS+Rfpp1SrRV9gBKevxtPuZod7igMjR6L674QrxEwGiAILRq6AKCSbrP2I0ufKj7e5xz8JqA4Q==}
6635 expo-router@56.2.11:
6636 resolution: {integrity: sha512-08DBTrKv3QanOc9u1JNxSEChW9c/qNFbQ0dO28OLvufWWfdSRkSdHmh365D2FgoZg1qaOzZPCDuL3tM6nGSfkQ==}
65556637 peerDependencies:
6556 '@expo/log-box': ^56.0.12
6557 '@expo/metro-runtime': ^56.0.13
6638 '@expo/log-box': ^56.0.13
6639 '@expo/metro-runtime': ^56.0.15
65586640 '@testing-library/react-native': '>= 13.2.0'
65596641 expo: '*'
6560 expo-constants: ^56.0.16
6561 expo-linking: ^56.0.13
6642 expo-constants: ^56.0.18
6643 expo-linking: ^56.0.14
65626644 react: '*'
65636645 react-dom: '*'
65646646 react-native: '*'
......@@ -6587,8 +6669,8 @@ packages:
65876669 peerDependencies:
65886670 expo: '*'
65896671
6590 expo-server@56.0.4:
6591 resolution: {integrity: sha512-4dJ57KuAwDl7eQGD6aG9kTzBIftWAfHH1+6Zxy7NcPCBrKYis3/H5enGUz1asH8HHhONXfJ5BdJqfEWAEAgWxA==}
6672 expo-server@56.0.5:
6673 resolution: {integrity: sha512-SmM2p2g3Jrktpiazcst+OxhjSzOHXKAY4BPURHYHXvApzzoybMmrNF4IEZ8DKZ145BhSe4ydAmlEFCRTsdtgUQ==}
65926674 engines: {node: '>=20.16.0'}
65936675
65946676 expo-splash-screen@56.0.10:
......@@ -6599,8 +6681,8 @@ packages:
65996681 expo-structured-headers@56.0.0:
66006682 resolution: {integrity: sha512-Yv4x+SQxNnMQm4nu8NFfzx197YaDhdYH2N0u7tGErwWTmH9Tm1SAhqo7bLbBWLC9kf7W+kdzTshLU9rTiCXWGw==}
66016683
6602 expo-symbols@56.0.5:
6603 resolution: {integrity: sha512-RIukH0Xo80C7RU8qreipL2SPy2Py+Km8JFPbCmbPQpHkM3DW9Znlmg6VfhzbtUOlO5EuNSF0lAJ3l2VJi6qYrw==}
6684 expo-symbols@56.0.6:
6685 resolution: {integrity: sha512-BrA81DjcNafdj7gXVhdrExb9LtUiSVyOf/NavyMmDAHgHMY1GqeR5cnn1PSAZeYKnSgQhee/H89XUpAxtog5hg==}
66046686 peerDependencies:
66056687 expo: '*'
66066688 expo-font: '*'
......@@ -6612,8 +6694,8 @@ packages:
66126694 peerDependencies:
66136695 expo: '*'
66146696
6615 expo-updates@56.0.17:
6616 resolution: {integrity: sha512-4005IRQQ7Vpwb01X3NSG0SwY61HTJzT2pZ0OrkeZ6guQE1WKiRfUKisekRgYXTI8sPbxSHUc9aOpIx6yC49XZQ==}
6697 expo-updates@56.0.19:
6698 resolution: {integrity: sha512-tTSPYO5h8wDA6a+wQ2v/SRdnOdz29x0npGHCv+4Ev31Fz5r05Ii1Wgfh3BlTXNz8mikMReDsZCf6YN71YeQKpw==}
66176699 hasBin: true
66186700 peerDependencies:
66196701 expo: '*'
......@@ -6630,15 +6712,15 @@ packages:
66306712 expo: '*'
66316713 react-native: '*'
66326714
6633 expo-widgets@56.0.16:
6634 resolution: {integrity: sha512-EmWVvskad2XlgCk6j9wz/OqSAeaGxjD6x6Wji99TVjjFd+sTD6W3YJbz3QW/BKTVRcll95m6XjJDCa6B9t9f9A==}
6715 expo-widgets@56.0.19:
6716 resolution: {integrity: sha512-D2RWectoEalVdGwXyE2LX3L9T6q6jSKh8jjvk1K3JSE1qOcCVZk+TJtvBUveTq8OoLblkeFMXqQ2fHG+kg655w==}
66356717 peerDependencies:
66366718 expo: '*'
66376719 react: '*'
66386720 react-native: '*'
66396721
6640 expo@56.0.8:
6641 resolution: {integrity: sha512-GzQi5450yrCk5JRSlm0epsmtURBErh0wS77uWLZImFdnPICuX912MaRWooR+Q1Sw/7aQjp9F+KXH+dvrqGxpeQ==}
6722 expo@56.0.12:
6723 resolution: {integrity: sha512-FxgdI/Yqva6iJOThZIHfvxlKPxs4EC4uScUnEswwSArR/Fj9k430O13R590LcOQTsdNsjIs+GBHwjfoAY6vmAQ==}
66426724 hasBin: true
66436725 peerDependencies:
66446726 '@expo/dom-webview': '*'
......@@ -8737,8 +8819,8 @@ packages:
87378819 react: '*'
87388820 react-native: '*'
87398821
8740 react-native-keyboard-controller@1.21.7:
8741 resolution: {integrity: sha512-gs+8nI8HYnRdDt4NWbk1iVuS6kDLf2taJvp+h/TjM1FBdtnQmlYLJ6buNiUqSnkIH4OFEAxdNr3/GOOYdLfkUQ==}
8822 react-native-keyboard-controller@1.21.6:
8823 resolution: {integrity: sha512-nAXCmar/W8Gn4iQV7O5fAVuTh57JszCsqTS+cfR95WFOLR/AfbwfPz/+sWyz/q2SOIe2VpyQzq6hzYiwErhqqw==}
87428824 peerDependencies:
87438825 react: '*'
87448826 react-native: '*'
......@@ -9306,6 +9388,12 @@ packages:
93069388 standard-as-callback@2.1.0:
93079389 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
93099397 standardwebhooks@1.0.0:
93109398 resolution: {integrity: sha512-BbHGOQK9olHPMvQNHWul6MYlrRTAOKn03rOe4A8O3CLWhNf4YHBqq2HJKKC+sfqpxiBY52pNeesD6jIiLDz8jg==}
93119399
......@@ -10248,7 +10336,8 @@ snapshots:
1024810336
1024910337 7zip-bin@5.2.0: {}
1025010338
10251 '@adobe/css-tools@4.5.0': {}
10339 '@adobe/css-tools@4.5.0':
10340 optional: true
1025210341
1025310342 '@alcalzone/ansi-tokenize@0.2.5':
1025410343 dependencies:
......@@ -11216,10 +11305,10 @@ snapshots:
1121611305 '@bruits/satteri-win32-x64-msvc@0.9.3':
1121711306 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)':
1122011309 dependencies:
1122111310 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
1122411313 '@capsizecss/unpack@4.0.1':
1122511314 dependencies:
......@@ -11248,18 +11337,18 @@ snapshots:
1124811337 fast-wrap-ansi: 0.2.2
1124911338 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)':
1125211341 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)
1125411343 standardwebhooks: 1.0.0
1125511344 tslib: 2.8.1
1125611345 transitivePeerDependencies:
1125711346 - react
1125811347 - 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)':
1126111350 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)
1126311352 '@stripe/stripe-js': 5.6.0
1126411353 '@swc/helpers': 0.5.21
1126511354 '@tanstack/query-core': 5.100.14
......@@ -11274,9 +11363,9 @@ snapshots:
1127411363 - react
1127511364 - 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)':
1127811367 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)
1128011369 '@stripe/stripe-js': 5.6.0
1128111370 '@swc/helpers': 0.5.21
1128211371 '@tanstack/query-core': 5.100.14
......@@ -11291,72 +11380,72 @@ snapshots:
1129111380 - react
1129211381 - react-dom
1129311382
11294 '@clerk/electron-passkeys-darwin-arm64@0.0.2-snapshot.v20260622234151':
11383 '@clerk/electron-passkeys-darwin-arm64@0.0.3':
1129511384 optional: true
1129611385
11297 '@clerk/electron-passkeys-darwin-x64@0.0.2-snapshot.v20260622234151':
11386 '@clerk/electron-passkeys-darwin-x64@0.0.3':
1129811387 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':
1130111390 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':
1130411393 optional: true
1130511394
11306 '@clerk/electron-passkeys@0.0.2-snapshot.v20260622234151':
11395 '@clerk/electron-passkeys@0.0.3':
1130711396 optionalDependencies:
11308 '@clerk/electron-passkeys-darwin-arm64': 0.0.2-snapshot.v20260622234151
11309 '@clerk/electron-passkeys-darwin-x64': 0.0.2-snapshot.v20260622234151
11310 '@clerk/electron-passkeys-win32-arm64-msvc': 0.0.2-snapshot.v20260622234151
11311 '@clerk/electron-passkeys-win32-x64-msvc': 0.0.2-snapshot.v20260622234151
11397 '@clerk/electron-passkeys-darwin-arm64': 0.0.3
11398 '@clerk/electron-passkeys-darwin-x64': 0.0.3
11399 '@clerk/electron-passkeys-win32-arm64-msvc': 0.0.3
11400 '@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)':
1131411403 dependencies:
11315 '@clerk/clerk-js': 6.21.0-snapshot.v20260622234151(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)
11317 '@clerk/shared': 4.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)
11405 '@clerk/react': 6.11.1(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)
1131811407 electron: 41.5.0
1131911408 react: 19.2.6
1132011409 tslib: 2.8.1
1132111410 optionalDependencies:
11322 '@clerk/electron-passkeys': 0.0.2-snapshot.v20260622234151
11411 '@clerk/electron-passkeys': 0.0.3
1132311412 electron-store: 8.2.0
1132411413 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)':
1132711416 dependencies:
11328 '@clerk/clerk-js': 6.21.0-snapshot.v20260622234151(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)
11330 '@clerk/shared': 4.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)
11418 '@clerk/react': 6.11.1(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)
1133111420 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)
1133311422 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)
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))
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)
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))
1133611425 tslib: 2.8.1
1133711426 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)
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))
11340 expo-crypto: 56.0.4(expo@56.0.8)
11341 expo-secure-store: 56.0.4(expo@56.0.8)
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))
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)
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))
11429 expo-crypto: 56.0.4(expo@56.0.12)
11430 expo-secure-store: 56.0.4(expo@56.0.12)
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))
1134311432 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)':
1134611435 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)
1134811437 react: 19.2.3
1134911438 react-dom: 19.2.3(react@19.2.3)
1135011439 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)':
1135311442 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)
1135511444 react: 19.2.6
1135611445 react-dom: 19.2.6(react@19.2.6)
1135711446 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)':
1136011449 dependencies:
1136111450 '@tanstack/query-core': 5.100.14
1136211451 dequal: 2.0.3
......@@ -11366,7 +11455,7 @@ snapshots:
1136611455 react: 19.2.3
1136711456 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)':
1137011459 dependencies:
1137111460 '@tanstack/query-core': 5.100.14
1137211461 dequal: 2.0.3
......@@ -11922,29 +12011,29 @@ snapshots:
1192212011
1192312012 '@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)':
1192612015 dependencies:
1192712016 '@expo/code-signing-certificates': 0.0.6
1192812017 '@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)
1193012019 '@expo/devcert': 1.2.1
1193112020 '@expo/env': 2.3.0
1193212021 '@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)
1193412023 '@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)
1193612025 '@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)
1193812027 '@expo/metro-file-map': 56.0.3
1193912028 '@expo/osascript': 2.6.0
1194012029 '@expo/package-manager': 1.12.1
1194112030 '@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)
1194312032 '@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)
1194512034 '@expo/schema-utils': 56.0.1
1194612035 '@expo/spawn-async': 1.8.0
11947 '@expo/ws-tunnel': 1.0.6
12036 '@expo/ws-tunnel': 2.0.0(ws@8.21.0(bufferutil@4.1.0)(utf-8-validate@6.0.6))
1194812037 '@expo/xcpretty': 4.4.4
1194912038 '@react-native/dev-middleware': 0.85.3(bufferutil@4.1.0)(utf-8-validate@6.0.6)
1195012039 accepts: 1.3.8
......@@ -11957,8 +12046,8 @@ snapshots:
1195712046 connect: 3.7.0
1195812047 debug: 4.4.3
1195912048 dnssd-advertise: 1.1.4
11960 expo: 56.0.8(63f7aade424ad9e7b1154b679fa2a14d)
11961 expo-server: 56.0.4
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)
12050 expo-server: 56.0.5
1196212051 fetch-nodeshim: 0.4.10
1196312052 getenv: 2.0.0
1196412053 glob: 13.0.6
......@@ -11983,8 +12072,8 @@ snapshots:
1198312072 ws: 8.21.0(bufferutil@4.1.0)(utf-8-validate@6.0.6)
1198412073 zod: 3.25.76
1198512074 optionalDependencies:
11986 expo-router: 56.2.8(5bfdf39b8f760e4dc2d5c3acffc97310)
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)
12075 expo-router: 56.2.11(3af01b44e50aae5a8ae60852f3cf7e3f)
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)
1198812077 transitivePeerDependencies:
1198912078 - '@expo/dom-webview'
1199012079 - '@expo/metro-runtime'
......@@ -12021,11 +12110,32 @@ snapshots:
1202112110 - supports-color
1202212111 - 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
1202412132 '@expo/config-types@56.0.5': {}
1202512133
12134 '@expo/config-types@56.0.6': {}
12135
1202612136 '@expo/config@56.0.9(typescript@6.0.3)':
1202712137 dependencies:
12028 '@expo/config-plugins': 56.0.8(typescript@6.0.3)
12138 '@expo/config-plugins': 56.0.9(typescript@6.0.3)
1202912139 '@expo/config-types': 56.0.5
1203012140 '@expo/json-file': 10.2.0
1203112141 '@expo/require-utils': 56.1.3(typescript@6.0.3)
......@@ -12046,18 +12156,18 @@ snapshots:
1204612156 transitivePeerDependencies:
1204712157 - 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)':
1205012160 dependencies:
1205112161 chalk: 4.1.2
1205212162 optionalDependencies:
1205312163 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)':
1205712167 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)
1205912169 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
1206212172 '@expo/env@2.3.0':
1206312173 dependencies:
......@@ -12067,9 +12177,9 @@ snapshots:
1206712177 transitivePeerDependencies:
1206812178 - 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':
1207312183 dependencies:
1207412184 '@expo/env': 2.3.0
1207512185 '@expo/spawn-async': 1.8.0
......@@ -12098,9 +12208,9 @@ snapshots:
1209812208 - supports-color
1209912209 - typescript
1210012210
12101 '@expo/inline-modules@0.0.10(typescript@6.0.3)':
12211 '@expo/inline-modules@0.0.12(typescript@6.0.3)':
1210212212 dependencies:
12103 '@expo/config-plugins': 56.0.8(typescript@6.0.3)
12213 '@expo/config-plugins': 56.0.9(typescript@6.0.3)
1210412214 transitivePeerDependencies:
1210512215 - supports-color
1210612216 - typescript
......@@ -12118,16 +12228,16 @@ snapshots:
1211812228 - supports-color
1211912229 - 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)':
1212212232 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)
1212412234 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)
1212612236 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)
1212812238 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)':
1213112241 dependencies:
1213212242 '@babel/code-frame': 7.29.7
1213312243 '@babel/core': 7.29.7
......@@ -12149,12 +12259,11 @@ snapshots:
1214912259 hermes-parser: 0.33.3
1215012260 jsc-safe-url: 0.2.4
1215112261 lightningcss: 1.32.0
12152 msgpackr: 2.0.2
1215312262 picomatch: 4.0.4
1215412263 postcss: 8.5.15
1215512264 resolve-from: 5.0.0
1215612265 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)
1215812267 transitivePeerDependencies:
1215912268 - bufferutil
1216012269 - supports-color
......@@ -12172,14 +12281,14 @@ snapshots:
1217212281 transitivePeerDependencies:
1217312282 - 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)':
1217612285 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)
1217812287 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)
1218012289 pretty-format: 29.7.0
1218112290 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)
1218312292 stacktrace-parser: 0.1.11
1218412293 whatwg-fetch: 3.6.20
1218512294 optionalDependencies:
......@@ -12225,16 +12334,16 @@ snapshots:
1222512334 base64-js: 1.5.1
1222612335 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)':
1222912338 dependencies:
1223012339 '@expo/config': 56.0.9(typescript@6.0.3)
12231 '@expo/config-plugins': 56.0.8(typescript@6.0.3)
12232 '@expo/config-types': 56.0.5
12340 '@expo/config-plugins': 56.0.9(typescript@6.0.3)
12341 '@expo/config-types': 56.0.6
1223312342 '@expo/image-utils': 0.10.1(typescript@6.0.3)
1223412343 '@expo/json-file': 10.2.0
1223512344 '@react-native/normalize-colors': 0.85.3
1223612345 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)
1223812347 resolve-from: 5.0.0
1223912348 semver: 7.8.5
1224012349 transitivePeerDependencies:
......@@ -12251,17 +12360,17 @@ snapshots:
1225112360 transitivePeerDependencies:
1225212361 - 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)':
1225512364 dependencies:
1225612365 debug: 4.4.3
12257 expo: 56.0.8(63f7aade424ad9e7b1154b679fa2a14d)
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))
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)
12260 expo-server: 56.0.4
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)
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))
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)
12369 expo-server: 56.0.5
1226112370 react: 19.2.3
1226212371 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)
12264 expo-router: 56.2.8(5bfdf39b8f760e4dc2d5c3acffc97310)
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)
12373 expo-router: 56.2.11(3af01b44e50aae5a8ae60852f3cf7e3f)
1226512374 react-dom: 19.2.3(react@19.2.3)
1226612375 transitivePeerDependencies:
1226712376 - supports-color
......@@ -12276,23 +12385,25 @@ snapshots:
1227612385
1227712386 '@expo/sudo-prompt@9.3.2': {}
1227812387
12279 '@expo/ui@56.0.15(961c4aa6f32829b318e3c87ef20ad401)':
12388 '@expo/ui@56.0.18(19413efe5eaad64848598eedfe3a0fd3)':
1228012389 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)
1228212391 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)
1228412393 sf-symbols-typescript: 2.2.0
1228512394 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)
1228612395 optionalDependencies:
1228712396 '@babel/core': 7.29.7
1228812397 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)
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)
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)
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)
1229112400 transitivePeerDependencies:
1229212401 - '@types/react'
1229312402 - '@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
1229712408 '@expo/xcpretty@4.4.4':
1229812409 dependencies:
......@@ -12548,13 +12659,13 @@ snapshots:
1254812659 dependencies:
1254912660 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)':
1255212663 dependencies:
1255312664 react: 19.2.3
1255412665 use-sync-external-store: 1.6.0(react@19.2.3)
1255512666 optionalDependencies:
1255612667 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
1255912670 '@legendapp/list@3.2.0(react-dom@19.2.6(react@19.2.6))(react@19.2.6)':
1256012671 dependencies:
......@@ -13178,6 +13289,7 @@ snapshots:
1317813289 optionalDependencies:
1317913290 '@types/react': 19.2.16
1318013291 '@types/react-dom': 19.2.3(@types/react@19.2.16)
13292 optional: true
1318113293
1318213294 '@radix-ui/react-compose-refs@1.1.2(@types/react@19.2.16)(react@19.2.3)':
1318313295 dependencies:
......@@ -13218,6 +13330,7 @@ snapshots:
1321813330 react: 19.2.3
1321913331 optionalDependencies:
1322013332 '@types/react': 19.2.16
13333 optional: true
1322113334
1322213335 '@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)':
1322313336 dependencies:
......@@ -13301,6 +13414,7 @@ snapshots:
1330113414 optionalDependencies:
1330213415 '@types/react': 19.2.16
1330313416 '@types/react-dom': 19.2.3(@types/react@19.2.16)
13417 optional: true
1330413418
1330513419 '@radix-ui/react-slot@1.2.3(@types/react@19.2.16)(react@19.2.3)':
1330613420 dependencies:
......@@ -13315,6 +13429,7 @@ snapshots:
1331513429 react: 19.2.3
1331613430 optionalDependencies:
1331713431 '@types/react': 19.2.16
13432 optional: true
1331813433
1331913434 '@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)':
1332013435 dependencies:
......@@ -13331,6 +13446,7 @@ snapshots:
1333113446 optionalDependencies:
1333213447 '@types/react': 19.2.16
1333313448 '@types/react-dom': 19.2.3(@types/react@19.2.16)
13449 optional: true
1333413450
1333513451 '@radix-ui/react-use-callback-ref@1.1.1(@types/react@19.2.16)(react@19.2.3)':
1333613452 dependencies:
......@@ -13377,15 +13493,16 @@ snapshots:
1337713493 prompts: 2.4.2
1337813494 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)':
1338113497 dependencies:
1338213498 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)':
1338613503 dependencies:
1338713504 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
1339013507 '@react-native/assets-registry@0.85.3': {}
1339113508
......@@ -13445,7 +13562,7 @@ snapshots:
1344513562 tinyglobby: 0.2.17
1344613563 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)':
1344913566 dependencies:
1345013567 '@react-native/dev-middleware': 0.85.3(bufferutil@4.1.0)(utf-8-validate@6.0.6)
1345113568 debug: 4.4.3
......@@ -13455,7 +13572,7 @@ snapshots:
1345513572 metro-core: 0.84.4
1345613573 semver: 7.8.5
1345713574 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)
1345913576 transitivePeerDependencies:
1346013577 - bufferutil
1346113578 - supports-color
......@@ -13503,7 +13620,7 @@ snapshots:
1350313620 transitivePeerDependencies:
1350413621 - 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)':
1350713624 dependencies:
1350813625 '@react-native/js-polyfills': 0.85.3
1350913626 '@react-native/metro-babel-transformer': 0.85.3(@babel/core@7.29.7)
......@@ -13511,21 +13628,72 @@ snapshots:
1351113628 metro-runtime: 0.84.4
1351213629 transitivePeerDependencies:
1351313630 - '@babel/core'
13514 - bufferutil
1351513631 - supports-color
13516 - utf-8-validate
1351713632
1351813633 '@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)':
1352113636 dependencies:
1352213637 invariant: 2.2.4
1352313638 nullthrows: 1.1.1
1352413639 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)
1352613641 optionalDependencies:
1352713642 '@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
1352913697 '@rolldown/binding-android-arm64@1.0.0-rc.17':
1353013698 optional: true
1353113699
......@@ -13907,15 +14075,15 @@ snapshots:
1390714075 dependencies:
1390814076 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)':
1391114079 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)
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)
13914 expo-haptics: 56.0.3(expo@56.0.8)
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)
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)
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)
14082 expo-haptics: 56.0.3(expo@56.0.12)
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)
1391614084 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)
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)
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)
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
1392014088 '@t3tools/mobile-review-diff-native@file:apps/mobile/modules/t3-review-diff': {}
1392114089
......@@ -14171,6 +14339,7 @@ snapshots:
1417114339 dom-accessibility-api: 0.6.3
1417214340 picocolors: 1.1.1
1417314341 redent: 3.0.0
14342 optional: true
1417414343
1417514344 '@testing-library/user-event@14.6.1(@testing-library/dom@10.4.1)':
1417614345 dependencies:
......@@ -14983,7 +15152,60 @@ snapshots:
1498315152 transitivePeerDependencies:
1498415153 - '@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):
1498715209 dependencies:
1498815210 '@babel/generator': 7.29.7
1498915211 '@babel/helper-module-imports': 7.29.7
......@@ -15030,8 +15252,8 @@ snapshots:
1503015252 react-refresh: 0.14.2
1503115253 optionalDependencies:
1503215254 '@babel/runtime': 7.29.7
15033 expo: 56.0.8(63f7aade424ad9e7b1154b679fa2a14d)
15034 expo-widgets: 56.0.16(961c4aa6f32829b318e3c87ef20ad401)
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)
15256 expo-widgets: 56.0.19(19413efe5eaad64848598eedfe3a0fd3)
1503515257 transitivePeerDependencies:
1503615258 - '@babel/core'
1503715259 - supports-color
......@@ -15307,7 +15529,8 @@ snapshots:
1530715529
1530815530 cli-width@4.1.0: {}
1530915531
15310 client-only@0.0.1: {}
15532 client-only@0.0.1:
15533 optional: true
1531115534
1531215535 cliui@8.0.1:
1531315536 dependencies:
......@@ -15499,7 +15722,8 @@ snapshots:
1549915722
1550015723 css-what@6.2.2: {}
1550115724
15502 css.escape@1.5.1: {}
15725 css.escape@1.5.1:
15726 optional: true
1550315727
1550415728 csso@5.0.5:
1550515729 dependencies:
......@@ -15622,7 +15846,8 @@ snapshots:
1562215846
1562315847 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
1562715852 dom-serializer@2.0.0:
1562815853 dependencies:
......@@ -15926,154 +16151,154 @@ snapshots:
1592616151
1592716152 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):
1593016155 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):
1593416159 dependencies:
1593516160 '@expo/image-utils': 0.10.1(typescript@6.0.3)
15936 expo: 56.0.8(63f7aade424ad9e7b1154b679fa2a14d)
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))
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)
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))
1593816163 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)
1594016165 transitivePeerDependencies:
1594116166 - supports-color
1594216167 - 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):
1594516170 dependencies:
15946 expo-application: 56.0.3(expo@56.0.8)
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))
15948 expo-crypto: 56.0.4(expo@56.0.8)
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)
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))
16171 expo-application: 56.0.3(expo@56.0.12)
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))
16173 expo-crypto: 56.0.4(expo@56.0.12)
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)
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))
1595116176 invariant: 2.2.4
1595216177 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)
1595416179 transitivePeerDependencies:
1595516180 - expo
1595616181 - 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):
1595916184 dependencies:
1596016185 '@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)
1596216187 resolve-from: 5.0.0
1596316188 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):
1596616191 dependencies:
1596716192 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)
1596916194 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)
1597116196 transitivePeerDependencies:
1597216197 - '@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):
1597516200 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)
1597716202 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)):
1598116206 dependencies:
1598216207 '@expo/env': 2.3.0
15983 expo: 56.0.8(63f7aade424ad9e7b1154b679fa2a14d)
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)
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)
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)
1598516210 transitivePeerDependencies:
1598616211 - supports-color
1598716212
15988 expo-crypto@56.0.4(expo@56.0.8):
16213 expo-crypto@56.0.4(expo@56.0.12):
1598916214 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)):
1599316218 dependencies:
15994 expo: 56.0.8(63f7aade424ad9e7b1154b679fa2a14d)
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))
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))
15997 expo-dev-menu-interface: 56.0.1(expo@56.0.8)
15998 expo-manifests: 56.0.4(expo@56.0.8)
15999 expo-updates-interface: 56.0.2(expo@56.0.8)
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)
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))
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))
16222 expo-dev-menu-interface: 56.0.1(expo@56.0.12)
16223 expo-manifests: 56.0.4(expo@56.0.12)
16224 expo-updates-interface: 56.0.2(expo@56.0.12)
1600016225 transitivePeerDependencies:
1600116226 - 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)):
1600416229 dependencies:
1600516230 '@expo/schema-utils': 56.0.1
16006 expo: 56.0.8(63f7aade424ad9e7b1154b679fa2a14d)
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))
16008 expo-manifests: 56.0.4(expo@56.0.8)
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)
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)
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))
16233 expo-manifests: 56.0.4(expo@56.0.12)
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):
1601216237 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)):
1601616241 dependencies:
16017 expo: 56.0.8(63f7aade424ad9e7b1154b679fa2a14d)
16018 expo-dev-menu-interface: 56.0.1(expo@56.0.8)
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)
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)
16243 expo-dev-menu-interface: 56.0.1(expo@56.0.12)
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
1602116246 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)):
1602416249 dependencies:
16025 expo: 56.0.8(63f7aade424ad9e7b1154b679fa2a14d)
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)
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)
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):
1602916254 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)
1603116256 fontfaceobserver: 2.3.0
1603216257 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):
1603616261 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)
1603816263 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):
1604216267 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):
1604616271 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):
1605016275 dependencies:
16051 expo: 56.0.8(63f7aade424ad9e7b1154b679fa2a14d)
16052 expo-image-loader: 56.0.3(expo@56.0.8)
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)
16277 expo-image-loader: 56.0.3(expo@56.0.12)
1605316278
1605416279 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):
1605716282 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)
1605916284 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):
1606216287 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))
1606416289 invariant: 2.2.4
1606516290 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)
1606716292 transitivePeerDependencies:
1606816293 - expo
1606916294 - supports-color
1607016295
16071 expo-manifests@56.0.4(expo@56.0.8):
16296 expo-manifests@56.0.4(expo@56.0.12):
1607216297 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)
1607416299 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):
1607716302 dependencies:
1607816303 '@expo/require-utils': 56.1.3(typescript@6.0.3)
1607916304 '@expo/spawn-async': 1.8.0
......@@ -16083,66 +16308,66 @@ snapshots:
1608316308 - supports-color
1608416309 - 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):
1608716312 dependencies:
16088 '@expo/expo-modules-macros-plugin': 0.0.9
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))
16313 '@expo/expo-modules-macros-plugin': 0.2.2
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))
1609016315 invariant: 2.2.4
1609116316 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)
1609316318 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)):
1609716322 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):
1610116326 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)
1610316328 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):
1610616331 dependencies:
1610716332 '@expo/image-utils': 0.10.1(typescript@6.0.3)
1610816333 abort-controller: 3.0.0
1610916334 badgin: 1.2.3
16110 expo: 56.0.8(63f7aade424ad9e7b1154b679fa2a14d)
16111 expo-application: 56.0.3(expo@56.0.8)
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))
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)
16336 expo-application: 56.0.3(expo@56.0.12)
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))
1611316338 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)
1611516340 transitivePeerDependencies:
1611616341 - supports-color
1611716342 - 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):
1612016345 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)
1612216347 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):
1612616351 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)
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)
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)
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)
1612916354 '@expo/schema-utils': 56.0.1
16130 '@expo/ui': 56.0.15(961c4aa6f32829b318e3c87ef20ad401)
16355 '@expo/ui': 56.0.18(19413efe5eaad64848598eedfe3a0fd3)
1613116356 '@radix-ui/react-slot': 1.2.4(@types/react@19.2.16)(react@19.2.3)
1613216357 '@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)
1613416359 '@testing-library/jest-dom': 6.9.1
1613516360 '@testing-library/user-event': 14.6.1(@testing-library/dom@10.4.1)
1613616361 client-only: 0.0.1
1613716362 color: 4.2.3
1613816363 debug: 4.4.3
1613916364 escape-string-regexp: 4.0.0
16140 expo: 56.0.8(63f7aade424ad9e7b1154b679fa2a14d)
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))
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)
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)
16144 expo-server: 56.0.4
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)
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)
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))
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)
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)
16369 expo-server: 56.0.5
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)
1614616371 fast-deep-equal: 3.1.3
1614716372 invariant: 2.2.4
1614816373 nanoid: 3.3.12
......@@ -16150,18 +16375,19 @@ snapshots:
1615016375 react: 19.2.3
1615116376 react-fast-compare: 3.2.2
1615216377 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)
16154 react-native-drawer-layout: 4.2.4(0e9729601f58a7a7ae26c76fe6017455)
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)
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)
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)
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)
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)
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)
1615716382 server-only: 0.0.1
1615816383 sf-symbols-typescript: 2.2.0
1615916384 shallowequal: 1.1.0
16385 standard-navigation: 0.0.5
1616016386 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)
1616116387 optionalDependencies:
1616216388 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)
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)
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)
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)
1616516391 transitivePeerDependencies:
1616616392 - '@babel/core'
1616716393 - '@testing-library/dom'
......@@ -16170,18 +16396,19 @@ snapshots:
1617016396 - expo-font
1617116397 - react-native-worklets
1617216398 - 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):
1617516402 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):
1618116408 dependencies:
1618216409 '@expo/config-plugins': 56.0.8(typescript@6.0.3)
1618316410 '@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)
1618516412 xml2js: 0.6.0
1618616413 transitivePeerDependencies:
1618716414 - supports-color
......@@ -16189,20 +16416,20 @@ snapshots:
1618916416
1619016417 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):
1619316420 dependencies:
1619416421 '@expo-google-fonts/material-symbols': 0.4.38
16195 expo: 56.0.8(63f7aade424ad9e7b1154b679fa2a14d)
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)
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)
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)
1619716424 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)
1619916426 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):
1620216429 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):
1620616433 dependencies:
1620716434 '@expo/code-signing-certificates': 0.0.6
1620816435 '@expo/plist': 0.7.0
......@@ -16210,35 +16437,35 @@ snapshots:
1621016437 arg: 4.1.3
1621116438 chalk: 4.1.2
1621216439 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)
1621416441 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)
1621616443 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)
1621816445 getenv: 2.0.0
1621916446 glob: 13.0.6
1622016447 ignore: 5.3.2
1622116448 nullthrows: 1.1.1
1622216449 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)
1622416451 resolve-from: 5.0.0
1622516452 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))
1622716454 transitivePeerDependencies:
1622816455 - 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)):
1623116458 dependencies:
16232 expo: 56.0.8(63f7aade424ad9e7b1154b679fa2a14d)
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)
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)
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):
1623616463 dependencies:
1623716464 '@expo/plist': 0.7.0
16238 '@expo/ui': 56.0.15(961c4aa6f32829b318e3c87ef20ad401)
16239 expo: 56.0.8(63f7aade424ad9e7b1154b679fa2a14d)
16465 '@expo/ui': 56.0.18(19413efe5eaad64848598eedfe3a0fd3)
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)
1624016467 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)
1624216469 transitivePeerDependencies:
1624316470 - '@babel/core'
1624416471 - '@types/react'
......@@ -16247,37 +16474,37 @@ snapshots:
1624716474 - react-native-reanimated
1624816475 - 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):
1625116478 dependencies:
1625216479 '@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)
1625416481 '@expo/config': 56.0.9(typescript@6.0.3)
16255 '@expo/config-plugins': 56.0.8(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)
16257 '@expo/fingerprint': 0.19.3
16482 '@expo/config-plugins': 56.0.9(typescript@6.0.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)
16484 '@expo/fingerprint': 0.19.4
1625816485 '@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)
1626016487 '@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)
1626216489 '@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)
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)
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))
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))
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)
16268 expo-keep-awake: 56.0.3(expo@56.0.8)(react@19.2.3)
16269 expo-modules-autolinking: 56.0.14(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)
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)
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)
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))
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))
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)
16495 expo-keep-awake: 56.0.3(expo@56.0.12)(react@19.2.3)
16496 expo-modules-autolinking: 56.0.16(typescript@6.0.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)
1627116498 pretty-format: 29.7.0
1627216499 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)
1627416501 react-refresh: 0.14.2
1627516502 whatwg-url-minimum: 0.1.2
1627616503 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)
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)
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)
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)
1627916506 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)
1628116508 transitivePeerDependencies:
1628216509 - '@babel/core'
1628316510 - bufferutil
......@@ -16878,7 +17105,8 @@ snapshots:
1687817105
1687917106 immediate@3.0.6: {}
1688017107
16881 indent-string@4.0.0: {}
17108 indent-string@4.0.0:
17109 optional: true
1688217110
1688317111 indent-string@5.0.0: {}
1688417112
......@@ -17990,7 +18218,8 @@ snapshots:
1799018218
1799118219 mimic-response@3.1.0: {}
1799218220
17993 min-indent@1.0.1: {}
18221 min-indent@1.0.1:
18222 optional: true
1799418223
1799518224 minimatch@10.2.5:
1799618225 dependencies:
......@@ -18659,7 +18888,8 @@ snapshots:
1865918888 dependencies:
1866018889 react: 19.2.6
1866118890
18662 react-fast-compare@3.2.2: {}
18891 react-fast-compare@3.2.2:
18892 optional: true
1866318893
1866418894 react-freeze@1.0.4(react@19.2.3):
1866518895 dependencies:
......@@ -18698,102 +18928,103 @@ snapshots:
1869818928 transitivePeerDependencies:
1869918929 - supports-color
1870018930
18701 react-native-drawer-layout@4.2.4(0e9729601f58a7a7ae26c76fe6017455):
18702 dependencies:
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)
18932 : dependencies:
1870318933 color: 4.2.3
1870418934 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)
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)
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)
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)
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)
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)
1870818938 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):
1871118942 dependencies:
1871218943 '@egjs/hammerjs': 2.0.17
1871318944 '@types/react-test-renderer': 19.1.0
1871418945 hoist-non-react-statics: 3.3.2
1871518946 invariant: 2.2.4
1871618947 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):
1872018951 dependencies:
1872118952 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):
1872518956 dependencies:
1872618957 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):
1873018961 dependencies:
1873118962 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)
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)
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)
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)
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)
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):
1873718968 dependencies:
1873818969 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)
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)
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)
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)
1874118972 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):
1874518976 dependencies:
1874618977 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):
1875018981 dependencies:
1875118982 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)
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)
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)
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)
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)
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)
1875518986 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):
1875818989 dependencies:
1875918990 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):
1876318994 dependencies:
1876418995 react: 19.2.3
1876518996 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)
1876718998 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):
1877019001 dependencies:
1877119002 '@shikijs/types': 4.3.0
1877219003 '@shikijs/vscode-textmate': 10.0.2
1877319004 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):
1877719008 dependencies:
1877819009 css-select: 5.2.2
1877919010 css-tree: 1.1.3
1878019011 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)
1878219013 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)):
1878519016 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)
1878719018 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):
1879019021 dependencies:
1879119022 escape-string-regexp: 4.0.0
1879219023 invariant: 2.2.4
1879319024 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):
1879719028 dependencies:
1879819029 '@babel/core': 7.29.7
1879919030 '@babel/plugin-transform-arrow-functions': 7.29.7(@babel/core@7.29.7)
......@@ -18805,23 +19036,23 @@ snapshots:
1880519036 '@babel/plugin-transform-template-literals': 7.29.7(@babel/core@7.29.7)
1880619037 '@babel/plugin-transform-unicode-regex': 7.29.7(@babel/core@7.29.7)
1880719038 '@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)
1880919040 convert-source-map: 2.0.0
1881019041 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)
1881219043 semver: 7.8.5
1881319044 transitivePeerDependencies:
1881419045 - 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):
1881719048 dependencies:
1881819049 '@react-native/assets-registry': 0.85.3
1881919050 '@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)
1882119052 '@react-native/gradle-plugin': 0.85.3
1882219053 '@react-native/js-polyfills': 0.85.3
1882319054 '@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)
1882519056 abort-controller: 3.0.0
1882619057 anser: 1.4.10
1882719058 ansi-regex: 5.0.1
......@@ -18920,6 +19151,7 @@ snapshots:
1892019151 dependencies:
1892119152 indent-string: 4.0.0
1892219153 strip-indent: 3.0.0
19154 optional: true
1892319155
1892419156 redis-errors@1.2.0: {}
1892519157
......@@ -19351,7 +19583,8 @@ snapshots:
1935119583 transitivePeerDependencies:
1935219584 - supports-color
1935319585
19354 server-only@0.0.1: {}
19586 server-only@0.0.1:
19587 optional: true
1935519588
1935619589 setimmediate@1.0.5: {}
1935719590
......@@ -19359,7 +19592,8 @@ snapshots:
1935919592
1936019593 sf-symbols-typescript@2.2.0: {}
1936119594
19362 shallowequal@1.1.0: {}
19595 shallowequal@1.1.0:
19596 optional: true
1936319597
1936419598 sharp@0.34.5:
1936519599 dependencies:
......@@ -19523,6 +19757,11 @@ snapshots:
1952319757
1952419758 standard-as-callback@2.1.0: {}
1952519759
19760 standard-navigation@0.0.5:
19761 optional: true
19762
19763 standard-navigation@0.0.7: {}
19764
1952619765 standardwebhooks@1.0.0:
1952719766 dependencies:
1952819767 '@stablelib/base64': 1.0.1
......@@ -19585,6 +19824,7 @@ snapshots:
1958519824 strip-indent@3.0.0:
1958619825 dependencies:
1958719826 min-indent: 1.0.1
19827 optional: true
1958819828
1958919829 strnum@2.3.0: {}
1959019830
......@@ -19870,14 +20110,14 @@ snapshots:
1987020110
1987120111 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):
1987420114 dependencies:
1987520115 '@tailwindcss/node': 4.2.1
1987620116 '@tailwindcss/oxide': 4.2.1
1987720117 culori: 4.0.2
1987820118 lightningcss: 1.30.1
1987920119 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)
1988120121 tailwindcss: 4.3.0
1988220122
1988320123 unpipe@1.0.0: {}
pnpm-workspace.yaml+19-19
......@@ -20,13 +20,13 @@ supportedArchitectures:
2020 - glibc
2121
2222catalog:
23 "@clerk/backend": 3.8.3-snapshot.v20260622234151
24 "@clerk/clerk-js": 6.21.0-snapshot.v20260622234151
25 "@clerk/electron": 0.0.2-snapshot.v20260622234151
26 "@clerk/electron-passkeys": 0.0.2-snapshot.v20260622234151
27 "@clerk/expo": 3.5.3-snapshot.v20260622234151
28 "@clerk/react": 6.11.0-snapshot.v20260622234151
29 "@clerk/shared": 4.21.0-snapshot.v20260622234151
23 "@clerk/backend": 3.8.4
24 "@clerk/clerk-js": 6.22.0
25 "@clerk/electron": 0.0.5
26 "@clerk/electron-passkeys": 0.0.3
27 "@clerk/expo": 3.6.2
28 "@clerk/react": 6.11.1
29 "@clerk/shared": 4.22.0
3030 "@effect/atom-react": 4.0.0-beta.78
3131 "@effect/openapi-generator": 4.0.0-beta.78
3232 "@effect/platform-bun": 4.0.0-beta.78
......@@ -59,22 +59,19 @@ onlyBuiltDependencies:
5959 - sharp
6060
6161overrides:
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.
6562 "@clerk/backend": "catalog:"
6663 "@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:"
7264 "@clerk/clerk-js>@base-org/account": "-"
7365 "@clerk/clerk-js>@coinbase/wallet-sdk": "-"
7466 "@clerk/clerk-js>@solana/wallet-adapter-base": "-"
7567 "@clerk/clerk-js>@solana/wallet-adapter-react": "-"
7668 "@clerk/clerk-js>@solana/wallet-standard": "-"
7769 "@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:"
7875 "@effect/atom-react": "catalog:"
7976 "@effect/platform-bun": "catalog:"
8077 "@effect/platform-node": "catalog:"
......@@ -82,10 +79,8 @@ overrides:
8279 "@effect/sql-pg": "catalog:"
8380 "@effect/sql-sqlite-bun": "catalog:"
8481 "@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.
8782 "@effect/vitest>vitest": "-"
88 "@expo/metro-config": 56.0.13
83 "@expo/metro-config": 56.0.14
8984 "@pierre/diffs>@shikijs/transformers": ^4.2.0
9085 "@types/node": "catalog:"
9186 effect: "catalog:"
......@@ -105,11 +100,16 @@ packageExtensions:
105100
106101patchedDependencies:
107102 "@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.patch
103 "@expo/metro-config@56.0.14": patches/@expo%2Fmetro-config@56.0.14.patch
109104 "@ff-labs/fff-node@0.9.4": patches/@ff-labs__fff-node@0.9.4.patch
110105 "@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
111108 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
112111 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
114114peerDependencyRules:
115115 allowAny: