diff --git a/apps/mobile/.swiftlint.yml b/apps/mobile/.swiftlint.yml index 0714ce90e63..c56a56251f7 100644 --- a/apps/mobile/.swiftlint.yml +++ b/apps/mobile/.swiftlint.yml @@ -1,6 +1,7 @@ included: - ios/T3Code - modules/t3-composer-editor/ios + - modules/t3-native-controls/ios - modules/t3-terminal/ios - modules/t3-review-diff/ios diff --git a/apps/mobile/app.config.ts b/apps/mobile/app.config.ts index 8cdf6f2e25c..192153316b0 100644 --- a/apps/mobile/app.config.ts +++ b/apps/mobile/app.config.ts @@ -17,6 +17,7 @@ const VARIANT_CONFIG: Record< readonly iosIcon: string; readonly iosBundleIdentifier: string; readonly androidPackage: string; + readonly relyingParty?: string; } > = { development: { @@ -25,6 +26,7 @@ const VARIANT_CONFIG: Record< iosIcon: "./assets/icon-composer-dev.icon", iosBundleIdentifier: "com.t3tools.t3code.dev", androidPackage: "com.t3tools.t3code.dev", + relyingParty: "clerk.t3.codes", }, preview: { appName: "T3 Code Preview", @@ -32,6 +34,7 @@ const VARIANT_CONFIG: Record< iosIcon: "./assets/icon-composer-prod.icon", iosBundleIdentifier: "com.t3tools.t3code.preview", androidPackage: "com.t3tools.t3code.preview", + relyingParty: "clerk.t3.codes", }, production: { appName: "T3 Code", @@ -39,6 +42,7 @@ const VARIANT_CONFIG: Record< iosIcon: "./assets/icon-composer-prod.icon", iosBundleIdentifier: "com.t3tools.t3code", androidPackage: "com.t3tools.t3code", + relyingParty: "clerk.t3.codes", }, }; @@ -77,6 +81,14 @@ const config: ExpoConfig = { icon: variant.iosIcon, supportsTablet: true, bundleIdentifier: variant.iosBundleIdentifier, + // Pin code signing to the T3 Tools team so non-interactive `expo run:ios` + // does not fall back to a personal team (which cannot sign app groups, + // Sign in with Apple, or push notification entitlements). + appleTeamId: "ARK85ZXQ4Z", + associatedDomains: [ + `applinks:${variant.relyingParty}`, + `webcredentials:${variant.relyingParty}`, + ], infoPlist: { NSAppTransportSecurity: { NSAllowsArbitraryLoads: true, @@ -101,7 +113,6 @@ const config: ExpoConfig = { favicon: "./assets/favicon.png", }, plugins: [ - "expo-router", "expo-font", "expo-secure-store", ["@clerk/expo", { theme: "./clerk-theme.json" }], @@ -139,6 +150,7 @@ const config: ExpoConfig = { }, }, ], + "./plugins/withIosCocoaPodsUuidCache.cjs", [ "expo-widgets", { @@ -155,6 +167,7 @@ const config: ExpoConfig = { ], }, ], + "./plugins/withIosSceneLifecycle.cjs", "./plugins/withAndroidCleartextTraffic.cjs", ], extra: { diff --git a/apps/mobile/global.css b/apps/mobile/global.css index b2014bf9353..f76300ee524 100644 --- a/apps/mobile/global.css +++ b/apps/mobile/global.css @@ -199,24 +199,24 @@ --font-bold: "DMSans_700Bold"; /* Keep this scale aligned with src/lib/typography.ts for native style props. */ - --text-3xs: 10px; - --text-3xs--line-height: 13px; - --text-2xs: 11px; - --text-2xs--line-height: 15px; - --text-xs: 12px; - --text-xs--line-height: 16px; - --text-sm: 13px; - --text-sm--line-height: 18px; - --text-base: 15px; - --text-base--line-height: 22px; - --text-lg: 17px; - --text-lg--line-height: 22px; - --text-xl: 20px; - --text-xl--line-height: 26px; - --text-2xl: 24px; - --text-2xl--line-height: 30px; - --text-3xl: 28px; - --text-3xl--line-height: 34px; + --text-3xs: 11px; + --text-3xs--line-height: 14px; + --text-2xs: 12px; + --text-2xs--line-height: 16px; + --text-xs: 13px; + --text-xs--line-height: 17px; + --text-sm: 14px; + --text-sm--line-height: 19px; + --text-base: 16px; + --text-base--line-height: 23px; + --text-lg: 18px; + --text-lg--line-height: 23px; + --text-xl: 21px; + --text-xl--line-height: 28px; + --text-2xl: 26px; + --text-2xl--line-height: 32px; + --text-3xl: 30px; + --text-3xl--line-height: 36px; } /* ─── Custom utilities ──────────────────────────────────────────────── */ diff --git a/apps/mobile/index.ts b/apps/mobile/index.ts index 642e1b77be9..979dc75d5f1 100644 --- a/apps/mobile/index.ts +++ b/apps/mobile/index.ts @@ -1,2 +1,11 @@ +import { registerRootComponent } from "expo"; import "react-native-gesture-handler"; -import "expo-router/entry"; +import { featureFlags } from "react-native-screens"; + +import App from "./src/App"; + +// Required for react-native-screens' iOS FormSheet sizing fix when a nested +// native stack is rendered inside a non-fitToContents formSheet. +featureFlags.experiment.synchronousScreenUpdatesEnabled = true; + +registerRootComponent(App); diff --git a/apps/mobile/modules/t3-composer-editor/ios/T3ComposerEditorModule.swift b/apps/mobile/modules/t3-composer-editor/ios/T3ComposerEditorModule.swift index d3b12770b04..a56619b7d48 100644 --- a/apps/mobile/modules/t3-composer-editor/ios/T3ComposerEditorModule.swift +++ b/apps/mobile/modules/t3-composer-editor/ios/T3ComposerEditorModule.swift @@ -47,6 +47,7 @@ public class T3ComposerEditorModule: Module { "onComposerSelectionChange", "onComposerFocus", "onComposerBlur", + "onComposerSubmit", "onComposerPasteImages", "onComposerContentSizeChange" ) diff --git a/apps/mobile/modules/t3-composer-editor/ios/T3ComposerEditorView.swift b/apps/mobile/modules/t3-composer-editor/ios/T3ComposerEditorView.swift index afb3ba2d94f..8515abfbae8 100644 --- a/apps/mobile/modules/t3-composer-editor/ios/T3ComposerEditorView.swift +++ b/apps/mobile/modules/t3-composer-editor/ios/T3ComposerEditorView.swift @@ -63,6 +63,24 @@ private final class ComposerTextView: UITextView { var onPasteImages: (([String]) -> Void)? var onAttributedMutation: (() -> Void)? + var onSubmit: (() -> Void)? + + override var keyCommands: [UIKeyCommand]? { + var commands = super.keyCommands ?? [] + let submit = UIKeyCommand( + input: "\r", + modifierFlags: .command, + action: #selector(submitMessage(_:)) + ) + submit.discoverabilityTitle = "Send Message" + submit.wantsPriorityOverSystemBehavior = true + commands.append(submit) + return commands + } + + @objc private func submitMessage(_ sender: UIKeyCommand) { + onSubmit?() + } override func canPerformAction(_ action: Selector, withSender sender: Any?) -> Bool { if action == #selector(paste(_:)) { @@ -299,6 +317,7 @@ public final class T3ComposerEditorView: ExpoView, UITextViewDelegate { let onComposerSelectionChange = EventDispatcher() let onComposerFocus = EventDispatcher() let onComposerBlur = EventDispatcher() + let onComposerSubmit = EventDispatcher() let onComposerPasteImages = EventDispatcher() let onComposerContentSizeChange = EventDispatcher() @@ -320,6 +339,9 @@ public final class T3ComposerEditorView: ExpoView, UITextViewDelegate { textView.onAttributedMutation = { [weak self] in self?.emitTextChange() } + textView.onSubmit = { [weak self] in + self?.onComposerSubmit([:]) + } addSubview(textView) placeholderLabel.numberOfLines = 0 diff --git a/apps/mobile/modules/t3-markdown-text/src/NativeMarkdownBlock.ios.tsx b/apps/mobile/modules/t3-markdown-text/src/NativeMarkdownBlock.ios.tsx index 212c385124e..e6a045b3cd9 100644 --- a/apps/mobile/modules/t3-markdown-text/src/NativeMarkdownBlock.ios.tsx +++ b/apps/mobile/modules/t3-markdown-text/src/NativeMarkdownBlock.ios.tsx @@ -26,6 +26,15 @@ function nodeKey(node: MarkdownNode, index: number): string { return `${node.type}:${node.beg ?? index}:${node.end ?? index}`; } +/** Code inside markdown scales with the base text size (12pt at the default 15pt body). */ +function codeBlockFontSize(textStyle: NativeMarkdownTextStyle): number { + return Math.max(10, Math.round(textStyle.fontSize * 0.8)); +} + +function codeBlockLineHeight(textStyle: NativeMarkdownTextStyle): number { + return codeBlockFontSize(textStyle) + 6; +} + function nodeText(node: MarkdownNode): string { if (node.content !== undefined) { return node.content; @@ -161,8 +170,8 @@ function HighlightedCodeText(props: { style={{ color: props.textStyle.codeColor, fontFamily: "ui-monospace", - fontSize: 12, - lineHeight: 18, + fontSize: codeBlockFontSize(props.textStyle), + lineHeight: codeBlockLineHeight(props.textStyle), }} > {props.content} @@ -196,8 +205,8 @@ function HighlightedCodeText(props: { style={{ color: props.textStyle.codeColor, fontFamily: "ui-monospace", - fontSize: 12, - lineHeight: 18, + fontSize: codeBlockFontSize(props.textStyle), + lineHeight: codeBlockLineHeight(props.textStyle), }} > {keyedLines.map((line, lineIndex) => ( @@ -264,7 +273,7 @@ function NativeCodeBlock(props: { flex: 1, color: props.textStyle.mutedColor, fontFamily: "ui-monospace", - fontSize: 12, + fontSize: codeBlockFontSize(props.textStyle), }} > {languageLabel} diff --git a/apps/mobile/modules/t3-markdown-text/src/NativeMarkdownSelectableText.ios.tsx b/apps/mobile/modules/t3-markdown-text/src/NativeMarkdownSelectableText.ios.tsx index c7a5a16d6fd..994c8ce2ed5 100644 --- a/apps/mobile/modules/t3-markdown-text/src/NativeMarkdownSelectableText.ios.tsx +++ b/apps/mobile/modules/t3-markdown-text/src/NativeMarkdownSelectableText.ios.tsx @@ -32,11 +32,25 @@ function runKeySignature(run: NativeMarkdownTextRun): string { ].join(":"); } +const DEFAULT_BODY_FONT_SIZE = 15; +const DEFAULT_HEADING_FONT_SIZES = [22, 19, 17, 16, 15, 15] as const; + +function resolveHeadingFontSize(textStyle: NativeMarkdownTextStyle, headingLevel: number): number { + const index = Math.max(0, Math.min(5, headingLevel - 1)); + const configured = textStyle.headingFontSizes?.[index]; + if (typeof configured === "number" && Number.isFinite(configured)) { + return configured; + } + + const scale = textStyle.fontSize / DEFAULT_BODY_FONT_SIZE; + return Math.max(12, Math.round(DEFAULT_HEADING_FONT_SIZES[index] * scale)); +} + function runStyle(run: NativeMarkdownTextRun, textStyle: NativeMarkdownTextStyle): TextStyle { const isFile = run.fileIcon != null; const isSkill = run.skillName != null; const headingLevel = Math.max(1, Math.min(6, run.headingLevel ?? 1)); - const headingFontSize = [22, 19, 17, 16, 15, 15][headingLevel - 1] ?? 15; + const headingFontSize = resolveHeadingFontSize(textStyle, headingLevel); const isHeading = run.role === "heading"; const isCodeBlock = run.role === "code-block" || run.role === "code-language"; const hasParagraphStyle = run.headIndent !== undefined; @@ -88,7 +102,7 @@ function runStyle(run: NativeMarkdownTextRun, textStyle: NativeMarkdownTextStyle : isHeading ? headingFontSize : run.role === "code-language" - ? 11 + ? Math.max(10, Math.round(textStyle.fontSize * 0.73)) : run.code || isCodeBlock ? Math.max(12, textStyle.fontSize - 2) : textStyle.fontSize, @@ -98,9 +112,9 @@ function runStyle(run: NativeMarkdownTextRun, textStyle: NativeMarkdownTextStyle : run.role === "list-break" ? textStyle.lineHeight + (run.spacing ?? 0) : isHeading - ? Math.max(headingFontSize + 6, 20) + ? Math.max(headingFontSize + 6, textStyle.lineHeight + 2) : isCodeBlock - ? 18 + ? Math.max(16, textStyle.lineHeight - 2) : textStyle.lineHeight, fontStyle: run.italic ? "italic" : "normal", fontWeight: isHeading || run.bold || isFile || isSkill ? "700" : "400", @@ -148,6 +162,9 @@ export function NativeMarkdownSelectableText(props: { // color-only child update can otherwise leave the previous appearance cached. const appearanceKey = [ colorScheme ?? "unspecified", + props.textStyle.fontSize, + props.textStyle.lineHeight, + props.textStyle.headingFontSizes?.join(","), props.textStyle.color, props.textStyle.strongColor, props.textStyle.mutedColor, diff --git a/apps/mobile/modules/t3-markdown-text/src/SelectableMarkdownText.types.ts b/apps/mobile/modules/t3-markdown-text/src/SelectableMarkdownText.types.ts index 76c1402d3c8..42cc3cd6fb6 100644 --- a/apps/mobile/modules/t3-markdown-text/src/SelectableMarkdownText.types.ts +++ b/apps/mobile/modules/t3-markdown-text/src/SelectableMarkdownText.types.ts @@ -16,6 +16,7 @@ export interface NativeMarkdownTextStyle { readonly fontFamily: string; readonly headingFontFamily: string; readonly boldFontFamily: string; + readonly headingFontSizes?: ReadonlyArray; } export interface MarkdownHighlightedToken { diff --git a/apps/mobile/modules/t3-native-controls/expo-module.config.json b/apps/mobile/modules/t3-native-controls/expo-module.config.json new file mode 100644 index 00000000000..b53b3c50a03 --- /dev/null +++ b/apps/mobile/modules/t3-native-controls/expo-module.config.json @@ -0,0 +1,6 @@ +{ + "platforms": ["apple"], + "apple": { + "modules": ["T3NativeControlsModule", "T3KeyboardCommandsModule"] + } +} diff --git a/apps/mobile/modules/t3-native-controls/ios/T3HeaderButtonView.swift b/apps/mobile/modules/t3-native-controls/ios/T3HeaderButtonView.swift new file mode 100644 index 00000000000..7b7f9db6707 --- /dev/null +++ b/apps/mobile/modules/t3-native-controls/ios/T3HeaderButtonView.swift @@ -0,0 +1,62 @@ +import ExpoModulesCore +import UIKit + +public final class T3HeaderButtonView: ExpoView { + private static let size: CGFloat = 44 + private static let symbolSize: CGFloat = 18 + + private let button = UIButton(type: .system) + private var systemImage = "circle" + + let onTriggered = EventDispatcher() + + public required init(appContext: AppContext? = nil) { + super.init(appContext: appContext) + + isAccessibilityElement = false + button.frame = bounds + button.autoresizingMask = [.flexibleWidth, .flexibleHeight] + button.addTarget(self, action: #selector(handlePress), for: .primaryActionTriggered) + addSubview(button) + applyConfiguration() + } + + public override var intrinsicContentSize: CGSize { + CGSize(width: Self.size, height: Self.size) + } + + public func setLabel(_ label: String) { + button.accessibilityLabel = label + } + + public func setSystemImage(_ systemImage: String) { + guard self.systemImage != systemImage else { + return + } + self.systemImage = systemImage + applyConfiguration() + } + + private func applyConfiguration() { + var configuration: UIButton.Configuration + if #available(iOS 26.0, *) { + configuration = .glass() + configuration.cornerStyle = .capsule + } else { + configuration = .plain() + } + + configuration.baseForegroundColor = .label + configuration.contentInsets = .zero + configuration.image = UIImage(systemName: systemImage) + configuration.preferredSymbolConfigurationForImage = UIImage.SymbolConfiguration( + pointSize: Self.symbolSize, + weight: .regular + ) + button.configuration = configuration + } + + @objc private func handlePress() { + onTriggered() + } +} diff --git a/apps/mobile/modules/t3-native-controls/ios/T3KeyboardCommandsModule.swift b/apps/mobile/modules/t3-native-controls/ios/T3KeyboardCommandsModule.swift new file mode 100644 index 00000000000..ea572cc7a01 --- /dev/null +++ b/apps/mobile/modules/t3-native-controls/ios/T3KeyboardCommandsModule.swift @@ -0,0 +1,131 @@ +import ExpoModulesCore +import UIKit + +public final class T3KeyboardCommandsModule: Module { + public func definition() -> ModuleDefinition { + Name("T3KeyboardCommands") + + View(T3KeyboardCommandsView.self) { + Prop("enabledCommands") { (view: T3KeyboardCommandsView, commands: [String]) in + view.setEnabledCommands(commands) + } + Events("onCommand") + } + } +} + +public final class T3KeyboardCommandsView: ExpoView { + let onCommand = EventDispatcher() + private var enabledCommands = Set() + + public override var canBecomeFirstResponder: Bool { true } + + public override var keyCommands: [UIKeyCommand]? { + [ + enabledCommand("newTask", input: "n", modifiers: .command, action: #selector(newTask), title: "New Task"), + enabledCommand("focusSearch", input: "f", modifiers: .command, action: #selector(focusSearch), title: "Find"), + enabledCommand("focusSearch", input: "k", modifiers: .command, action: #selector(focusSearch), title: "Focus Search"), + enabledCommand("back", input: "[", modifiers: .command, action: #selector(goBack), title: "Back"), + enabledCommand("files", input: "f", modifiers: [.command, .shift], action: #selector(openFiles), title: "Open Files"), + enabledCommand("terminal", input: "t", modifiers: [.command, .shift], action: #selector(openTerminal), title: "Open Terminal"), + enabledCommand("review", input: "r", modifiers: [.command, .shift], action: #selector(openReview), title: "Open Review"), + enabledCommand("toggleSidebar", input: "\\", modifiers: .command, action: #selector(handleToggleSidebar), title: "Toggle Sidebar"), + ].compactMap { $0 } + } + + func setEnabledCommands(_ commands: [String]) { + enabledCommands = Set(commands) + if isFirstResponder { + resignFirstResponder() + } + reclaimFirstResponderIfAvailable() + } + + private func enabledCommand( + _ identifier: String, + input: String, + modifiers: UIKeyModifierFlags, + action: Selector, + title: String + ) -> UIKeyCommand? { + guard enabledCommands.contains(identifier) else { return nil } + return command(input, modifiers: modifiers, action: action, title: title) + } + + public required init(appContext: AppContext? = nil) { + super.init(appContext: appContext) + NotificationCenter.default.addObserver( + self, + selector: #selector(reclaimFirstResponderIfAvailable), + name: UITextView.textDidEndEditingNotification, + object: nil + ) + NotificationCenter.default.addObserver( + self, + selector: #selector(reclaimFirstResponderIfAvailable), + name: UITextField.textDidEndEditingNotification, + object: nil + ) + NotificationCenter.default.addObserver( + self, + selector: #selector(reclaimFirstResponderIfAvailable), + name: UIApplication.didBecomeActiveNotification, + object: nil + ) + } + + deinit { + NotificationCenter.default.removeObserver(self) + } + + public override func didMoveToWindow() { + super.didMoveToWindow() + reclaimFirstResponderIfAvailable() + } + + public override func layoutSubviews() { + super.layoutSubviews() + reclaimFirstResponderIfAvailable() + } + + private func command( + _ input: String, + modifiers: UIKeyModifierFlags, + action: Selector, + title: String + ) -> UIKeyCommand { + let command = UIKeyCommand(input: input, modifierFlags: modifiers, action: action) + command.discoverabilityTitle = title + command.wantsPriorityOverSystemBehavior = true + return command + } + + @objc private func newTask() { emit("newTask") } + @objc private func focusSearch() { emit("focusSearch") } + @objc private func goBack() { emit("back") } + @objc private func openFiles() { emit("files") } + @objc private func openTerminal() { emit("terminal") } + @objc private func openReview() { emit("review") } + @objc private func handleToggleSidebar() { emit("toggleSidebar") } + + private func emit(_ command: String) { + onCommand(["command": command]) + } + + @objc private func reclaimFirstResponderIfAvailable() { + DispatchQueue.main.async { [weak self] in + guard let self, self.window?.t3FirstResponder == nil else { return } + self.becomeFirstResponder() + } + } +} + +private extension UIView { + var t3FirstResponder: UIResponder? { + if isFirstResponder { return self } + for subview in subviews { + if let responder = subview.t3FirstResponder { return responder } + } + return nil + } +} diff --git a/apps/mobile/modules/t3-native-controls/ios/T3NativeControls.podspec b/apps/mobile/modules/t3-native-controls/ios/T3NativeControls.podspec new file mode 100644 index 00000000000..29735c301cc --- /dev/null +++ b/apps/mobile/modules/t3-native-controls/ios/T3NativeControls.podspec @@ -0,0 +1,19 @@ +Pod::Spec.new do |s| + s.name = 'T3NativeControls' + s.version = '1.0.0' + s.summary = 'Native UIKit controls for T3 Code mobile.' + s.description = 'UIKit-backed controls that match native iOS navigation chrome.' + s.author = 'T3 Tools' + s.homepage = 'https://t3tools.com' + s.platforms = { + :ios => '18.0', + } + s.source = { :path => '.' } + s.static_framework = true + + s.dependency 'ExpoModulesCore' + s.pod_target_xcconfig = { + 'DEFINES_MODULE' => 'YES', + } + s.source_files = '**/*.{h,m,mm,swift,hpp,cpp}' +end diff --git a/apps/mobile/modules/t3-native-controls/ios/T3NativeControlsModule.swift b/apps/mobile/modules/t3-native-controls/ios/T3NativeControlsModule.swift new file mode 100644 index 00000000000..33e1dc9086c --- /dev/null +++ b/apps/mobile/modules/t3-native-controls/ios/T3NativeControlsModule.swift @@ -0,0 +1,18 @@ +import ExpoModulesCore + +public final class T3NativeControlsModule: Module { + public func definition() -> ModuleDefinition { + Name("T3NativeControls") + + View(T3HeaderButtonView.self) { + Prop("label") { (view: T3HeaderButtonView, label: String) in + view.setLabel(label) + } + Prop("systemImage") { (view: T3HeaderButtonView, systemImage: String) in + view.setSystemImage(systemImage) + } + + Events("onTriggered") + } + } +} diff --git a/apps/mobile/modules/t3-review-diff/ios/T3ReviewDiffModule.swift b/apps/mobile/modules/t3-review-diff/ios/T3ReviewDiffModule.swift index 81cdd8417d3..96a1785f9fc 100644 --- a/apps/mobile/modules/t3-review-diff/ios/T3ReviewDiffModule.swift +++ b/apps/mobile/modules/t3-review-diff/ios/T3ReviewDiffModule.swift @@ -5,22 +5,14 @@ public class T3ReviewDiffModule: Module { Name("T3ReviewDiffSurface") View(T3ReviewDiffView.self) { - Prop("rowsJson") { (view: T3ReviewDiffView, rowsJson: String) in - view.setRowsJson(rowsJson) - } - - Prop("tokensJson") { (view: T3ReviewDiffView, tokensJson: String) in - view.setTokensJson(tokensJson) - } - - Prop("tokensPatchJson") { (view: T3ReviewDiffView, tokensPatchJson: String) in - view.setTokensPatchJson(tokensPatchJson) - } - Prop("tokensResetKey") { (view: T3ReviewDiffView, tokensResetKey: String) in view.setTokensResetKey(tokensResetKey) } + Prop("contentResetKey") { (view: T3ReviewDiffView, contentResetKey: String) in + view.setContentResetKey(contentResetKey) + } + Prop("collapsedFileIdsJson") { (view: T3ReviewDiffView, collapsedFileIdsJson: String) in view.setCollapsedFileIdsJson(collapsedFileIdsJson) } @@ -61,7 +53,42 @@ public class T3ReviewDiffModule: Module { view.setInitialRowIndex(initialRowIndex) } - Events("onDebug", "onToggleFile", "onToggleViewedFile", "onPressLine", "onToggleComment") + Prop("refreshing") { (view: T3ReviewDiffView, refreshing: Bool) in + view.setRefreshing(refreshing) + } + + Events( + "onDebug", + "onVisibleFileChange", + "onToggleFile", + "onToggleViewedFile", + "onPressLine", + "onToggleComment", + "onPullToRefresh" + ) + + AsyncFunction("scrollToFile") { (view: T3ReviewDiffView, fileId: String, animated: Bool) in + view.scrollToFile(fileId, animated: animated) + } + + AsyncFunction("scrollToTop") { (view: T3ReviewDiffView, animated: Bool) in + view.scrollToTop(animated: animated) + } + + // Large, frequently changing JSON values cannot be regular Fabric props. Expo's + // prop adapter compares strings on the main thread before invoking a setter, which + // makes a syntax-token patch capable of blocking a frame by itself. + AsyncFunction("setRowsJson") { (view: T3ReviewDiffView, rowsJson: String) in + view.setRowsJson(rowsJson) + } + + AsyncFunction("setTokensJson") { (view: T3ReviewDiffView, tokensJson: String) in + view.setTokensJson(tokensJson) + } + + AsyncFunction("setTokensPatchJson") { (view: T3ReviewDiffView, tokensPatchJson: String) in + view.setTokensPatchJson(tokensPatchJson) + } } } } diff --git a/apps/mobile/modules/t3-review-diff/ios/T3ReviewDiffView.swift b/apps/mobile/modules/t3-review-diff/ios/T3ReviewDiffView.swift index f8e080e7609..ef2157d4355 100644 --- a/apps/mobile/modules/t3-review-diff/ios/T3ReviewDiffView.swift +++ b/apps/mobile/modules/t3-review-diff/ios/T3ReviewDiffView.swift @@ -1,7 +1,7 @@ import ExpoModulesCore import UIKit -private struct ReviewDiffNativeRow: Decodable { +private struct ReviewDiffNativeRow: Decodable, Sendable { let kind: String let id: String let fileId: String? @@ -21,18 +21,18 @@ private struct ReviewDiffNativeRow: Decodable { let commentSectionTitle: String? } -private struct ReviewDiffNativeWordDiffRange: Decodable { +private struct ReviewDiffNativeWordDiffRange: Decodable, Sendable { let start: Int let end: Int } -private struct ReviewDiffNativeToken: Decodable { +private struct ReviewDiffNativeToken: Decodable, Sendable { let content: String let color: String? let fontStyle: Int? } -private struct ReviewDiffNativeTokenPatch: Decodable { +private struct ReviewDiffNativeTokenPatch: Decodable, Sendable { let resetKey: String? let chunkIndex: Int? let tokensByRowId: [String: [ReviewDiffNativeToken]]? @@ -312,6 +312,10 @@ private struct ReviewDiffNativeStyle { } public final class T3ReviewDiffView: ExpoView, UIScrollViewDelegate { + private let payloadDecodeQueue = DispatchQueue( + label: "com.t3tools.review-diff.payload-decode", + qos: .userInitiated + ) private let scrollView = UIScrollView() private let contentView = ReviewDiffContentView() private var rows: [ReviewDiffNativeRow] = [] @@ -323,14 +327,25 @@ public final class T3ReviewDiffView: ExpoView, UIScrollViewDelegate { private var lastMetricsDebugKey = "" private var lastVisibleRangeDebugKey = "" private var tokensResetKey = "" + private var contentResetKey = "" private var initialRowIndex: Int? private var hasAppliedInitialRowIndex = false + private var lastVisibleFileId: String? + private var pendingScrollFileId: String? + private var pendingScrollAnimated = false + private var isProgrammaticScrollActive = false + private var rowsDecodeGeneration = 0 + private var tokensDecodeGeneration = 0 let onDebug = EventDispatcher() + let onVisibleFileChange = EventDispatcher() let onToggleFile = EventDispatcher() let onToggleViewedFile = EventDispatcher() let onPressLine = EventDispatcher() let onToggleComment = EventDispatcher() + let onPullToRefresh = EventDispatcher() + + private let pullRefreshControl = UIRefreshControl() public required init(appContext: AppContext? = nil) { super.init(appContext: appContext) @@ -345,6 +360,8 @@ public final class T3ReviewDiffView: ExpoView, UIScrollViewDelegate { scrollView.showsVerticalScrollIndicator = true scrollView.showsHorizontalScrollIndicator = false scrollView.backgroundColor = contentView.theme.background + pullRefreshControl.addTarget(self, action: #selector(handlePullToRefresh), for: .valueChanged) + scrollView.refreshControl = pullRefreshControl addSubview(scrollView) contentView.backgroundColor = contentView.theme.background @@ -379,6 +396,13 @@ public final class T3ReviewDiffView: ExpoView, UIScrollViewDelegate { updateViewportFrame() } + public func scrollViewWillBeginDragging(_ scrollView: UIScrollView) { + // A direct gesture takes ownership from any interrupted programmatic jump. + // Resume visible-file events immediately so the inspector follows the finger. + isProgrammaticScrollActive = false + contentView.isVerticalScrollActive = true + } + public func scrollViewDidEndDragging(_ scrollView: UIScrollView, willDecelerate decelerate: Bool) { guard !decelerate else { return @@ -396,77 +420,125 @@ public final class T3ReviewDiffView: ExpoView, UIScrollViewDelegate { } func setRowsJson(_ rowsJson: String) { - guard let data = rowsJson.data(using: .utf8) else { - return - } + rowsDecodeGeneration += 1 + let generation = rowsDecodeGeneration - do { - rows = try JSONDecoder().decode([ReviewDiffNativeRow].self, from: data) - contentView.rows = rows - hasAppliedInitialRowIndex = false - emitDebug("rows-decoded", [ - "rows": rows.count, - "firstKind": rows.first?.kind ?? "none", - ]) - updateContentMetrics() - } catch { - rows = [] - contentView.rows = [] - hasAppliedInitialRowIndex = false - updateContentMetrics() - emitDebug("rows-decode-failed", [ - "error": error.localizedDescription, - ]) + payloadDecodeQueue.async { [weak self] in + guard let data = rowsJson.data(using: .utf8) else { + return + } + + do { + let decodedRows = try JSONDecoder().decode([ReviewDiffNativeRow].self, from: data) + DispatchQueue.main.async { [weak self] in + guard let self, generation == self.rowsDecodeGeneration else { + return + } + self.rows = decodedRows + self.contentView.rows = decodedRows + self.hasAppliedInitialRowIndex = false + self.lastVisibleFileId = nil + self.emitDebug("rows-decoded", [ + "rows": decodedRows.count, + "firstKind": decodedRows.first?.kind ?? "none", + ]) + self.updateContentMetrics() + self.applyPendingScrollIfNeeded() + } + } catch { + let message = error.localizedDescription + DispatchQueue.main.async { [weak self] in + guard let self, generation == self.rowsDecodeGeneration else { + return + } + self.rows = [] + self.contentView.rows = [] + self.hasAppliedInitialRowIndex = false + self.lastVisibleFileId = nil + self.pendingScrollFileId = nil + self.updateContentMetrics() + self.emitDebug("rows-decode-failed", ["error": message]) + } + } } } func setTokensJson(_ tokensJson: String) { - guard let data = tokensJson.data(using: .utf8) else { - return - } + tokensDecodeGeneration += 1 + let generation = tokensDecodeGeneration - do { - contentView.tokensByRowId = try JSONDecoder().decode( - [String: [ReviewDiffNativeToken]].self, - from: data - ) - } catch { - contentView.tokensByRowId = [:] - emitDebug("tokens-decode-failed", [ - "error": error.localizedDescription, - ]) + payloadDecodeQueue.async { [weak self] in + guard let data = tokensJson.data(using: .utf8) else { + return + } + + do { + let decodedTokens = try JSONDecoder().decode( + [String: [ReviewDiffNativeToken]].self, + from: data + ) + DispatchQueue.main.async { [weak self] in + guard let self, generation == self.tokensDecodeGeneration else { + return + } + self.contentView.tokensByRowId = decodedTokens + } + } catch { + let message = error.localizedDescription + DispatchQueue.main.async { [weak self] in + guard let self, generation == self.tokensDecodeGeneration else { + return + } + self.contentView.tokensByRowId = [:] + self.emitDebug("tokens-decode-failed", ["error": message]) + } + } } } func setTokensPatchJson(_ tokensPatchJson: String) { - guard let data = tokensPatchJson.data(using: .utf8) else { - return - } + let contentResetKey = self.contentResetKey - do { - let patch = try JSONDecoder().decode(ReviewDiffNativeTokenPatch.self, from: data) - if let resetKey = patch.resetKey, resetKey != tokensResetKey { - tokensResetKey = resetKey - contentView.tokensByRowId = [:] - } - - let tokensByRowId = patch.tokensByRowId ?? [:] - if tokensByRowId.isEmpty { + payloadDecodeQueue.async { [weak self] in + guard let data = tokensPatchJson.data(using: .utf8) else { return } - contentView.mergeTokensByRowId(tokensByRowId) - if let chunkIndex = patch.chunkIndex, chunkIndex < 5 || chunkIndex.isMultiple(of: 10) { - emitDebug("tokens-patch-decoded", [ - "chunkIndex": chunkIndex, - "rows": tokensByRowId.count, - "totalRows": contentView.tokensByRowId.count, - ]) + do { + let patch = try JSONDecoder().decode(ReviewDiffNativeTokenPatch.self, from: data) + DispatchQueue.main.async { [weak self] in + guard let self else { + return + } + guard contentResetKey == self.contentResetKey else { + return + } + // A highlighter request from the previous file can finish after the view has + // already reset. Never let that stale patch roll the native token state back. + if let resetKey = patch.resetKey, resetKey != self.tokensResetKey { + return + } + + let tokensByRowId = patch.tokensByRowId ?? [:] + if tokensByRowId.isEmpty { + return + } + + self.contentView.mergeTokensByRowId(tokensByRowId) + if let chunkIndex = patch.chunkIndex, chunkIndex < 5 || chunkIndex.isMultiple(of: 10) { + self.emitDebug("tokens-patch-decoded", [ + "chunkIndex": chunkIndex, + "rows": tokensByRowId.count, + "totalRows": self.contentView.tokensByRowId.count, + ]) + } + } + } catch { + let message = error.localizedDescription + DispatchQueue.main.async { [weak self] in + self?.emitDebug("tokens-patch-decode-failed", ["error": message]) + } } - } catch { - emitDebug("tokens-patch-decode-failed", [ - "error": error.localizedDescription, - ]) } } @@ -482,6 +554,27 @@ public final class T3ReviewDiffView: ExpoView, UIScrollViewDelegate { ]) } + func setContentResetKey(_ contentResetKey: String) { + guard contentResetKey != self.contentResetKey else { + return + } + + self.contentResetKey = contentResetKey + rowsDecodeGeneration += 1 + tokensDecodeGeneration += 1 + contentView.tokensByRowId = [:] + rows = [] + contentView.rows = [] + hasAppliedInitialRowIndex = false + pendingScrollFileId = nil + isProgrammaticScrollActive = false + scrollView.setContentOffset(.zero, animated: false) + updateContentMetrics() + updateViewportFrame() + lastVisibleFileId = nil + applyInitialRowIndexIfNeeded() + } + func setCollapsedFileIdsJson(_ collapsedFileIdsJson: String) { let nextCollapsedFileIds = decodeFileIdSet(collapsedFileIdsJson) let changedFileIds = contentView.collapsedFileIds.symmetricDifference(nextCollapsedFileIds) @@ -573,6 +666,8 @@ public final class T3ReviewDiffView: ExpoView, UIScrollViewDelegate { contentView.invalidateVisibleViewport() contentView.setNeedsDisplay() applyInitialRowIndexIfNeeded() + applyPendingScrollIfNeeded() + emitVisibleFileIfNeeded() let debugKey = "\(rows.count):\(Int(bounds.width)):\(Int(bounds.height)):\(Int(height))" if debugKey != lastMetricsDebugKey { @@ -596,11 +691,41 @@ public final class T3ReviewDiffView: ExpoView, UIScrollViewDelegate { } private func finishVerticalScroll() { + isProgrammaticScrollActive = false contentView.isVerticalScrollActive = false updateViewportFrame() emitVisibleRange(reason: "scroll-end") } + private func emitVisibleFileIfNeeded() { + // Keep the explicit destination selected while UIKit animates through the files + // between the old and new offsets. Emitting every intermediate header forces a + // React render per crossing and makes the navigator visibly flash. + guard !isProgrammaticScrollActive else { + return + } + + // The top of the combined diff is the explicit "All files" destination. + // Treat it as a first-class selection instead of immediately resolving the + // first file header and undoing the navigator's optimistic selection. + if scrollView.contentOffset.y <= 0.5 { + guard lastVisibleFileId != nil else { + return + } + lastVisibleFileId = nil + onVisibleFileChange(["fileId": NSNull()]) + return + } + + guard let fileId = contentView.visibleFileId(atVerticalOffset: scrollView.contentOffset.y), + fileId != lastVisibleFileId else { + return + } + + lastVisibleFileId = fileId + onVisibleFileChange(["fileId": fileId]) + } + private func emitVisibleRange(reason: String) { guard let range = contentView.currentVisibleRowRange() else { return @@ -670,6 +795,33 @@ public final class T3ReviewDiffView: ExpoView, UIScrollViewDelegate { applyInitialRowIndexIfNeeded() } + func scrollToFile(_ fileId: String, animated: Bool) { + pendingScrollFileId = fileId + pendingScrollAnimated = animated + applyPendingScrollIfNeeded() + } + + func scrollToTop(animated: Bool) { + pendingScrollFileId = nil + pendingScrollAnimated = false + setVerticalContentOffset(0, animated: animated) + } + + @objc private func handlePullToRefresh() { + onPullToRefresh([:]) + } + + /// Driven from JS: set to false once the reload completes to dismiss the spinner. + func setRefreshing(_ refreshing: Bool) { + if refreshing { + if !pullRefreshControl.isRefreshing { + pullRefreshControl.beginRefreshing() + } + } else if pullRefreshControl.isRefreshing { + pullRefreshControl.endRefreshing() + } + } + private func applyStyle() { contentView.style = ReviewDiffNativeStyle .resolve(stylePayload) @@ -686,6 +838,38 @@ public final class T3ReviewDiffView: ExpoView, UIScrollViewDelegate { ) contentView.verticalOffset = scrollView.contentOffset.y contentView.invalidateVisibleViewport() + emitVisibleFileIfNeeded() + } + + private func applyPendingScrollIfNeeded() { + guard let fileId = pendingScrollFileId, + bounds.height > 0 else { + return + } + + guard let headerOffset = contentView.fileHeaderOffset(forFileId: fileId) else { + if !rows.isEmpty { + pendingScrollFileId = nil + } + return + } + + let animated = pendingScrollAnimated + pendingScrollFileId = nil + pendingScrollAnimated = false + setVerticalContentOffset(headerOffset, animated: animated) + } + + private func setVerticalContentOffset(_ targetOffset: CGFloat, animated: Bool) { + let maxOffset = max(scrollView.contentSize.height - scrollView.bounds.height, 0) + let clampedOffset = min(max(targetOffset, 0), maxOffset) + let shouldAnimate = animated && abs(scrollView.contentOffset.y - clampedOffset) > 0.5 + isProgrammaticScrollActive = shouldAnimate + contentView.isVerticalScrollActive = shouldAnimate + scrollView.setContentOffset(CGPoint(x: 0, y: clampedOffset), animated: shouldAnimate) + if !shouldAnimate { + updateViewportFrame() + } } private func applyInitialRowIndexIfNeeded() { @@ -1255,6 +1439,32 @@ private final class ReviewDiffContentView: UIView, UIGestureRecognizerDelegate { return rowOffsets[rowIndex] } + func visibleFileId(atVerticalOffset verticalOffset: CGFloat) -> String? { + guard let firstHeaderRowIndex = fileHeaderRowIndices.first else { + return nil + } + + var lowerBound = 0 + var upperBound = fileHeaderRowIndices.count + while lowerBound < upperBound { + let midpoint = (lowerBound + upperBound) / 2 + let rowIndex = fileHeaderRowIndices[midpoint] + if rowOffsets[rowIndex] <= verticalOffset + 0.5 { + lowerBound = midpoint + 1 + } else { + upperBound = midpoint + } + } + + let rowIndex = lowerBound > 0 + ? fileHeaderRowIndices[lowerBound - 1] + : firstHeaderRowIndex + guard rows.indices.contains(rowIndex) else { + return nil + } + return resolvedFileId(for: rows[rowIndex]) + } + private func fileHeaderRowIndex(forFileId fileId: String) -> Int? { fileHeaderRowIndices.first { rowIndex in rows.indices.contains(rowIndex) && resolvedFileId(for: rows[rowIndex]) == fileId diff --git a/apps/mobile/modules/t3-terminal/android/src/main/java/expo/modules/t3terminal/T3TerminalModule.kt b/apps/mobile/modules/t3-terminal/android/src/main/java/expo/modules/t3terminal/T3TerminalModule.kt index abb3982be1e..20e1bab41e5 100644 --- a/apps/mobile/modules/t3-terminal/android/src/main/java/expo/modules/t3terminal/T3TerminalModule.kt +++ b/apps/mobile/modules/t3-terminal/android/src/main/java/expo/modules/t3terminal/T3TerminalModule.kt @@ -7,6 +7,12 @@ class T3TerminalModule : Module() { override fun definition() = ModuleDefinition { Name("T3TerminalSurface") + // Bumped when native hardware-keyboard handling changes; surfaced in the JS debug + // logs so a stale native binary is distinguishable from a broken key pipeline. + Constants( + "hardwareKeyRevision" to 2, + ) + View(T3TerminalView::class) { Prop("terminalKey") { view: T3TerminalView, terminalKey: String -> view.terminalKey = terminalKey @@ -20,6 +26,10 @@ class T3TerminalModule : Module() { view.fontSize = fontSize.toFloat() } + Prop("focusRequest") { view: T3TerminalView, focusRequest: Double -> + view.focusRequest = focusRequest + } + Prop("appearanceScheme") { view: T3TerminalView, appearanceScheme: String -> view.appearanceScheme = appearanceScheme } diff --git a/apps/mobile/modules/t3-terminal/android/src/main/java/expo/modules/t3terminal/T3TerminalView.kt b/apps/mobile/modules/t3-terminal/android/src/main/java/expo/modules/t3terminal/T3TerminalView.kt index ec85d0ba070..cebe86272fd 100644 --- a/apps/mobile/modules/t3-terminal/android/src/main/java/expo/modules/t3terminal/T3TerminalView.kt +++ b/apps/mobile/modules/t3-terminal/android/src/main/java/expo/modules/t3terminal/T3TerminalView.kt @@ -62,6 +62,15 @@ class T3TerminalView(context: Context, appContext: AppContext) : ExpoView(contex var themeConfig: String = "" + var focusRequest: Double = 0.0 + set(value) { + val previous = field + field = value + if (value != previous && value > 0) { + requestKeyboardFocus() + } + } + var backgroundColorHex: String = "#24292E" set(value) { field = value @@ -113,11 +122,19 @@ class T3TerminalView(context: Context, appContext: AppContext) : ExpoView(contex } inputView.setOnKeyListener { _, keyCode, event -> if (event.action != android.view.KeyEvent.ACTION_DOWN) return@setOnKeyListener false - when (keyCode) { - android.view.KeyEvent.KEYCODE_DEL -> { + when { + keyCode == android.view.KeyEvent.KEYCODE_DEL -> { onInput(mapOf("data" to "\u007F")) true } + // Hardware keyboard Ctrl+A..Z -> control bytes 0x01..0x1A (Ctrl+C, Ctrl+Z, ...). + event.isCtrlPressed && + keyCode in android.view.KeyEvent.KEYCODE_A..android.view.KeyEvent.KEYCODE_Z -> { + onInput( + mapOf("data" to (keyCode - android.view.KeyEvent.KEYCODE_A + 1).toChar().toString()), + ) + true + } else -> false } } diff --git a/apps/mobile/modules/t3-terminal/ios/T3TerminalModule.swift b/apps/mobile/modules/t3-terminal/ios/T3TerminalModule.swift index 8f35bcc3aa2..39e1874d5d7 100644 --- a/apps/mobile/modules/t3-terminal/ios/T3TerminalModule.swift +++ b/apps/mobile/modules/t3-terminal/ios/T3TerminalModule.swift @@ -4,6 +4,12 @@ public class T3TerminalModule: Module { public func definition() -> ModuleDefinition { Name("T3TerminalSurface") + // Bumped when native hardware-keyboard handling changes; surfaced in the JS debug + // logs so a stale native binary is distinguishable from a broken key pipeline. + Constants([ + "hardwareKeyRevision": 2, + ]) + View(T3TerminalView.self) { Prop("terminalKey") { (view: T3TerminalView, terminalKey: String) in view.terminalKey = terminalKey @@ -17,6 +23,10 @@ public class T3TerminalModule: Module { view.fontSize = CGFloat(fontSize) } + Prop("focusRequest") { (view: T3TerminalView, focusRequest: Double) in + view.focusRequest = focusRequest + } + Prop("appearanceScheme") { (view: T3TerminalView, appearanceScheme: String) in view.appearanceScheme = appearanceScheme } diff --git a/apps/mobile/modules/t3-terminal/ios/T3TerminalView.swift b/apps/mobile/modules/t3-terminal/ios/T3TerminalView.swift index 685c2642a1f..14cdb4b7802 100644 --- a/apps/mobile/modules/t3-terminal/ios/T3TerminalView.swift +++ b/apps/mobile/modules/t3-terminal/ios/T3TerminalView.swift @@ -22,13 +22,129 @@ private enum GhosttyRuntime { } } +/// Encodes hardware-keyboard combos that UITextField never surfaces through its +/// text-editing delegate (control combos, Escape, Tab, arrow keys) into the byte +/// sequences a terminal expects. +/// +/// Capture uses UIKeyCommand with `wantsPriorityOverSystemBehavior` rather than +/// `pressesBegan`: while a text field is first responder, iPadOS routes hardware key +/// events through the text-input system, which can consume presses before they reach +/// responder press callbacks. Registered key commands are matched deterministically +/// before that happens. +private enum TerminalHardwareKeyEncoder { + /// Characters that produce a control byte when combined with Ctrl. + private static let controlInputs = "abcdefghijklmnopqrstuvwxyz@[\\]^_-? " + + static func makeKeyCommands(action: Selector) -> [UIKeyCommand] { + var commands: [UIKeyCommand] = [] + + let specialInputs = [ + UIKeyCommand.inputEscape, + UIKeyCommand.inputUpArrow, + UIKeyCommand.inputDownArrow, + UIKeyCommand.inputLeftArrow, + UIKeyCommand.inputRightArrow, + "\t", + ] + for input in specialInputs { + commands.append(makeCommand(input: input, modifierFlags: [], action: action)) + } + commands.append(makeCommand(input: "\t", modifierFlags: .shift, action: action)) + + for character in controlInputs { + commands.append(makeCommand(input: String(character), modifierFlags: .control, action: action)) + commands.append( + makeCommand(input: String(character), modifierFlags: [.control, .shift], action: action) + ) + } + + return commands + } + + private static func makeCommand( + input: String, + modifierFlags: UIKeyModifierFlags, + action: Selector + ) -> UIKeyCommand { + let command = UIKeyCommand(input: input, modifierFlags: modifierFlags, action: action) + command.wantsPriorityOverSystemBehavior = true + return command + } + + static func sequence(input: String, modifiers: UIKeyModifierFlags) -> String? { + switch input { + case UIKeyCommand.inputEscape: + return "\u{1B}" + case UIKeyCommand.inputUpArrow: + return "\u{1B}[A" + case UIKeyCommand.inputDownArrow: + return "\u{1B}[B" + case UIKeyCommand.inputRightArrow: + return "\u{1B}[C" + case UIKeyCommand.inputLeftArrow: + return "\u{1B}[D" + case "\t": + return modifiers.contains(.shift) ? "\u{1B}[Z" : "\t" + default: + break + } + + guard modifiers.contains(.control) else { return nil } + guard let scalar = input.lowercased().unicodeScalars.first else { return nil } + return controlSequence(for: scalar) + } + + private static func controlSequence(for scalar: Unicode.Scalar) -> String? { + switch scalar { + case "a"..."z": + // Ctrl+A..Z -> 0x01..0x1A (Ctrl+C = ETX, Ctrl+Z = SUB, ...). + return UnicodeScalar(scalar.value - 96).map(String.init) + case " ", "@": + return "\u{00}" + case "[": + return "\u{1B}" + case "\\": + return "\u{1C}" + case "]": + return "\u{1D}" + case "^": + return "\u{1E}" + case "_", "-": + return "\u{1F}" + case "?": + return "\u{7F}" + default: + return nil + } + } +} + private final class TerminalInputField: UITextField { var onDeleteBackward: (() -> Void)? + var onInsert: ((String) -> Void)? + + private static let hardwareKeyCommands = TerminalHardwareKeyEncoder.makeKeyCommands( + action: #selector(handleHardwareKeyCommand(_:)) + ) + + override var keyCommands: [UIKeyCommand]? { + Self.hardwareKeyCommands + } override func deleteBackward() { onDeleteBackward?() super.deleteBackward() } + + @objc + private func handleHardwareKeyCommand(_ command: UIKeyCommand) { + guard let input = command.input else { return } + guard let sequence = TerminalHardwareKeyEncoder.sequence( + input: input, + modifiers: command.modifierFlags + ) else { return } + onInsert?(sequence) + } } private enum TerminalAppearanceScheme: String { @@ -108,6 +224,15 @@ public final class T3TerminalView: ExpoView, UITextFieldDelegate { } } + var focusRequest: Double = 0 { + didSet { + guard oldValue != focusRequest else { return } + DispatchQueue.main.async { [weak self] in + self?.requestKeyboardFocus() + } + } + } + var appearanceScheme: String = TerminalAppearanceScheme.dark.rawValue { didSet { guard oldValue != appearanceScheme else { return } @@ -167,6 +292,9 @@ public final class T3TerminalView: ExpoView, UITextFieldDelegate { inputField.onDeleteBackward = { [weak self] in self?.emitInput("\u{7F}") } + inputField.onInsert = { [weak self] data in + self?.emitInput(data) + } focusTapGesture.addTarget(self, action: #selector(handleViewportTap)) terminalViewport.addGestureRecognizer(focusTapGesture) diff --git a/apps/mobile/package.json b/apps/mobile/package.json index 963cefd6b7c..f35b92574f5 100644 --- a/apps/mobile/package.json +++ b/apps/mobile/package.json @@ -5,7 +5,7 @@ "main": "index.ts", "scripts": { "dev": "expo start --clear", - "dev:client": "APP_VARIANT=development expo start --dev-client --clear", + "dev:client": "APP_VARIANT=development expo start --dev-client --scheme t3code-dev --clear", "start": "expo start", "start:dev": "APP_VARIANT=development expo start", "start:preview": "APP_VARIANT=preview expo start", @@ -43,12 +43,16 @@ "@clerk/expo": "catalog:", "@effect/atom-react": "catalog:", "@expo-google-fonts/dm-sans": "^0.4.2", - "@expo/ui": "~56.0.8", + "@expo/metro-runtime": "~56.0.15", + "@expo/ui": "~56.0.18", "@legendapp/list": "3.2.0", "@noble/curves": "catalog:", "@noble/hashes": "catalog:", "@pierre/diffs": "catalog:", "@react-native-menu/menu": "^2.0.0", + "@react-navigation/elements": "2.9.26", + "@react-navigation/native": "7.3.4", + "@react-navigation/native-stack": "7.17.6", "@shikijs/core": "4.2.0", "@shikijs/engine-javascript": "4.2.0", "@shikijs/langs": "4.2.0", @@ -62,38 +66,37 @@ "clsx": "^2.1.1", "diff": "8.0.3", "effect": "catalog:", - "expo": "^56.0.0", - "expo-asset": "~56.0.15", - "expo-auth-session": "~56.0.12", - "expo-build-properties": "~56.0.15", - "expo-camera": "~56.0.7", - "expo-clipboard": "~56.0.3", - "expo-constants": "~56.0.16", + "expo": "~56.0.12", + "expo-asset": "~56.0.17", + "expo-auth-session": "~56.0.14", + "expo-build-properties": "~56.0.19", + "expo-camera": "~56.0.8", + "expo-clipboard": "~56.0.4", + "expo-constants": "~56.0.18", "expo-crypto": "~56.0.4", - "expo-dev-client": "~56.0.16", - "expo-file-system": "~56.0.7", - "expo-font": "~56.0.5", + "expo-dev-client": "~56.0.20", + "expo-file-system": "~56.0.8", + "expo-font": "~56.0.7", "expo-glass-effect": "~56.0.4", "expo-haptics": "~56.0.3", - "expo-image-picker": "~56.0.14", - "expo-linking": "~56.0.12", + "expo-image-picker": "~56.0.18", + "expo-linking": "~56.0.14", "expo-network": "~56.0.5", - "expo-notifications": "~56.0.14", + "expo-notifications": "~56.0.18", "expo-paste-input": "^0.1.15", - "expo-router": "~56.2.7", "expo-secure-store": "~56.0.4", "expo-splash-screen": "~56.0.10", - "expo-symbols": "~56.0.5", - "expo-updates": "~56.0.17", + "expo-symbols": "~56.0.6", + "expo-updates": "~56.0.19", "expo-web-browser": "~56.0.5", - "expo-widgets": "~56.0.15", + "expo-widgets": "~56.0.19", "punycode": "^2.3.1", "react": "19.2.3", "react-dom": "19.2.3", "react-native": "0.85.3", "react-native-gesture-handler": "~2.31.1", "react-native-image-viewing": "^0.2.2", - "react-native-keyboard-controller": "1.21.7", + "react-native-keyboard-controller": "1.21.6", "react-native-nitro-markdown": "^0.5.0", "react-native-nitro-modules": "0.35.9", "react-native-reanimated": "4.3.1", diff --git a/apps/mobile/plugins/withIosCocoaPodsUuidCache.cjs b/apps/mobile/plugins/withIosCocoaPodsUuidCache.cjs new file mode 100644 index 00000000000..e0de313bbd4 --- /dev/null +++ b/apps/mobile/plugins/withIosCocoaPodsUuidCache.cjs @@ -0,0 +1,52 @@ +const fs = require("node:fs"); +const path = require("node:path"); + +const { withDangerousMod } = require("expo/config-plugins"); + +const MARKER = "# t3code: repair cached CocoaPods UUID allocation before SPM integration"; +const UUID_REPAIR = `${MARKER} + pods_project = installer.pods_project + existing_uuids = pods_project.objects.map(&:uuid) + uuid_prefix = pods_project.instance_variable_get(:@uuid_prefix)[0, 6] + sequential_uuid = /\\A#{Regexp.escape(uuid_prefix)}([0-9A-F]{7})0\\z/ + highest_index = existing_uuids.filter_map do |uuid| + match = sequential_uuid.match(uuid) + match && match[1].to_i(16) + end.max || -1 + + # Pod::Project generates sequential UUIDs without collision checks because CocoaPods + # normally creates the project in this process. EAS can restore Pods.xcodeproj from + # cache, leaving the allocator behind the loaded objects. React Native's SPM support + # then reuses an existing UUID and silently corrupts the project graph. + next_index = highest_index + 1 + pods_project.instance_variable_set(:@generated_uuids, Array.new(next_index)) + pods_project.instance_variable_set(:@available_uuids, []) + pods_project.generate_available_uuid_list(1_000) + Pod::UI.puts "T3Code: reset CocoaPods UUID allocator at #{next_index} (#{existing_uuids.length} existing objects)" +`; + +module.exports = function withIosCocoaPodsUuidCache(config) { + return withDangerousMod(config, [ + "ios", + (nextConfig) => { + const podfilePath = path.join(nextConfig.modRequest.platformProjectRoot, "Podfile"); + const podfile = fs.readFileSync(podfilePath, "utf8"); + + if (podfile.includes(MARKER)) { + return nextConfig; + } + + const postInstallStart = "post_install do |installer|\n"; + if (!podfile.includes(postInstallStart)) { + throw new Error("Unable to repair CocoaPods UUID allocation: post_install is missing."); + } + + fs.writeFileSync( + podfilePath, + podfile.replace(postInstallStart, `${postInstallStart}${UUID_REPAIR}`), + "utf8", + ); + return nextConfig; + }, + ]); +}; diff --git a/apps/mobile/plugins/withIosSceneLifecycle.cjs b/apps/mobile/plugins/withIosSceneLifecycle.cjs new file mode 100644 index 00000000000..d426475250a --- /dev/null +++ b/apps/mobile/plugins/withIosSceneLifecycle.cjs @@ -0,0 +1,99 @@ +const { withAppDelegate, withInfoPlist } = require("expo/config-plugins"); + +const SCENE_DELEGATE = ` + +class SceneDelegate: UIResponder, UIWindowSceneDelegate { + var window: UIWindow? + + func scene( + _ scene: UIScene, + willConnectTo session: UISceneSession, + options connectionOptions: UIScene.ConnectionOptions + ) { + guard + let windowScene = scene as? UIWindowScene, + let appDelegate = UIApplication.shared.delegate as? AppDelegate + else { + return + } + + let appWindow: UIWindow + if let existingWindow = appDelegate.window { + appWindow = existingWindow + } else { + appWindow = UIWindow(windowScene: windowScene) + appDelegate.window = appWindow + appDelegate.reactNativeFactory?.startReactNative( + withModuleName: "main", + in: appWindow, + launchOptions: nil) + } + + window = appWindow + appWindow.windowScene = windowScene + appWindow.makeKeyAndVisible() + + if let url = connectionOptions.urlContexts.first?.url { + _ = appDelegate.application(UIApplication.shared, open: url, options: [:]) + } + + if let userActivity = connectionOptions.userActivities.first { + _ = appDelegate.application( + UIApplication.shared, + continue: userActivity, + restorationHandler: { _ in }) + } + } + + func scene(_ scene: UIScene, openURLContexts URLContexts: Set) { + guard + let url = URLContexts.first?.url, + let appDelegate = UIApplication.shared.delegate as? AppDelegate + else { + return + } + + _ = appDelegate.application(UIApplication.shared, open: url, options: [:]) + } + + func scene(_ scene: UIScene, continue userActivity: NSUserActivity) { + guard let appDelegate = UIApplication.shared.delegate as? AppDelegate else { + return + } + + _ = appDelegate.application( + UIApplication.shared, + continue: userActivity, + restorationHandler: { _ in }) + } +}`; + +module.exports = function withIosSceneLifecycle(config) { + config = withInfoPlist(config, (nextConfig) => { + nextConfig.modResults.UIApplicationSceneManifest = { + UIApplicationSupportsMultipleScenes: false, + UISceneConfigurations: { + UIWindowSceneSessionRoleApplication: [ + { + UISceneConfigurationName: "Default Configuration", + UISceneDelegateClassName: "$(PRODUCT_MODULE_NAME).SceneDelegate", + }, + ], + }, + }; + + return nextConfig; + }); + + return withAppDelegate(config, (nextConfig) => { + if (nextConfig.modResults.language !== "swift") { + throw new Error("The iOS scene lifecycle plugin requires a Swift AppDelegate."); + } + + if (!nextConfig.modResults.contents.includes("class SceneDelegate:")) { + nextConfig.modResults.contents += SCENE_DELEGATE; + } + + return nextConfig; + }); +}; diff --git a/apps/mobile/src/App.tsx b/apps/mobile/src/App.tsx new file mode 100644 index 00000000000..d0880a39798 --- /dev/null +++ b/apps/mobile/src/App.tsx @@ -0,0 +1,72 @@ +import { + DMSans_400Regular, + DMSans_500Medium, + DMSans_700Bold, + useFonts, +} from "@expo-google-fonts/dm-sans"; +import * as Linking from "expo-linking"; +import * as SplashScreen from "expo-splash-screen"; +import { StatusBar, useColorScheme } from "react-native"; +import { GestureHandlerRootView } from "react-native-gesture-handler"; +import { KeyboardProvider } from "react-native-keyboard-controller"; +import { SafeAreaProvider } from "react-native-safe-area-context"; +import { createStaticNavigation, DarkTheme, DefaultTheme } from "@react-navigation/native"; + +import { RegistryContext } from "@effect/atom-react"; +import { useEffect } from "react"; +import { CloudAuthProvider } from "./features/cloud/CloudAuthProvider"; +import { AppearancePreferencesProvider } from "./features/settings/appearance/AppearancePreferencesProvider"; +import { RootStack } from "./Stack"; +import { appAtomRegistry } from "./state/atom-registry"; +import { useThemeColor } from "./lib/useThemeColor"; + +import "../global.css"; + +const appLinking = { + prefixes: [Linking.createURL("/"), "t3code://", "t3code-dev://", "t3code-preview://"], +}; + +const Navigation = createStaticNavigation(RootStack); + +export default function App() { + const [fontsLoaded] = useFonts({ + DMSans_400Regular, + DMSans_500Medium, + DMSans_700Bold, + }); + const colorScheme = useColorScheme(); + const statusBarBg = useThemeColor("--color-status-bar"); + + useEffect(() => { + if (fontsLoaded) SplashScreen.hide(); + }, [fontsLoaded]); + + return ( + + + + + + + + {/* The navigation theme drives the NATIVE header appearance: native-stack + forwards `dark` as the nav bar's overrideUserInterfaceStyle. Without + this, React Navigation defaults to its light theme and every native + header (glass buttons, title, materials) is forced light even when + the system is in dark mode. */} + + + + + + + + ); +} diff --git a/apps/mobile/src/Stack.tsx b/apps/mobile/src/Stack.tsx new file mode 100644 index 00000000000..29cf29da90a --- /dev/null +++ b/apps/mobile/src/Stack.tsx @@ -0,0 +1,463 @@ +import { + createPathConfigForStaticNavigation, + getPathFromState, + NavigationState, + StackActions, + useNavigation, +} from "@react-navigation/native"; +import { + createNativeStackNavigator, + createNativeStackScreen, + type NativeStackNavigationOptions, +} from "@react-navigation/native-stack"; +import { DynamicColorIOS, Platform, Pressable, ScrollView, StyleSheet } from "react-native"; +import { useResolveClassNames } from "uniwind"; + +import { AppText as Text } from "./components/AppText"; +import { ArchivedThreadsRouteScreen } from "./features/archive/ArchivedThreadsRouteScreen"; +import { useAgentNotificationNavigation } from "./features/agent-awareness/notificationNavigation"; +import { ClerkSettingsSheetDetentProvider } from "./features/cloud/ClerkSettingsSheetDetent"; +import { ThreadFilesTreeScreen, ThreadFileScreen } from "./features/files/ThreadFilesRouteScreen"; +import { AdaptiveWorkspaceLayout } from "./features/layout/AdaptiveWorkspaceLayout"; +import { HardwareKeyboardCommandProvider } from "./features/keyboard/HardwareKeyboardCommandProvider"; +import { ReviewCommentComposerSheet } from "./features/review/ReviewCommentComposerSheet"; +import { ReviewSheet } from "./features/review/ReviewSheet"; +import { ThreadTerminalRouteScreen } from "./features/terminal/ThreadTerminalRouteScreen"; +import { GitBranchesSheet } from "./features/threads/git/GitBranchesSheet"; +import { GitCommitSheet } from "./features/threads/git/GitCommitSheet"; +import { GitConfirmSheet } from "./features/threads/git/GitConfirmSheet"; +import { GitOverviewSheet } from "./features/threads/git/GitOverviewSheet"; +import { ThreadRouteScreen } from "./features/threads/ThreadRouteScreen"; +import { ConnectionsRouteScreen } from "./features/connection/ConnectionsRouteScreen"; +import { ConnectionsNewRouteScreen } from "./features/connection/ConnectionsNewRouteScreen"; +import { HomeRouteScreen } from "./features/home/HomeRouteScreen"; +import { AddProjectDestinationRoute } from "./features/projects/AddProjectDestinationRoute"; +import { AddProjectLocalRoute } from "./features/projects/AddProjectLocalRoute"; +import { AddProjectRepositoryRoute } from "./features/projects/AddProjectRepositoryRoute"; +import { AddProjectSourceRoute } from "./features/projects/AddProjectSourceRoute"; +import { NewTaskDraftRouteScreen } from "./features/threads/NewTaskDraftRouteScreen"; +import { NewTaskFlowProvider } from "./features/threads/new-task-flow-provider"; +import { NewTaskRouteScreen } from "./features/threads/NewTaskRouteScreen"; +import { SettingsAppearanceRouteScreen } from "./features/settings/SettingsAppearanceRouteScreen"; +import { SettingsAuthRouteScreen } from "./features/settings/SettingsAuthRouteScreen"; +import { SettingsEnvironmentsRouteScreen } from "./features/settings/SettingsEnvironmentsRouteScreen"; +import { SettingsRouteScreen } from "./features/settings/SettingsRouteScreen"; +import { SettingsWaitlistRouteScreen } from "./features/settings/SettingsWaitlistRouteScreen"; +import { nativeHeaderScrollEdgeEffects } from "./native/StackHeader"; +import { useThreadOutboxDrain } from "./state/use-thread-outbox-drain"; + +const HEADER_SCROLL_EDGE_EFFECTS = nativeHeaderScrollEdgeEffects(Platform.OS, Platform.Version); + +// Matches --color-sheet in global.css (light/dark). DynamicColorIOS lets the header +// background stay STATIC config while still adapting to appearance changes. +const SHEET_BACKGROUND_COLOR = + Platform.OS === "ios" + ? DynamicColorIOS({ light: "rgba(242, 242, 247, 0.98)", dark: "rgba(14, 14, 14, 0.98)" }) + : undefined; + +type AppScreenOptions = NativeStackNavigationOptions & { + readonly unstable_navigationItemStyle?: "editor"; +}; + +// Shared header presets. Screens only override genuinely dynamic values (titles, +// subtitles, toolbar items, search callbacks) via NativeStackScreenOptions. +// +// GLASS: transparent header over the screen's primary scroll view, with the iOS 26 +// scroll-edge blur sampling the content (Home, Thread, Files tree, settings sheet). +const GLASS_HEADER_OPTIONS: AppScreenOptions = { + headerBackButtonDisplayMode: "minimal", + headerBackTitle: "", + headerLargeTitle: false, + headerShadowVisible: false, + headerShown: true, + headerStyle: Platform.OS === "ios" ? { backgroundColor: "transparent" } : undefined, + headerTitleStyle: { fontSize: 18, fontWeight: "800" }, + headerTransparent: Platform.OS === "ios", + scrollEdgeEffects: Platform.OS === "ios" ? HEADER_SCROLL_EDGE_EFFECTS : undefined, + unstable_navigationItemStyle: Platform.OS === "ios" ? "editor" : undefined, +}; + +// SOLID: opaque sheet-colored header for surfaces whose content scrolls internally +// (file viewer, terminal, review) — there is nothing for glass to sample there. +const SOLID_HEADER_OPTIONS: AppScreenOptions = { + headerBackButtonDisplayMode: "minimal", + headerBackTitle: "", + headerLargeTitle: false, + headerShadowVisible: false, + headerShown: true, + headerStyle: + SHEET_BACKGROUND_COLOR !== undefined + ? // native-stack types this as `string`, but the native side accepts any + // ColorValue including DynamicColorIOS. + { backgroundColor: SHEET_BACKGROUND_COLOR as unknown as string } + : undefined, + headerTitleStyle: { fontSize: 18, fontWeight: "800" }, + headerTransparent: false, + unstable_navigationItemStyle: Platform.OS === "ios" ? "editor" : undefined, +}; + +// Solid header variant for screens inside sheets (centered title, no editor style). +const SHEET_SOLID_HEADER_OPTIONS: AppScreenOptions = { + ...SOLID_HEADER_OPTIONS, + unstable_navigationItemStyle: undefined, +}; + +const SettingsSheetStack = createNativeStackNavigator({ + initialRouteName: "Settings", + screenOptions: { + ...GLASS_HEADER_OPTIONS, + // Sheets read better with the iOS-default centered title (no editor style). + unstable_navigationItemStyle: undefined, + }, + screens: { + Settings: createNativeStackScreen({ + screen: SettingsRouteScreen, + linking: "", + options: { + title: "Settings", + }, + }), + SettingsEnvironments: createNativeStackScreen({ + screen: SettingsEnvironmentsRouteScreen, + linking: "environments", + options: { + title: "Environments", + }, + }), + SettingsEnvironmentNew: createNativeStackScreen({ + screen: ConnectionsNewRouteScreen, + linking: "environment-new", + options: { + title: "Add Environment", + }, + }), + SettingsArchive: createNativeStackScreen({ + screen: ArchivedThreadsRouteScreen, + linking: "archive", + options: { + title: "Archived Threads", + }, + }), + SettingsAppearance: createNativeStackScreen({ + screen: SettingsAppearanceRouteScreen, + linking: "appearance", + options: { + title: "Appearance", + }, + }), + SettingsAuth: createNativeStackScreen({ + screen: SettingsAuthRouteScreen, + linking: "auth", + options: { + title: "Sign in", + }, + }), + SettingsWaitlist: createNativeStackScreen({ + screen: SettingsWaitlistRouteScreen, + linking: "waitlist", + options: { + title: "Join the waitlist", + }, + }), + }, +}); + +// Thread routes live FLAT in the root stack (not in a nested navigator). A nested +// stack means a second UINavigationController with its own UINavigationBar, which +// breaks iOS 26's shared-header morphing between Home and Thread (each pair inside +// one bar morphs; across two bars the whole screen slides). Flat linking paths keep +// the same deep-link URLs the nested config produced. +const THREAD_LINKING_PREFIX = "threads/:environmentId/:threadId"; + +// New-task / add-project flow: nested navigator inside the formSheet (Settings-sheet +// pattern — a plain formSheet screen cannot render a stack header; the header and +// in-sheet pushes come from this nested stack). +const NewTaskSheetStack = createNativeStackNavigator({ + initialRouteName: "NewTask", + screenOptions: { + ...GLASS_HEADER_OPTIONS, + // Sheets read better with the iOS-default centered title (no editor style). + unstable_navigationItemStyle: undefined, + }, + screens: { + NewTask: createNativeStackScreen({ + screen: NewTaskRouteScreen, + linking: "", + options: { + title: "Choose project", + }, + }), + NewTaskDraft: createNativeStackScreen({ + screen: NewTaskDraftRouteScreen, + linking: "draft", + // The draft composer has no scroll view for glass to sample; a solid + // header also lays the content out below the bar (no manual inset). + options: SHEET_SOLID_HEADER_OPTIONS, + }), + AddProject: createNativeStackScreen({ + screen: AddProjectSourceRoute, + linking: "add-project", + options: { + title: "Add Project", + }, + }), + AddProjectRepository: createNativeStackScreen({ + screen: AddProjectRepositoryRoute, + linking: "add-project/repository", + }), + AddProjectDestination: createNativeStackScreen({ + screen: AddProjectDestinationRoute, + linking: "add-project/destination", + }), + AddProjectLocal: createNativeStackScreen({ + screen: AddProjectLocalRoute, + linking: "add-project/local", + }), + }, +}); + +// Routes presented as sheets/overlays ON TOP of the workspace. They must not +// influence the adaptive workspace layout: opening Settings over Home should +// not flip the sidebar in or change the active thread. +const WORKSPACE_OVERLAY_ROUTES = new Set([ + "Connections", + "ConnectionsNew", + "GitBranches", + "GitCommit", + "GitConfirm", + "GitOverview", + "NewTaskSheet", + "SettingsSheet", + "ThreadReviewComment", +]); + +/** + * Pathname of the topmost NON-overlay route — the screen the workspace is + * actually "on", regardless of any sheets floating above it. + */ +function workspacePathFromState(state: NavigationState): string { + const routes = state.routes.filter((route) => !WORKSPACE_OVERLAY_ROUTES.has(route.name)); + const effectiveState = + routes.length > 0 && routes.length !== state.routes.length + ? ({ ...state, routes, index: routes.length - 1 } as NavigationState) + : state; + const path = getPathFromState(effectiveState, navigationPathConfig); + return path.startsWith("/") ? path : `/${path}`; +} + +function RootStackLayout(props: { + readonly children: React.ReactNode; + readonly state: NavigationState; +}) { + useAgentNotificationNavigation(); + useThreadOutboxDrain(); + // Full pathname (sheets included) for keyboard-command scoping; the + // workspace layout only reacts to the underlying non-overlay route. + const path = getPathFromState(props.state, navigationPathConfig); + const pathname = path.startsWith("/") ? path : `/${path}`; + const workspacePathname = workspacePathFromState(props.state); + + return ( + + + + {props.children} + + + + ); +} + +function NotFoundScreen() { + const navigation = useNavigation(); + const screenBgStyle = StyleSheet.flatten(useResolveClassNames("bg-screen")); + const primaryBgStyle = StyleSheet.flatten(useResolveClassNames("bg-primary")); + const returnHomeButtonStyle = StyleSheet.flatten([ + { + borderRadius: 999, + paddingHorizontal: 20, + paddingVertical: 14, + }, + primaryBgStyle, + ]); + + return ( + + + Route not found + + navigation.dispatch(StackActions.replace("Home"))} + > + Return home + + + ); +} + +export const RootStack = createNativeStackNavigator({ + initialRouteName: "Home", + layout: RootStackLayout, + screenOptions: { + headerShown: false, + }, + screens: { + Home: createNativeStackScreen({ + screen: HomeRouteScreen, + linking: "", + options: { + ...GLASS_HEADER_OPTIONS, + contentStyle: { backgroundColor: "transparent" }, + headerBackVisible: false, + title: "Threads", + }, + }), + Thread: createNativeStackScreen({ + screen: ThreadRouteScreen, + linking: THREAD_LINKING_PREFIX, + options: GLASS_HEADER_OPTIONS, + }), + ThreadTerminal: createNativeStackScreen({ + screen: ThreadTerminalRouteScreen, + linking: `${THREAD_LINKING_PREFIX}/terminal`, + options: SOLID_HEADER_OPTIONS, + }), + ThreadReview: createNativeStackScreen({ + screen: ReviewSheet, + linking: `${THREAD_LINKING_PREFIX}/review`, + options: SOLID_HEADER_OPTIONS, + }), + ThreadReviewComment: createNativeStackScreen({ + screen: ReviewCommentComposerSheet, + linking: `${THREAD_LINKING_PREFIX}/review-comment`, + options: { + presentation: "formSheet", + sheetAllowedDetents: [0.55, 0.92], + sheetGrabberVisible: true, + }, + }), + ThreadFiles: createNativeStackScreen({ + screen: ThreadFilesTreeScreen, + linking: `${THREAD_LINKING_PREFIX}/files`, + options: { + ...GLASS_HEADER_OPTIONS, + contentStyle: + SHEET_BACKGROUND_COLOR !== undefined + ? { backgroundColor: SHEET_BACKGROUND_COLOR } + : undefined, + title: "Files", + }, + }), + ThreadFile: createNativeStackScreen({ + screen: ThreadFileScreen, + linking: `${THREAD_LINKING_PREFIX}/files/:path*`, + options: SOLID_HEADER_OPTIONS, + }), + GitOverview: createNativeStackScreen({ + screen: GitOverviewSheet, + linking: `${THREAD_LINKING_PREFIX}/git`, + options: { + presentation: "formSheet", + sheetAllowedDetents: [0.55, 0.92], + sheetGrabberVisible: true, + }, + }), + GitCommit: createNativeStackScreen({ + screen: GitCommitSheet, + linking: `${THREAD_LINKING_PREFIX}/git/commit`, + options: { + presentation: "formSheet", + sheetAllowedDetents: [0.55, 0.92], + sheetGrabberVisible: true, + }, + }), + GitBranches: createNativeStackScreen({ + screen: GitBranchesSheet, + linking: `${THREAD_LINKING_PREFIX}/git/branches`, + options: { + presentation: "formSheet", + sheetAllowedDetents: [0.55, 0.92], + sheetGrabberVisible: true, + }, + }), + GitConfirm: createNativeStackScreen({ + screen: GitConfirmSheet, + linking: `${THREAD_LINKING_PREFIX}/git-confirm`, + options: { + presentation: "formSheet", + sheetAllowedDetents: [0.45, 0.7], + sheetGrabberVisible: true, + }, + }), + SettingsSheet: createNativeStackScreen({ + screen: SettingsSheetStack, + linking: "settings", + options: { + gestureEnabled: true, + headerShown: false, + presentation: "formSheet", + sheetAllowedDetents: [0.7, 0.92], + sheetGrabberVisible: true, + }, + }), + Connections: createNativeStackScreen({ + screen: ConnectionsRouteScreen, + linking: "connections", + options: { + title: "Environments", + presentation: "formSheet", + sheetAllowedDetents: [0.55, 0.7], + sheetGrabberVisible: true, + }, + }), + ConnectionsNew: createNativeStackScreen({ + screen: ConnectionsNewRouteScreen, + linking: "connections/new", + options: { + presentation: "formSheet", + sheetAllowedDetents: [0.55, 0.7], + sheetGrabberVisible: true, + }, + }), + NewTaskSheet: createNativeStackScreen({ + screen: NewTaskSheetStack, + linking: "new", + // The whole new-task flow (choose project → draft → add project) shares + // draft state via NewTaskFlowProvider. The expo-router era mounted it in + // app/new/_layout.tsx; this layout wrapper is the native-stack equivalent. + layout: ({ children }) => {children}, + options: { + gestureEnabled: true, + headerShown: false, + presentation: "formSheet", + sheetAllowedDetents: [0.92], + sheetGrabberVisible: true, + }, + }), + NotFound: createNativeStackScreen({ + screen: NotFoundScreen, + linking: "*", + }), + }, +}); +type RootStackType = typeof RootStack; + +const navigationPathConfig = { + screens: createPathConfigForStaticNavigation(RootStack) ?? {}, +}; + +declare module "@react-navigation/native" { + interface RootNavigator extends RootStackType {} +} diff --git a/apps/mobile/src/app/+not-found.tsx b/apps/mobile/src/app/+not-found.tsx deleted file mode 100644 index d11155f8602..00000000000 --- a/apps/mobile/src/app/+not-found.tsx +++ /dev/null @@ -1,43 +0,0 @@ -import { Link } from "expo-router"; -import { Pressable, ScrollView, StyleSheet } from "react-native"; -import { useResolveClassNames } from "uniwind"; - -import { AppText as Text } from "../components/AppText"; - -export default function NotFoundRoute() { - const screenBgStyle = StyleSheet.flatten(useResolveClassNames("bg-screen")); - const primaryBgStyle = StyleSheet.flatten(useResolveClassNames("bg-primary")); - - return ( - - - Route not found - - - - Return home - - - - ); -} diff --git a/apps/mobile/src/app/_layout.tsx b/apps/mobile/src/app/_layout.tsx deleted file mode 100644 index 968be6c14a8..00000000000 --- a/apps/mobile/src/app/_layout.tsx +++ /dev/null @@ -1,149 +0,0 @@ -import "../../global.css"; -import { - DMSans_400Regular, - DMSans_500Medium, - DMSans_700Bold, - useFonts, -} from "@expo-google-fonts/dm-sans"; -import { usePathname } from "expo-router"; -import Stack from "expo-router/stack"; -import { useCallback } from "react"; -import { StatusBar, useColorScheme } from "react-native"; -import { GestureHandlerRootView } from "react-native-gesture-handler"; -import { KeyboardProvider } from "react-native-keyboard-controller"; -import { SafeAreaProvider } from "react-native-safe-area-context"; -import { useResolveClassNames } from "uniwind"; - -import { LoadingScreen } from "../components/LoadingScreen"; - -import { useWorkspaceState } from "../state/workspace"; -import { useThreadOutboxDrain } from "../state/use-thread-outbox-drain"; -import { RegistryContext } from "@effect/atom-react"; -import { appAtomRegistry } from "../state/atom-registry"; -import { CloudAuthProvider } from "../features/cloud/CloudAuthProvider"; -import { - ClerkSettingsSheetDetentProvider, - useClerkSettingsSheetDetent, -} from "../features/cloud/ClerkSettingsSheetDetent"; -import { useAgentNotificationNavigation } from "../features/agent-awareness/notificationNavigation"; -import { useThemeColor } from "../lib/useThemeColor"; - -function AppNavigator() { - const pathname = usePathname(); - const expandedSettingsRouteIsActive = - pathname === "/settings/archive" || pathname === "/settings/auth"; - - return ( - - - - ); -} - -function AppNavigatorContent() { - const { state } = useWorkspaceState(); - const { collapse, isExpanded } = useClerkSettingsSheetDetent(); - const colorScheme = useColorScheme(); - const statusBarBg = useThemeColor("--color-status-bar"); - const sheetStyle = useResolveClassNames("bg-sheet"); - useAgentNotificationNavigation(); - useThreadOutboxDrain(); - - const handleSettingsTransitionEnd = useCallback( - (event: { data: { closing: boolean } }) => { - if (event.data.closing) { - collapse(); - } - }, - [collapse], - ); - - const newTaskScreenOptions = { - contentStyle: sheetStyle, - gestureEnabled: true, - headerShown: false, - presentation: "formSheet" as const, - sheetAllowedDetents: [0.92], - sheetGrabberVisible: true, - }; - - const connectionSheetScreenOptions = { - contentStyle: sheetStyle, - gestureEnabled: true, - headerShown: false, - presentation: "formSheet" as const, - sheetAllowedDetents: [0.55, 0.7], - sheetGrabberVisible: true, - }; - - const settingsSheetScreenOptions = { - ...connectionSheetScreenOptions, - sheetAllowedDetents: isExpanded ? [0.92] : [0.7], - }; - - if (state.isLoadingConnections) { - return ; - } - - return ( - <> - - - - - - - - - - ); -} - -export default function RootLayout() { - const [fontsLoaded] = useFonts({ - DMSans_400Regular, - DMSans_500Medium, - DMSans_700Bold, - }); - return ( - - - - - - {fontsLoaded ? ( - - ) : ( - - )} - - - - - - ); -} diff --git a/apps/mobile/src/app/connections/_layout.tsx b/apps/mobile/src/app/connections/_layout.tsx deleted file mode 100644 index 902b53cb15a..00000000000 --- a/apps/mobile/src/app/connections/_layout.tsx +++ /dev/null @@ -1,29 +0,0 @@ -import Stack from "expo-router/stack"; -import { useResolveClassNames } from "uniwind"; -import { useThemeColor } from "../../lib/useThemeColor"; - -export const unstable_settings = { - anchor: "index", -}; - -export default function ConnectionsLayout() { - const contentStyle = useResolveClassNames("bg-sheet"); - const connSheetBg = useThemeColor("--color-sheet"); - const headerTint = useThemeColor("--color-foreground"); - - return ( - - - - - ); -} diff --git a/apps/mobile/src/app/index.tsx b/apps/mobile/src/app/index.tsx deleted file mode 100644 index 7f9962efc98..00000000000 --- a/apps/mobile/src/app/index.tsx +++ /dev/null @@ -1,120 +0,0 @@ -import type { - EnvironmentId, - SidebarProjectGroupingMode, - SidebarThreadSortOrder, -} from "@t3tools/contracts"; -import { - DEFAULT_SIDEBAR_PROJECT_GROUPING_MODE, - DEFAULT_SIDEBAR_PROJECT_SORT_ORDER, - DEFAULT_SIDEBAR_THREAD_SORT_ORDER, -} from "@t3tools/contracts"; -import * as Arr from "effect/Array"; -import * as Order from "effect/Order"; -import { useRouter } from "expo-router"; -import { useCallback, useMemo, useState } from "react"; - -import { useProjects, useThreadShells } from "../state/entities"; -import { useWorkspaceState } from "../state/workspace"; -import { buildThreadRoutePath } from "../lib/routes"; -import { useSavedRemoteConnections } from "../state/use-remote-environment-registry"; -import { HomeScreen } from "../features/home/HomeScreen"; -import { HomeHeader } from "../features/home/HomeHeader"; -import type { HomeProjectSortOrder } from "../features/home/homeThreadList"; -import { useThreadListActions } from "../features/home/useThreadListActions"; - -interface HomeListOptions { - readonly selectedEnvironmentId: EnvironmentId | null; - readonly projectSortOrder: HomeProjectSortOrder; - readonly threadSortOrder: SidebarThreadSortOrder; - readonly projectGroupingMode: SidebarProjectGroupingMode; -} - -/* ─── Route screen ───────────────────────────────────────────────────── */ - -export default function HomeRouteScreen() { - const projects = useProjects(); - const threads = useThreadShells(); - const { state: catalogState } = useWorkspaceState(); - const { savedConnectionsById } = useSavedRemoteConnections(); - const router = useRouter(); - const [searchQuery, setSearchQuery] = useState(""); - const [listOptions, setListOptions] = useState({ - selectedEnvironmentId: null, - projectSortOrder: - DEFAULT_SIDEBAR_PROJECT_SORT_ORDER === "manual" - ? "updated_at" - : DEFAULT_SIDEBAR_PROJECT_SORT_ORDER, - threadSortOrder: DEFAULT_SIDEBAR_THREAD_SORT_ORDER, - projectGroupingMode: DEFAULT_SIDEBAR_PROJECT_GROUPING_MODE, - }); - const { archiveThread, confirmDeleteThread } = useThreadListActions(); - const environments = useMemo( - () => - Arr.sort( - Object.values(savedConnectionsById).map((connection) => ({ - environmentId: connection.environmentId, - label: connection.environmentLabel, - })), - Order.mapInput( - Order.String, - (environment: { readonly label: string }) => environment.label, - ), - ), - [savedConnectionsById], - ); - const selectedEnvironmentId = environments.some( - (environment) => environment.environmentId === listOptions.selectedEnvironmentId, - ) - ? listOptions.selectedEnvironmentId - : null; - const setSelectedEnvironmentId = useCallback((environmentId: EnvironmentId | null) => { - setListOptions((current) => ({ ...current, selectedEnvironmentId: environmentId })); - }, []); - const setProjectSortOrder = useCallback((projectSortOrder: HomeProjectSortOrder) => { - setListOptions((current) => ({ ...current, projectSortOrder })); - }, []); - const setThreadSortOrder = useCallback((threadSortOrder: SidebarThreadSortOrder) => { - setListOptions((current) => ({ ...current, threadSortOrder })); - }, []); - const setProjectGroupingMode = useCallback((projectGroupingMode: SidebarProjectGroupingMode) => { - setListOptions((current) => ({ ...current, projectGroupingMode })); - }, []); - - return ( - <> - router.push("/settings")} - onProjectGroupingModeChange={setProjectGroupingMode} - onProjectSortOrderChange={setProjectSortOrder} - onSearchQueryChange={setSearchQuery} - onStartNewTask={() => router.push("/new")} - onThreadSortOrderChange={setThreadSortOrder} - /> - - router.push("/connections/new")} - onArchiveThread={archiveThread} - onDeleteThread={confirmDeleteThread} - onOpenEnvironments={() => router.push("/settings/environments")} - onSelectThread={(thread) => { - router.push(buildThreadRoutePath(thread)); - }} - projectGroupingMode={listOptions.projectGroupingMode} - projects={projects} - projectSortOrder={listOptions.projectSortOrder} - savedConnectionsById={savedConnectionsById} - searchQuery={searchQuery} - selectedEnvironmentId={selectedEnvironmentId} - threads={threads} - threadSortOrder={listOptions.threadSortOrder} - /> - - ); -} diff --git a/apps/mobile/src/app/new/_layout.tsx b/apps/mobile/src/app/new/_layout.tsx deleted file mode 100644 index 2113b13311c..00000000000 --- a/apps/mobile/src/app/new/_layout.tsx +++ /dev/null @@ -1,50 +0,0 @@ -import Stack from "expo-router/stack"; -import { useResolveClassNames } from "uniwind"; - -import { NewTaskFlowProvider } from "../../features/threads/new-task-flow-provider"; -import { useThemeColor } from "../../lib/useThemeColor"; - -export const unstable_settings = { - anchor: "index", -}; - -export default function NewTaskLayout() { - const sheetStyle = useResolveClassNames("bg-sheet"); - const sheetBg = useThemeColor("--color-sheet"); - const headerTint = useThemeColor("--color-foreground"); - - return ( - - - - - - - - - - - ); -} diff --git a/apps/mobile/src/app/new/add-project/destination.tsx b/apps/mobile/src/app/new/add-project/destination.tsx deleted file mode 100644 index 7c00aa28c65..00000000000 --- a/apps/mobile/src/app/new/add-project/destination.tsx +++ /dev/null @@ -1,5 +0,0 @@ -import { AddProjectDestinationScreen } from "../../../features/projects/AddProjectScreen"; - -export default function AddProjectDestinationRoute() { - return ; -} diff --git a/apps/mobile/src/app/new/add-project/index.tsx b/apps/mobile/src/app/new/add-project/index.tsx deleted file mode 100644 index e7d88b9c4f5..00000000000 --- a/apps/mobile/src/app/new/add-project/index.tsx +++ /dev/null @@ -1,5 +0,0 @@ -import { AddProjectSourceScreen } from "../../../features/projects/AddProjectScreen"; - -export default function AddProjectRoute() { - return ; -} diff --git a/apps/mobile/src/app/new/add-project/local.tsx b/apps/mobile/src/app/new/add-project/local.tsx deleted file mode 100644 index db8e2f4d617..00000000000 --- a/apps/mobile/src/app/new/add-project/local.tsx +++ /dev/null @@ -1,5 +0,0 @@ -import { AddProjectLocalFolderScreen } from "../../../features/projects/AddProjectScreen"; - -export default function AddProjectLocalRoute() { - return ; -} diff --git a/apps/mobile/src/app/new/add-project/repository.tsx b/apps/mobile/src/app/new/add-project/repository.tsx deleted file mode 100644 index 7bf23a4955a..00000000000 --- a/apps/mobile/src/app/new/add-project/repository.tsx +++ /dev/null @@ -1,23 +0,0 @@ -import { Stack, useLocalSearchParams } from "expo-router"; -import { addProjectRemoteSourceLabel } from "@t3tools/client-runtime/operations/projects"; - -import { AddProjectRepositoryScreen } from "../../../features/projects/AddProjectScreen"; - -export default function AddProjectRepositoryRoute() { - const params = useLocalSearchParams<{ source?: string | string[] }>(); - const source = Array.isArray(params.source) ? params.source[0] : params.source; - const title = - source === "github" || - source === "gitlab" || - source === "bitbucket" || - source === "azure-devops" - ? addProjectRemoteSourceLabel(source) - : "Git URL"; - - return ( - <> - - - - ); -} diff --git a/apps/mobile/src/app/new/draft.tsx b/apps/mobile/src/app/new/draft.tsx deleted file mode 100644 index 3adc7c53f92..00000000000 --- a/apps/mobile/src/app/new/draft.tsx +++ /dev/null @@ -1,29 +0,0 @@ -import { Stack, useLocalSearchParams } from "expo-router"; - -import { NewTaskDraftScreen } from "../../features/threads/NewTaskDraftScreen"; - -export default function NewTaskDraftRoute() { - const params = useLocalSearchParams<{ - environmentId?: string | string[]; - projectId?: string | string[]; - title?: string | string[]; - }>(); - - return ( - <> - - - - ); -} diff --git a/apps/mobile/src/app/settings/_layout.tsx b/apps/mobile/src/app/settings/_layout.tsx deleted file mode 100644 index 2607c2cd1f1..00000000000 --- a/apps/mobile/src/app/settings/_layout.tsx +++ /dev/null @@ -1,62 +0,0 @@ -import Stack from "expo-router/stack"; -import { useCallback } from "react"; -import { useResolveClassNames } from "uniwind"; - -import { useClerkSettingsSheetDetent } from "../../features/cloud/ClerkSettingsSheetDetent"; -import { useThemeColor } from "../../lib/useThemeColor"; - -export const unstable_settings = { - anchor: "index", -}; - -export default function SettingsLayout() { - const { collapse } = useClerkSettingsSheetDetent(); - const contentStyle = useResolveClassNames("bg-sheet"); - const sheetBg = useThemeColor("--color-sheet"); - const headerTint = useThemeColor("--color-foreground"); - const handleExpandedRouteTransitionEnd = useCallback( - (event: { data: { closing: boolean } }) => { - if (event.data.closing) { - collapse(); - } - }, - [collapse], - ); - - return ( - - - - - - - - - ); -} diff --git a/apps/mobile/src/app/settings/archive.tsx b/apps/mobile/src/app/settings/archive.tsx deleted file mode 100644 index 2b900afbbce..00000000000 --- a/apps/mobile/src/app/settings/archive.tsx +++ /dev/null @@ -1,3 +0,0 @@ -import { ArchivedThreadsRouteScreen } from "../../features/archive/ArchivedThreadsRouteScreen"; - -export default ArchivedThreadsRouteScreen; diff --git a/apps/mobile/src/app/settings/auth.tsx b/apps/mobile/src/app/settings/auth.tsx deleted file mode 100644 index de33207ccda..00000000000 --- a/apps/mobile/src/app/settings/auth.tsx +++ /dev/null @@ -1,33 +0,0 @@ -import { useAuth } from "@clerk/expo"; -import { AuthView, UserProfileView } from "@clerk/expo/native"; -import { Redirect, Stack } from "expo-router"; -import { View } from "react-native"; - -import { hasCloudPublicConfig } from "../../features/cloud/publicConfig"; - -export default function SettingsAuthRouteScreen() { - return hasCloudPublicConfig() ? ( - - ) : ( - - ); -} - -function ConfiguredSettingsAuthRouteScreen() { - const { isLoaded, isSignedIn } = useAuth({ treatPendingAsSignedOut: false }); - - return ( - <> - - - {isLoaded ? ( - isSignedIn ? ( - - ) : ( - - ) - ) : null} - - - ); -} diff --git a/apps/mobile/src/app/settings/environment-new.tsx b/apps/mobile/src/app/settings/environment-new.tsx deleted file mode 100644 index 3fb3e8d3a04..00000000000 --- a/apps/mobile/src/app/settings/environment-new.tsx +++ /dev/null @@ -1 +0,0 @@ -export { default } from "../connections/new"; diff --git a/apps/mobile/src/app/threads/[environmentId]/[threadId]/_layout.tsx b/apps/mobile/src/app/threads/[environmentId]/[threadId]/_layout.tsx deleted file mode 100644 index 92e90920e2d..00000000000 --- a/apps/mobile/src/app/threads/[environmentId]/[threadId]/_layout.tsx +++ /dev/null @@ -1,107 +0,0 @@ -import Stack from "expo-router/stack"; -import { StyleSheet } from "react-native"; -import { useResolveClassNames } from "uniwind"; - -export default function ThreadLayout() { - const sheetStyle = StyleSheet.flatten(useResolveClassNames("bg-sheet")); - const headerBg = { - backgroundColor: (sheetStyle as { backgroundColor?: string })?.backgroundColor, - }; - - return ( - - - - - - - - - - - ); -} diff --git a/apps/mobile/src/app/threads/[environmentId]/[threadId]/files/[...path].tsx b/apps/mobile/src/app/threads/[environmentId]/[threadId]/files/[...path].tsx deleted file mode 100644 index 225c39f2ec8..00000000000 --- a/apps/mobile/src/app/threads/[environmentId]/[threadId]/files/[...path].tsx +++ /dev/null @@ -1,5 +0,0 @@ -import { ThreadFileScreen } from "../../../../../features/files/ThreadFilesRouteScreen"; - -export default function ThreadFileRoute() { - return ; -} diff --git a/apps/mobile/src/app/threads/[environmentId]/[threadId]/files/index.tsx b/apps/mobile/src/app/threads/[environmentId]/[threadId]/files/index.tsx deleted file mode 100644 index b67630dbf06..00000000000 --- a/apps/mobile/src/app/threads/[environmentId]/[threadId]/files/index.tsx +++ /dev/null @@ -1,5 +0,0 @@ -import { ThreadFilesTreeScreen } from "../../../../../features/files/ThreadFilesRouteScreen"; - -export default function ThreadFilesIndexRoute() { - return ; -} diff --git a/apps/mobile/src/app/threads/[environmentId]/[threadId]/git-confirm.tsx b/apps/mobile/src/app/threads/[environmentId]/[threadId]/git-confirm.tsx deleted file mode 100644 index 3773ce55b2e..00000000000 --- a/apps/mobile/src/app/threads/[environmentId]/[threadId]/git-confirm.tsx +++ /dev/null @@ -1,5 +0,0 @@ -import { GitConfirmSheet } from "../../../../features/threads/git/GitConfirmSheet"; - -export default function GitConfirmRoute() { - return ; -} diff --git a/apps/mobile/src/app/threads/[environmentId]/[threadId]/git/_layout.tsx b/apps/mobile/src/app/threads/[environmentId]/[threadId]/git/_layout.tsx deleted file mode 100644 index 15b10c295f3..00000000000 --- a/apps/mobile/src/app/threads/[environmentId]/[threadId]/git/_layout.tsx +++ /dev/null @@ -1,58 +0,0 @@ -import Stack from "expo-router/stack"; -import { StyleSheet } from "react-native"; -import { useResolveClassNames } from "uniwind"; - -export const unstable_settings = { - anchor: "index", -}; - -export default function GitSheetLayout() { - const sheetStyle = StyleSheet.flatten(useResolveClassNames("bg-sheet")); - const headerBg = { - backgroundColor: (sheetStyle as { backgroundColor?: string })?.backgroundColor, - }; - - return ( - - - - - - - ); -} diff --git a/apps/mobile/src/app/threads/[environmentId]/[threadId]/git/branches.tsx b/apps/mobile/src/app/threads/[environmentId]/[threadId]/git/branches.tsx deleted file mode 100644 index 955e4febd7a..00000000000 --- a/apps/mobile/src/app/threads/[environmentId]/[threadId]/git/branches.tsx +++ /dev/null @@ -1,5 +0,0 @@ -import { GitBranchesSheet } from "../../../../../features/threads/git/GitBranchesSheet"; - -export default function GitBranchesRoute() { - return ; -} diff --git a/apps/mobile/src/app/threads/[environmentId]/[threadId]/git/commit.tsx b/apps/mobile/src/app/threads/[environmentId]/[threadId]/git/commit.tsx deleted file mode 100644 index 4510f81a8c9..00000000000 --- a/apps/mobile/src/app/threads/[environmentId]/[threadId]/git/commit.tsx +++ /dev/null @@ -1,5 +0,0 @@ -import { GitCommitSheet } from "../../../../../features/threads/git/GitCommitSheet"; - -export default function GitCommitRoute() { - return ; -} diff --git a/apps/mobile/src/app/threads/[environmentId]/[threadId]/git/index.tsx b/apps/mobile/src/app/threads/[environmentId]/[threadId]/git/index.tsx deleted file mode 100644 index dd099d80d24..00000000000 --- a/apps/mobile/src/app/threads/[environmentId]/[threadId]/git/index.tsx +++ /dev/null @@ -1,5 +0,0 @@ -import { GitOverviewSheet } from "../../../../../features/threads/git/GitOverviewSheet"; - -export default function GitRoute() { - return ; -} diff --git a/apps/mobile/src/app/threads/[environmentId]/[threadId]/git/review.tsx b/apps/mobile/src/app/threads/[environmentId]/[threadId]/git/review.tsx deleted file mode 100644 index 8ca686c1469..00000000000 --- a/apps/mobile/src/app/threads/[environmentId]/[threadId]/git/review.tsx +++ /dev/null @@ -1,18 +0,0 @@ -import { Redirect, useLocalSearchParams } from "expo-router"; -import type { EnvironmentId, ThreadId } from "@t3tools/contracts"; - -export default function ReviewRoute() { - const { environmentId, threadId } = useLocalSearchParams<{ - environmentId: EnvironmentId; - threadId: ThreadId; - }>(); - - return ( - - ); -} diff --git a/apps/mobile/src/app/threads/[environmentId]/[threadId]/index.tsx b/apps/mobile/src/app/threads/[environmentId]/[threadId]/index.tsx deleted file mode 100644 index 4586ee93ca0..00000000000 --- a/apps/mobile/src/app/threads/[environmentId]/[threadId]/index.tsx +++ /dev/null @@ -1,5 +0,0 @@ -import { ThreadRouteScreen } from "../../../../features/threads/ThreadRouteScreen"; - -export default function ThreadRoute() { - return ; -} diff --git a/apps/mobile/src/app/threads/[environmentId]/[threadId]/review-comment.tsx b/apps/mobile/src/app/threads/[environmentId]/[threadId]/review-comment.tsx deleted file mode 100644 index 281ec341079..00000000000 --- a/apps/mobile/src/app/threads/[environmentId]/[threadId]/review-comment.tsx +++ /dev/null @@ -1,5 +0,0 @@ -import { ReviewCommentComposerSheet } from "../../../../features/review/ReviewCommentComposerSheet"; - -export default function ReviewCommentRoute() { - return ; -} diff --git a/apps/mobile/src/app/threads/[environmentId]/[threadId]/review.tsx b/apps/mobile/src/app/threads/[environmentId]/[threadId]/review.tsx deleted file mode 100644 index ee993ee7c87..00000000000 --- a/apps/mobile/src/app/threads/[environmentId]/[threadId]/review.tsx +++ /dev/null @@ -1,10 +0,0 @@ -import { ReviewHighlighterProvider } from "../../../../features/review/ReviewHighlighterProvider"; -import { ReviewSheet } from "../../../../features/review/ReviewSheet"; - -export default function ReviewRoute() { - return ( - - - - ); -} diff --git a/apps/mobile/src/app/threads/[environmentId]/[threadId]/terminal.tsx b/apps/mobile/src/app/threads/[environmentId]/[threadId]/terminal.tsx deleted file mode 100644 index e209a180a42..00000000000 --- a/apps/mobile/src/app/threads/[environmentId]/[threadId]/terminal.tsx +++ /dev/null @@ -1,5 +0,0 @@ -import { ThreadTerminalRouteScreen } from "../../../../features/terminal/ThreadTerminalRouteScreen"; - -export default function ThreadTerminalRoute() { - return ; -} diff --git a/apps/mobile/src/components/AppText.tsx b/apps/mobile/src/components/AppText.tsx index d98a8573e6c..33bffb1f8cd 100644 --- a/apps/mobile/src/components/AppText.tsx +++ b/apps/mobile/src/components/AppText.tsx @@ -1,4 +1,3 @@ -import type { Ref } from "react"; import { Text as RNText, TextInput as RNTextInput, @@ -21,7 +20,7 @@ export function AppText({ className, ...props }: AppTextProps) { export type AppTextInputProps = RNTextInputProps & { readonly className?: string; - readonly ref?: Ref; + readonly ref?: React.Ref; }; /** @@ -40,7 +39,7 @@ export function AppTextInput({ , "children" | "style" | "themeVariant"> & { + props: Omit, "children" | "themeVariant"> & { readonly children: ReactNode; }, ) { diff --git a/apps/mobile/src/components/EmptyState.tsx b/apps/mobile/src/components/EmptyState.tsx index f06835176fa..ce068bc4613 100644 --- a/apps/mobile/src/components/EmptyState.tsx +++ b/apps/mobile/src/components/EmptyState.tsx @@ -7,11 +7,33 @@ export function EmptyState(props: { readonly detail: string; readonly actionLabel?: string; readonly onAction?: () => void; + readonly variant?: "card" | "plain"; }) { + if (props.variant === "plain") { + return ( + + {props.title} + + {props.detail} + + {props.actionLabel && props.onAction ? ( + + + {props.actionLabel} + + + ) : null} + + ); + } + return ( {props.title} - + {props.detail} {props.actionLabel && props.onAction ? ( diff --git a/apps/mobile/src/components/ErrorBanner.tsx b/apps/mobile/src/components/ErrorBanner.tsx index d47f924b398..76e06edcd16 100644 --- a/apps/mobile/src/components/ErrorBanner.tsx +++ b/apps/mobile/src/components/ErrorBanner.tsx @@ -4,7 +4,7 @@ import { AppText as Text } from "./AppText"; export function ErrorBanner(props: { readonly message: string }) { return ( - + {props.message} diff --git a/apps/mobile/src/features/agent-awareness/notificationNavigation.ts b/apps/mobile/src/features/agent-awareness/notificationNavigation.ts index 18bb93d723e..e9ca9246b9a 100644 --- a/apps/mobile/src/features/agent-awareness/notificationNavigation.ts +++ b/apps/mobile/src/features/agent-awareness/notificationNavigation.ts @@ -1,12 +1,12 @@ import { useEffect, useRef } from "react"; import * as Notifications from "expo-notifications"; -import { useRouter } from "expo-router"; +import { useLinkTo } from "@react-navigation/native"; import { routeAgentNotificationResponseOnce } from "./notificationPayload"; import { consumeLastAgentNotificationResponse } from "./notificationResponseConsumer"; export function useAgentNotificationNavigation(): void { - const router = useRouter(); + const linkTo = useLinkTo(); const handledResponseIds = useRef(new Set()); useEffect(() => { @@ -14,7 +14,7 @@ export function useAgentNotificationNavigation(): void { routeAgentNotificationResponseOnce({ handledResponseIds: handledResponseIds.current, response, - navigate: (deepLink) => router.push(deepLink as never), + navigate: linkTo, }); }; @@ -28,5 +28,5 @@ export function useAgentNotificationNavigation(): void { return () => { subscription.remove(); }; - }, [router]); + }, [linkTo]); } diff --git a/apps/mobile/src/features/archive/ArchivedThreadsRouteScreen.tsx b/apps/mobile/src/features/archive/ArchivedThreadsRouteScreen.tsx index d560f8db9fa..c2381ef2580 100644 --- a/apps/mobile/src/features/archive/ArchivedThreadsRouteScreen.tsx +++ b/apps/mobile/src/features/archive/ArchivedThreadsRouteScreen.tsx @@ -1,7 +1,7 @@ import type { EnvironmentId } from "@t3tools/contracts"; import * as Arr from "effect/Array"; import * as Order from "effect/Order"; -import { useFocusEffect } from "expo-router"; +import { useFocusEffect } from "@react-navigation/native"; import { useCallback, useMemo, useState } from "react"; import { useSavedRemoteConnections } from "../../state/use-remote-environment-registry"; diff --git a/apps/mobile/src/features/archive/ArchivedThreadsScreen.tsx b/apps/mobile/src/features/archive/ArchivedThreadsScreen.tsx index 3e1934100cd..7f6f22e7499 100644 --- a/apps/mobile/src/features/archive/ArchivedThreadsScreen.tsx +++ b/apps/mobile/src/features/archive/ArchivedThreadsScreen.tsx @@ -2,23 +2,22 @@ import type { EnvironmentProject, EnvironmentThreadShell, } from "@t3tools/client-runtime/state/shell"; +import { LegendList } from "@legendapp/list/react-native"; import type { EnvironmentId } from "@t3tools/contracts"; import type { MenuAction } from "@react-native-menu/menu"; -import * as Haptics from "expo-haptics"; -import { Stack } from "expo-router"; +import { NativeHeaderToolbar, NativeStackScreenOptions } from "../../native/StackHeader"; import { SymbolView } from "expo-symbols"; -import { useCallback, useRef } from "react"; +import { useCallback, useMemo, useRef, type ComponentProps } from "react"; import { ActivityIndicator, + Platform, Pressable, RefreshControl, - ScrollView, useWindowDimensions, View, } from "react-native"; -import ReanimatedSwipeable, { - type SwipeableMethods, -} from "react-native-gesture-handler/ReanimatedSwipeable"; +import { Gesture, GestureDetector } from "react-native-gesture-handler"; +import type { SwipeableMethods } from "react-native-gesture-handler/ReanimatedSwipeable"; import { AppText as Text } from "../../components/AppText"; import { ControlPillMenu } from "../../components/ControlPill"; @@ -26,11 +25,8 @@ import { EmptyState } from "../../components/EmptyState"; import { ProjectFavicon } from "../../components/ProjectFavicon"; import { relativeTime } from "../../lib/time"; import { useThemeColor } from "../../lib/useThemeColor"; -import { - THREAD_SWIPE_ACTIONS_WIDTH, - THREAD_SWIPE_SPRING, - ThreadSwipeActions, -} from "../home/thread-swipe-actions"; +import { ThreadSwipeable } from "../home/thread-swipe-actions"; +import { createNativeMailSearchToolbarItem } from "../layout/native-mail-search-toolbar"; import type { ArchivedThreadGroup, ArchivedThreadSortOrder } from "./archivedThreadList"; export interface ArchivedThreadsHeaderEnvironment { @@ -52,84 +48,185 @@ const THREAD_ACTIONS: MenuAction[] = [ }, ]; +type ArchivedThreadListItem = + | { + readonly kind: "project"; + readonly key: string; + readonly environmentLabel: string | null; + readonly project: EnvironmentProject; + } + | { + readonly kind: "thread"; + readonly key: string; + readonly environmentLabel: string | null; + readonly isFirst: boolean; + readonly isLast: boolean; + readonly thread: EnvironmentThreadShell; + }; + function ArchivedThreadsHeader(props: { readonly environments: ReadonlyArray; readonly selectedEnvironmentId: EnvironmentId | null; readonly sortOrder: ArchivedThreadSortOrder; readonly onEnvironmentChange: (environmentId: EnvironmentId | null) => void; + readonly onRefresh: () => void; readonly onSearchQueryChange: (query: string) => void; readonly onSortOrderChange: (sortOrder: ArchivedThreadSortOrder) => void; }) { + const { width } = useWindowDimensions(); const hasCustomFilter = props.selectedEnvironmentId !== null || props.sortOrder !== "newest"; + const usesNativeChrome = Platform.OS === "ios"; + const usesCompactMailToolbar = Platform.OS === "ios" && width < 700; + const archiveFilterMenu = { + title: "Archived thread options", + items: [ + { + type: "submenu" as const, + title: "Environment", + items: [ + { + type: "action" as const, + title: "All environments", + state: props.selectedEnvironmentId === null ? ("on" as const) : ("off" as const), + onPress: () => props.onEnvironmentChange(null), + }, + ...props.environments.map((environment) => ({ + type: "action" as const, + title: environment.label, + state: + props.selectedEnvironmentId === environment.environmentId + ? ("on" as const) + : ("off" as const), + onPress: () => props.onEnvironmentChange(environment.environmentId), + })), + ], + }, + { + type: "submenu" as const, + title: "Sort by archived date", + items: [ + { + type: "action" as const, + title: "Newest first", + state: props.sortOrder === "newest" ? ("on" as const) : ("off" as const), + onPress: () => props.onSortOrderChange("newest"), + }, + { + type: "action" as const, + title: "Oldest first", + state: props.sortOrder === "oldest" ? ("on" as const) : ("off" as const), + onPress: () => props.onSortOrderChange("oldest"), + }, + ], + }, + ], + }; return ( <> - { - props.onSearchQueryChange(event.nativeEvent.text); - }, - onCancelButtonPress: () => { - props.onSearchQueryChange(""); - }, - }, + unstable_headerToolbarItems: usesCompactMailToolbar + ? () => [ + createNativeMailSearchToolbarItem({ + composeButtonId: "archived-refresh", + composeSystemImageName: "arrow.clockwise", + filterMenu: archiveFilterMenu, + filterButtonId: "archived-filter", + filterSystemImageName: hasCustomFilter + ? "line.3.horizontal.decrease.circle.fill" + : "line.3.horizontal.decrease", + onComposePress: props.onRefresh, + onSearchTextChange: props.onSearchQueryChange, + placeholder: "Search", + searchTextChangeId: "archived-search-text", + }), + ] + : undefined, + headerSearchBarOptions: usesCompactMailToolbar + ? undefined + : { + ...(usesNativeChrome + ? { + allowToolbarIntegration: true, + placement: "integratedButton" as const, + } + : { + placement: "stacked" as const, + }), + autoCapitalize: "none", + hideNavigationBar: false, + obscureBackground: false, + placeholder: "Search archived threads", + onChangeText: (event) => { + props.onSearchQueryChange(event.nativeEvent.text); + }, + onCancelButtonPress: () => { + props.onSearchQueryChange(""); + }, + }, }} /> - - - - Environment - props.onEnvironmentChange(null)} - > - All environments - - {props.environments.map((environment) => ( - props.onEnvironmentChange(environment.environmentId)} + {usesCompactMailToolbar ? null : ( + + {usesNativeChrome ? ( + + ) : null} + + + Environment + props.onEnvironmentChange(null)} > - {environment.label} - - ))} - + All environments + + {props.environments.map((environment) => ( + props.onEnvironmentChange(environment.environmentId)} + > + {environment.label} + + ))} + - - Sort by archived date - props.onSortOrderChange("newest")} - > - Newest first - - props.onSortOrderChange("oldest")} - > - Oldest first - - - - + + Sort by archived date + props.onSortOrderChange("newest")} + > + Newest first + + props.onSortOrderChange("oldest")} + > + Oldest first + + + + + )} ); } @@ -164,30 +261,25 @@ function ProjectGroupLabel(props: { function ArchivedThreadRow(props: { readonly environmentLabel: string | null; + readonly isFirst: boolean; readonly isLast: boolean; readonly onDelete: () => void; readonly onSwipeableClose: (methods: SwipeableMethods) => void; readonly onSwipeableWillOpen: (methods: SwipeableMethods) => void; + readonly simultaneousSwipeGesture?: ComponentProps< + typeof ThreadSwipeable + >["simultaneousWithExternalGesture"]; readonly onUnarchive: () => void; readonly thread: EnvironmentThreadShell; }) { - const swipeableRef = useRef(null); - const fullSwipeArmedRef = useRef(false); const { width: windowWidth } = useWindowDimensions(); const cardColor = useThemeColor("--color-card"); const iconColor = useThemeColor("--color-icon-subtle"); const separatorColor = useThemeColor("--color-separator"); - const fullSwipeThreshold = Math.max(THREAD_SWIPE_ACTIONS_WIDTH + 44, (windowWidth - 32) * 0.58); const timestamp = relativeTime(props.thread.archivedAt ?? props.thread.updatedAt); const subtitle = [props.environmentLabel, props.thread.branch].filter((part): part is string => Boolean(part), ); - const handleFullSwipeArmedChange = useCallback((armed: boolean) => { - if (armed && !fullSwipeArmedRef.current && process.env.EXPO_OS === "ios") { - void Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Medium); - } - fullSwipeArmedRef.current = armed; - }, []); const handleMenuAction = useCallback( (event: { nativeEvent: { event: string } }) => { if (event.nativeEvent.event === "unarchive") { @@ -200,114 +292,84 @@ function ArchivedThreadRow(props: { ); return ( - { - fullSwipeArmedRef.current = false; - if (swipeableRef.current) { - props.onSwipeableClose(swipeableRef.current); - } - }} - onSwipeableOpenStartDrag={() => { - if (swipeableRef.current) { - props.onSwipeableWillOpen(swipeableRef.current); - } + { - const methods = swipeableRef.current; - if (!methods) return; - - props.onSwipeableWillOpen(methods); - if (fullSwipeArmedRef.current) { - fullSwipeArmedRef.current = false; - methods.close(); - props.onDelete(); - } - }} - overshootFriction={1} - overshootRight - renderRightActions={(_progress, translation, methods) => ( - - )} - rightThreshold={THREAD_SWIPE_ACTIONS_WIDTH * 0.42} + simultaneousWithExternalGesture={props.simultaneousSwipeGesture} + threadTitle={props.thread.title} > - - - - - - - - - {props.thread.title} - - - {timestamp} - + {() => ( + + + - {subtitle.length > 0 ? ( - - + + + - {subtitle.join(" · ")} + {props.thread.title} + + + {timestamp} - ) : null} - + {subtitle.length > 0 ? ( + + + + {subtitle.join(" · ")} + + + ) : null} + - - - - - - - + + + + + + + )} + ); } @@ -317,7 +379,7 @@ function ArchiveError(props: { readonly message: string; readonly onRetry: () => Could not load every archive - {props.message} + {props.message} Try again @@ -340,8 +402,41 @@ export function ArchivedThreadsScreen(props: { readonly onSortOrderChange: (sortOrder: ArchivedThreadSortOrder) => void; readonly onUnarchiveThread: (thread: EnvironmentThreadShell) => void; }) { + const { onDeleteThread, onUnarchiveThread } = props; const openSwipeableRef = useRef(null); + const archiveScrollGesture = useMemo(() => Gesture.Native(), []); const refreshTint = useThemeColor("--color-icon"); + const environmentLabelsById = useMemo( + () => + new Map( + props.environments.map((environment) => [environment.environmentId, environment.label]), + ), + [props.environments], + ); + const listItems = useMemo>(() => { + const items: ArchivedThreadListItem[] = []; + for (const group of props.groups) { + const environmentLabel = environmentLabelsById.get(group.project.environmentId) ?? null; + items.push({ + kind: "project", + key: `${group.key}:project`, + environmentLabel, + project: group.project, + }); + + group.threads.forEach((thread, index) => { + items.push({ + kind: "thread", + key: `${thread.environmentId}:${thread.id}`, + environmentLabel, + isFirst: index === 0, + isLast: index === group.threads.length - 1, + thread, + }); + }); + } + return items; + }, [environmentLabelsById, props.groups]); const handleSwipeableWillOpen = useCallback((methods: SwipeableMethods) => { if (openSwipeableRef.current && openSwipeableRef.current !== methods) { openSwipeableRef.current.close(); @@ -355,82 +450,103 @@ export function ArchivedThreadsScreen(props: { }, []); const isInitialLoad = props.isLoading && props.groups.length === 0 && props.error === null; const isFiltered = props.searchQuery.trim().length > 0 || props.selectedEnvironmentId !== null; + const renderListItem = useCallback( + ({ item }: { item: ArchivedThreadListItem }) => { + if (item.kind === "project") { + return ( + + + + ); + } + + return ( + onDeleteThread(item.thread)} + onSwipeableClose={handleSwipeableClose} + onSwipeableWillOpen={handleSwipeableWillOpen} + onUnarchive={() => onUnarchiveThread(item.thread)} + simultaneousSwipeGesture={archiveScrollGesture} + thread={item.thread} + /> + ); + }, + [ + archiveScrollGesture, + handleSwipeableClose, + handleSwipeableWillOpen, + onDeleteThread, + onUnarchiveThread, + ], + ); + const listEmptyComponent = useMemo(() => { + if (isInitialLoad) { + return ( + + + Loading archive... + + ); + } + + return ( + + ); + }, [isFiltered, isInitialLoad, refreshTint]); return ( - openSwipeableRef.current?.close()} - refreshControl={ - - } - showsVerticalScrollIndicator={false} - > - {props.error ? : null} - - {isInitialLoad ? ( - - - Loading archive… - - ) : props.groups.length === 0 ? ( - - ) : ( - props.groups.map((group) => { - const environmentLabel = - props.environments.find( - (environment) => environment.environmentId === group.project.environmentId, - )?.label ?? null; - - return ( - - - - {group.threads.map((thread, index) => ( - props.onDeleteThread(thread)} - onSwipeableClose={handleSwipeableClose} - onSwipeableWillOpen={handleSwipeableWillOpen} - onUnarchive={() => props.onUnarchiveThread(thread)} - thread={thread} - /> - ))} - - - ); - }) - )} - + + item.kind} + keyboardDismissMode="on-drag" + keyboardShouldPersistTaps="handled" + keyExtractor={(item) => item.key} + ListEmptyComponent={listEmptyComponent} + ListHeaderComponent={ + props.error ? : null + } + onScrollBeginDrag={() => openSwipeableRef.current?.close()} + refreshControl={ + + } + renderItem={renderListItem} + showsVerticalScrollIndicator={false} + /> + ); } diff --git a/apps/mobile/src/features/cloud/CloudWaitlistEnrollment.tsx b/apps/mobile/src/features/cloud/CloudWaitlistEnrollment.tsx index 4d5b5703329..dbaf564e91f 100644 --- a/apps/mobile/src/features/cloud/CloudWaitlistEnrollment.tsx +++ b/apps/mobile/src/features/cloud/CloudWaitlistEnrollment.tsx @@ -2,7 +2,6 @@ import { useWaitlist } from "@clerk/expo"; import { ActivityIndicator, Pressable, StyleSheet, Text, TextInput, View } from "react-native"; import { useState } from "react"; -import { MOBILE_TYPOGRAPHY } from "../../lib/typography"; import { useThemeColor } from "../../lib/useThemeColor"; import { CloudWaitlistJoinRejectedError, joinCloudWaitlist } from "./cloudWaitlistJoin"; @@ -36,8 +35,10 @@ export function CloudWaitlistEnrollment(props: { readonly onSignIn: () => void } if (waitlist.id) { return ( - You are on the waitlist - + + You are on the waitlist + + We will email you when your T3 Cloud access is ready. @@ -47,17 +48,18 @@ export function CloudWaitlistEnrollment(props: { readonly onSignIn: () => void } return ( - + Enter your email and we will let you know when access is ready. - Email address + Email address { setEmailAddress(value); @@ -73,7 +75,6 @@ export function CloudWaitlistEnrollment(props: { readonly onSignIn: () => void } backgroundColor: colors.input, borderColor: fieldError || requestError ? colors.dangerForeground : colors.inputBorder, - color: colors.foreground, }, ]} textContentType="emailAddress" @@ -82,7 +83,7 @@ export function CloudWaitlistEnrollment(props: { readonly onSignIn: () => void } {fieldError || requestError ? ( {fieldError ?? requestError} @@ -107,7 +108,7 @@ export function CloudWaitlistEnrollment(props: { readonly onSignIn: () => void } ]} > {isSubmitting ? : null} - + {isSubmitting ? "Joining" : "Join the waitlist"} @@ -118,12 +119,11 @@ export function CloudWaitlistEnrollment(props: { readonly onSignIn: () => void } } function SignInAction(props: { readonly onPress: () => void }) { - const colors = useCloudWaitlistColors(); return ( - Already have access? + Already have access? - Sign in + Sign in ); @@ -132,35 +132,18 @@ function SignInAction(props: { readonly onPress: () => void }) { function useCloudWaitlistColors() { return { dangerForeground: String(useThemeColor("--color-danger-foreground")), - foreground: String(useThemeColor("--color-foreground")), input: String(useThemeColor("--color-input")), inputBorder: String(useThemeColor("--color-input-border")), placeholder: String(useThemeColor("--color-placeholder")), primary: String(useThemeColor("--color-primary")), primaryForeground: String(useThemeColor("--color-primary-foreground")), - secondaryForeground: String(useThemeColor("--color-foreground-secondary")), }; } const styles = StyleSheet.create({ - body: { - fontFamily: "DMSans_400Regular", - ...MOBILE_TYPOGRAPHY.body, - }, - buttonText: { - fontFamily: "DMSans_700Bold", - fontSize: MOBILE_TYPOGRAPHY.body.fontSize, - }, content: { gap: 18, }, - confirmationBody: { - textAlign: "center", - }, - error: { - fontFamily: "DMSans_400Regular", - ...MOBILE_TYPOGRAPHY.footnote, - }, field: { gap: 8, }, @@ -168,16 +151,10 @@ const styles = StyleSheet.create({ borderCurve: "continuous", borderRadius: 16, borderWidth: 1, - fontFamily: "DMSans_400Regular", - fontSize: MOBILE_TYPOGRAPHY.headline.fontSize, minHeight: 54, paddingHorizontal: 16, paddingVertical: 14, }, - label: { - fontFamily: "DMSans_700Bold", - ...MOBILE_TYPOGRAPHY.footnote, - }, primaryButton: { alignItems: "center", borderRadius: 999, @@ -195,13 +172,4 @@ const styles = StyleSheet.create({ justifyContent: "center", paddingTop: 4, }, - signInText: { - fontFamily: "DMSans_700Bold", - ...MOBILE_TYPOGRAPHY.body, - }, - title: { - fontFamily: "DMSans_700Bold", - ...MOBILE_TYPOGRAPHY.title, - textAlign: "center", - }, }); diff --git a/apps/mobile/src/features/cloud/dpop.ts b/apps/mobile/src/features/cloud/dpop.ts index 0bd4b7ff1bd..eabf0dd79b6 100644 --- a/apps/mobile/src/features/cloud/dpop.ts +++ b/apps/mobile/src/features/cloud/dpop.ts @@ -8,12 +8,7 @@ import * as Schema from "effect/Schema"; import * as ExpoCrypto from "expo-crypto"; import * as SecureStore from "expo-secure-store"; import { p256 } from "@noble/curves/nist"; -import { - computeDpopAccessTokenHash, - computeDpopJwkThumbprint, - DpopPublicJwk, - normalizeDpopHtu, -} from "@t3tools/shared/dpop"; +import { DpopPublicJwk, normalizeDpopHtu } from "@t3tools/shared/dpopCommon"; import * as Layer from "effect/Layer"; export class CloudDpopError extends Data.TaggedError("CloudDpopError")<{ @@ -107,6 +102,40 @@ function sha256Digest( ); } +function base64UrlSha256( + data: Uint8Array, + message: string, +): Effect.Effect { + return sha256Digest(data, message).pipe(Effect.map(Encoding.encodeBase64Url)); +} + +function dpopThumbprintInput(jwk: DpopPublicJwk): string { + return JSON.stringify({ + crv: jwk.crv, + kty: jwk.kty, + x: jwk.x, + y: jwk.y, + }); +} + +function computeDpopJwkThumbprintEffect( + jwk: DpopPublicJwk, +): Effect.Effect { + return base64UrlSha256( + new TextEncoder().encode(dpopThumbprintInput(jwk)), + "Could not hash the DPoP public key thumbprint.", + ); +} + +function computeDpopAccessTokenHashEffect( + accessToken: string, +): Effect.Effect { + return base64UrlSha256( + new TextEncoder().encode(accessToken), + "Could not hash the DPoP access token.", + ); +} + function secureRandomBytes( byteCount: number, message: string, @@ -153,7 +182,7 @@ export function generateDpopProofKeyPair(): Effect.Effect< try: () => publicJwkFromUncompressedPublicKey(p256.getPublicKey(privateKey, false)), catch: cloudDpopError("Generated DPoP public key is invalid."), }); - const thumbprint = computeDpopJwkThumbprint(publicJwk); + const thumbprint = yield* computeDpopJwkThumbprintEffect(publicJwk); return { privateJwk: privateJwkFromPrivateKey(privateKey, publicJwk), publicJwk, @@ -189,9 +218,10 @@ export function loadOrCreateDpopProofKeyPair(): Effect.Effect< }, catch: cloudDpopError("Stored DPoP proof key is invalid."), }); + const thumbprint = yield* computeDpopJwkThumbprintEffect(restored.publicJwk); return { ...restored, - thumbprint: computeDpopJwkThumbprint(restored.publicJwk), + thumbprint, }; } const generated = yield* generateDpopProofKeyPair(); @@ -243,7 +273,9 @@ export function createDpopProof(input: { Effect.map(Encoding.encodeBase64Url), Effect.mapError(cloudDpopError("Could not encode DPoP proof header.")), ); - const ath = input.accessToken ? computeDpopAccessTokenHash(input.accessToken) : null; + const ath = input.accessToken + ? yield* computeDpopAccessTokenHashEffect(input.accessToken) + : null; const payload = yield* encodeDpopJwtPayloadJson({ htm: input.method.toUpperCase(), htu, diff --git a/apps/mobile/src/features/connection/ConnectionEnvironmentRow.tsx b/apps/mobile/src/features/connection/ConnectionEnvironmentRow.tsx index 7b901ec4c66..394bc180183 100644 --- a/apps/mobile/src/features/connection/ConnectionEnvironmentRow.tsx +++ b/apps/mobile/src/features/connection/ConnectionEnvironmentRow.tsx @@ -76,16 +76,16 @@ export function ConnectionEnvironmentRow(props: { /> - + {props.environment.environmentLabel} - + {props.environment.displayUrl} {statusLabel ? ( {props.environment.isRelayManaged ? ( - + Managed by T3 Cloud. Tunnel details update automatically. ) : ( diff --git a/apps/mobile/src/app/connections/new.tsx b/apps/mobile/src/features/connection/ConnectionsNewRouteScreen.tsx similarity index 87% rename from apps/mobile/src/app/connections/new.tsx rename to apps/mobile/src/features/connection/ConnectionsNewRouteScreen.tsx index ca9693dbb19..af4431c6a66 100644 --- a/apps/mobile/src/app/connections/new.tsx +++ b/apps/mobile/src/features/connection/ConnectionsNewRouteScreen.tsx @@ -1,5 +1,6 @@ import { CameraView, useCameraPermissions } from "expo-camera"; -import { Stack, useLocalSearchParams, useRouter } from "expo-router"; +import { NativeHeaderToolbar, NativeStackScreenOptions } from "../../native/StackHeader"; +import { StackActions, useNavigation, type StaticScreenProps } from "@react-navigation/native"; import { AsyncResult } from "effect/unstable/reactivity"; import { useCallback, useEffect, useState } from "react"; import { Alert, ScrollView, View } from "react-native"; @@ -8,21 +9,26 @@ import { useThemeColor } from "../../lib/useThemeColor"; import { AppText as Text, AppTextInput as TextInput } from "../../components/AppText"; import { ErrorBanner } from "../../components/ErrorBanner"; -import { dismissRoute } from "../../lib/routes"; -import { ConnectionSheetButton } from "../../features/connection/ConnectionSheetButton"; -import { extractPairingUrlFromQrPayload } from "../../features/connection/pairing"; +import { ConnectionSheetButton } from "./ConnectionSheetButton"; +import { extractPairingUrlFromQrPayload } from "./pairing"; import { useRemoteConnections } from "../../state/use-remote-environment-registry"; -import { buildPairingUrl, parsePairingUrl } from "../../features/connection/pairing"; +import { buildPairingUrl, parsePairingUrl } from "./pairing"; -export default function ConnectionsNewRouteScreen() { +type ConnectionsNewRouteParams = { + readonly mode?: string; +}; + +export function ConnectionsNewRouteScreen({ + route, +}: StaticScreenProps) { const { connectionPairingUrl, onChangeConnectionPairingUrl, onConnectPress, pairingConnectionError, } = useRemoteConnections(); - const router = useRouter(); - const params = useLocalSearchParams<{ mode?: string }>(); + const navigation = useNavigation(); + const params = route.params ?? {}; const insets = useSafeAreaInsets(); const [hostInput, setHostInput] = useState(""); const [codeInput, setCodeInput] = useState(""); @@ -31,6 +37,7 @@ export default function ConnectionsNewRouteScreen() { const [cameraPermission, requestCameraPermission] = useCameraPermissions(); const [scannerLocked, setScannerLocked] = useState(false); + const headerIconColor = useThemeColor("--color-icon"); const placeholderColor = useThemeColor("--color-placeholder"); const connectDisabled = isSubmitting || hostInput.trim().length === 0; @@ -116,21 +123,25 @@ export default function ConnectionsNewRouteScreen() { onChangeConnectionPairingUrl(pairingUrl); const result = await onConnectPress(pairingUrl); if (AsyncResult.isSuccess(result)) { - dismissRoute(router); + if (navigation.canGoBack()) { + navigation.goBack(); + } else { + navigation.dispatch(StackActions.replace("Home")); + } } else { setIsSubmitting(false); } - }, [codeInput, hostInput, onChangeConnectionPairingUrl, onConnectPress, router]); + }, [codeInput, hostInput, onChangeConnectionPairingUrl, onConnectPress, navigation]); return ( - - - + { if (showScanner) { @@ -140,8 +151,9 @@ export default function ConnectionsNewRouteScreen() { } }} separateBackground + tintColor={headerIconColor} /> - + - + Camera permission is required to scan a QR code. 0; const [expandedId, setExpandedId] = useState(null); @@ -31,18 +32,13 @@ export default function ConnectionsRouteScreen() { return ( - - - + router.push("/connections/new")} + onPress={() => navigation.navigate("ConnectionsNew")} separateBackground /> - + - + No environments connected yet.{"\n"}Tap{" "} + to add one. diff --git a/apps/mobile/src/features/connection/EnvironmentConnectionNotice.tsx b/apps/mobile/src/features/connection/EnvironmentConnectionNotice.tsx index 373b0d3ef03..519dafb004c 100644 --- a/apps/mobile/src/features/connection/EnvironmentConnectionNotice.tsx +++ b/apps/mobile/src/features/connection/EnvironmentConnectionNotice.tsx @@ -73,10 +73,10 @@ export function EnvironmentConnectionNotice(props: { /> )} - + {noticeTitle(props.connection.phase, props.environmentLabel)} - + {noticeDetail(props.connection.phase, props.resourceName, props.connection.error)} {props.connection.traceId ? ( <> diff --git a/apps/mobile/src/features/diffs/nativeReviewDiffSurface.test.ts b/apps/mobile/src/features/diffs/nativeReviewDiffSurface.test.ts index 975bf7be13d..0409655583b 100644 --- a/apps/mobile/src/features/diffs/nativeReviewDiffSurface.test.ts +++ b/apps/mobile/src/features/diffs/nativeReviewDiffSurface.test.ts @@ -33,11 +33,14 @@ describe("resolveNativeReviewDiffView", () => { expect(expoMocks.requireNativeView).not.toHaveBeenCalled(); }); - it("returns the native review diff view when the view config is installed", async () => { + it("returns the payload bridge when the native review diff view is installed", async () => { setExpoViewConfigAvailable(); expoMocks.requireNativeView.mockReturnValue(nativeView); const { resolveNativeReviewDiffView } = await import("./nativeReviewDiffSurface"); - expect(resolveNativeReviewDiffView()).toBe(nativeView); + const resolvedView = resolveNativeReviewDiffView(); + expect(resolvedView).not.toBeNull(); + expect(resolvedView).not.toBe(nativeView); + expect(resolveNativeReviewDiffView()).toBe(resolvedView); expect(expoMocks.requireNativeView).toHaveBeenCalledWith("T3ReviewDiffSurface"); }); @@ -78,3 +81,20 @@ describe("resolveNativeReviewDiffView", () => { expect(consoleError).toHaveBeenCalledTimes(1); }); }); + +describe("isPendingNativeViewRegistration", () => { + it("recognizes registration races for the installed native view name", async () => { + const { isPendingNativeViewRegistration } = await import("./nativeReviewDiffSurface"); + + expect( + isPendingNativeViewRegistration( + new Error("Unable to find the 'T3ReviewDiffSurface' view for this native tag"), + ), + ).toBe(true); + expect( + isPendingNativeViewRegistration( + new Error("Unable to find the 'T3ReviewDiffView' view for this native tag"), + ), + ).toBe(false); + }); +}); diff --git a/apps/mobile/src/features/diffs/nativeReviewDiffSurface.ts b/apps/mobile/src/features/diffs/nativeReviewDiffSurface.ts index 7660a047752..551e577b294 100644 --- a/apps/mobile/src/features/diffs/nativeReviewDiffSurface.ts +++ b/apps/mobile/src/features/diffs/nativeReviewDiffSurface.ts @@ -1,10 +1,18 @@ -import type { ComponentType } from "react"; +import { + createElement, + useEffect, + useImperativeHandle, + useRef, + type ComponentType, + type Ref, +} from "react"; import type { NativeSyntheticEvent, ViewProps } from "react-native"; import { requireNativeView } from "expo"; import { NativeViewResolutionError } from "../../native/nativeViewResolutionError"; const NATIVE_REVIEW_DIFF_MODULE_NAME = "T3ReviewDiffSurface"; +const NATIVE_REVIEW_DIFF_PAYLOAD_RETRY_FRAMES = 60; interface ExpoGlobalWithViewConfig { readonly expo?: { @@ -103,6 +111,7 @@ export interface NativeReviewDiffViewProps extends ViewProps { readonly tokensJson?: string; readonly tokensPatchJson?: string; readonly tokensResetKey?: string; + readonly contentResetKey?: string; readonly collapsedFileIdsJson?: string; readonly viewedFileIdsJson?: string; readonly selectedRowIdsJson?: string; @@ -113,7 +122,13 @@ export interface NativeReviewDiffViewProps extends ViewProps { readonly rowHeight: number; readonly contentWidth: number; readonly initialRowIndex?: number; + readonly refreshing?: boolean; + readonly nativeViewRef?: Ref; + readonly onPullToRefresh?: (event: NativeSyntheticEvent>) => void; readonly onDebug?: (event: NativeSyntheticEvent>) => void; + readonly onVisibleFileChange?: ( + event: NativeSyntheticEvent<{ readonly fileId?: string | null }>, + ) => void; readonly onToggleFile?: (event: NativeSyntheticEvent<{ readonly fileId?: string }>) => void; readonly onToggleViewedFile?: (event: NativeSyntheticEvent<{ readonly fileId?: string }>) => void; readonly onPressLine?: ( @@ -129,18 +144,130 @@ export interface NativeReviewDiffViewProps extends ViewProps { readonly onToggleComment?: (event: NativeSyntheticEvent<{ readonly commentId?: string }>) => void; } -let cachedNativeReviewDiffView: ComponentType | undefined; +export interface NativeReviewDiffViewHandle { + readonly scrollToFile: (fileId: string, animated?: boolean) => Promise; + readonly scrollToTop: (animated?: boolean) => Promise; +} + +interface NativeReviewDiffViewRef { + readonly setRowsJson: (rowsJson: string) => Promise; + readonly setTokensJson: (tokensJson: string) => Promise; + readonly setTokensPatchJson: (tokensPatchJson: string) => Promise; + readonly scrollToFile: (fileId: string, animated: boolean) => Promise; + readonly scrollToTop: (animated: boolean) => Promise; +} + +type NativeReviewDiffRawViewProps = Omit< + NativeReviewDiffViewProps, + "nativeViewRef" | "rowsJson" | "tokensJson" | "tokensPatchJson" +> & { + readonly ref?: Ref; +}; + +let cachedNativeReviewDiffRawView: ComponentType | undefined; let nativeReviewDiffViewResolutionFailed = false; +type NativeReviewDiffPayloadMethod = "setRowsJson" | "setTokensJson" | "setTokensPatchJson"; + +export function isPendingNativeViewRegistration(error: unknown): boolean { + return ( + error instanceof Error && + error.message.includes(`Unable to find the '${NATIVE_REVIEW_DIFF_MODULE_NAME}' view`) + ); +} + +function useNativeReviewDiffPayload( + nativeRef: React.RefObject, + method: NativeReviewDiffPayloadMethod, + payload: string | undefined, +) { + useEffect(() => { + if (payload === undefined) { + return; + } + + let cancelled = false; + let frame: number | null = null; + let attempts = 0; + + const dispatch = () => { + if (cancelled) { + return; + } + + const view = nativeRef.current; + const command = view?.[method]; + if (!view || !command) { + if (attempts < NATIVE_REVIEW_DIFF_PAYLOAD_RETRY_FRAMES) { + attempts += 1; + frame = requestAnimationFrame(dispatch); + } + return; + } + + void command.call(view, payload).catch((error: unknown) => { + if ( + !cancelled && + attempts < NATIVE_REVIEW_DIFF_PAYLOAD_RETRY_FRAMES && + isPendingNativeViewRegistration(error) + ) { + attempts += 1; + frame = requestAnimationFrame(dispatch); + return; + } + console.error(`[native-review-diff] ${method} failed`, error); + }); + }; + + // Fabric attaches the React ref before Expo registers the native tag used by + // view functions. Starting on the next frame avoids racing that registration. + frame = requestAnimationFrame(dispatch); + + return () => { + cancelled = true; + if (frame !== null) { + cancelAnimationFrame(frame); + } + }; + }, [method, nativeRef, payload]); +} + function getExpoViewConfig(moduleName: string) { return (globalThis as typeof globalThis & ExpoGlobalWithViewConfig).expo?.getViewConfig?.( moduleName, ); } +function NativeReviewDiffView(props: NativeReviewDiffViewProps) { + const { nativeViewRef, rowsJson, tokensJson, tokensPatchJson, ...nativeProps } = props; + const nativeRef = useRef(null); + useNativeReviewDiffPayload(nativeRef, "setRowsJson", rowsJson); + useNativeReviewDiffPayload(nativeRef, "setTokensJson", tokensJson); + useNativeReviewDiffPayload(nativeRef, "setTokensPatchJson", tokensPatchJson); + useImperativeHandle( + nativeViewRef, + () => ({ + scrollToFile: async (fileId, animated = true) => { + await nativeRef.current?.scrollToFile(fileId, animated); + }, + scrollToTop: async (animated = true) => { + await nativeRef.current?.scrollToTop(animated); + }, + }), + [], + ); + + const RawNativeView = cachedNativeReviewDiffRawView; + if (!RawNativeView) { + return null; + } + + return createElement(RawNativeView, { ...nativeProps, ref: nativeRef }); +} + export function resolveNativeReviewDiffView(): ComponentType | null { - if (cachedNativeReviewDiffView) { - return cachedNativeReviewDiffView; + if (cachedNativeReviewDiffRawView) { + return NativeReviewDiffView; } if (nativeReviewDiffViewResolutionFailed) { @@ -152,7 +279,7 @@ export function resolveNativeReviewDiffView(): ComponentType( + cachedNativeReviewDiffRawView = requireNativeView( NATIVE_REVIEW_DIFF_MODULE_NAME, ); } catch (cause) { @@ -166,5 +293,5 @@ export function resolveNativeReviewDiffView(): ComponentType resolveMarkdownFontSizes(appearance.baseFontSize), + [appearance.baseFontSize], + ); + const nativeMarkdownTypography = useMemo( + () => resolveNativeMarkdownTypography(appearance.baseFontSize), + [appearance.baseFontSize], + ); const body = String(useThemeColor("--color-md-body")); const strong = String(useThemeColor("--color-md-strong")); const link = String(useThemeColor("--color-md-link")); @@ -74,7 +87,8 @@ function useMarkdownPreviewStyles(): MarkdownPreviewStyles { text: { color: body, fontFamily: "DMSans_400Regular", - ...MOBILE_TYPOGRAPHY.body, + fontSize: markdownFontSizes.m, + lineHeight: markdownFontSizes.bodyLineHeight, }, heading: { color: strong, @@ -124,7 +138,9 @@ function useMarkdownPreviewStyles(): MarkdownPreviewStyles { skillTextColor: codeText, quoteMarkerColor: blockquoteBorder, dividerColor: horizontalRule, - ...MOBILE_TYPOGRAPHY.body, + fontSize: nativeMarkdownTypography.fontSize, + lineHeight: nativeMarkdownTypography.lineHeight, + headingFontSizes: nativeMarkdownTypography.headingFontSizes, fontFamily: "DMSans_400Regular", headingFontFamily: "DMSans_700Bold", boldFontFamily: "DMSans_700Bold", @@ -138,18 +154,46 @@ function useMarkdownPreviewStyles(): MarkdownPreviewStyles { codeText, horizontalRule, link, + markdownFontSizes, + nativeMarkdownTypography, strong, ]); } -export function FileMarkdownPreview(props: { readonly markdown: string }) { +export function FileMarkdownPreview(props: { + readonly markdown: string; + readonly onRefresh?: () => Promise | void; +}) { + const [isPullRefreshing, setIsPullRefreshing] = useState(false); + const handlePullToRefresh = useCallback(async () => { + if (!props.onRefresh) { + return; + } + setIsPullRefreshing(true); + try { + await props.onRefresh(); + } finally { + setIsPullRefreshing(false); + } + }, [props.onRefresh]); const styles = useMarkdownPreviewStyles(); const onLinkPress = useCallback((href: string) => { void tryOpenExternalUrl(href, "markdown-link"); }, []); return ( - + void handlePullToRefresh()} + /> + ) : undefined + } + > {hasNativeSelectableMarkdownText() ? ( , ReadonlyArray>(); +const FILE_TREE_INITIAL_RENDER_COUNT = 20; +const FILE_TREE_RENDER_BATCH_SIZE = 12; +const OPTIMISTIC_SELECTION_TIMEOUT_MS = 1_000; + +function cachedFileTree(entries: ReadonlyArray): ReadonlyArray { + const cached = fileTreeCache.get(entries); + if (cached !== undefined) { + return cached; + } + const tree = buildFileTree(entries); + fileTreeCache.set(entries, tree); + return tree; +} + function ancestorPaths(path: string): ReadonlyArray { const parts = path.split("/").filter(Boolean); const ancestors: string[] = []; @@ -25,19 +49,24 @@ function ancestorPaths(path: string): ReadonlyArray { const FileTreeRow = memo(function FileTreeRow(props: { readonly item: VisibleFileTreeNode; - readonly selectedPath: string | null; + readonly selected: boolean; readonly expanded: boolean; readonly iconColor: string; readonly onPressDirectory: (path: string) => void; + readonly onPreviewFile?: (path: string) => void; readonly onPressFile: (path: string) => void; }) { const { node, depth } = props.item; - const selected = node.kind === "file" && node.path === props.selectedPath; return ( { + if (node.kind === "file") { + props.onPreviewFile?.(node.path); + } + }} onPress={() => { if (node.kind === "directory") { props.onPressDirectory(node.path); @@ -47,7 +76,7 @@ const FileTreeRow = memo(function FileTreeRow(props: { }} className={cn( "mx-2 min-h-[42px] flex-row items-center gap-2 rounded-[12px] px-2 active:bg-subtle", - selected && "bg-subtle-strong", + props.selected && "bg-subtle-strong", )} style={{ paddingLeft: 8 + depth * 18 }} > @@ -64,8 +93,10 @@ const FileTreeRow = memo(function FileTreeRow(props: { @@ -86,13 +117,30 @@ export function FileTreeBrowser(props: { readonly isPending: boolean; readonly searchQuery: string; readonly selectedPath: string | null; + readonly onPreviewFile?: (path: string) => void; readonly onRefresh: () => void; readonly onSelectFile: (path: string) => void; }) { const [expandedPaths, setExpandedPaths] = useState>(() => new Set()); + const [pendingSelection, setPendingSelection] = useState<{ + readonly path: string; + readonly selectedPathAtPress: string | null; + } | null>(null); + const insets = useSafeAreaInsets(); + // Native transparent-header height ≈ safe-area top + nav bar (~44). Matches the + // observed adjustedContentInset bottom (~102) seen in the native trace. + const headerInset = Platform.OS === "ios" ? insets.top + 44 : 0; const iconColor = String(useThemeColor("--color-icon-muted")); + const { onPreviewFile, onSelectFile, selectedPath: controlledSelectedPath } = props; + const controlledSelectedPathRef = useRef(controlledSelectedPath); + const pendingSelectionTimeoutRef = useRef | null>(null); + controlledSelectedPathRef.current = controlledSelectedPath; - const tree = useMemo(() => buildFileTree(props.entries), [props.entries]); + const selectedPath = + pendingSelection?.selectedPathAtPress === controlledSelectedPath + ? pendingSelection.path + : controlledSelectedPath; + const tree = useMemo(() => cachedFileTree(props.entries), [props.entries]); const defaultExpanded = useMemo(() => defaultExpandedTreePaths(tree), [tree]); const visibleNodes = useMemo( () => @@ -114,17 +162,30 @@ export function FileTreeBrowser(props: { }, [defaultExpanded]); useEffect(() => { - if (!props.selectedPath) { + if (!controlledSelectedPath) { return; } setExpandedPaths((current) => { + const ancestors = ancestorPaths(controlledSelectedPath); + if (ancestors.every((ancestor) => current.has(ancestor))) { + return current; + } const next = new Set(current); - for (const ancestor of ancestorPaths(props.selectedPath ?? "")) { + for (const ancestor of ancestors) { next.add(ancestor); } return next; }); - }, [props.selectedPath]); + }, [controlledSelectedPath]); + + useEffect( + () => () => { + if (pendingSelectionTimeoutRef.current !== null) { + clearTimeout(pendingSelectionTimeoutRef.current); + } + }, + [], + ); const toggleDirectory = useCallback((path: string) => { setExpandedPaths((current) => { @@ -137,53 +198,86 @@ export function FileTreeBrowser(props: { return next; }); }, []); + const handleSelectFile = useCallback( + (path: string) => { + if (pendingSelectionTimeoutRef.current !== null) { + clearTimeout(pendingSelectionTimeoutRef.current); + } + setPendingSelection({ + path, + selectedPathAtPress: controlledSelectedPathRef.current, + }); + pendingSelectionTimeoutRef.current = setTimeout(() => { + pendingSelectionTimeoutRef.current = null; + setPendingSelection((current) => (current?.path === path ? null : current)); + }, OPTIMISTIC_SELECTION_TIMEOUT_MS); + onSelectFile(path); + }, + [onSelectFile], + ); + const renderItem = useCallback( + ({ item }: { readonly item: VisibleFileTreeNode }) => ( + + ), + [expandedPaths, handleSelectFile, iconColor, onPreviewFile, selectedPath, toggleDirectory], + ); + if (props.error && props.entries.length === 0) { + return ( + + Files unavailable + {props.error} + + ); + } + + // SPIKE: render the FlatList as the screen's DIRECT content (no wrapping View), and + // mirror the Home ScrollView exactly — `contentInsetAdjustmentBehavior: "automatic"` + // with NO manual contentInset. iOS only applies the nav-bar top inset + scroll-edge + // blur to a scroll view in the screen's primary position; a scroll view buried in + // flex-1 Views is ignored, which is why the tree rendered under the header with no blur. return ( - - {props.error && props.entries.length === 0 ? ( + item.node.path} + contentInsetAdjustmentBehavior={Platform.OS === "ios" ? "automatic" : "never"} + scrollIndicatorInsets={ + Platform.OS === "ios" ? { top: headerInset, left: 0, right: 0, bottom: 0 } : undefined + } + keyboardDismissMode="on-drag" + keyboardShouldPersistTaps="handled" + initialNumToRender={FILE_TREE_INITIAL_RENDER_COUNT} + maxToRenderPerBatch={FILE_TREE_RENDER_BATCH_SIZE} + updateCellsBatchingPeriod={16} + windowSize={5} + contentContainerStyle={{ paddingTop: 8, paddingBottom: 8 }} + refreshControl={} + renderItem={renderItem} + ListEmptyComponent={ - Files unavailable - {props.error} - - ) : ( - item.node.path} - contentInsetAdjustmentBehavior="automatic" - keyboardDismissMode="on-drag" - keyboardShouldPersistTaps="handled" - contentContainerStyle={{ paddingVertical: 8 }} - refreshControl={ - - } - renderItem={({ item }) => ( - + {props.isPending ? ( + + ) : ( + <> + No files found + + {props.searchQuery.trim().length > 0 + ? "Try a different search." + : "The workspace file index is empty."} + + )} - ListEmptyComponent={ - - {props.isPending ? ( - - ) : ( - <> - No files found - - {props.searchQuery.trim().length > 0 - ? "Try a different search." - : "The workspace file index is empty."} - - - )} - - } - /> - )} - + + } + /> ); } diff --git a/apps/mobile/src/features/files/SourceFileSurface.tsx b/apps/mobile/src/features/files/SourceFileSurface.tsx index b96d6515951..9774130eb2e 100644 --- a/apps/mobile/src/features/files/SourceFileSurface.tsx +++ b/apps/mobile/src/features/files/SourceFileSurface.tsx @@ -1,8 +1,15 @@ import { useAtomValue } from "@effect/atom-react"; import { AsyncResult } from "effect/unstable/reactivity"; import type { ComponentType } from "react"; -import { memo, useCallback, useEffect, useMemo, useRef } from "react"; -import { FlatList, ScrollView, Text as NativeText, useColorScheme, View } from "react-native"; +import { memo, useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { + FlatList, + ScrollView, + Text as NativeText, + useColorScheme, + useWindowDimensions, + View, +} from "react-native"; import { AppText as Text } from "../../components/AppText"; import { LoadingStrip } from "../../components/LoadingStrip"; @@ -11,71 +18,62 @@ import { resolveNativeReviewDiffView, } from "../diffs/nativeReviewDiffSurface"; import { createNativeReviewDiffTheme } from "../review/nativeReviewDiffAdapter"; -import { - REVIEW_DIFF_LINE_HEIGHT, - REVIEW_MONO_FONT_FAMILY, - renderVisibleWhitespace, -} from "../review/reviewDiffRendering"; +import { REVIEW_MONO_FONT_FAMILY, renderVisibleWhitespace } from "../review/reviewDiffRendering"; import type { ReviewHighlightedToken } from "../review/shikiReviewHighlighter"; import { cn } from "../../lib/cn"; -import { MOBILE_CODE_SURFACE } from "../../lib/typography"; +import type { ResolvedMobileCodeSurface } from "../../lib/appearancePreferences"; +import { useAppearanceCodeSurface } from "../settings/appearance/useAppearanceCodeSurface"; import { - buildNativeSourceRows, buildNativeSourceTokens, NATIVE_SOURCE_CONTENT_WIDTH, - NATIVE_SOURCE_ROW_HEIGHT, - NATIVE_SOURCE_STYLE, nativeSourceRowId, } from "./nativeSourceFileAdapter"; +import { prepareSourceFileDocument } from "./source-file-document"; import { sourceHighlightAtom } from "./sourceHighlightingState"; -const SOURCE_LINE_HEIGHT = MOBILE_CODE_SURFACE.rowHeight; -const SOURCE_LINE_NUMBER_WIDTH = MOBILE_CODE_SURFACE.gutterWidth; -const NATIVE_SOURCE_STYLE_JSON = JSON.stringify(NATIVE_SOURCE_STYLE); - interface SourceFileSurfaceProps { readonly contents: string; readonly path: string; readonly initialLine?: number | null; + /** Enables native pull-to-refresh on the source surface. */ + readonly onRefresh?: () => Promise | void; } type SourceHighlightStatus = "highlighting" | "ready" | "error"; -function splitSourceLines(contents: string): ReadonlyArray { - return contents.replace(/\r\n?/g, "\n").split("\n"); -} - const HighlightedSourceLine = memo(function HighlightedSourceLine(props: { + readonly codeSurface: ResolvedMobileCodeSurface; readonly index: number; readonly line: string; readonly tokens: ReadonlyArray | null; readonly highlighted: boolean; + readonly wordBreak: boolean; }) { return ( {props.index + 1} {props.tokens && props.tokens.length > 0 @@ -119,11 +117,8 @@ const HighlightedSourceLine = memo(function HighlightedSourceLine(props: { function useSourceFileModel(props: SourceFileSurfaceProps) { const colorScheme = useColorScheme(); const theme: "dark" | "light" = colorScheme === "dark" ? "dark" : "light"; - const normalizedContents = useMemo( - () => props.contents.replace(/\r\n?/g, "\n"), - [props.contents], - ); - const lines = useMemo(() => splitSourceLines(normalizedContents), [normalizedContents]); + const document = useMemo(() => prepareSourceFileDocument(props.contents), [props.contents]); + const { contents: normalizedContents, lines, rowsJson } = document; const targetIndex = props.initialLine !== null && props.initialLine !== undefined && props.initialLine > 0 ? Math.min(Math.floor(props.initialLine) - 1, Math.max(0, lines.length - 1)) @@ -140,7 +135,7 @@ function useSourceFileModel(props: SourceFileSurfaceProps) { ? "ready" : "highlighting"; - return { lines, status, targetIndex, theme, tokens }; + return { lines, rowsJson, status, targetIndex, theme, tokens }; } function SourceHighlightStatusView(props: { readonly status: SourceHighlightStatus }) { @@ -162,39 +157,63 @@ function NativeSourceFileSurface( readonly NativeView: ComponentType; }, ) { - const { NativeView } = props; - const { lines, status, targetIndex, theme, tokens } = useSourceFileModel(props); - const rowsJson = useMemo(() => JSON.stringify(buildNativeSourceRows(lines)), [lines]); + const { NativeView, onRefresh } = props; + const { codeSurface, codeWordBreak, nativeSourceStyle } = useAppearanceCodeSurface(); + const { width: viewportWidth } = useWindowDimensions(); + const { rowsJson, status, targetIndex, theme, tokens } = useSourceFileModel(props); + const [isPullRefreshing, setIsPullRefreshing] = useState(false); + const handlePullToRefresh = useCallback(async () => { + if (!onRefresh) { + return; + } + setIsPullRefreshing(true); + try { + await onRefresh(); + } finally { + setIsPullRefreshing(false); + } + }, [onRefresh]); const tokensJson = useMemo(() => JSON.stringify(buildNativeSourceTokens(tokens)), [tokens]); const selectedRowIdsJson = useMemo( () => JSON.stringify(targetIndex === null ? [] : [nativeSourceRowId(targetIndex)]), [targetIndex], ); const themeJson = useMemo(() => JSON.stringify(createNativeReviewDiffTheme(theme)), [theme]); + const styleJson = useMemo(() => JSON.stringify(nativeSourceStyle), [nativeSourceStyle]); + const contentWidth = codeWordBreak + ? Math.max(240, viewportWidth - codeSurface.gutterWidth - 24) + : NATIVE_SOURCE_CONTENT_WIDTH; return ( - + void handlePullToRefresh(), + } + : {})} /> ); } function JavaScriptSourceFileSurface(props: SourceFileSurfaceProps) { + const { codeSurface, codeWordBreak } = useAppearanceCodeSurface(); const { lines, status, targetIndex, tokens } = useSourceFileModel(props); const listRef = useRef>(null); @@ -211,39 +230,53 @@ function JavaScriptSourceFileSurface(props: SourceFileSurfaceProps) { const renderLine = useCallback( ({ item, index }: { item: string; index: number }) => ( ), - [targetIndex, tokens], + [codeSurface, codeWordBreak, targetIndex, tokens], + ); + + const list = ( + String(index)} + initialNumToRender={80} + maxToRenderPerBatch={80} + windowSize={12} + {...(codeWordBreak + ? {} + : { + getItemLayout: (_data, index) => ({ + length: codeSurface.rowHeight, + offset: codeSurface.rowHeight * index, + index, + }), + })} + contentContainerStyle={{ + minWidth: codeWordBreak ? undefined : "100%", + paddingBottom: codeSurface.rowHeight, + paddingTop: 8, + }} + renderItem={renderLine} + /> ); return ( - + - - String(index)} - initialNumToRender={80} - maxToRenderPerBatch={80} - windowSize={12} - getItemLayout={(_data, index) => ({ - length: SOURCE_LINE_HEIGHT, - offset: SOURCE_LINE_HEIGHT * index, - index, - })} - contentContainerStyle={{ - minWidth: "100%", - paddingBottom: REVIEW_DIFF_LINE_HEIGHT, - paddingTop: 8, - }} - renderItem={renderLine} - /> - + {codeWordBreak ? ( + list + ) : ( + + {list} + + )} ); } diff --git a/apps/mobile/src/features/files/ThreadFilesRouteScreen.tsx b/apps/mobile/src/features/files/ThreadFilesRouteScreen.tsx index fba032c0369..487d8271e1b 100644 --- a/apps/mobile/src/features/files/ThreadFilesRouteScreen.tsx +++ b/apps/mobile/src/features/files/ThreadFilesRouteScreen.tsx @@ -1,8 +1,15 @@ -import Stack from "expo-router/stack"; -import { SymbolView } from "expo-symbols"; -import { useLocalSearchParams, useRouter } from "expo-router"; -import { useCallback, useMemo, useRef, useState } from "react"; -import { ActivityIndicator, Pressable, ScrollView, Text as RNText, View } from "react-native"; +import { NativeHeaderToolbar, NativeStackScreenOptions } from "../../native/StackHeader"; +import { StackActions, useNavigation, type StaticScreenProps } from "@react-navigation/native"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { + ActivityIndicator, + Platform, + Pressable, + ScrollView, + useColorScheme, + View, +} from "react-native"; +import { useSafeAreaInsets } from "react-native-safe-area-context"; import Svg, { Defs, LinearGradient, Rect, Stop } from "react-native-svg"; import { EnvironmentId, @@ -12,22 +19,31 @@ import { } from "@t3tools/contracts"; import { AppText as Text } from "../../components/AppText"; -import { CopyTextButton } from "../../components/CopyTextButton"; import { EmptyState } from "../../components/EmptyState"; import { LoadingScreen } from "../../components/LoadingScreen"; import { cn } from "../../lib/cn"; +import { resolveFileSelectionNavigationAction } from "../../lib/adaptive-navigation"; +import { copyTextWithHaptic } from "../../lib/copyTextWithHaptic"; import { tryOpenExternalUrl } from "../../lib/openExternalUrl"; -import { buildThreadFilesNavigation } from "../../lib/routes"; -import { MOBILE_TYPOGRAPHY } from "../../lib/typography"; import { useThemeColor } from "../../lib/useThemeColor"; import { useThreadSelection } from "../../state/use-thread-selection"; import { useSelectedThreadWorktree } from "../../state/use-selected-thread-worktree"; import { useEnvironmentQuery } from "../../state/query"; import { projectEnvironment } from "../../state/projects"; +import { + useAdaptiveWorkspaceLayout, + useAdaptiveWorkspacePaneRole, + useRegisterWorkspaceInspector, +} from "../layout/AdaptiveWorkspaceLayout"; +import { createNativeMailSearchToolbarItem } from "../layout/native-mail-search-toolbar"; +import { WorkspaceSidebarToolbar } from "../layout/workspace-sidebar-toolbar"; import { ReviewHighlighterProvider } from "../review/ReviewHighlighterProvider"; +import { ThreadRouteScreen } from "../threads/ThreadRouteScreen"; import { FileMarkdownPreview } from "./FileMarkdownPreview"; import { FileTreeBrowser } from "./FileTreeBrowser"; +import { preloadWorkspaceFileContents } from "./preload-workspace-file"; import { SourceFileSurface } from "./SourceFileSurface"; +import { ThreadFileNavigatorPane } from "./thread-file-navigator-pane"; import { WorkspaceFileImagePreview } from "./WorkspaceFileImagePreview"; import { WorkspaceFileWebPreview } from "./WorkspaceFileWebPreview"; import { @@ -72,196 +88,6 @@ function defaultViewMode(path: string | null): FileViewMode { : "source"; } -function ModeButton(props: { - readonly active: boolean; - readonly icon: "doc.text" | "eye"; - readonly label: string; - readonly onPress: () => void; -}) { - const iconColor = String( - useThemeColor(props.active ? "--color-primary-foreground" : "--color-icon-muted"), - ); - - return ( - - - - {props.label} - - - ); -} - -function BreadcrumbFade(props: { readonly color: string; readonly side: "left" | "right" }) { - const gradientId = `file-breadcrumb-${props.side}-fade`; - const isLeft = props.side === "left"; - - return ( - - - - - - - - - - - - ); -} - -function FileBreadcrumbs(props: { readonly projectName: string; readonly relativePath: string }) { - const iconColor = String(useThemeColor("--color-icon-muted")); - const cardColor = String(useThemeColor("--color-card")); - const scrollMetrics = useRef({ contentWidth: 0, offsetX: 0, viewportWidth: 0 }); - const [fadeVisibility, setFadeVisibility] = useState({ left: false, right: false }); - const breadcrumbs = useMemo( - () => fileBreadcrumbs(props.projectName, props.relativePath), - [props.projectName, props.relativePath], - ); - const updateFadeVisibility = useCallback( - (metrics: Partial<(typeof scrollMetrics)["current"]>) => { - Object.assign(scrollMetrics.current, metrics); - const { contentWidth, offsetX, viewportWidth } = scrollMetrics.current; - const maxOffset = Math.max(0, contentWidth - viewportWidth); - const next = { - left: maxOffset > 1 && offsetX > 1, - right: maxOffset > 1 && offsetX < maxOffset - 1, - }; - - setFadeVisibility((current) => - current.left === next.left && current.right === next.right ? current : next, - ); - }, - [], - ); - - return ( - - { - updateFadeVisibility({ contentWidth }); - }} - onLayout={(event) => { - updateFadeVisibility({ viewportWidth: event.nativeEvent.layout.width }); - }} - onScroll={(event) => { - updateFadeVisibility({ offsetX: event.nativeEvent.contentOffset.x }); - }} - scrollEventThrottle={16} - > - - {breadcrumbs.map((crumb, index) => ( - - {index > 0 ? ( - - ) : null} - - {crumb.label} - - - ))} - - - {fadeVisibility.left ? : null} - {fadeVisibility.right ? : null} - - ); -} - -function FilePreviewHeader(props: { - readonly activeMode: FileViewMode; - readonly showModeSelector: boolean; - readonly externalPreviewUri?: string | null; - readonly projectName: string; - readonly relativePath: string; - readonly onSetMode: (mode: FileViewMode) => void; -}) { - const iconColor = String(useThemeColor("--color-icon-muted")); - - return ( - - - - - - {props.showModeSelector ? ( - - props.onSetMode("preview")} - /> - props.onSetMode("source")} - /> - {props.externalPreviewUri !== undefined ? ( - { - if (typeof props.externalPreviewUri === "string") { - void tryOpenExternalUrl(props.externalPreviewUri, "file-preview"); - } - }} - > - - - ) : null} - - ) : null} - - ); -} - function FileContent(props: { readonly activeMode: FileViewMode; readonly previewUri: string | null; @@ -270,6 +96,7 @@ function FileContent(props: { readonly relativePath: string; readonly initialLine: number | null; readonly truncated: boolean; + readonly onRefresh?: () => Promise | void; }) { const isMarkdown = isMarkdownPreviewFile(props.relativePath); const isBrowserFile = isBrowserPreviewFile(props.relativePath); @@ -293,7 +120,7 @@ function FileContent(props: { if (props.fileError && props.fileContents === null) { return ( - + ); @@ -301,7 +128,7 @@ function FileContent(props: { if (props.fileContents === null) { return ( - + Loading file... @@ -309,35 +136,47 @@ function FileContent(props: { } return ( - + {props.truncated ? ( Partial file - + Preview limited to the first 1 MB of a truncated file. ) : null} {props.activeMode === "preview" && isMarkdown ? ( - + ) : ( )} ); } -function useThreadFilesWorkspace() { - const params = useLocalSearchParams<{ - environmentId?: string | string[]; - threadId?: string | string[]; - }>(); +type ThreadFilesRouteScreenProps = StaticScreenProps<{ + readonly environmentId: string; + readonly threadId: string; +}>; + +type ThreadFileRouteScreenProps = StaticScreenProps<{ + readonly environmentId: string; + readonly threadId: string; + readonly path: string[]; + readonly line?: string; +}>; + +function useThreadFilesWorkspace(params: { + readonly environmentId?: string | string[]; + readonly threadId?: string | string[]; +}) { const routeEnvironmentId = firstRouteParam(params.environmentId); const routeThreadId = firstRouteParam(params.threadId); const { selectedThread, selectedThreadProject } = useThreadSelection(); @@ -364,7 +203,7 @@ function useThreadFilesWorkspace() { function FilesUnavailable() { return ( - + - - Files - - - {props.projectName} - - - ); -} - function FilesToolbarBottomFade() { const sheetColor = String(useThemeColor("--color-sheet")); @@ -442,12 +247,20 @@ function FilesToolbarBottomFade() { ); } -export function ThreadFilesTreeScreen() { - const router = useRouter(); +export function ThreadFilesTreeScreen(props: ThreadFilesRouteScreenProps) { + useAdaptiveWorkspacePaneRole("inspector"); + const navigation = useNavigation(); + const { fileInspector, layout, panes, showAuxiliaryPane, togglePrimarySidebar } = + useAdaptiveWorkspaceLayout(); const [searchQuery, setSearchQuery] = useState(""); - const { cwd, environmentId, projectName, selectedThread, threadId } = useThreadFilesWorkspace(); + const colorScheme = useColorScheme(); + const highlightTheme = colorScheme === "dark" ? "dark" : "light"; + const { cwd, environmentId, projectName, selectedThread, threadId } = useThreadFilesWorkspace( + props.route.params, + ); + const revealedInspectorRef = useRef(false); const entriesQuery = useEnvironmentQuery( - environmentId !== null && cwd !== null + environmentId !== null && cwd !== null && !fileInspector.supported ? projectEnvironment.listEntries({ environmentId, input: { cwd }, @@ -455,18 +268,87 @@ export function ThreadFilesTreeScreen() { : null, ); const entriesData = entriesQuery.data as ProjectListEntriesResult | null; + const handleReturnToThread = useCallback(() => { + if (navigation.canGoBack()) { + navigation.goBack(); + return; + } + if (environmentId !== null && threadId !== null) { + navigation.dispatch( + StackActions.replace("Thread", { + environmentId: String(environmentId), + threadId: String(threadId), + }), + ); + } + }, [environmentId, navigation, threadId]); const handleSelectFile = useCallback( (path: string) => { if (environmentId === null || threadId === null) { return; } - router.push(buildThreadFilesNavigation({ environmentId, threadId }, path)); + const params = { + environmentId: String(environmentId), + threadId: String(threadId), + path: path.split("/").filter((segment) => segment.length > 0), + }; + const navigationAction = resolveFileSelectionNavigationAction({ + hasPersistentFileInspector: fileInspector.supported, + }); + if (navigationAction === "replace") { + navigation.dispatch(StackActions.replace("ThreadFile", params)); + return; + } + navigation.navigate("ThreadFile", params); }, - [environmentId, router, threadId], + [environmentId, fileInspector.supported, navigation, threadId], ); + const renderInspector = useCallback( + (headerInset: number) => + environmentId !== null && cwd !== null ? ( + + ) : null, + [cwd, environmentId, handleSelectFile, projectName], + ); + const handlePreviewFile = useCallback( + (relativePath: string) => { + if (environmentId === null || cwd === null) { + return; + } + preloadWorkspaceFileContents({ + cwd, + environmentId, + relativePath, + theme: highlightTheme, + }); + }, + [cwd, environmentId, highlightTheme], + ); + useEffect(() => { + if (fileInspector.supported && cwd !== null && !revealedInspectorRef.current) { + revealedInspectorRef.current = true; + showAuxiliaryPane("inspector"); + } + }, [cwd, fileInspector.supported, showAuxiliaryPane]); if (selectedThread === null || environmentId === null || threadId === null) { + if (fileInspector.supported) { + return ( + + ); + } return ; } @@ -474,62 +356,95 @@ export function ThreadFilesTreeScreen() { return ; } + if (fileInspector.supported) { + return ( + + ); + } + + const usesCompactMailToolbar = Platform.OS === "ios" && !layout.usesSplitView; + return ( - - + {/* Static header config (glass preset, title, contentStyle) lives in Stack.tsx. + Only genuinely dynamic options are set here. */} + , - headerSearchBarOptions: { - allowToolbarIntegration: true, - autoCapitalize: "none", - hideNavigationBar: false, - placeholder: "Search files", - onChangeText: (event) => { - setSearchQuery(event.nativeEvent.text); - }, - onCancelButtonPress: () => { - setSearchQuery(""); - }, - }, + unstable_headerSubtitle: + Platform.OS === "ios" && projectName.length > 0 ? projectName : undefined, + // No refresh button: the list already supports pull-to-refresh. + unstable_headerToolbarItems: usesCompactMailToolbar + ? () => [ + createNativeMailSearchToolbarItem({ + onSearchTextChange: setSearchQuery, + placeholder: "Search files", + searchTextChangeId: "files-search-text", + }), + ] + : undefined, + headerSearchBarOptions: usesCompactMailToolbar + ? undefined + : { + allowToolbarIntegration: true, + autoCapitalize: "none", + hideNavigationBar: false, + placeholder: "Search files", + onChangeText: (event) => { + setSearchQuery(event.nativeEvent.text); + }, + onCancelButtonPress: () => { + setSearchQuery(""); + }, + }, }} /> - - - - - - + {layout.usesSplitView ? ( + + + + ) : null} + {usesCompactMailToolbar ? null : ( + + + + )} - + ); } -export function ThreadFileScreen() { - const params = useLocalSearchParams<{ - line?: string | string[]; - path?: string | string[]; - }>(); +export function ThreadFileScreen(props: ThreadFileRouteScreenProps) { + useAdaptiveWorkspacePaneRole("inspector"); + const navigation = useNavigation(); + const { fileInspector, panes, toggleAuxiliaryPane } = useAdaptiveWorkspaceLayout(); + const iconColor = useThemeColor("--color-icon"); + const params = props.route.params; const relativePath = normalizeRoutePath(params.path); const targetLine = normalizeRouteLine(firstRouteParam(params.line)); - const { cwd, environmentId, projectName, selectedThread, threadId } = useThreadFilesWorkspace(); + const { cwd, environmentId, projectName, selectedThread, threadId } = useThreadFilesWorkspace( + props.route.params, + ); const [modeOverride, setModeOverride] = useState<{ readonly path: string; readonly mode: FileViewMode; @@ -568,6 +483,43 @@ export function ThreadFileScreen() { ); const fileData = fileQuery.data as ProjectReadFileResult | null; + const handleSelectFile = useCallback( + (path: string) => { + navigation.navigate("ThreadFile", { + environmentId: String(environmentId), + threadId: String(threadId), + path: path.split("/").filter(Boolean), + }); + }, + [environmentId, navigation, threadId], + ); + const renderInspector = useCallback( + (headerInset: number) => + fileInspector.supported && environmentId !== null && cwd !== null ? ( + + ) : undefined, + [cwd, environmentId, fileInspector.supported, handleSelectFile, projectName, relativePath], + ); + // The workspace inspector column spans the full window height. On iOS the + // pane brings its own nested native header; elsewhere it pads itself below + // the top inset. + const safeAreaInsets = useSafeAreaInsets(); + const inspectorHeaderInset = Platform.OS === "ios" ? 0 : safeAreaInsets.top; + // Hand the file navigator to the workspace so it renders beside the + // navigator, outside this screen's native header. + const renderWorkspaceInspector = useCallback( + () => renderInspector(inspectorHeaderInset), + [inspectorHeaderInset, renderInspector], + ); + useRegisterWorkspaceInspector(fileInspector.supported ? renderWorkspaceInspector : undefined); + if (selectedThread === null || environmentId === null || threadId === null) { return ; } @@ -579,38 +531,104 @@ export function ThreadFileScreen() { if (relativePath === null) { return ( - + ); } + const parentDir = relativePath.split("/").slice(0, -1).join("/"); + const headerSubtitle = [projectName, parentDir].filter(Boolean).join(" · "); + return ( - - - { - if (resolvedActiveMode === "preview" && (isBrowserFile || isImageFile)) { - setPreviewRevision((current) => current + 1); - return; - } - fileQuery.refresh(); - }} - /> - - { - setModeOverride({ path: relativePath, mode }); + 0 ? headerSubtitle : undefined, }} /> + + {fileInspector.supported ? ( + { + navigation.dispatch( + StackActions.replace("Thread", { + environmentId: String(environmentId), + threadId: String(threadId), + }), + ); + }} + /> + ) : null} + + + {fileInspector.supported ? ( + + ) : null} + + {canPreview && !isImageFile ? ( + + setModeOverride({ path: relativePath, mode: "preview" })} + > + Preview + + setModeOverride({ path: relativePath, mode: "source" })} + > + Source + + + ) : null} + copyTextWithHaptic(relativePath)} + > + Copy path + + {isBrowserFile && typeof assetPreviewUri === "string" ? ( + { + void tryOpenExternalUrl(assetPreviewUri, "file-preview"); + }} + > + Open in Safari + + ) : null} + {resolvedActiveMode === "preview" && (isBrowserFile || isImageFile) ? ( + { + setPreviewRevision((current) => current + 1); + }} + > + Refresh + + ) : null} + + fileQuery.refresh()} /> diff --git a/apps/mobile/src/features/files/WorkspaceFileWebPreview.tsx b/apps/mobile/src/features/files/WorkspaceFileWebPreview.tsx index 6d03a23d52a..efa7ee88cba 100644 --- a/apps/mobile/src/features/files/WorkspaceFileWebPreview.tsx +++ b/apps/mobile/src/features/files/WorkspaceFileWebPreview.tsx @@ -24,7 +24,7 @@ export function WorkspaceFileWebPreview(props: { readonly uri: string | null }) {loadError ? ( Preview failed - {loadError} + {loadError} ) : null} >(); +const MAX_HIGHLIGHT_PRELOAD_CHARACTERS = 256 * 1024; + +function preloadKey(input: { + readonly cwd: string; + readonly environmentId: EnvironmentId; + readonly relativePath: string; +}): string { + return JSON.stringify([input.environmentId, input.cwd, input.relativePath]); +} + +export function preloadWorkspaceFileContents(input: { + readonly cwd: string; + readonly environmentId: EnvironmentId; + readonly relativePath: string; + readonly theme: ReviewDiffTheme; +}): void { + if (isBrowserPreviewFile(input.relativePath) || isImagePreviewFile(input.relativePath)) { + return; + } + + const key = preloadKey(input); + if (inFlightPreloads.has(key)) { + return; + } + + const preload = executeAtomQuery( + appAtomRegistry, + projectEnvironment.readFile({ + environmentId: input.environmentId, + input: { cwd: input.cwd, relativePath: input.relativePath }, + }), + { + label: "workspace file preload", + reportDefect: false, + reportFailure: false, + }, + ) + .then(async (result) => { + if (result._tag === "Success") { + const document = prepareSourceFileDocument(result.value.contents); + if (document.contents.length <= MAX_HIGHLIGHT_PRELOAD_CHARACTERS) { + await executeAtomQuery( + appAtomRegistry, + sourceHighlightAtom({ + path: input.relativePath, + contents: document.contents, + theme: input.theme, + }), + { + label: "workspace source highlight preload", + reportDefect: false, + reportFailure: false, + }, + ); + } + } + }) + .finally(() => { + inFlightPreloads.delete(key); + }); + + inFlightPreloads.set(key, preload); +} diff --git a/apps/mobile/src/features/files/source-file-document.test.ts b/apps/mobile/src/features/files/source-file-document.test.ts new file mode 100644 index 00000000000..04b3046994a --- /dev/null +++ b/apps/mobile/src/features/files/source-file-document.test.ts @@ -0,0 +1,16 @@ +import { describe, expect, it } from "vite-plus/test"; + +import { prepareSourceFileDocument } from "./source-file-document"; + +describe("prepareSourceFileDocument", () => { + it("normalizes and serializes source rows once for repeated consumers", () => { + const first = prepareSourceFileDocument("const value = 1;\r\n\tvalue;\r"); + const second = prepareSourceFileDocument("const value = 1;\r\n\tvalue;\r"); + const rows = JSON.parse(first.rowsJson) as ReadonlyArray<{ readonly content: string }>; + + expect(first.contents).toBe("const value = 1;\n\tvalue;\n"); + expect(first.lines).toEqual(["const value = 1;", "\tvalue;", ""]); + expect(rows.map((row) => row.content)).toEqual(["const value = 1;", " value;", ""]); + expect(second).toBe(first); + }); +}); diff --git a/apps/mobile/src/features/files/source-file-document.ts b/apps/mobile/src/features/files/source-file-document.ts new file mode 100644 index 00000000000..d78ead3288f --- /dev/null +++ b/apps/mobile/src/features/files/source-file-document.ts @@ -0,0 +1,54 @@ +import { buildNativeSourceRows } from "./nativeSourceFileAdapter"; + +const MAX_CACHED_DOCUMENTS = 8; +const MAX_CACHED_CHARACTERS = 4 * 1024 * 1024; + +export interface SourceFileDocument { + readonly contents: string; + readonly lines: ReadonlyArray; + readonly rowsJson: string; +} + +const documentCache = new Map(); +let cachedCharacterCount = 0; + +function removeOldestCachedDocument(): void { + const oldestKey = documentCache.keys().next().value; + if (typeof oldestKey !== "string") { + return; + } + const document = documentCache.get(oldestKey); + documentCache.delete(oldestKey); + cachedCharacterCount -= (document?.contents.length ?? 0) + (document?.rowsJson.length ?? 0); +} + +export function prepareSourceFileDocument(contents: string): SourceFileDocument { + const cached = documentCache.get(contents); + if (cached !== undefined) { + documentCache.delete(contents); + documentCache.set(contents, cached); + return cached; + } + + const normalizedContents = contents.replace(/\r\n?/g, "\n"); + const lines = normalizedContents.split("\n"); + const document = { + contents: normalizedContents, + lines, + rowsJson: JSON.stringify(buildNativeSourceRows(lines)), + } satisfies SourceFileDocument; + const characterCount = document.contents.length + document.rowsJson.length; + + if (characterCount <= MAX_CACHED_CHARACTERS) { + while ( + documentCache.size >= MAX_CACHED_DOCUMENTS || + cachedCharacterCount + characterCount > MAX_CACHED_CHARACTERS + ) { + removeOldestCachedDocument(); + } + documentCache.set(contents, document); + cachedCharacterCount += characterCount; + } + + return document; +} diff --git a/apps/mobile/src/features/files/sourceHighlightingState.test.ts b/apps/mobile/src/features/files/sourceHighlightingState.test.ts index 6c4c00e1663..50b4eec41b0 100644 --- a/apps/mobile/src/features/files/sourceHighlightingState.test.ts +++ b/apps/mobile/src/features/files/sourceHighlightingState.test.ts @@ -88,7 +88,7 @@ describe("sourceHighlightingState", () => { expect(AsyncResult.isSuccess(registry.get(atom))).toBe(true); }); firstUnmount(); - await new Promise((resolve) => setTimeout(resolve, 25)); + await new Promise((resolve) => setTimeout(resolve, 100)); const secondUnmount = registry.mount(atom); await vi.waitFor(() => { diff --git a/apps/mobile/src/features/files/thread-file-navigator-pane.tsx b/apps/mobile/src/features/files/thread-file-navigator-pane.tsx new file mode 100644 index 00000000000..f0fa6920c27 --- /dev/null +++ b/apps/mobile/src/features/files/thread-file-navigator-pane.tsx @@ -0,0 +1,175 @@ +import type { EnvironmentId, ProjectListEntriesResult } from "@t3tools/contracts"; +import { SymbolView } from "expo-symbols"; +import { useCallback, useMemo, useState, type ComponentProps } from "react"; +import { Platform, Pressable, useColorScheme, View, type NativeSyntheticEvent } from "react-native"; +import { + Screen, + ScreenStack, + ScreenStackHeaderConfig, + ScreenStackHeaderSearchBarView, + SearchBar, +} from "react-native-screens"; + +import { AppText as Text, AppTextInput as TextInput } from "../../components/AppText"; +import { nativeHeaderScrollEdgeEffects } from "../../native/StackHeader"; +import { useThemeColor } from "../../lib/useThemeColor"; +import { projectEnvironment } from "../../state/projects"; +import { useEnvironmentQuery } from "../../state/query"; +import { FileTreeBrowser } from "./FileTreeBrowser"; +import { preloadWorkspaceFileContents } from "./preload-workspace-file"; + +export function ThreadFileNavigatorPane(props: { + readonly cwd: string; + readonly environmentId: EnvironmentId; + readonly headerInset: number; + readonly projectName: string; + readonly selectedPath: string | null; + readonly onSelectFile: (path: string) => void; +}) { + const [searchQuery, setSearchQuery] = useState(""); + const colorScheme = useColorScheme(); + const highlightTheme = colorScheme === "dark" ? "dark" : "light"; + const iconColor = String(useThemeColor("--color-icon-muted")); + const foregroundColor = String(useThemeColor("--color-foreground")); + const sheetColor = String(useThemeColor("--color-sheet")); + const headerScrollEdgeEffects = nativeHeaderScrollEdgeEffects(Platform.OS, Platform.Version); + const entriesQuery = useEnvironmentQuery( + projectEnvironment.listEntries({ + environmentId: props.environmentId, + input: { cwd: props.cwd }, + }), + ); + const entriesData = entriesQuery.data as ProjectListEntriesResult | null; + const handlePreviewFile = useCallback( + (relativePath: string) => { + preloadWorkspaceFileContents({ + cwd: props.cwd, + environmentId: props.environmentId, + relativePath, + theme: highlightTheme, + }); + }, + [highlightTheme, props.cwd, props.environmentId], + ); + const nativeHeaderRightBarButtonItems = useMemo( + () => + [ + { + accessibilityLabel: "Refresh files", + icon: { name: "arrow.clockwise", type: "sfSymbol" as const }, + identifier: "thread-file-navigator-refresh", + onPress: entriesQuery.refresh, + sharesBackground: false, + tintColor: foregroundColor, + type: "button" as const, + width: 44, + }, + ] as ComponentProps["headerRightBarButtonItems"], + [entriesQuery.refresh, foregroundColor], + ); + + const fileTree = ( + + ); + + if (Platform.OS === "ios") { + return ( + + + + {fileTree} + + + { + setSearchQuery(""); + }} + onChangeText={(event: NativeSyntheticEvent<{ readonly text?: string }>) => { + setSearchQuery(event.nativeEvent.text ?? ""); + }} + placement="integratedButton" + placeholder="Search files" + textColor={foregroundColor} + tintColor={foregroundColor} + /> + + + + + + ); + } + + return ( + + + + + Files + + {props.projectName} + + + + + + + + + + + + {fileTree} + + ); +} diff --git a/apps/mobile/src/features/home/HomeHeader.tsx b/apps/mobile/src/features/home/HomeHeader.tsx index 9757d5fbf91..652dc02fccb 100644 --- a/apps/mobile/src/features/home/HomeHeader.tsx +++ b/apps/mobile/src/features/home/HomeHeader.tsx @@ -3,60 +3,30 @@ import type { SidebarProjectGroupingMode, SidebarThreadSortOrder, } from "@t3tools/contracts"; -import { - DEFAULT_SIDEBAR_PROJECT_GROUPING_MODE, - DEFAULT_SIDEBAR_PROJECT_SORT_ORDER, - DEFAULT_SIDEBAR_THREAD_SORT_ORDER, -} from "@t3tools/contracts"; -import { Stack } from "expo-router"; -import { Text as RNText, View } from "react-native"; +import { NativeHeaderToolbar, NativeStackScreenOptions } from "../../native/StackHeader"; +import { useCallback, useRef } from "react"; +import { Platform } from "react-native"; +import type { SearchBarCommands } from "react-native-screens"; +import { nativeHeaderScrollEdgeEffects } from "../../native/StackHeader"; import { useThemeColor } from "../../lib/useThemeColor"; -import { MOBILE_TYPOGRAPHY } from "../../lib/typography"; +import { useHardwareKeyboardCommand } from "../keyboard/hardwareKeyboardCommands"; +import { withNativeGlassHeaderItem } from "../layout/native-glass-header-items"; +import { createNativeMailSearchToolbarItem } from "../layout/native-mail-search-toolbar"; import type { HomeProjectSortOrder } from "./homeThreadList"; +import { + buildHomeListFilterMenu, + type HomeListFilterMenuEnvironment, +} from "./home-list-filter-menu"; +import { + hasCustomHomeListOptions, + PROJECT_GROUPING_OPTIONS, + PROJECT_SORT_OPTIONS, + THREAD_SORT_OPTIONS, +} from "./home-list-options"; -export interface HomeHeaderEnvironment { - readonly environmentId: EnvironmentId; - readonly label: string; -} - -const PROJECT_SORT_OPTIONS: ReadonlyArray<{ - readonly value: HomeProjectSortOrder; - readonly label: string; -}> = [ - { value: "updated_at", label: "Last user message" }, - { value: "created_at", label: "Created at" }, -]; - -const THREAD_SORT_OPTIONS: ReadonlyArray<{ - readonly value: SidebarThreadSortOrder; - readonly label: string; -}> = [ - { value: "updated_at", label: "Last user message" }, - { value: "created_at", label: "Created at" }, -]; - -const PROJECT_GROUPING_OPTIONS: ReadonlyArray<{ - readonly value: SidebarProjectGroupingMode; - readonly label: string; - readonly subtitle: string; -}> = [ - { - value: "repository", - label: "Group by repository", - subtitle: "Combine matching repositories across environments", - }, - { - value: "repository_path", - label: "Group by repository path", - subtitle: "Combine only matching paths within a repository", - }, - { - value: "separate", - label: "Keep separate", - subtitle: "Show every project path separately", - }, -]; +export type HomeHeaderEnvironment = HomeListFilterMenuEnvironment; +const HEADER_SCROLL_EDGE_EFFECTS = nativeHeaderScrollEdgeEffects(Platform.OS, Platform.Version); export function HomeHeader(props: { readonly environments: ReadonlyArray; @@ -72,174 +42,170 @@ export function HomeHeader(props: { readonly onOpenSettings: () => void; readonly onStartNewTask: () => void; }) { + const searchBarRef = useRef(null); const iconColor = useThemeColor("--color-icon"); - const mutedColor = useThemeColor("--color-foreground-muted"); - const subtleColor = useThemeColor("--color-subtle"); - const hasCustomListOptions = - props.selectedEnvironmentId !== null || - props.projectSortOrder !== DEFAULT_SIDEBAR_PROJECT_SORT_ORDER || - props.threadSortOrder !== DEFAULT_SIDEBAR_THREAD_SORT_ORDER || - props.projectGroupingMode !== DEFAULT_SIDEBAR_PROJECT_GROUPING_MODE; + const hasCustomListOptions = hasCustomHomeListOptions(props); + const focusSearch = useCallback(() => { + searchBarRef.current?.focus(); + return searchBarRef.current !== null; + }, []); + useHardwareKeyboardCommand("focusSearch", focusSearch); + const filterMenu = buildHomeListFilterMenu(props); return ( <> - { - props.onSearchQueryChange(event.nativeEvent.text); - }, - onCancelButtonPress: () => { - props.onSearchQueryChange(""); - }, - allowToolbarIntegration: true, - }, + unstable_headerRightItems: + Platform.OS === "ios" + ? () => [ + withNativeGlassHeaderItem({ + accessibilityLabel: "Open settings", + icon: { name: "ellipsis", type: "sfSymbol" } as const, + identifier: "home-settings", + label: "", + onPress: props.onOpenSettings, + type: "button", + }), + ] + : undefined, + unstable_headerToolbarItems: + Platform.OS === "ios" + ? () => [ + createNativeMailSearchToolbarItem({ + composeButtonId: "home-new-task", + composeSystemImageName: "square.and.pencil", + filterMenu, + filterButtonId: "home-filter", + filterSystemImageName: hasCustomListOptions + ? "line.3.horizontal.decrease.circle.fill" + : "line.3.horizontal.decrease", + onComposePress: props.onStartNewTask, + onSearchTextChange: props.onSearchQueryChange, + placeholder: "Search", + searchTextChangeId: "home-search-text", + }), + ] + : undefined, + headerSearchBarOptions: + Platform.OS === "ios" + ? undefined + : { + ref: searchBarRef, + allowToolbarIntegration: true, + hideNavigationBar: false, + placeholder: "Search", + onCancelButtonPress: () => { + props.onSearchQueryChange(""); + }, + onChangeText: (event) => { + props.onSearchQueryChange(event.nativeEvent.text); + }, + }, }} /> - - - - - T3 Code - - - - Alpha - - - - - - - - - - Environment - props.onEnvironmentChange(null)} - subtitle="Show threads from every environment" - > - All environments - - {props.environments.map((environment) => ( - props.onEnvironmentChange(environment.environmentId)} - > - {environment.label} - - ))} - + {Platform.OS === "ios" ? null : ( + + + + )} - - Sort projects - {PROJECT_SORT_OPTIONS.map((option) => ( - props.onProjectSortOrderChange(option.value)} - > - {option.label} - - ))} - + {Platform.OS === "ios" ? null : ( + + + + Settings + - - Sort threads - {THREAD_SORT_OPTIONS.map((option) => ( - props.onThreadSortOrderChange(option.value)} + + Environment + props.onEnvironmentChange(null)} + subtitle="Show threads from every environment" > - {option.label} - - ))} - + All environments + + {props.environments.map((environment) => ( + props.onEnvironmentChange(environment.environmentId)} + > + {environment.label} + + ))} + - - Group projects - {PROJECT_GROUPING_OPTIONS.map((option) => ( - props.onProjectGroupingModeChange(option.value)} - subtitle={option.subtitle} - > - {option.label} - - ))} - - + + Sort projects + {PROJECT_SORT_OPTIONS.map((option) => ( + props.onProjectSortOrderChange(option.value)} + > + {option.label} + + ))} + - - + + Sort threads + {THREAD_SORT_OPTIONS.map((option) => ( + props.onThreadSortOrderChange(option.value)} + > + {option.label} + + ))} + - - - - - + + Group projects + {PROJECT_GROUPING_OPTIONS.map((option) => ( + props.onProjectGroupingModeChange(option.value)} + subtitle={option.subtitle} + > + {option.label} + + ))} + + + + + + + + )} ); } diff --git a/apps/mobile/src/features/home/HomeRouteScreen.tsx b/apps/mobile/src/features/home/HomeRouteScreen.tsx new file mode 100644 index 00000000000..52b2682265d --- /dev/null +++ b/apps/mobile/src/features/home/HomeRouteScreen.tsx @@ -0,0 +1,132 @@ +import * as Arr from "effect/Array"; +import * as Order from "effect/Order"; +import { useNavigation } from "@react-navigation/native"; +import { useMemo, useState } from "react"; + +import { NativeHeaderToolbar, NativeStackScreenOptions } from "../../native/StackHeader"; +import { useProjects, useThreadShells } from "../../state/entities"; +import { useWorkspaceState } from "../../state/workspace"; +import { useSavedRemoteConnections } from "../../state/use-remote-environment-registry"; +import { useAdaptiveWorkspaceLayout } from "../layout/AdaptiveWorkspaceLayout"; +import { WorkspaceEmptyDetail } from "../layout/WorkspaceEmptyDetail"; +import { WorkspaceSidebarToolbar } from "../layout/workspace-sidebar-toolbar"; +import { HomeScreen } from "./HomeScreen"; +import { HomeHeader } from "./HomeHeader"; +import { useHomeListOptions } from "./home-list-options"; +import { useThreadListActions } from "./useThreadListActions"; + +/* ─── Route screen ───────────────────────────────────────────────────── */ + +export function HomeRouteScreen() { + const { layout } = useAdaptiveWorkspaceLayout(); + const projects = useProjects(); + const threads = useThreadShells(); + const { state: catalogState } = useWorkspaceState(); + const { savedConnectionsById } = useSavedRemoteConnections(); + const navigation = useNavigation(); + const [searchQuery, setSearchQuery] = useState(""); + const { archiveThread, confirmDeleteThread } = useThreadListActions(); + const environments = useMemo( + () => + Arr.sort( + Object.values(savedConnectionsById).map((connection) => ({ + environmentId: connection.environmentId, + label: connection.environmentLabel, + })), + Order.mapInput( + Order.String, + (environment: { readonly label: string }) => environment.label, + ), + ), + [savedConnectionsById], + ); + const availableEnvironmentIds = useMemo( + () => new Set(environments.map((environment) => environment.environmentId)), + [environments], + ); + const { + options: listOptions, + setSelectedEnvironmentId, + setProjectGroupingMode, + setProjectSortOrder, + setThreadSortOrder, + } = useHomeListOptions(availableEnvironmentIds); + const selectedEnvironmentId = listOptions.selectedEnvironmentId; + + // In split layouts the persistent sidebar IS the thread list — Home becomes + // an empty detail pane so selecting a thread never transitions layouts. + if (layout.usesSplitView) { + return ( + <> + + navigation.navigate("NewTaskSheet", { screen: "NewTask" })} + /> + } + /> + navigation.navigate("NewTaskSheet", { screen: "NewTask" })} + /> + + ); + } + + return ( + <> + {/* Restore the compact title in case the split branch blanked it. */} + + navigation.navigate("SettingsSheet", { screen: "Settings" })} + onProjectGroupingModeChange={setProjectGroupingMode} + onProjectSortOrderChange={setProjectSortOrder} + onSearchQueryChange={setSearchQuery} + onStartNewTask={() => navigation.navigate("NewTaskSheet", { screen: "NewTask" })} + onThreadSortOrderChange={setThreadSortOrder} + /> + + + navigation.navigate("SettingsSheet", { screen: "SettingsEnvironmentNew" }) + } + onArchiveThread={archiveThread} + onDeleteThread={confirmDeleteThread} + onEnvironmentChange={setSelectedEnvironmentId} + onOpenEnvironments={() => + navigation.navigate("SettingsSheet", { screen: "SettingsEnvironments" }) + } + onOpenSettings={() => navigation.navigate("SettingsSheet", { screen: "Settings" })} + onProjectGroupingModeChange={setProjectGroupingMode} + onProjectSortOrderChange={setProjectSortOrder} + onSearchQueryChange={setSearchQuery} + onSelectThread={(thread) => { + navigation.navigate("Thread", { + environmentId: thread.environmentId, + threadId: thread.id, + }); + }} + onStartNewTask={() => navigation.navigate("NewTaskSheet", { screen: "NewTask" })} + onThreadSortOrderChange={setThreadSortOrder} + projectGroupingMode={listOptions.projectGroupingMode} + projects={projects} + projectSortOrder={listOptions.projectSortOrder} + savedConnectionsById={savedConnectionsById} + searchQuery={searchQuery} + selectedEnvironmentId={selectedEnvironmentId} + threads={threads} + threadSortOrder={listOptions.threadSortOrder} + /> + + ); +} diff --git a/apps/mobile/src/features/home/HomeScreen.tsx b/apps/mobile/src/features/home/HomeScreen.tsx index 7ee5660edf1..5418fc9596a 100644 --- a/apps/mobile/src/features/home/HomeScreen.tsx +++ b/apps/mobile/src/features/home/HomeScreen.tsx @@ -1,3 +1,8 @@ +import { + LegendList, + type LegendListRef, + type LegendListRenderItemProps, +} from "@legendapp/list/react-native"; import { type EnvironmentProject, type EnvironmentThreadShell, @@ -7,36 +12,35 @@ import type { SidebarProjectGroupingMode, SidebarThreadSortOrder, } from "@t3tools/contracts"; -import * as Haptics from "expo-haptics"; -import { SymbolView } from "expo-symbols"; import { useCallback, useMemo, useRef, useState } from "react"; -import { ActivityIndicator, Pressable, ScrollView, useWindowDimensions, View } from "react-native"; -import ReanimatedSwipeable, { - type SwipeableMethods, -} from "react-native-gesture-handler/ReanimatedSwipeable"; -import Animated, { - Easing, - LinearTransition, - type ExitAnimationsValues, - withDelay, - withTiming, -} from "react-native-reanimated"; +import { ActivityIndicator, Platform, View } from "react-native"; +import type { SwipeableMethods } from "react-native-gesture-handler/ReanimatedSwipeable"; import { useSafeAreaInsets } from "react-native-safe-area-context"; import { useThemeColor } from "../../lib/useThemeColor"; -import { AppText as Text } from "../../components/AppText"; import { EmptyState } from "../../components/EmptyState"; -import { ProjectFavicon } from "../../components/ProjectFavicon"; import type { WorkspaceState } from "../../state/workspaceModel"; import type { SavedRemoteConnection } from "../../lib/connection"; -import { relativeTime } from "../../lib/time"; -import { threadStatusTone } from "../threads/threadPresentation"; -import { buildHomeThreadGroups, type HomeProjectSortOrder } from "./homeThreadList"; +import { scopedProjectKey } from "../../lib/scopedEntities"; import { - THREAD_SWIPE_ACTIONS_WIDTH, - THREAD_SWIPE_SPRING, - ThreadSwipeActions, -} from "./thread-swipe-actions"; + ThreadListGroupHeader, + ThreadListRow, + ThreadListShowMoreRow, +} from "../threads/thread-list-items"; +import type { HomeListFilterMenuEnvironment } from "./home-list-filter-menu"; +import { + buildHomeListLayout, + DEFAULT_GROUP_DISPLAY_STATE, + homeListItemsAreEqual, + nextGroupDisplayState, + type HomeGroupDisplayAction, + type HomeGroupDisplayState, + type HomeListItem, +} from "./homeListItems"; +import { buildHomeThreadGroups, type HomeProjectSortOrder } from "./homeThreadList"; +import { SwipeableScrollGateProvider, useSwipeableScrollGate } from "./thread-swipe-actions"; +import { WorkspaceConnectionStatus } from "./WorkspaceConnectionStatus"; +import { shouldShowWorkspaceConnectionStatus } from "./workspace-connection-status"; /* ─── Types ──────────────────────────────────────────────────────────── */ @@ -45,63 +49,31 @@ interface HomeScreenProps { readonly threads: ReadonlyArray; readonly catalogState: WorkspaceState; readonly savedConnectionsById: Readonly>; + readonly environments: ReadonlyArray; readonly searchQuery: string; readonly selectedEnvironmentId: EnvironmentId | null; readonly projectSortOrder: HomeProjectSortOrder; readonly threadSortOrder: SidebarThreadSortOrder; readonly projectGroupingMode: SidebarProjectGroupingMode; + readonly onSearchQueryChange: (query: string) => void; + readonly onEnvironmentChange: (environmentId: EnvironmentId | null) => void; + readonly onProjectSortOrderChange: (sortOrder: HomeProjectSortOrder) => void; + readonly onThreadSortOrderChange: (sortOrder: SidebarThreadSortOrder) => void; + readonly onProjectGroupingModeChange: (mode: SidebarProjectGroupingMode) => void; readonly onAddConnection: () => void; readonly onOpenEnvironments: () => void; + readonly onOpenSettings: () => void; + readonly onStartNewTask: () => void; readonly onSelectThread: (thread: EnvironmentThreadShell) => void; readonly onArchiveThread: (thread: EnvironmentThreadShell) => void; readonly onDeleteThread: (thread: EnvironmentThreadShell) => void; } -/* ─── Status indicator colors ────────────────────────────────────────── */ - -function statusColors(thread: EnvironmentThreadShell): { bg: string; fg: string } { - switch (thread.session?.status) { - case "running": - return { bg: "rgba(249,115,22,0.14)", fg: "#f97316" }; - case "ready": - return { bg: "rgba(34,197,94,0.14)", fg: "#22c55e" }; - case "starting": - return { bg: "rgba(59,130,246,0.14)", fg: "#3b82f6" }; - case "error": - return { bg: "rgba(239,68,68,0.14)", fg: "#ef4444" }; - default: - return { bg: "rgba(163,163,163,0.10)", fg: "#a3a3a3" }; - } -} +/* ─── Layout constants ───────────────────────────────────────────────── */ -const COLLAPSED_THREAD_LIMIT = 6; -const THREAD_LAYOUT_TRANSITION = LinearTransition.duration(220).easing(Easing.out(Easing.cubic)); - -function threadRowExit(values: ExitAnimationsValues) { - "worklet"; - - return { - initialValues: { - height: values.currentHeight, - opacity: 1, - originX: values.currentOriginX, - }, - animations: { - height: withDelay( - 90, - withTiming(0, { - duration: 170, - easing: Easing.inOut(Easing.cubic), - }), - ), - opacity: withDelay(80, withTiming(0, { duration: 100 })), - originX: withTiming(values.currentOriginX - values.windowWidth, { - duration: 190, - easing: Easing.out(Easing.cubic), - }), - }, - }; -} +const ESTIMATED_THREAD_ROW_HEIGHT = 72; +/** Height of the floating custom header on non-iOS platforms. */ +const CUSTOM_HEADER_HEIGHT = 78; function deriveEmptyState(props: { readonly catalogState: WorkspaceState; @@ -166,287 +138,28 @@ function deriveEmptyState(props: { }; } -/* ─── Project group header ───────────────────────────────────────────── */ - -function ProjectGroupLabel(props: { - readonly project: EnvironmentProject; - readonly title: string; - readonly totalThreadCount: number; - readonly isExpanded: boolean; - readonly onToggleExpand: () => void; -}) { - const hiddenCount = props.totalThreadCount - COLLAPSED_THREAD_LIMIT; - - return ( - - - - {props.title} - - - {hiddenCount > 0 ? ( - - - {props.isExpanded ? "Show less" : `${hiddenCount} more`} - - - ) : null} - - ); -} - -/* ─── Thread row ─────────────────────────────────────────────────────── */ - -function ThreadRow(props: { - readonly thread: EnvironmentThreadShell; - readonly environmentLabel: string | null; - readonly onPress: () => void; - readonly onArchive: () => void; - readonly onDelete: () => void; - readonly onSwipeableWillOpen: (methods: SwipeableMethods) => void; - readonly onSwipeableClose: (methods: SwipeableMethods) => void; - readonly isLast: boolean; -}) { - const swipeableRef = useRef(null); - const fullSwipeArmedRef = useRef(false); - const { width: windowWidth } = useWindowDimensions(); - const separatorColor = useThemeColor("--color-separator"); - const iconSubtleColor = useThemeColor("--color-icon-subtle"); - const cardColor = useThemeColor("--color-card"); - const fullSwipeThreshold = Math.max(THREAD_SWIPE_ACTIONS_WIDTH + 44, (windowWidth - 32) * 0.58); - const { bg, fg } = statusColors(props.thread); - const tone = threadStatusTone(props.thread); - const timestamp = relativeTime( - props.thread.latestUserMessageAt ?? props.thread.updatedAt ?? props.thread.createdAt, - ); - const branch = props.thread.branch; - const subtitleParts = [props.environmentLabel, branch].filter((part): part is string => - Boolean(part), - ); - const handleFullSwipeArmedChange = useCallback((armed: boolean) => { - if (armed && !fullSwipeArmedRef.current && process.env.EXPO_OS === "ios") { - void Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Medium); - } - fullSwipeArmedRef.current = armed; - }, []); - - return ( - { - fullSwipeArmedRef.current = false; - if (swipeableRef.current) { - props.onSwipeableClose(swipeableRef.current); - } - }} - onSwipeableOpenStartDrag={() => { - if (swipeableRef.current) { - props.onSwipeableWillOpen(swipeableRef.current); - } - }} - onSwipeableWillOpen={() => { - const methods = swipeableRef.current; - if (!methods) { - return; - } - - props.onSwipeableWillOpen(methods); - if (fullSwipeArmedRef.current) { - fullSwipeArmedRef.current = false; - methods.close(); - props.onDelete(); - } - }} - overshootFriction={1} - overshootRight - renderRightActions={(_progress, translation, methods) => ( - - )} - rightThreshold={THREAD_SWIPE_ACTIONS_WIDTH * 0.42} - > - { - swipeableRef.current?.close(); - props.onPress(); - }} - style={({ pressed }) => ({ opacity: pressed ? 0.7 : 1 })} - > - - - - - - - - - {props.thread.title} - - - - - {tone.label} - - - - {timestamp} - - - - - {subtitleParts.length > 0 ? ( - - - - {subtitleParts.join(" · ")} - - - ) : null} - - - - - ); +function HomeTopContentSpacer(props: { readonly topInset: number }) { + return ; } /* ─── Main screen ────────────────────────────────────────────────────── */ -function staleCatalogPillLabel(props: { readonly catalogState: WorkspaceState }): string { - if (props.catalogState.networkStatus === "offline") { - return "You are offline"; - } - const connectingEnvironments = props.catalogState.connectingEnvironments; - if (connectingEnvironments.length === 1) { - return `Reconnecting to ${connectingEnvironments[0]!.environmentLabel}`; - } - if (connectingEnvironments.length > 1) { - return `Reconnecting ${connectingEnvironments.length} environments`; - } - return "Not connected"; -} - -function StaleCatalogStatusPill(props: { - readonly catalogState: WorkspaceState; - readonly onPress: () => void; -}) { - const iconColor = useThemeColor("--color-icon-muted"); - const label = staleCatalogPillLabel(props); - const isReconnecting = props.catalogState.connectingEnvironments.length > 0; - - return ( - - {isReconnecting ? ( - - ) : ( - - )} - - {label} - - - ); -} - export function HomeScreen(props: HomeScreenProps) { - const [expandedProjects, setExpandedProjects] = useState>(() => new Set()); + const [groupDisplayStates, setGroupDisplayStates] = useState< + ReadonlyMap + >(() => new Map()); const openSwipeableRef = useRef(null); + const listRef = useRef(null); const insets = useSafeAreaInsets(); const accentColor = useThemeColor("--color-icon-muted"); - const toggleExpanded = useCallback((key: string) => { - setExpandedProjects((prev) => { - const next = new Set(prev); - if (next.has(key)) next.delete(key); - else next.add(key); + const updateGroupDisplay = useCallback((key: string, action: HomeGroupDisplayAction) => { + setGroupDisplayStates((previous) => { + const next = new Map(previous); + next.set( + key, + nextGroupDisplayState(previous.get(key) ?? DEFAULT_GROUP_DISPLAY_STATE, action), + ); return next; }); }, []); @@ -464,6 +177,13 @@ export function HomeScreen(props: HomeScreenProps) { } }, []); + const handleScrollBeginDrag = useCallback(() => { + openSwipeableRef.current?.close(); + }, []); + const { swipeEnabled, scrollGateHandlers } = useSwipeableScrollGate({ + onScrollBeginDrag: handleScrollBeginDrag, + }); + const projectGroups = useMemo( () => buildHomeThreadGroups({ @@ -486,6 +206,94 @@ export function HomeScreen(props: HomeScreenProps) { ], ); + const hasSearchQuery = props.searchQuery.trim().length > 0; + const listLayout = useMemo( + () => + buildHomeListLayout({ + groups: projectGroups, + displayStates: groupDisplayStates, + showAllThreads: hasSearchQuery, + }), + [projectGroups, groupDisplayStates, hasSearchQuery], + ); + + const projectCwdByKey = useMemo(() => { + const map = new Map(); + for (const project of props.projects) { + map.set(scopedProjectKey(project.environmentId, project.id), project.workspaceRoot); + } + return map; + }, [props.projects]); + + const extraData = useMemo( + () => ({ savedConnectionsById: props.savedConnectionsById, projectCwdByKey }), + [props.savedConnectionsById, projectCwdByKey], + ); + + const renderItem = useCallback( + ({ item }: LegendListRenderItemProps) => { + switch (item.type) { + case "header": + return ( + + ); + case "thread": { + const thread = item.thread; + return ( + + ); + } + case "show-more": + return ( + + ); + } + }, + [ + handleSwipeableClose, + handleSwipeableWillOpen, + projectCwdByKey, + props.onArchiveThread, + props.onDeleteThread, + props.onSelectThread, + props.savedConnectionsById, + updateGroupDisplay, + ], + ); + + const keyExtractor = useCallback((item: HomeListItem) => item.key, []); + /* Empty states */ const hasAnyThreads = props.threads.some((thread) => thread.archivedAt === null); const hasResults = projectGroups.length > 0; @@ -494,127 +302,121 @@ export function HomeScreen(props: HomeScreenProps) { ? null : (props.savedConnectionsById[props.selectedEnvironmentId]?.environmentLabel ?? "this environment"); - const hasSearchQuery = props.searchQuery.trim().length > 0; - const shouldShowConnectionStatus = - props.catalogState.networkStatus === "offline" || - props.catalogState.hasConnectingEnvironment || - (props.catalogState.hasLoadedShellSnapshot && !props.catalogState.hasReadyEnvironment); + const shouldShowConnectionStatus = shouldShowWorkspaceConnectionStatus(props.catalogState); const emptyState = deriveEmptyState({ catalogState: props.catalogState, projectCount: props.projects.length, }); - - return ( - - openSwipeableRef.current?.close()} - className="flex-1" - contentContainerStyle={{ - paddingHorizontal: 16, - paddingTop: 8, - paddingBottom: 24, - gap: 20, + const connectionStatus = + shouldShowConnectionStatus && Platform.OS !== "ios" ? ( + + + + ) : null; + + if (!hasAnyThreads) { + return ( + - {!hasAnyThreads ? ( - - - {emptyState.loading ? ( - - - - ) : null} - - ) : !hasResults && hasSearchQuery ? ( - - ) : !hasResults && selectedEnvironmentLabel ? ( - - ) : !hasResults ? ( + - ) : ( - projectGroups.map((group) => { - const isExpanded = expandedProjects.has(group.key); - const visibleThreads = isExpanded - ? group.threads - : group.threads.slice(0, COLLAPSED_THREAD_LIMIT); + {emptyState.loading ? ( + + + + ) : null} + + {connectionStatus} + + ); + } - return ( - - toggleExpanded(group.key)} - project={group.representative} - title={group.title} - totalThreadCount={group.threads.length} - /> - - {visibleThreads.map((thread, i) => { - const threadKey = `${thread.environmentId}:${thread.id}`; - return ( - - props.onArchiveThread(thread)} - onDelete={() => props.onDeleteThread(thread)} - onPress={() => props.onSelectThread(thread)} - onSwipeableClose={handleSwipeableClose} - onSwipeableWillOpen={handleSwipeableWillOpen} - /> - - ); - })} - - - ); - }) - )} - - {shouldShowConnectionStatus ? ( - - + {Platform.OS === "ios" ? null : } + + {shouldShowConnectionStatus && Platform.OS === "ios" ? ( + + ) : null} + + ); + + const listEmpty = !hasResults ? ( + hasSearchQuery ? ( + + ) : selectedEnvironmentLabel ? ( + + ) : ( + + ) + ) : null; + + return ( + + {/* Sticky headers are deliberately not wired up: LegendList's JS sticky + implementation mispositions pinned headers at mount under iOS + automatic content insets (headers render one nav-inset too low until + the first scroll event) and blanks non-pinned headers after + collapse/expand data changes. The flattened layout still exposes + `stickyHeaderIndices` if this gets revisited. */} + + + + {connectionStatus} ); } diff --git a/apps/mobile/src/features/home/WorkspaceConnectionStatus.test.ts b/apps/mobile/src/features/home/WorkspaceConnectionStatus.test.ts new file mode 100644 index 00000000000..767f0e3dd3f --- /dev/null +++ b/apps/mobile/src/features/home/WorkspaceConnectionStatus.test.ts @@ -0,0 +1,70 @@ +import { describe, expect, it } from "vite-plus/test"; + +import type { WorkspaceState } from "../../state/workspaceModel"; +import { + shouldShowWorkspaceConnectionStatus, + workspaceConnectionStatusLabel, +} from "./workspace-connection-status"; + +function workspaceState(overrides: Partial = {}): WorkspaceState { + return { + isLoadingConnections: false, + hasConnections: true, + hasLoadedShellSnapshot: true, + hasPendingShellSnapshot: false, + hasReadyEnvironment: true, + hasConnectingEnvironment: false, + connectingEnvironments: [], + connectionState: "connected", + connectionError: null, + shellSnapshotError: null, + latestCachedSnapshotReceivedAt: null, + networkStatus: "online", + ...overrides, + }; +} + +describe("workspace connection status", () => { + it("stays hidden while a ready environment is connected", () => { + expect(shouldShowWorkspaceConnectionStatus(workspaceState())).toBe(false); + }); + + it("surfaces offline snapshots", () => { + const state = workspaceState({ networkStatus: "offline", hasReadyEnvironment: false }); + + expect(shouldShowWorkspaceConnectionStatus(state)).toBe(true); + expect(workspaceConnectionStatusLabel(state)).toBe("You are offline"); + }); + + it("names the environment while reconnecting", () => { + const state = workspaceState({ + hasConnectingEnvironment: true, + hasReadyEnvironment: false, + connectingEnvironments: [ + { + environmentId: "environment-1" as never, + environmentLabel: "Julius’s Mac mini", + displayUrl: "", + isRelayManaged: false, + connectionState: "reconnecting", + connectionError: null, + connectionErrorTraceId: null, + }, + ], + }); + + expect(shouldShowWorkspaceConnectionStatus(state)).toBe(true); + expect(workspaceConnectionStatusLabel(state)).toBe("Reconnecting to Julius’s Mac mini"); + }); + + it("surfaces connection errors before the generic disconnected fallback", () => { + const state = workspaceState({ + connectionError: "Could not reach Julius’s Mac mini", + hasLoadedShellSnapshot: false, + hasReadyEnvironment: false, + }); + + expect(shouldShowWorkspaceConnectionStatus(state)).toBe(true); + expect(workspaceConnectionStatusLabel(state)).toBe("Could not reach Julius’s Mac mini"); + }); +}); diff --git a/apps/mobile/src/features/home/WorkspaceConnectionStatus.tsx b/apps/mobile/src/features/home/WorkspaceConnectionStatus.tsx new file mode 100644 index 00000000000..f41ac8c45d6 --- /dev/null +++ b/apps/mobile/src/features/home/WorkspaceConnectionStatus.tsx @@ -0,0 +1,53 @@ +import { SymbolView } from "expo-symbols"; +import { ActivityIndicator, Pressable } from "react-native"; + +import { AppText as Text } from "../../components/AppText"; +import { useThemeColor } from "../../lib/useThemeColor"; +import type { WorkspaceState } from "../../state/workspaceModel"; +import { workspaceConnectionStatusLabel } from "./workspace-connection-status"; + +export function WorkspaceConnectionStatus(props: { + readonly state: WorkspaceState; + readonly onPress: () => void; + readonly variant?: "floating" | "sidebar"; +}) { + const iconColor = useThemeColor("--color-icon-muted"); + const isReconnecting = props.state.connectingEnvironments.length > 0; + const variant = props.variant ?? "floating"; + + return ( + + {isReconnecting ? ( + + ) : ( + + )} + + {workspaceConnectionStatusLabel(props.state)} + + {variant === "sidebar" ? ( + + ) : null} + + ); +} diff --git a/apps/mobile/src/features/home/home-list-filter-menu.ts b/apps/mobile/src/features/home/home-list-filter-menu.ts new file mode 100644 index 00000000000..46ea639677b --- /dev/null +++ b/apps/mobile/src/features/home/home-list-filter-menu.ts @@ -0,0 +1,120 @@ +import type { + EnvironmentId, + SidebarProjectGroupingMode, + SidebarThreadSortOrder, +} from "@t3tools/contracts"; + +import type { HomeProjectSortOrder } from "./homeThreadList"; +import { + PROJECT_GROUPING_OPTIONS, + PROJECT_SORT_OPTIONS, + THREAD_SORT_OPTIONS, +} from "./home-list-options"; + +export interface HomeListFilterMenuEnvironment { + readonly environmentId: EnvironmentId; + readonly label: string; +} + +type HomeListFilterMenuAction = { + readonly type: "action"; + readonly title: string; + readonly subtitle?: string; + readonly state?: "on" | "off"; + readonly onPress: () => void; +}; + +type HomeListFilterMenuSubmenu = { + readonly type: "submenu"; + readonly title: string; + readonly items: HomeListFilterMenuAction[]; +}; + +export interface HomeListFilterMenu { + readonly title: string; + readonly items: Array; +} + +export function buildHomeListFilterMenu(props: { + readonly environments: ReadonlyArray; + readonly selectedEnvironmentId: EnvironmentId | null; + readonly projectSortOrder: HomeProjectSortOrder; + readonly threadSortOrder: SidebarThreadSortOrder; + readonly projectGroupingMode: SidebarProjectGroupingMode; + readonly onEnvironmentChange: (environmentId: EnvironmentId | null) => void; + readonly onProjectSortOrderChange: (sortOrder: HomeProjectSortOrder) => void; + readonly onThreadSortOrderChange: (sortOrder: SidebarThreadSortOrder) => void; + readonly onProjectGroupingModeChange: (mode: SidebarProjectGroupingMode) => void; + readonly onOpenSettings?: () => void; +}): HomeListFilterMenu { + const items: Array = []; + + if (props.onOpenSettings) { + items.push({ + type: "action", + title: "Settings", + onPress: props.onOpenSettings, + }); + } + + items.push( + { + type: "submenu", + title: "Environment", + items: [ + { + type: "action", + title: "All environments", + subtitle: "Show threads from every environment", + state: props.selectedEnvironmentId === null ? "on" : "off", + onPress: () => props.onEnvironmentChange(null), + }, + ...props.environments.map((environment) => ({ + type: "action" as const, + title: environment.label, + state: + props.selectedEnvironmentId === environment.environmentId + ? ("on" as const) + : ("off" as const), + onPress: () => props.onEnvironmentChange(environment.environmentId), + })), + ], + }, + { + type: "submenu", + title: "Sort projects", + items: PROJECT_SORT_OPTIONS.map((option) => ({ + type: "action", + title: option.label, + state: props.projectSortOrder === option.value ? "on" : "off", + onPress: () => props.onProjectSortOrderChange(option.value), + })), + }, + { + type: "submenu", + title: "Sort threads", + items: THREAD_SORT_OPTIONS.map((option) => ({ + type: "action", + title: option.label, + state: props.threadSortOrder === option.value ? "on" : "off", + onPress: () => props.onThreadSortOrderChange(option.value), + })), + }, + { + type: "submenu", + title: "Group projects", + items: PROJECT_GROUPING_OPTIONS.map((option) => ({ + type: "action", + title: option.label, + subtitle: option.subtitle, + state: props.projectGroupingMode === option.value ? "on" : "off", + onPress: () => props.onProjectGroupingModeChange(option.value), + })), + }, + ); + + return { + title: "Thread list options", + items, + }; +} diff --git a/apps/mobile/src/features/home/home-list-options.test.ts b/apps/mobile/src/features/home/home-list-options.test.ts new file mode 100644 index 00000000000..788be25906b --- /dev/null +++ b/apps/mobile/src/features/home/home-list-options.test.ts @@ -0,0 +1,31 @@ +import { + DEFAULT_SIDEBAR_PROJECT_GROUPING_MODE, + DEFAULT_SIDEBAR_PROJECT_SORT_ORDER, + DEFAULT_SIDEBAR_THREAD_SORT_ORDER, +} from "@t3tools/contracts"; +import { describe, expect, it } from "vite-plus/test"; + +import { hasCustomHomeListOptions, type HomeListOptions } from "./home-list-options"; + +const defaults: HomeListOptions = { + selectedEnvironmentId: null, + projectSortOrder: + DEFAULT_SIDEBAR_PROJECT_SORT_ORDER === "manual" + ? "updated_at" + : DEFAULT_SIDEBAR_PROJECT_SORT_ORDER, + threadSortOrder: DEFAULT_SIDEBAR_THREAD_SORT_ORDER, + projectGroupingMode: DEFAULT_SIDEBAR_PROJECT_GROUPING_MODE, +}; + +describe("home list options", () => { + it("recognizes default options", () => { + expect(hasCustomHomeListOptions(defaults)).toBe(false); + }); + + it("marks environment filters and grouping changes as customized", () => { + expect( + hasCustomHomeListOptions({ ...defaults, selectedEnvironmentId: "environment-1" as never }), + ).toBe(true); + expect(hasCustomHomeListOptions({ ...defaults, projectGroupingMode: "separate" })).toBe(true); + }); +}); diff --git a/apps/mobile/src/features/home/home-list-options.ts b/apps/mobile/src/features/home/home-list-options.ts new file mode 100644 index 00000000000..64881034306 --- /dev/null +++ b/apps/mobile/src/features/home/home-list-options.ts @@ -0,0 +1,144 @@ +import type { + EnvironmentId, + SidebarProjectGroupingMode, + SidebarThreadSortOrder, +} from "@t3tools/contracts"; +import { + DEFAULT_SIDEBAR_PROJECT_GROUPING_MODE, + DEFAULT_SIDEBAR_PROJECT_SORT_ORDER, + DEFAULT_SIDEBAR_THREAD_SORT_ORDER, +} from "@t3tools/contracts"; +import { + createContext, + createElement, + useCallback, + useContext, + useMemo, + useState, + type PropsWithChildren, + type Dispatch, + type SetStateAction, +} from "react"; + +import type { HomeProjectSortOrder } from "./homeThreadList"; + +export interface HomeListOptions { + readonly selectedEnvironmentId: EnvironmentId | null; + readonly projectSortOrder: HomeProjectSortOrder; + readonly threadSortOrder: SidebarThreadSortOrder; + readonly projectGroupingMode: SidebarProjectGroupingMode; +} + +export const PROJECT_SORT_OPTIONS: ReadonlyArray<{ + readonly value: HomeProjectSortOrder; + readonly label: string; +}> = [ + { value: "updated_at", label: "Last user message" }, + { value: "created_at", label: "Created at" }, +]; + +export const THREAD_SORT_OPTIONS: ReadonlyArray<{ + readonly value: SidebarThreadSortOrder; + readonly label: string; +}> = [ + { value: "updated_at", label: "Last user message" }, + { value: "created_at", label: "Created at" }, +]; + +export const PROJECT_GROUPING_OPTIONS: ReadonlyArray<{ + readonly value: SidebarProjectGroupingMode; + readonly label: string; + readonly subtitle: string; +}> = [ + { + value: "repository", + label: "Group by repository", + subtitle: "Combine matching repositories across environments", + }, + { + value: "repository_path", + label: "Group by repository path", + subtitle: "Combine only matching paths within a repository", + }, + { + value: "separate", + label: "Keep separate", + subtitle: "Show every project path separately", + }, +]; + +function defaultHomeListOptions(): HomeListOptions { + return { + selectedEnvironmentId: null, + projectSortOrder: + DEFAULT_SIDEBAR_PROJECT_SORT_ORDER === "manual" + ? "updated_at" + : DEFAULT_SIDEBAR_PROJECT_SORT_ORDER, + threadSortOrder: DEFAULT_SIDEBAR_THREAD_SORT_ORDER, + projectGroupingMode: DEFAULT_SIDEBAR_PROJECT_GROUPING_MODE, + }; +} + +interface HomeListOptionsContextValue { + readonly options: HomeListOptions; + readonly setOptions: Dispatch>; +} + +const HomeListOptionsContext = createContext(null); + +/** Keeps list preferences stable while the app moves between compact and split shells. */ +export function HomeListOptionsProvider({ children }: PropsWithChildren) { + const [options, setOptions] = useState(defaultHomeListOptions); + const value = useMemo(() => ({ options, setOptions }), [options]); + return createElement(HomeListOptionsContext, { value }, children); +} + +export function hasCustomHomeListOptions(options: HomeListOptions): boolean { + const defaultProjectSortOrder = + DEFAULT_SIDEBAR_PROJECT_SORT_ORDER === "manual" + ? "updated_at" + : DEFAULT_SIDEBAR_PROJECT_SORT_ORDER; + return ( + options.selectedEnvironmentId !== null || + options.projectSortOrder !== defaultProjectSortOrder || + options.threadSortOrder !== DEFAULT_SIDEBAR_THREAD_SORT_ORDER || + options.projectGroupingMode !== DEFAULT_SIDEBAR_PROJECT_GROUPING_MODE + ); +} + +export function useHomeListOptions(availableEnvironmentIds: ReadonlySet) { + const shared = useContext(HomeListOptionsContext); + const [localOptions, setLocalOptions] = useState(defaultHomeListOptions); + const options = shared?.options ?? localOptions; + const setOptions = shared?.setOptions ?? setLocalOptions; + const selectedEnvironmentId = + options.selectedEnvironmentId !== null && + availableEnvironmentIds.has(options.selectedEnvironmentId) + ? options.selectedEnvironmentId + : null; + const resolvedOptions = + selectedEnvironmentId === options.selectedEnvironmentId + ? options + : { ...options, selectedEnvironmentId }; + + const setSelectedEnvironmentId = useCallback((value: EnvironmentId | null) => { + setOptions((current) => ({ ...current, selectedEnvironmentId: value })); + }, []); + const setProjectSortOrder = useCallback((value: HomeProjectSortOrder) => { + setOptions((current) => ({ ...current, projectSortOrder: value })); + }, []); + const setThreadSortOrder = useCallback((value: SidebarThreadSortOrder) => { + setOptions((current) => ({ ...current, threadSortOrder: value })); + }, []); + const setProjectGroupingMode = useCallback((value: SidebarProjectGroupingMode) => { + setOptions((current) => ({ ...current, projectGroupingMode: value })); + }, []); + + return { + options: resolvedOptions, + setSelectedEnvironmentId, + setProjectSortOrder, + setThreadSortOrder, + setProjectGroupingMode, + } as const; +} diff --git a/apps/mobile/src/features/home/homeListItems.test.ts b/apps/mobile/src/features/home/homeListItems.test.ts new file mode 100644 index 00000000000..891a7271104 --- /dev/null +++ b/apps/mobile/src/features/home/homeListItems.test.ts @@ -0,0 +1,192 @@ +import type { + EnvironmentProject, + EnvironmentThreadShell, +} from "@t3tools/client-runtime/state/shell"; +import { EnvironmentId, ProjectId, ProviderInstanceId, ThreadId } from "@t3tools/contracts"; +import { describe, expect, it } from "vite-plus/test"; + +import { + buildHomeListLayout, + DEFAULT_GROUP_DISPLAY_STATE, + HOME_INITIAL_VISIBLE_THREADS, + HOME_SHOW_MORE_STEP, + nextGroupDisplayState, + type HomeGroupDisplayState, + type HomeListItem, +} from "./homeListItems"; +import type { HomeThreadGroup } from "./homeThreadList"; + +const environmentId = EnvironmentId.make("environment-1"); + +function makeProject(id: string, title: string): EnvironmentProject { + return { + environmentId, + id: ProjectId.make(id), + title, + workspaceRoot: `/workspaces/${id}`, + repositoryIdentity: null, + defaultModelSelection: null, + scripts: [], + createdAt: "2026-06-01T00:00:00.000Z", + updatedAt: "2026-06-01T00:00:00.000Z", + }; +} + +function makeThread(id: string, projectId: ProjectId): EnvironmentThreadShell { + return { + environmentId, + id: ThreadId.make(id), + projectId, + title: `Thread ${id}`, + modelSelection: { instanceId: ProviderInstanceId.make("codex"), model: "gpt-5.4" }, + runtimeMode: "full-access", + interactionMode: "default", + branch: null, + worktreePath: null, + latestTurn: null, + createdAt: "2026-06-01T00:00:00.000Z", + updatedAt: "2026-06-01T00:00:00.000Z", + archivedAt: null, + session: null, + latestUserMessageAt: null, + hasPendingApprovals: false, + hasPendingUserInput: false, + hasActionableProposedPlan: false, + }; +} + +function makeGroup(key: string, threadCount: number): HomeThreadGroup { + const project = makeProject(key, key); + return { + key, + title: key, + representative: project, + projects: [project], + threads: Array.from({ length: threadCount }, (_, index) => + makeThread(`${key}-thread-${index}`, project.id), + ), + }; +} + +function itemTypes(items: ReadonlyArray): string[] { + return items.map((item) => item.type); +} + +function displayStates( + entries: Record, +): ReadonlyMap { + return new Map(Object.entries(entries)); +} + +describe("buildHomeListLayout", () => { + it("renders a header plus all threads for a small group without a show-more row", () => { + const layout = buildHomeListLayout({ + groups: [makeGroup("alpha", 3)], + displayStates: displayStates({}), + }); + + expect(itemTypes(layout.items)).toEqual(["header", "thread", "thread", "thread"]); + expect(layout.stickyHeaderIndices).toEqual([0]); + expect(layout.items.at(-1)).toMatchObject({ type: "thread", isLast: true }); + }); + + it("limits large groups to the initial visible count with a show-more row", () => { + const layout = buildHomeListLayout({ + groups: [makeGroup("alpha", 133)], + displayStates: displayStates({}), + }); + + const threadItems = layout.items.filter((item) => item.type === "thread"); + expect(threadItems).toHaveLength(HOME_INITIAL_VISIBLE_THREADS); + expect(layout.items.at(-1)).toMatchObject({ + type: "show-more", + groupKey: "alpha", + hiddenCount: 133 - HOME_INITIAL_VISIBLE_THREADS, + canShowLess: false, + }); + // The show-more row takes over the last slot, so no thread is marked last. + expect(threadItems.every((item) => item.type === "thread" && !item.isLast)).toBe(true); + }); + + it("reveals more threads per show-more step and offers show-less when exhausted", () => { + const group = makeGroup("alpha", 20); + + const expandedOnce = buildHomeListLayout({ + groups: [group], + displayStates: displayStates({ + alpha: nextGroupDisplayState(DEFAULT_GROUP_DISPLAY_STATE, "show-more"), + }), + }); + expect(expandedOnce.items.filter((item) => item.type === "thread")).toHaveLength( + HOME_INITIAL_VISIBLE_THREADS + HOME_SHOW_MORE_STEP, + ); + expect(expandedOnce.items.at(-1)).toMatchObject({ + type: "show-more", + hiddenCount: 4, + canShowLess: true, + }); + + const fullyExpanded = buildHomeListLayout({ + groups: [group], + displayStates: displayStates({ + alpha: nextGroupDisplayState( + nextGroupDisplayState(DEFAULT_GROUP_DISPLAY_STATE, "show-more"), + "show-more", + ), + }), + }); + expect(fullyExpanded.items.filter((item) => item.type === "thread")).toHaveLength(20); + expect(fullyExpanded.items.at(-1)).toMatchObject({ + type: "show-more", + hiddenCount: 0, + canShowLess: true, + }); + + const reset = nextGroupDisplayState( + nextGroupDisplayState( + nextGroupDisplayState(DEFAULT_GROUP_DISPLAY_STATE, "show-more"), + "show-more", + ), + "show-less", + ); + expect(reset.visibleCount).toBe(HOME_INITIAL_VISIBLE_THREADS); + }); + + it("hides threads and the show-more row for collapsed groups", () => { + const layout = buildHomeListLayout({ + groups: [makeGroup("alpha", 12), makeGroup("beta", 2)], + displayStates: displayStates({ + alpha: nextGroupDisplayState(DEFAULT_GROUP_DISPLAY_STATE, "toggle-collapsed"), + }), + }); + + expect(itemTypes(layout.items)).toEqual(["header", "header", "thread", "thread"]); + expect(layout.items[0]).toMatchObject({ type: "header", collapsed: true, isFirst: true }); + expect(layout.items[1]).toMatchObject({ type: "header", collapsed: false, isFirst: false }); + expect(layout.stickyHeaderIndices).toEqual([0, 1]); + }); + + it("suspends collapse and pagination while searching", () => { + const layout = buildHomeListLayout({ + groups: [makeGroup("alpha", 12)], + displayStates: displayStates({ + alpha: nextGroupDisplayState(DEFAULT_GROUP_DISPLAY_STATE, "toggle-collapsed"), + }), + showAllThreads: true, + }); + + expect(layout.items.filter((item) => item.type === "thread")).toHaveLength(12); + expect(layout.items.some((item) => item.type === "show-more")).toBe(false); + }); + + it("keeps sticky indices aligned across multiple expanded groups", () => { + const layout = buildHomeListLayout({ + groups: [makeGroup("alpha", 8), makeGroup("beta", 1)], + displayStates: displayStates({}), + }); + + // header + 6 threads + show-more = 8 items, so beta's header is index 8. + expect(layout.stickyHeaderIndices).toEqual([0, 8]); + expect(layout.items[8]).toMatchObject({ type: "header", isFirst: false }); + }); +}); diff --git a/apps/mobile/src/features/home/homeListItems.ts b/apps/mobile/src/features/home/homeListItems.ts new file mode 100644 index 00000000000..4a938514227 --- /dev/null +++ b/apps/mobile/src/features/home/homeListItems.ts @@ -0,0 +1,158 @@ +import type { EnvironmentThreadShell } from "@t3tools/client-runtime/state/shell"; + +import type { HomeThreadGroup } from "./homeThreadList"; + +/** Threads shown per project before the "Show more" affordance appears. */ +export const HOME_INITIAL_VISIBLE_THREADS = 6; +/** Additional threads revealed per "Show more" tap. */ +export const HOME_SHOW_MORE_STEP = 10; + +export interface HomeGroupDisplayState { + readonly collapsed: boolean; + /** How many threads are currently revealed (clamped to the group size). */ + readonly visibleCount: number; +} + +export const DEFAULT_GROUP_DISPLAY_STATE: HomeGroupDisplayState = { + collapsed: false, + visibleCount: HOME_INITIAL_VISIBLE_THREADS, +}; + +export interface HomeHeaderListItem { + readonly type: "header"; + readonly key: string; + readonly group: HomeThreadGroup; + readonly collapsed: boolean; + readonly isFirst: boolean; +} + +export interface HomeThreadListItem { + readonly type: "thread"; + readonly key: string; + readonly thread: EnvironmentThreadShell; + readonly isLast: boolean; +} + +export interface HomeShowMoreListItem { + readonly type: "show-more"; + readonly key: string; + readonly groupKey: string; + /** Threads still hidden. 0 means the group is fully expanded. */ + readonly hiddenCount: number; + /** Whether more than the initial count is revealed, so "Show less" applies. */ + readonly canShowLess: boolean; +} + +export type HomeListItem = HomeHeaderListItem | HomeThreadListItem | HomeShowMoreListItem; + +export interface HomeListLayout { + readonly items: ReadonlyArray; + readonly stickyHeaderIndices: ReadonlyArray; +} + +export type HomeGroupDisplayAction = "toggle-collapsed" | "show-more" | "show-less"; + +export function nextGroupDisplayState( + current: HomeGroupDisplayState, + action: HomeGroupDisplayAction, +): HomeGroupDisplayState { + switch (action) { + case "toggle-collapsed": + return { ...current, collapsed: !current.collapsed }; + case "show-more": + return { ...current, visibleCount: current.visibleCount + HOME_SHOW_MORE_STEP }; + case "show-less": + return { ...current, visibleCount: HOME_INITIAL_VISIBLE_THREADS }; + } +} + +/** + * Structural equality for list items. Item objects are rebuilt on every + * collapse/show-more toggle; without this the lists would consider every + * mounted row changed and re-render all of them (each carrying a swipeable + + * a vcs-status subscription). Group/thread references are stable across + * toggles. + */ +export function homeListItemsAreEqual(previous: HomeListItem, item: HomeListItem): boolean { + switch (item.type) { + case "header": + return ( + previous.type === "header" && + previous.group === item.group && + previous.collapsed === item.collapsed && + previous.isFirst === item.isFirst + ); + case "thread": + return ( + previous.type === "thread" && + previous.thread === item.thread && + previous.isLast === item.isLast + ); + case "show-more": + return ( + previous.type === "show-more" && + previous.groupKey === item.groupKey && + previous.hiddenCount === item.hiddenCount && + previous.canShowLess === item.canShowLess + ); + } +} + +export function buildHomeListLayout(input: { + readonly groups: ReadonlyArray; + readonly displayStates: ReadonlyMap; + /** + * When searching, pagination is suspended so every match stays visible. + */ + readonly showAllThreads?: boolean; +}): HomeListLayout { + const items: HomeListItem[] = []; + const stickyHeaderIndices: number[] = []; + + for (const [groupIndex, group] of input.groups.entries()) { + const display = input.displayStates.get(group.key) ?? DEFAULT_GROUP_DISPLAY_STATE; + const collapsed = display.collapsed && input.showAllThreads !== true; + + stickyHeaderIndices.push(items.length); + items.push({ + type: "header", + key: `header:${group.key}`, + group, + collapsed, + isFirst: groupIndex === 0, + }); + + if (collapsed) { + continue; + } + + const totalCount = group.threads.length; + const visibleCount = input.showAllThreads + ? totalCount + : Math.min(Math.max(display.visibleCount, HOME_INITIAL_VISIBLE_THREADS), totalCount); + const visibleThreads = group.threads.slice(0, visibleCount); + const hiddenCount = totalCount - visibleCount; + const hasShowMoreRow = !input.showAllThreads && totalCount > HOME_INITIAL_VISIBLE_THREADS; + + for (const [threadIndex, thread] of visibleThreads.entries()) { + items.push({ + type: "thread", + key: `thread:${thread.environmentId}:${thread.id}`, + thread, + isLast: threadIndex === visibleThreads.length - 1 && !hasShowMoreRow, + }); + } + + if (hasShowMoreRow) { + items.push({ + type: "show-more", + key: `show-more:${group.key}`, + groupKey: group.key, + hiddenCount, + canShowLess: visibleCount > HOME_INITIAL_VISIBLE_THREADS, + }); + } + } + + return { items, stickyHeaderIndices }; +} diff --git a/apps/mobile/src/features/home/thread-swipe-actions.tsx b/apps/mobile/src/features/home/thread-swipe-actions.tsx index dd0e2901bba..dd412301077 100644 --- a/apps/mobile/src/features/home/thread-swipe-actions.tsx +++ b/apps/mobile/src/features/home/thread-swipe-actions.tsx @@ -1,8 +1,26 @@ import { SymbolView } from "expo-symbols"; -import type { ComponentProps } from "react"; -import type { ColorValue } from "react-native"; +import * as Haptics from "expo-haptics"; +import { + createContext, + use, + useCallback, + useEffect, + useRef, + useState, + type ComponentProps, + type ReactNode, +} from "react"; +import type { + ColorValue, + NativeScrollEvent, + NativeSyntheticEvent, + StyleProp, + ViewStyle, +} from "react-native"; import { Pressable, View } from "react-native"; -import type { SwipeableMethods } from "react-native-gesture-handler/ReanimatedSwipeable"; +import ReanimatedSwipeable, { + type SwipeableMethods, +} from "react-native-gesture-handler/ReanimatedSwipeable"; import Animated, { Extrapolation, interpolate, @@ -26,6 +44,231 @@ export const THREAD_SWIPE_SPRING = { stiffness: 330, }; +interface ThreadSwipePrimaryAction { + readonly accessibilityLabel: string; + readonly icon: ComponentProps["name"]; + readonly label: string; + readonly onPress: () => void; +} + +/** + * Delivers the scroll gate to swipeables via context so that flipping it does + * NOT re-render whole rows: putting the flag in list extraData/renderItem deps + * re-rendered every visible row (hooks, subscriptions and all) exactly at + * scroll start — peak frame pressure. As a context value only the + * ThreadSwipeable consumers re-render. + */ +const SwipeableScrollGateContext = createContext(true); + +export function SwipeableScrollGateProvider(props: { + readonly enabled: boolean; + readonly children: ReactNode; +}) { + return ( + + {props.children} + + ); +} + +/** + * Gates row swipes on list scroll activity, mirroring UIKit's own swipe + * actions (`!isDragging && !isDecelerating`). failOffsetY on the swipe pan + * covers the first pan of a scroll, but trackpad scroll sessions spawn fresh + * gesture sessions (momentum catch, direction changes) whose reset + * translation can re-activate a swipe mid-scroll — so while the list has + * moved vertically during an active drag/momentum phase, row swipes are + * disabled entirely. + * + * Spread the returned handlers onto the list and pass `swipeEnabled` to rows. + */ +export function useSwipeableScrollGate(options?: { + readonly onScroll?: (event: NativeSyntheticEvent) => void; + readonly onScrollBeginDrag?: (event: NativeSyntheticEvent) => void; +}) { + const [gateActive, setGateActive] = useState(false); + const gateActiveRef = useRef(false); + const draggingRef = useRef(false); + const dragStartYRef = useRef(0); + const settleTimerRef = useRef | null>(null); + const externalOnScroll = options?.onScroll; + const externalOnScrollBeginDrag = options?.onScrollBeginDrag; + + const update = useCallback((next: boolean) => { + if (gateActiveRef.current !== next) { + gateActiveRef.current = next; + setGateActive(next); + } + }, []); + const clearSettle = useCallback(() => { + if (settleTimerRef.current !== null) { + clearTimeout(settleTimerRef.current); + settleTimerRef.current = null; + } + }, []); + useEffect(() => clearSettle, [clearSettle]); + + const onScrollBeginDrag = useCallback( + (event: NativeSyntheticEvent) => { + draggingRef.current = true; + dragStartYRef.current = event.nativeEvent.contentOffset.y; + clearSettle(); + externalOnScrollBeginDrag?.(event); + }, + [clearSettle, externalOnScrollBeginDrag], + ); + const onScroll = useCallback( + (event: NativeSyntheticEvent) => { + // Only vertical movement during a user drag arms the gate — a purely + // horizontal row swipe never moves contentOffset.y, and inset-driven + // offset changes at mount happen outside a drag. + if ( + draggingRef.current && + !gateActiveRef.current && + Math.abs(event.nativeEvent.contentOffset.y - dragStartYRef.current) > 4 + ) { + update(true); + } + externalOnScroll?.(event); + }, + [externalOnScroll, update], + ); + const onScrollEndDrag = useCallback(() => { + draggingRef.current = false; + clearSettle(); + // If momentum follows, onMomentumScrollBegin cancels this and the gate + // stays armed until the deceleration finishes. + settleTimerRef.current = setTimeout(() => update(false), 160); + }, [clearSettle, update]); + const onMomentumScrollBegin = useCallback(() => { + clearSettle(); + }, [clearSettle]); + const onMomentumScrollEnd = useCallback(() => { + update(false); + }, [update]); + + return { + swipeEnabled: !gateActive, + scrollGateHandlers: { + onScroll, + onScrollBeginDrag, + onScrollEndDrag, + onMomentumScrollBegin, + onMomentumScrollEnd, + }, + }; +} + +export function ThreadSwipeable(props: { + readonly backgroundColor: ColorValue; + readonly children: (close: () => void) => ReactNode; + readonly containerStyle?: StyleProp; + /** Disables NEW swipe activations (e.g. while the list scrolls). */ + readonly enabled?: boolean; + readonly enableTrackpadSwipe?: boolean; + readonly fullSwipeWidth: number; + readonly onDelete: () => void; + readonly onSwipeableClose?: (methods: SwipeableMethods) => void; + readonly onSwipeableWillOpen?: (methods: SwipeableMethods) => void; + readonly primaryAction: ThreadSwipePrimaryAction; + /** + * Identity of the content being wrapped. When a recycled list reuses this + * component for a different item, the swipeable snaps back to closed so an + * open/mid-drag state can't leak onto another row. + */ + readonly resetKey?: string; + readonly simultaneousWithExternalGesture?: ComponentProps< + typeof ReanimatedSwipeable + >["simultaneousWithExternalGesture"]; + readonly threadTitle: string; +}) { + const swipeableRef = useRef(null); + const fullSwipeArmedRef = useRef(false); + const fullSwipeThreshold = Math.max(THREAD_SWIPE_ACTIONS_WIDTH + 44, props.fullSwipeWidth * 0.58); + const close = useCallback(() => swipeableRef.current?.close(), []); + const gateEnabled = use(SwipeableScrollGateContext); + const resetKey = props.resetKey; + useEffect(() => { + if (resetKey === undefined) { + return; + } + fullSwipeArmedRef.current = false; + swipeableRef.current?.reset(); + }, [resetKey]); + const handleFullSwipeArmedChange = useCallback((armed: boolean) => { + if (armed && !fullSwipeArmedRef.current && process.env.EXPO_OS === "ios") { + void Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Medium); + } + fullSwipeArmedRef.current = armed; + }, []); + + return ( + { + fullSwipeArmedRef.current = false; + if (swipeableRef.current) { + props.onSwipeableClose?.(swipeableRef.current); + } + }} + onSwipeableOpenStartDrag={() => { + if (swipeableRef.current) { + props.onSwipeableWillOpen?.(swipeableRef.current); + } + }} + onSwipeableWillOpen={() => { + const methods = swipeableRef.current; + if (!methods) { + return; + } + + props.onSwipeableWillOpen?.(methods); + if (fullSwipeArmedRef.current) { + fullSwipeArmedRef.current = false; + methods.close(); + props.onDelete(); + } + }} + overshootFriction={1} + overshootRight + renderRightActions={(_progress, translation, methods) => ( + { + methods.close(); + props.primaryAction.onPress(); + }, + }} + swipeableMethods={methods} + threadTitle={props.threadTitle} + translation={translation} + /> + )} + rightThreshold={THREAD_SWIPE_ACTIONS_WIDTH * 0.42} + simultaneousWithExternalGesture={props.simultaneousWithExternalGesture} + > + {props.children(close)} + + ); +} + function SwipeActionButton(props: { readonly accessibilityLabel: string; readonly backgroundColor: string; @@ -179,12 +422,7 @@ export function ThreadSwipeActions(props: { readonly fullSwipeThreshold: number; readonly onDelete: () => void; readonly onFullSwipeArmedChange: (armed: boolean) => void; - readonly primaryAction: { - readonly accessibilityLabel: string; - readonly icon: ComponentProps["name"]; - readonly label: string; - readonly onPress: () => void; - }; + readonly primaryAction: ThreadSwipePrimaryAction; readonly swipeableMethods: SwipeableMethods; readonly threadTitle: string; readonly translation: SharedValue; diff --git a/apps/mobile/src/features/home/workspace-connection-status.ts b/apps/mobile/src/features/home/workspace-connection-status.ts new file mode 100644 index 00000000000..7d2c6d840d4 --- /dev/null +++ b/apps/mobile/src/features/home/workspace-connection-status.ts @@ -0,0 +1,22 @@ +import type { WorkspaceState } from "../../state/workspaceModel"; + +export function shouldShowWorkspaceConnectionStatus(state: WorkspaceState): boolean { + return ( + state.networkStatus === "offline" || + state.connectionError !== null || + state.hasConnectingEnvironment || + (state.hasLoadedShellSnapshot && !state.hasReadyEnvironment) + ); +} + +export function workspaceConnectionStatusLabel(state: WorkspaceState): string { + if (state.networkStatus === "offline") return "You are offline"; + if (state.connectingEnvironments.length === 1) { + return `Reconnecting to ${state.connectingEnvironments[0]!.environmentLabel}`; + } + if (state.connectingEnvironments.length > 1) { + return `Reconnecting ${state.connectingEnvironments.length} environments`; + } + if (state.connectionError !== null) return state.connectionError; + return "Not connected"; +} diff --git a/apps/mobile/src/features/keyboard/HardwareKeyboardCommandProvider.tsx b/apps/mobile/src/features/keyboard/HardwareKeyboardCommandProvider.tsx new file mode 100644 index 00000000000..96a4a63e901 --- /dev/null +++ b/apps/mobile/src/features/keyboard/HardwareKeyboardCommandProvider.tsx @@ -0,0 +1,73 @@ +import { StackActions, useNavigation } from "@react-navigation/native"; +import { useCallback, useMemo, useSyncExternalStore, type PropsWithChildren } from "react"; + +import { T3KeyboardCommands } from "../../native/T3KeyboardCommands"; +import { + dispatchHardwareKeyboardCommand, + getHardwareKeyboardCommandRegistrationVersion, + getRegisteredHardwareKeyboardCommands, + parseActiveThreadPath, + subscribeToHardwareKeyboardCommandRegistrations, + type HardwareKeyboardCommand, +} from "./hardwareKeyboardCommands"; + +export function HardwareKeyboardCommandProvider({ + children, + pathname, +}: PropsWithChildren<{ readonly pathname: string }>) { + const navigation = useNavigation(); + const registrationVersion = useSyncExternalStore( + subscribeToHardwareKeyboardCommandRegistrations, + getHardwareKeyboardCommandRegistrationVersion, + getHardwareKeyboardCommandRegistrationVersion, + ); + const enabledCommands = useMemo(() => { + const commands = new Set(getRegisteredHardwareKeyboardCommands()); + commands.add("newTask"); + if (pathname !== "/" || navigation.canGoBack()) commands.add("back"); + if (parseActiveThreadPath(pathname)) { + commands.add("files"); + commands.add("terminal"); + commands.add("review"); + } + return [...commands]; + }, [pathname, registrationVersion, navigation]); + + const onCommand = useCallback( + (command: HardwareKeyboardCommand) => { + if (dispatchHardwareKeyboardCommand(command)) return; + + if (command === "newTask") { + navigation.navigate("NewTaskSheet", { screen: "NewTask" }); + return; + } + if (command === "back") { + if (navigation.canGoBack()) { + navigation.goBack(); + } else { + navigation.dispatch(StackActions.replace("Home")); + } + return; + } + + const thread = parseActiveThreadPath(pathname); + if (!thread) return; + if (command === "files" && !/\/files(?:\/|$)/.test(pathname)) { + navigation.navigate("ThreadFiles", thread); + } + if (command === "terminal" && !/\/terminal(?:\/|$)/.test(pathname)) { + navigation.navigate("ThreadTerminal", thread); + } + if (command === "review" && !/\/review(?:\/|$)/.test(pathname)) { + navigation.navigate("ThreadReview", thread); + } + }, + [pathname, navigation], + ); + + return ( + + {children} + + ); +} diff --git a/apps/mobile/src/features/keyboard/hardwareKeyboardCommands.test.ts b/apps/mobile/src/features/keyboard/hardwareKeyboardCommands.test.ts new file mode 100644 index 00000000000..55f1037c010 --- /dev/null +++ b/apps/mobile/src/features/keyboard/hardwareKeyboardCommands.test.ts @@ -0,0 +1,28 @@ +import { describe, expect, it } from "vite-plus/test"; + +import { parseActiveThreadPath } from "./hardwareKeyboardCommands"; + +describe("parseActiveThreadPath", () => { + it("extracts the active thread from thread subroutes", () => { + expect(parseActiveThreadPath("/threads/environment-1/thread-1/files/src/index.ts")).toEqual({ + environmentId: "environment-1", + threadId: "thread-1", + }); + }); + + it("decodes route components", () => { + expect(parseActiveThreadPath("/threads/local%20machine/thread%2Fone/review")).toEqual({ + environmentId: "local machine", + threadId: "thread/one", + }); + }); + + it("ignores non-thread routes", () => { + expect(parseActiveThreadPath("/settings")).toBeNull(); + expect(parseActiveThreadPath("/threads/environment-only")).toBeNull(); + }); + + it("ignores malformed encoded route components", () => { + expect(parseActiveThreadPath("/threads/%E0%A4%A/thread-1")).toBeNull(); + }); +}); diff --git a/apps/mobile/src/features/keyboard/hardwareKeyboardCommands.ts b/apps/mobile/src/features/keyboard/hardwareKeyboardCommands.ts new file mode 100644 index 00000000000..300434eb736 --- /dev/null +++ b/apps/mobile/src/features/keyboard/hardwareKeyboardCommands.ts @@ -0,0 +1,78 @@ +import { EnvironmentId, ThreadId } from "@t3tools/contracts"; +import { useEffect } from "react"; + +export type HardwareKeyboardCommand = + | "newTask" + | "focusSearch" + | "back" + | "files" + | "terminal" + | "review" + | "toggleSidebar"; + +type CommandHandler = () => boolean | void; + +const handlers = new Map>(); +const registrationListeners = new Set<() => void>(); +let registrationVersion = 0; + +/** + * Registers a context-specific hardware-keyboard action. The most recently mounted handler gets + * the first chance to consume the command, allowing focused screens to override app defaults. + */ +export function useHardwareKeyboardCommand( + command: HardwareKeyboardCommand, + handler: CommandHandler, +): void { + useEffect(() => { + const commandHandlers = handlers.get(command) ?? new Set(); + commandHandlers.add(handler); + handlers.set(command, commandHandlers); + registrationVersion += 1; + registrationListeners.forEach((listener) => listener()); + return () => { + commandHandlers.delete(handler); + if (commandHandlers.size === 0) handlers.delete(command); + registrationVersion += 1; + registrationListeners.forEach((listener) => listener()); + }; + }, [command, handler]); +} + +export function getRegisteredHardwareKeyboardCommands(): ReadonlySet { + return new Set(handlers.keys()); +} + +export function getHardwareKeyboardCommandRegistrationVersion(): number { + return registrationVersion; +} + +export function subscribeToHardwareKeyboardCommandRegistrations(listener: () => void): () => void { + registrationListeners.add(listener); + return () => registrationListeners.delete(listener); +} + +export function dispatchHardwareKeyboardCommand(command: HardwareKeyboardCommand): boolean { + const commandHandlers = handlers.get(command); + if (!commandHandlers) return false; + for (const handler of [...commandHandlers].toReversed()) { + if (handler() !== false) return true; + } + return false; +} + +export function parseActiveThreadPath(pathname: string): { + readonly environmentId: EnvironmentId; + readonly threadId: ThreadId; +} | null { + const match = /^\/threads\/([^/]+)\/([^/]+)(?:\/|$)/.exec(pathname); + if (!match?.[1] || !match[2]) return null; + try { + return { + environmentId: EnvironmentId.make(decodeURIComponent(match[1])), + threadId: ThreadId.make(decodeURIComponent(match[2])), + }; + } catch { + return null; + } +} diff --git a/apps/mobile/src/features/layout/AdaptiveWorkspaceLayout.tsx b/apps/mobile/src/features/layout/AdaptiveWorkspaceLayout.tsx new file mode 100644 index 00000000000..fe3db9a4f03 --- /dev/null +++ b/apps/mobile/src/features/layout/AdaptiveWorkspaceLayout.tsx @@ -0,0 +1,507 @@ +import type { EnvironmentThreadShell } from "@t3tools/client-runtime/state/shell"; +import { EnvironmentId, ThreadId } from "@t3tools/contracts"; +import { useFocusEffect } from "@react-navigation/native"; +import { + NavigationContext, + NavigationRouteContext, + StackActions, + useNavigation, +} from "@react-navigation/native"; +import { + createContext, + use, + useCallback, + useEffect, + useMemo, + useRef, + useState, + type ReactNode, +} from "react"; +import { useWindowDimensions, View } from "react-native"; +import Animated, { useAnimatedStyle, useSharedValue, withTiming } from "react-native-reanimated"; + +import { + deriveFileInspectorPaneLayout, + deriveLayout, + deriveWorkspacePaneLayout, + type FileInspectorPaneLayout, + type Layout, + type WorkspaceAuxiliaryPaneRole, + type WorkspacePaneLayout, +} from "../../lib/layout"; +import { resolveThreadSelectionNavigationAction } from "../../lib/adaptive-navigation"; +import { scopedThreadKey } from "../../lib/scopedEntities"; +import { + parseActiveThreadPath, + useHardwareKeyboardCommand, +} from "../keyboard/hardwareKeyboardCommands"; +import { HomeListOptionsProvider } from "../home/home-list-options"; +import { ThreadNavigationSidebar } from "../threads/ThreadNavigationSidebar"; +import { WORKSPACE_PANE_TIMING } from "./workspace-pane-animation"; +import { WorkspaceInspectorPane } from "./workspace-inspector-pane"; + +interface AdaptiveWorkspaceContextValue { + readonly layout: Layout; + readonly panes: WorkspacePaneLayout; + readonly fileInspector: FileInspectorPaneLayout; + readonly primarySidebarSearchQuery: string; + readonly activateAuxiliaryPaneRole: (role: WorkspaceAuxiliaryPaneRole) => () => void; + /** + * Route screens hand their inspector pane content to the workspace so it + * renders BESIDE the navigator (outside the native stack header) instead of + * inside the route. Returns a deactivate callback: the pane animates closed + * (content kept mounted for the exit transition) unless a newer + * registration already took over — stale deactivates never clobber it. + * Prefer useRegisterWorkspaceInspector over calling this directly. + */ + readonly registerWorkspaceInspector: (render: () => ReactNode) => () => void; + readonly setPrimarySidebarSearchQuery: (query: string) => void; + readonly showAuxiliaryPane: (role: WorkspaceAuxiliaryPaneRole) => void; + readonly toggleAuxiliaryPane: () => void; + readonly togglePrimarySidebar: () => void; + readonly setAuxiliaryPaneWidth: (width: number) => void; +} + +const compactLayout = deriveLayout({ width: 0, height: 0 }); +const compactPanes = deriveWorkspacePaneLayout({ + layout: compactLayout, + viewportWidth: 0, + primarySidebarPreferredVisible: true, + auxiliaryPanePreferredVisible: true, +}); +const compactFileInspector = deriveFileInspectorPaneLayout({ + layout: compactLayout, + viewportWidth: 0, +}); +const AdaptiveWorkspaceContext = createContext({ + layout: compactLayout, + panes: compactPanes, + fileInspector: compactFileInspector, + primarySidebarSearchQuery: "", + activateAuxiliaryPaneRole: () => () => undefined, + registerWorkspaceInspector: () => () => undefined, + setPrimarySidebarSearchQuery: () => undefined, + showAuxiliaryPane: () => undefined, + toggleAuxiliaryPane: () => undefined, + togglePrimarySidebar: () => undefined, + setAuxiliaryPaneWidth: () => undefined, +}); + +export function useAdaptiveWorkspaceLayout(): AdaptiveWorkspaceContextValue { + return use(AdaptiveWorkspaceContext); +} + +export function useAdaptiveWorkspacePaneRole(role: WorkspaceAuxiliaryPaneRole) { + const { activateAuxiliaryPaneRole } = useAdaptiveWorkspaceLayout(); + + useFocusEffect( + useCallback(() => activateAuxiliaryPaneRole(role), [activateAuxiliaryPaneRole, role]), + ); +} + +/** + * Register this screen's inspector pane content with the workspace column. + * + * The column renders BESIDE the navigator — outside any screen — so the + * registering screen's navigation and route contexts are captured here and + * re-provided around the portal content. Without them, useNavigation/useRoute + * inside the pane (e.g. GitOverviewSheet via useThreadSelection) throw + * "Couldn't find a route object". + * + * Registration is FOCUS-scoped, driven by navigation events rather than the + * screen's own render cycle: react-native-screens freezes blurred screens, so + * a cleanup that depends on the blurred subtree re-rendering never runs and + * would leak the pane into the next route. Blur deactivates the pane (it + * animates closed, or is replaced seamlessly when the next route registers in + * the same commit); focus re-registers it. + */ +export function useRegisterWorkspaceInspector(render: (() => ReactNode) | undefined) { + const { registerWorkspaceInspector } = useAdaptiveWorkspaceLayout(); + // Raw context values (not the useNavigation/useRoute wrappers) so the + // portal re-provides exactly what this screen sees. + const navigation = use(NavigationContext); + const route = use(NavigationRouteContext); + + const wrappedRender = useMemo(() => { + if (render === undefined) { + return undefined; + } + return () => ( + + {render()} + + ); + }, [navigation, render, route]); + + const wrappedRenderRef = useRef(wrappedRender); + wrappedRenderRef.current = wrappedRender; + const focusedRef = useRef(false); + const deactivateRef = useRef<(() => void) | null>(null); + + const syncRegistration = useCallback(() => { + if (!focusedRef.current || wrappedRenderRef.current === undefined) { + deactivateRef.current?.(); + return; + } + deactivateRef.current = registerWorkspaceInspector(wrappedRenderRef.current); + }, [registerWorkspaceInspector]); + + // Focus lifecycle. Blur/focus events fire even when the blurred subtree is + // frozen (events are navigation-driven, renders are not). + useFocusEffect( + useCallback(() => { + focusedRef.current = true; + syncRegistration(); + return () => { + focusedRef.current = false; + syncRegistration(); + }; + }, [syncRegistration]), + ); + + // Content changes while focused re-register in place. + useEffect(() => { + if (focusedRef.current) { + syncRegistration(); + } + }, [syncRegistration, wrappedRender]); + + // Unmount: hand the pane back (owner-guarded, so a route that already + // took over is unaffected). + useEffect( + () => () => { + deactivateRef.current?.(); + deactivateRef.current = null; + }, + [], + ); +} + +export function AdaptiveWorkspaceLayout(props: { + readonly children: ReactNode; + readonly pathname: string; +}) { + const { width, height } = useWindowDimensions(); + const pathname = props.pathname; + const navigation = useNavigation(); + const activeRoleOwner = useRef(null); + const [primarySidebarPreferredVisible, setPrimarySidebarPreferredVisible] = useState(true); + const [supplementaryPanePreferredVisible, setSupplementaryPanePreferredVisible] = useState(true); + const [supplementaryPanePreferredWidth, setSupplementaryPanePreferredWidth] = useState< + number | null + >(null); + const [fileInspectorPreferredVisible, setFileInspectorPreferredVisible] = useState(true); + const [fileInspectorPreferredWidth, setFileInspectorPreferredWidth] = useState( + null, + ); + const [primarySidebarSearchQuery, setPrimarySidebarSearchQuery] = useState(""); + const [focusedAuxiliaryPaneRole, setFocusedAuxiliaryPaneRole] = + useState(null); + const baseLayout = useMemo(() => deriveLayout({ width, height }), [height, width]); + const layout = baseLayout; + // In split layouts the sidebar IS the thread list — it renders on every + // route, including Home (which shows an empty-detail pane instead of the + // compact list). + const shouldRenderPrimarySidebar = layout.usesSplitView; + const fileInspector = useMemo( + () => + deriveFileInspectorPaneLayout({ + layout, + viewportWidth: width, + preferredWidth: fileInspectorPreferredWidth ?? undefined, + reservedLeadingWidth: + shouldRenderPrimarySidebar && primarySidebarPreferredVisible + ? (layout.listPaneWidth ?? 0) + : 0, + }), + [ + fileInspectorPreferredWidth, + layout, + primarySidebarPreferredVisible, + shouldRenderPrimarySidebar, + width, + ], + ); + const auxiliaryPaneRole: WorkspaceAuxiliaryPaneRole = + focusedAuxiliaryPaneRole ?? (/\/files(?:\/|$)/.test(pathname) ? "inspector" : "supplementary"); + const auxiliaryPanePreferredVisible = + auxiliaryPaneRole === "inspector" + ? fileInspectorPreferredVisible + : supplementaryPanePreferredVisible; + const auxiliaryPanePreferredWidth = + auxiliaryPaneRole === "inspector" + ? fileInspectorPreferredWidth + : supplementaryPanePreferredWidth; + const panes = useMemo( + () => + deriveWorkspacePaneLayout({ + layout, + viewportWidth: width, + primarySidebarPreferredVisible, + auxiliaryPanePreferredVisible, + auxiliaryPaneRole, + auxiliaryPanePreferredWidth: auxiliaryPanePreferredWidth ?? undefined, + }), + [ + auxiliaryPanePreferredVisible, + auxiliaryPaneRole, + auxiliaryPanePreferredWidth, + layout, + primarySidebarPreferredVisible, + width, + ], + ); + const activeThread = parseActiveThreadPath(pathname); + const environmentId = activeThread?.environmentId ?? null; + const threadId = activeThread?.threadId ?? null; + const selectedThreadKey = useMemo(() => { + if (environmentId === null || threadId === null) { + return null; + } + try { + return scopedThreadKey(EnvironmentId.make(environmentId), ThreadId.make(threadId)); + } catch { + return null; + } + }, [environmentId, threadId]); + // Wrapped in an object: bare functions in useState would be treated as + // lazy initializers/updaters. `active: false` keeps the outgoing route's + // content mounted so the pane can animate closed (or be replaced + // seamlessly by the next route's registration in the same commit). + const [workspaceInspector, setWorkspaceInspector] = useState<{ + readonly render: () => ReactNode; + readonly active: boolean; + } | null>(null); + const workspaceInspectorOwner = useRef(null); + const registerWorkspaceInspector = useCallback((render: () => ReactNode) => { + const owner = Symbol("workspace-inspector"); + workspaceInspectorOwner.current = owner; + setWorkspaceInspector({ render, active: true }); + + return () => { + // During a push/replace the outgoing screen deactivates AFTER the + // incoming screen registered — only the current owner may deactivate. + if (workspaceInspectorOwner.current !== owner) { + return; + } + setWorkspaceInspector((current) => (current === null ? null : { ...current, active: false })); + }; + }, []); + // Once the close animation settles, drop the stale content entirely. + const handleWorkspaceInspectorClosed = useCallback(() => { + setWorkspaceInspector((current) => (current !== null && !current.active ? null : current)); + }, []); + const activateAuxiliaryPaneRole = useCallback((role: WorkspaceAuxiliaryPaneRole) => { + const owner = Symbol(role); + activeRoleOwner.current = owner; + setFocusedAuxiliaryPaneRole(role); + + return () => { + if (activeRoleOwner.current !== owner) { + return; + } + activeRoleOwner.current = null; + setFocusedAuxiliaryPaneRole(null); + }; + }, []); + const togglePrimarySidebar = useCallback(() => { + if (!panes.primarySidebarVisible && panes.primarySidebarSuppressedByAuxiliary) { + setFileInspectorPreferredVisible(false); + setPrimarySidebarPreferredVisible(true); + return; + } + setPrimarySidebarPreferredVisible((current) => !current); + }, [panes.primarySidebarSuppressedByAuxiliary, panes.primarySidebarVisible]); + const revealPrimarySidebar = useCallback(() => { + if (panes.primarySidebarSuppressedByAuxiliary) { + setFileInspectorPreferredVisible(false); + } + setPrimarySidebarPreferredVisible(true); + }, [panes.primarySidebarSuppressedByAuxiliary]); + const handleToggleSidebarCommand = useCallback(() => { + togglePrimarySidebar(); + return true; + }, [togglePrimarySidebar]); + useHardwareKeyboardCommand("toggleSidebar", handleToggleSidebarCommand); + const showAuxiliaryPane = useCallback((role: WorkspaceAuxiliaryPaneRole) => { + if (role === "inspector") { + setFocusedAuxiliaryPaneRole("inspector"); + setFileInspectorPreferredVisible(true); + return; + } + setFocusedAuxiliaryPaneRole("supplementary"); + setSupplementaryPanePreferredVisible(true); + }, []); + const handleOpenFilesCommand = useCallback(() => { + const activeThread = parseActiveThreadPath(pathname); + if (!layout.usesSplitView || !fileInspector.supported || activeThread === null) { + return false; + } + showAuxiliaryPane("inspector"); + if (/\/files(?:\/|$)/.test(pathname)) { + return true; + } + navigation.navigate("ThreadFiles", activeThread); + return true; + }, [fileInspector.supported, layout.usesSplitView, pathname, navigation, showAuxiliaryPane]); + useHardwareKeyboardCommand("files", handleOpenFilesCommand); + const toggleAuxiliaryPane = useCallback(() => { + if (auxiliaryPaneRole === "inspector") { + setFileInspectorPreferredVisible((current) => !current); + return; + } + setSupplementaryPanePreferredVisible((current) => !current); + }, [auxiliaryPaneRole]); + const setAuxiliaryPaneWidth = useCallback( + (nextWidth: number) => { + if (auxiliaryPaneRole === "inspector") { + setFileInspectorPreferredWidth(nextWidth); + return; + } + setSupplementaryPanePreferredWidth(nextWidth); + }, + [auxiliaryPaneRole], + ); + const contextValue = useMemo( + () => ({ + layout, + panes, + fileInspector, + primarySidebarSearchQuery, + activateAuxiliaryPaneRole, + registerWorkspaceInspector, + setPrimarySidebarSearchQuery, + showAuxiliaryPane, + toggleAuxiliaryPane, + togglePrimarySidebar, + setAuxiliaryPaneWidth, + }), + [ + activateAuxiliaryPaneRole, + fileInspector, + layout, + panes, + primarySidebarSearchQuery, + registerWorkspaceInspector, + showAuxiliaryPane, + setPrimarySidebarSearchQuery, + setAuxiliaryPaneWidth, + toggleAuxiliaryPane, + togglePrimarySidebar, + ], + ); + + const handleOpenSettings = useCallback(() => { + navigation.navigate("SettingsSheet", { screen: "Settings" }); + }, [navigation]); + + // Minted here (root stack navigation) so the sidebar pane stays free of + // navigation hooks — on iOS it renders inside an independent nav tree. + const handleOpenEnvironmentSettings = useCallback(() => { + navigation.navigate("SettingsSheet", { screen: "SettingsEnvironments" }); + }, [navigation]); + + const renderedSidebarWidth = useSharedValue( + panes.primarySidebarVisible ? (layout.listPaneWidth ?? 0) : 0, + ); + useEffect(() => { + const targetWidth = panes.primarySidebarVisible ? (layout.listPaneWidth ?? 0) : 0; + renderedSidebarWidth.value = withTiming(targetWidth, WORKSPACE_PANE_TIMING); + }, [layout.listPaneWidth, panes.primarySidebarVisible, renderedSidebarWidth]); + const sidebarAnimatedStyle = useAnimatedStyle(() => ({ + opacity: Math.min(1, renderedSidebarWidth.value / 80), + width: renderedSidebarWidth.value, + })); + + // Freeze the content pane at its SETTLED width while the side panes + // animate. The navigator (native header + markdown feed) lays out ONCE per + // pane toggle instead of re-measuring on every animation frame — the + // animating columns merely clip/reveal it over a matching background. + // Continuously re-wrapping the chat feed was the main source of dropped + // frames during sidebar/inspector transitions. + const inspectorColumnTargetWidth = + workspaceInspector !== null && workspaceInspector.active && panes.auxiliaryPaneVisible + ? (panes.auxiliaryPaneWidth ?? 0) + : 0; + const contentSettledWidth = layout.usesSplitView + ? Math.max(0, panes.contentPaneWidth - inspectorColumnTargetWidth) + : null; + + const handleSelectThread = useCallback( + (thread: EnvironmentThreadShell) => { + const params = { + environmentId: String(thread.environmentId), + threadId: String(thread.id), + }; + const navigationAction = resolveThreadSelectionNavigationAction({ + usesSplitView: layout.usesSplitView, + pathname, + }); + if (navigationAction === "set-params") { + const nextThreadKey = scopedThreadKey(thread.environmentId, thread.id); + if (nextThreadKey === selectedThreadKey) { + return; + } + setFileInspectorPreferredVisible(false); + navigation.navigate("Thread", params); + return; + } + if (navigationAction === "replace") { + setFileInspectorPreferredVisible(false); + navigation.dispatch(StackActions.replace("Thread", params)); + return; + } + navigation.navigate("Thread", params); + }, + [layout.usesSplitView, pathname, navigation, selectedThreadKey], + ); + + return ( + + + + {shouldRenderPrimarySidebar && layout.listPaneWidth !== null ? ( + + + + ) : null} + + + {props.children} + + + + + + + ); +} diff --git a/apps/mobile/src/features/layout/WorkspaceEmptyDetail.tsx b/apps/mobile/src/features/layout/WorkspaceEmptyDetail.tsx new file mode 100644 index 00000000000..e21920977bc --- /dev/null +++ b/apps/mobile/src/features/layout/WorkspaceEmptyDetail.tsx @@ -0,0 +1,30 @@ +import { SymbolView } from "expo-symbols"; +import { Pressable, View } from "react-native"; + +import { AppText as Text } from "../../components/AppText"; +import { useThemeColor } from "../../lib/useThemeColor"; + +export function WorkspaceEmptyDetail(props: { readonly onStartNewTask?: () => void }) { + const iconColor = useThemeColor("--color-icon-subtle"); + + return ( + + + + Select a thread + + Choose a thread from the sidebar or start a new task. + + {props.onStartNewTask ? ( + + New Task + + ) : null} + + + ); +} diff --git a/apps/mobile/src/features/layout/native-glass-header-items.ts b/apps/mobile/src/features/layout/native-glass-header-items.ts new file mode 100644 index 00000000000..a8019776c45 --- /dev/null +++ b/apps/mobile/src/features/layout/native-glass-header-items.ts @@ -0,0 +1,33 @@ +type NativeGlassHeaderItem = { + readonly type: "button" | "menu"; + readonly glassEffect?: boolean; + readonly hidesSharedBackground?: boolean; + readonly sharesBackground?: boolean; + readonly variant?: "plain" | "done" | "prominent"; + readonly width?: number; +}; + +/** + * iOS 26/27 Mail-style header controls need the native glass button + * shared background configuration when they are not part of a larger toolbar. + * Do not enable `glassEffect` for normal bar-button items: react-native-screens + * renders that as a custom UIButton, which creates a second skinny capsule. + */ +export function withNativeGlassHeaderItem( + item: T, + options: { + readonly hidesSharedBackground?: boolean; + readonly sharesBackground?: boolean; + readonly width?: number; + } = {}, +): T { + const sharesBackground = options.sharesBackground ?? item.sharesBackground ?? true; + return { + ...item, + glassEffect: item.glassEffect ?? false, + hidesSharedBackground: options.hidesSharedBackground ?? item.hidesSharedBackground ?? false, + sharesBackground, + variant: item.variant ?? "plain", + width: options.width ?? item.width, + } as T; +} diff --git a/apps/mobile/src/features/layout/native-mail-search-toolbar.ts b/apps/mobile/src/features/layout/native-mail-search-toolbar.ts new file mode 100644 index 00000000000..820e1222243 --- /dev/null +++ b/apps/mobile/src/features/layout/native-mail-search-toolbar.ts @@ -0,0 +1,25 @@ +import type { HeaderBarButtonMailSearchToolbarItem } from "react-native-screens"; + +type NativeMailSearchToolbarInput = Omit< + HeaderBarButtonMailSearchToolbarItem, + "type" | "useFallbackSearchField" +>; + +/** + * Builds the patched react-native-screens Mail-style bottom search toolbar. + * + * Keeping this behind an app-level helper makes the iOS-only RNS patch an + * explicit layout primitive instead of a per-screen object literal. Android can + * keep using platform-specific header/search primitives without depending on + * this helper. + */ +export function createNativeMailSearchToolbarItem( + input: NativeMailSearchToolbarInput, +): HeaderBarButtonMailSearchToolbarItem { + return { + placeholder: "Search", + ...input, + type: "mailSearchToolbar", + useFallbackSearchField: true, + }; +} diff --git a/apps/mobile/src/features/layout/workspace-inspector-pane.tsx b/apps/mobile/src/features/layout/workspace-inspector-pane.tsx new file mode 100644 index 00000000000..efc97380c84 --- /dev/null +++ b/apps/mobile/src/features/layout/workspace-inspector-pane.tsx @@ -0,0 +1,145 @@ +import { useCallback, useEffect, useRef, useState, type ReactNode } from "react"; +import Animated, { + runOnJS, + useAnimatedStyle, + useSharedValue, + withTiming, +} from "react-native-reanimated"; + +import { constrainAuxiliaryPaneWidth, type WorkspacePaneLayout } from "../../lib/layout"; +import { WORKSPACE_PANE_TIMING } from "./workspace-pane-animation"; +import { WorkspacePaneDivider } from "./workspace-pane-divider"; + +/** + * The trailing inspector column: resize divider + animated reveal. + * + * Rendered by AdaptiveWorkspaceLayout as a SIBLING of the navigator so the + * native stack header (and its trailing toolbar items) spans only the content + * pane — the inspector owns its own full-height column, mirroring how each + * column of a UISplitViewController has its own chrome. + * + * Receives the pane layout via props (not the workspace context hook) so this + * module stays import-cycle-free with AdaptiveWorkspaceLayout. + */ +export function WorkspaceInspectorPane(props: { + /** + * When false the pane animates closed but keeps its content mounted for the + * exit transition (a route that lost focus). `onClosed` fires once the + * close animation settles so the owner can drop the stale content. + */ + readonly active?: boolean; + readonly onClosed?: () => void; + readonly panes: WorkspacePaneLayout; + readonly renderInspector?: () => ReactNode; + readonly setAuxiliaryPaneWidth: (width: number) => void; +}) { + const { panes, setAuxiliaryPaneWidth } = props; + const inspectorWidth = panes.auxiliaryPaneWidth; + const inspectorSupported = props.renderInspector !== undefined && inspectorWidth !== null; + const inspectorVisible = + inspectorSupported && panes.auxiliaryPaneVisible && (props.active ?? true); + const resizeStartWidth = useRef(0); + const [resizing, setResizing] = useState(false); + + // A file-to-file replace remounts the route. Initialize an already-visible + // inspector at its final position so route replacement never replays an + // entering transition. Only visibility and explicit resizing change it. + const inspectorProgress = useSharedValue(inspectorVisible ? 1 : 0); + const renderedInspectorWidth = useSharedValue(inspectorVisible ? (inspectorWidth ?? 0) : 0); + // The content keeps its own width so the reveal (outer width) clips a + // fully-laid-out pane instead of reflowing text every frame. When the OPEN + // pane's target width changes (e.g. the sidebar toggles and reserves + // space), animate the content width in lockstep rather than snapping. + const renderedContentWidth = useSharedValue(inspectorWidth ?? 0); + + const onClosed = props.onClosed; + useEffect(() => { + inspectorProgress.value = withTiming( + inspectorVisible ? 1 : 0, + WORKSPACE_PANE_TIMING, + (finished) => { + if (finished === true && !inspectorVisible && onClosed !== undefined) { + runOnJS(onClosed)(); + } + }, + ); + const targetWidth = inspectorVisible ? (inspectorWidth ?? 0) : 0; + renderedInspectorWidth.value = resizing + ? targetWidth + : withTiming(targetWidth, WORKSPACE_PANE_TIMING); + }, [ + inspectorProgress, + inspectorVisible, + inspectorWidth, + onClosed, + renderedInspectorWidth, + resizing, + ]); + + useEffect(() => { + const targetWidth = inspectorWidth ?? 0; + if (!inspectorVisible || resizing) { + // Hidden panes re-measure silently; during a divider drag the content + // tracks the finger directly. + renderedContentWidth.value = targetWidth; + return; + } + renderedContentWidth.value = withTiming(targetWidth, WORKSPACE_PANE_TIMING); + }, [inspectorVisible, inspectorWidth, renderedContentWidth, resizing]); + + const inspectorStyle = useAnimatedStyle( + () => ({ + opacity: inspectorProgress.value, + transform: [{ translateX: (1 - inspectorProgress.value) * 24 }], + width: renderedInspectorWidth.value, + }), + [], + ); + const inspectorContentStyle = useAnimatedStyle(() => ({ width: renderedContentWidth.value }), []); + const beginResize = useCallback(() => { + resizeStartWidth.current = inspectorWidth ?? 0; + setResizing(true); + }, [inspectorWidth]); + const resizeBy = useCallback( + (delta: number) => { + setAuxiliaryPaneWidth( + constrainAuxiliaryPaneWidth({ + preferredWidth: resizeStartWidth.current + delta, + availableWidth: panes.contentPaneWidth, + }), + ); + }, + [panes.contentPaneWidth, setAuxiliaryPaneWidth], + ); + const endResize = useCallback(() => { + setResizing(false); + }, []); + + return ( + <> + {inspectorVisible ? ( + + ) : null} + {inspectorSupported ? ( + + + {props.renderInspector?.()} + + + ) : null} + + ); +} diff --git a/apps/mobile/src/features/layout/workspace-pane-animation.ts b/apps/mobile/src/features/layout/workspace-pane-animation.ts new file mode 100644 index 00000000000..5f037cbcc6a --- /dev/null +++ b/apps/mobile/src/features/layout/workspace-pane-animation.ts @@ -0,0 +1,16 @@ +import { Easing, ReduceMotion } from "react-native-reanimated"; + +/** + * One timing curve for every workspace pane (primary sidebar + inspector). + * + * Panes frequently animate together — opening the sidebar can auto-close the + * inspector when both no longer fit — so identical duration and easing on + * every pane keeps the center content pane from wobbling while both edges + * move. Asymmetric open/close timings (the previous 220ms out-cubic open vs + * 160ms in-cubic close) read as jank during those simultaneous swaps. + */ +export const WORKSPACE_PANE_TIMING = { + duration: 260, + easing: Easing.inOut(Easing.cubic), + reduceMotion: ReduceMotion.System, +} as const; diff --git a/apps/mobile/src/features/layout/workspace-pane-divider.tsx b/apps/mobile/src/features/layout/workspace-pane-divider.tsx new file mode 100644 index 00000000000..a892ab73655 --- /dev/null +++ b/apps/mobile/src/features/layout/workspace-pane-divider.tsx @@ -0,0 +1,116 @@ +import { useCallback, useMemo, useRef, useState } from "react"; +import { + Platform, + PlatformColor, + Pressable, + StyleSheet, + View, + type AccessibilityActionEvent, +} from "react-native"; +import { Gesture, GestureDetector } from "react-native-gesture-handler"; +import { runOnJS } from "react-native-reanimated"; + +const ACCESSIBILITY_RESIZE_STEP = 24; + +interface WorkspacePaneDividerProps { + readonly accessibilityLabel: string; + readonly currentWidth: number; + /** 1 when dragging right grows the pane, -1 when dragging left grows it. */ + readonly resizeDirection: 1 | -1; + readonly onResizeStart?: () => void; + readonly onResizeBy: (delta: number) => void; + readonly onResizeEnd?: () => void; +} + +/** A forgiving divider target for touch, pointer, and VoiceOver users. */ +export function WorkspacePaneDivider(props: WorkspacePaneDividerProps) { + const latestProps = useRef(props); + latestProps.current = props; + const [hovered, setHovered] = useState(false); + const [dragging, setDragging] = useState(false); + const handleResizeStart = useCallback(() => { + setDragging(true); + latestProps.current.onResizeStart?.(); + }, []); + const handleResize = useCallback((translationX: number) => { + latestProps.current.onResizeBy(translationX * latestProps.current.resizeDirection); + }, []); + const handleResizeEnd = useCallback(() => { + setDragging(false); + latestProps.current.onResizeEnd?.(); + }, []); + const resizeGesture = useMemo( + () => + Gesture.Pan() + .activeOffsetX([-4, 4]) + .failOffsetY([-24, 24]) + .onStart(() => { + runOnJS(handleResizeStart)(); + }) + .onUpdate((event) => { + runOnJS(handleResize)(event.translationX); + }) + .onFinalize(() => { + runOnJS(handleResizeEnd)(); + }), + [handleResize, handleResizeEnd, handleResizeStart], + ); + + const handleAccessibilityAction = (event: AccessibilityActionEvent) => { + props.onResizeStart?.(); + if (event.nativeEvent.actionName === "increment") { + props.onResizeBy(ACCESSIBILITY_RESIZE_STEP); + } else if (event.nativeEvent.actionName === "decrement") { + props.onResizeBy(-ACCESSIBILITY_RESIZE_STEP); + } + props.onResizeEnd?.(); + }; + + return ( + + setHovered(true)} + onHoverOut={() => setHovered(false)} + style={styles.hitTarget} + > + + + + ); +} + +const styles = StyleSheet.create({ + hitTarget: { + alignSelf: "stretch", + cursor: "pointer", + justifyContent: "center", + marginHorizontal: -22, + position: "relative", + width: 44, + zIndex: 100, + }, + line: { + alignSelf: "center", + backgroundColor: + Platform.OS === "ios" ? PlatformColor("separator") : "rgba(120, 120, 128, 0.28)", + height: "100%", + opacity: 0.7, + width: StyleSheet.hairlineWidth, + }, + activeLine: { + backgroundColor: Platform.OS === "ios" ? PlatformColor("systemBlueColor") : "#0a84ff", + opacity: 1, + width: 2, + }, +}); diff --git a/apps/mobile/src/features/layout/workspace-sidebar-toolbar.tsx b/apps/mobile/src/features/layout/workspace-sidebar-toolbar.tsx new file mode 100644 index 00000000000..ad53d75323c --- /dev/null +++ b/apps/mobile/src/features/layout/workspace-sidebar-toolbar.tsx @@ -0,0 +1,31 @@ +import { NativeHeaderToolbar } from "../../native/StackHeader"; +import type { ReactNode } from "react"; + +import { useAdaptiveWorkspaceLayout } from "./AdaptiveWorkspaceLayout"; + +export function WorkspaceSidebarToolbar( + props: { + readonly children?: ReactNode; + readonly afterSidebarButton?: ReactNode; + } = {}, +) { + const { layout, panes, togglePrimarySidebar } = useAdaptiveWorkspaceLayout(); + + if (!layout.usesSplitView) { + return null; + } + + return ( + + {props.children} + + {props.afterSidebarButton} + + ); +} diff --git a/apps/mobile/src/features/projects/AddProjectDestinationRoute.tsx b/apps/mobile/src/features/projects/AddProjectDestinationRoute.tsx new file mode 100644 index 00000000000..04e2e236bea --- /dev/null +++ b/apps/mobile/src/features/projects/AddProjectDestinationRoute.tsx @@ -0,0 +1,15 @@ +import type { StaticScreenProps } from "@react-navigation/native"; +import { AddProjectDestinationScreen } from "./AddProjectScreen"; + +type AddProjectDestinationRouteParams = { + readonly environmentId?: string | string[]; + readonly source?: string | string[]; + readonly remoteUrl?: string | string[]; + readonly repositoryTitle?: string | string[]; +}; + +export function AddProjectDestinationRoute({ + route, +}: StaticScreenProps) { + return ; +} diff --git a/apps/mobile/src/features/projects/AddProjectLocalRoute.tsx b/apps/mobile/src/features/projects/AddProjectLocalRoute.tsx new file mode 100644 index 00000000000..abe7fb6d6c6 --- /dev/null +++ b/apps/mobile/src/features/projects/AddProjectLocalRoute.tsx @@ -0,0 +1,12 @@ +import type { StaticScreenProps } from "@react-navigation/native"; +import { AddProjectLocalFolderScreen } from "./AddProjectScreen"; + +type AddProjectLocalRouteParams = { + readonly environmentId?: string | string[]; +}; + +export function AddProjectLocalRoute({ + route, +}: StaticScreenProps) { + return ; +} diff --git a/apps/mobile/src/features/projects/AddProjectRepositoryRoute.tsx b/apps/mobile/src/features/projects/AddProjectRepositoryRoute.tsx new file mode 100644 index 00000000000..cdf52022a44 --- /dev/null +++ b/apps/mobile/src/features/projects/AddProjectRepositoryRoute.tsx @@ -0,0 +1,31 @@ +import type { StaticScreenProps } from "@react-navigation/native"; +import { NativeStackScreenOptions } from "../../native/StackHeader"; +import { addProjectRemoteSourceLabel } from "@t3tools/client-runtime/operations/projects"; + +import { AddProjectRepositoryScreen } from "./AddProjectScreen"; + +type AddProjectRepositoryRouteParams = { + readonly environmentId?: string | string[]; + readonly source?: string | string[]; +}; + +export function AddProjectRepositoryRoute({ + route, +}: StaticScreenProps) { + const params = route.params ?? {}; + const source = Array.isArray(params.source) ? params.source[0] : params.source; + const title = + source === "github" || + source === "gitlab" || + source === "bitbucket" || + source === "azure-devops" + ? addProjectRemoteSourceLabel(source) + : "Git URL"; + + return ( + <> + + + + ); +} diff --git a/apps/mobile/src/features/projects/AddProjectScreen.tsx b/apps/mobile/src/features/projects/AddProjectScreen.tsx index fa1f635de8d..841b96f70e2 100644 --- a/apps/mobile/src/features/projects/AddProjectScreen.tsx +++ b/apps/mobile/src/features/projects/AddProjectScreen.tsx @@ -22,7 +22,7 @@ import { isFilesystemBrowseQuery, } from "@t3tools/client-runtime/state/projects"; import { CommandId, type EnvironmentId, ProjectId } from "@t3tools/contracts"; -import { useLocalSearchParams, useRouter } from "expo-router"; +import { StackActions, useNavigation } from "@react-navigation/native"; import { SymbolView } from "expo-symbols"; import { useCallback, useEffect, useMemo, useState, type ReactNode } from "react"; import { ActivityIndicator, Alert, Pressable, ScrollView, View } from "react-native"; @@ -168,9 +168,9 @@ function ListRow(props: { {props.icon} - {props.title} + {props.title} {props.subtitle ? ( - + {props.subtitle} ) : null} @@ -215,7 +215,7 @@ function ProjectPathInput(props: { }) { return ( void; } { - const router = useRouter(); - const params = useLocalSearchParams<{ environmentId?: string }>(); + const [selectedEnvironmentId, setSelectedEnvironmentId] = useState(null); const environmentOptions = useEnvironmentOptions(); - const requestedEnvironmentId = stringParam(params.environmentId) as EnvironmentId | null; const selectedEnvironment = - environmentOptions.find( - (environment) => environment.environmentId === requestedEnvironmentId, - ) ?? + environmentOptions.find((environment) => environment.environmentId === selectedEnvironmentId) ?? environmentOptions[0] ?? null; return { environmentOptions, selectedEnvironment, - setSelectedEnvironmentId: (environmentId) => router.setParams({ environmentId }), + setSelectedEnvironmentId, }; } function EmptyEnvironmentState() { - const router = useRouter(); + const navigation = useNavigation(); return ( No environments connected - + Add an environment before adding a project. router.replace("/connections/new")} + onPress={() => navigation.dispatch(StackActions.replace("ConnectionsNew"))} className="mt-1 rounded-full bg-primary px-4 py-2.5 active:opacity-70" > Add environment @@ -294,7 +290,7 @@ function SourceControlRow(props: { readonly hint: string; readonly isFirst: boolean; }) { - const router = useRouter(); + const navigation = useNavigation(); const iconColor = useThemeColor("--color-icon"); const title = props.source === "url" ? "Git URL" : `${addProjectRemoteSourceLabel(props.source)} repository`; @@ -322,8 +318,8 @@ function SourceControlRow(props: { icon={icon} isFirst={props.isFirst} onPress={() => - router.push({ - pathname: "/new/add-project/repository", + navigation.navigate("NewTaskSheet", { + screen: "AddProjectRepository", params: { environmentId: props.selectedEnvironmentId, source: props.source, @@ -335,7 +331,7 @@ function SourceControlRow(props: { } export function AddProjectSourceScreen() { - const router = useRouter(); + const navigation = useNavigation(); const accentColor = useThemeColor("--color-icon-muted"); const iconColor = useThemeColor("--color-icon"); const { environmentOptions, selectedEnvironment, setSelectedEnvironmentId } = @@ -409,9 +405,11 @@ export function AddProjectSourceScreen() { } isFirst onPress={() => - router.push({ - pathname: "/new/add-project/local", - params: { environmentId: selectedEnvironment.environmentId }, + navigation.navigate("NewTaskSheet", { + screen: "AddProjectLocal", + params: { + environmentId: selectedEnvironment.environmentId, + }, }) } /> @@ -440,7 +438,7 @@ export function AddProjectSourceScreen() { } function useCreateProject(environment: EnvironmentOption | null) { - const router = useRouter(); + const navigation = useNavigation(); const createProject = useAtomCommand(projectEnvironment.create, { reportFailure: false }); const projects = useProjects(); @@ -455,14 +453,13 @@ function useCreateProject(environment: EnvironmentOption | null) { }); if (existing) { Alert.alert("Project already exists", existing.title); - router.replace({ - pathname: "/new/draft", - params: { + navigation.dispatch( + StackActions.replace("NewTaskDraft", { environmentId: existing.environmentId, projectId: existing.id, title: existing.title, - }, - }); + }), + ); return; } @@ -480,24 +477,24 @@ function useCreateProject(environment: EnvironmentOption | null) { if (AsyncResult.isFailure(result)) { return result; } - router.replace({ - pathname: "/new/draft", - params: { + navigation.dispatch( + StackActions.replace("NewTaskDraft", { environmentId: environment.environmentId, projectId, title: inferProjectTitleFromPath(workspaceRoot), - }, - }); + }), + ); return result; }, - [createProject, environment, projects, router], + [createProject, environment, projects, navigation], ); } -function useEnvironmentFromParam(): EnvironmentOption | null { - const params = useLocalSearchParams<{ environmentId?: string }>(); +function useEnvironmentFromParam( + environmentIdParam: string | string[] | undefined, +): EnvironmentOption | null { const environmentOptions = useEnvironmentOptions(); - const environmentId = stringParam(params.environmentId) as EnvironmentId | null; + const environmentId = stringParam(environmentIdParam) as EnvironmentId | null; return ( environmentOptions.find((environment) => environment.environmentId === environmentId) ?? environmentOptions[0] ?? @@ -505,14 +502,16 @@ function useEnvironmentFromParam(): EnvironmentOption | null { ); } -export function AddProjectRepositoryScreen() { +export function AddProjectRepositoryScreen(props: { + readonly environmentId?: string | string[]; + readonly source?: string | string[]; +}) { const lookupRepositoryQuery = useAtomQueryRunner(sourceControlEnvironment.repository, { reportFailure: false, }); - const router = useRouter(); - const params = useLocalSearchParams<{ environmentId?: string; source?: string }>(); - const environment = useEnvironmentFromParam(); - const source = sourceFromParam(params.source); + const navigation = useNavigation(); + const environment = useEnvironmentFromParam(props.environmentId); + const source = sourceFromParam(props.source); const [repositoryInput, setRepositoryInput] = useState(""); const [isSubmitting, setIsSubmitting] = useState(false); const [error, setError] = useState(null); @@ -524,8 +523,8 @@ export function AddProjectRepositoryScreen() { const provider = addProjectRemoteSourceProvider(source); if (!provider) { const remoteUrl = repositoryInput.trim(); - router.push({ - pathname: "/new/add-project/destination", + navigation.navigate("NewTaskSheet", { + screen: "AddProjectDestination", params: { environmentId: environment.environmentId, source, @@ -548,8 +547,8 @@ export function AddProjectRepositoryScreen() { setError(errorMessage(Cause.squash(result.cause))); } else { const repository = result.value; - router.push({ - pathname: "/new/add-project/destination", + navigation.navigate("NewTaskSheet", { + screen: "AddProjectDestination", params: { environmentId: environment.environmentId, source, @@ -559,13 +558,13 @@ export function AddProjectRepositoryScreen() { }); } setIsSubmitting(false); - }, [environment, isSubmitting, lookupRepositoryQuery, repositoryInput, router, source]); + }, [environment, isSubmitting, lookupRepositoryQuery, repositoryInput, navigation, source]); return ( {error ? : null} getAddProjectInitialQuery(environment?.baseDirectory), @@ -745,19 +744,18 @@ export function AddProjectLocalFolderScreen() { ); } -export function AddProjectDestinationScreen() { +export function AddProjectDestinationScreen(props: { + readonly environmentId?: string | string[]; + readonly remoteUrl?: string | string[]; + readonly repositoryTitle?: string | string[]; +}) { const cloneRepository = useAtomCommand(sourceControlEnvironment.cloneRepository, { reportFailure: false, }); - const params = useLocalSearchParams<{ - environmentId?: string; - remoteUrl?: string; - repositoryTitle?: string; - }>(); - const environment = useEnvironmentFromParam(); + const environment = useEnvironmentFromParam(props.environmentId); const createProject = useCreateProject(environment); - const remoteUrl = stringParam(params.remoteUrl); - const repositoryTitle = stringParam(params.repositoryTitle); + const remoteUrl = stringParam(props.remoteUrl); + const repositoryTitle = stringParam(props.repositoryTitle); const [pathInput, setPathInput] = useState(() => getAddProjectInitialQuery(environment?.baseDirectory), ); diff --git a/apps/mobile/src/features/projects/AddProjectSourceRoute.tsx b/apps/mobile/src/features/projects/AddProjectSourceRoute.tsx new file mode 100644 index 00000000000..2dc85606be7 --- /dev/null +++ b/apps/mobile/src/features/projects/AddProjectSourceRoute.tsx @@ -0,0 +1,5 @@ +import { AddProjectSourceScreen } from "./AddProjectScreen"; + +export function AddProjectSourceRoute() { + return ; +} diff --git a/apps/mobile/src/features/review/ReviewCommentComposerSheet.tsx b/apps/mobile/src/features/review/ReviewCommentComposerSheet.tsx index d35c48e8a9b..62894f7ed61 100644 --- a/apps/mobile/src/features/review/ReviewCommentComposerSheet.tsx +++ b/apps/mobile/src/features/review/ReviewCommentComposerSheet.tsx @@ -1,4 +1,4 @@ -import { useLocalSearchParams, useRouter } from "expo-router"; +import { useNavigation, type StaticScreenProps } from "@react-navigation/native"; import { SymbolView } from "expo-symbols"; import { TextInputWrapper } from "expo-paste-input"; import type { EnvironmentId, ThreadId } from "@t3tools/contracts"; @@ -25,10 +25,10 @@ import { getSelectedReviewCommentLines, useReviewCommentTarget, } from "./reviewCommentSelection"; +import { useAppearanceCodeSurface } from "../settings/appearance/useAppearanceCodeSurface"; import { changeTone, DiffTokenText, - REVIEW_DIFF_LINE_HEIGHT, REVIEW_MONO_FONT_FAMILY, ReviewChangeBar, } from "./reviewDiffRendering"; @@ -40,17 +40,20 @@ import { const REVIEW_COMMENT_PREVIEW_MAX_LINES = 5; -export function ReviewCommentComposerSheet() { - const router = useRouter(); +type ReviewCommentComposerSheetProps = StaticScreenProps<{ + readonly environmentId: EnvironmentId; + readonly threadId: ThreadId; +}>; + +export function ReviewCommentComposerSheet(props: ReviewCommentComposerSheetProps) { + const navigation = useNavigation(); const insets = useSafeAreaInsets(); const { width } = useWindowDimensions(); const colorScheme = useColorScheme(); const iconTint = String(useThemeColor("--color-icon")); const target = useReviewCommentTarget(); - const { environmentId, threadId } = useLocalSearchParams<{ - environmentId: EnvironmentId; - threadId: ThreadId; - }>(); + const { codeSurface } = useAppearanceCodeSurface(); + const { environmentId, threadId } = props.route.params; const [commentText, setCommentText] = useState(""); const [highlightedLinesById, setHighlightedLinesById] = useState< Record> @@ -78,14 +81,14 @@ export function ReviewCommentComposerSheet() { ? `Lines ${firstNumber}-${lastNumber}` : `${selectedLines.length} lines selected`; const previewHeight = Math.max( - Math.min(selectedLines.length, REVIEW_COMMENT_PREVIEW_MAX_LINES) * REVIEW_DIFF_LINE_HEIGHT, - REVIEW_DIFF_LINE_HEIGHT, + Math.min(selectedLines.length, REVIEW_COMMENT_PREVIEW_MAX_LINES) * codeSurface.rowHeight, + codeSurface.rowHeight, ); const previewViewportWidth = Math.max(width - 40, 280); const dismissComposer = useCallback(() => { clearReviewCommentTarget(); - router.dismiss(); - }, [router]); + navigation.goBack(); + }, [navigation]); const handleNativePaste = useNativePaste((uris) => { void (async () => { try { @@ -167,7 +170,7 @@ export function ReviewCommentComposerSheet() { {!target ? ( No selection - + Select a diff line or range first. @@ -178,7 +181,7 @@ export function ReviewCommentComposerSheet() { {selectionLabel} @@ -211,9 +214,9 @@ export function ReviewCommentComposerSheet() { - + diff --git a/apps/mobile/src/features/review/ReviewSheet.tsx b/apps/mobile/src/features/review/ReviewSheet.tsx index 92203c0ed4e..a79dfa108bb 100644 --- a/apps/mobile/src/features/review/ReviewSheet.tsx +++ b/apps/mobile/src/features/review/ReviewSheet.tsx @@ -1,14 +1,30 @@ import type { EnvironmentId, ThreadId } from "@t3tools/contracts"; -import { useLocalSearchParams } from "expo-router"; -import Stack from "expo-router/stack"; +import { useNavigation, type StaticScreenProps } from "@react-navigation/native"; +import { + NativeHeaderToolbar, + NativeStackScreenOptions, + nativeHeaderScrollEdgeEffects, +} from "../../native/StackHeader"; +import { Screen, ScreenStack, ScreenStackHeaderConfig } from "react-native-screens"; import { SymbolView } from "expo-symbols"; -import { memo, type ReactElement, useCallback, useMemo } from "react"; +import { + memo, + type Ref, + type ReactElement, + useCallback, + useEffect, + useImperativeHandle, + useMemo, + useRef, + useState, +} from "react"; import { ActivityIndicator, + FlatList, + Platform, Pressable, ScrollView, type NativeSyntheticEvent, - Text as NativeText, StyleSheet, useColorScheme, View, @@ -20,23 +36,38 @@ import { environmentCatalog } from "../../connection/catalog"; import { useEnvironmentPresentation } from "../../state/presentation"; import { useAtomCommand } from "../../state/use-atom-command"; import { useThemeColor } from "../../lib/useThemeColor"; -import { MOBILE_TYPOGRAPHY } from "../../lib/typography"; import { useThreadDraftForThread } from "../../state/use-thread-composer-state"; import { EnvironmentConnectionNotice } from "../connection/EnvironmentConnectionNotice"; +import { + useAdaptiveWorkspaceLayout, + useAdaptiveWorkspacePaneRole, + useRegisterWorkspaceInspector, +} from "../layout/AdaptiveWorkspaceLayout"; +import { useEnvironmentQuery } from "../../state/query"; +import { useSelectedThreadGitActions } from "../../state/use-selected-thread-git-actions"; +import { useSelectedThreadGitState } from "../../state/use-selected-thread-git-state"; +import { useSelectedThreadWorktree } from "../../state/use-selected-thread-worktree"; +import { useThreadSelection } from "../../state/use-thread-selection"; +import { vcsEnvironment } from "../../state/vcs"; +import { WorkspaceSidebarToolbar } from "../layout/workspace-sidebar-toolbar"; +import { ThreadGitMenu } from "../threads/ThreadGitControls"; import { useReviewCacheForThread } from "./reviewState"; -import { resolveNativeReviewDiffView } from "../diffs/nativeReviewDiffSurface"; import { - NATIVE_REVIEW_DIFF_CONTENT_WIDTH, - NATIVE_REVIEW_DIFF_ROW_HEIGHT, -} from "./nativeReviewDiffAdapter"; + type NativeReviewDiffViewHandle, + resolveNativeReviewDiffView, +} from "../diffs/nativeReviewDiffSurface"; +import { NATIVE_REVIEW_DIFF_CONTENT_WIDTH } from "./nativeReviewDiffAdapter"; +import { useAppearanceCodeSurface } from "../settings/appearance/useAppearanceCodeSurface"; import { useReviewDiffData } from "./useReviewDiffData"; +import { useReviewDiffPrewarming } from "./useReviewDiffPrewarming"; import { useReviewFileVisibility } from "./reviewFileVisibility"; import { useReviewSections } from "./useReviewSections"; import { useNativeReviewDiffBridge } from "./useNativeReviewDiffBridge"; import { useReviewCommentSelectionController } from "./useReviewCommentSelectionController"; import { resolveReviewAvailability } from "./reviewAvailability"; +import { resolveSelectedReviewFileId } from "./reviewPaneSelection"; +import { buildReviewSectionMenu } from "./review-section-menu"; -const IOS_NAV_BAR_HEIGHT = 44; const REVIEW_HEADER_SPACING = 0; const ReviewNotice = memo(function ReviewNotice(props: { readonly notice: string }) { @@ -45,7 +76,7 @@ const ReviewNotice = memo(function ReviewNotice(props: { readonly notice: string Partial diff - + {props.notice} @@ -110,36 +141,244 @@ function ReviewSelectionActionBar(props: { ); } -export function ReviewSheet() { +interface ReviewNavigatorFile { + readonly id: string; + readonly path: string; + readonly additions: number; + readonly deletions: number; +} + +const ReviewFileNavigatorRow = memo(function ReviewFileNavigatorRow(props: { + readonly file: ReviewNavigatorFile; + readonly selected: boolean; + readonly onSelectFile: (fileId: string | null) => void; +}) { + const { file, selected, onSelectFile } = props; + // Tapping the selected file again returns to the all-files diff. + const handlePress = useCallback(() => { + onSelectFile(selected ? null : file.id); + }, [file.id, onSelectFile, selected]); + + return ( + + + {file.path} + + + +{file.additions} + -{file.deletions} + + + ); +}); + +interface ReviewFileNavigatorHandle { + readonly setVisibleFile: (fileId: string | null) => void; +} + +interface ReviewFileNavigatorProps { + readonly files: ReadonlyArray; + readonly headerInset: number; + readonly sectionId: string | null; + readonly onSelectFile: (fileId: string | null) => void; + readonly ref?: Ref; +} + +function ReviewFileNavigator({ + files, + headerInset, + sectionId, + onSelectFile, + ref, +}: ReviewFileNavigatorProps) { + const insets = useSafeAreaInsets(); + const sheetColor = String(useThemeColor("--color-sheet")); + const foregroundColor = String(useThemeColor("--color-foreground")); + const headerScrollEdgeEffects = nativeHeaderScrollEdgeEffects(Platform.OS, Platform.Version); + const [fileSelection, setFileSelection] = useState<{ + readonly sectionId: string | null; + readonly fileId: string | null; + }>({ sectionId: null, fileId: null }); + const availableFileIds = useMemo(() => files.map((file) => file.id), [files]); + const selectedFileId = resolveSelectedReviewFileId({ + selection: fileSelection, + sectionId, + availableFileIds, + }); + + useImperativeHandle( + ref, + () => ({ + setVisibleFile: (fileId) => { + if (fileId !== null && !availableFileIds.includes(fileId)) { + return; + } + setFileSelection((current) => { + if (current.sectionId === sectionId && current.fileId === fileId) { + return current; + } + return { sectionId, fileId }; + }); + }, + }), + [availableFileIds, sectionId], + ); + + const handleSelectFile = useCallback( + (fileId: string | null) => { + setFileSelection({ sectionId, fileId }); + onSelectFile(fileId); + }, + [onSelectFile, sectionId], + ); + + const renderFile = useCallback( + ({ item }: { readonly item: ReviewNavigatorFile }) => ( + + ), + [handleSelectFile, selectedFileId], + ); + + const fileList = ( + file.id} + contentContainerStyle={{ + paddingHorizontal: 8, + paddingBottom: 8, + // The nested native header is translucent; start the list below it so + // the scroll-edge effect can sample the content (same treatment as + // FileTreeBrowser in the Files pane). + paddingTop: Platform.OS === "ios" ? insets.top + 44 + 8 : 8, + }} + scrollIndicatorInsets={Platform.OS === "ios" ? { top: insets.top + 44 } : undefined} + renderItem={renderFile} + /> + ); + + if (Platform.OS === "ios") { + return ( + + + + {fileList} + + + + + ); + } + + return ( + + + + Changed files + + {files.length} {files.length === 1 ? "file" : "files"} + + + + {fileList} + + ); +} + +type ReviewSheetProps = StaticScreenProps<{ + readonly environmentId: EnvironmentId; + readonly threadId: ThreadId; +}>; + +export function ReviewSheet(props: ReviewSheetProps) { + const { nativeReviewDiffStyle } = useAppearanceCodeSurface(); + useAdaptiveWorkspacePaneRole("inspector"); + const { panes, showAuxiliaryPane, toggleAuxiliaryPane } = useAdaptiveWorkspaceLayout(); + const navigation = useNavigation(); const insets = useSafeAreaInsets(); const colorScheme = useColorScheme(); - const headerForeground = String(useThemeColor("--color-foreground")); - const headerMuted = String(useThemeColor("--color-foreground-muted")); const headerIcon = String(useThemeColor("--color-icon")); - const { environmentId, threadId } = useLocalSearchParams<{ - environmentId: EnvironmentId; - threadId: ThreadId; - }>(); + const { environmentId, threadId } = props.route.params; const environment = useEnvironmentPresentation(environmentId); const retryEnvironment = useAtomCommand(environmentCatalog.retryNow, "environment retry"); const isEnvironmentReady = environment.presentation?.connection.phase === "connected"; const { draftMessage } = useThreadDraftForThread({ environmentId, threadId }); const reviewCache = useReviewCacheForThread({ environmentId, threadId }); + /* ─── Git actions for the toolbar menu (commit/push without leaving review) ── */ + const { selectedThread } = useThreadSelection(); + const { selectedThreadCwd } = useSelectedThreadWorktree(); + const gitState = useSelectedThreadGitState(); + const gitActions = useSelectedThreadGitActions(); + const gitStatusQuery = useEnvironmentQuery( + selectedThread !== null && selectedThreadCwd !== null + ? vcsEnvironment.status({ + environmentId: selectedThread.environmentId, + input: { cwd: selectedThreadCwd }, + }) + : null, + ); + // The selection-based git hooks only apply when this review belongs to the + // selected thread (it always does when reached from the thread's toolbar). + const gitMenuAvailable = + selectedThread !== null && String(selectedThread.id) === String(threadId); const selectedTheme = colorScheme === "dark" ? "dark" : "light"; - const topContentInset = insets.top + IOS_NAV_BAR_HEIGHT; - const { - error, - loadingGitDiffs, - loadingTurnIds, - reviewSections, - selectedSection, - refreshSelectedSection, - selectSection, - } = useReviewSections({ - enabled: isEnvironmentReady, - environmentId, - threadId, - reviewCache, + // With a solid (non-overlay) header the content lays out below the header + // natively, so no manual top inset is needed. + const topContentInset = 0; + + useEffect(() => { + showAuxiliaryPane("inspector"); + }, [environmentId, showAuxiliaryPane, threadId]); + const { error, reviewSections, selectedSection, refreshSelectedSection, selectSection } = + useReviewSections({ + enabled: isEnvironmentReady, + environmentId, + threadId, + reviewCache, + }); + useReviewDiffPrewarming({ + threadKey: reviewCache.threadKey, + sections: reviewSections, + selectedSectionId: selectedSection?.id ?? null, }); const { headerDiffSummary, nativeReviewDiffData, parsedDiff, pendingReviewCommentCount } = useReviewDiffData({ @@ -148,6 +387,18 @@ export function ReviewSheet() { draftMessage, }); const NativeReviewDiffView = resolveNativeReviewDiffView()!; + const nativeReviewDiffViewRef = useRef(null); + // Native pull-to-refresh on the diff surface (replaces the old Refresh menu item). + const [isPullRefreshing, setIsPullRefreshing] = useState(false); + const handlePullToRefresh = useCallback(async () => { + setIsPullRefreshing(true); + try { + await refreshSelectedSection(); + } finally { + setIsPullRefreshing(false); + } + }, [refreshSelectedSection]); + const reviewFileNavigatorRef = useRef(null); const reviewFiles = parsedDiff.kind === "files" ? parsedDiff.files : []; const fileVisibility = useReviewFileVisibility({ threadKey: reviewCache.threadKey, @@ -179,6 +430,43 @@ export function ReviewSheet() { canHighlight: parsedDiff.kind === "files", }); + const handleSelectFile = useCallback( + (fileId: string | null) => { + commentSelection.clearSelection(); + if (fileId !== null && collapsedFileIds.includes(fileId)) { + toggleExpandedFile(fileId); + } + const navigation = + fileId === null + ? nativeReviewDiffViewRef.current?.scrollToTop(true) + : nativeReviewDiffViewRef.current?.scrollToFile(fileId, true); + void navigation?.catch((error: unknown) => { + console.error("[review] Failed to navigate to diff file", error); + }); + }, + [collapsedFileIds, commentSelection, toggleExpandedFile], + ); + const handleVisibleFileChange = useCallback( + (event: NativeSyntheticEvent<{ readonly fileId?: string | null }>) => { + reviewFileNavigatorRef.current?.setVisibleFile(event.nativeEvent.fileId ?? null); + }, + [], + ); + const renderInspector = useCallback( + () => ( + + ), + [handleSelectFile, insets.top, nativeReviewDiffData.files, selectedSection?.id], + ); + const handleNativeToggleFile = useCallback( (event: NativeSyntheticEvent<{ readonly fileId?: string }>) => { const { fileId } = event.nativeEvent; @@ -203,6 +491,7 @@ export function ReviewSheet() { parsedDiff.kind === "files" || parsedDiff.kind === "raw" ? parsedDiff.notice : null; const hasCachedSelectedDiff = selectedSection?.diff != null; const hasAnyCachedDiff = reviewSections.some((section) => section.diff != null); + const sectionMenu = useMemo(() => buildReviewSectionMenu(reviewSections), [reviewSections]); const { showConnectionNotice, showSectionToolbar } = resolveReviewAvailability({ hasEnvironmentPresentation: environment.isReady, isEnvironmentConnected: isEnvironmentReady, @@ -212,6 +501,22 @@ export function ReviewSheet() { const handleRetryEnvironment = useCallback(() => { void retryEnvironment(environmentId); }, [environmentId, retryEnvironment]); + const handleReturnToThread = useCallback(() => { + if (navigation.canGoBack()) { + navigation.goBack(); + return; + } + navigation.navigate("Thread", { + environmentId: String(environmentId), + threadId: String(threadId), + }); + }, [environmentId, navigation, threadId]); + + // The changed-files navigator lives in the workspace inspector column — + // the single right-hand pane per route — instead of an in-screen panel. + const showChangedFilesPane = + !showConnectionNotice && selectedSection !== null && parsedDiff.kind === "files"; + useRegisterWorkspaceInspector(showChangedFilesPane ? renderInspector : undefined); const listHeader = useMemo(() => { const children: ReactElement[] = []; @@ -220,7 +525,7 @@ export function ReviewSheet() { children.push( Review unavailable - {error} + {error} , ); } @@ -235,134 +540,117 @@ export function ReviewSheet() { return <>{children}; }, [error, parsedDiffNotice]); + const headerSubtitle = [ + headerDiffSummary.additions, + headerDiffSummary.deletions, + pendingReviewCommentCount > 0 + ? `${pendingReviewCommentCount} comment${pendingReviewCommentCount === 1 ? "" : "s"}` + : null, + ] + .filter(Boolean) + .join(" · "); + const headerTitleText = selectedSection?.title ?? "Review changes"; return ( <> - ( - - - Files Changed - - - {headerDiffSummary.additions && headerDiffSummary.deletions ? ( - <> - - {headerDiffSummary.additions} - - - {headerDiffSummary.deletions} - - {pendingReviewCommentCount > 0 ? ( - - {pendingReviewCommentCount} pending - - ) : null} - - ) : ( - - - {selectedSection?.title ?? "Review changes"} - - {pendingReviewCommentCount > 0 ? ( - - {pendingReviewCommentCount} pending - - ) : null} - - )} - - - ), + headerTitle: headerTitleText, + title: headerTitleText, + unstable_headerSubtitle: + Platform.OS === "ios" && headerSubtitle.length > 0 ? headerSubtitle : undefined, }} /> - {showSectionToolbar ? ( - - - {reviewSections.map((section) => ( - selectSection(section.id)} - subtitle={section.subtitle ?? undefined} - > - {section.title} - - ))} - + + + + {showSectionToolbar || panes.supportsAuxiliaryPane || gitMenuAvailable ? ( + + {panes.supportsAuxiliaryPane ? ( + void refreshSelectedSection()} - subtitle="Reload current diff" - > - Refresh - - - + icon="sidebar.right" + onPress={toggleAuxiliaryPane} + separateBackground + /> + ) : null} + {gitMenuAvailable && selectedThread !== null ? ( + + ) : null} + {showSectionToolbar ? ( + + + { + if (sectionMenu.workingTree) { + selectSection(sectionMenu.workingTree.id); + } + }} + > + Working tree + + { + if (sectionMenu.branchChanges) { + selectSection(sectionMenu.branchChanges.id); + } + }} + > + Branch changes + + { + if (sectionMenu.latestTurn) { + selectSection(sectionMenu.latestTurn.id); + } + }} + > + Latest turn + + {sectionMenu.turns.length > 0 ? ( + + {sectionMenu.turns.map((section) => ( + selectSection(section.id)} + subtitle={section.subtitle ?? undefined} + > + {section.title} + + ))} + + ) : null} + + + ) : null} + ) : null} @@ -386,34 +674,42 @@ export function ReviewSheet() { className="flex-1" style={{ backgroundColor: nativeBridge.theme.background, - paddingTop: topContentInset + REVIEW_HEADER_SPACING, }} > - {listHeader} - - + + {listHeader} + + void handlePullToRefresh()} + style={StyleSheet.absoluteFill} + appearanceScheme={selectedTheme} + collapsedFileIdsJson={nativeBridge.collapsedFileIdsJson} + collapsedCommentIdsJson={nativeBridge.collapsedCommentIdsJson} + contentResetKey={`${reviewCache.threadKey}:${selectedSection.id}`} + contentWidth={NATIVE_REVIEW_DIFF_CONTENT_WIDTH} + nativeViewRef={nativeReviewDiffViewRef} + rowHeight={nativeReviewDiffStyle.rowHeight} + rowsJson={nativeBridge.rowsJson} + selectedRowIdsJson={nativeBridge.selectedRowIdsJson} + styleJson={nativeBridge.styleJson} + themeJson={nativeBridge.themeJson} + tokensPatchJson={nativeBridge.tokensPatchJson} + tokensResetKey={nativeBridge.tokensResetKey} + viewedFileIdsJson={nativeBridge.viewedFileIdsJson} + onDebug={nativeBridge.onDebug} + onPressLine={commentSelection.onPressLine} + onVisibleFileChange={handleVisibleFileChange} + onToggleComment={nativeBridge.onToggleComment} + onToggleFile={handleNativeToggleFile} + onToggleViewedFile={handleNativeToggleViewedFile} + /> + ) : ( @@ -432,7 +728,7 @@ export function ReviewSheet() { {!selectedSection ? ( No review diffs - + This thread has no ready turn diffs and the worktree diff is empty. @@ -444,17 +740,17 @@ export function ReviewSheet() { ) : parsedDiff.kind === "empty" ? ( No changes - + {selectedSection.subtitle ?? "This diff is empty."} ) : parsedDiff.kind === "raw" ? ( - + {parsedDiff.reason} - + {parsedDiff.text} diff --git a/apps/mobile/src/features/review/nativeReviewDiffAdapter.test.ts b/apps/mobile/src/features/review/nativeReviewDiffAdapter.test.ts new file mode 100644 index 00000000000..1722b06d6f8 --- /dev/null +++ b/apps/mobile/src/features/review/nativeReviewDiffAdapter.test.ts @@ -0,0 +1,56 @@ +import { describe, expect, it } from "vite-plus/test"; + +import { + getCachedNativeReviewDiffData, + type BuildNativeReviewDiffDataInput, +} from "./nativeReviewDiffAdapter"; +import type { ReviewInlineComment } from "./reviewCommentSelection"; +import { buildReviewParsedDiff } from "./reviewModel"; + +const parsedDiff = buildReviewParsedDiff( + [ + "diff --git a/example.ts b/example.ts", + "--- a/example.ts", + "+++ b/example.ts", + "@@ -1 +1 @@", + "-const before = 1;", + "+const after = 2;", + ].join("\n"), + "native-review-cache-test", +); + +function makeComment(text: string): ReviewInlineComment { + return { + id: "comment-1", + sectionId: "git:working-tree", + sectionTitle: "Dirty worktree", + filePath: "example.ts", + startIndex: 0, + endIndex: 0, + rangeLabel: "-1", + text, + diff: "@@ -1,1 +1,0 @@\n-const before = 1;", + }; +} + +function buildInput(comments: BuildNativeReviewDiffDataInput["comments"]) { + return { parsedDiff, comments } satisfies BuildNativeReviewDiffDataInput; +} + +describe("getCachedNativeReviewDiffData", () => { + it("reuses the row model for equivalent empty comment arrays", () => { + const first = getCachedNativeReviewDiffData(buildInput([])); + const second = getCachedNativeReviewDiffData(buildInput([])); + + expect(second).toBe(first); + }); + + it("reuses equivalent comment contents and invalidates changed comments", () => { + const first = getCachedNativeReviewDiffData(buildInput([makeComment("First")])); + const equivalent = getCachedNativeReviewDiffData(buildInput([makeComment("First")])); + const changed = getCachedNativeReviewDiffData(buildInput([makeComment("Changed")])); + + expect(equivalent).toBe(first); + expect(changed).not.toBe(first); + }); +}); diff --git a/apps/mobile/src/features/review/nativeReviewDiffAdapter.ts b/apps/mobile/src/features/review/nativeReviewDiffAdapter.ts index f60fdfe70e0..6d82940bb02 100644 --- a/apps/mobile/src/features/review/nativeReviewDiffAdapter.ts +++ b/apps/mobile/src/features/review/nativeReviewDiffAdapter.ts @@ -5,6 +5,8 @@ import type { } from "../diffs/nativeReviewDiffTypes"; import * as Arr from "effect/Array"; import { pipe } from "effect/Function"; +import type { ResolvedMobileCodeSurface } from "../../lib/appearancePreferences"; +import { resolveMobileCodeSurface } from "../../lib/appearancePreferences"; import { MOBILE_CODE_SURFACE } from "../../lib/typography"; import { getPierreTerminalTheme, type TerminalAppearanceScheme } from "../terminal/terminalTheme"; import { computeWordAltDiffRanges } from "./reviewWordDiffs"; @@ -22,38 +24,44 @@ const NATIVE_REVIEW_MAX_WORD_DIFF_COVERAGE = 0.45; export const NATIVE_REVIEW_DIFF_ROW_HEIGHT = MOBILE_CODE_SURFACE.rowHeight; export const NATIVE_REVIEW_DIFF_CONTENT_WIDTH = 2_800; -export const NATIVE_REVIEW_DIFF_STYLE = { - rowHeight: NATIVE_REVIEW_DIFF_ROW_HEIGHT, - contentWidth: NATIVE_REVIEW_DIFF_CONTENT_WIDTH, - changeBarWidth: 4, - gutterWidth: MOBILE_CODE_SURFACE.gutterWidth, - codePadding: MOBILE_CODE_SURFACE.codePadding, - textVerticalInset: MOBILE_CODE_SURFACE.textVerticalInset, - fileHeaderHeight: 56, - fileHeaderHorizontalMargin: 8, - fileHeaderVerticalMargin: 6, - fileHeaderCornerRadius: 10, - fileHeaderHorizontalPadding: 10, - fileHeaderPathRightPadding: 118, - fileHeaderCountColumnWidth: 38, - fileHeaderCountGap: 5, - codeFontSize: MOBILE_CODE_SURFACE.fontSize, - codeFontWeight: "regular", - lineNumberFontSize: MOBILE_CODE_SURFACE.lineNumberFontSize, - lineNumberFontWeight: "regular", - hunkFontSize: 11, - hunkFontWeight: "medium", - fileHeaderFontSize: 11, - fileHeaderFontWeight: "semibold", - fileHeaderMetaFontSize: 10, - fileHeaderMetaFontWeight: "semibold", - fileHeaderSubtextFontSize: 11, - fileHeaderSubtextFontWeight: "medium", - fileHeaderStatusFontSize: 9, - fileHeaderStatusFontWeight: "bold", - emptyStateFontSize: 12, - emptyStateFontWeight: "medium", -} as const; +export const NATIVE_REVIEW_DIFF_STYLE = createNativeReviewDiffStyle( + resolveMobileCodeSurface(MOBILE_CODE_SURFACE.fontSize), +); + +export function createNativeReviewDiffStyle(codeSurface: ResolvedMobileCodeSurface) { + return { + rowHeight: codeSurface.rowHeight, + contentWidth: NATIVE_REVIEW_DIFF_CONTENT_WIDTH, + changeBarWidth: 4, + gutterWidth: codeSurface.gutterWidth, + codePadding: codeSurface.codePadding, + textVerticalInset: codeSurface.textVerticalInset, + fileHeaderHeight: 56, + fileHeaderHorizontalMargin: 8, + fileHeaderVerticalMargin: 6, + fileHeaderCornerRadius: 10, + fileHeaderHorizontalPadding: 10, + fileHeaderPathRightPadding: 118, + fileHeaderCountColumnWidth: 38, + fileHeaderCountGap: 5, + codeFontSize: codeSurface.fontSize, + codeFontWeight: "regular", + lineNumberFontSize: codeSurface.lineNumberFontSize, + lineNumberFontWeight: "regular", + hunkFontSize: 11, + hunkFontWeight: "medium", + fileHeaderFontSize: 11, + fileHeaderFontWeight: "semibold", + fileHeaderMetaFontSize: 10, + fileHeaderMetaFontWeight: "semibold", + fileHeaderSubtextFontSize: 11, + fileHeaderSubtextFontWeight: "medium", + fileHeaderStatusFontSize: 9, + fileHeaderStatusFontWeight: "bold", + emptyStateFontSize: 12, + emptyStateFontWeight: "medium", + } as const; +} export interface NativeReviewDiffData { readonly rows: ReadonlyArray; @@ -75,6 +83,33 @@ export interface BuildNativeReviewDiffDataInput { readonly comments?: ReadonlyArray; } +interface CachedNativeReviewDiffData { + readonly commentsKey: string; + readonly data: NativeReviewDiffData; +} + +const nativeReviewDiffDataCache = new WeakMap(); + +function buildReviewCommentsCacheKey(comments: ReadonlyArray): string { + if (comments.length === 0) { + return "none"; + } + + return comments + .map((comment) => + [ + comment.id, + comment.sectionId, + comment.filePath, + comment.startIndex, + comment.endIndex, + comment.rangeLabel, + comment.text, + ].join("\u001f"), + ) + .join("\u001e"); +} + export function createNativeReviewDiffTheme( scheme: TerminalAppearanceScheme, ): NativeReviewDiffTheme { @@ -83,10 +118,12 @@ export function createNativeReviewDiffTheme( if (scheme === "dark") { return { - background: terminalTheme.background, + // Match the app surface (--color-sheet) so code views blend with the rest of + // the app instead of using a distinct code-editor background. + background: "#0e0e0e", text: terminalTheme.foreground, mutedText: terminalTheme.mutedForeground, - headerBackground: terminalTheme.background, + headerBackground: "#0e0e0e", border: terminalTheme.border, hunkBackground: "#071f28", hunkText: terminalBlue ?? "#009fff", @@ -100,10 +137,12 @@ export function createNativeReviewDiffTheme( } return { - background: "#ffffff", + // Match the app surface (--color-sheet) so code views blend with the rest of the + // app instead of using a distinct code-editor background. + background: "#f2f2f7", text: "#070707", mutedText: terminalTheme.mutedForeground, - headerBackground: "#ffffff", + headerBackground: "#f2f2f7", border: terminalTheme.border, hunkBackground: "#e0f2ff", hunkText: terminalBlue ?? "#009fff", @@ -430,3 +469,26 @@ export function buildNativeReviewDiffData( deletions: parsedDiff.deletions, }; } + +/** + * Reuses the expensive flattened native row model across React development + * render probes and unrelated draft updates. Only the latest comment version + * is retained for each parsed diff so editing a comment cannot grow the cache. + */ +export function getCachedNativeReviewDiffData( + input: BuildNativeReviewDiffDataInput, +): NativeReviewDiffData { + const comments = input.comments ?? []; + const commentsKey = buildReviewCommentsCacheKey(comments); + const cached = nativeReviewDiffDataCache.get(input.parsedDiff); + if (cached?.commentsKey === commentsKey) { + return cached.data; + } + + const data = buildNativeReviewDiffData({ + parsedDiff: input.parsedDiff, + comments, + }); + nativeReviewDiffDataCache.set(input.parsedDiff, { commentsKey, data }); + return data; +} diff --git a/apps/mobile/src/features/review/review-section-menu.test.ts b/apps/mobile/src/features/review/review-section-menu.test.ts new file mode 100644 index 00000000000..7eca4ce3b4d --- /dev/null +++ b/apps/mobile/src/features/review/review-section-menu.test.ts @@ -0,0 +1,40 @@ +import { describe, expect, it } from "vite-plus/test"; + +import type { ReviewSectionItem, ReviewSectionKind } from "./reviewModel"; +import { buildReviewSectionMenu } from "./review-section-menu"; + +function section(id: string, kind: ReviewSectionKind): ReviewSectionItem { + return { + id, + kind, + title: id, + subtitle: null, + diff: null, + isLoading: false, + }; +} + +describe("buildReviewSectionMenu", () => { + it("exposes git scopes and the latest turn at the top level", () => { + const turn28 = section("turn:28", "turn"); + const turn27 = section("turn:27", "turn"); + const workingTree = section("git:working-tree", "working-tree"); + const branchChanges = section("git:branch-range", "branch-range"); + + expect(buildReviewSectionMenu([turn28, turn27, workingTree, branchChanges])).toEqual({ + workingTree, + branchChanges, + latestTurn: turn28, + turns: [turn28, turn27], + }); + }); + + it("keeps unavailable scopes empty while data loads", () => { + expect(buildReviewSectionMenu([])).toEqual({ + workingTree: null, + branchChanges: null, + latestTurn: null, + turns: [], + }); + }); +}); diff --git a/apps/mobile/src/features/review/review-section-menu.ts b/apps/mobile/src/features/review/review-section-menu.ts new file mode 100644 index 00000000000..87d10266529 --- /dev/null +++ b/apps/mobile/src/features/review/review-section-menu.ts @@ -0,0 +1,21 @@ +import type { ReviewSectionItem } from "./reviewModel"; + +export interface ReviewSectionMenu { + readonly workingTree: ReviewSectionItem | null; + readonly branchChanges: ReviewSectionItem | null; + readonly latestTurn: ReviewSectionItem | null; + readonly turns: ReadonlyArray; +} + +export function buildReviewSectionMenu( + sections: ReadonlyArray, +): ReviewSectionMenu { + const turns = sections.filter((section) => section.kind === "turn"); + + return { + workingTree: sections.find((section) => section.kind === "working-tree") ?? null, + branchChanges: sections.find((section) => section.kind === "branch-range") ?? null, + latestTurn: turns[0] ?? null, + turns, + }; +} diff --git a/apps/mobile/src/features/review/useNativeReviewDiffBridge.test.ts b/apps/mobile/src/features/review/reviewDiffBridgeKeys.test.ts similarity index 96% rename from apps/mobile/src/features/review/useNativeReviewDiffBridge.test.ts rename to apps/mobile/src/features/review/reviewDiffBridgeKeys.test.ts index cb28a30d92e..9b39a51dc90 100644 --- a/apps/mobile/src/features/review/useNativeReviewDiffBridge.test.ts +++ b/apps/mobile/src/features/review/reviewDiffBridgeKeys.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from "vite-plus/test"; -import { buildNativeReviewTokensResetKey, hashReviewDiffKey } from "./useNativeReviewDiffBridge"; +import { buildNativeReviewTokensResetKey, hashReviewDiffKey } from "./reviewDiffBridgeKeys"; describe("native review diff bridge", () => { it("builds stable reset keys from the rendered diff identity", () => { diff --git a/apps/mobile/src/features/review/reviewDiffBridgeKeys.ts b/apps/mobile/src/features/review/reviewDiffBridgeKeys.ts new file mode 100644 index 00000000000..d04534003b3 --- /dev/null +++ b/apps/mobile/src/features/review/reviewDiffBridgeKeys.ts @@ -0,0 +1,35 @@ +import type { NativeReviewDiffHighlightScheme } from "../diffs/nativeReviewDiffHighlighter"; + +// Pure key-derivation helpers for the native review diff bridge. Kept free of +// react-native / hook imports so they stay unit-testable in node. + +export function hashReviewDiffKey(diff: string | null | undefined): string { + if (!diff) { + return "empty"; + } + + let hash = 5381; + for (let index = 0; index < diff.length; index += 1) { + hash = (hash * 33) ^ diff.charCodeAt(index); + } + + return `${diff.length}:${(hash >>> 0).toString(36)}`; +} + +export function buildNativeReviewTokensResetKey(input: { + readonly threadKey: string | null; + readonly sectionId: string | null; + readonly scheme: NativeReviewDiffHighlightScheme; + readonly diff: string | null | undefined; + readonly fileCount: number; + readonly rowCount: number; +}): string { + return [ + input.threadKey ?? "none", + input.sectionId ?? "none", + input.scheme, + hashReviewDiffKey(input.diff), + input.fileCount, + input.rowCount, + ].join(":"); +} diff --git a/apps/mobile/src/features/review/reviewDiffRendering.tsx b/apps/mobile/src/features/review/reviewDiffRendering.tsx index 14ff0276657..d7f5c5ff8a8 100644 --- a/apps/mobile/src/features/review/reviewDiffRendering.tsx +++ b/apps/mobile/src/features/review/reviewDiffRendering.tsx @@ -13,7 +13,6 @@ export const REVIEW_MONO_FONT_FAMILY = Platform.select({ }); export const REVIEW_DIFF_LINE_HEIGHT = MOBILE_CODE_SURFACE.rowHeight; -const REVIEW_DELETE_STRIPE_COUNT = REVIEW_DIFF_LINE_HEIGHT / 2; export function renderVisibleWhitespace(value: string): string { const expandedTabs = value.replace(/\t/g, " "); @@ -38,12 +37,16 @@ function diffHighlightColor(change: ReviewRenderableLineRow["change"]): string | return undefined; } -export function ReviewChangeBar(props: { readonly change: ReviewRenderableLineRow["change"] }) { +export function ReviewChangeBar(props: { + readonly change: ReviewRenderableLineRow["change"]; + readonly height?: number; +}) { + const height = props.height ?? REVIEW_DIFF_LINE_HEIGHT; if (props.change === "delete") { return ( - + - {Array.from({ length: REVIEW_DELETE_STRIPE_COUNT }, (_, index) => ( + {Array.from({ length: Math.ceil(height / 2) }, (_, index) => ( @@ -55,7 +58,7 @@ export function ReviewChangeBar(props: { readonly change: ReviewRenderableLineRo } return ( - + ); @@ -66,7 +69,11 @@ export function DiffTokenText(props: { readonly fallback: string; readonly change?: ReviewRenderableLineRow["change"]; readonly className?: string; + readonly fontSize?: number; + readonly lineHeight?: number; }) { + const fontSize = props.fontSize ?? MOBILE_CODE_SURFACE.fontSize; + const lineHeight = props.lineHeight ?? MOBILE_CODE_SURFACE.rowHeight; if (!props.tokens || props.tokens.length === 0) { return ( {renderVisibleWhitespace(props.fallback || " ")} @@ -91,8 +98,8 @@ export function DiffTokenText(props: { className={cn("font-normal text-foreground", props.className)} style={{ fontFamily: REVIEW_MONO_FONT_FAMILY, - fontSize: MOBILE_CODE_SURFACE.fontSize, - lineHeight: MOBILE_CODE_SURFACE.rowHeight, + fontSize, + lineHeight, }} > {(() => { diff --git a/apps/mobile/src/features/review/reviewPaneSelection.test.ts b/apps/mobile/src/features/review/reviewPaneSelection.test.ts new file mode 100644 index 00000000000..5b1574d8f42 --- /dev/null +++ b/apps/mobile/src/features/review/reviewPaneSelection.test.ts @@ -0,0 +1,35 @@ +import { describe, expect, it } from "vite-plus/test"; + +import { resolveSelectedReviewFileId } from "./reviewPaneSelection"; + +describe("resolveSelectedReviewFileId", () => { + it("keeps a visible file selected within the active section", () => { + expect( + resolveSelectedReviewFileId({ + selection: { sectionId: "worktree", fileId: "second" }, + sectionId: "worktree", + availableFileIds: ["first", "second"], + }), + ).toBe("second"); + }); + + it("clears selection when the review section changes", () => { + expect( + resolveSelectedReviewFileId({ + selection: { sectionId: "turn-1", fileId: "first" }, + sectionId: "turn-2", + availableFileIds: ["first"], + }), + ).toBeNull(); + }); + + it("clears a file that no longer exists in the diff", () => { + expect( + resolveSelectedReviewFileId({ + selection: { sectionId: "worktree", fileId: "removed" }, + sectionId: "worktree", + availableFileIds: ["first", "second"], + }), + ).toBeNull(); + }); +}); diff --git a/apps/mobile/src/features/review/reviewPaneSelection.ts b/apps/mobile/src/features/review/reviewPaneSelection.ts new file mode 100644 index 00000000000..6f3b7e41c1f --- /dev/null +++ b/apps/mobile/src/features/review/reviewPaneSelection.ts @@ -0,0 +1,16 @@ +export interface ReviewPaneFileSelection { + readonly sectionId: string | null; + readonly fileId: string | null; +} + +export function resolveSelectedReviewFileId(input: { + readonly selection: ReviewPaneFileSelection; + readonly sectionId: string | null; + readonly availableFileIds: ReadonlyArray; +}): string | null { + if (input.selection.sectionId !== input.sectionId || input.selection.fileId === null) { + return null; + } + + return input.availableFileIds.includes(input.selection.fileId) ? input.selection.fileId : null; +} diff --git a/apps/mobile/src/features/review/useNativeReviewDiffBridge.ts b/apps/mobile/src/features/review/useNativeReviewDiffBridge.ts index d0030e51a4a..d28e45844f6 100644 --- a/apps/mobile/src/features/review/useNativeReviewDiffBridge.ts +++ b/apps/mobile/src/features/review/useNativeReviewDiffBridge.ts @@ -2,43 +2,12 @@ import { useCallback, useMemo, useState } from "react"; import type { NativeSyntheticEvent } from "react-native"; import { type NativeReviewDiffHighlightScheme } from "../diffs/nativeReviewDiffHighlighter"; -import { - createNativeReviewDiffTheme, - NATIVE_REVIEW_DIFF_STYLE, - type NativeReviewDiffData, -} from "./nativeReviewDiffAdapter"; +import { createNativeReviewDiffTheme, type NativeReviewDiffData } from "./nativeReviewDiffAdapter"; +import { useAppearanceCodeSurface } from "../settings/appearance/useAppearanceCodeSurface"; import { useNativeReviewDiffHighlighting } from "./useNativeReviewDiffHighlighting"; +import { buildNativeReviewTokensResetKey } from "./reviewDiffBridgeKeys"; -export function hashReviewDiffKey(diff: string | null | undefined): string { - if (!diff) { - return "empty"; - } - - let hash = 5381; - for (let index = 0; index < diff.length; index += 1) { - hash = (hash * 33) ^ diff.charCodeAt(index); - } - - return `${diff.length}:${(hash >>> 0).toString(36)}`; -} - -export function buildNativeReviewTokensResetKey(input: { - readonly threadKey: string | null; - readonly sectionId: string | null; - readonly scheme: NativeReviewDiffHighlightScheme; - readonly diff: string | null | undefined; - readonly fileCount: number; - readonly rowCount: number; -}): string { - return [ - input.threadKey ?? "none", - input.sectionId ?? "none", - input.scheme, - hashReviewDiffKey(input.diff), - input.fileCount, - input.rowCount, - ].join(":"); -} +export { buildNativeReviewTokensResetKey, hashReviewDiffKey } from "./reviewDiffBridgeKeys"; export function useNativeReviewDiffBridge(input: { readonly threadKey: string | null; @@ -62,6 +31,7 @@ export function useNativeReviewDiffBridge(input: { threadKey, viewedFileIds, } = input; + const { nativeReviewDiffStyle } = useAppearanceCodeSurface(); const [collapsedCommentIds, setCollapsedCommentIds] = useState>( () => new Set(), ); @@ -76,7 +46,7 @@ export function useNativeReviewDiffBridge(input: { [collapsedCommentIds], ); const themeJson = useMemo(() => JSON.stringify(theme), [theme]); - const styleJson = useMemo(() => JSON.stringify(NATIVE_REVIEW_DIFF_STYLE), []); + const styleJson = useMemo(() => JSON.stringify(nativeReviewDiffStyle), [nativeReviewDiffStyle]); const tokensResetKey = useMemo( () => buildNativeReviewTokensResetKey({ diff --git a/apps/mobile/src/features/review/useReviewCommentSelectionController.ts b/apps/mobile/src/features/review/useReviewCommentSelectionController.ts index ae1dc6a0575..923369a5e9e 100644 --- a/apps/mobile/src/features/review/useReviewCommentSelectionController.ts +++ b/apps/mobile/src/features/review/useReviewCommentSelectionController.ts @@ -1,12 +1,11 @@ import { useCallback, useEffect, useMemo, useState } from "react"; import type { NativeSyntheticEvent } from "react-native"; -import { useRouter } from "expo-router"; +import { useNavigation } from "@react-navigation/native"; import * as Arr from "effect/Array"; import { pipe } from "effect/Function"; import * as Result from "effect/Result"; import type { EnvironmentId, ThreadId } from "@t3tools/contracts"; - import { buildReviewCommentTarget, clearReviewCommentTarget, @@ -34,7 +33,7 @@ export function useReviewCommentSelectionController(input: { readonly nativeReviewDiffData: NativeReviewDiffData; }) { const { environmentId, nativeReviewDiffData, selectedSection, threadId } = input; - const { push } = useRouter(); + const navigation = useNavigation(); const activeCommentTarget = useReviewCommentTarget(); const [pendingNativeCommentSelection, setPendingNativeCommentSelection] = useState(null); @@ -44,11 +43,11 @@ export function useReviewCommentSelectionController(input: { return; } - push({ - pathname: "/threads/[environmentId]/[threadId]/review-comment", - params: { environmentId, threadId }, + navigation.navigate("ThreadReviewComment", { + environmentId, + threadId, }); - }, [environmentId, push, threadId]); + }, [environmentId, navigation, threadId]); const selectedRowIds = useMemo(() => { if ( diff --git a/apps/mobile/src/features/review/useReviewDiffData.ts b/apps/mobile/src/features/review/useReviewDiffData.ts index ee04673dfc0..85aa6b032fa 100644 --- a/apps/mobile/src/features/review/useReviewDiffData.ts +++ b/apps/mobile/src/features/review/useReviewDiffData.ts @@ -1,11 +1,13 @@ import { useEffect, useMemo } from "react"; import { countReviewCommentContexts, parseReviewInlineComments } from "./reviewCommentSelection"; -import { buildNativeReviewDiffData } from "./nativeReviewDiffAdapter"; +import { getCachedNativeReviewDiffData } from "./nativeReviewDiffAdapter"; import { markReviewEvent, measureReviewWork } from "./reviewPerf"; import { getCachedReviewParsedDiff } from "./reviewState"; import type { ReviewParsedDiff, ReviewSectionItem } from "./reviewModel"; +const EMPTY_INLINE_REVIEW_COMMENTS = Object.freeze([]); + function isReviewDiffDebugLoggingEnabled(): boolean { return typeof __DEV__ !== "undefined" ? __DEV__ : false; } @@ -43,6 +45,7 @@ export function useReviewDiffData(input: { readonly draftMessage: string; }) { const { draftMessage, selectedSection, threadKey } = input; + const selectedSectionId = selectedSection?.id ?? null; const parsedDiff = useMemo( () => measureReviewWork("parse-diff", () => @@ -59,17 +62,16 @@ export function useReviewDiffData(input: { () => parseReviewInlineComments(draftMessage), [draftMessage], ); - const selectedSectionInlineComments = useMemo( - () => - selectedSection - ? inlineReviewComments.filter((comment) => comment.sectionId === selectedSection.id) - : [], - [inlineReviewComments, selectedSection], - ); + const selectedSectionInlineComments = useMemo(() => { + if (!selectedSectionId || inlineReviewComments.length === 0) { + return EMPTY_INLINE_REVIEW_COMMENTS; + } + return inlineReviewComments.filter((comment) => comment.sectionId === selectedSectionId); + }, [inlineReviewComments, selectedSectionId]); const nativeReviewDiffData = useMemo( () => measureReviewWork("build-native-diff-data", () => - buildNativeReviewDiffData({ + getCachedNativeReviewDiffData({ parsedDiff, comments: selectedSectionInlineComments, }), diff --git a/apps/mobile/src/features/review/useReviewDiffPrewarming.ts b/apps/mobile/src/features/review/useReviewDiffPrewarming.ts new file mode 100644 index 00000000000..80a2a64dbf6 --- /dev/null +++ b/apps/mobile/src/features/review/useReviewDiffPrewarming.ts @@ -0,0 +1,101 @@ +import { useEffect } from "react"; + +import { getCachedNativeReviewDiffData } from "./nativeReviewDiffAdapter"; +import type { ReviewSectionItem } from "./reviewModel"; +import { getCachedReviewParsedDiff } from "./reviewState"; + +interface IdleDeadlineLike { + readonly didTimeout: boolean; + timeRemaining(): number; +} + +type IdleCallback = (deadline: IdleDeadlineLike) => void; + +function scheduleIdle(callback: IdleCallback): number { + if (typeof globalThis.requestIdleCallback === "function") { + return globalThis.requestIdleCallback(callback, { timeout: 2_000 }); + } + + return setTimeout( + () => callback({ didTimeout: true, timeRemaining: () => 0 }), + 100, + ) as unknown as number; +} + +function cancelIdle(handle: number): void { + if (typeof globalThis.cancelIdleCallback === "function") { + globalThis.cancelIdleCallback(handle); + return; + } + clearTimeout(handle); +} + +export function prewarmReviewDiffSection(input: { + readonly threadKey: string; + readonly section: ReviewSectionItem; +}): void { + const { section, threadKey } = input; + if (section.diff === null) { + return; + } + + const parsedDiff = getCachedReviewParsedDiff({ + threadKey, + sectionId: section.id, + diff: section.diff, + }); + getCachedNativeReviewDiffData({ parsedDiff, comments: [] }); +} + +/** Warms one cached section per idle period, after navigation animations finish. */ +export function useReviewDiffPrewarming(input: { + readonly threadKey: string | null; + readonly sections: ReadonlyArray; + readonly selectedSectionId: string | null; +}): void { + const { sections, selectedSectionId, threadKey } = input; + + useEffect(() => { + if (!threadKey) { + return; + } + + const pendingSections = sections.filter( + (section) => section.id !== selectedSectionId && section.diff !== null, + ); + if (pendingSections.length === 0) { + return; + } + + let cancelled = false; + let idleHandle: number | null = null; + let nextSectionIndex = 0; + + const scheduleNext = () => { + idleHandle = scheduleIdle(() => { + if (cancelled) { + return; + } + + const section = pendingSections[nextSectionIndex]; + if (!section) { + return; + } + nextSectionIndex += 1; + prewarmReviewDiffSection({ threadKey, section }); + + if (nextSectionIndex < pendingSections.length) { + scheduleNext(); + } + }); + }; + + scheduleNext(); + return () => { + cancelled = true; + if (idleHandle !== null) { + cancelIdle(idleHandle); + } + }; + }, [sections, selectedSectionId, threadKey]); +} diff --git a/apps/mobile/src/features/settings/SettingsAppearanceRouteScreen.tsx b/apps/mobile/src/features/settings/SettingsAppearanceRouteScreen.tsx new file mode 100644 index 00000000000..46b7d210236 --- /dev/null +++ b/apps/mobile/src/features/settings/SettingsAppearanceRouteScreen.tsx @@ -0,0 +1,30 @@ +import { ScrollView, View } from "react-native"; +import { useSafeAreaInsets } from "react-native-safe-area-context"; + +import { CodeAppearanceSection } from "./appearance/sections/CodeAppearanceSection"; +import { TerminalAppearanceSection } from "./appearance/sections/TerminalAppearanceSection"; +import { TextAppearanceSection } from "./appearance/sections/TextAppearanceSection"; + +export function SettingsAppearanceRouteScreen() { + const insets = useSafeAreaInsets(); + + return ( + + + + + + + + ); +} diff --git a/apps/mobile/src/features/settings/SettingsAuthRouteScreen.tsx b/apps/mobile/src/features/settings/SettingsAuthRouteScreen.tsx new file mode 100644 index 00000000000..f6f92f5fc84 --- /dev/null +++ b/apps/mobile/src/features/settings/SettingsAuthRouteScreen.tsx @@ -0,0 +1,39 @@ +import { useAuth } from "@clerk/expo"; +import { AuthView, UserProfileView } from "@clerk/expo/native"; +import { StackActions, useNavigation } from "@react-navigation/native"; +import { NativeStackScreenOptions } from "../../native/StackHeader"; +import { useEffect } from "react"; +import { View } from "react-native"; + +import { hasCloudPublicConfig } from "../cloud/publicConfig"; + +export function SettingsAuthRouteScreen() { + const navigation = useNavigation(); + + useEffect(() => { + if (!hasCloudPublicConfig()) { + navigation.dispatch(StackActions.replace("Settings")); + } + }, [navigation]); + + return hasCloudPublicConfig() ? : null; +} + +function ConfiguredSettingsAuthRouteScreen() { + const { isLoaded, isSignedIn } = useAuth({ treatPendingAsSignedOut: false }); + + return ( + <> + + + {isLoaded ? ( + isSignedIn ? ( + + ) : ( + + ) + ) : null} + + + ); +} diff --git a/apps/mobile/src/app/settings/environments.tsx b/apps/mobile/src/features/settings/SettingsEnvironmentsRouteScreen.tsx similarity index 91% rename from apps/mobile/src/app/settings/environments.tsx rename to apps/mobile/src/features/settings/SettingsEnvironmentsRouteScreen.tsx index 8f65c630a54..c8861ec75a1 100644 --- a/apps/mobile/src/app/settings/environments.tsx +++ b/apps/mobile/src/features/settings/SettingsEnvironmentsRouteScreen.tsx @@ -1,5 +1,6 @@ import { useAuth } from "@clerk/expo"; -import { Stack, useRouter } from "expo-router"; +import { NativeHeaderToolbar } from "../../native/StackHeader"; +import { useNavigation } from "@react-navigation/native"; import { SymbolView } from "expo-symbols"; import { connectionStatusText, @@ -22,26 +23,26 @@ import { AppText as Text } from "../../components/AppText"; import { type RelayEnvironmentView, useConnectionController, -} from "../../features/connection/useConnectionController"; -import { hasCloudPublicConfig } from "../../features/cloud/publicConfig"; -import { availableCloudEnvironmentPresentation } from "../../features/cloud/cloudEnvironmentPresentation"; -import { ConnectionEnvironmentRow } from "../../features/connection/ConnectionEnvironmentRow"; -import { ConnectionStatusDot } from "../../features/connection/ConnectionStatusDot"; -import { splitEnvironmentSections } from "../../features/connection/environmentSections"; +} from "../connection/useConnectionController"; +import { hasCloudPublicConfig } from "../cloud/publicConfig"; +import { availableCloudEnvironmentPresentation } from "../cloud/cloudEnvironmentPresentation"; +import { ConnectionEnvironmentRow } from "../connection/ConnectionEnvironmentRow"; +import { ConnectionStatusDot } from "../connection/ConnectionStatusDot"; +import { splitEnvironmentSections } from "../connection/environmentSections"; import { cn } from "../../lib/cn"; import { copyTextWithHaptic } from "../../lib/copyTextWithHaptic"; import { useThemeColor } from "../../lib/useThemeColor"; import type { ConnectedEnvironmentSummary } from "../../state/remote-runtime-types"; import { useRemoteConnections } from "../../state/use-remote-environment-registry"; -export default function SettingsEnvironmentsRouteScreen() { +export function SettingsEnvironmentsRouteScreen() { const { connectedEnvironments, onReconnectEnvironment, onRemoveEnvironmentPress, onUpdateEnvironment, } = useRemoteConnections(); - const router = useRouter(); + const navigation = useNavigation(); const insets = useSafeAreaInsets(); const { localEnvironments, connectedCloudEnvironments } = splitEnvironmentSections({ connectedEnvironments, @@ -50,6 +51,7 @@ export default function SettingsEnvironmentsRouteScreen() { const hasLocalEnvironments = localEnvironments.length > 0; const [expandedId, setExpandedId] = useState(null); const accentColor = useThemeColor("--color-icon-muted"); + const headerIconColor = useThemeColor("--color-icon"); const handleToggle = useCallback((environmentId: EnvironmentId) => { setExpandedId((prev) => (prev === environmentId ? null : environmentId)); @@ -57,18 +59,14 @@ export default function SettingsEnvironmentsRouteScreen() { return ( - - - + router.push("/settings/environment-new")} + onPress={() => navigation.navigate("SettingsSheet", { screen: "SettingsEnvironmentNew" })} separateBackground + tintColor={headerIconColor} /> - + - + No environments connected yet.{"\n"}Tap{" "} + to add one. @@ -204,7 +202,7 @@ function ConfiguredCloudEnvironmentRows(props: { ) : controller.relayDiscovery.isRefreshing ? ( - + Loading linked cloud environments. @@ -213,16 +211,14 @@ function ConfiguredCloudEnvironmentRows(props: { Could not load T3 Cloud environments - - {controller.relayDiscovery.error} - + {controller.relayDiscovery.error} {controller.relayDiscovery.errorTraceId ? ( ) : null} ) : ( - + No additional linked cloud environments. @@ -361,7 +357,7 @@ function CloudEnvironmentRowShell(props: { {props.label} @@ -370,7 +366,7 @@ function CloudEnvironmentRowShell(props: { {props.connectionError ? ( @@ -384,7 +380,7 @@ function CloudEnvironmentRowShell(props: { className="min-w-0 flex-row items-start gap-1" > {statusText} @@ -394,7 +390,7 @@ function CloudEnvironmentRowShell(props: { { event.stopPropagation(); copyTextWithHaptic(errorTraceId, { target: "connection-trace-id" }); diff --git a/apps/mobile/src/app/settings/index.tsx b/apps/mobile/src/features/settings/SettingsRouteScreen.tsx similarity index 73% rename from apps/mobile/src/app/settings/index.tsx rename to apps/mobile/src/features/settings/SettingsRouteScreen.tsx index 41799ae7b8b..11d363dbf69 100644 --- a/apps/mobile/src/app/settings/index.tsx +++ b/apps/mobile/src/features/settings/SettingsRouteScreen.tsx @@ -1,11 +1,11 @@ import { useAuth, useUser } from "@clerk/expo"; import * as Notifications from "expo-notifications"; -import { Link, Stack, useRouter } from "expo-router"; +import { useNavigation } from "@react-navigation/native"; +import { NativeStackScreenOptions } from "../../native/StackHeader"; import { SymbolView } from "expo-symbols"; import * as Effect from "effect/Effect"; import { useCallback, useEffect, useMemo, useState } from "react"; -import type { ComponentProps, ReactNode } from "react"; -import { Alert, Linking, Pressable, ScrollView, Switch, View } from "react-native"; +import { Alert, Linking, Platform, ScrollView, View } from "react-native"; import { useSafeAreaInsets } from "react-native-safe-area-context"; import { @@ -16,25 +16,51 @@ import { squashAtomCommandFailure, } from "@t3tools/client-runtime/state/runtime"; import { AppText as Text } from "../../components/AppText"; -import { setLiveActivityUpdatesEnabled } from "../../features/agent-awareness/liveActivityPreferences"; -import { requestAgentNotificationPermission } from "../../features/agent-awareness/notificationPermissions"; -import { refreshAgentAwarenessRegistration } from "../../features/agent-awareness/remoteRegistration"; -import { refreshManagedRelayEnvironments } from "../../features/cloud/managedRelayState"; -import { useClerkSettingsSheetDetent } from "../../features/cloud/ClerkSettingsSheetDetent"; -import { - hasCloudPublicConfig, - resolveRelayClerkTokenOptions, -} from "../../features/cloud/publicConfig"; +import { setLiveActivityUpdatesEnabled } from "../agent-awareness/liveActivityPreferences"; +import { requestAgentNotificationPermission } from "../agent-awareness/notificationPermissions"; +import { refreshAgentAwarenessRegistration } from "../agent-awareness/remoteRegistration"; +import { refreshManagedRelayEnvironments } from "../cloud/managedRelayState"; +import { useClerkSettingsSheetDetent } from "../cloud/ClerkSettingsSheetDetent"; +import { hasCloudPublicConfig, resolveRelayClerkTokenOptions } from "../cloud/publicConfig"; +import { withNativeGlassHeaderItem } from "../layout/native-glass-header-items"; +import { WorkspaceSidebarToolbar } from "../layout/workspace-sidebar-toolbar"; import { runtime } from "../../lib/runtime"; import { loadPreferences } from "../../lib/storage"; import { useThemeColor } from "../../lib/useThemeColor"; import { useSavedRemoteConnections } from "../../state/use-remote-environment-registry"; +import { SettingsRow } from "./components/SettingsRow"; +import { SettingsSection } from "./components/SettingsSection"; +import { SettingsSwitchRow } from "./components/SettingsSwitchRow"; type NotificationStatus = "checking" | "enabled" | "disabled" | "unsupported"; type LiveActivityStatus = "checking" | "enabled" | "disabled" | "signed-out" | "linking"; -export default function SettingsRouteScreen() { - return hasCloudPublicConfig() ? : ; +export function SettingsRouteScreen() { + const navigation = useNavigation(); + + return ( + <> + + [ + withNativeGlassHeaderItem({ + accessibilityLabel: "Close settings", + icon: { name: "xmark", type: "sfSymbol" } as const, + identifier: "settings-close", + label: "", + onPress: () => navigation.goBack(), + type: "button", + }), + ] + : undefined, + }} + /> + {hasCloudPublicConfig() ? : } + + ); } function LocalSettingsRouteScreen() { @@ -44,7 +70,6 @@ function LocalSettingsRouteScreen() { return ( - + + + + @@ -75,7 +104,7 @@ function LocalSettingsRouteScreen() { function ConfiguredSettingsRouteScreen() { const insets = useSafeAreaInsets(); - const { push } = useRouter(); + const navigation = useNavigation(); const { expand: expandClerkSheet } = useClerkSettingsSheetDetent(); const { getToken, isLoaded, isSignedIn } = useAuth({ treatPendingAsSignedOut: false }); const { user } = useUser(); @@ -186,10 +215,13 @@ function ConfiguredSettingsRouteScreen() { "Live Activity updates require approved T3 Cloud access so relay can deliver updates to this device.", [ { text: "Cancel", style: "cancel" }, - { text: "Continue", onPress: () => push("/settings/waitlist") }, + { + text: "Continue", + onPress: () => navigation.navigate("SettingsSheet", { screen: "SettingsWaitlist" }), + }, ], ); - }, [push]); + }, [navigation]); const linkEnvironments = useCallback(async () => { if (!isSignedIn) { @@ -316,16 +348,15 @@ function ConfiguredSettingsRouteScreen() { const openAccount = useCallback(() => { if (!isLoaded) return; if (!isSignedIn) { - push("/settings/waitlist"); + navigation.navigate("SettingsSheet", { screen: "SettingsWaitlist" }); return; } expandClerkSheet(); - push("/settings/auth"); - }, [expandClerkSheet, isLoaded, isSignedIn, push]); + navigation.navigate("SettingsSheet", { screen: "SettingsAuth" }); + }, [expandClerkSheet, isLoaded, isSignedIn, navigation]); return ( - - + T3 Code works locally without signing in. Cloud features are optional. @@ -356,7 +387,7 @@ function ConfiguredSettingsRouteScreen() { icon="desktopcomputer" label="Environments" value={`${environmentCount}`} - href="/settings/environments" + target="SettingsEnvironments" /> + + + + @@ -384,22 +419,6 @@ function ConfiguredSettingsRouteScreen() { ); } -type SymbolName = ComponentProps["name"]; - -function SettingsSection(props: { readonly title: string; readonly children: ReactNode }) { - return ( - - {props.title} - - {props.children} - - - ); -} - function AppSettingsSection() { const icon = useThemeColor("--color-icon"); @@ -423,93 +442,7 @@ function AppSettingsSection() { function ArchivedThreadsSettingsSection() { return ( - + ); } - -function SettingsRow(props: { - readonly disabled?: boolean; - readonly icon: SymbolName; - readonly label: string; - readonly value?: string; - readonly href?: "/settings/archive" | "/settings/environments"; - readonly onPress?: () => void; -}) { - const icon = useThemeColor("--color-icon"); - const chevron = useThemeColor("--color-chevron"); - const content = ( - - - - {props.label} - - - {props.value ? ( - - {props.value} - - ) : null} - - - - ); - - if (props.href) { - return ( - - - {content} - - - ); - } - - return ( - - {content} - - ); -} - -function SettingsSwitchRow(props: { - readonly disabled?: boolean; - readonly icon: SymbolName; - readonly label: string; - readonly value: boolean; - readonly onValueChange: (value: boolean) => void; -}) { - const icon = useThemeColor("--color-icon"); - const activeTrack = String(useThemeColor("--color-switch-active")); - const track = String(useThemeColor("--color-secondary-border")); - - return ( - - - {props.label} - - - ); -} diff --git a/apps/mobile/src/app/settings/waitlist.tsx b/apps/mobile/src/features/settings/SettingsWaitlistRouteScreen.tsx similarity index 52% rename from apps/mobile/src/app/settings/waitlist.tsx rename to apps/mobile/src/features/settings/SettingsWaitlistRouteScreen.tsx index 3fc2fad2695..f5182307ebb 100644 --- a/apps/mobile/src/app/settings/waitlist.tsx +++ b/apps/mobile/src/features/settings/SettingsWaitlistRouteScreen.tsx @@ -1,36 +1,41 @@ import { useAuth } from "@clerk/expo"; -import { Redirect, Stack, useFocusEffect, useRouter } from "expo-router"; +import { StackActions, useFocusEffect, useNavigation } from "@react-navigation/native"; import { useCallback } from "react"; import { ScrollView } from "react-native"; -import { CloudWaitlistEnrollment } from "../../features/cloud/CloudWaitlistEnrollment"; -import { useClerkSettingsSheetDetent } from "../../features/cloud/ClerkSettingsSheetDetent"; -import { hasCloudPublicConfig } from "../../features/cloud/publicConfig"; +import { CloudWaitlistEnrollment } from "../cloud/CloudWaitlistEnrollment"; +import { useClerkSettingsSheetDetent } from "../cloud/ClerkSettingsSheetDetent"; +import { hasCloudPublicConfig } from "../cloud/publicConfig"; -export default function SettingsWaitlistRouteScreen() { - return hasCloudPublicConfig() ? ( - - ) : ( - +export function SettingsWaitlistRouteScreen() { + const navigation = useNavigation(); + + useFocusEffect( + useCallback(() => { + if (!hasCloudPublicConfig()) { + navigation.dispatch(StackActions.replace("Settings")); + } + }, [navigation]), ); + + return hasCloudPublicConfig() ? : null; } function ConfiguredSettingsWaitlistRouteScreen() { const { isLoaded, isSignedIn } = useAuth({ treatPendingAsSignedOut: false }); const { expand } = useClerkSettingsSheetDetent(); - const router = useRouter(); + const navigation = useNavigation(); useFocusEffect( useCallback(() => { if (isLoaded && isSignedIn) { - router.replace("/settings"); + navigation.dispatch(StackActions.replace("Settings")); } - }, [isLoaded, isSignedIn, router]), + }, [isLoaded, isSignedIn, navigation]), ); return ( <> - { expand(); - router.push("/settings/auth"); + navigation.navigate("SettingsSheet", { screen: "SettingsAuth" }); }} /> diff --git a/apps/mobile/src/features/settings/appearance/AppearancePreferencesProvider.tsx b/apps/mobile/src/features/settings/appearance/AppearancePreferencesProvider.tsx new file mode 100644 index 00000000000..8d8ae322e13 --- /dev/null +++ b/apps/mobile/src/features/settings/appearance/AppearancePreferencesProvider.tsx @@ -0,0 +1,158 @@ +import { + createContext, + useCallback, + useContext, + useEffect, + useMemo, + useState, + type ReactNode, +} from "react"; + +import { Uniwind } from "uniwind"; + +import { + resolveAppearance, + resolveAppearancePreferences, + resolveTextScaleVariables, + type AppearancePreferences, + type ResolvedAppearance, +} from "../../../lib/appearancePreferences"; +import { loadPreferences, savePreferencesPatch } from "../../../lib/storage"; +import { cacheTerminalFontSize } from "../../terminal/terminalUiState"; + +interface AppearancePreferencesContextValue { + /** Effective values with base-size derivation applied. Use this for rendering. */ + readonly appearance: ResolvedAppearance; + readonly isReady: boolean; + readonly setBaseFontSize: (value: number) => void; + /** Pass null to clear the override and follow the base font size. */ + readonly setTerminalFontSize: (value: number | null) => void; + /** Pass null to clear the override and follow the base font size. */ + readonly setCodeFontSize: (value: number | null) => void; + readonly setCodeWordBreak: (value: boolean) => void; +} + +const AppearancePreferencesContext = createContext(null); + +/** + * Injects the scaled `--text-*` variables into Uniwind so every + * className-based text size (`text-sm`, `text-base`, ...) re-resolves live. + * Updates the current theme last so the active stylesheet settles correctly. + */ +function applyTextScaleVariables(baseFontSize: number) { + const variables = resolveTextScaleVariables(baseFontSize); + const currentTheme = Uniwind.currentTheme; + + for (const theme of ["light", "dark"] as const) { + if (theme !== currentTheme) { + Uniwind.updateCSSVariables(theme, variables); + } + } + Uniwind.updateCSSVariables(currentTheme, variables); +} + +export function AppearancePreferencesProvider(props: { readonly children: ReactNode }) { + const [preferences, setPreferences] = useState(() => + resolveAppearancePreferences(null), + ); + const [isReady, setIsReady] = useState(false); + + useEffect(() => { + let cancelled = false; + + void loadPreferences() + .then((stored) => { + if (cancelled) { + return; + } + + const resolved = resolveAppearancePreferences(stored); + setPreferences(resolved); + cacheTerminalFontSize(resolveAppearance(resolved).terminalFontSize); + setIsReady(true); + }) + .catch(() => { + if (cancelled) { + return; + } + + setIsReady(true); + }); + + return () => { + cancelled = true; + }; + }, []); + + useEffect(() => { + applyTextScaleVariables(preferences.baseFontSize); + }, [preferences.baseFontSize]); + + const updatePreferences = useCallback((patch: Partial) => { + setPreferences((current) => { + const next = resolveAppearancePreferences({ ...current, ...patch }); + cacheTerminalFontSize(resolveAppearance(next).terminalFontSize); + void savePreferencesPatch({ + baseFontSize: next.baseFontSize, + terminalFontSize: next.terminalFontSize, + codeFontSize: next.codeFontSize, + codeWordBreak: next.codeWordBreak, + }).catch(() => undefined); + return next; + }); + }, []); + + const setBaseFontSize = useCallback( + (value: number) => { + updatePreferences({ baseFontSize: value }); + }, + [updatePreferences], + ); + + const setTerminalFontSize = useCallback( + (value: number | null) => { + updatePreferences({ terminalFontSize: value }); + }, + [updatePreferences], + ); + + const setCodeFontSize = useCallback( + (value: number | null) => { + updatePreferences({ codeFontSize: value }); + }, + [updatePreferences], + ); + + const setCodeWordBreak = useCallback( + (value: boolean) => { + updatePreferences({ codeWordBreak: value }); + }, + [updatePreferences], + ); + + const value = useMemo( + (): AppearancePreferencesContextValue => ({ + appearance: resolveAppearance(preferences), + isReady, + setBaseFontSize, + setTerminalFontSize, + setCodeFontSize, + setCodeWordBreak, + }), + [preferences, isReady, setBaseFontSize, setTerminalFontSize, setCodeFontSize, setCodeWordBreak], + ); + + return ( + + {props.children} + + ); +} + +export function useAppearancePreferences(): AppearancePreferencesContextValue { + const context = useContext(AppearancePreferencesContext); + if (!context) { + throw new Error("useAppearancePreferences must be used within AppearancePreferencesProvider"); + } + return context; +} diff --git a/apps/mobile/src/features/settings/appearance/components/AppearancePreviews.tsx b/apps/mobile/src/features/settings/appearance/components/AppearancePreviews.tsx new file mode 100644 index 00000000000..fe960109634 --- /dev/null +++ b/apps/mobile/src/features/settings/appearance/components/AppearancePreviews.tsx @@ -0,0 +1,177 @@ +import { Platform, ScrollView, View, useColorScheme } from "react-native"; + +import { AppText as Text } from "../../../../components/AppText"; +import { + resolveMarkdownFontSizes, + resolveMobileCodeSurface, +} from "../../../../lib/appearancePreferences"; +import { useThemeColor } from "../../../../lib/useThemeColor"; +import { getPierreTerminalTheme } from "../../../terminal/terminalTheme"; + +const CODE_FONT_FAMILY = Platform.select({ + ios: "ui-monospace", + android: "monospace", + default: "monospace", +}); + +/** Hairline between a section's preview surface and its control rows. */ +export function AppearancePreviewSeparator() { + return ; +} + +/** Live sample of body text rendered at the chosen base font size. */ +export function TextAppearancePreview(props: { readonly fontSize: number }) { + const sizes = resolveMarkdownFontSizes(props.fontSize); + + return ( + + + The quick brown fox jumps over the lazy dog. + + + Messages, labels, and headings scale with this size. + + + ); +} + +/** + * Live terminal sample using the real terminal theme's text colors and font, + * on the shared card background so it reads like the other previews. + */ +export function TerminalAppearancePreview(props: { readonly fontSize: number }) { + const scheme = useColorScheme() === "light" ? "light" : "dark"; + const theme = getPierreTerminalTheme(scheme); + const lineHeight = Math.round(props.fontSize * 1.6); + const lineStyle = { + fontFamily: "Menlo", + fontSize: props.fontSize, + lineHeight, + } as const; + + return ( + + $ npm run dev + ✓ Ready in 430ms + + Local: http://localhost:3000{" "} + + + + ); +} + +interface CodePreviewToken { + readonly text: string; + readonly keyword?: boolean; +} + +interface CodePreviewLine { + readonly id: string; + readonly tokens: ReadonlyArray; +} + +const CODE_PREVIEW_LINES: ReadonlyArray = [ + { + id: "signature", + tokens: [{ text: "function", keyword: true }, { text: " formatUser(user) {" }], + }, + { + id: "body", + tokens: [ + { text: " " }, + { text: "return", keyword: true }, + { text: " `${user.name} <${user.email}>` // demonstrates how long lines behave" }, + ], + }, + { id: "close", tokens: [{ text: "}" }] }, +]; + +/** + * Live code sample matching the code & diff surface metrics. Long lines wrap + * when word break is on and scroll horizontally when it is off, mirroring the + * real code surface. + */ +export function CodeAppearancePreview(props: { + readonly fontSize: number; + readonly wordBreak: boolean; +}) { + const surface = resolveMobileCodeSurface(props.fontSize); + const lineNumberColor = useThemeColor("--color-icon-subtle"); + const keywordColor = useThemeColor("--color-md-link"); + + const lineNumber = (line: CodePreviewLine, index: number) => ( + + {index + 1} + + ); + + const codeLine = (line: CodePreviewLine, wrap: boolean) => ( + + {line.tokens.map((token) => ( + + {token.text} + + ))} + + ); + + if (props.wordBreak) { + return ( + + {CODE_PREVIEW_LINES.map((line, index) => ( + + {lineNumber(line, index)} + {codeLine(line, true)} + + ))} + + ); + } + + return ( + + {CODE_PREVIEW_LINES.map((line, index) => lineNumber(line, index))} + + {CODE_PREVIEW_LINES.map((line) => codeLine(line, false))} + + + ); +} diff --git a/apps/mobile/src/features/settings/appearance/components/FontSizeSliderRow.tsx b/apps/mobile/src/features/settings/appearance/components/FontSizeSliderRow.tsx new file mode 100644 index 00000000000..5166ed11db7 --- /dev/null +++ b/apps/mobile/src/features/settings/appearance/components/FontSizeSliderRow.tsx @@ -0,0 +1,214 @@ +import * as Haptics from "expo-haptics"; +import { SymbolView } from "expo-symbols"; +import { useCallback, useEffect, useMemo, useRef } from "react"; +import { View, type AccessibilityActionEvent } from "react-native"; +import { Gesture, GestureDetector } from "react-native-gesture-handler"; +import Animated, { + runOnJS, + useAnimatedStyle, + useSharedValue, + withTiming, +} from "react-native-reanimated"; +import type { ComponentProps } from "react"; + +import { AppText as Text } from "../../../../components/AppText"; +import { useThemeColor } from "../../../../lib/useThemeColor"; + +type SymbolName = ComponentProps["name"]; + +const THUMB_SIZE = 26; +const TRACK_HEIGHT = 4; +const SNAP_ANIMATION = { duration: 120 } as const; + +function clampFraction(value: number): number { + "worklet"; + return Math.min(1, Math.max(0, value)); +} + +export function FontSizeSliderRow(props: { + readonly disabled?: boolean; + readonly icon: SymbolName; + readonly label: string; + readonly valueLabel: string; + readonly min: number; + readonly max: number; + readonly step: number; + readonly value: number; + readonly onChange: (value: number) => void; +}) { + const icon = useThemeColor("--color-icon"); + const iconMuted = String(useThemeColor("--color-icon-muted")); + const trackColor = String(useThemeColor("--color-secondary-border")); + const fillColor = String(useThemeColor("--color-primary")); + + const latest = useRef(props); + latest.current = props; + + const { min, max, step, value, disabled } = props; + const fraction = (value - min) / (max - min); + + const progress = useSharedValue(clampFraction(fraction)); + const trackWidth = useSharedValue(0); + const dragging = useSharedValue(false); + + useEffect(() => { + if (!dragging.value) { + progress.value = withTiming(clampFraction(fraction), SNAP_ANIMATION); + } + }, [dragging, fraction, progress]); + + const commit = useCallback((next: number) => { + if (next === latest.current.value) { + return; + } + Haptics.selectionAsync().catch(() => undefined); + latest.current.onChange(next); + }, []); + + const gesture = useMemo(() => { + const snapValue = (raw: number): number => { + "worklet"; + const stepped = Math.round((raw - min) / step) * step + min; + return Math.min(max, Math.max(min, stepped)); + }; + const fractionAt = (x: number): number => { + "worklet"; + const usable = trackWidth.value - THUMB_SIZE; + if (usable <= 0) { + return 0; + } + return clampFraction((x - THUMB_SIZE / 2) / usable); + }; + const valueAtFraction = (f: number): number => { + "worklet"; + return snapValue(min + f * (max - min)); + }; + const fractionOfValue = (v: number): number => { + "worklet"; + return clampFraction((v - min) / (max - min)); + }; + + const pan = Gesture.Pan() + .enabled(!disabled) + .activeOffsetX([-8, 8]) + .failOffsetY([-12, 12]) + .onUpdate((event) => { + dragging.value = true; + const f = fractionAt(event.x); + progress.value = f; + runOnJS(commit)(valueAtFraction(f)); + }) + .onFinalize(() => { + if (!dragging.value) { + return; + } + dragging.value = false; + progress.value = withTiming( + fractionOfValue(valueAtFraction(progress.value)), + SNAP_ANIMATION, + ); + }); + + const tap = Gesture.Tap() + .enabled(!disabled) + .onEnd((event) => { + const next = valueAtFraction(fractionAt(event.x)); + progress.value = withTiming(fractionOfValue(next), SNAP_ANIMATION); + runOnJS(commit)(next); + }); + + return Gesture.Race(pan, tap); + }, [commit, disabled, dragging, max, min, progress, step, trackWidth]); + + const fillStyle = useAnimatedStyle(() => ({ + width: THUMB_SIZE / 2 + progress.value * Math.max(0, trackWidth.value - THUMB_SIZE), + })); + const thumbStyle = useAnimatedStyle(() => ({ + transform: [{ translateX: progress.value * Math.max(0, trackWidth.value - THUMB_SIZE) }], + })); + + const handleAccessibilityAction = (event: AccessibilityActionEvent) => { + if (event.nativeEvent.actionName === "increment") { + commit(Math.min(max, value + step)); + } else if (event.nativeEvent.actionName === "decrement") { + commit(Math.max(min, value - step)); + } + }; + + return ( + + + + {props.label} + {props.valueLabel} + + + + + { + trackWidth.value = event.nativeEvent.layout.width; + }} + > + + + + + + + + + + ); +} diff --git a/apps/mobile/src/features/settings/appearance/sections/CodeAppearanceSection.tsx b/apps/mobile/src/features/settings/appearance/sections/CodeAppearanceSection.tsx new file mode 100644 index 00000000000..6760504bfa2 --- /dev/null +++ b/apps/mobile/src/features/settings/appearance/sections/CodeAppearanceSection.tsx @@ -0,0 +1,64 @@ +import { useCallback } from "react"; + +import { + CODE_FONT_SIZE_STEP, + MAX_CODE_FONT_SIZE, + MIN_CODE_FONT_SIZE, +} from "../../../../lib/appearancePreferences"; +import { SettingsSection } from "../../components/SettingsSection"; +import { SettingsSwitchRow } from "../../components/SettingsSwitchRow"; +import { useAppearancePreferences } from "../AppearancePreferencesProvider"; +import { + AppearancePreviewSeparator, + CodeAppearancePreview, +} from "../components/AppearancePreviews"; +import { FontSizeSliderRow } from "../components/FontSizeSliderRow"; + +export function CodeAppearanceSection() { + const { isReady, appearance, setCodeFontSize, setCodeWordBreak } = useAppearancePreferences(); + const custom = appearance.isCodeFontSizeCustom; + + const handleToggleCustom = useCallback( + (enabled: boolean) => { + setCodeFontSize(enabled ? appearance.codeFontSize : null); + }, + [appearance.codeFontSize, setCodeFontSize], + ); + + return ( + + + + + {custom ? ( + + ) : null} + + + ); +} diff --git a/apps/mobile/src/features/settings/appearance/sections/TerminalAppearanceSection.tsx b/apps/mobile/src/features/settings/appearance/sections/TerminalAppearanceSection.tsx new file mode 100644 index 00000000000..1e8b5a3fbef --- /dev/null +++ b/apps/mobile/src/features/settings/appearance/sections/TerminalAppearanceSection.tsx @@ -0,0 +1,54 @@ +import { useCallback } from "react"; + +import { + MAX_TERMINAL_FONT_SIZE, + MIN_TERMINAL_FONT_SIZE, + TERMINAL_FONT_SIZE_STEP, +} from "../../../../lib/appearancePreferences"; +import { SettingsSection } from "../../components/SettingsSection"; +import { SettingsSwitchRow } from "../../components/SettingsSwitchRow"; +import { useAppearancePreferences } from "../AppearancePreferencesProvider"; +import { + AppearancePreviewSeparator, + TerminalAppearancePreview, +} from "../components/AppearancePreviews"; +import { FontSizeSliderRow } from "../components/FontSizeSliderRow"; + +export function TerminalAppearanceSection() { + const { isReady, appearance, setTerminalFontSize } = useAppearancePreferences(); + const custom = appearance.isTerminalFontSizeCustom; + + const handleToggleCustom = useCallback( + (enabled: boolean) => { + setTerminalFontSize(enabled ? appearance.terminalFontSize : null); + }, + [appearance.terminalFontSize, setTerminalFontSize], + ); + + return ( + + + + + {custom ? ( + + ) : null} + + ); +} diff --git a/apps/mobile/src/features/settings/appearance/sections/TextAppearanceSection.tsx b/apps/mobile/src/features/settings/appearance/sections/TextAppearanceSection.tsx new file mode 100644 index 00000000000..c1424088f8d --- /dev/null +++ b/apps/mobile/src/features/settings/appearance/sections/TextAppearanceSection.tsx @@ -0,0 +1,34 @@ +import { + BASE_FONT_SIZE_STEP, + MAX_BASE_FONT_SIZE, + MIN_BASE_FONT_SIZE, +} from "../../../../lib/appearancePreferences"; +import { SettingsSection } from "../../components/SettingsSection"; +import { useAppearancePreferences } from "../AppearancePreferencesProvider"; +import { + AppearancePreviewSeparator, + TextAppearancePreview, +} from "../components/AppearancePreviews"; +import { FontSizeSliderRow } from "../components/FontSizeSliderRow"; + +export function TextAppearanceSection() { + const { isReady, appearance, setBaseFontSize } = useAppearancePreferences(); + + return ( + + + + + + ); +} diff --git a/apps/mobile/src/features/settings/appearance/useAppearanceCodeSurface.ts b/apps/mobile/src/features/settings/appearance/useAppearanceCodeSurface.ts new file mode 100644 index 00000000000..62760f1e43f --- /dev/null +++ b/apps/mobile/src/features/settings/appearance/useAppearanceCodeSurface.ts @@ -0,0 +1,34 @@ +import { useMemo } from "react"; + +import { + resolveMobileCodeSurface, + type ResolvedMobileCodeSurface, +} from "../../../lib/appearancePreferences"; +import { createNativeReviewDiffStyle } from "../../review/nativeReviewDiffAdapter"; +import { createNativeSourceStyle } from "../../files/nativeSourceFileAdapter"; +import { useAppearancePreferences } from "./AppearancePreferencesProvider"; + +export function useAppearanceCodeSurface(): { + readonly codeSurface: ResolvedMobileCodeSurface; + readonly codeWordBreak: boolean; + readonly nativeSourceStyle: ReturnType; + readonly nativeReviewDiffStyle: ReturnType; +} { + const { appearance } = useAppearancePreferences(); + const codeSurface = useMemo( + () => resolveMobileCodeSurface(appearance.codeFontSize), + [appearance.codeFontSize], + ); + const nativeSourceStyle = useMemo(() => createNativeSourceStyle(codeSurface), [codeSurface]); + const nativeReviewDiffStyle = useMemo( + () => createNativeReviewDiffStyle(codeSurface), + [codeSurface], + ); + + return { + codeSurface, + codeWordBreak: appearance.codeWordBreak, + nativeSourceStyle, + nativeReviewDiffStyle, + }; +} diff --git a/apps/mobile/src/features/settings/appearance/useScaledTextRole.ts b/apps/mobile/src/features/settings/appearance/useScaledTextRole.ts new file mode 100644 index 00000000000..4224740c26f --- /dev/null +++ b/apps/mobile/src/features/settings/appearance/useScaledTextRole.ts @@ -0,0 +1,36 @@ +import { useCSSVariable } from "uniwind"; + +import { MOBILE_TYPOGRAPHY } from "../../../lib/typography"; + +const TEXT_ROLE_VARIABLES = { + micro: "--text-3xs", + caption: "--text-2xs", + label: "--text-xs", + footnote: "--text-sm", + body: "--text-base", + headline: "--text-lg", + title: "--text-xl", + largeTitle: "--text-2xl", + display: "--text-3xl", +} as const satisfies Record; + +export interface ScaledTextRole { + readonly fontSize: number; + readonly lineHeight: number; +} + +/** + * Reads a typography role's current size from the Uniwind `--text-*` CSS + * variables (scaled at runtime with the base font size). Use for style-prop + * consumers that can't express their size as a `text-*` className. Reactive: + * re-renders when the appearance provider re-injects the variables. + */ +export function useScaledTextRole(role: keyof typeof MOBILE_TYPOGRAPHY): ScaledTextRole { + const variable = TEXT_ROLE_VARIABLES[role]; + const [fontSize, lineHeight] = useCSSVariable([variable, `${variable}--line-height`]); + + return { + fontSize: typeof fontSize === "number" ? fontSize : MOBILE_TYPOGRAPHY[role].fontSize, + lineHeight: typeof lineHeight === "number" ? lineHeight : MOBILE_TYPOGRAPHY[role].lineHeight, + }; +} diff --git a/apps/mobile/src/features/settings/components/SettingsRow.tsx b/apps/mobile/src/features/settings/components/SettingsRow.tsx new file mode 100644 index 00000000000..23089c33950 --- /dev/null +++ b/apps/mobile/src/features/settings/components/SettingsRow.tsx @@ -0,0 +1,76 @@ +import { useNavigation } from "@react-navigation/native"; +import { SymbolView } from "expo-symbols"; +import type { ComponentProps } from "react"; +import { Pressable, View } from "react-native"; + +import { AppText as Text } from "../../../components/AppText"; +import { useThemeColor } from "../../../lib/useThemeColor"; +import type { SettingsSheetTarget } from "./settings-sheet-targets"; + +type SymbolName = ComponentProps["name"]; + +export function SettingsRow(props: { + readonly disabled?: boolean; + readonly icon: SymbolName; + readonly label: string; + readonly value?: string; + readonly target?: SettingsSheetTarget; + readonly onPress?: () => void; +}) { + const navigation = useNavigation(); + const icon = useThemeColor("--color-icon"); + const chevron = useThemeColor("--color-chevron"); + const content = ( + + + + {props.label} + + + {props.value ? ( + + {props.value} + + ) : null} + + + + ); + + const target = props.target; + if (target) { + return ( + + navigation.navigate("SettingsSheet", { + screen: target, + }) + } + > + {content} + + ); + } + + return ( + + {content} + + ); +} diff --git a/apps/mobile/src/features/settings/components/SettingsSection.tsx b/apps/mobile/src/features/settings/components/SettingsSection.tsx new file mode 100644 index 00000000000..7b4f0379161 --- /dev/null +++ b/apps/mobile/src/features/settings/components/SettingsSection.tsx @@ -0,0 +1,18 @@ +import type { ReactNode } from "react"; +import { View } from "react-native"; + +import { AppText as Text } from "../../../components/AppText"; + +export function SettingsSection(props: { readonly title: string; readonly children: ReactNode }) { + return ( + + {props.title} + + {props.children} + + + ); +} diff --git a/apps/mobile/src/features/settings/components/SettingsSwitchRow.tsx b/apps/mobile/src/features/settings/components/SettingsSwitchRow.tsx new file mode 100644 index 00000000000..8620166eff2 --- /dev/null +++ b/apps/mobile/src/features/settings/components/SettingsSwitchRow.tsx @@ -0,0 +1,37 @@ +import { SymbolView } from "expo-symbols"; +import type { ComponentProps } from "react"; +import { Switch, View } from "react-native"; + +import { AppText as Text } from "../../../components/AppText"; +import { useThemeColor } from "../../../lib/useThemeColor"; + +type SymbolName = ComponentProps["name"]; + +export function SettingsSwitchRow(props: { + readonly disabled?: boolean; + readonly icon: SymbolName; + readonly label: string; + readonly value: boolean; + readonly onValueChange: (value: boolean) => void; +}) { + const icon = useThemeColor("--color-icon"); + const activeTrack = String(useThemeColor("--color-switch-active")); + const track = String(useThemeColor("--color-secondary-border")); + + return ( + + + {props.label} + + + ); +} diff --git a/apps/mobile/src/features/settings/components/settings-sheet-targets.ts b/apps/mobile/src/features/settings/components/settings-sheet-targets.ts new file mode 100644 index 00000000000..7de54bceee5 --- /dev/null +++ b/apps/mobile/src/features/settings/components/settings-sheet-targets.ts @@ -0,0 +1 @@ +export type SettingsSheetTarget = "SettingsEnvironments" | "SettingsArchive" | "SettingsAppearance"; diff --git a/apps/mobile/src/features/terminal/NativeTerminalSurface.tsx b/apps/mobile/src/features/terminal/NativeTerminalSurface.tsx index ad693dcb445..ad174a2502d 100644 --- a/apps/mobile/src/features/terminal/NativeTerminalSurface.tsx +++ b/apps/mobile/src/features/terminal/NativeTerminalSurface.tsx @@ -12,7 +12,10 @@ import { import { AppText as Text } from "../../components/AppText"; import { MOBILE_TYPOGRAPHY } from "../../lib/typography"; -import { resolveNativeTerminalSurfaceView } from "./nativeTerminalModule"; +import { + getNativeTerminalHardwareKeyRevision, + resolveNativeTerminalSurfaceView, +} from "./nativeTerminalModule"; import { buildGhosttyThemeConfig, getPierreTerminalTheme, @@ -92,9 +95,9 @@ const FallbackTerminalSurface = memo(function FallbackTerminalSurface(props: Ter > @@ -137,11 +140,11 @@ const FallbackTerminalSurface = memo(function FallbackTerminalSurface(props: Ter placeholder="type and press return" placeholderTextColor={theme.mutedForeground} returnKeyType="send" + className="text-sm" style={{ color: theme.foreground, flex: 1, fontFamily: "Menlo", - fontSize: MOBILE_TYPOGRAPHY.footnote.fontSize, padding: 0, }} onSubmitEditing={(event) => { @@ -163,10 +166,10 @@ const FallbackTerminalSurface = memo(function FallbackTerminalSurface(props: Ter onPress={() => props.onInput("\u0003")} > Ctrl-C @@ -179,7 +182,6 @@ const FallbackTerminalSurface = memo(function FallbackTerminalSurface(props: Ter export const TerminalSurface = memo(function TerminalSurface(props: TerminalSurfaceProps) { const fontSize = props.fontSize ?? MOBILE_TYPOGRAPHY.label.fontSize; - const keyboardInputRef = useRef(null); const appearanceScheme = useColorScheme() === "light" ? "light" : "dark"; const theme = props.theme ?? getPierreTerminalTheme(appearanceScheme); const { onInput, onResize } = props; @@ -190,15 +192,23 @@ export const TerminalSurface = memo(function TerminalSurface(props: TerminalSurf terminalDebugLog("native:surface", { terminalKey: props.terminalKey, native: hasNativeSurface, + // null = installed binary predates native hardware-key handling (rebuild needed). + hardwareKeyRevision: getNativeTerminalHardwareKeyRevision(), bufferLen: props.buffer.length, isRunning: props.isRunning, }); }, [hasNativeSurface, props.buffer.length, props.isRunning, props.terminalKey]); const handleNativeInput = useCallback( (event: NativeSyntheticEvent) => { + if (!props.isRunning) { + return; + } + terminalDebugLog("native:onInput", { + codes: Array.from(event.nativeEvent.data, (char) => char.codePointAt(0)), + }); onInput(event.nativeEvent.data); }, - [onInput], + [onInput, props.isRunning], ); const handleNativeResize = useCallback( (event: NativeSyntheticEvent) => { @@ -210,33 +220,13 @@ export const TerminalSurface = memo(function TerminalSurface(props: TerminalSurf [onResize], ); - // Reopen focus through React and forward input normally; this avoids a native focus-command bridge. - useEffect(() => { - if (!NativeTerminalSurfaceView || (props.keyboardFocusRequest ?? 0) <= 0) { - return undefined; - } - - keyboardInputRef.current?.blur(); - const focusFrame = requestAnimationFrame(() => keyboardInputRef.current?.focus()); - return () => cancelAnimationFrame(focusFrame); - }, [NativeTerminalSurfaceView, props.keyboardFocusRequest]); - - const handleKeyboardInput = useCallback( - (data: string) => { - if (data.length > 0) { - onInput(data); - keyboardInputRef.current?.clear(); - } - }, - [onInput], - ); - if (NativeTerminalSurfaceView) { return ( - { - if (event.nativeEvent.key === "Backspace") { - onInput("\u007f"); - } - }} - onSubmitEditing={() => onInput("\n")} - /> ); } diff --git a/apps/mobile/src/features/terminal/ThreadTerminalRouteScreen.tsx b/apps/mobile/src/features/terminal/ThreadTerminalRouteScreen.tsx index 8e9a47a58b5..6234c40a31f 100644 --- a/apps/mobile/src/features/terminal/ThreadTerminalRouteScreen.tsx +++ b/apps/mobile/src/features/terminal/ThreadTerminalRouteScreen.tsx @@ -1,9 +1,10 @@ import { DEFAULT_TERMINAL_ID, EnvironmentId, ThreadId } from "@t3tools/contracts"; import { type KnownTerminalSession } from "@t3tools/client-runtime/state/terminal"; import { SymbolView } from "expo-symbols"; -import { Stack, useLocalSearchParams, useRouter } from "expo-router"; +import { NativeHeaderToolbar, NativeStackScreenOptions } from "../../native/StackHeader"; +import { StackActions, useNavigation, type StaticScreenProps } from "@react-navigation/native"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; -import { Pressable, Text as RNText, View, useColorScheme } from "react-native"; +import { Platform, Pressable, View, useColorScheme } from "react-native"; import { KeyboardController, KeyboardEvents, @@ -24,8 +25,13 @@ import { useEnvironmentPresentation } from "../../state/presentation"; import { terminalEnvironment } from "../../state/terminal"; import { useAtomCommand } from "../../state/use-atom-command"; import { useWorkspaceState } from "../../state/workspace"; -import { buildThreadTerminalNavigation } from "../../lib/routes"; -import { MOBILE_TYPOGRAPHY } from "../../lib/typography"; +import { + MAX_TERMINAL_FONT_SIZE, + MIN_TERMINAL_FONT_SIZE, + TERMINAL_FONT_SIZE_STEP, + stepTerminalFontSize, +} from "../../lib/appearancePreferences"; +import { useAppearancePreferences } from "../settings/appearance/AppearancePreferencesProvider"; import { useAttachedTerminalSession, useKnownTerminalSessions, @@ -33,9 +39,9 @@ import { import { useThreadSelection } from "../../state/use-thread-selection"; import { useSelectedThreadDetail } from "../../state/use-thread-detail"; import { EnvironmentConnectionNotice } from "../connection/EnvironmentConnectionNotice"; +import { useAdaptiveWorkspaceLayout } from "../layout/AdaptiveWorkspaceLayout"; import { TerminalSurface } from "./NativeTerminalSurface"; import { getPierreTerminalTheme } from "./terminalTheme"; -import { loadPreferences, savePreferencesPatch } from "../../lib/storage"; import { terminalDebugLog } from "./terminalDebugLog"; import { getTerminalBufferReplayKey, @@ -55,19 +61,7 @@ import { resolveTerminalSessionLabel, type TerminalMenuSession, } from "./terminalMenu"; -import { - DEFAULT_TERMINAL_FONT_SIZE, - MAX_TERMINAL_FONT_SIZE, - MIN_TERMINAL_FONT_SIZE, - TERMINAL_FONT_SIZE_STEP, - normalizeTerminalFontSize, -} from "./terminalPreferences"; -import { - cacheTerminalFontSize, - cacheTerminalGridSize, - getCachedTerminalFontSize, - getCachedTerminalGridSize, -} from "./terminalUiState"; +import { cacheTerminalGridSize, getCachedTerminalGridSize } from "./terminalUiState"; const DEFAULT_TERMINAL_COLS = 80; const DEFAULT_TERMINAL_ROWS = 24; @@ -153,19 +147,22 @@ function pickRunningTerminalSessionForBootstrap( ); } -export function ThreadTerminalRouteScreen() { - const router = useRouter(); +type ThreadTerminalRouteScreenProps = StaticScreenProps<{ + readonly environmentId: string; + readonly threadId: string; + readonly terminalId?: string; +}>; + +export function ThreadTerminalRouteScreen(props: ThreadTerminalRouteScreenProps) { + const navigation = useNavigation(); const writeTerminal = useAtomCommand(terminalEnvironment.write, "terminal write"); const resizeTerminal = useAtomCommand(terminalEnvironment.resize, "terminal resize"); const clearTerminal = useAtomCommand(terminalEnvironment.clear, "terminal clear"); const retryEnvironment = useAtomCommand(environmentCatalog.retryNow, "environment retry"); const appearanceScheme = useColorScheme() === "light" ? "light" : "dark"; const { state: workspaceState } = useWorkspaceState(); - const params = useLocalSearchParams<{ - environmentId?: string | string[]; - threadId?: string | string[]; - terminalId?: string | string[]; - }>(); + const { layout, panes, togglePrimarySidebar } = useAdaptiveWorkspaceLayout(); + const params = props.route.params; const { selectedThread, selectedThreadProject, selectedEnvironmentConnection } = useThreadSelection(); const selectedThreadDetail = useSelectedThreadDetail(); @@ -179,7 +176,12 @@ export function ThreadTerminalRouteScreen() { const isEnvironmentReady = environment.presentation?.connection.phase === "connected"; const requestedTerminalId = firstRouteParam(params.terminalId); const terminalId = requestedTerminalId ?? DEFAULT_TERMINAL_ID; - const cachedFontSize = getCachedTerminalFontSize(); + const { + isReady: hasResolvedFontPreference, + appearance, + setTerminalFontSize, + } = useAppearancePreferences(); + const fontSize = appearance.terminalFontSize; const cachedRouteGridSize = routeEnvironmentId && routeThreadId ? getCachedTerminalGridSize({ @@ -239,7 +241,6 @@ export function ThreadTerminalRouteScreen() { rows: DEFAULT_TERMINAL_ROWS, }, ); - const [fontSize, setFontSize] = useState(cachedFontSize ?? DEFAULT_TERMINAL_FONT_SIZE); const [keyboardFocusRequest, setKeyboardFocusRequest] = useState(0); const [isAccessoryDismissed, setIsAccessoryDismissed] = useState(false); const bufferReplayTimerRef = useRef | null>(null); @@ -247,9 +248,6 @@ export function ThreadTerminalRouteScreen() { const lastBufferReplayKeyRef = useRef(null); const sentInitialInputKeyRef = useRef(null); const [readyBufferReplayKey, setReadyBufferReplayKey] = useState(null); - const [hasResolvedFontPreference, setHasResolvedFontPreference] = useState( - cachedFontSize !== null, - ); /** Default grid is always valid for attach; onResize refines cols/rows. Requiring a cached size blocked bootstrap for new terminal routes. */ const [hasMeasuredSurface, setHasMeasuredSurface] = useState(true); const [pendingModifierState, setPendingModifierState] = useState<{ @@ -404,24 +402,10 @@ export function ThreadTerminalRouteScreen() { ); const terminalTheme = getPierreTerminalTheme(appearanceScheme); + const usesNativeHeaderGlass = Platform.OS === "ios"; const pendingModifier = pendingModifierState.terminalId === terminalId ? pendingModifierState.value : null; - const headerTitle = useMemo(() => { - const topLineParts = [ - selectedEnvironmentConnection?.environmentLabel ?? null, - selectedThreadProject?.title ?? null, - ].filter((value): value is string => Boolean(value)); - - return { - topLine: topLineParts.join(" \u00b7 "), - bottomLine: cwd ?? selectedThreadProject?.workspaceRoot ?? "", - }; - }, [ - cwd, - selectedEnvironmentConnection?.environmentLabel, - selectedThreadProject?.title, - selectedThreadProject?.workspaceRoot, - ]); + const headerSubtitle = selectedThreadProject?.title ?? ""; const terminalToolbarActions = useMemo>(() => { const modifierActions: ReadonlyArray = hostPlatform === "mac" @@ -546,8 +530,14 @@ export function ThreadTerminalRouteScreen() { if (!shouldRedirectToRunningTerminal || !selectedThread || !runningSession) { return; } - router.replace(buildThreadTerminalNavigation(selectedThread, runningSession.target.terminalId)); - }, [router, runningSession, selectedThread, shouldRedirectToRunningTerminal]); + navigation.dispatch( + StackActions.replace("ThreadTerminal", { + environmentId: String(selectedThread.environmentId), + threadId: String(selectedThread.id), + terminalId: runningSession.target.terminalId, + }), + ); + }, [navigation, runningSession, selectedThread, shouldRedirectToRunningTerminal]); useEffect(() => { const initialInput = pendingLaunch?.initialInput; @@ -637,42 +627,6 @@ export function ThreadTerminalRouteScreen() { setHasMeasuredSurface(true); }, [routeEnvironmentId, routeThreadId, terminalId]); - useEffect(() => { - let cancelled = false; - - void loadPreferences() - .then((preferences) => { - if (cancelled) { - return; - } - - setFontSize(cacheTerminalFontSize(preferences.terminalFontSize)); - setHasResolvedFontPreference(true); - }) - .catch(() => { - if (cancelled) { - return; - } - - setHasResolvedFontPreference(true); - }); - - return () => { - cancelled = true; - }; - }, []); - - useEffect(() => { - if (!hasResolvedFontPreference) { - return; - } - - cacheTerminalFontSize(fontSize); - void savePreferencesPatch({ - terminalFontSize: normalizeTerminalFontSize(fontSize), - }); - }, [fontSize, hasResolvedFontPreference]); - const writeInput = useCallback( (data: string) => { if (!selectedThread || !isRunning) { @@ -772,9 +726,15 @@ export function ThreadTerminalRouteScreen() { return; } - router.replace(buildThreadTerminalNavigation(selectedThread, nextTerminalId)); + navigation.dispatch( + StackActions.replace("ThreadTerminal", { + environmentId: String(selectedThread.environmentId), + threadId: String(selectedThread.id), + terminalId: nextTerminalId, + }), + ); }, - [router, selectedThread, terminalId], + [navigation, selectedThread, terminalId], ); const handleOpenNewTerminal = useCallback(() => { @@ -782,30 +742,25 @@ export function ThreadTerminalRouteScreen() { return; } - router.replace( - buildThreadTerminalNavigation( - selectedThread, - nextOpenTerminalId({ + navigation.dispatch( + StackActions.replace("ThreadTerminal", { + environmentId: String(selectedThread.environmentId), + threadId: String(selectedThread.id), + terminalId: nextOpenTerminalId({ listedTerminalIds: terminalMenuSessions.map((session) => session.terminalId), activeRouteTerminalId: terminalId, }), - ), + }), ); - }, [router, selectedThread, terminalId, terminalMenuSessions]); - - const adjustFontSize = useCallback((delta: number) => { - setTimeout(() => { - setFontSize((current) => cacheTerminalFontSize(current + delta)); - }, 0); - }, []); + }, [navigation, selectedThread, terminalId, terminalMenuSessions]); const handleDecreaseFontSize = useCallback(() => { - adjustFontSize(-TERMINAL_FONT_SIZE_STEP); - }, [adjustFontSize]); + setTerminalFontSize(stepTerminalFontSize(fontSize, -1)); + }, [fontSize, setTerminalFontSize]); const handleIncreaseFontSize = useCallback(() => { - adjustFontSize(TERMINAL_FONT_SIZE_STEP); - }, [adjustFontSize]); + setTerminalFontSize(stepTerminalFontSize(fontSize, 1)); + }, [fontSize, setTerminalFontSize]); const handleClearTerminal = useCallback(() => { if (!selectedThread) { @@ -898,80 +853,58 @@ export function ThreadTerminalRouteScreen() { return ( <> - ( - - - {headerTitle.topLine} - - - {headerTitle.bottomLine} - - - ), + // Static header config lives in Stack.tsx (SOLID_HEADER_OPTIONS — the pty + // scrolls internally, nothing for glass to sample). Default title/subtitle + // styling, like every other page. + title: "Terminal", + unstable_headerSubtitle: + usesNativeHeaderGlass && headerSubtitle.length > 0 ? headerSubtitle : undefined, }} /> + {layout.usesSplitView ? ( + + + + ) : null} + {isEnvironmentReady ? ( - - - + + + {getTerminalStatusLabel({ status: terminal.status, hasRunningSubprocess: terminal.hasRunningSubprocess, })} - - - Text size - + + Text size + - {`A- ${Math.max(MIN_TERMINAL_FONT_SIZE, fontSize - TERMINAL_FONT_SIZE_STEP).toFixed(1)} pt`} - - {`A- ${Math.max(MIN_TERMINAL_FONT_SIZE, fontSize - TERMINAL_FONT_SIZE_STEP).toFixed(1)} pt`} + + = MAX_TERMINAL_FONT_SIZE} discoverabilityLabel="Increase terminal text size" onPress={handleIncreaseFontSize} > - {`A+ ${Math.min(MAX_TERMINAL_FONT_SIZE, fontSize + TERMINAL_FONT_SIZE_STEP).toFixed(1)} pt`} - - + {`A+ ${Math.min(MAX_TERMINAL_FONT_SIZE, fontSize + TERMINAL_FONT_SIZE_STEP).toFixed(1)} pt`} + + {terminalMenuSessions.map((session) => ( - handleSelectTerminal(session.terminalId)} @@ -982,18 +915,18 @@ export function ThreadTerminalRouteScreen() { .filter(Boolean) .join(" · ")} > - {session.displayLabel} - + {session.displayLabel} + ))} - - Open new terminal - - - + Open new terminal + + + ) : null} diff --git a/apps/mobile/src/features/terminal/nativeTerminalModule.ts b/apps/mobile/src/features/terminal/nativeTerminalModule.ts index e5b1f630073..120b7962f2c 100644 --- a/apps/mobile/src/features/terminal/nativeTerminalModule.ts +++ b/apps/mobile/src/features/terminal/nativeTerminalModule.ts @@ -1,6 +1,6 @@ import type { ComponentType } from "react"; import type { NativeSyntheticEvent, ViewProps } from "react-native"; -import { requireNativeView } from "expo"; +import { requireNativeView, requireOptionalNativeModule } from "expo"; import { NativeViewResolutionError } from "../../native/nativeViewResolutionError"; @@ -23,6 +23,7 @@ interface TerminalResizeEvent { export interface NativeTerminalSurfaceProps extends ViewProps { readonly appearanceScheme?: "light" | "dark"; + readonly focusRequest?: number; readonly themeConfig?: string; readonly backgroundColor?: string; readonly foregroundColor?: string; @@ -74,6 +75,25 @@ export function resolveNativeTerminalSurfaceView(): ComponentType( + NATIVE_TERMINAL_MODULE_NAME, + ); + return module?.hardwareKeyRevision ?? null; + } catch { + return null; + } +} + export function hasNativeTerminalSurface() { return resolveNativeTerminalSurfaceView() !== null; } diff --git a/apps/mobile/src/features/terminal/terminalPreferences.ts b/apps/mobile/src/features/terminal/terminalPreferences.ts index 095876fa2a8..330a31d5f23 100644 --- a/apps/mobile/src/features/terminal/terminalPreferences.ts +++ b/apps/mobile/src/features/terminal/terminalPreferences.ts @@ -1,4 +1,4 @@ -export const DEFAULT_TERMINAL_FONT_SIZE = 10; +export const DEFAULT_TERMINAL_FONT_SIZE = 10.5; export const TERMINAL_FONT_SIZE_STEP = 0.5; export const MIN_TERMINAL_FONT_SIZE = 6; export const MAX_TERMINAL_FONT_SIZE = 14; diff --git a/apps/mobile/src/features/threads/ComposerCommandPopover.tsx b/apps/mobile/src/features/threads/ComposerCommandPopover.tsx index 8b6fe078088..87ac2a6f951 100644 --- a/apps/mobile/src/features/threads/ComposerCommandPopover.tsx +++ b/apps/mobile/src/features/threads/ComposerCommandPopover.tsx @@ -7,8 +7,6 @@ import { Pressable, ScrollView, useColorScheme, View, type ViewStyle } from "rea import { AppText as Text } from "../../components/AppText"; import { PierreEntryIcon } from "../../components/PierreEntryIcon"; -import { MOBILE_TYPOGRAPHY } from "../../lib/typography"; - export type ComposerCommandItem = | { readonly id: string; @@ -165,11 +163,11 @@ const CommandRow = memo(function CommandRow(props: { {props.item.description ? ( diff --git a/apps/mobile/src/features/threads/NewTaskDraftRouteScreen.tsx b/apps/mobile/src/features/threads/NewTaskDraftRouteScreen.tsx new file mode 100644 index 00000000000..e821a9d1bc2 --- /dev/null +++ b/apps/mobile/src/features/threads/NewTaskDraftRouteScreen.tsx @@ -0,0 +1,32 @@ +import type { StaticScreenProps } from "@react-navigation/native"; +import { NativeStackScreenOptions } from "../../native/StackHeader"; + +import { NewTaskDraftScreen } from "./NewTaskDraftScreen"; + +type NewTaskDraftRouteParams = { + readonly environmentId?: string | string[]; + readonly projectId?: string | string[]; + readonly title?: string | string[]; +}; + +export function NewTaskDraftRouteScreen({ route }: StaticScreenProps) { + const params = route.params ?? {}; + + return ( + <> + + + + ); +} diff --git a/apps/mobile/src/features/threads/NewTaskDraftScreen.tsx b/apps/mobile/src/features/threads/NewTaskDraftScreen.tsx index ce24198f5e2..de41e18571f 100644 --- a/apps/mobile/src/features/threads/NewTaskDraftScreen.tsx +++ b/apps/mobile/src/features/threads/NewTaskDraftScreen.tsx @@ -1,4 +1,5 @@ -import { Stack, useRouter } from "expo-router"; +import { NativeStackScreenOptions } from "../../native/StackHeader"; +import { StackActions, useNavigation } from "@react-navigation/native"; import { useCallback, useEffect, useMemo, useRef } from "react"; import { Alert, InteractionManager, View, useColorScheme } from "react-native"; import { KeyboardAvoidingView, useKeyboardState } from "react-native-keyboard-controller"; @@ -29,9 +30,8 @@ import { providerOptionsConfigurationLabel, resolveProviderOptionDescriptors, } from "../../lib/providerOptions"; -import { buildThreadRoutePath } from "../../lib/routes"; import { scopedProjectKey } from "../../lib/scopedEntities"; -import { MOBILE_TYPOGRAPHY } from "../../lib/typography"; +import { useScaledTextRole } from "../settings/appearance/useScaledTextRole"; import { getComposerDraftSnapshot } from "../../state/use-composer-drafts"; import { useProjects } from "../../state/entities"; import { branchBadgeLabel, useNewTaskFlow } from "./new-task-flow-provider"; @@ -58,7 +58,7 @@ export function NewTaskDraftScreen(props: { const projects = useProjects(); const createProjectThread = useCreateProjectThread(); const flow = useNewTaskFlow(); - const router = useRouter(); + const navigation = useNavigation(); const insets = useSafeAreaInsets(); const colorScheme = useColorScheme(); const isKeyboardVisible = useKeyboardState((state) => state.isVisible); @@ -68,6 +68,7 @@ export function NewTaskDraftScreen(props: { const loadedBranchesProjectKeyRef = useRef(null); const borderColor = useThemeColor("--color-border"); + const bodyText = useScaledTextRole("body"); const sheetFadeOpaque = colorScheme === "dark" ? "rgba(14,14,14,0.98)" : "rgba(242,242,247,0.98)"; const sheetFadeTransparent = colorScheme === "dark" ? "rgba(14,14,14,0)" : "rgba(242,242,247,0)"; @@ -101,13 +102,13 @@ export function NewTaskDraftScreen(props: { return; } - router.replace("/new"); + navigation.dispatch(StackActions.replace("NewTask")); }, [ logicalProjects, projects, props.initialProjectRef?.environmentId, props.initialProjectRef?.projectId, - router, + navigation, selectedProject, setProject, ]); @@ -429,20 +430,25 @@ export function NewTaskDraftScreen(props: { flow.setPrompt(""); flow.clearAttachments(); - router.replace(buildThreadRoutePath(result.value)); + navigation.dispatch( + StackActions.replace("Thread", { + environmentId: String(result.value.environmentId), + threadId: String(result.value.threadId), + }), + ); } if (!selectedProject) { return ( - + ); } return ( - + @@ -457,7 +463,7 @@ export function NewTaskDraftScreen(props: { onPasteImages={(uris) => void handleNativePasteImages(uris)} placeholder={`Describe a coding task in ${selectedProject.title}`} style={{ flex: 1, minHeight: 0 }} - textStyle={MOBILE_TYPOGRAPHY.composer} + textStyle={bodyText} /> diff --git a/apps/mobile/src/app/new/index.tsx b/apps/mobile/src/features/threads/NewTaskRouteScreen.tsx similarity index 65% rename from apps/mobile/src/app/new/index.tsx rename to apps/mobile/src/features/threads/NewTaskRouteScreen.tsx index 6e2aa64ce11..896a43d636b 100644 --- a/apps/mobile/src/app/new/index.tsx +++ b/apps/mobile/src/features/threads/NewTaskRouteScreen.tsx @@ -1,4 +1,5 @@ -import { Link, Stack, useRouter } from "expo-router"; +import { NativeHeaderToolbar } from "../../native/StackHeader"; +import { useNavigation } from "@react-navigation/native"; import { SymbolView } from "expo-symbols"; import type { EnvironmentId, ProjectId } from "@t3tools/contracts"; import { useMemo } from "react"; @@ -12,6 +13,7 @@ import { useProjects, useThreadShells } from "../../state/entities"; import type { WorkspaceState } from "../../state/workspaceModel"; import { useWorkspaceState } from "../../state/workspace"; import { groupProjectsByRepository } from "../../lib/repositoryGroups"; +import { useAdaptiveWorkspaceLayout } from "../layout/AdaptiveWorkspaceLayout"; function deriveProjectEmptyState(catalogState: WorkspaceState): { readonly title: string; @@ -68,11 +70,12 @@ function deriveProjectEmptyState(catalogState: WorkspaceState): { }; } -export default function NewTaskRoute() { +export function NewTaskRouteScreen() { const projects = useProjects(); const threads = useThreadShells(); const { state: catalogState } = useWorkspaceState(); - const router = useRouter(); + const navigation = useNavigation(); + const { layout } = useAdaptiveWorkspaceLayout(); const insets = useSafeAreaInsets(); const chevronColor = useThemeColor("--color-chevron"); const accentColor = useThemeColor("--color-icon-muted"); @@ -108,16 +111,24 @@ export default function NewTaskRoute() { return ( - - - + {layout.usesSplitView ? ( + navigation.goBack()} + separateBackground + /> + ) : null} + router.push("/new/add-project")} + onPress={() => navigation.navigate("NewTaskSheet", { screen: "AddProject" })} separateBackground /> - + {projectEmptyState.title} - + {projectEmptyState.detail} {!catalogState.hasReadyEnvironment ? ( router.push("/connections/new")} + onPress={() => navigation.navigate("ConnectionsNew")} > Add environment @@ -147,7 +158,7 @@ export default function NewTaskRoute() { ) : ( router.push("/new/add-project")} + onPress={() => navigation.navigate("NewTaskSheet", { screen: "AddProject" })} > Add new project @@ -162,52 +173,50 @@ export default function NewTaskRoute() { const isLast = index === items.length - 1; return ( - + navigation.navigate("NewTaskSheet", { + screen: "NewTaskDraft", + params: { + environmentId: item.environmentId, + projectId: item.id, + title: item.title, + }, + }) + } + style={{ + paddingHorizontal: 16, + paddingVertical: 14, + borderTopWidth: isFirst ? 0 : 1, + borderTopColor: borderSubtleColor, + borderTopLeftRadius: isFirst ? 24 : 0, + borderTopRightRadius: isFirst ? 24 : 0, + borderBottomLeftRadius: isLast ? 24 : 0, + borderBottomRightRadius: isLast ? 24 : 0, }} - asChild > - - - - - - - {item.title} - - + + - - + + {item.title} + + + + ); })} diff --git a/apps/mobile/src/features/threads/PendingApprovalCard.tsx b/apps/mobile/src/features/threads/PendingApprovalCard.tsx index 0617ef1cbcf..caf9bd4cd0c 100644 --- a/apps/mobile/src/features/threads/PendingApprovalCard.tsx +++ b/apps/mobile/src/features/threads/PendingApprovalCard.tsx @@ -23,7 +23,7 @@ export function PendingApprovalCard(props: PendingApprovalCardProps) { {props.approval.requestKind} {props.approval.detail ? ( - + {props.approval.detail} ) : null} diff --git a/apps/mobile/src/features/threads/PendingUserInputCard.tsx b/apps/mobile/src/features/threads/PendingUserInputCard.tsx index c9e01777214..4d2c2c84159 100644 --- a/apps/mobile/src/features/threads/PendingUserInputCard.tsx +++ b/apps/mobile/src/features/threads/PendingUserInputCard.tsx @@ -39,7 +39,7 @@ export function PendingUserInputCard(props: PendingUserInputCardProps) { {question.header} - + {question.question} diff --git a/apps/mobile/src/features/threads/ThreadComposer.tsx b/apps/mobile/src/features/threads/ThreadComposer.tsx index 75991cae885..eded9870dc6 100644 --- a/apps/mobile/src/features/threads/ThreadComposer.tsx +++ b/apps/mobile/src/features/threads/ThreadComposer.tsx @@ -26,6 +26,7 @@ import { } from "react-native"; import ImageViewing from "react-native-image-viewing"; import { useThemeColor } from "../../lib/useThemeColor"; +import { scopedThreadKey } from "../../lib/scopedEntities"; import { AppText as Text } from "../../components/AppText"; import { ComposerAttachmentStrip } from "../../components/ComposerAttachmentStrip"; @@ -44,7 +45,7 @@ import { ControlPill, ControlPillMenu } from "../../components/ControlPill"; import { ProviderIcon } from "../../components/ProviderIcon"; import type { DraftComposerImageAttachment } from "../../lib/composerImages"; import { buildModelOptions, groupByProvider } from "../../lib/modelOptions"; -import { MOBILE_TYPOGRAPHY } from "../../lib/typography"; +import { useScaledTextRole } from "../settings/appearance/useScaledTextRole"; import type { RemoteClientConnectionState } from "../../lib/connection"; import { insertRankedSearchResult, @@ -76,10 +77,17 @@ export interface ThreadComposerProps { readonly draftMessage: string; readonly draftAttachments: ReadonlyArray; readonly placeholder: string; + readonly contentMaxWidth?: number; readonly bottomInset?: number; readonly connectionState: RemoteClientConnectionState; readonly connectionError: string | null; readonly environmentLabel: string | null; + /** + * Message sync phase for the selected thread (drives the status pill): + * "loading" = first fetch, nothing to show yet; "syncing" = cached messages + * are on screen while they reconcile with the server. + */ + readonly threadSyncPhase?: "loading" | "syncing" | null; readonly selectedThread: OrchestrationThreadShell; readonly serverConfig: T3ServerConfig | null; readonly queueCount: number; @@ -139,11 +147,17 @@ function ComposerSurface(props: { ); } +type ComposerStatusPillState = { + readonly kind: "unavailable" | "reconnecting" | "syncing"; + readonly label: string; +}; + function composerConnectionStatus(input: { readonly connectionError: string | null; readonly connectionState: RemoteClientConnectionState; readonly environmentLabel: string | null; -}): { readonly kind: "unavailable" | "reconnecting"; readonly label: string } | null { + readonly threadSyncPhase?: "loading" | "syncing" | null; +}): ComposerStatusPillState | null { const environmentLabel = input.environmentLabel ?? "Environment"; switch (input.connectionState) { @@ -168,15 +182,27 @@ function composerConnectionStatus(input: { case "available": return { kind: "unavailable", label: `${environmentLabel} is not connected` }; case "connected": + break; + } + + // Connected: the pill is the single loading/sync indicator. One stable + // label per open — "Loading" when starting from scratch, "Syncing" when + // cached messages are already visible. + switch (input.threadSyncPhase) { + case "loading": + return { kind: "syncing", label: "Loading messages..." }; + case "syncing": + return { kind: "syncing", label: "Syncing messages..." }; + default: return null; } } const ComposerConnectionStatusPill = memo(function ComposerConnectionStatusPill(props: { readonly onPress: () => void; - readonly status: { readonly kind: "unavailable" | "reconnecting"; readonly label: string }; + readonly status: ComposerStatusPillState; }) { - const isReconnecting = props.status.kind === "reconnecting"; + const isReconnecting = props.status.kind !== "unavailable"; return ( @@ -191,7 +217,7 @@ const ComposerConnectionStatusPill = memo(function ComposerConnectionStatusPill( )} {props.status.label} @@ -204,10 +230,12 @@ const ComposerConnectionStatusPill = memo(function ComposerConnectionStatusPill( export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposerProps) { const isDarkMode = useColorScheme() === "dark"; const foregroundColor = useThemeColor("--color-foreground"); + const bodyText = useScaledTextRole("body"); const fallbackInputRef = useRef(null); const inputRef = props.editorRef ?? fallbackInputRef; const [isFocused, setIsFocused] = useState(false); const wasExpandedBeforePreviewRef = useRef(false); + const inFlightThreadIdsRef = useRef(new Set()); const { onExpandedChange } = props; const [previewImageUri, setPreviewImageUri] = useState(null); @@ -254,6 +282,7 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer connectionError: props.connectionError, connectionState: props.connectionState, environmentLabel: props.environmentLabel, + threadSyncPhase: props.threadSyncPhase, }); const toolbarFadeOpaque = isDarkMode ? "rgba(0,0,0,0.95)" : "rgba(255,255,255,0.95)"; 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 // ── Handle command selection ────────────────────────────── const { onChangeDraftMessage, onUpdateInteractionMode, draftMessage, onSendMessage } = props; - const handleSend = useCallback(() => { - void onSendMessage(); - }, [onSendMessage]); + const handleSend = useCallback(async () => { + const threadKey = scopedThreadKey(props.environmentId, props.selectedThread.id); + if (inFlightThreadIdsRef.current.has(threadKey)) return; + inFlightThreadIdsRef.current.add(threadKey); + try { + await onSendMessage(); + } finally { + inFlightThreadIdsRef.current.delete(threadKey); + } + }, [onSendMessage, props.environmentId, props.selectedThread.id]); const handleCommandSelect = useCallback( (item: ComposerCommandItem) => { if (!composerTrigger) return; @@ -629,7 +665,10 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer : "linear-gradient(to bottom, rgba(255,255,255,0) 0%, rgba(255,255,255,0.85) 40%, rgba(255,255,255,0.95) 100%)", }} > - + {composerTrigger && composerMenuItems.length > 0 ? ( 0 ? ( - + {props.queueCount} queued message{props.queueCount === 1 ? "" : "s"} will send automatically. diff --git a/apps/mobile/src/features/threads/ThreadDetailScreen.tsx b/apps/mobile/src/features/threads/ThreadDetailScreen.tsx index 62d1bce1157..81c3b9d0076 100644 --- a/apps/mobile/src/features/threads/ThreadDetailScreen.tsx +++ b/apps/mobile/src/features/threads/ThreadDetailScreen.tsx @@ -1,4 +1,5 @@ import { type EnvironmentConnectionPhase } from "@t3tools/client-runtime/connection"; +import type { EnvironmentThreadStatus } from "@t3tools/client-runtime/state/threads"; import { useKeyboardChatComposerInset, useKeyboardScrollToEnd } from "@legendapp/list/keyboard"; import type { LegendListRef } from "@legendapp/list/react-native"; import type { @@ -15,7 +16,6 @@ import type { } from "@t3tools/contracts"; import { formatElapsed } from "@t3tools/shared/orchestrationTiming"; import * as Haptics from "expo-haptics"; -import { useHeaderHeight } from "expo-router/build/react-navigation/elements"; import { memo, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react"; import { View, type GestureResponderEvent } from "react-native"; import { Gesture, GestureDetector } from "react-native-gesture-handler"; @@ -27,7 +27,7 @@ import { AppText as Text } from "../../components/AppText"; import type { ComposerEditorHandle } from "../../components/ComposerEditor"; import type { StatusTone } from "../../components/StatusPill"; import type { DraftComposerImageAttachment } from "../../lib/composerImages"; -import type { LayoutVariant } from "../../lib/layout"; +import { CHAT_CONTENT_MAX_WIDTH, type LayoutVariant } from "../../lib/layout"; import { scopedThreadKey } from "../../lib/scopedEntities"; import type { PendingApproval, @@ -62,6 +62,8 @@ export interface ThreadDetailScreenProps { readonly draftMessage: string; readonly draftAttachments: ReadonlyArray; readonly connectionStateLabel: EnvironmentConnectionPhase; + /** Message sync status for the selected thread (drives the composer status pill). */ + readonly threadSyncStatus?: EnvironmentThreadStatus; readonly activeThreadBusy: boolean; readonly environmentId: EnvironmentId; readonly projectWorkspaceRoot: string | null; @@ -69,6 +71,8 @@ export interface ThreadDetailScreenProps { readonly selectedThreadQueueCount: number; readonly serverConfig: T3ServerConfig | null; readonly layoutVariant?: LayoutVariant; + readonly usesAutomaticContentInsets?: boolean; + readonly onHeaderMaterialVisibilityChange?: (visible: boolean) => void; readonly onOpenDrawer: () => void; readonly onOpenConnectionEditor: () => void; readonly onChangeDraftMessage: (value: string) => void; @@ -207,7 +211,6 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread const { onOpenDrawer } = props; const insets = useSafeAreaInsets(); - const headerHeight = useHeaderHeight(); const agentLabel = `${props.selectedThread.modelSelection.instanceId} agent`; const selectedThreadKey = scopedThreadKey(props.environmentId, props.selectedThread.id); const composerEditorRef = useRef(null); @@ -220,6 +223,22 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread const [anchorMessageId, setAnchorMessageId] = useState(null); const composerBottomInset = composerExpanded ? 0 : Math.max(insets.bottom, 12); const contentPresentationKind = props.contentPresentation.kind; + // The raw sync status enters "synchronizing" on every full fetch, cached or + // not. Whether messages are already on screen decides the pill label: no + // data yet → "Loading messages", cached data reconciling → "Syncing". + const threadSyncPhase = (() => { + switch (props.threadSyncStatus) { + case "empty": + case "cached": + case "synchronizing": + if (contentPresentationKind === "ready") { + return "syncing" as const; + } + return contentPresentationKind === "loading" ? ("loading" as const) : null; + default: + return null; + } + })(); const selectedThreadFeed = props.selectedThreadFeed; const composerChrome = composerExpanded ? COMPOSER_EXPANDED_CHROME : COMPOSER_COLLAPSED_CHROME; const composerOverlapHeight = composerChrome + composerBottomInset; @@ -234,6 +253,7 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread const showContent = props.showContent ?? true; const layoutVariant = props.layoutVariant ?? "compact"; const isSplitLayout = layoutVariant === "split"; + const contentMaxWidth = isSplitLayout ? CHAT_CONTENT_MAX_WIDTH : undefined; const selectedInstanceId = props.selectedThread.modelSelection.instanceId; useStreamingHaptics(props.selectedThread.id, props.selectedThreadFeed); const selectedProviderSkills = useMemo( @@ -382,9 +402,12 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread freeze={freeze} anchorMessageId={anchorMessageId} contentInsetEndAdjustment={contentInsetEndAdjustment} - contentTopInset={headerHeight} + contentTopInset={0} contentBottomInset={estimatedOverlayHeight} + contentMaxWidth={contentMaxWidth} layoutVariant={layoutVariant} + usesAutomaticContentInsets={props.usesAutomaticContentInsets} + onHeaderMaterialVisibilityChange={props.onHeaderMaterialVisibilityChange} skills={selectedProviderSkills} /> @@ -398,42 +421,50 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread style={{ position: "absolute", bottom: 0, left: 0, right: 0 }} offset={{ closed: 0, opened: 0 }} > - - {props.activeWorkStartedAt ? ( - - ) : null} - - {props.activePendingApproval || props.activePendingUserInput ? ( - - {props.activePendingApproval ? ( - - ) : null} - {props.activePendingUserInput ? ( - - ) : null} - - ) : null} + + + {props.activeWorkStartedAt ? ( + + ) : null} + + {props.activePendingApproval || props.activePendingUserInput ? ( + + {props.activePendingApproval ? ( + + ) : null} + {props.activePendingUserInput ? ( + + ) : null} + + ) : null} + ; readonly contentTopInset?: number; readonly contentBottomInset?: number; + readonly contentMaxWidth?: number; readonly layoutVariant?: LayoutVariant; + readonly usesAutomaticContentInsets?: boolean; + readonly onHeaderMaterialVisibilityChange?: (visible: boolean) => void; readonly skills?: ReadonlyArray; } @@ -273,6 +281,15 @@ function useReviewCommentColors(): ReviewCommentColors { function useMarkdownStyles(onLinkPress: (href: string) => void): MarkdownStyleSets { const colorScheme = useColorScheme(); + const { appearance } = useAppearancePreferences(); + const markdownFontSizes = useMemo( + () => resolveMarkdownFontSizes(appearance.baseFontSize), + [appearance.baseFontSize], + ); + const nativeMarkdownTypography = useMemo( + () => resolveNativeMarkdownTypography(appearance.baseFontSize), + [appearance.baseFontSize], + ); const colors = MARKDOWN_COLORS[colorScheme === "dark" ? "dark" : "light"]; const inlineSkillForeground = String(useThemeColor("--color-inline-skill-foreground")); @@ -316,14 +333,14 @@ function useMarkdownStyles(onLinkPress: (href: string) => void): MarkdownStyleSe xl: 16, }, fontSizes: { - s: 13, - m: 15, - h1: 20, - h2: 18, - h3: 16, - h4: 14, - h5: 14, - h6: 14, + s: markdownFontSizes.s, + m: markdownFontSizes.m, + h1: markdownFontSizes.h1, + h2: markdownFontSizes.h2, + h3: markdownFontSizes.h3, + h4: markdownFontSizes.h4, + h5: markdownFontSizes.h5, + h6: markdownFontSizes.h6, }, fontFamilies: { regular: "DMSans_400Regular", @@ -345,7 +362,7 @@ function useMarkdownStyles(onLinkPress: (href: string) => void): MarkdownStyleSe list: { marginTop: 4, marginBottom: 8 }, list_item: { marginTop: 0, marginBottom: 4 }, task_list_item: { marginTop: 0, marginBottom: 4 }, - text: { lineHeight: 22 }, + text: { lineHeight: markdownFontSizes.bodyLineHeight }, bold: { fontWeight: "700", color: markdownStrongColor, @@ -454,7 +471,8 @@ function useMarkdownStyles(onLinkPress: (href: string) => void): MarkdownStyleSe marginRight: 5, color: inlineTextColor, fontFamily: "DMSans_400Regular", - ...MOBILE_TYPOGRAPHY.body, + fontSize: markdownFontSizes.m, + lineHeight: markdownFontSizes.bodyLineHeight, textAlign: ordered ? "right" : "center", }} > @@ -475,8 +493,8 @@ function useMarkdownStyles(onLinkPress: (href: string) => void): MarkdownStyleSe style={{ color: inlineCodeTextColor, fontFamily: "ui-monospace", - fontSize: MOBILE_TYPOGRAPHY.label.fontSize, - lineHeight: 22, + fontSize: markdownFontSizes.codeBlockFontSize, + lineHeight: markdownFontSizes.bodyLineHeight, }} > {value} @@ -512,7 +530,7 @@ function useMarkdownStyles(onLinkPress: (href: string) => void): MarkdownStyleSe style={{ color: markdownBodyColor, fontFamily: "ui-monospace", - fontSize: MOBILE_TYPOGRAPHY.label.fontSize, + fontSize: markdownFontSizes.codeBlockFontSize, opacity: 0.7, textTransform: "uppercase", }} @@ -532,8 +550,8 @@ function useMarkdownStyles(onLinkPress: (href: string) => void): MarkdownStyleSe style={{ color: blockTextColor, fontFamily: "ui-monospace", - fontSize: MOBILE_TYPOGRAPHY.label.fontSize, - lineHeight: 18, + fontSize: markdownFontSizes.codeBlockFontSize, + lineHeight: markdownFontSizes.codeBlockLineHeight, }} > {content} @@ -612,7 +630,9 @@ function useMarkdownStyles(onLinkPress: (href: string) => void): MarkdownStyleSe skillTextColor: "#f0abfc", quoteMarkerColor: markdownUserBodyColor, dividerColor: markdownUserBodyColor, - ...MOBILE_TYPOGRAPHY.body, + fontSize: nativeMarkdownTypography.fontSize, + lineHeight: nativeMarkdownTypography.lineHeight, + headingFontSizes: nativeMarkdownTypography.headingFontSizes, fontFamily: "DMSans_400Regular", headingFontFamily: "DMSans_700Bold", boldFontFamily: "DMSans_700Bold", @@ -641,14 +661,16 @@ function useMarkdownStyles(onLinkPress: (href: string) => void): MarkdownStyleSe skillTextColor: inlineSkillForeground, quoteMarkerColor: markdownBlockquoteBorder, dividerColor: markdownHrColor, - ...MOBILE_TYPOGRAPHY.body, + fontSize: nativeMarkdownTypography.fontSize, + lineHeight: nativeMarkdownTypography.lineHeight, + headingFontSizes: nativeMarkdownTypography.headingFontSizes, fontFamily: "DMSans_400Regular", headingFontFamily: "DMSans_700Bold", boldFontFamily: "DMSans_700Bold", }, }, }; - }, [colors, inlineSkillForeground, onLinkPress]); + }, [colors, inlineSkillForeground, markdownFontSizes, nativeMarkdownTypography, onLinkPress]); } function renderFeedEntry( @@ -924,6 +946,7 @@ const ReviewCommentCard = memo(function ReviewCommentCard(props: { readonly comment: ReviewInlineComment; readonly colors: ReviewCommentColors; }) { + const { codeSurface, nativeReviewDiffStyle } = useAppearanceCodeSurface(); const colorScheme = useColorScheme(); const appearanceScheme = colorScheme === "light" ? "light" : "dark"; const NativeReviewDiffView = resolveNativeReviewDiffView(); @@ -946,18 +969,21 @@ const ReviewCommentCard = memo(function ReviewCommentCard(props: { () => JSON.stringify(nativeReviewDiffTheme), [nativeReviewDiffTheme], ); - const nativeStyleJson = useMemo(() => JSON.stringify(NATIVE_REVIEW_DIFF_STYLE), []); + const nativeStyleJson = useMemo( + () => JSON.stringify(nativeReviewDiffStyle), + [nativeReviewDiffStyle], + ); const nativeDiffHeight = useMemo( () => Math.min( 360, Math.max( 112, - compactNativeRows.length * NATIVE_REVIEW_DIFF_ROW_HEIGHT + - NATIVE_REVIEW_DIFF_STYLE.fileHeaderVerticalMargin, + compactNativeRows.length * nativeReviewDiffStyle.rowHeight + + nativeReviewDiffStyle.fileHeaderVerticalMargin, ), ), - [compactNativeRows.length], + [compactNativeRows.length, nativeReviewDiffStyle], ); const shouldRenderNativeDiff = NativeReviewDiffView != null && compactNativeRows.length > 0; @@ -987,7 +1013,7 @@ const ReviewCommentCard = memo(function ReviewCommentCard(props: { @@ -1010,7 +1036,7 @@ const ReviewCommentCard = memo(function ReviewCommentCard(props: { style={StyleSheet.absoluteFill} appearanceScheme={appearanceScheme} contentWidth={NATIVE_REVIEW_DIFF_CONTENT_WIDTH} - rowHeight={NATIVE_REVIEW_DIFF_ROW_HEIGHT} + rowHeight={nativeReviewDiffStyle.rowHeight} rowsJson={nativeRowsJson} styleJson={nativeStyleJson} themeJson={nativeThemeJson} @@ -1032,8 +1058,8 @@ const ReviewCommentCard = memo(function ReviewCommentCard(props: { style={{ color: props.colors.text, fontFamily: "ui-monospace", - fontSize: MOBILE_CODE_SURFACE.fontSize, - lineHeight: MOBILE_CODE_SURFACE.rowHeight, + fontSize: codeSurface.fontSize, + lineHeight: codeSurface.rowHeight, }} > {props.comment.diff.trim()} @@ -1042,11 +1068,7 @@ const ReviewCommentCard = memo(function ReviewCommentCard(props: { ) : null} {props.comment.text.length > 0 ? ( - + {props.comment.text} @@ -1087,7 +1109,6 @@ function ThreadFeedPlaceholder(props: { readonly bottomInset: number; readonly detail: string; readonly horizontalPadding: number; - readonly loading?: boolean; readonly title: string; readonly topInset: number; }) { @@ -1104,9 +1125,8 @@ function ThreadFeedPlaceholder(props: { }} > - {props.loading ? : null} {props.title} - + {props.detail} @@ -1115,13 +1135,17 @@ function ThreadFeedPlaceholder(props: { } export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { - const router = useRouter(); + const navigation = useNavigation(); const copyFeedbackTimeoutRef = useRef | null>(null); const foldSettleFrameRef = useRef(null); const foldSettleSecondFrameRef = useRef(null); const disclosureAnchorKeyRef = useRef(null); + const headerMaterialVisibleRef = useRef(false); const previousLatestTurnRef = useRef(props.latestTurn); - const { width: viewportWidth } = useWindowDimensions(); + const { width: windowWidth } = useWindowDimensions(); + const [viewportWidth, setViewportWidth] = useState(() => + props.layoutVariant === "split" ? 0 : windowWidth, + ); const [disclosureToggleSettling, setDisclosureToggleSettling] = useState(false); const [interactionState, setInteractionState] = useState<{ readonly copiedRowId: string | null; @@ -1140,12 +1164,19 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { headers?: Record; } | null>(null); const horizontalPadding = props.layoutVariant === "split" ? 20 : 16; - const contentWidth = Math.max(0, viewportWidth - horizontalPadding * 2); + const contentHorizontalPadding = deriveCenteredContentHorizontalPadding({ + viewportWidth, + maxContentWidth: props.contentMaxWidth ?? null, + minimumPadding: horizontalPadding, + }); + const contentWidth = Math.max(0, viewportWidth - contentHorizontalPadding * 2); const userBubbleMaxWidth = contentWidth * 0.85; const reviewCommentBubbleWidth = Math.min(Math.max(280, contentWidth * 0.85), contentWidth); const insets = useSafeAreaInsets(); const topContentInset = props.contentTopInset ?? insets.top + 44; const bottomContentInset = props.contentBottomInset ?? 18; + const usesNativeAutomaticInsets = + props.usesAutomaticContentInsets === true && Platform.OS === "ios"; const iconSubtleColor = useThemeColor("--color-icon-subtle"); const userBubbleColor = useThemeColor("--color-user-bubble"); @@ -1159,13 +1190,12 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { ); if (relativePath) { void Haptics.selectionAsync(); - router.push( - buildThreadFilesNavigation( - { environmentId: props.environmentId, threadId: props.threadId }, - relativePath, - presentation.line, - ), - ); + navigation.navigate("ThreadFile", { + environmentId: String(props.environmentId), + threadId: String(props.threadId), + path: relativePath.split("/").filter((segment) => segment.length > 0), + ...(presentation.line ? { line: String(presentation.line) } : {}), + }); } return; } @@ -1174,7 +1204,7 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { void Linking.openURL(presentation.href); } }, - [props.environmentId, props.threadId, props.workspaceRoot, router], + [props.environmentId, props.threadId, props.workspaceRoot, navigation], ); const markdownStyles = useMarkdownStyles(onMarkdownLinkPress); const reviewCommentColors = useReviewCommentColors(); @@ -1188,6 +1218,7 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { markdownStyles, reviewCommentColors, userBubbleColor, + viewportWidth, }), [ copiedRowId, @@ -1196,8 +1227,34 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { markdownStyles, reviewCommentColors, userBubbleColor, + viewportWidth, ], ); + const reportHeaderMaterialVisibility = useCallback( + (visible: boolean) => { + if (headerMaterialVisibleRef.current === visible) { + return; + } + headerMaterialVisibleRef.current = visible; + props.onHeaderMaterialVisibilityChange?.(visible); + }, + [props.onHeaderMaterialVisibilityChange], + ); + const handleScroll = useCallback( + (event: NativeSyntheticEvent) => { + reportHeaderMaterialVisibility(event.nativeEvent.contentOffset.y + topContentInset > 6); + }, + [reportHeaderMaterialVisibility, topContentInset], + ); + const handleViewportLayout = useCallback((event: LayoutChangeEvent) => { + const nextWidth = Math.round(event.nativeEvent.layout.width); + setViewportWidth((current) => (Math.abs(current - nextWidth) > 1 ? nextWidth : current)); + }, []); + + useEffect(() => { + reportHeaderMaterialVisibility(false); + }, [props.threadId, reportHeaderMaterialVisibility]); + const expandedWorkGroupIds = useMemo(() => { const ids = new Set(); for (const [groupId, expanded] of Object.entries(expandedWorkGroups)) { @@ -1217,6 +1274,7 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { ), [expandedTurnIds, expandedWorkGroupIds, props.feed, props.latestTurn], ); + const anchoredEndSpace = useMemo( () => resolveChatListAnchoredEndSpace( @@ -1424,19 +1482,6 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { ], ); - if (props.contentPresentation.kind === "loading") { - return ( - - ); - } - if (props.contentPresentation.kind === "unavailable") { return ( - - + + entry.id} - getItemType={(entry) => - entry.type === "message" ? `message:${entry.message.role}` : entry.type - } - keyboardShouldPersistTaps="always" - keyboardDismissMode="none" - keyboardLiftBehavior="whenAtEnd" - estimatedItemSize={180} - initialScrollAtEnd - ListHeaderComponent={} - contentContainerStyle={{ - paddingTop: 12, - paddingHorizontal: horizontalPadding, - }} - /> - {props.feed.length === 0 ? ( + : { scrollIndicatorInsets: { top: topContentInset, bottom: 0 } })} + {...(anchoredEndSpace ? { anchoredEndSpace } : {})} + contentInsetEndAdjustment={props.contentInsetEndAdjustment} + freeze={props.freeze} + maintainScrollAtEnd={ + disclosureToggleSettling + ? false + : { + animated: false, + on: { + dataChange: true, + itemLayout: true, + layout: true, + }, + } + } + maintainVisibleContentPosition={maintainVisibleContentPosition} + data={presentedFeed} + extraData={listAppearanceData} + renderItem={renderItem} + keyExtractor={(entry) => entry.id} + getItemType={(entry) => + entry.type === "message" ? `message:${entry.message.role}` : entry.type + } + keyboardShouldPersistTaps="always" + keyboardDismissMode="none" + keyboardLiftBehavior="whenAtEnd" + estimatedItemSize={180} + initialScrollAtEnd + onScroll={handleScroll} + scrollEventThrottle={16} + ListHeaderComponent={ + usesNativeAutomaticInsets ? null : + } + contentContainerStyle={{ + paddingTop: 12, + paddingHorizontal: contentHorizontalPadding, + }} + /> + + {props.feed.length === 0 && props.contentPresentation.kind === "ready" ? ( ; +type HeaderItems = HeaderItem[]; +type ThreadGitHeaderActionItems = { + readonly terminal: HeaderItem; + readonly files: HeaderItem; + readonly git: HeaderItem; +}; +type QuickActionIcon = + | "arrow.down.circle" + | "arrow.up.right.circle" + | "checkmark.circle" + | "arrow.up.circle"; + +/** The subset of git-control wiring the standalone git menu needs. */ +export type ThreadGitMenuProps = { + readonly environmentId: EnvironmentId | string; + readonly threadId: ThreadId | string; readonly currentBranch: string | null; readonly gitStatus: VcsStatusResult | null; readonly gitOperationLabel: string | null; + readonly onOpenFilesInspector?: () => void; + readonly onOpenGitInspector?: () => void; + readonly onPull: () => Promise; + readonly onRunAction: (input: GitActionRequestInput) => Promise; +}; + +type ThreadGitControlsProps = ThreadGitMenuProps & { + readonly auxiliaryPaneControl?: { + readonly accessibilityLabel: string; + readonly onPress: () => void; + }; readonly canOpenTerminal: boolean; readonly canOpenFiles: boolean; readonly projectScripts: ReadonlyArray; readonly terminalSessions: ReadonlyArray; + readonly showActionControls?: boolean; + readonly showDirectFileControl?: boolean; readonly onOpenTerminal: (terminalId?: string | null) => void; readonly onOpenNewTerminal: () => void; readonly onRunProjectScript: (script: ProjectScript) => Promise; - readonly onPull: () => Promise; - readonly onRunAction: (input: GitActionRequestInput) => Promise; -}) { - const router = useRouter(); - const { environmentId, threadId } = useLocalSearchParams<{ - environmentId: EnvironmentId; - threadId: ThreadId; - }>(); +}; + +function useThreadGitControlModel(props: ThreadGitMenuProps) { + const navigation = useNavigation(); + const environmentId = props.environmentId; + const threadId = props.threadId; const { gitStatus, gitOperationLabel, onPull, onRunAction } = props; const currentBranchLabel = gitStatus?.refName ?? props.currentBranch ?? "Detached HEAD"; @@ -109,7 +135,7 @@ export function ThreadGitControls(props: { ? (quickAction.hint ?? "This action is unavailable.") : null; - const quickActionIcon = (() => { + const quickActionIcon: QuickActionIcon = (() => { if (quickAction.kind === "run_pull") return "arrow.down.circle"; if (quickAction.kind === "open_pr") return "arrow.up.right.circle"; if (quickAction.kind === "run_action") { @@ -147,24 +173,21 @@ export function ThreadGitControls(props: { !input.featureBranch && requiresDefaultBranchConfirmation(input.action, isDefaultRef) ) { - router.push({ - pathname: "/threads/[environmentId]/[threadId]/git-confirm", - params: { - environmentId, - threadId, - confirmAction: confirmableAction, - branchName, - includesCommit: String( - input.action === "commit_push" || input.action === "commit_push_pr", - ), - }, + navigation.navigate("GitConfirm", { + environmentId: String(environmentId), + threadId: String(threadId), + confirmAction: confirmableAction, + branchName, + includesCommit: String( + input.action === "commit_push" || input.action === "commit_push_pr", + ), }); return; } await onRunAction(input); }, - [environmentId, gitStatus, isDefaultRef, onRunAction, router, threadId], + [environmentId, gitStatus, isDefaultRef, onRunAction, navigation, threadId], ); const runQuickAction = useCallback(async () => { @@ -181,102 +204,339 @@ export function ThreadGitControls(props: { } }, [onPull, openExistingPr, quickAction, runActionWithPrompt]); + const openFiles = useCallback(() => { + if (props.onOpenFilesInspector) { + props.onOpenFilesInspector(); + return; + } + navigation.navigate("ThreadFiles", { + environmentId: String(environmentId), + threadId: String(threadId), + }); + }, [environmentId, props.onOpenFilesInspector, navigation, threadId]); + + const openReview = useCallback(() => { + navigation.navigate("ThreadReview", { + environmentId: EnvironmentId.make(String(environmentId)), + threadId: ThreadId.make(String(threadId)), + }); + }, [environmentId, navigation, threadId]); + + const openGitInspector = useCallback(() => { + if (props.onOpenGitInspector) { + props.onOpenGitInspector(); + return; + } + navigation.navigate("GitOverview", { + environmentId: String(environmentId), + threadId: String(threadId), + }); + }, [environmentId, props.onOpenGitInspector, navigation, threadId]); + + return { + currentBranchLabel, + isRepo, + openFiles, + openGitInspector, + openReview, + quickAction, + quickActionHint, + quickActionIcon, + runQuickAction, + }; +} + +function useThreadGitHeaderActionItems(props: ThreadGitControlsProps): ThreadGitHeaderActionItems { + const model = useThreadGitControlModel(props); + + return useMemo( + () => ({ + terminal: { + accessibilityLabel: "Open terminal", + disabled: !props.canOpenTerminal, + icon: { name: "terminal", type: "sfSymbol" }, + identifier: "thread-right-terminal", + label: "Terminal", + menu: { + items: [ + ...props.projectScripts.map((script) => ({ + description: script.command, + icon: { name: projectScriptMenuIcon(script.icon), type: "sfSymbol" as const }, + label: projectScriptMenuLabel(script), + onPress: () => void props.onRunProjectScript(script), + type: "action" as const, + })), + ...(props.projectScripts.length === 0 + ? [ + { + description: "This project has no saved scripts yet", + disabled: true, + icon: { name: "play", type: "sfSymbol" as const }, + label: "No project scripts", + onPress: () => {}, + type: "action" as const, + }, + ] + : []), + ...props.terminalSessions.map((session) => ({ + description: [ + getTerminalStatusLabel({ + status: session.status, + hasRunningSubprocess: session.hasRunningSubprocess, + }), + basename(session.cwd), + ] + .filter(Boolean) + .join(" · "), + icon: { name: "terminal", type: "sfSymbol" as const }, + label: session.displayLabel, + onPress: () => props.onOpenTerminal(session.terminalId), + type: "action" as const, + })), + { + description: "Start another shell for this thread", + icon: { name: "plus", type: "sfSymbol" }, + label: "Open new terminal", + onPress: props.onOpenNewTerminal, + type: "action", + }, + ], + title: "Terminal", + }, + sharesBackground: true, + type: "menu", + variant: "plain", + }, + files: { + accessibilityLabel: "Open files", + disabled: !props.canOpenFiles, + icon: { name: "folder", type: "sfSymbol" }, + identifier: "thread-right-files", + label: "Files", + onPress: model.openFiles, + sharesBackground: true, + type: "button", + variant: "plain", + }, + git: { + accessibilityLabel: "Git actions", + icon: { name: "point.topleft.down.curvedto.point.bottomright.up", type: "sfSymbol" }, + identifier: "thread-right-git", + label: "Git", + menu: { + items: [ + { + description: compactMenuStatus(props.gitStatus), + disabled: true, + icon: { + name: "point.topleft.down.curvedto.point.bottomright.up", + type: "sfSymbol", + }, + label: compactMenuBranchLabel(model.currentBranchLabel), + onPress: (): void => {}, + type: "action", + }, + { + description: model.quickActionHint ?? undefined, + disabled: model.quickAction.disabled, + icon: { name: model.quickActionIcon, type: "sfSymbol" }, + label: model.quickAction.label, + onPress: (): void => void model.runQuickAction(), + type: "action", + }, + { + description: "Turn diffs and worktree changes", + disabled: !model.isRepo, + icon: { name: "text.bubble", type: "sfSymbol" }, + label: "Review changes", + onPress: model.openReview, + type: "action", + }, + { + description: "Commit, files, branches", + icon: { name: "ellipsis", type: "sfSymbol" }, + label: "More", + onPress: model.openGitInspector, + type: "action", + }, + ], + title: "Git", + }, + sharesBackground: true, + type: "menu", + variant: "plain", + }, + }), + [ + model.currentBranchLabel, + model.isRepo, + model.openFiles, + model.openGitInspector, + model.openReview, + model.quickAction.disabled, + model.quickAction.label, + model.quickActionHint, + model.quickActionIcon, + model.runQuickAction, + props.canOpenFiles, + props.canOpenTerminal, + props.gitStatus, + props.onOpenNewTerminal, + props.onOpenTerminal, + props.onRunProjectScript, + props.projectScripts, + props.terminalSessions, + ], + ); +} + +export function useThreadGitRightHeaderItems(props: ThreadGitControlsProps): HeaderItems { + const actionItems = useThreadGitHeaderActionItems(props); + return useMemo( + () => [actionItems.git, actionItems.files, actionItems.terminal] as HeaderItems, + [actionItems], + ); +} + +export function useThreadGitCenterHeaderItems(props: ThreadGitControlsProps): HeaderItems { + const actionItems = useThreadGitHeaderActionItems(props); + return useMemo( + () => [actionItems.files, actionItems.git, actionItems.terminal] as HeaderItems, + [actionItems], + ); +} + +export function ThreadGitControls(props: ThreadGitControlsProps) { + const model = useThreadGitControlModel(props); + const showActionControls = props.showActionControls ?? true; + + if (!showActionControls) { + return null; + } + return ( - - - {props.projectScripts.length > 0 ? ( - props.projectScripts.map((script) => ( - void props.onRunProjectScript(script)} - subtitle={script.command} + + {showActionControls && props.auxiliaryPaneControl ? ( + + ) : null} + {showActionControls ? ( + + {props.projectScripts.length > 0 ? ( + props.projectScripts.map((script) => ( + void props.onRunProjectScript(script)} + subtitle={script.command} + > + + {projectScriptMenuLabel(script)} + + + )) + ) : ( + {}} + subtitle="This project has no saved scripts yet" > - {projectScriptMenuLabel(script)} - - )) - ) : ( - {}} - subtitle="This project has no saved scripts yet" - > - No project scripts - - )} - {props.terminalSessions.map((session) => ( - props.onOpenTerminal(session.terminalId)} - subtitle={[ - getTerminalStatusLabel({ - status: session.status, - hasRunningSubprocess: session.hasRunningSubprocess, - }), - basename(session.cwd), - ] - .filter(Boolean) - .join(" · ")} + No project scripts + + )} + {props.terminalSessions.map((session) => ( + props.onOpenTerminal(session.terminalId)} + subtitle={[ + getTerminalStatusLabel({ + status: session.status, + hasRunningSubprocess: session.hasRunningSubprocess, + }), + basename(session.cwd), + ] + .filter(Boolean) + .join(" · ")} + > + {session.displayLabel} + + ))} + - {session.displayLabel} - - ))} - - Open new terminal - - - - {}} - subtitle={compactMenuStatus(gitStatus)} - > - {compactMenuBranchLabel(currentBranchLabel)} - - void runQuickAction()} - subtitle={quickActionHint ?? undefined} - > - {quickAction.label} - - router.push(buildThreadReviewRoutePath({ environmentId, threadId }))} - subtitle="Turn diffs and worktree changes" - > - Review changes - - Open new terminal + + + ) : null} + {showActionControls && props.showDirectFileControl ? ( + router.push(buildThreadFilesNavigation({ environmentId, threadId }))} - subtitle="Browse this workspace" - > - Files - - - router.push({ - pathname: "/threads/[environmentId]/[threadId]/git", - params: { environmentId, threadId }, - }) - } - subtitle="Commit, files, branches" - > - More - - - + icon="folder" + onPress={model.openFiles} + separateBackground + /> + ) : null} + {showActionControls ? : null} + + ); +} + +/** + * The standalone git actions menu (branch status, quick commit/push action, + * review, more). Rendered inside a NativeHeaderToolbar by both the thread + * chat header and the review screen's toolbar. + */ +export function ThreadGitMenu(props: ThreadGitMenuProps) { + const model = useThreadGitControlModel(props); + + return ( + + {}} + subtitle={compactMenuStatus(props.gitStatus)} + > + + {compactMenuBranchLabel(model.currentBranchLabel)} + + + void model.runQuickAction()} + subtitle={model.quickActionHint ?? undefined} + > + {model.quickAction.label} + + + Review changes + + + More + + ); } diff --git a/apps/mobile/src/features/threads/ThreadNavigationDrawer.tsx b/apps/mobile/src/features/threads/ThreadNavigationDrawer.tsx index 9318fb76017..a143c2f1834 100644 --- a/apps/mobile/src/features/threads/ThreadNavigationDrawer.tsx +++ b/apps/mobile/src/features/threads/ThreadNavigationDrawer.tsx @@ -8,8 +8,6 @@ import { useWindowDimensions, View, } from "react-native"; -import * as Arr from "effect/Array"; -import * as Order from "effect/Order"; import { Gesture, GestureDetector } from "react-native-gesture-handler"; import { useSafeAreaInsets } from "react-native-safe-area-context"; import Animated, { @@ -23,22 +21,11 @@ import { useThemeColor } from "../../lib/useThemeColor"; import { AppText as Text } from "../../components/AppText"; import { StatusPill } from "../../components/StatusPill"; import { useProjects, useThreadShells } from "../../state/entities"; -import { groupProjectsByRepository } from "../../lib/repositoryGroups"; import { scopedThreadKey } from "../../lib/scopedEntities"; import { relativeTime } from "../../lib/time"; -import { threadStatusTone } from "./threadPresentation"; -import { EnvironmentThreadShell } from "@t3tools/client-runtime/state/shell"; - -const threadActivityOrder = Order.mapInput( - Order.Struct({ - activityAt: Order.flip(Order.Number), - title: Order.String, - }), - (thread: EnvironmentThreadShell) => ({ - activityAt: new Date(thread.updatedAt ?? thread.createdAt).getTime(), - title: thread.title, - }), -); +import { resolveThreadStatus } from "./threadPresentation"; +import type { EnvironmentThreadShell } from "@t3tools/client-runtime/state/shell"; +import { buildThreadNavigationGroups } from "./thread-navigation-groups"; export function ThreadNavigationDrawer(props: { readonly visible: boolean; @@ -192,24 +179,9 @@ function ThreadNavigationDrawerContent(props: { }) { const projects = useProjects(); const threads = useThreadShells(); - const repositoryGroups = useMemo( - () => groupProjectsByRepository({ projects, threads }), - [projects, threads], - ); const groupedThreads = useMemo( - () => - repositoryGroups.map((group) => { - const threads: EnvironmentThreadShell[] = []; - for (const projectGroup of group.projects) { - threads.push(...projectGroup.threads); - } - return { - key: group.key, - title: group.projects[0]?.project.title ?? group.title, - threads: Arr.sort(threads, threadActivityOrder), - }; - }), - [repositoryGroups], + () => buildThreadNavigationGroups({ projects, threads }), + [projects, threads], ); return ( @@ -239,6 +211,7 @@ function ThreadNavigationDrawerContent(props: { group.threads.map((thread, index) => { const threadKey = scopedThreadKey(thread.environmentId, thread.id); const selected = props.selectedThreadKey === threadKey; + const status = resolveThreadStatus(thread); return ( - + {status ? : null} ); diff --git a/apps/mobile/src/features/threads/ThreadNavigationSidebar.tsx b/apps/mobile/src/features/threads/ThreadNavigationSidebar.tsx new file mode 100644 index 00000000000..21a4851e440 --- /dev/null +++ b/apps/mobile/src/features/threads/ThreadNavigationSidebar.tsx @@ -0,0 +1,794 @@ +import { isLiquidGlassSupported, LiquidGlassView } from "@callstack/liquid-glass"; +import type { EnvironmentThreadShell } from "@t3tools/client-runtime/state/shell"; +import { LegendList } from "@legendapp/list/react-native"; +import type { MenuAction } from "@react-native-menu/menu"; +import { SymbolView } from "expo-symbols"; +import { useCallback, useMemo, useRef, useState, type ReactNode } from "react"; +import type { LayoutChangeEvent, NativeScrollEvent, NativeSyntheticEvent } from "react-native"; +import { Platform, StyleSheet, TextInput, View, useColorScheme } from "react-native"; +import { Gesture, GestureDetector } from "react-native-gesture-handler"; +import type { SwipeableMethods } from "react-native-gesture-handler/ReanimatedSwipeable"; +import { useSafeAreaInsets } from "react-native-safe-area-context"; +import type { SearchBarCommands } from "react-native-screens"; +import Svg, { Defs, LinearGradient, Rect, Stop } from "react-native-svg"; + +import { AppText as Text } from "../../components/AppText"; +import { ControlPillMenu } from "../../components/ControlPill"; +import { NativeStackScreenOptions } from "../../native/StackHeader"; +import { scopedProjectKey, scopedThreadKey } from "../../lib/scopedEntities"; +import { useThemeColor } from "../../lib/useThemeColor"; +import { useProjects, useThreadShells } from "../../state/entities"; +import { useWorkspaceState } from "../../state/workspace"; +import { useSavedRemoteConnections } from "../../state/use-remote-environment-registry"; +import { useHardwareKeyboardCommand } from "../keyboard/hardwareKeyboardCommands"; +import { + hasCustomHomeListOptions, + PROJECT_GROUPING_OPTIONS, + PROJECT_SORT_OPTIONS, + THREAD_SORT_OPTIONS, + useHomeListOptions, +} from "../home/home-list-options"; +import { buildHomeListFilterMenu } from "../home/home-list-filter-menu"; +import { + buildHomeListLayout, + DEFAULT_GROUP_DISPLAY_STATE, + homeListItemsAreEqual, + nextGroupDisplayState, + type HomeGroupDisplayAction, + type HomeGroupDisplayState, + type HomeListItem, +} from "../home/homeListItems"; +import { buildHomeThreadGroups } from "../home/homeThreadList"; +import { SwipeableScrollGateProvider, useSwipeableScrollGate } from "../home/thread-swipe-actions"; +import { useThreadListActions } from "../home/useThreadListActions"; +import { WorkspaceConnectionStatus } from "../home/WorkspaceConnectionStatus"; +import { shouldShowWorkspaceConnectionStatus } from "../home/workspace-connection-status"; +import { SidebarHeaderActions } from "./sidebar-header-actions"; +import { SidebarFilterButton } from "./sidebar-filter-button"; +import { createSidebarHeaderItems } from "./sidebar-native-header-items"; +import { SidebarNavigationShell } from "./sidebar-navigation-shell"; +import { ThreadListGroupHeader, ThreadListRow, ThreadListShowMoreRow } from "./thread-list-items"; + +/** + * Shared capsule behind the sidebar header buttons — a native liquid-glass + * surface on iOS 26+, a tinted pill everywhere else. + */ +function SidebarHeaderButtonGroup(props: { + readonly children: ReactNode; + readonly colorScheme: "light" | "dark"; +}) { + if (isLiquidGlassSupported) { + return ( + + {props.children} + + ); + } + + return ( + + {props.children} + + ); +} + +const SIDEBAR_STICKY_HEADER_HEIGHT = 106; +const SIDEBAR_STICKY_HEADER_FADE_HEIGHT = 44; +const IOS_SEARCH_FILL_DARK = "rgba(118, 118, 128, 0.24)"; +const IOS_SEARCH_FILL_LIGHT = "rgba(118, 118, 128, 0.12)"; +const SIDEBAR_HEADER_WASH_OPACITY = { + dark: [0.22, 0.14, 0.04], + light: [0.46, 0.3, 0.08], +} as const; + +interface ThreadNavigationSidebarProps { + readonly width: number; + readonly visible: boolean; + readonly selectedThreadKey: string | null; + readonly onOpenSettings: () => void; + readonly onOpenEnvironmentSettings: () => void; + readonly onSearchQueryChange: (query: string) => void; + readonly onSelectThread: (thread: EnvironmentThreadShell) => void; + readonly onRequestVisibility: () => void; + readonly searchQuery: string; +} + +/** + * iPad/large-width sidebar column. + * + * On iOS the pane is hosted inside its own navigation-inert single-screen + * native stack (SidebarNavigationShell) so the header is a real + * UINavigationBar: large title, native bar-button items, and a + * UISearchController search field — the same chrome a UISplitViewController + * column gets. Other platforms keep the custom header chrome. + */ +export function ThreadNavigationSidebar(props: ThreadNavigationSidebarProps) { + if (Platform.OS !== "ios") { + return ; + } + return ; +} + +function NativeSidebarContainer(props: ThreadNavigationSidebarProps) { + const backgroundColor = useThemeColor("--color-drawer"); + const borderColor = useThemeColor("--color-border"); + + return ( + + + + + + ); +} + +function ThreadNavigationSidebarPane( + props: ThreadNavigationSidebarProps & { readonly nativeChrome: boolean }, +) { + const insets = useSafeAreaInsets(); + const colorScheme = useColorScheme() === "dark" ? "dark" : "light"; + const projects = useProjects(); + const threads = useThreadShells(); + const { state: catalogState } = useWorkspaceState(); + const { savedConnectionsById } = useSavedRemoteConnections(); + const [headerIsOverContent, setHeaderIsOverContent] = useState(false); + const searchInputRef = useRef(null); + const searchBarRef = useRef(null); + const openSwipeableRef = useRef(null); + const headerIsOverContentRef = useRef(false); + const sidebarScrollGesture = useMemo(() => Gesture.Native(), []); + const { archiveThread, confirmDeleteThread } = useThreadListActions(); + const environments = useMemo( + () => + Object.values(savedConnectionsById) + .map((connection) => ({ + environmentId: connection.environmentId, + label: connection.environmentLabel, + })) + .sort((left, right) => left.label.localeCompare(right.label)), + [savedConnectionsById], + ); + const availableEnvironmentIds = useMemo( + () => new Set(environments.map((environment) => environment.environmentId)), + [environments], + ); + const { + options, + setSelectedEnvironmentId, + setProjectGroupingMode, + setProjectSortOrder, + setThreadSortOrder, + } = useHomeListOptions(availableEnvironmentIds); + const groups = useMemo( + () => + buildHomeThreadGroups({ + projects, + threads, + environmentId: options.selectedEnvironmentId, + searchQuery: props.searchQuery, + projectSortOrder: options.projectSortOrder, + threadSortOrder: options.threadSortOrder, + projectGroupingMode: options.projectGroupingMode, + }), + [options, projects, props.searchQuery, threads], + ); + const [groupDisplayStates, setGroupDisplayStates] = useState< + ReadonlyMap + >(() => new Map()); + const updateGroupDisplay = useCallback((key: string, action: HomeGroupDisplayAction) => { + setGroupDisplayStates((previous) => { + const next = new Map(previous); + next.set( + key, + nextGroupDisplayState(previous.get(key) ?? DEFAULT_GROUP_DISPLAY_STATE, action), + ); + return next; + }); + }, []); + const hasSearchQuery = props.searchQuery.trim().length > 0; + const listLayout = useMemo( + () => + buildHomeListLayout({ + groups, + displayStates: groupDisplayStates, + showAllThreads: hasSearchQuery, + }), + [groups, groupDisplayStates, hasSearchQuery], + ); + const projectCwdByKey = useMemo(() => { + const map = new Map(); + for (const project of projects) { + map.set(scopedProjectKey(project.environmentId, project.id), project.workspaceRoot); + } + return map; + }, [projects]); + const showsConnectionStatus = shouldShowWorkspaceConnectionStatus(catalogState); + const listMenuActions = useMemo( + () => [ + { + id: "environment", + title: "Environment", + subactions: [ + { + id: "environment:all", + title: "All environments", + subtitle: "Show threads from every environment", + state: options.selectedEnvironmentId === null ? "on" : "off", + }, + ...environments.map((environment) => ({ + id: `environment:${environment.environmentId}`, + title: environment.label, + state: + options.selectedEnvironmentId === environment.environmentId + ? ("on" as const) + : ("off" as const), + })), + ], + }, + { + id: "project-sort", + title: "Sort projects", + subactions: PROJECT_SORT_OPTIONS.map((option) => ({ + id: `project-sort:${option.value}`, + title: option.label, + state: options.projectSortOrder === option.value ? "on" : "off", + })), + }, + { + id: "thread-sort", + title: "Sort threads", + subactions: THREAD_SORT_OPTIONS.map((option) => ({ + id: `thread-sort:${option.value}`, + title: option.label, + state: options.threadSortOrder === option.value ? "on" : "off", + })), + }, + { + id: "project-grouping", + title: "Group projects", + subactions: PROJECT_GROUPING_OPTIONS.map((option) => ({ + id: `project-grouping:${option.value}`, + title: option.label, + subtitle: option.subtitle, + state: options.projectGroupingMode === option.value ? "on" : "off", + })), + }, + ], + [environments, options], + ); + const handleListMenuAction = useCallback( + ({ nativeEvent }: { readonly nativeEvent: { readonly event: string } }) => { + const event = nativeEvent.event; + if (event === "environment:all") { + setSelectedEnvironmentId(null); + return; + } + if (event.startsWith("environment:")) { + const environment = environments.find( + (candidate) => String(candidate.environmentId) === event.slice("environment:".length), + ); + if (environment) setSelectedEnvironmentId(environment.environmentId); + return; + } + const projectSort = PROJECT_SORT_OPTIONS.find( + (option) => `project-sort:${option.value}` === event, + ); + if (projectSort) { + setProjectSortOrder(projectSort.value); + return; + } + const threadSort = THREAD_SORT_OPTIONS.find( + (option) => `thread-sort:${option.value}` === event, + ); + if (threadSort) { + setThreadSortOrder(threadSort.value); + return; + } + const grouping = PROJECT_GROUPING_OPTIONS.find( + (option) => `project-grouping:${option.value}` === event, + ); + if (grouping) setProjectGroupingMode(grouping.value); + }, + [ + environments, + setProjectGroupingMode, + setProjectSortOrder, + setSelectedEnvironmentId, + setThreadSortOrder, + ], + ); + + const backgroundColor = useThemeColor("--color-drawer"); + const borderColor = useThemeColor("--color-border"); + const foregroundColor = useThemeColor("--color-foreground"); + const mutedColor = useThemeColor("--color-foreground-muted"); + const placeholderColor = useThemeColor("--color-placeholder"); + const searchBackgroundColor = + colorScheme === "dark" ? IOS_SEARCH_FILL_DARK : IOS_SEARCH_FILL_LIGHT; + const headerFadeColor = String(backgroundColor); + const headerWashOpacity = SIDEBAR_HEADER_WASH_OPACITY[colorScheme]; + const [measuredHeaderHeight, setMeasuredHeaderHeight] = useState(null); + // The sticky header (title row, search field, optional connection status) + // is measured so the list inset always matches its real height — no + // hardcoded per-variant constants. + const stickyHeaderHeight = measuredHeaderHeight ?? insets.top + SIDEBAR_STICKY_HEADER_HEIGHT; + const topListInset = stickyHeaderHeight + 6; + const handleStickyHeaderLayout = useCallback((event: LayoutChangeEvent) => { + const nextHeight = Math.round(event.nativeEvent.layout.height); + setMeasuredHeaderHeight((current) => (current === nextHeight ? current : nextHeight)); + }, []); + const handleSwipeableWillOpen = useCallback((methods: SwipeableMethods) => { + if (openSwipeableRef.current !== methods) { + openSwipeableRef.current?.close(); + openSwipeableRef.current = methods; + } + }, []); + const handleSwipeableClose = useCallback((methods: SwipeableMethods) => { + if (openSwipeableRef.current === methods) { + openSwipeableRef.current = null; + } + }, []); + const handleSelectThread = useCallback( + (thread: EnvironmentThreadShell) => { + props.onSelectThread(thread); + openSwipeableRef.current?.close(); + }, + [props.onSelectThread], + ); + const handleScroll = useCallback((event: NativeSyntheticEvent) => { + const next = event.nativeEvent.contentOffset.y > 6; + if (headerIsOverContentRef.current === next) { + return; + } + headerIsOverContentRef.current = next; + setHeaderIsOverContent(next); + }, []); + const handleScrollBeginDrag = useCallback(() => { + openSwipeableRef.current?.close(); + }, []); + const { swipeEnabled, scrollGateHandlers } = useSwipeableScrollGate({ + onScroll: handleScroll, + onScrollBeginDrag: handleScrollBeginDrag, + }); + const listExtraData = props.selectedThreadKey ?? ""; + const focusSearch = useCallback(() => { + const focus = () => { + if (props.nativeChrome) { + searchBarRef.current?.focus(); + return; + } + searchInputRef.current?.focus(); + }; + if (!props.visible) { + props.onRequestVisibility(); + setTimeout(focus, 240); + } else { + focus(); + } + return true; + }, [props.nativeChrome, props.onRequestVisibility, props.visible]); + useHardwareKeyboardCommand("focusSearch", focusSearch); + const renderListItem = useCallback( + ({ item }: { readonly item: HomeListItem }) => { + switch (item.type) { + case "header": + return ( + + ); + case "thread": { + const thread = item.thread; + return ( + + ); + } + case "show-more": + return ( + + ); + } + }, + [ + archiveThread, + confirmDeleteThread, + handleSelectThread, + handleSwipeableClose, + handleSwipeableWillOpen, + projectCwdByKey, + props.selectedThreadKey, + props.width, + savedConnectionsById, + sidebarScrollGesture, + updateGroupDisplay, + ], + ); + const filterIcon = hasCustomHomeListOptions(options) + ? "line.3.horizontal.decrease.circle.fill" + : "line.3.horizontal.decrease.circle"; + const filterMenu = useMemo( + () => + buildHomeListFilterMenu({ + environments, + selectedEnvironmentId: options.selectedEnvironmentId, + projectSortOrder: options.projectSortOrder, + threadSortOrder: options.threadSortOrder, + projectGroupingMode: options.projectGroupingMode, + onEnvironmentChange: setSelectedEnvironmentId, + onProjectSortOrderChange: setProjectSortOrder, + onThreadSortOrderChange: setThreadSortOrder, + onProjectGroupingModeChange: setProjectGroupingMode, + }), + [ + environments, + options, + setProjectGroupingMode, + setProjectSortOrder, + setSelectedEnvironmentId, + setThreadSortOrder, + ], + ); + const nativeHeaderItems = useMemo( + () => + createSidebarHeaderItems({ + filterIcon, + filterMenu, + onOpenSettings: props.onOpenSettings, + }), + [filterIcon, filterMenu, props.onOpenSettings], + ); + const listEmpty = ( + + {catalogState.isLoadingConnections + ? "Loading threads…" + : props.searchQuery.trim().length > 0 + ? "No matching threads" + : "No threads yet"} + + ); + + if (props.nativeChrome) { + return ( + <> + { + props.onSearchQueryChange(""); + }, + onChangeText: (event) => { + props.onSearchQueryChange(event.nativeEvent.text); + }, + }, + unstable_headerRightItems: () => nativeHeaderItems, + }} + /> + + + + item.type} + itemsAreEqual={homeListItemsAreEqual} + keyExtractor={(item) => item.key} + renderItem={renderListItem} + automaticallyAdjustsScrollIndicatorInsets + contentInsetAdjustmentBehavior="automatic" + contentContainerStyle={[ + styles.threadListContent, + { + paddingBottom: Math.max(insets.bottom, 16) + 16, + paddingTop: 6, + }, + ]} + keyboardDismissMode="on-drag" + keyboardShouldPersistTaps="handled" + {...scrollGateHandlers} + recycleItems + scrollEventThrottle={16} + showsVerticalScrollIndicator={false} + style={styles.threadList} + ListHeaderComponent={ + showsConnectionStatus ? ( + + + + ) : null + } + ListEmptyComponent={listEmpty} + /> + + + + + ); + } + + return ( + + + + + item.type} + itemsAreEqual={homeListItemsAreEqual} + keyExtractor={(item) => item.key} + renderItem={renderListItem} + contentContainerStyle={[ + styles.threadListContent, + { + paddingBottom: 16 + insets.bottom, + paddingTop: topListInset, + }, + ]} + keyboardDismissMode="on-drag" + keyboardShouldPersistTaps="handled" + {...scrollGateHandlers} + recycleItems + scrollEventThrottle={16} + showsVerticalScrollIndicator={false} + style={styles.threadList} + ListEmptyComponent={listEmpty} + /> + + + + + + + + + + + + + + + + + + + + + Threads + + + + + + + + + + + + + + + {showsConnectionStatus ? ( + + + + ) : null} + + + ); +} + +const styles = StyleSheet.create({ + container: { + flex: 1, + }, + stickyHeader: { + left: 0, + position: "absolute", + right: 0, + top: 0, + zIndex: 4, + }, + stickyHeaderWash: { + left: 0, + position: "absolute", + right: 0, + top: 0, + }, + header: { + height: 50, + paddingLeft: 20, + paddingRight: 8, + flexDirection: "row", + alignItems: "flex-end", + gap: 2, + }, + headerButtonGroup: { + alignItems: "center", + borderRadius: 22, + flexDirection: "row", + overflow: "hidden", + }, + connectionStatus: { + paddingTop: 10, + paddingHorizontal: 14, + }, + connectionStatusNative: { + paddingBottom: 8, + paddingHorizontal: 6, + paddingTop: 2, + }, + searchField: { + height: 38, + marginTop: 9, + marginHorizontal: 16, + paddingLeft: 11, + paddingRight: 10, + borderRadius: 12, + flexDirection: "row", + alignItems: "center", + gap: 6, + }, + searchInput: { + flex: 1, + height: 34, + paddingVertical: 0, + paddingHorizontal: 0, + fontFamily: "DMSans_400Regular", + }, + threadList: { + flex: 1, + }, + threadListContent: { + paddingHorizontal: 8, + }, +}); diff --git a/apps/mobile/src/features/threads/ThreadRouteScreen.tsx b/apps/mobile/src/features/threads/ThreadRouteScreen.tsx index f8c916974e5..bf165715d01 100644 --- a/apps/mobile/src/features/threads/ThreadRouteScreen.tsx +++ b/apps/mobile/src/features/threads/ThreadRouteScreen.tsx @@ -1,9 +1,16 @@ -import { Stack, useLocalSearchParams, useRouter } from "expo-router"; -import { useCallback, useMemo, useState } from "react"; +import { NativeStackScreenOptions } from "../../native/StackHeader"; +import { + StackActions, + useFocusEffect, + useNavigation, + type StaticScreenProps, +} from "@react-navigation/native"; +import { useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from "react"; import * as Option from "effect/Option"; -import { EnvironmentId, type ProjectScript } from "@t3tools/contracts"; +import { EnvironmentId, ThreadId, type ProjectScript } from "@t3tools/contracts"; import { projectScriptCwd, projectScriptRuntimeEnv } from "@t3tools/shared/projectScripts"; -import { Pressable, ScrollView, Text as RNText, View } from "react-native"; +import { Platform, Pressable, ScrollView, Text as RNText, View } from "react-native"; +import { useSafeAreaInsets } from "react-native-safe-area-context"; import { useWorkspaceState } from "../../state/workspace"; import { useThemeColor } from "../../lib/useThemeColor"; import { useEnvironmentQuery } from "../../state/query"; @@ -12,7 +19,6 @@ import { vcsEnvironment } from "../../state/vcs"; import { EmptyState } from "../../components/EmptyState"; import { LoadingScreen } from "../../components/LoadingScreen"; -import { buildThreadRoutePath, buildThreadTerminalNavigation } from "../../lib/routes"; import { scopedThreadKey } from "../../lib/scopedEntities"; import { MOBILE_TYPOGRAPHY } from "../../lib/typography"; import { connectionTone } from "../connection/connectionTone"; @@ -37,7 +43,12 @@ import { } from "../terminal/terminalLaunchContext"; import { terminalDebugLog } from "../terminal/terminalDebugLog"; import { ThreadDetailScreen } from "./ThreadDetailScreen"; -import { ThreadGitControls } from "./ThreadGitControls"; +import { + ThreadGitControls, + useThreadGitCenterHeaderItems, + useThreadGitRightHeaderItems, +} from "./ThreadGitControls"; +import { GitOverviewSheet } from "./git/GitOverviewSheet"; import { ThreadNavigationDrawer } from "./ThreadNavigationDrawer"; import { useAtomCommand } from "../../state/use-atom-command"; import { useSelectedThreadGitActions } from "../../state/use-selected-thread-git-actions"; @@ -47,6 +58,30 @@ import { useSelectedThreadWorktree } from "../../state/use-selected-thread-workt import { useThreadComposerState } from "../../state/use-thread-composer-state"; import { threadEnvironment } from "../../state/threads"; import { projectThreadContentPresentation } from "./threadContentPresentation"; +import { + useAdaptiveWorkspaceLayout, + useAdaptiveWorkspacePaneRole, + useRegisterWorkspaceInspector, +} from "../layout/AdaptiveWorkspaceLayout"; +import { withNativeGlassHeaderItem } from "../layout/native-glass-header-items"; +import { ThreadFileNavigatorPane } from "../files/thread-file-navigator-pane"; +import { + ThreadInspectorContentStack, + type ThreadInspectorMode, +} from "./thread-inspector-content-stack"; +import { useHardwareKeyboardCommand } from "../keyboard/hardwareKeyboardCommands"; + +interface ThreadInspectorSelection { + readonly routeThreadIdentity: string | null; + readonly mode: ThreadInspectorMode; +} + +type NativeHeaderItems = ReadonlyArray>; + +function InspectorPaneRoleActivation() { + useAdaptiveWorkspacePaneRole("inspector"); + return null; +} function firstRouteParam(value: string | string[] | undefined): string | null { if (Array.isArray(value)) { @@ -60,13 +95,99 @@ function OpeningThreadLoadingScreen() { return ; } -export function ThreadRouteScreen() { +type ThreadRouteScreenRouteProps = StaticScreenProps<{ + readonly environmentId: string; + readonly threadId: string; +}>; + +interface ThreadRouteScreenProps extends ThreadRouteScreenRouteProps { + readonly onReturnToThread?: () => void; + readonly renderInspector?: (headerInset: number) => ReactNode; +} + +function ThreadUnavailableScreen() { + return ( + + + + ); +} + +export function ThreadRouteScreen(props: ThreadRouteScreenProps) { const { state: workspaceState } = useWorkspaceState(); const { connectionState } = useRemoteConnectionStatus(); + const { selectedThread } = useThreadSelection(); + const params = props.route.params; + const environmentIdRaw = firstRouteParam(params.environmentId); + const threadIdRaw = firstRouteParam(params.threadId); + const environmentId = environmentIdRaw ? EnvironmentId.make(environmentIdRaw) : null; + const routeEnvironmentRuntime = useRemoteEnvironmentRuntime(environmentId); + const routeConnectionState = + routeEnvironmentRuntime?.connectionState ?? (environmentId ? "available" : connectionState); + const routeThreadKey = + environmentId !== null && threadIdRaw !== null + ? scopedThreadKey(environmentId, ThreadId.make(threadIdRaw)) + : null; + const selectedThreadKey = + selectedThread === null + ? null + : scopedThreadKey(selectedThread.environmentId, selectedThread.id); + const selectedThreadDetailState = useSelectedThreadDetailState(); + + if (environmentId === null || threadIdRaw === null) { + return ; + } + + // Render the full thread chrome (header, feed, composer) as soon as the + // thread SHELL is known — no blocking on message detail. The feed shows a + // loading placeholder while messages fetch, and the composer's connection + // pill reports connecting/reconnecting/syncing status. + if (selectedThread !== null && selectedThreadKey === routeThreadKey) { + return ; + } + + const stillHydrating = + workspaceState.isLoadingConnections || + routeConnectionState === "connecting" || + routeConnectionState === "reconnecting"; + + if (stillHydrating) { + return ; + } + + return ; +} + +function ThreadRouteContent( + props: ThreadRouteScreenProps & { + readonly selectedThreadDetailState: ReturnType; + }, +) { + const { + fileInspector, + layout, + panes, + showAuxiliaryPane, + toggleAuxiliaryPane, + togglePrimarySidebar, + } = useAdaptiveWorkspaceLayout(); + const { connectionState } = useRemoteConnectionStatus(); const { onReconnectEnvironment } = useRemoteConnections(); const { selectedThread, selectedThreadProject, selectedEnvironmentConnection } = useThreadSelection(); - const selectedThreadDetailState = useSelectedThreadDetailState(); + const selectedThreadDetailState = props.selectedThreadDetailState; const selectedThreadDetail = Option.getOrNull(selectedThreadDetailState.data); const { selectedThreadCwd } = useSelectedThreadWorktree(); const composer = useThreadComposerState(); @@ -74,15 +195,72 @@ export function ThreadRouteScreen() { const gitActions = useSelectedThreadGitActions(); const requests = useSelectedThreadRequests(); const interruptThreadTurn = useAtomCommand(threadEnvironment.interruptTurn, "thread interrupt"); - const router = useRouter(); - const params = useLocalSearchParams<{ - environmentId?: string | string[]; - threadId?: string | string[]; - }>(); + const navigation = useNavigation(); + const params = props.route.params; const [drawerVisible, setDrawerVisible] = useState(false); const environmentIdRaw = firstRouteParam(params.environmentId); const environmentId = environmentIdRaw ? EnvironmentId.make(environmentIdRaw) : null; const threadId = firstRouteParam(params.threadId); + const routeThreadIdentity = + environmentIdRaw !== null && threadId !== null ? `${environmentIdRaw}:${threadId}` : null; + const [inspectorSelection, setInspectorSelection] = useState( + () => (props.renderInspector ? { routeThreadIdentity, mode: "route" } : null), + ); + const inspectorMode = (() => { + if (inspectorSelection?.routeThreadIdentity === routeThreadIdentity) { + if (inspectorSelection.mode === "files" && selectedThreadCwd === null) { + return null; + } + return inspectorSelection.mode; + } + return null; + })(); + useEffect(() => { + if ( + fileInspector.supported && + selectedThreadCwd === null && + inspectorMode === null && + panes.auxiliaryPaneVisible + ) { + toggleAuxiliaryPane(); + } + }, [ + fileInspector.supported, + inspectorMode, + panes.auxiliaryPaneVisible, + selectedThreadCwd, + toggleAuxiliaryPane, + ]); + + useEffect(() => { + setInspectorSelection((current) => { + if (props.renderInspector === undefined) { + if (current === null || current.mode === "route") { + return null; + } + return { ...current, routeThreadIdentity }; + } + + if (current === null || current.mode === "route") { + return { routeThreadIdentity, mode: "route" }; + } + + return { ...current, routeThreadIdentity }; + }); + }, [props.renderInspector, routeThreadIdentity]); + + useFocusEffect( + useCallback(() => { + return () => { + if (props.renderInspector === undefined) { + // Inspectors are contextual to this chat destination. Clear the + // hidden chat copy after a native push so returning from Files, + // Review, or Terminal cannot reserve an empty trailing pane. + setInspectorSelection(null); + } + }; + }, [props.renderInspector]), + ); const routeEnvironmentRuntime = useRemoteEnvironmentRuntime(environmentId); const routeConnectionState = routeEnvironmentRuntime?.connectionState ?? (environmentId ? "available" : connectionState); @@ -101,10 +279,14 @@ export function ThreadRouteScreen() { ); /* ─── Native header theming ──────────────────────────────────────── */ - const iconColor = String(useThemeColor("--color-icon")); const foregroundColor = String(useThemeColor("--color-foreground")); - const secondaryFg = String(useThemeColor("--color-foreground-secondary")); - + const usesNativeHeaderGlass = Platform.OS === "ios"; + const headerSubtitle = [ + selectedThreadProject?.title ?? null, + selectedEnvironmentConnection?.environmentLabel ?? null, + ] + .filter(Boolean) + .join(" · "); /* ─── Git status for native header trigger ───────────────────────── */ const gitStatus = useEnvironmentQuery( selectedThread !== null && selectedThreadCwd !== null @@ -145,12 +327,134 @@ export function ThreadRouteScreen() { const gitActionProgress = useGitActionProgress(gitActionProgressTarget); const handleOpenDrawer = useCallback(() => { - setDrawerVisible(true); + if (!layout.usesSplitView) { + setDrawerVisible(true); + } + }, [layout.usesSplitView]); + + useEffect(() => { + if (layout.usesSplitView) { + setDrawerVisible(false); + } + }, [layout.usesSplitView]); + + const handleOpenGitInspector = useCallback(() => { + setInspectorSelection({ routeThreadIdentity, mode: "git" }); + showAuxiliaryPane("inspector"); + }, [routeThreadIdentity, showAuxiliaryPane]); + const handleOpenFilesInspector = useCallback(() => { + if (!fileInspector.supported || selectedThread === null || selectedThreadCwd === null) { + return; + } + setInspectorSelection({ + routeThreadIdentity, + mode: props.renderInspector === undefined ? "files" : "route", + }); + showAuxiliaryPane("inspector"); + }, [ + fileInspector.supported, + props.renderInspector, + routeThreadIdentity, + selectedThread, + selectedThreadCwd, + showAuxiliaryPane, + ]); + const inspectorToggleActionRef = useRef({ + inspectorMode, + openFilesInspector: handleOpenFilesInspector, + toggleAuxiliaryPane, + }); + inspectorToggleActionRef.current = { + inspectorMode, + openFilesInspector: handleOpenFilesInspector, + toggleAuxiliaryPane, + }; + const handleToggleInspector = useCallback(() => { + const action = inspectorToggleActionRef.current; + if (action.inspectorMode === null) { + action.openFilesInspector(); + return; + } + action.toggleAuxiliaryPane(); }, []); + const handleSelectInspectorFile = useCallback( + (path: string) => { + if (selectedThread === null) { + return; + } + const params = { + environmentId: String(selectedThread.environmentId), + threadId: String(selectedThread.id), + path: path.split("/").filter((segment) => segment.length > 0), + }; + if (fileInspector.supported) { + navigation.navigate("ThreadFile", params); + return; + } + navigation.navigate("ThreadFile", params); + }, + [fileInspector.supported, navigation, selectedThread], + ); + // The workspace inspector column spans the full window height. On iOS the + // panes bring their own nested native headers (which underlap the status + // bar); elsewhere the pane content pads itself below the top inset. + const safeAreaInsets = useSafeAreaInsets(); + const inspectorHeaderInset = Platform.OS === "ios" ? 0 : safeAreaInsets.top; + const GitInspector = useCallback( + () => ( + + ), + [inspectorHeaderInset, props.route.params], + ); + const FilesInspector = useCallback( + () => + selectedThread !== null && selectedThreadCwd !== null ? ( + + ) : null, + [ + handleSelectInspectorFile, + inspectorHeaderInset, + selectedThread, + selectedThreadCwd, + selectedThreadProject?.title, + ], + ); + const RouteInspector = useCallback( + () => props.renderInspector?.(inspectorHeaderInset), + [inspectorHeaderInset, props.renderInspector], + ); + const renderInspectorStack = useCallback( + () => + inspectorMode === null ? null : ( + + ), + [FilesInspector, GitInspector, RouteInspector, inspectorMode, props.renderInspector], + ); + const activeInspectorRenderer = inspectorMode === null ? undefined : renderInspectorStack; + // Hand the inspector to the workspace so it renders beside the navigator, + // outside this screen's native header — the terminal/git/files toolbar + // stays anchored to the chat pane instead of floating above the inspector. + useRegisterWorkspaceInspector(activeInspectorRenderer); const handleOpenConnectionEditor = useCallback(() => { - void router.push("/connections"); - }, [router]); + void navigation.navigate("Connections"); + }, [navigation]); const handleStopThread = useCallback(() => { if ( !selectedThread || @@ -182,9 +486,13 @@ export function ThreadRouteScreen() { return; } - void router.push(buildThreadTerminalNavigation(selectedThread, nextTerminalId)); + void navigation.navigate("ThreadTerminal", { + environmentId: String(selectedThread.environmentId), + threadId: String(selectedThread.id), + ...(nextTerminalId ? { terminalId: nextTerminalId } : {}), + }); }, - [router, selectedThread, selectedThreadProject?.workspaceRoot], + [navigation, selectedThread, selectedThreadProject?.workspaceRoot], ); const handleOpenNewTerminal = useCallback(() => { @@ -201,8 +509,12 @@ export function ThreadRouteScreen() { const nextId = nextOpenTerminalId({ listedTerminalIds: terminalMenuSessions.map((session) => session.terminalId), }); - void router.push(buildThreadTerminalNavigation(selectedThread, nextId)); - }, [router, selectedThread, selectedThreadProject?.workspaceRoot, terminalMenuSessions]); + void navigation.navigate("ThreadTerminal", { + environmentId: String(selectedThread.environmentId), + threadId: String(selectedThread.id), + terminalId: nextId, + }); + }, [navigation, selectedThread, selectedThreadProject?.workspaceRoot, terminalMenuSessions]); const handleRunProjectScript = useCallback( async (script: ProjectScript) => { @@ -259,48 +571,97 @@ export function ThreadRouteScreen() { worktreePath: preferredWorktreePath, }); - void router.push(buildThreadTerminalNavigation(selectedThread, targetTerminalId)); + void navigation.navigate("ThreadTerminal", { + environmentId: String(selectedThread.environmentId), + threadId: String(selectedThread.id), + terminalId: targetTerminalId, + }); }, [ - router, + navigation, selectedThread, selectedThreadDetailWorktreePath, selectedThreadProject, terminalMenuSessions, ], ); + const threadGitControlProps = { + environmentId: environmentIdRaw ?? "", + threadId: threadId ?? "", + auxiliaryPaneControl: + !layout.usesSplitView && fileInspector.supported && selectedThreadCwd !== null + ? { + accessibilityLabel: "Toggle inspector", + onPress: handleToggleInspector, + } + : undefined, + onOpenFilesInspector: + fileInspector.supported && selectedThreadCwd !== null ? handleOpenFilesInspector : undefined, + onOpenGitInspector: fileInspector.supported ? handleOpenGitInspector : undefined, + currentBranch: selectedThread?.branch ?? null, + gitStatus: gitStatus.data, + gitOperationLabel: gitState.gitOperationLabel, + canOpenTerminal: Boolean(selectedThreadProject?.workspaceRoot), + canOpenFiles: Boolean(selectedThreadProject?.workspaceRoot), + projectScripts: selectedThreadProject?.scripts ?? [], + terminalSessions: terminalMenuSessions, + showDirectFileControl: layout.usesSplitView, + onOpenTerminal: handleOpenTerminal, + onOpenNewTerminal: handleOpenNewTerminal, + onRunProjectScript: handleRunProjectScript, + onPull: gitActions.onPullSelectedThreadBranch, + onRunAction: gitActions.onRunSelectedThreadGitAction, + }; + const threadCenterHeaderItems = useThreadGitCenterHeaderItems(threadGitControlProps); + const compactRightHeaderItems = useThreadGitRightHeaderItems(threadGitControlProps); + const splitLeftHeaderItems = useMemo( + () => [ + { + // Match Mail's split-view detail toolbar: the first detail action sits + // inside the content pane, not flush against the sidebar divider. + spacing: 18, + type: "spacing" as const, + }, + ...(props.onReturnToThread + ? [ + withNativeGlassHeaderItem({ + accessibilityLabel: "Return to chat", + icon: { name: "chevron.left", type: "sfSymbol" as const }, + identifier: "thread-left-return", + onPress: props.onReturnToThread, + type: "button" as const, + }), + ] + : []), + withNativeGlassHeaderItem({ + accessibilityLabel: panes.primarySidebarVisible + ? "Maximize content" + : "Show thread sidebar", + icon: { + name: panes.primarySidebarVisible ? "arrow.up.left.and.arrow.down.right" : "sidebar.left", + type: "sfSymbol" as const, + }, + identifier: "thread-left-sidebar", + onPress: togglePrimarySidebar, + type: "button" as const, + }), + withNativeGlassHeaderItem({ + accessibilityLabel: "New task", + icon: { name: "square.and.pencil", type: "sfSymbol" as const }, + identifier: "thread-left-new-task", + onPress: () => navigation.navigate("NewTaskSheet", { screen: "NewTask" }), + type: "button" as const, + }), + ], + [panes.primarySidebarVisible, props.onReturnToThread, navigation, togglePrimarySidebar], + ); if (!environmentId || !threadId) { return ; } if (!selectedThread) { - const stillHydrating = - workspaceState.isLoadingConnections || - routeConnectionState === "connecting" || - routeConnectionState === "reconnecting"; - - if (stillHydrating) { - return ; - } - - return ( - - - - ); + return ; } const selectedThreadKey = scopedThreadKey(selectedThread.environmentId, selectedThread.id); @@ -311,74 +672,9 @@ export function ThreadRouteScreen() { connectionState: routeConnectionState, }); const serverConfig = routeEnvironmentRuntime?.serverConfig ?? null; - - const headerSubtitle = [ - selectedThreadProject?.title ?? null, - selectedEnvironmentConnection?.environmentLabel ?? null, - ] - .filter(Boolean) - .join(" · "); - - return ( + const renderThreadRouteBody = (showActionControls: boolean) => ( <> - ( - { - // TODO: trigger rename modal - }} - > - - {selectedThread.title} - - - {headerSubtitle} - - - ), - }} - /> - - + @@ -400,11 +696,14 @@ export function ThreadRouteScreen() { draftMessage={composer.draftMessage} draftAttachments={composer.draftAttachments} connectionStateLabel={routeConnectionState} + threadSyncStatus={selectedThreadDetailState.status} activeThreadBusy={composer.activeThreadBusy} environmentId={selectedThread.environmentId} projectWorkspaceRoot={selectedThreadProject?.workspaceRoot ?? null} threadCwd={selectedThreadCwd} selectedThreadQueueCount={composer.selectedThreadQueueCount} + layoutVariant={layout.variant} + usesAutomaticContentInsets={usesNativeHeaderGlass} onOpenDrawer={handleOpenDrawer} onOpenConnectionEditor={handleOpenConnectionEditor} onChangeDraftMessage={composer.onChangeDraftMessage} @@ -424,16 +723,57 @@ export function ThreadRouteScreen() { onSubmitUserInput={requests.onSubmitUserInput} /> - setDrawerVisible(false)} - onSelectThread={(thread) => { - router.replace(buildThreadRoutePath(thread)); - }} - onStartNewTask={() => router.push("/new")} - /> + {layout.usesSplitView ? null : ( + setDrawerVisible(false)} + onSelectThread={(thread) => { + navigation.dispatch( + StackActions.replace("Thread", { + environmentId: String(thread.environmentId), + threadId: String(thread.id), + }), + ); + }} + onStartNewTask={() => navigation.navigate("NewTaskSheet", { screen: "NewTask" })} + /> + )} ); + + return ( + <> + {activeInspectorRenderer ? : null} + splitLeftHeaderItems : undefined, + // Search lives in the persistent sidebar, so the split header keeps + // the git controls on the RIGHT (no center items — center space is + // reserved for future breadcrumbs/status). + unstable_headerRightItems: + Platform.OS === "ios" + ? () => (layout.usesSplitView ? threadCenterHeaderItems : compactRightHeaderItems) + : undefined, + unstable_headerSubtitle: usesNativeHeaderGlass ? headerSubtitle : undefined, + }} + /> + + {renderThreadRouteBody(!layout.usesSplitView && !usesNativeHeaderGlass)} + + ); } diff --git a/apps/mobile/src/features/threads/git/GitBranchesSheet.tsx b/apps/mobile/src/features/threads/git/GitBranchesSheet.tsx index e27136702f2..2acf641753d 100644 --- a/apps/mobile/src/features/threads/git/GitBranchesSheet.tsx +++ b/apps/mobile/src/features/threads/git/GitBranchesSheet.tsx @@ -1,5 +1,5 @@ import { sanitizeFeatureBranchName } from "@t3tools/shared/git"; -import { useRouter } from "expo-router"; +import { useNavigation, type StaticScreenProps } from "@react-navigation/native"; import { useState } from "react"; import { Pressable, ScrollView, View } from "react-native"; import { useSafeAreaInsets } from "react-native-safe-area-context"; @@ -14,8 +14,13 @@ import { useSelectedThreadWorktree } from "../../../state/use-selected-thread-wo import { vcsEnvironment } from "../../../state/vcs"; import { SheetActionButton } from "./gitSheetComponents"; -export function GitBranchesSheet() { - const router = useRouter(); +type GitBranchesSheetProps = StaticScreenProps<{ + readonly environmentId: string; + readonly threadId: string; +}>; + +export function GitBranchesSheet(_props: GitBranchesSheetProps) { + const navigation = useNavigation(); const insets = useSafeAreaInsets(); const { selectedThread } = useThreadSelection(); const { selectedThreadCwd, selectedThreadWorktreePath } = useSelectedThreadWorktree(); @@ -96,7 +101,7 @@ export function GitBranchesSheet() { if (branch.length === 0) return; void gitActions.onCreateSelectedThreadBranch(branch).then(() => { setNewBranchName(""); - router.dismiss(); + navigation.goBack(); }); }} /> @@ -146,7 +151,7 @@ export function GitBranchesSheet() { if (baseBranch.length === 0 || newBranch.length === 0) return; void gitActions.onCreateSelectedThreadWorktree({ baseBranch, newBranch }).then(() => { setWorktreeBranchName(""); - router.dismiss(); + navigation.goBack(); }); }} /> @@ -188,7 +193,7 @@ export function GitBranchesSheet() { }} onPress={() => { void gitActions.onCheckoutSelectedThreadBranch(branch.name).then(() => { - router.dismiss(); + navigation.goBack(); }); }} > diff --git a/apps/mobile/src/features/threads/git/GitCommitSheet.tsx b/apps/mobile/src/features/threads/git/GitCommitSheet.tsx index 76e0daf5f0a..bf2d187af0d 100644 --- a/apps/mobile/src/features/threads/git/GitCommitSheet.tsx +++ b/apps/mobile/src/features/threads/git/GitCommitSheet.tsx @@ -1,4 +1,4 @@ -import { useRouter } from "expo-router"; +import { useNavigation, type StaticScreenProps } from "@react-navigation/native"; import { useCallback, useState } from "react"; import { Pressable, ScrollView, View, useColorScheme } from "react-native"; import { useSafeAreaInsets } from "react-native-safe-area-context"; @@ -13,8 +13,13 @@ import { useSelectedThreadWorktree } from "../../../state/use-selected-thread-wo import { vcsEnvironment } from "../../../state/vcs"; import { SheetActionButton } from "./gitSheetComponents"; -export function GitCommitSheet() { - const router = useRouter(); +type GitCommitSheetProps = StaticScreenProps<{ + readonly environmentId: string; + readonly threadId: string; +}>; + +export function GitCommitSheet(_props: GitCommitSheetProps) { + const navigation = useNavigation(); const insets = useSafeAreaInsets(); const isDarkMode = useColorScheme() === "dark"; const { selectedThread } = useThreadSelection(); @@ -55,7 +60,7 @@ export function GitCommitSheet() { const runCommitAction = useCallback( async (featureBranch: boolean) => { const commitMessage = dialogCommitMessage.trim(); - router.dismiss(); + navigation.goBack(); await gitActions.onRunSelectedThreadGitAction({ action: "commit", featureBranch, @@ -63,7 +68,7 @@ export function GitCommitSheet() { ...(!allSelected ? { filePaths: selectedFiles.map((file) => file.path) } : {}), }); }, - [allSelected, dialogCommitMessage, gitActions, router, selectedFiles], + [allSelected, dialogCommitMessage, gitActions, navigation, selectedFiles], ); return ( @@ -86,7 +91,7 @@ export function GitCommitSheet() { {isDefaultRef ? ( Warning: this is the default branch. @@ -98,7 +103,7 @@ export function GitCommitSheet() { Files - + {selectedFiles.length} selected · +{selectedInsertions} / -{selectedDeletions} @@ -123,7 +128,7 @@ export function GitCommitSheet() { {allFiles.length === 0 ? ( - + No changed files are available to commit. ) : !isEditingFiles ? ( @@ -142,7 +147,7 @@ export function GitCommitSheet() { ))} {selectedFiles.length > selectedFilePreview.length ? ( - + +{selectedFiles.length - selectedFilePreview.length} more files ) : null} @@ -182,7 +187,7 @@ export function GitCommitSheet() { {file.path} {!included ? ( - + Excluded from this commit ) : null} diff --git a/apps/mobile/src/features/threads/git/GitConfirmSheet.tsx b/apps/mobile/src/features/threads/git/GitConfirmSheet.tsx index 4272c31d2c1..b4f78742141 100644 --- a/apps/mobile/src/features/threads/git/GitConfirmSheet.tsx +++ b/apps/mobile/src/features/threads/git/GitConfirmSheet.tsx @@ -2,7 +2,7 @@ import { resolveDefaultBranchActionDialogCopy } from "@t3tools/client-runtime/st import { resolveAutoFeatureBranchName } from "@t3tools/shared/git"; import * as Arr from "effect/Array"; import * as Result from "effect/Result"; -import { useLocalSearchParams, useRouter } from "expo-router"; +import { StackActions, useNavigation, type StaticScreenProps } from "@react-navigation/native"; import { useCallback, useMemo } from "react"; import { View } from "react-native"; import { useSafeAreaInsets } from "react-native-safe-area-context"; @@ -12,19 +12,23 @@ import { useSelectedThreadGitActions } from "../../../state/use-selected-thread- import { useSelectedThreadGitState } from "../../../state/use-selected-thread-git-state"; import { SheetActionButton } from "./gitSheetComponents"; -export function GitConfirmSheet() { - const router = useRouter(); +type GitConfirmSheetProps = StaticScreenProps<{ + readonly environmentId: string; + readonly threadId: string; + readonly confirmAction?: string; + readonly branchName?: string; + readonly includesCommit?: string; + readonly commitMessage?: string; + readonly filePaths?: string; +}>; + +export function GitConfirmSheet(props: GitConfirmSheetProps) { + const navigation = useNavigation(); const insets = useSafeAreaInsets(); const gitState = useSelectedThreadGitState(); const gitActions = useSelectedThreadGitActions(); - const params = useLocalSearchParams<{ - confirmAction?: string; - branchName?: string; - includesCommit?: string; - commitMessage?: string; - filePaths?: string; - }>(); + const params = props.route.params; const confirmAction = params.confirmAction as | "push" @@ -34,6 +38,8 @@ export function GitConfirmSheet() { | undefined; const branchName = params.branchName ?? ""; const includesCommit = params.includesCommit === "true"; + const environmentId = params.environmentId ?? ""; + const threadId = params.threadId ?? ""; const copy = useMemo( () => @@ -49,17 +55,17 @@ export function GitConfirmSheet() { const continuePendingAction = useCallback(async () => { if (!confirmAction) return; - router.dismissAll(); + navigation.dispatch(StackActions.replace("Thread", { environmentId, threadId })); await gitActions.onRunSelectedThreadGitAction({ action: confirmAction, ...(params.commitMessage ? { commitMessage: params.commitMessage } : {}), ...(params.filePaths ? { filePaths: params.filePaths.split(",") } : {}), }); - }, [confirmAction, gitActions, params, router]); + }, [confirmAction, environmentId, gitActions, params, navigation, threadId]); const movePendingActionToFeatureBranch = useCallback(async () => { if (!confirmAction) return; - router.dismissAll(); + navigation.dispatch(StackActions.replace("Thread", { environmentId, threadId })); if (includesCommit) { await gitActions.onRunSelectedThreadGitAction({ @@ -82,7 +88,16 @@ export function GitConfirmSheet() { ); await gitActions.onCreateSelectedThreadBranch(newBranchName); await gitActions.onRunSelectedThreadGitAction({ action: confirmAction }); - }, [confirmAction, gitActions, gitState.selectedThreadBranches, includesCommit, params, router]); + }, [ + confirmAction, + gitActions, + gitState.selectedThreadBranches, + includesCommit, + params, + navigation, + environmentId, + threadId, + ]); return ( @@ -98,7 +113,7 @@ export function GitConfirmSheet() { {copy?.title ?? "Run action on default branch?"} - + {copy?.description ?? "Choose how to continue."} diff --git a/apps/mobile/src/features/threads/git/GitOverviewSheet.tsx b/apps/mobile/src/features/threads/git/GitOverviewSheet.tsx index 0db7876a774..ab2e0168ff5 100644 --- a/apps/mobile/src/features/threads/git/GitOverviewSheet.tsx +++ b/apps/mobile/src/features/threads/git/GitOverviewSheet.tsx @@ -4,17 +4,18 @@ import { getGitActionDisabledReason, requiresDefaultBranchConfirmation, } from "@t3tools/client-runtime/state/vcs"; -import type { EnvironmentId, ThreadId } from "@t3tools/contracts"; -import { useLocalSearchParams, useRouter } from "expo-router"; +import { EnvironmentId, ThreadId } from "@t3tools/contracts"; +import { useNavigation, type StaticScreenProps } from "@react-navigation/native"; import { SymbolView } from "expo-symbols"; -import { useCallback, useEffect, useMemo } from "react"; -import { Alert, Pressable, ScrollView, View } from "react-native"; +import { useCallback, useEffect, useMemo, useState } from "react"; +import { Alert, Platform, Pressable, RefreshControl, ScrollView, View } from "react-native"; +import { Screen, ScreenStack, ScreenStackHeaderConfig } from "react-native-screens"; import { useSafeAreaInsets } from "react-native-safe-area-context"; import { useThemeColor } from "../../../lib/useThemeColor"; import { AppText as Text } from "../../../components/AppText"; +import { nativeHeaderScrollEdgeEffects } from "../../../native/StackHeader"; import { tryOpenExternalUrl } from "../../../lib/openExternalUrl"; -import { buildThreadReviewRoutePath } from "../../../lib/routes"; import { useEnvironmentQuery } from "../../../state/query"; import { useThreadSelection } from "../../../state/use-thread-selection"; import { useSelectedThreadGitActions } from "../../../state/use-selected-thread-git-actions"; @@ -23,13 +24,23 @@ import { useSelectedThreadWorktree } from "../../../state/use-selected-thread-wo import { vcsEnvironment } from "../../../state/vcs"; import { MetaCard, SheetListRow, menuItemIconName, statusSummary } from "./gitSheetComponents"; -export function GitOverviewSheet() { - const router = useRouter(); +const HEADER_SCROLL_EDGE_EFFECTS = nativeHeaderScrollEdgeEffects(Platform.OS, Platform.Version); + +type GitOverviewSheetProps = StaticScreenProps<{ + readonly environmentId: string; + readonly threadId: string; +}> & { + readonly headerInset?: number; + readonly presentation?: "sheet" | "inspector"; +}; + +export function GitOverviewSheet(props: GitOverviewSheetProps) { + const navigation = useNavigation(); const insets = useSafeAreaInsets(); - const { environmentId, threadId } = useLocalSearchParams<{ - environmentId: EnvironmentId; - threadId: ThreadId; - }>(); + const presentation = props.presentation ?? "sheet"; + const isInspector = presentation === "inspector"; + const environmentId = EnvironmentId.make(props.route.params.environmentId); + const threadId = ThreadId.make(props.route.params.threadId); const { selectedThread } = useThreadSelection(); const { selectedThreadCwd, selectedThreadWorktreePath } = useSelectedThreadWorktree(); const gitState = useSelectedThreadGitState(); @@ -37,6 +48,8 @@ export function GitOverviewSheet() { const iconColor = useThemeColor("--color-icon"); const borderColor = useThemeColor("--color-border"); + const foregroundColor = String(useThemeColor("--color-foreground")); + const sheetColor = String(useThemeColor("--color-sheet")); const gitStatus = useEnvironmentQuery( selectedThread !== null && selectedThreadCwd !== null @@ -48,6 +61,7 @@ export function GitOverviewSheet() { ); const currentBranchLabel = gitStatus.data?.refName ?? selectedThread?.branch ?? "Detached HEAD"; + const currentStatusSummary = statusSummary(gitStatus.data); const currentWorktreePath = selectedThreadWorktreePath; const gitOperationLabel = gitState.gitOperationLabel; const busy = gitOperationLabel !== null; @@ -105,25 +119,24 @@ export function GitOverviewSheet() { !input.featureBranch && requiresDefaultBranchConfirmation(input.action, isDefaultRef) ) { - router.push({ - pathname: "/threads/[environmentId]/[threadId]/git-confirm", - params: { - environmentId, - threadId, - confirmAction: confirmableAction, - branchName, - includesCommit: String( - input.action === "commit_push" || input.action === "commit_push_pr", - ), - }, + navigation.navigate("GitConfirm", { + environmentId: String(environmentId), + threadId: String(threadId), + confirmAction: confirmableAction, + branchName, + includesCommit: String( + input.action === "commit_push" || input.action === "commit_push_pr", + ), }); return; } - router.dismiss(); + if (!isInspector) { + navigation.goBack(); + } await gitActions.onRunSelectedThreadGitAction(input); }, - [environmentId, gitActions, gitStatus.data, isDefaultRef, router, threadId], + [environmentId, gitActions, gitStatus.data, isDefaultRef, isInspector, navigation, threadId], ); const onPressMenuItem = useCallback( @@ -134,9 +147,9 @@ export function GitOverviewSheet() { return; } if (item.dialogAction === "commit") { - router.push({ - pathname: "/threads/[environmentId]/[threadId]/git/commit", - params: { environmentId, threadId }, + navigation.navigate("GitCommit", { + environmentId: String(environmentId), + threadId: String(threadId), }); return; } @@ -148,14 +161,209 @@ export function GitOverviewSheet() { await runActionWithPrompt({ action: "create_pr" }); } }, - [environmentId, openExistingPr, router, runActionWithPrompt, threadId], + [environmentId, openExistingPr, navigation, runActionWithPrompt, threadId], + ); + + // Status facts live on the relevant rows instead of crowding the header + // subtitle: files changed → Commit, ahead → Push, PR → View PR, behind → Pull. + const rowStatusDetail = useCallback( + (item: (typeof menuItems)[number]): string | undefined => { + const status = gitStatus.data; + if (status == null) { + return undefined; + } + if (item.dialogAction === "commit" && status.hasWorkingTreeChanges) { + const fileCount = status.workingTree?.files.length ?? 0; + return `${fileCount} file${fileCount === 1 ? "" : "s"} changed`; + } + if (item.dialogAction === "push" && (status.aheadCount ?? 0) > 0) { + const ahead = status.aheadCount ?? 0; + return `${ahead} commit${ahead === 1 ? "" : "s"} ahead`; + } + if (item.kind === "open_pr" && status.pr?.number != null) { + return `PR #${status.pr.number} ${status.pr.state ?? "open"}`; + } + return undefined; + }, + [gitStatus.data, menuItems], + ); + + const behindCount = gitStatus.data?.behindCount ?? 0; + + // Deterministic pull-to-refresh state. Tying RefreshControl to the query's + // isPending flag left the spinner stuck (the status query reports pending + // during quiet background refreshes too). + const [isPullRefreshing, setIsPullRefreshing] = useState(false); + const handlePullRefresh = useCallback(async () => { + setIsPullRefreshing(true); + try { + await gitActions.refreshSelectedThreadGitStatus(); + } finally { + setIsPullRefreshing(false); + } + }, [gitActions]); + + const content = ( + void handlePullRefresh()} /> + } + > + + {sheetMenuItems.map(({ item, disabledReason }, index) => ( + + {index > 0 ? ( + + ) : null} + void onPressMenuItem(item)} + /> + + ))} + {behindCount > 0 ? ( + <> + + void gitActions.onPullSelectedThreadBranch()} + /> + + ) : null} + + navigation.navigate("ThreadReview", { environmentId, threadId })} + /> + + + navigation.navigate("GitBranches", { + environmentId: String(environmentId), + threadId: String(threadId), + }) + } + /> + + + {currentWorktreePath ? : null} + ); + if (isInspector && Platform.OS === "ios") { + return ( + + + + {content} + + + + + ); + } + + if (Platform.OS === "ios") { + // Compact form sheet: a plain screen presented as formSheet never renders a + // stack header, so — like the Settings sheet — the header must come from a + // nested native stack INSIDE the sheet. This reuses the exact structure of the + // inspector branch below: branch as the title, status summary as the native + // subtitle, refresh as a header button. + return ( + + + + {content} + + + + + ); + } + return ( - - + + - + - Branch + {isInspector ? "Repository" : "Branch"} + + + {currentBranchLabel} - {currentBranchLabel} - - {statusSummary(gitStatus.data)} + + {currentStatusSummary} - - - {sheetMenuItems.map(({ item, disabledReason }, index) => ( - - {index > 0 ? ( - - ) : null} - void onPressMenuItem(item)} - /> - - ))} - {(gitStatus.data?.behindCount ?? 0) > 0 ? ( - <> - - void gitActions.onPullSelectedThreadBranch()} - /> - - ) : null} - - router.push(buildThreadReviewRoutePath({ environmentId, threadId }))} - /> - - - router.push({ - pathname: "/threads/[environmentId]/[threadId]/git/branches", - params: { environmentId, threadId }, - }) - } - /> - - - {currentWorktreePath ? : null} - + {content} ); } diff --git a/apps/mobile/src/features/threads/git/gitSheetComponents.tsx b/apps/mobile/src/features/threads/git/gitSheetComponents.tsx index 16c311bff57..8045de78228 100644 --- a/apps/mobile/src/features/threads/git/gitSheetComponents.tsx +++ b/apps/mobile/src/features/threads/git/gitSheetComponents.tsx @@ -104,7 +104,7 @@ export function SheetListRow(props: { {props.title} {props.subtitle ? ( - {props.subtitle} + {props.subtitle} ) : null} diff --git a/apps/mobile/src/features/threads/sidebar-filter-button.tsx b/apps/mobile/src/features/threads/sidebar-filter-button.tsx new file mode 100644 index 00000000000..642a4a957bb --- /dev/null +++ b/apps/mobile/src/features/threads/sidebar-filter-button.tsx @@ -0,0 +1,55 @@ +import { SymbolView } from "expo-symbols"; +import { Pressable, StyleSheet, useColorScheme } from "react-native"; + +import { useThemeColor } from "../../lib/useThemeColor"; + +export type SidebarFilterButtonIcon = + | "line.3.horizontal.decrease.circle" + | "line.3.horizontal.decrease.circle.fill"; + +export function SidebarFilterButton(props: { + readonly accessibilityLabel: string; + readonly icon: SidebarFilterButtonIcon; + /** Rendered inside a shared capsule group — no own background/border. */ + readonly grouped?: boolean; +}) { + const iconColor = useThemeColor("--color-foreground"); + const pressedBackgroundColor = useThemeColor("--color-subtle"); + const colorScheme = useColorScheme() === "dark" ? "dark" : "light"; + const idleBackgroundColor = + colorScheme === "dark" ? "rgba(118,118,128,0.24)" : "rgba(255,255,255,0.72)"; + const borderColor = colorScheme === "dark" ? "rgba(255,255,255,0.08)" : "rgba(0,0,0,0.08)"; + + return ( + [ + styles.button, + props.grouped + ? { backgroundColor: pressed ? pressedBackgroundColor : "transparent", borderWidth: 0 } + : { + backgroundColor: pressed ? pressedBackgroundColor : idleBackgroundColor, + borderColor, + }, + ]} + > + + + ); +} + +const styles = StyleSheet.create({ + button: { + // Match the native glass UIBarButtonItem group metrics (~50pt slots, + // 44pt bar height, label-colored ~20pt glyphs). + width: 50, + height: 44, + borderRadius: 22, + borderWidth: StyleSheet.hairlineWidth, + alignItems: "center", + justifyContent: "center", + cursor: "pointer", + }, +}); diff --git a/apps/mobile/src/features/threads/sidebar-header-actions.tsx b/apps/mobile/src/features/threads/sidebar-header-actions.tsx new file mode 100644 index 00000000000..e193f1f2d9b --- /dev/null +++ b/apps/mobile/src/features/threads/sidebar-header-actions.tsx @@ -0,0 +1,74 @@ +import { SymbolView } from "expo-symbols"; +import { Pressable, StyleSheet, View, useColorScheme } from "react-native"; + +import { useThemeColor } from "../../lib/useThemeColor"; + +export interface SidebarHeaderActionsProps { + readonly onOpenSettings: () => void; + /** Rendered inside a shared capsule group — buttons drop their own chrome. */ + readonly grouped?: boolean; +} + +function FallbackHeaderButton(props: { + readonly accessibilityLabel: string; + readonly icon: "gearshape" | "square.and.pencil"; + readonly grouped?: boolean; + readonly onPress: () => void; +}) { + const iconColor = useThemeColor("--color-foreground"); + const pressedBackgroundColor = useThemeColor("--color-subtle"); + const colorScheme = useColorScheme() === "dark" ? "dark" : "light"; + const idleBackgroundColor = + colorScheme === "dark" ? "rgba(118,118,128,0.24)" : "rgba(255,255,255,0.72)"; + const borderColor = colorScheme === "dark" ? "rgba(255,255,255,0.08)" : "rgba(0,0,0,0.08)"; + + return ( + [ + styles.button, + props.grouped + ? { backgroundColor: pressed ? pressedBackgroundColor : "transparent", borderWidth: 0 } + : { + backgroundColor: pressed ? pressedBackgroundColor : idleBackgroundColor, + borderColor, + }, + ]} + > + + + ); +} + +export function SidebarHeaderActions(props: SidebarHeaderActionsProps) { + return ( + + + + ); +} + +const styles = StyleSheet.create({ + actions: { + flexDirection: "row", + alignItems: "center", + gap: 2, + }, + button: { + // Match the native glass UIBarButtonItem group metrics. + width: 50, + height: 44, + borderRadius: 22, + borderWidth: StyleSheet.hairlineWidth, + alignItems: "center", + justifyContent: "center", + }, +}); diff --git a/apps/mobile/src/features/threads/sidebar-native-header-items.ts b/apps/mobile/src/features/threads/sidebar-native-header-items.ts new file mode 100644 index 00000000000..b80fffda057 --- /dev/null +++ b/apps/mobile/src/features/threads/sidebar-native-header-items.ts @@ -0,0 +1,63 @@ +import type { + NativeStackHeaderItem, + NativeStackHeaderItemMenu, +} from "@react-navigation/native-stack"; + +import type { HomeListFilterMenu } from "../home/home-list-filter-menu"; +import { withNativeGlassHeaderItem } from "../layout/native-glass-header-items"; + +type NativeHeaderMenuItems = NativeStackHeaderItemMenu["menu"]["items"]; +type NativeHeaderIcon = NonNullable["icon"]>; + +function sfSymbolIcon(name: string): NativeHeaderIcon { + return { type: "sfSymbol", name: name as never }; +} + +function toNativeHeaderMenuItems(items: HomeListFilterMenu["items"]): NativeHeaderMenuItems { + return items.map((item) => + item.type === "action" + ? { + type: "action" as const, + label: item.title, + description: item.subtitle, + onPress: item.onPress, + state: item.state === "on" ? ("on" as const) : undefined, + } + : { + type: "submenu" as const, + label: item.title, + items: toNativeHeaderMenuItems(item.items), + }, + ); +} + +/** + * Right-side UINavigationBar items for the sidebar column: the thread list + * filter/sort menu plus the settings button, sharing one glass capsule — + * the Messages-style grouped header buttons. + */ +export function createSidebarHeaderItems(input: { + readonly filterIcon: string; + readonly filterMenu: HomeListFilterMenu; + readonly onOpenSettings: () => void; +}): NativeStackHeaderItem[] { + return [ + withNativeGlassHeaderItem({ + type: "menu", + label: "", + accessibilityLabel: "Filter and sort threads", + icon: sfSymbolIcon(input.filterIcon), + menu: { + title: input.filterMenu.title, + items: toNativeHeaderMenuItems(input.filterMenu.items), + }, + }), + withNativeGlassHeaderItem({ + type: "button", + label: "", + accessibilityLabel: "Open settings", + icon: sfSymbolIcon("gearshape"), + onPress: input.onOpenSettings, + }), + ]; +} diff --git a/apps/mobile/src/features/threads/sidebar-navigation-shell.tsx b/apps/mobile/src/features/threads/sidebar-navigation-shell.tsx new file mode 100644 index 00000000000..f1e06926d0b --- /dev/null +++ b/apps/mobile/src/features/threads/sidebar-navigation-shell.tsx @@ -0,0 +1,72 @@ +import { + DarkTheme, + DefaultTheme, + NavigationContainer, + NavigationIndependentTree, +} from "@react-navigation/native"; +import { + createNativeStackNavigator, + type NativeStackNavigationOptions, +} from "@react-navigation/native-stack"; +import type { ReactNode } from "react"; +import { Platform, useColorScheme } from "react-native"; + +import { nativeHeaderScrollEdgeEffects } from "../../native/StackHeader"; + +const SCROLL_EDGE_EFFECTS = nativeHeaderScrollEdgeEffects(Platform.OS, Platform.Version); + +type SidebarScreenOptions = NativeStackNavigationOptions & { + // Same patched RNS option the GLASS/SOLID presets in Stack.tsx use — the + // iOS 26 "editor" navigation-item style leading-aligns the inline title. + readonly unstable_navigationItemStyle?: "editor"; +}; + +/** + * Static chrome for the sidebar column: a real UINavigationBar with a fixed + * inline title (no large title — saves vertical space, left-aligned via the + * editor item style) and the search bar pinned below it, scroll-edge blur + * sampling the list. Only genuinely dynamic values (search callbacks, header + * items) are set by the screen content via NativeStackScreenOptions. + */ +const SIDEBAR_SCREEN_OPTIONS: SidebarScreenOptions = { + contentStyle: { backgroundColor: "transparent" }, + headerLargeTitle: false, + headerShadowVisible: false, + headerShown: true, + headerStyle: { backgroundColor: "transparent" }, + headerTitleStyle: { fontSize: 18, fontWeight: "800" }, + headerTransparent: true, + scrollEdgeEffects: SCROLL_EDGE_EFFECTS, + title: "Threads", + unstable_navigationItemStyle: "editor", +}; + +const SidebarStack = createNativeStackNavigator(); + +/** + * Hosts the iPad sidebar pane inside its own single-screen native stack. + * + * The stack is navigation-inert — nothing is ever pushed onto it. It exists so + * the sidebar column owns a real UINavigationBar (large title, native bar + * button items, UISearchController), mirroring how each column of a + * UISplitViewController has its own UINavigationController. All real + * navigation still flows through the root stack via callbacks minted in + * AdaptiveWorkspaceLayout; NavigationIndependentTree only isolates the + * navigation hooks used for header configuration inside the pane. + */ +export function SidebarNavigationShell(props: { readonly children: ReactNode }) { + const colorScheme = useColorScheme(); + + return ( + + + + {() => props.children} + + + + ); +} diff --git a/apps/mobile/src/features/threads/thread-inspector-content-stack.tsx b/apps/mobile/src/features/threads/thread-inspector-content-stack.tsx new file mode 100644 index 00000000000..2c8ec73342d --- /dev/null +++ b/apps/mobile/src/features/threads/thread-inspector-content-stack.tsx @@ -0,0 +1,101 @@ +import { useEffect, useState, type ComponentType, type ReactNode } from "react"; +import { View } from "react-native"; + +export type ThreadInspectorMode = "route" | "git" | "files"; + +const INSPECTOR_PREWARM_DELAY_MS = 350; + +function InspectorContentPane(props: { + readonly children: ReactNode; + readonly mounted: boolean; + readonly visible: boolean; +}) { + if (!props.mounted) { + return null; + } + + return ( + + {props.children} + + ); +} + +export function ThreadInspectorContentStack(props: { + readonly Files: ComponentType; + readonly Git: ComponentType; + readonly mode: ThreadInspectorMode; + readonly Route?: ComponentType; +}) { + const [mountedModes, setMountedModes] = useState>( + () => new Set([props.mode]), + ); + + useEffect(() => { + setMountedModes((current) => { + if (current.has(props.mode)) { + return current; + } + return new Set([...current, props.mode]); + }); + + if (props.mode === "route") { + return; + } + + // The file tree is expensive to detach because UIKit rebuilds its focus + // graph. Keep both chat inspectors alive after the opening animation so a + // later Files/Git switch only changes visibility. + const alternateMode = props.mode === "files" ? "git" : "files"; + const timeout = setTimeout(() => { + setMountedModes((current) => { + if (current.has(alternateMode)) { + return current; + } + return new Set([...current, alternateMode]); + }); + }, INSPECTOR_PREWARM_DELAY_MS); + + return () => clearTimeout(timeout); + }, [props.mode]); + + const Files = props.Files; + const Git = props.Git; + const Route = props.Route; + + return ( + + + + + + + + {Route ? ( + + + + ) : null} + + ); +} diff --git a/apps/mobile/src/features/threads/thread-list-items.tsx b/apps/mobile/src/features/threads/thread-list-items.tsx new file mode 100644 index 00000000000..3952637da67 --- /dev/null +++ b/apps/mobile/src/features/threads/thread-list-items.tsx @@ -0,0 +1,444 @@ +import { useRecyclingState } from "@legendapp/list/react-native"; +import type { + EnvironmentProject, + EnvironmentThreadShell, +} from "@t3tools/client-runtime/state/shell"; +import type { MenuAction } from "@react-native-menu/menu"; +import { SymbolView } from "expo-symbols"; +import { memo, useCallback, useMemo, type ComponentProps } from "react"; +import { Pressable, useWindowDimensions, View } from "react-native"; +import type { SwipeableMethods } from "react-native-gesture-handler/ReanimatedSwipeable"; + +import { AppText as Text } from "../../components/AppText"; +import { ControlPillMenu } from "../../components/ControlPill"; +import { ProjectFavicon } from "../../components/ProjectFavicon"; +import { relativeTime } from "../../lib/time"; +import { useThemeColor } from "../../lib/useThemeColor"; +import { useThreadPr } from "../../state/use-thread-pr"; +import type { HomeGroupDisplayAction } from "../home/homeListItems"; +import { ThreadSwipeable } from "../home/thread-swipe-actions"; +import { resolveThreadStatus } from "./threadPresentation"; + +/** + * Shared presentation for the thread lists: the compact (phone) Home list and + * the iPad sidebar render the SAME items — group headers with collapse, + * thread rows with status/PR/subtitle, and show-more rows — differing only in + * metrics and chrome via `variant`. + */ +export type ThreadListVariant = "compact" | "sidebar"; + +/** Left inset that aligns compact secondary rows with the title column. */ +export const THREAD_LIST_COMPACT_INSET = 20; +const SIDEBAR_ROW_RADIUS = 12; + +/* ─── Project group header ───────────────────────────────────────────── */ + +export const ThreadListGroupHeader = memo(function ThreadListGroupHeader(props: { + readonly variant: ThreadListVariant; + readonly project: EnvironmentProject; + readonly title: string; + readonly threadCount: number; + readonly collapsed: boolean; + readonly isFirst: boolean; + readonly groupKey: string; + readonly onGroupAction: (key: string, action: HomeGroupDisplayAction) => void; +}) { + const iconSubtleColor = useThemeColor("--color-icon-subtle"); + const { groupKey, onGroupAction } = props; + const compact = props.variant === "compact"; + const handleToggle = useCallback( + () => onGroupAction(groupKey, "toggle-collapsed"), + [groupKey, onGroupAction], + ); + + return ( + + + + + {props.title} + + + {props.threadCount} + + + + + ); +}); + +/* ─── Show more / show less row ──────────────────────────────────────── */ + +export const ThreadListShowMoreRow = memo(function ThreadListShowMoreRow(props: { + readonly variant: ThreadListVariant; + readonly hiddenCount: number; + readonly canShowLess: boolean; + readonly groupKey: string; + readonly onGroupAction: (key: string, action: HomeGroupDisplayAction) => void; +}) { + const iconSubtleColor = useThemeColor("--color-icon-subtle"); + const showsMore = props.hiddenCount > 0; + const compact = props.variant === "compact"; + const { groupKey, onGroupAction } = props; + const handleShowMore = useCallback( + () => onGroupAction(groupKey, "show-more"), + [groupKey, onGroupAction], + ); + const handleShowLess = useCallback( + () => onGroupAction(groupKey, "show-less"), + [groupKey, onGroupAction], + ); + + const button = (label: string, icon: "chevron.down" | "chevron.up", onPress: () => void) => ( + ({ + opacity: pressed ? 0.6 : 1, + paddingHorizontal: compact ? 14 : 12, + paddingVertical: compact ? 7 : 6, + borderCurve: "continuous", + })} + > + + + + {label} + + + + ); + + return ( + + {showsMore ? button("Show more", "chevron.down", handleShowMore) : null} + {props.canShowLess ? button("Show less", "chevron.up", handleShowLess) : null} + + ); +}); + +/* ─── Thread row ─────────────────────────────────────────────────────── */ + +const THREAD_ROW_MENU_ACTIONS: MenuAction[] = [ + { id: "archive", title: "Archive", image: "archivebox" }, + { id: "delete", title: "Delete", image: "trash", attributes: { destructive: true } }, +]; + +export const ThreadListRow = memo(function ThreadListRow(props: { + readonly variant: ThreadListVariant; + readonly thread: EnvironmentThreadShell; + readonly environmentLabel: string | null; + readonly projectCwd: string | null; + readonly isLast: boolean; + /** Sidebar only: the thread currently open in the detail pane. */ + readonly selected?: boolean; + /** Defaults to window width minus compact margins. */ + readonly fullSwipeWidth?: number; + readonly onSelectThread: (thread: EnvironmentThreadShell) => void; + readonly onArchiveThread: (thread: EnvironmentThreadShell) => void; + readonly onDeleteThread: (thread: EnvironmentThreadShell) => void; + readonly onSwipeableWillOpen: (methods: SwipeableMethods) => void; + readonly onSwipeableClose: (methods: SwipeableMethods) => void; + readonly simultaneousSwipeGesture?: ComponentProps< + typeof ThreadSwipeable + >["simultaneousWithExternalGesture"]; +}) { + const { width: windowWidth } = useWindowDimensions(); + const compact = props.variant === "compact"; + const selected = props.selected === true; + // Recycling-safe: resets when the list container is reused for another + // thread, so a hover highlight can't leak across rows. + const [hovered, setHovered] = useRecyclingState(false); + + const separatorColor = useThemeColor("--color-separator"); + const iconSubtleColor = useThemeColor("--color-icon-subtle"); + const screenColor = useThemeColor("--color-screen"); + const drawerColor = useThemeColor("--color-drawer"); + const foregroundColor = useThemeColor("--color-foreground"); + const mutedColor = useThemeColor("--color-foreground-muted"); + const pressedBackgroundColor = useThemeColor("--color-subtle"); + const selectedBackgroundColor = useThemeColor("--color-user-bubble"); + const selectedForegroundColor = useThemeColor("--color-user-bubble-foreground"); + const selectedMutedColor = useThemeColor("--color-user-bubble-foreground-muted"); + + const { thread, onSelectThread, onArchiveThread, onDeleteThread } = props; + const status = resolveThreadStatus(thread); + const pr = useThreadPr(thread, props.projectCwd); + const timestamp = relativeTime( + thread.latestUserMessageAt ?? thread.updatedAt ?? thread.createdAt, + ); + const subtitleParts = [props.environmentLabel, thread.branch].filter((part): part is string => + Boolean(part), + ); + + const backgroundColor = compact ? screenColor : drawerColor; + const effectiveForeground = selected ? selectedForegroundColor : foregroundColor; + const effectiveMuted = selected ? selectedMutedColor : mutedColor; + const effectivePressedBackground = selected ? "rgba(255,255,255,0.16)" : pressedBackgroundColor; + const effectiveStatus = + selected && status + ? { ...status, pillClassName: "bg-white/20", textClassName: "text-white" } + : status; + + const handleDelete = useCallback(() => onDeleteThread(thread), [onDeleteThread, thread]); + const handleArchive = useCallback(() => onArchiveThread(thread), [onArchiveThread, thread]); + const primaryAction = useMemo( + () => ({ + accessibilityLabel: `Archive ${thread.title}`, + icon: "archivebox" as const, + label: "Archive", + onPress: handleArchive, + }), + [handleArchive, thread.title], + ); + const handleMenuAction = useCallback( + ({ nativeEvent }: { readonly nativeEvent: { readonly event: string } }) => { + if (nativeEvent.event === "archive") handleArchive(); + if (nativeEvent.event === "delete") handleDelete(); + }, + [handleArchive, handleDelete], + ); + + const statusPill = effectiveStatus ? ( + + + {effectiveStatus.label} + + + ) : null; + + const subtitleRow = + subtitleParts.length > 0 || pr !== null ? ( + + {subtitleParts.length > 0 ? ( + <> + + + {subtitleParts.join(" · ")} + + + ) : null} + {pr !== null ? ( + + {pr.label} + + ) : null} + + ) : null; + + const rowContent = (close: () => void) => + compact ? ( + { + close(); + onSelectThread(thread); + }} + style={({ pressed }) => ({ opacity: pressed ? 0.7 : 1 })} + > + + + + + {thread.title} + + + {statusPill} + + {timestamp} + + + + + {subtitleRow} + + + + ) : ( + setHovered(true)} + onHoverOut={() => setHovered(false)} + onPress={() => { + close(); + onSelectThread(thread); + }} + style={({ pressed }) => ({ + backgroundColor: selected + ? selectedBackgroundColor + : pressed || hovered + ? effectivePressedBackground + : backgroundColor, + borderRadius: SIDEBAR_ROW_RADIUS, + cursor: "pointer", + minHeight: 64, + justifyContent: "center", + paddingHorizontal: 12, + paddingVertical: 10, + })} + > + + + + {thread.title} + + + {statusPill} + + {timestamp} + + + + {subtitleRow} + + + ); + + return ( + + {(close) => ( + // Messages-style row actions: a real UIContextMenuInteraction on + // long-press / pointer right-click, with the row as the zoom preview. + // Requires the patched @react-native-menu (see + // patches/@react-native-menu__menu@2.0.0.patch): in long-press mode + // the interaction is hosted by the component view and the underlying + // UIButton passes touches through, so row taps keep working. + + {rowContent(close)} + + )} + + ); +}); diff --git a/apps/mobile/src/features/threads/thread-navigation-groups.test.ts b/apps/mobile/src/features/threads/thread-navigation-groups.test.ts new file mode 100644 index 00000000000..2cfda2f4865 --- /dev/null +++ b/apps/mobile/src/features/threads/thread-navigation-groups.test.ts @@ -0,0 +1,111 @@ +import type { + EnvironmentProject, + EnvironmentThreadShell, +} from "@t3tools/client-runtime/state/shell"; +import { EnvironmentId, ProjectId, ProviderInstanceId, ThreadId } from "@t3tools/contracts"; +import { describe, expect, it } from "vite-plus/test"; + +import { buildThreadNavigationGroups } from "./thread-navigation-groups"; + +const environmentId = EnvironmentId.make("environment-1"); + +function makeProject(input: Pick): EnvironmentProject { + return { + environmentId, + workspaceRoot: `/workspaces/${input.id}`, + repositoryIdentity: null, + defaultModelSelection: null, + scripts: [], + createdAt: "2026-06-01T00:00:00.000Z", + updatedAt: "2026-06-01T00:00:00.000Z", + ...input, + }; +} + +function makeThread( + input: Pick & + Partial, +): EnvironmentThreadShell { + return { + environmentId, + modelSelection: { instanceId: ProviderInstanceId.make("codex"), model: "gpt-5.4" }, + runtimeMode: "full-access", + interactionMode: "default", + branch: null, + worktreePath: null, + latestTurn: null, + createdAt: "2026-06-01T00:00:00.000Z", + updatedAt: "2026-06-01T00:00:00.000Z", + archivedAt: null, + session: null, + latestUserMessageAt: null, + hasPendingApprovals: false, + hasPendingUserInput: false, + hasActionableProposedPlan: false, + ...input, + }; +} + +describe("buildThreadNavigationGroups", () => { + const project = makeProject({ id: ProjectId.make("project-1"), title: "T3 Code" }); + const threads = [ + makeThread({ + id: ThreadId.make("older"), + projectId: project.id, + title: "Fix reconnect flow", + updatedAt: "2026-06-02T00:00:00.000Z", + }), + makeThread({ + id: ThreadId.make("newer"), + projectId: project.id, + title: "Build adaptive sidebar", + updatedAt: "2026-06-03T00:00:00.000Z", + }), + ]; + + it("sorts each group by recent activity", () => { + expect( + buildThreadNavigationGroups({ projects: [project], threads })[0]?.threads.map( + (thread) => thread.id, + ), + ).toEqual(["newer", "older"]); + }); + + it("matches thread titles without dropping their group", () => { + const groups = buildThreadNavigationGroups({ + projects: [project], + threads, + searchQuery: "reconnect", + }); + + expect(groups).toHaveLength(1); + expect(groups[0]?.threads.map((thread) => thread.id)).toEqual(["older"]); + }); + + it("keeps every thread when the project title matches", () => { + expect( + buildThreadNavigationGroups({ + projects: [project], + threads, + searchQuery: "t3 code", + })[0]?.threads.map((thread) => thread.id), + ).toEqual(["newer", "older"]); + }); + + it("excludes archived threads from the navigation sidebar", () => { + const archived = makeThread({ + id: ThreadId.make("archived"), + projectId: project.id, + title: "Archived work", + archivedAt: "2026-06-04T00:00:00.000Z", + updatedAt: "2026-06-04T00:00:00.000Z", + }); + + expect( + buildThreadNavigationGroups({ + projects: [project], + threads: [...threads, archived], + })[0]?.threads.map((thread) => thread.id), + ).toEqual(["newer", "older"]); + }); +}); diff --git a/apps/mobile/src/features/threads/thread-navigation-groups.ts b/apps/mobile/src/features/threads/thread-navigation-groups.ts new file mode 100644 index 00000000000..1531f6deb67 --- /dev/null +++ b/apps/mobile/src/features/threads/thread-navigation-groups.ts @@ -0,0 +1,64 @@ +import type { + EnvironmentProject, + EnvironmentThreadShell, +} from "@t3tools/client-runtime/state/shell"; +import * as Arr from "effect/Array"; +import * as Order from "effect/Order"; + +import { groupProjectsByRepository } from "../../lib/repositoryGroups"; + +export interface ThreadNavigationGroup { + readonly key: string; + readonly title: string; + readonly threads: ReadonlyArray; +} + +const threadActivityOrder = Order.mapInput( + Order.Struct({ + activityAt: Order.flip(Order.Number), + title: Order.String, + }), + (thread: EnvironmentThreadShell) => ({ + activityAt: new Date(thread.updatedAt ?? thread.createdAt).getTime(), + title: thread.title, + }), +); + +export function buildThreadNavigationGroups(input: { + readonly projects: ReadonlyArray; + readonly threads: ReadonlyArray; + readonly searchQuery?: string; +}): ReadonlyArray { + const query = input.searchQuery?.trim().toLocaleLowerCase() ?? ""; + const activeThreads = input.threads.filter((thread) => thread.archivedAt === null); + + return groupProjectsByRepository({ ...input, threads: activeThreads }).flatMap((group) => { + const threads = Arr.sort( + group.projects.flatMap((projectGroup) => projectGroup.threads), + threadActivityOrder, + ); + const title = group.projects[0]?.project.title ?? group.title; + const groupMatches = + query.length === 0 || + title.toLocaleLowerCase().includes(query) || + group.title.toLocaleLowerCase().includes(query) || + group.projects.some((projectGroup) => + projectGroup.project.title.toLocaleLowerCase().includes(query), + ); + const matchingThreads = groupMatches + ? threads + : threads.filter((thread) => thread.title.toLocaleLowerCase().includes(query)); + + if (query.length > 0 && matchingThreads.length === 0) { + return []; + } + + return [ + { + key: group.key, + title, + threads: matchingThreads, + }, + ]; + }); +} diff --git a/apps/mobile/src/features/threads/thread-work-log.tsx b/apps/mobile/src/features/threads/thread-work-log.tsx index a788f8a409e..2bf3b5c9a15 100644 --- a/apps/mobile/src/features/threads/thread-work-log.tsx +++ b/apps/mobile/src/features/threads/thread-work-log.tsx @@ -138,10 +138,7 @@ export function ThreadWorkLog(props: { /> - + {row.fullDetail} diff --git a/apps/mobile/src/features/threads/threadContentPresentation.ts b/apps/mobile/src/features/threads/threadContentPresentation.ts index c806e6dfc46..8b67815a642 100644 --- a/apps/mobile/src/features/threads/threadContentPresentation.ts +++ b/apps/mobile/src/features/threads/threadContentPresentation.ts @@ -32,7 +32,13 @@ export function projectThreadContentPresentation(input: { detail: input.detailError, }; } - if (input.connectionState === "connected") { + if ( + input.connectionState === "connected" || + input.connectionState === "connecting" || + input.connectionState === "reconnecting" + ) { + // Messages will arrive once the (re)connection completes — present as + // loading; the composer's connection pill reports the connection phase. return { kind: "loading" }; } return { diff --git a/apps/mobile/src/features/threads/threadPresentation.ts b/apps/mobile/src/features/threads/threadPresentation.ts index cf5eb1817a4..9de3d4d3089 100644 --- a/apps/mobile/src/features/threads/threadPresentation.ts +++ b/apps/mobile/src/features/threads/threadPresentation.ts @@ -1,4 +1,5 @@ import type { StatusTone } from "../../components/StatusPill"; +import type { OrchestrationLatestTurn, OrchestrationSession } from "@t3tools/contracts"; import { EnvironmentThreadShell } from "@t3tools/client-runtime/state/shell"; export function threadSortValue(thread: EnvironmentThreadShell): number { @@ -6,39 +7,123 @@ export function threadSortValue(thread: EnvironmentThreadShell): number { return Number.isNaN(candidate) ? 0 : candidate; } -export function threadStatusTone(thread: EnvironmentThreadShell): StatusTone { - const status = thread.session?.status; - if (status === "running") { +export type ThreadStatusKind = + | "pending-approval" + | "awaiting-input" + | "working" + | "connecting" + | "error" + | "plan-ready"; + +export interface ThreadStatusPresentation extends StatusTone { + readonly kind: ThreadStatusKind; + /** Foreground color for the leading status icon. */ + readonly iconColor: string; + /** Background color for the leading status icon circle. */ + readonly iconBackground: string; + /** Whether the indicator represents in-flight activity. */ + readonly pulse: boolean; +} + +/** Neutral icon colors for threads with no actionable status. */ +export const THREAD_STATUS_NEUTRAL_ICON = { + iconColor: "#8e8e93", + iconBackground: "rgba(142,142,147,0.22)", +} as const; + +function isLatestTurnSettled( + latestTurn: OrchestrationLatestTurn | null, + session: OrchestrationSession | null, +): boolean { + if (!latestTurn?.startedAt) return false; + if (!latestTurn.completedAt) return false; + if (!session) return true; + return session.status !== "running"; +} + +/** + * Resolves the user-facing status of a thread, in priority order. Returns + * `null` for quiescent threads so rows stay free of "Idle"-style noise. + * Mirrors `resolveThreadStatusPill` in apps/web/src/components/Sidebar.logic.ts. + */ +export function resolveThreadStatus( + thread: EnvironmentThreadShell, +): ThreadStatusPresentation | null { + if (thread.hasPendingApprovals) { + return { + kind: "pending-approval", + label: "Needs Approval", + pillClassName: "bg-amber-500/12 dark:bg-amber-500/16", + textClassName: "text-amber-700 dark:text-amber-300", + iconColor: "#ff9f0a", + iconBackground: "rgba(255,159,10,0.22)", + pulse: false, + }; + } + + if (thread.hasPendingUserInput) { return { - label: "Running", - pillClassName: "bg-orange-500/12 dark:bg-orange-500/16", - textClassName: "text-orange-700 dark:text-orange-300", + kind: "awaiting-input", + label: "Awaiting Input", + pillClassName: "bg-indigo-500/12 dark:bg-indigo-500/16", + textClassName: "text-indigo-700 dark:text-indigo-300", + iconColor: "#5e5ce6", + iconBackground: "rgba(94,92,230,0.22)", + pulse: false, }; } - if (status === "ready") { + + if (thread.session?.status === "running") { return { - label: "Ready", - pillClassName: "bg-emerald-500/12 dark:bg-emerald-500/16", - textClassName: "text-emerald-700 dark:text-emerald-300", + kind: "working", + label: "Working", + pillClassName: "bg-sky-500/12 dark:bg-sky-500/16", + textClassName: "text-sky-700 dark:text-sky-300", + iconColor: "#0a84ff", + iconBackground: "rgba(10,132,255,0.22)", + pulse: true, }; } - if (status === "starting") { + + if (thread.session?.status === "starting") { return { - label: "Starting", + kind: "connecting", + label: "Connecting", pillClassName: "bg-sky-500/12 dark:bg-sky-500/16", textClassName: "text-sky-700 dark:text-sky-300", + iconColor: "#0a84ff", + iconBackground: "rgba(10,132,255,0.22)", + pulse: true, }; } - if (status === "error") { + + if (thread.session?.status === "error" || thread.latestTurn?.state === "error") { return { + kind: "error", label: "Error", pillClassName: "bg-rose-500/12 dark:bg-rose-500/16", textClassName: "text-rose-700 dark:text-rose-300", + iconColor: "#ff453a", + iconBackground: "rgba(255,69,58,0.22)", + pulse: false, }; } - return { - label: "Idle", - pillClassName: "bg-neutral-500/10 dark:bg-neutral-500/16", - textClassName: "text-neutral-600 dark:text-neutral-300", - }; + + const hasPlanReadyPrompt = + thread.interactionMode === "plan" && + isLatestTurnSettled(thread.latestTurn, thread.session) && + thread.hasActionableProposedPlan; + if (hasPlanReadyPrompt) { + return { + kind: "plan-ready", + label: "Plan Ready", + pillClassName: "bg-violet-500/12 dark:bg-violet-500/16", + textClassName: "text-violet-700 dark:text-violet-300", + iconColor: "#bf5af2", + iconBackground: "rgba(191,90,242,0.22)", + pulse: false, + }; + } + + return null; } diff --git a/apps/mobile/src/lib/adaptive-navigation.test.ts b/apps/mobile/src/lib/adaptive-navigation.test.ts new file mode 100644 index 00000000000..324881eb4bb --- /dev/null +++ b/apps/mobile/src/lib/adaptive-navigation.test.ts @@ -0,0 +1,68 @@ +import { describe, expect, it } from "vite-plus/test"; + +import { + isBaseThreadRoute, + resolveFileSelectionNavigationAction, + resolveThreadSelectionNavigationAction, +} from "./adaptive-navigation"; + +describe("isBaseThreadRoute", () => { + it("recognizes only the thread detail route", () => { + expect(isBaseThreadRoute("/threads/environment/thread")).toBe(true); + expect(isBaseThreadRoute("/threads/environment/thread/")).toBe(true); + expect(isBaseThreadRoute("/threads/environment/thread/files")).toBe(false); + expect(isBaseThreadRoute("/threads/environment/thread/review")).toBe(false); + }); +}); + +describe("resolveThreadSelectionNavigationAction", () => { + it("updates params when a persistent sidebar selects a peer thread", () => { + expect( + resolveThreadSelectionNavigationAction({ + usesSplitView: true, + pathname: "/threads/environment/thread", + }), + ).toBe("set-params"); + }); + + it("replaces nested thread content when a persistent sidebar selects a peer", () => { + expect( + resolveThreadSelectionNavigationAction({ + usesSplitView: true, + pathname: "/threads/environment/thread/files/path", + }), + ).toBe("replace"); + }); + + it("pushes from Home so the back stack survives collapsing to compact", () => { + expect( + resolveThreadSelectionNavigationAction({ + usesSplitView: true, + pathname: "/", + }), + ).toBe("push"); + }); + + it("pushes compact list selections onto the native stack", () => { + expect( + resolveThreadSelectionNavigationAction({ + usesSplitView: false, + pathname: "/threads/environment/thread", + }), + ).toBe("push"); + }); +}); + +describe("resolveFileSelectionNavigationAction", () => { + it("replaces the wide file browser with the selected preview", () => { + expect(resolveFileSelectionNavigationAction({ hasPersistentFileInspector: true })).toBe( + "replace", + ); + }); + + it("pushes a preview above the compact file browser", () => { + expect(resolveFileSelectionNavigationAction({ hasPersistentFileInspector: false })).toBe( + "push", + ); + }); +}); diff --git a/apps/mobile/src/lib/adaptive-navigation.ts b/apps/mobile/src/lib/adaptive-navigation.ts new file mode 100644 index 00000000000..7eb6f658dc6 --- /dev/null +++ b/apps/mobile/src/lib/adaptive-navigation.ts @@ -0,0 +1,35 @@ +export type AdaptiveNavigationAction = "push" | "replace" | "set-params"; + +const BASE_THREAD_ROUTE_PATTERN = /^\/threads\/[^/]+\/[^/]+\/?$/; + +export function isBaseThreadRoute(pathname: string): boolean { + return BASE_THREAD_ROUTE_PATTERN.test(pathname); +} + +/** + * A persistent sidebar selects a peer destination in place. A compact list + * drills into a new destination so the native back stack remains available. + * From Home the selection pushes (never replaces) so Home stays beneath the + * thread — collapsing back to a compact width keeps a sane back stack. + */ +export function resolveThreadSelectionNavigationAction(input: { + readonly usesSplitView: boolean; + readonly pathname: string; +}): AdaptiveNavigationAction { + if (!input.usesSplitView || input.pathname === "/") { + return "push"; + } + + return isBaseThreadRoute(input.pathname) ? "set-params" : "replace"; +} + +/** + * On regular-width layouts, the file browser and preview occupy one workspace + * destination. Replacing the browser route keeps a single back step to chat. + * Compact layouts retain the browser as the previous stack screen. + */ +export function resolveFileSelectionNavigationAction(input: { + readonly hasPersistentFileInspector: boolean; +}): AdaptiveNavigationAction { + return input.hasPersistentFileInspector ? "replace" : "push"; +} diff --git a/apps/mobile/src/lib/appearancePreferences.test.ts b/apps/mobile/src/lib/appearancePreferences.test.ts new file mode 100644 index 00000000000..3458f0120f9 --- /dev/null +++ b/apps/mobile/src/lib/appearancePreferences.test.ts @@ -0,0 +1,142 @@ +import { describe, expect, it } from "vite-plus/test"; + +import { + DEFAULT_BASE_FONT_SIZE, + deriveCodeFontSize, + deriveTerminalFontSize, + normalizeBaseFontSize, + normalizeCodeFontSize, + normalizeCodeWordBreak, + resolveAppearance, + resolveAppearancePreferences, + resolveMarkdownFontSizes, + resolveMobileCodeSurface, + resolveNativeMarkdownTypography, + resolveTextScaleVariables, + stepBaseFontSize, + stepCodeFontSize, + stepTerminalFontSize, +} from "./appearancePreferences"; + +describe("appearancePreferences", () => { + it("resolves defaults for empty stored preferences", () => { + expect(resolveAppearancePreferences({})).toEqual({ + baseFontSize: DEFAULT_BASE_FONT_SIZE, + terminalFontSize: null, + codeFontSize: null, + codeWordBreak: false, + }); + }); + + it("migrates the legacy markdownFontSize key to baseFontSize", () => { + expect(resolveAppearancePreferences({ markdownFontSize: 18 }).baseFontSize).toBe(18); + expect( + resolveAppearancePreferences({ baseFontSize: 16, markdownFontSize: 18 }).baseFontSize, + ).toBe(16); + }); + + it("keeps explicit overrides and treats missing values as automatic", () => { + const preferences = resolveAppearancePreferences({ terminalFontSize: 12, codeFontSize: 14 }); + expect(preferences.terminalFontSize).toBe(12); + expect(preferences.codeFontSize).toBe(14); + expect(resolveAppearancePreferences({ terminalFontSize: null }).terminalFontSize).toBe(null); + }); + + it("derives terminal and code sizes from the base size when not overridden", () => { + const appearance = resolveAppearance(resolveAppearancePreferences({ baseFontSize: 15 })); + expect(appearance.terminalFontSize).toBe(10); + expect(appearance.codeFontSize).toBe(11); + expect(appearance.isTerminalFontSizeCustom).toBe(false); + expect(appearance.isCodeFontSizeCustom).toBe(false); + + const scaled = resolveAppearance(resolveAppearancePreferences({ baseFontSize: 22 })); + expect(scaled.terminalFontSize).toBe(deriveTerminalFontSize(22)); + expect(scaled.codeFontSize).toBe(deriveCodeFontSize(22)); + expect(scaled.terminalFontSize).toBeGreaterThan(10); + expect(scaled.codeFontSize).toBeGreaterThan(11); + }); + + it("applies explicit overrides over derived values", () => { + const appearance = resolveAppearance( + resolveAppearancePreferences({ baseFontSize: 22, terminalFontSize: 8, codeFontSize: 9 }), + ); + expect(appearance.terminalFontSize).toBe(8); + expect(appearance.codeFontSize).toBe(9); + expect(appearance.isTerminalFontSizeCustom).toBe(true); + expect(appearance.isCodeFontSizeCustom).toBe(true); + }); + + it("clamps base and code font sizes", () => { + expect(normalizeBaseFontSize(4)).toBe(11); + expect(normalizeBaseFontSize(30)).toBe(22); + expect(normalizeCodeFontSize(4)).toBe(8); + expect(normalizeCodeFontSize(30)).toBe(18); + }); + + it("steps font sizes within bounds", () => { + expect(stepTerminalFontSize(6, -1)).toBe(6); + expect(stepBaseFontSize(11, -1)).toBe(11); + expect(stepCodeFontSize(8, -1)).toBe(8); + expect(stepBaseFontSize(15, 1)).toBe(16); + }); + + it("scales markdown typography from the base size", () => { + expect(resolveMarkdownFontSizes(15)).toMatchObject({ + m: 15, + h1: 20, + bodyLineHeight: 22, + codeBlockFontSize: 12, + codeBlockLineHeight: 18, + }); + }); + + it("scales code surface geometry from the code font size", () => { + expect(resolveMobileCodeSurface(11)).toMatchObject({ + fontSize: 11, + rowHeight: 20, + }); + }); + + it("defaults code word break to false", () => { + expect(normalizeCodeWordBreak(undefined)).toBe(false); + expect(normalizeCodeWordBreak(true)).toBe(true); + }); + + it("returns the authored text scale at the 16pt default", () => { + expect(DEFAULT_BASE_FONT_SIZE).toBe(16); + + const variables = resolveTextScaleVariables(DEFAULT_BASE_FONT_SIZE); + expect(variables["--text-base"]).toBe(16); + expect(variables["--text-base--line-height"]).toBe(23); + expect(variables["--text-sm"]).toBe(14); + expect(variables["--text-sm--line-height"]).toBe(19); + expect(variables["--text-lg"]).toBe(18); + expect(variables["--text-3xl"]).toBe(30); + }); + + it("scales every text variable proportionally with the base size", () => { + const smallerVariables = resolveTextScaleVariables(15); + expect(smallerVariables["--text-base"]).toBe(15); + expect(smallerVariables["--text-sm"]).toBe(13); + + const variables = resolveTextScaleVariables(20); + expect(variables["--text-base"]).toBe(20); + expect(variables["--text-base--line-height"]).toBe(29); + expect(variables["--text-sm"]).toBe(18); + expect(variables["--text-xs"]).toBe(16); + expect(variables["--text-lg"]).toBe(23); + + const smaller = resolveTextScaleVariables(11); + expect(smaller["--text-base"]).toBe(11); + expect(smaller["--text-3xs"]).toBeGreaterThanOrEqual(8); + expect(smaller["--text-3xs--line-height"]).toBeGreaterThanOrEqual(10); + }); + + it("derives native markdown typography from the base size", () => { + expect(resolveNativeMarkdownTypography(22)).toEqual({ + fontSize: 22, + lineHeight: 32, + headingFontSizes: [29, 26, 23, 21, 21, 21], + }); + }); +}); diff --git a/apps/mobile/src/lib/appearancePreferences.ts b/apps/mobile/src/lib/appearancePreferences.ts new file mode 100644 index 00000000000..7287eabba7c --- /dev/null +++ b/apps/mobile/src/lib/appearancePreferences.ts @@ -0,0 +1,245 @@ +import { MOBILE_CODE_SURFACE, MOBILE_TYPOGRAPHY } from "./typography"; +import { + DEFAULT_TERMINAL_FONT_SIZE, + MAX_TERMINAL_FONT_SIZE, + MIN_TERMINAL_FONT_SIZE, + TERMINAL_FONT_SIZE_STEP, + normalizeTerminalFontSize, +} from "../features/terminal/terminalPreferences"; + +export const DEFAULT_BASE_FONT_SIZE = MOBILE_TYPOGRAPHY.body.fontSize; +export const MIN_BASE_FONT_SIZE = 11; +export const MAX_BASE_FONT_SIZE = 22; +export const BASE_FONT_SIZE_STEP = 1; + +export const DEFAULT_CODE_FONT_SIZE = MOBILE_CODE_SURFACE.fontSize; +export const MIN_CODE_FONT_SIZE = 8; +export const MAX_CODE_FONT_SIZE = 18; +export const CODE_FONT_SIZE_STEP = 1; + +/** + * User-configurable appearance preferences as stored. `null` overrides mean + * "automatic": the value is derived from the base font size. + */ +export interface AppearancePreferences { + readonly baseFontSize: number; + readonly terminalFontSize: number | null; + readonly codeFontSize: number | null; + readonly codeWordBreak: boolean; +} + +/** Effective appearance values after applying base-size derivation. */ +export interface ResolvedAppearance { + readonly baseFontSize: number; + readonly terminalFontSize: number; + readonly codeFontSize: number; + readonly codeWordBreak: boolean; + readonly isTerminalFontSizeCustom: boolean; + readonly isCodeFontSizeCustom: boolean; +} + +export interface ResolvedMobileCodeSurface { + readonly fontSize: number; + readonly lineNumberFontSize: number; + readonly rowHeight: number; + readonly gutterWidth: number; + readonly codePadding: number; + readonly textVerticalInset: number; +} + +export interface ResolvedMarkdownFontSizes { + readonly s: number; + readonly m: number; + readonly h1: number; + readonly h2: number; + readonly h3: number; + readonly h4: number; + readonly h5: number; + readonly h6: number; + readonly bodyLineHeight: number; + readonly codeBlockFontSize: number; + readonly codeBlockLineHeight: number; +} + +export interface NativeMarkdownTypography { + readonly fontSize: number; + readonly lineHeight: number; + readonly headingFontSizes: readonly [number, number, number, number, number, number]; +} + +export function normalizeBaseFontSize(value: number | null | undefined): number { + if (typeof value !== "number" || !Number.isFinite(value)) { + return DEFAULT_BASE_FONT_SIZE; + } + + return Math.min(MAX_BASE_FONT_SIZE, Math.max(MIN_BASE_FONT_SIZE, Math.round(value))); +} + +export function normalizeCodeFontSize(value: number | null | undefined): number { + if (typeof value !== "number" || !Number.isFinite(value)) { + return DEFAULT_CODE_FONT_SIZE; + } + + return Math.min(MAX_CODE_FONT_SIZE, Math.max(MIN_CODE_FONT_SIZE, Math.round(value))); +} + +export function normalizeCodeWordBreak(value: boolean | null | undefined): boolean { + return value === true; +} + +/** Terminal size derived from base: 10.5pt at base 16, snapped to 0.5pt steps. */ +export function deriveTerminalFontSize(baseFontSize: number): number { + const scale = normalizeBaseFontSize(baseFontSize) / DEFAULT_BASE_FONT_SIZE; + return normalizeTerminalFontSize(Math.round(DEFAULT_TERMINAL_FONT_SIZE * scale * 2) / 2); +} + +/** Code/diff size derived from base: 12pt at base 16. */ +export function deriveCodeFontSize(baseFontSize: number): number { + const scale = normalizeBaseFontSize(baseFontSize) / DEFAULT_BASE_FONT_SIZE; + return normalizeCodeFontSize(Math.round(DEFAULT_CODE_FONT_SIZE * scale)); +} + +interface StoredAppearancePreferences { + readonly baseFontSize?: number | null | undefined; + /** Legacy key from before base font size existed; migrated to baseFontSize. */ + readonly markdownFontSize?: number | null | undefined; + readonly terminalFontSize?: number | null | undefined; + readonly codeFontSize?: number | null | undefined; + readonly codeWordBreak?: boolean | null | undefined; +} + +export function resolveAppearancePreferences( + stored: StoredAppearancePreferences | null | undefined, +): AppearancePreferences { + return { + baseFontSize: normalizeBaseFontSize(stored?.baseFontSize ?? stored?.markdownFontSize), + terminalFontSize: + typeof stored?.terminalFontSize === "number" && Number.isFinite(stored.terminalFontSize) + ? normalizeTerminalFontSize(stored.terminalFontSize) + : null, + codeFontSize: + typeof stored?.codeFontSize === "number" && Number.isFinite(stored.codeFontSize) + ? normalizeCodeFontSize(stored.codeFontSize) + : null, + codeWordBreak: normalizeCodeWordBreak(stored?.codeWordBreak), + }; +} + +export function resolveAppearance(preferences: AppearancePreferences): ResolvedAppearance { + return { + baseFontSize: preferences.baseFontSize, + terminalFontSize: + preferences.terminalFontSize ?? deriveTerminalFontSize(preferences.baseFontSize), + codeFontSize: preferences.codeFontSize ?? deriveCodeFontSize(preferences.baseFontSize), + codeWordBreak: preferences.codeWordBreak, + isTerminalFontSizeCustom: preferences.terminalFontSize !== null, + isCodeFontSizeCustom: preferences.codeFontSize !== null, + }; +} + +export function resolveMobileCodeSurface(codeFontSize: number): ResolvedMobileCodeSurface { + const fontSize = normalizeCodeFontSize(codeFontSize); + const scale = fontSize / DEFAULT_CODE_FONT_SIZE; + + return { + fontSize, + lineNumberFontSize: Math.max(8, Math.round(MOBILE_CODE_SURFACE.lineNumberFontSize * scale)), + rowHeight: Math.max(14, Math.round(MOBILE_CODE_SURFACE.rowHeight * scale)), + gutterWidth: MOBILE_CODE_SURFACE.gutterWidth, + codePadding: MOBILE_CODE_SURFACE.codePadding, + textVerticalInset: MOBILE_CODE_SURFACE.textVerticalInset, + }; +} + +export function resolveMarkdownFontSizes(baseFontSize: number): ResolvedMarkdownFontSizes { + const m = normalizeBaseFontSize(baseFontSize); + const scale = m / DEFAULT_BASE_FONT_SIZE; + const codeBlockFontSize = Math.max(10, Math.round(13 * scale)); + + return { + s: Math.max(10, Math.round(14 * scale)), + m, + h1: Math.max(16, Math.round(21 * scale)), + h2: Math.max(14, Math.round(19 * scale)), + h3: Math.max(13, Math.round(17 * scale)), + h4: Math.max(12, Math.round(15 * scale)), + h5: Math.max(12, Math.round(15 * scale)), + h6: Math.max(12, Math.round(15 * scale)), + bodyLineHeight: Math.max(18, Math.round(MOBILE_TYPOGRAPHY.body.lineHeight * scale)), + codeBlockFontSize, + codeBlockLineHeight: codeBlockFontSize + 6, + }; +} + +/** + * Maps the Uniwind `--text-*` theme variables (see global.css) to the + * MOBILE_TYPOGRAPHY roles they were authored from. Keep in sync with both. + */ +const TEXT_SCALE_VARIABLE_ROLES = { + "--text-3xs": MOBILE_TYPOGRAPHY.micro, + "--text-2xs": MOBILE_TYPOGRAPHY.caption, + "--text-xs": MOBILE_TYPOGRAPHY.label, + "--text-sm": MOBILE_TYPOGRAPHY.footnote, + "--text-base": MOBILE_TYPOGRAPHY.body, + "--text-lg": MOBILE_TYPOGRAPHY.headline, + "--text-xl": MOBILE_TYPOGRAPHY.title, + "--text-2xl": MOBILE_TYPOGRAPHY.largeTitle, + "--text-3xl": MOBILE_TYPOGRAPHY.display, +} as const; + +/** + * Scaled values for every `--text-*` size and line-height variable, ready to + * pass to `Uniwind.updateCSSVariables`. All className-based text (`text-sm`, + * `text-base`, ...) re-resolves live when these are injected. + */ +export function resolveTextScaleVariables(baseFontSize: number): Record { + const scale = normalizeBaseFontSize(baseFontSize) / DEFAULT_BASE_FONT_SIZE; + const variables: Record = {}; + + for (const [name, role] of Object.entries(TEXT_SCALE_VARIABLE_ROLES)) { + variables[name] = Math.max(8, Math.round(role.fontSize * scale)); + variables[`${name}--line-height`] = Math.max(10, Math.round(role.lineHeight * scale)); + } + + return variables; +} + +export function resolveNativeMarkdownTypography(baseFontSize: number): NativeMarkdownTypography { + const fontSizes = resolveMarkdownFontSizes(baseFontSize); + return { + fontSize: fontSizes.m, + lineHeight: fontSizes.bodyLineHeight, + headingFontSizes: [ + fontSizes.h1, + fontSizes.h2, + fontSizes.h3, + fontSizes.h4, + fontSizes.h5, + fontSizes.h6, + ], + }; +} + +export function stepBaseFontSize(current: number, direction: -1 | 1): number { + const next = direction === -1 ? current - BASE_FONT_SIZE_STEP : current + BASE_FONT_SIZE_STEP; + return normalizeBaseFontSize(next); +} + +export function stepTerminalFontSize(current: number, direction: -1 | 1): number { + const next = + direction === -1 ? current - TERMINAL_FONT_SIZE_STEP : current + TERMINAL_FONT_SIZE_STEP; + return normalizeTerminalFontSize(next); +} + +export function stepCodeFontSize(current: number, direction: -1 | 1): number { + const next = direction === -1 ? current - CODE_FONT_SIZE_STEP : current + CODE_FONT_SIZE_STEP; + return normalizeCodeFontSize(next); +} + +export { + DEFAULT_TERMINAL_FONT_SIZE, + MAX_TERMINAL_FONT_SIZE, + MIN_TERMINAL_FONT_SIZE, + TERMINAL_FONT_SIZE_STEP, + normalizeTerminalFontSize, +}; diff --git a/apps/mobile/src/lib/layout.test.ts b/apps/mobile/src/lib/layout.test.ts new file mode 100644 index 00000000000..6dea0beafbe --- /dev/null +++ b/apps/mobile/src/lib/layout.test.ts @@ -0,0 +1,343 @@ +import { describe, expect, it } from "vite-plus/test"; + +import { + constrainAuxiliaryPaneWidth, + constrainPrimarySidebarWidth, + deriveCenteredContentHorizontalPadding, + deriveFileInspectorPaneLayout, + deriveLayout, + deriveStableFormSheetDetent, + deriveWorkspacePaneLayout, + SPLIT_LAYOUT_MIN_HEIGHT, + SPLIT_LAYOUT_MIN_WIDTH, +} from "./layout"; + +describe("resizable pane constraints", () => { + it("keeps a preferred sidebar width across large windows and clamps it in a narrow split view", () => { + expect(constrainPrimarySidebarWidth(430, 1_366)).toBe(430); + expect(constrainPrimarySidebarWidth(430, 744)).toBe(384); + expect(constrainPrimarySidebarWidth(100, 1_366)).toBe(280); + }); + + it("preserves a useful main pane while constraining a trailing pane", () => { + expect(constrainAuxiliaryPaneWidth({ preferredWidth: 440, availableWidth: 1_100 })).toBe(440); + expect(constrainAuxiliaryPaneWidth({ preferredWidth: 440, availableWidth: 900 })).toBe(340); + expect(constrainAuxiliaryPaneWidth({ preferredWidth: 100, availableWidth: 1_100 })).toBe(260); + }); +}); + +describe("deriveCenteredContentHorizontalPadding", () => { + it("keeps the minimum padding while the viewport fits the reading width", () => { + expect( + deriveCenteredContentHorizontalPadding({ + viewportWidth: 744, + maxContentWidth: 960, + minimumPadding: 20, + }), + ).toBe(20); + }); + + it("centers only the content inside a wider full-width scroll host", () => { + expect( + deriveCenteredContentHorizontalPadding({ + viewportWidth: 1_032, + maxContentWidth: 960, + minimumPadding: 20, + }), + ).toBe(56); + }); + + it("supports unconstrained compact content", () => { + expect( + deriveCenteredContentHorizontalPadding({ + viewportWidth: 430, + maxContentWidth: null, + minimumPadding: 16, + }), + ).toBe(16); + }); +}); + +describe("deriveLayout", () => { + it.each([ + { name: "small iPhone portrait", width: 375, height: 667 }, + { name: "large iPhone portrait", width: 430, height: 932 }, + { name: "small iPhone landscape", width: 667, height: 375 }, + { name: "large iPhone landscape", width: 932, height: 430 }, + { name: "short wide window", width: 1_024, height: 599 }, + { name: "narrow tall window", width: 719, height: 1_024 }, + ])("keeps a $name in the compact shell", ({ width, height }) => { + expect(deriveLayout({ width, height })).toEqual({ + variant: "compact", + usesSplitView: false, + listPaneWidth: null, + shellPadding: 0, + }); + }); + + it.each([ + { name: "small tablet portrait", width: 744, height: 1_133 }, + { name: "tablet landscape", width: 1_024, height: 768 }, + { name: "large resizable window", width: 1_366, height: 1_024 }, + { name: "foldable-sized window", width: 800, height: 700 }, + ])("uses the split shell for a $name", ({ width, height }) => { + expect(deriveLayout({ width, height })).toMatchObject({ + variant: "split", + usesSplitView: true, + }); + }); + + it("switches only after both space requirements are met", () => { + expect( + deriveLayout({ width: SPLIT_LAYOUT_MIN_WIDTH, height: SPLIT_LAYOUT_MIN_HEIGHT }).variant, + ).toBe("split"); + expect( + deriveLayout({ width: SPLIT_LAYOUT_MIN_WIDTH - 1, height: SPLIT_LAYOUT_MIN_HEIGHT }).variant, + ).toBe("compact"); + expect( + deriveLayout({ width: SPLIT_LAYOUT_MIN_WIDTH, height: SPLIT_LAYOUT_MIN_HEIGHT - 1 }).variant, + ).toBe("compact"); + }); + + it("keeps the sidebar within usable native-column bounds", () => { + expect(deriveLayout({ width: 720, height: 1_000 }).listPaneWidth).toBe(280); + expect(deriveLayout({ width: 1_024, height: 768 }).listPaneWidth).toBe(328); + expect(deriveLayout({ width: 1_600, height: 1_000 }).listPaneWidth).toBe(380); + }); +}); + +describe("deriveWorkspacePaneLayout", () => { + it("keeps the auxiliary pane out of a standard iPad detail column", () => { + const layout = deriveLayout({ width: 1_194, height: 834 }); + + expect( + deriveWorkspacePaneLayout({ + layout, + viewportWidth: 1_194, + primarySidebarPreferredVisible: true, + auxiliaryPanePreferredVisible: true, + }), + ).toEqual({ + primarySidebarVisible: true, + primarySidebarSuppressedByAuxiliary: false, + contentPaneWidth: 814, + supportsAuxiliaryPane: false, + auxiliaryPaneVisible: false, + auxiliaryPaneWidth: null, + }); + }); + + it("offers an auxiliary pane when maximizing a standard iPad landscape window", () => { + const layout = deriveLayout({ width: 1_194, height: 834 }); + + expect( + deriveWorkspacePaneLayout({ + layout, + viewportWidth: 1_194, + primarySidebarPreferredVisible: false, + auxiliaryPanePreferredVisible: true, + }), + ).toEqual({ + primarySidebarVisible: false, + primarySidebarSuppressedByAuxiliary: false, + contentPaneWidth: 1_194, + supportsAuxiliaryPane: true, + auxiliaryPaneVisible: true, + auxiliaryPaneWidth: 320, + }); + }); + + it("prioritizes a trailing file inspector over the thread sidebar at medium widths", () => { + const layout = deriveLayout({ width: 1_024, height: 1_366 }); + + expect( + deriveWorkspacePaneLayout({ + layout, + viewportWidth: 1_024, + primarySidebarPreferredVisible: true, + auxiliaryPanePreferredVisible: true, + auxiliaryPaneRole: "inspector", + }), + ).toEqual({ + primarySidebarVisible: false, + primarySidebarSuppressedByAuxiliary: true, + contentPaneWidth: 1_024, + supportsAuxiliaryPane: true, + auxiliaryPaneVisible: true, + auxiliaryPaneWidth: 260, + }); + }); + + it("keeps threads, content, and the file inspector visible in a large landscape window", () => { + const layout = deriveLayout({ width: 1_366, height: 1_024 }); + + expect( + deriveWorkspacePaneLayout({ + layout, + viewportWidth: 1_366, + primarySidebarPreferredVisible: true, + auxiliaryPanePreferredVisible: true, + auxiliaryPaneRole: "inspector", + }), + ).toEqual({ + primarySidebarVisible: true, + primarySidebarSuppressedByAuxiliary: false, + contentPaneWidth: 986, + supportsAuxiliaryPane: true, + auxiliaryPaneVisible: true, + auxiliaryPaneWidth: 276, + }); + }); + + it("keeps an explicitly hidden thread sidebar hidden when the file inspector is visible", () => { + const layout = deriveLayout({ width: 1_024, height: 1_366 }); + + expect( + deriveWorkspacePaneLayout({ + layout, + viewportWidth: 1_024, + primarySidebarPreferredVisible: false, + auxiliaryPanePreferredVisible: true, + auxiliaryPaneRole: "inspector", + }), + ).toMatchObject({ + primarySidebarVisible: false, + primarySidebarSuppressedByAuxiliary: false, + auxiliaryPaneVisible: true, + }); + }); + + it("restores the thread sidebar when the file inspector is hidden", () => { + const layout = deriveLayout({ width: 1_024, height: 1_366 }); + + expect( + deriveWorkspacePaneLayout({ + layout, + viewportWidth: 1_024, + primarySidebarPreferredVisible: true, + auxiliaryPanePreferredVisible: false, + auxiliaryPaneRole: "inspector", + }), + ).toMatchObject({ + primarySidebarVisible: true, + primarySidebarSuppressedByAuxiliary: false, + auxiliaryPaneVisible: false, + }); + }); + + it("keeps file navigation on the native stack below the inspector breakpoint", () => { + const layout = deriveLayout({ width: 744, height: 1_133 }); + + expect(deriveFileInspectorPaneLayout({ layout, viewportWidth: 744 })).toEqual({ + supported: false, + width: null, + }); + expect( + deriveWorkspacePaneLayout({ + layout, + viewportWidth: 744, + primarySidebarPreferredVisible: true, + auxiliaryPanePreferredVisible: true, + auxiliaryPaneRole: "inspector", + }), + ).toMatchObject({ + primarySidebarVisible: true, + supportsAuxiliaryPane: false, + auxiliaryPaneVisible: false, + }); + }); + + it("supports three visible columns in a sufficiently large window", () => { + const layout = deriveLayout({ width: 1_366, height: 1_024 }); + + expect( + deriveWorkspacePaneLayout({ + layout, + viewportWidth: 1_366, + primarySidebarPreferredVisible: true, + auxiliaryPanePreferredVisible: true, + }), + ).toMatchObject({ + primarySidebarVisible: true, + contentPaneWidth: 986, + supportsAuxiliaryPane: true, + auxiliaryPaneVisible: true, + auxiliaryPaneWidth: 276, + }); + }); + + it("uses a preferred inspector width when all three panes still fit", () => { + const layout = deriveLayout({ width: 1_366, height: 1_024 }); + + expect( + deriveWorkspacePaneLayout({ + layout, + viewportWidth: 1_366, + primarySidebarPreferredVisible: true, + auxiliaryPanePreferredVisible: true, + auxiliaryPaneRole: "inspector", + auxiliaryPanePreferredWidth: 420, + }), + ).toMatchObject({ + primarySidebarVisible: true, + auxiliaryPaneVisible: true, + auxiliaryPaneWidth: 420, + }); + }); + + it("clamps a preferred supplementary width before squeezing the main pane", () => { + const layout = deriveLayout({ width: 1_366, height: 1_024 }); + + expect( + deriveWorkspacePaneLayout({ + layout, + viewportWidth: 1_366, + primarySidebarPreferredVisible: true, + auxiliaryPanePreferredVisible: true, + auxiliaryPanePreferredWidth: 460, + }).auxiliaryPaneWidth, + ).toBe(426); + }); + + it("respects a hidden auxiliary-pane preference", () => { + const layout = deriveLayout({ width: 1_366, height: 1_024 }); + + expect( + deriveWorkspacePaneLayout({ + layout, + viewportWidth: 1_366, + primarySidebarPreferredVisible: true, + auxiliaryPanePreferredVisible: false, + }).auxiliaryPaneVisible, + ).toBe(false); + }); + + it("never exposes workspace panes in compact layouts", () => { + const layout = deriveLayout({ width: 430, height: 932 }); + + expect( + deriveWorkspacePaneLayout({ + layout, + viewportWidth: 430, + primarySidebarPreferredVisible: true, + auxiliaryPanePreferredVisible: true, + }), + ).toMatchObject({ + primarySidebarVisible: false, + supportsAuxiliaryPane: false, + auxiliaryPaneVisible: false, + auxiliaryPaneWidth: null, + }); + }); +}); + +describe("deriveStableFormSheetDetent", () => { + it.each([ + { height: 1_194, expected: 0.62 }, + { height: 834, expected: 0.863 }, + { height: 600, expected: 0.893 }, + { height: 0, expected: 0.92 }, + ])("derives a stable sheet detent for height $height", ({ height, expected }) => { + expect(deriveStableFormSheetDetent(height)).toBe(expected); + }); +}); diff --git a/apps/mobile/src/lib/layout.ts b/apps/mobile/src/lib/layout.ts index 2ae4314fdba..eb0c45e0607 100644 --- a/apps/mobile/src/lib/layout.ts +++ b/apps/mobile/src/lib/layout.ts @@ -2,6 +2,33 @@ function clamp(value: number, min: number, max: number): number { return Math.min(Math.max(value, min), max); } +/** + * Use available space, not device or orientation labels, to choose the shell. + * + * The height floor deliberately keeps every current iPhone in the compact shell + * when it rotates to landscape, while still allowing iPad and foldable-sized + * windows to adopt the persistent sidebar as they resize. + */ +export const SPLIT_LAYOUT_MIN_WIDTH = 720; +export const SPLIT_LAYOUT_MIN_HEIGHT = 600; + +export const SPLIT_SIDEBAR_MIN_WIDTH = 280; +export const SPLIT_SIDEBAR_MAX_WIDTH = 460; +const SPLIT_SIDEBAR_DEFAULT_MAX_WIDTH = 380; + +export const AUXILIARY_PANE_MIN_CONTENT_WIDTH = 960; +export const CHAT_CONTENT_MAX_WIDTH = 960; + +export const AUXILIARY_PANE_MIN_WIDTH = 260; +export const AUXILIARY_PANE_MAX_WIDTH = 480; +const AUXILIARY_PANE_DEFAULT_MAX_WIDTH = 320; +const FILE_INSPECTOR_MIN_VIEWPORT_WIDTH = 820; +const FILE_INSPECTOR_MIN_MAIN_WIDTH = 560; +const STABLE_FORM_SHEET_MAX_HEIGHT = 720; +const STABLE_FORM_SHEET_VERTICAL_MARGIN = 64; +const STABLE_FORM_SHEET_MIN_DETENT = 0.62; +const STABLE_FORM_SHEET_MAX_DETENT = 0.92; + export type LayoutVariant = "compact" | "split"; export interface Layout { @@ -11,10 +38,25 @@ export interface Layout { readonly shellPadding: number; } +export interface WorkspacePaneLayout { + readonly primarySidebarVisible: boolean; + readonly primarySidebarSuppressedByAuxiliary: boolean; + readonly contentPaneWidth: number; + readonly supportsAuxiliaryPane: boolean; + readonly auxiliaryPaneVisible: boolean; + readonly auxiliaryPaneWidth: number | null; +} + +export interface FileInspectorPaneLayout { + readonly supported: boolean; + readonly width: number | null; +} + +export type WorkspaceAuxiliaryPaneRole = "supplementary" | "inspector"; + export function deriveLayout(input: { readonly width: number; readonly height: number }): Layout { const { width, height } = input; - const shortestEdge = Math.min(width, height); - const wideEnoughForSplit = width >= 900 || (width >= 700 && shortestEdge >= 700); + const wideEnoughForSplit = width >= SPLIT_LAYOUT_MIN_WIDTH && height >= SPLIT_LAYOUT_MIN_HEIGHT; if (!wideEnoughForSplit) { return { @@ -28,7 +70,187 @@ export function deriveLayout(input: { readonly width: number; readonly height: n return { variant: "split", usesSplitView: true, - listPaneWidth: clamp(Math.round(width * 0.34), 320, 420), - shellPadding: width >= 1180 ? 20 : 14, + listPaneWidth: clamp( + Math.round(width * 0.32), + SPLIT_SIDEBAR_MIN_WIDTH, + SPLIT_SIDEBAR_DEFAULT_MAX_WIDTH, + ), + shellPadding: 0, + }; +} + +export function deriveWorkspacePaneLayout(input: { + readonly layout: Layout; + readonly viewportWidth: number; + readonly primarySidebarPreferredVisible: boolean; + readonly auxiliaryPanePreferredVisible: boolean; + readonly auxiliaryPaneRole?: WorkspaceAuxiliaryPaneRole; + readonly auxiliaryPanePreferredWidth?: number; +}): WorkspacePaneLayout { + const viewportWidth = Math.max(0, input.viewportWidth); + const auxiliaryPaneRole = input.auxiliaryPaneRole ?? "supplementary"; + const preferredPrimarySidebarVisible = + input.layout.usesSplitView && input.primarySidebarPreferredVisible; + const preferredPrimarySidebarWidth = preferredPrimarySidebarVisible + ? (input.layout.listPaneWidth ?? 0) + : 0; + + if (auxiliaryPaneRole === "inspector") { + const fileInspector = deriveFileInspectorPaneLayout({ + layout: input.layout, + viewportWidth, + preferredWidth: input.auxiliaryPanePreferredWidth, + reservedLeadingWidth: preferredPrimarySidebarWidth, + }); + const auxiliaryPaneVisible = fileInspector.supported && input.auxiliaryPanePreferredVisible; + const primarySidebarSuppressedByAuxiliary = + preferredPrimarySidebarVisible && + auxiliaryPaneVisible && + fileInspector.width !== null && + input.layout.listPaneWidth !== null && + viewportWidth - input.layout.listPaneWidth - fileInspector.width < + FILE_INSPECTOR_MIN_MAIN_WIDTH; + const primarySidebarVisible = + preferredPrimarySidebarVisible && !primarySidebarSuppressedByAuxiliary; + const primarySidebarWidth = primarySidebarVisible ? (input.layout.listPaneWidth ?? 0) : 0; + + return { + primarySidebarVisible, + primarySidebarSuppressedByAuxiliary, + contentPaneWidth: Math.max(0, viewportWidth - primarySidebarWidth), + supportsAuxiliaryPane: fileInspector.supported, + auxiliaryPaneVisible, + auxiliaryPaneWidth: fileInspector.width, + }; + } + + const contentPaneWidth = Math.max(0, viewportWidth - preferredPrimarySidebarWidth); + const supportsAuxiliaryPane = + input.layout.usesSplitView && contentPaneWidth >= AUXILIARY_PANE_MIN_CONTENT_WIDTH; + const auxiliaryPaneVisible = supportsAuxiliaryPane && input.auxiliaryPanePreferredVisible; + const defaultAuxiliaryPaneWidth = clamp( + Math.round(contentPaneWidth * 0.28), + AUXILIARY_PANE_MIN_WIDTH, + AUXILIARY_PANE_DEFAULT_MAX_WIDTH, + ); + + return { + primarySidebarVisible: preferredPrimarySidebarVisible, + primarySidebarSuppressedByAuxiliary: false, + contentPaneWidth, + supportsAuxiliaryPane, + auxiliaryPaneVisible, + auxiliaryPaneWidth: supportsAuxiliaryPane + ? constrainAuxiliaryPaneWidth({ + preferredWidth: input.auxiliaryPanePreferredWidth ?? defaultAuxiliaryPaneWidth, + availableWidth: contentPaneWidth, + }) + : null, + }; +} + +export function deriveFileInspectorPaneLayout(input: { + readonly layout: Layout; + readonly viewportWidth: number; + readonly preferredWidth?: number; + readonly reservedLeadingWidth?: number; +}): FileInspectorPaneLayout { + const viewportWidth = Math.max(0, input.viewportWidth); + const reservedLeadingWidth = Number.isFinite(input.reservedLeadingWidth) + ? Math.max(0, input.reservedLeadingWidth ?? 0) + : 0; + const availableContentWidth = Math.max(0, viewportWidth - reservedLeadingWidth); + const supported = + input.layout.usesSplitView && viewportWidth >= FILE_INSPECTOR_MIN_VIEWPORT_WIDTH; + + return { + supported, + width: supported + ? constrainAuxiliaryPaneWidth({ + preferredWidth: + input.preferredWidth ?? + clamp( + Math.round(availableContentWidth * 0.28), + AUXILIARY_PANE_MIN_WIDTH, + AUXILIARY_PANE_DEFAULT_MAX_WIDTH, + ), + availableWidth: availableContentWidth, + }) + : null, }; } + +/** Keep a user-selected sidebar width useful as a window is resized. */ +export function constrainPrimarySidebarWidth( + preferredWidth: number, + viewportWidth = Number.POSITIVE_INFINITY, +): number { + const safeWidth = Number.isFinite(preferredWidth) ? preferredWidth : SPLIT_SIDEBAR_MIN_WIDTH; + const viewportMax = Number.isFinite(viewportWidth) + ? Math.max(SPLIT_SIDEBAR_MIN_WIDTH, viewportWidth - 360) + : SPLIT_SIDEBAR_MAX_WIDTH; + return clamp( + Math.round(safeWidth), + SPLIT_SIDEBAR_MIN_WIDTH, + Math.min(SPLIT_SIDEBAR_MAX_WIDTH, viewportMax), + ); +} + +/** + * Keep an auxiliary pane within native-feeling bounds without squeezing its + * neighboring content below a usable reading/editor width. + */ +export function constrainAuxiliaryPaneWidth(input: { + readonly preferredWidth: number; + readonly availableWidth: number; +}): number { + const safePreferredWidth = Number.isFinite(input.preferredWidth) + ? input.preferredWidth + : AUXILIARY_PANE_MIN_WIDTH; + const availableWidth = Number.isFinite(input.availableWidth) + ? Math.max(0, input.availableWidth) + : 0; + const maxWidth = Math.max( + AUXILIARY_PANE_MIN_WIDTH, + Math.min(AUXILIARY_PANE_MAX_WIDTH, availableWidth - FILE_INSPECTOR_MIN_MAIN_WIDTH), + ); + return clamp(Math.round(safePreferredWidth), AUXILIARY_PANE_MIN_WIDTH, maxWidth); +} + +export function deriveCenteredContentHorizontalPadding(input: { + readonly viewportWidth: number; + readonly maxContentWidth: number | null; + readonly minimumPadding: number; +}): number { + const viewportWidth = Number.isFinite(input.viewportWidth) ? Math.max(0, input.viewportWidth) : 0; + const minimumPadding = Number.isFinite(input.minimumPadding) + ? Math.max(0, input.minimumPadding) + : 0; + + if ( + input.maxContentWidth === null || + !Number.isFinite(input.maxContentWidth) || + input.maxContentWidth <= 0 + ) { + return minimumPadding; + } + + return minimumPadding + Math.max(0, (viewportWidth - input.maxContentWidth) / 2); +} + +export function deriveStableFormSheetDetent(containerHeight: number): number { + if (!Number.isFinite(containerHeight) || containerHeight <= 0) { + return STABLE_FORM_SHEET_MAX_DETENT; + } + + const targetHeight = Math.min( + STABLE_FORM_SHEET_MAX_HEIGHT, + Math.max(0, containerHeight - STABLE_FORM_SHEET_VERTICAL_MARGIN), + ); + const detent = clamp( + targetHeight / containerHeight, + STABLE_FORM_SHEET_MIN_DETENT, + STABLE_FORM_SHEET_MAX_DETENT, + ); + return Math.round(detent * 1_000) / 1_000; +} diff --git a/apps/mobile/src/lib/routes.test.ts b/apps/mobile/src/lib/routes.test.ts deleted file mode 100644 index 773de9d84f7..00000000000 --- a/apps/mobile/src/lib/routes.test.ts +++ /dev/null @@ -1,45 +0,0 @@ -import { describe, expect, it } from "vite-plus/test"; -import { EnvironmentId, ThreadId } from "@t3tools/contracts"; - -import { buildThreadFilesNavigation, buildThreadFilesRoutePath } from "./routes"; - -const thread = { - environmentId: EnvironmentId.make("environment-1"), - threadId: ThreadId.make("thread-1"), -}; - -describe("thread file routes", () => { - it("includes an optional source line in string routes", () => { - expect(buildThreadFilesRoutePath(thread, "src/main.ts", 42)).toBe( - "/threads/environment-1/thread-1/files/src/main.ts?line=42", - ); - }); - - it("encodes each file path segment without encoding separators", () => { - expect(buildThreadFilesRoutePath(thread, "docs/My File#1.md")).toBe( - "/threads/environment-1/thread-1/files/docs/My%20File%231.md", - ); - }); - - it("builds typed navigation params for a file and source line", () => { - expect(buildThreadFilesNavigation(thread, "src/main.ts", 42)).toEqual({ - pathname: "/threads/[environmentId]/[threadId]/files/[...path]", - params: { - environmentId: "environment-1", - threadId: "thread-1", - path: ["src", "main.ts"], - line: "42", - }, - }); - }); - - it("targets the files index when no file path is provided", () => { - expect(buildThreadFilesNavigation(thread)).toEqual({ - pathname: "/threads/[environmentId]/[threadId]/files", - params: { - environmentId: "environment-1", - threadId: "thread-1", - }, - }); - }); -}); diff --git a/apps/mobile/src/lib/routes.ts b/apps/mobile/src/lib/routes.ts deleted file mode 100644 index 3a33e2ee0f9..00000000000 --- a/apps/mobile/src/lib/routes.ts +++ /dev/null @@ -1,134 +0,0 @@ -import type { Href, useRouter } from "expo-router"; -import { type EnvironmentThreadShell } from "@t3tools/client-runtime/state/shell"; -import type { EnvironmentId, ThreadId } from "@t3tools/contracts"; - -import type { SelectedThreadRef } from "../state/remote-runtime-types"; - -type Router = ReturnType; - -type ThreadRouteInput = - | Pick - | Pick; -type PlainThreadRouteInput = - | { - environmentId: EnvironmentId; - threadId: ThreadId; - } - | { - environmentId: EnvironmentId; - id: ThreadId; - }; - -export function buildThreadRoutePath(input: ThreadRouteInput | PlainThreadRouteInput): string { - const environmentId = input.environmentId; - const threadId = "threadId" in input ? input.threadId : input.id; - - return `/threads/${encodeURIComponent(environmentId)}/${encodeURIComponent(threadId)}`; -} - -export function buildThreadReviewRoutePath( - input: ThreadRouteInput | PlainThreadRouteInput, -): string { - return `${buildThreadRoutePath(input)}/review`; -} - -export function buildThreadFilesRoutePath( - input: ThreadRouteInput | PlainThreadRouteInput, - relativePath?: string | null, - line?: number | null, -): string { - const basePath = `${buildThreadRoutePath(input)}/files`; - if (!relativePath) { - return basePath; - } - - const pathSegments = relativePath.split("/").filter((segment) => segment.length > 0); - if (pathSegments.length === 0) { - return basePath; - } - - const encodedPath = pathSegments.map(encodeURIComponent).join("/"); - const lineParam = - Number.isFinite(line) && Number(line) > 0 ? `?line=${Math.floor(Number(line))}` : ""; - return `${basePath}/${encodedPath}${lineParam}`; -} - -export function buildThreadTerminalRoutePath( - input: ThreadRouteInput | PlainThreadRouteInput, - terminalId?: string | null, -): string { - const basePath = `${buildThreadRoutePath(input)}/terminal`; - if (!terminalId) { - return basePath; - } - - return `${basePath}?terminalId=${encodeURIComponent(terminalId)}`; -} - -/** - * Prefer this over {@link buildThreadTerminalRoutePath} with `router.push(string)` — Expo Router - * often does not merge query strings into `useLocalSearchParams`, which breaks terminal bootstrap - * (`requestedTerminalId` stays null while the UI assumes `default`). - */ -export function buildThreadTerminalNavigation( - input: ThreadRouteInput | PlainThreadRouteInput, - terminalId?: string | null, -): Href { - const environmentId = String(input.environmentId); - const threadId = String("threadId" in input ? input.threadId : input.id); - - const params: { environmentId: string; threadId: string; terminalId?: string } = { - environmentId, - threadId, - }; - - if (terminalId != null && terminalId !== "") { - params.terminalId = terminalId; - } - - return { - pathname: "/threads/[environmentId]/[threadId]/terminal", - params, - }; -} - -export function buildThreadFilesNavigation( - input: ThreadRouteInput | PlainThreadRouteInput, - relativePath?: string | null, - line?: number | null, -): Href { - const environmentId = String(input.environmentId); - const threadId = String("threadId" in input ? input.threadId : input.id); - const path = relativePath?.split("/").filter((segment) => segment.length > 0) ?? []; - - if (path.length === 0) { - return { - pathname: "/threads/[environmentId]/[threadId]/files", - params: { environmentId, threadId }, - }; - } - - const params: { - environmentId: string; - threadId: string; - path: string[]; - line?: string; - } = { environmentId, threadId, path }; - if (Number.isFinite(line) && Number(line) > 0) { - params.line = String(Math.floor(Number(line))); - } - - return { - pathname: "/threads/[environmentId]/[threadId]/files/[...path]", - params, - }; -} - -export function dismissRoute(router: Router) { - if (router.canGoBack()) { - router.back(); - return; - } - - router.replace("/"); -} diff --git a/apps/mobile/src/lib/storage.ts b/apps/mobile/src/lib/storage.ts index 114648277b9..8ed999225e1 100644 --- a/apps/mobile/src/lib/storage.ts +++ b/apps/mobile/src/lib/storage.ts @@ -59,7 +59,14 @@ export class MobileStorageEncodeError extends Schema.TaggedErrorClass { @@ -153,15 +160,31 @@ export async function loadPreferences(): Promise { const preferences: { liveActivitiesEnabled?: boolean; - terminalFontSize?: number; + baseFontSize?: number; + terminalFontSize?: number | null; + markdownFontSize?: number; + codeFontSize?: number | null; + codeWordBreak?: boolean; } = {}; if (typeof parsed.liveActivitiesEnabled === "boolean") { preferences.liveActivitiesEnabled = parsed.liveActivitiesEnabled; } - if (typeof parsed.terminalFontSize === "number") { + if (typeof parsed.baseFontSize === "number") { + preferences.baseFontSize = parsed.baseFontSize; + } + if (typeof parsed.terminalFontSize === "number" || parsed.terminalFontSize === null) { preferences.terminalFontSize = parsed.terminalFontSize; } + if (typeof parsed.markdownFontSize === "number") { + preferences.markdownFontSize = parsed.markdownFontSize; + } + if (typeof parsed.codeFontSize === "number" || parsed.codeFontSize === null) { + preferences.codeFontSize = parsed.codeFontSize; + } + if (typeof parsed.codeWordBreak === "boolean") { + preferences.codeWordBreak = parsed.codeWordBreak; + } return preferences; } diff --git a/apps/mobile/src/lib/typography.test.ts b/apps/mobile/src/lib/typography.test.ts index 6a021dabcce..5b62e9bd312 100644 --- a/apps/mobile/src/lib/typography.test.ts +++ b/apps/mobile/src/lib/typography.test.ts @@ -3,21 +3,18 @@ import { describe, expect, it } from "vite-plus/test"; import { MOBILE_CODE_SURFACE, MOBILE_TYPOGRAPHY } from "./typography"; describe("mobile typography", () => { - it("uses the intentional compact mobile font scale", () => { + it("uses the intentional mobile font scale anchored at a 16pt body", () => { expect(Object.values(MOBILE_TYPOGRAPHY).map(({ fontSize }) => fontSize)).toEqual([ - 10, 11, 12, 13, 14, 15, 17, 20, 24, 28, + 11, 12, 13, 14, 16, 18, 21, 26, 30, ]); - }); - - it("uses a compact shared style for editable composer text", () => { - expect(MOBILE_TYPOGRAPHY.composer).toEqual({ fontSize: 14, lineHeight: 20 }); + expect(MOBILE_TYPOGRAPHY.body).toEqual({ fontSize: 16, lineHeight: 23 }); }); it("uses caption-sized code with a compact readable row height", () => { expect(MOBILE_CODE_SURFACE).toMatchObject({ fontSize: MOBILE_TYPOGRAPHY.caption.fontSize, lineNumberFontSize: MOBILE_TYPOGRAPHY.micro.fontSize, - rowHeight: 20, + rowHeight: 22, }); }); }); diff --git a/apps/mobile/src/lib/typography.ts b/apps/mobile/src/lib/typography.ts index 644fee36589..6572f38dde2 100644 --- a/apps/mobile/src/lib/typography.ts +++ b/apps/mobile/src/lib/typography.ts @@ -1,19 +1,18 @@ export const MOBILE_TYPOGRAPHY = { - micro: { fontSize: 10, lineHeight: 13 }, - caption: { fontSize: 11, lineHeight: 15 }, - label: { fontSize: 12, lineHeight: 16 }, - footnote: { fontSize: 13, lineHeight: 18 }, - composer: { fontSize: 14, lineHeight: 20 }, - body: { fontSize: 15, lineHeight: 22 }, - headline: { fontSize: 17, lineHeight: 22 }, - title: { fontSize: 20, lineHeight: 26 }, - largeTitle: { fontSize: 24, lineHeight: 30 }, - display: { fontSize: 28, lineHeight: 34 }, + micro: { fontSize: 11, lineHeight: 14 }, + caption: { fontSize: 12, lineHeight: 16 }, + label: { fontSize: 13, lineHeight: 17 }, + footnote: { fontSize: 14, lineHeight: 19 }, + body: { fontSize: 16, lineHeight: 23 }, + headline: { fontSize: 18, lineHeight: 23 }, + title: { fontSize: 21, lineHeight: 28 }, + largeTitle: { fontSize: 26, lineHeight: 32 }, + display: { fontSize: 30, lineHeight: 36 }, } as const; /** Shared geometry for dense, horizontally scrolling code surfaces. */ export const MOBILE_CODE_SURFACE = { - rowHeight: 20, + rowHeight: 22, gutterWidth: 46, codePadding: 7, textVerticalInset: 2, diff --git a/apps/mobile/src/native/StackHeader.tsx b/apps/mobile/src/native/StackHeader.tsx new file mode 100644 index 00000000000..e2708757995 --- /dev/null +++ b/apps/mobile/src/native/StackHeader.tsx @@ -0,0 +1,359 @@ +import { useNavigation, type ParamListBase } from "@react-navigation/native"; +import type { + NativeStackHeaderItem, + NativeStackHeaderItemMenu, + NativeStackNavigationOptions, + NativeStackNavigationProp, +} from "@react-navigation/native-stack"; +import { + Children, + isValidElement, + useEffect, + useLayoutEffect, + useMemo, + type ReactElement, + type ReactNode, +} from "react"; +import type { ColorValue } from "react-native"; + +export { + nativeHeaderScrollEdgeEffects, + nativeTopScrollEdgeEffect, + type NativeHeaderScrollEdgeEffects, + type NativeTopScrollEdgeEffect, +} from "./scrollEdgeEffects"; + +export type AppNativeStackNavigationOptions = Omit< + NativeStackNavigationOptions, + "headerTintColor" | "unstable_headerLeftItems" | "unstable_headerRightItems" +> & { + readonly headerTintColor?: string | ColorValue; + readonly unstable_headerCenterItems?: unknown; + readonly unstable_headerLeftItems?: unknown; + readonly unstable_headerRightItems?: unknown; + readonly unstable_headerSubtitle?: unknown; + readonly unstable_headerToolbarItems?: unknown; + readonly unstable_navigationItemStyle?: unknown; +}; + +function useNativeStackNavigation(): NativeStackNavigationProp | null { + return useNavigation>(); +} + +function normalizeScreenOptions( + options: AppNativeStackNavigationOptions | undefined, +): NativeStackNavigationOptions | undefined { + if (!options) { + return options; + } + + const normalized = { ...options } as NativeStackNavigationOptions & { + unstable_navigationItemStyle?: unknown; + unstable_headerCenterItems?: unknown; + unstable_headerSubtitle?: unknown; + unstable_headerToolbarItems?: unknown; + }; + + if (normalized.headerTintColor !== undefined) { + normalized.headerTintColor = String(normalized.headerTintColor); + } + + return normalized as NativeStackNavigationOptions; +} + +export function NativeStackScreenOptions(props: { + readonly options?: AppNativeStackNavigationOptions; + readonly listeners?: Record void>; + readonly name?: string; +}) { + const navigation = useNativeStackNavigation(); + const normalizedOptions = useMemo(() => normalizeScreenOptions(props.options), [props.options]); + + useLayoutEffect(() => { + if (!navigation || !normalizedOptions) { + return; + } + navigation.setOptions(normalizedOptions); + }, [navigation, normalizedOptions]); + + useEffect(() => { + if (!navigation || !props.listeners) { + return; + } + const subscriptions = Object.entries(props.listeners).map(([eventName, listener]) => + navigation.addListener(eventName as never, listener as never), + ); + return () => { + for (const unsubscribe of subscriptions) { + unsubscribe(); + } + }; + }, [navigation, props.listeners]); + + return null; +} + +function labelFromChildren(children: ReactNode): string { + const parts: string[] = []; + Children.forEach(children, (child) => { + if (typeof child === "string" || typeof child === "number") { + parts.push(String(child)); + } else if (isValidElement<{ children?: ReactNode }>(child)) { + parts.push(labelFromChildren(child.props.children)); + } + }); + return parts.join(""); +} + +type NativeStackHeaderIcon = NonNullable< + Extract["icon"] +>; +type NativeStackOptionsWithToolbar = NativeStackNavigationOptions & { + unstable_headerToolbarItems?: () => NativeStackHeaderItem[]; +}; + +function iconFromProp(icon: unknown): NativeStackHeaderIcon | undefined { + if (typeof icon !== "string") { + return undefined; + } + return { type: "sfSymbol", name: icon as never }; +} + +type ToolbarElementProps = Record & { readonly children?: ReactNode }; + +function elementTypeName(element: ReactElement): string | undefined { + const type = element.type; + if (typeof type === "function") { + return (type as { displayName?: string; name?: string }).displayName ?? type.name; + } + return undefined; +} + +function convertMenuAction( + element: ReactElement, +): NativeStackHeaderItemMenu["menu"]["items"][number] | null { + const typeName = elementTypeName(element); + if (typeName === "NativeHeaderToolbarMenuAction") { + const label = labelFromChildren(element.props.children); + return { + type: "action", + label, + description: typeof element.props.subtitle === "string" ? element.props.subtitle : undefined, + disabled: Boolean(element.props.disabled), + icon: iconFromProp(element.props.icon), + onPress: + typeof element.props.onPress === "function" + ? (element.props.onPress as () => void) + : () => undefined, + state: element.props.isOn === true ? "on" : undefined, + destructive: Boolean(element.props.destructive), + discoverabilityLabel: + typeof element.props.discoverabilityLabel === "string" + ? element.props.discoverabilityLabel + : undefined, + }; + } + + if (typeName === "NativeHeaderToolbarMenu") { + return { + type: "submenu", + label: + typeof element.props.title === "string" + ? element.props.title + : labelFromChildren(element.props.children), + icon: iconFromProp(element.props.icon), + inline: Boolean(element.props.inline), + items: collectMenuItems(element.props.children), + }; + } + + return null; +} + +function collectMenuItems(children: ReactNode): NativeStackHeaderItemMenu["menu"]["items"] { + const items: NativeStackHeaderItemMenu["menu"]["items"] = []; + Children.forEach(children, (child) => { + if (!isValidElement(child)) { + return; + } + const item = convertMenuAction(child); + if (item) { + items.push(item); + return; + } + items.push(...collectMenuItems(child.props.children)); + }); + return items; +} + +function convertToolbarChild(child: ReactNode): NativeStackHeaderItem | null { + if (!isValidElement(child)) { + return null; + } + + const typeName = elementTypeName(child); + if (typeName === "NativeHeaderToolbarButton") { + return { + type: "button", + label: "", + accessibilityLabel: + typeof child.props.accessibilityLabel === "string" + ? child.props.accessibilityLabel + : undefined, + disabled: Boolean(child.props.disabled), + icon: iconFromProp(child.props.icon), + onPress: + typeof child.props.onPress === "function" + ? (child.props.onPress as () => void) + : () => undefined, + sharesBackground: !child.props.separateBackground, + tintColor: child.props.tintColor as ColorValue | undefined, + variant: "plain", + }; + } + + if (typeName === "NativeHeaderToolbarMenu") { + return { + type: "menu", + label: typeof child.props.title === "string" ? child.props.title : "", + accessibilityLabel: + typeof child.props.accessibilityLabel === "string" + ? child.props.accessibilityLabel + : undefined, + disabled: Boolean(child.props.disabled), + icon: iconFromProp(child.props.icon), + menu: { + title: typeof child.props.title === "string" ? child.props.title : undefined, + items: collectMenuItems(child.props.children), + }, + sharesBackground: !child.props.separateBackground, + tintColor: child.props.tintColor as ColorValue | undefined, + variant: "plain", + }; + } + + if (typeName === "NativeHeaderToolbarSpacer") { + return { + type: "spacing", + spacing: typeof child.props.width === "number" ? child.props.width : 8, + }; + } + + return null; +} + +function collectToolbarItems(children: ReactNode): NativeStackHeaderItem[] { + const items: NativeStackHeaderItem[] = []; + Children.forEach(children, (child) => { + const item = convertToolbarChild(child); + if (item) { + items.push(item); + } + }); + return items; +} + +function NativeHeaderToolbarRoot(props: { + readonly placement?: "left" | "right" | "bottom"; + readonly children?: ReactNode; +}) { + const navigation = useNativeStackNavigation(); + const items = useMemo(() => collectToolbarItems(props.children), [props.children]); + + useEffect(() => { + if (!navigation) { + return; + } + if (props.placement === "bottom") { + navigation.setOptions({ + unstable_headerToolbarItems: () => items, + } as NativeStackOptionsWithToolbar); + return () => { + navigation.setOptions({ + unstable_headerToolbarItems: () => [], + } as NativeStackOptionsWithToolbar); + }; + } + if (props.placement === "left") { + navigation.setOptions({ unstable_headerLeftItems: () => items }); + return () => { + navigation.setOptions({ unstable_headerLeftItems: () => [] }); + }; + } + navigation.setOptions({ unstable_headerRightItems: () => items }); + return () => { + navigation.setOptions({ unstable_headerRightItems: () => [] }); + }; + }, [items, navigation, props.placement]); + + return null; +} + +function NativeHeaderToolbarButton(_props: { + readonly accessibilityLabel?: string; + readonly disabled?: boolean; + readonly icon?: string; + readonly onPress?: () => void; + readonly separateBackground?: boolean; + readonly tintColor?: ColorValue; +}) { + return null; +} +NativeHeaderToolbarButton.displayName = "NativeHeaderToolbarButton"; + +function NativeHeaderToolbarMenu(_props: { + readonly accessibilityLabel?: string; + readonly children?: ReactNode; + readonly disabled?: boolean; + readonly icon?: string; + readonly inline?: boolean; + readonly separateBackground?: boolean; + readonly tintColor?: ColorValue; + readonly title?: string; +}) { + return null; +} +NativeHeaderToolbarMenu.displayName = "NativeHeaderToolbarMenu"; + +function NativeHeaderToolbarMenuAction(_props: { + readonly children?: ReactNode; + readonly destructive?: boolean; + readonly disabled?: boolean; + readonly discoverabilityLabel?: string; + readonly icon?: string; + readonly isOn?: boolean; + readonly onPress?: () => void; + readonly subtitle?: string; +}) { + return null; +} +NativeHeaderToolbarMenuAction.displayName = "NativeHeaderToolbarMenuAction"; + +function NativeHeaderToolbarLabel(_props: { readonly children?: ReactNode }) { + return null; +} +NativeHeaderToolbarLabel.displayName = "NativeHeaderToolbarLabel"; + +function NativeHeaderToolbarSpacer(_props: { + readonly sharesBackground?: boolean; + readonly width?: number; +}) { + return null; +} +NativeHeaderToolbarSpacer.displayName = "NativeHeaderToolbarSpacer"; + +function NativeHeaderToolbarSearchBarSlot() { + return null; +} +NativeHeaderToolbarSearchBarSlot.displayName = "NativeHeaderToolbarSearchBarSlot"; + +export const NativeHeaderToolbar = Object.assign(NativeHeaderToolbarRoot, { + Button: NativeHeaderToolbarButton, + Label: NativeHeaderToolbarLabel, + Menu: Object.assign(NativeHeaderToolbarMenu, { + Action: NativeHeaderToolbarMenuAction, + }), + MenuAction: NativeHeaderToolbarMenuAction, + SearchBarSlot: NativeHeaderToolbarSearchBarSlot, + Spacer: NativeHeaderToolbarSpacer, +}); diff --git a/apps/mobile/src/native/T3ComposerEditor.ios.tsx b/apps/mobile/src/native/T3ComposerEditor.ios.tsx index eb935030e81..1089ab3ac1f 100644 --- a/apps/mobile/src/native/T3ComposerEditor.ios.tsx +++ b/apps/mobile/src/native/T3ComposerEditor.ios.tsx @@ -14,8 +14,8 @@ import { Image, StyleSheet } from "react-native"; import { markdownFileIconSource } from "@t3tools/mobile-markdown-text/file-icons"; import { resolveMarkdownFileIcon } from "@t3tools/mobile-markdown-text/links"; -import { MOBILE_TYPOGRAPHY } from "../lib/typography"; import { useThemeColor } from "../lib/useThemeColor"; +import { useScaledTextRole } from "../features/settings/appearance/useScaledTextRole"; import { acknowledgeComposerNativeEvent, isComposerNativeEcho, @@ -69,6 +69,7 @@ interface NativeComposerEditorProps extends ViewProps { readonly onComposerPasteImages?: (event: NativePasteImagesEvent) => void; readonly onComposerFocus?: () => void; readonly onComposerBlur?: () => void; + readonly onComposerSubmit?: () => void; } const NativeView = requireNativeView(NATIVE_MODULE_NAME); @@ -93,6 +94,7 @@ export function ComposerEditor({ onPasteImages, onFocus, onBlur, + onSubmit, contentInsetVertical = 0, ...props }: ComposerEditorProps) { @@ -105,6 +107,7 @@ export function ComposerEditor({ { eventCount: 0, value: props.value, selection: selection ?? null }, ]); const confirmedTokensRef = useRef(collectComposerInlineTokens(props.value)); + const bodyText = useScaledTextRole("body"); const textColor = useThemeColor("--color-foreground"); const placeholderColor = useThemeColor("--color-placeholder"); const chipBackground = useThemeColor("--color-subtle"); @@ -230,12 +233,12 @@ export function ComposerEditor({ fontSize={ typeof resolvedTextStyle.fontSize === "number" ? resolvedTextStyle.fontSize - : MOBILE_TYPOGRAPHY.composer.fontSize + : bodyText.fontSize } lineHeight={ typeof resolvedTextStyle.lineHeight === "number" ? resolvedTextStyle.lineHeight - : MOBILE_TYPOGRAPHY.composer.lineHeight + : bodyText.lineHeight } contentInsetVertical={contentInsetVertical} editable={props.editable ?? true} @@ -270,6 +273,7 @@ export function ComposerEditor({ onComposerPasteImages={(event) => onPasteImages?.(event.nativeEvent.uris)} onComposerFocus={onFocus} onComposerBlur={onBlur} + onComposerSubmit={onSubmit} /> ); } diff --git a/apps/mobile/src/native/T3ComposerEditor.tsx b/apps/mobile/src/native/T3ComposerEditor.tsx index dc2dfdfee03..27e61e1b99c 100644 --- a/apps/mobile/src/native/T3ComposerEditor.tsx +++ b/apps/mobile/src/native/T3ComposerEditor.tsx @@ -2,8 +2,8 @@ import { TextInputWrapper } from "expo-paste-input"; import { useImperativeHandle, useRef } from "react"; import { TextInput, type TextInput as RNTextInput } from "react-native"; -import { MOBILE_TYPOGRAPHY } from "../lib/typography"; import { useThemeColor } from "../lib/useThemeColor"; +import { useScaledTextRole } from "../features/settings/appearance/useScaledTextRole"; import { useNativePaste } from "../lib/useNativePaste"; import type { ComposerEditorProps } from "./T3ComposerEditor.types"; @@ -18,6 +18,7 @@ export function ComposerEditor({ ...props }: ComposerEditorProps) { const inputRef = useRef(null); + const bodyText = useScaledTextRole("body"); const foregroundColor = useThemeColor("--color-foreground"); const placeholderColor = useThemeColor("--color-placeholder"); const handlePaste = useNativePaste((uris) => onPasteImages?.(uris)); @@ -48,7 +49,7 @@ export function ComposerEditor({ minHeight: 0, color: foregroundColor, fontFamily: "DMSans_400Regular", - ...MOBILE_TYPOGRAPHY.composer, + ...bodyText, paddingVertical: contentInsetVertical, }, textStyle, diff --git a/apps/mobile/src/native/T3ComposerEditor.types.ts b/apps/mobile/src/native/T3ComposerEditor.types.ts index d70d63fa437..f7760c395ea 100644 --- a/apps/mobile/src/native/T3ComposerEditor.types.ts +++ b/apps/mobile/src/native/T3ComposerEditor.types.ts @@ -35,4 +35,6 @@ export interface ComposerEditorProps { readonly onPasteImages?: (uris: ReadonlyArray) => void; readonly onFocus?: () => void; readonly onBlur?: () => void; + /** Invoked by the native editor when Command-Return is pressed on a hardware keyboard. */ + readonly onSubmit?: () => void; } diff --git a/apps/mobile/src/native/T3KeyboardCommands.ios.tsx b/apps/mobile/src/native/T3KeyboardCommands.ios.tsx new file mode 100644 index 00000000000..ff4e817c200 --- /dev/null +++ b/apps/mobile/src/native/T3KeyboardCommands.ios.tsx @@ -0,0 +1,31 @@ +import { requireNativeView } from "expo"; +import type { PropsWithChildren } from "react"; +import type { NativeSyntheticEvent, ViewProps } from "react-native"; + +import type { HardwareKeyboardCommand } from "../features/keyboard/hardwareKeyboardCommands"; + +interface NativeKeyboardCommandsProps extends ViewProps, PropsWithChildren { + readonly enabledCommands: ReadonlyArray; + readonly onCommand: ( + event: NativeSyntheticEvent<{ readonly command: HardwareKeyboardCommand }>, + ) => void; +} + +const NativeKeyboardCommands = requireNativeView("T3KeyboardCommands"); + +export function T3KeyboardCommands( + props: PropsWithChildren<{ + readonly enabledCommands: ReadonlyArray; + readonly onCommand: (command: HardwareKeyboardCommand) => void; + }>, +) { + return ( + props.onCommand(event.nativeEvent.command)} + enabledCommands={props.enabledCommands} + style={{ flex: 1 }} + > + {props.children} + + ); +} diff --git a/apps/mobile/src/native/T3KeyboardCommands.tsx b/apps/mobile/src/native/T3KeyboardCommands.tsx new file mode 100644 index 00000000000..87629e6d676 --- /dev/null +++ b/apps/mobile/src/native/T3KeyboardCommands.tsx @@ -0,0 +1,13 @@ +import type { PropsWithChildren } from "react"; +import { View } from "react-native"; + +import type { HardwareKeyboardCommand } from "../features/keyboard/hardwareKeyboardCommands"; + +export function T3KeyboardCommands( + props: PropsWithChildren<{ + readonly enabledCommands: ReadonlyArray; + readonly onCommand: (command: HardwareKeyboardCommand) => void; + }>, +) { + return {props.children}; +} diff --git a/apps/mobile/src/native/scrollEdgeEffects.test.ts b/apps/mobile/src/native/scrollEdgeEffects.test.ts new file mode 100644 index 00000000000..cf7e590671c --- /dev/null +++ b/apps/mobile/src/native/scrollEdgeEffects.test.ts @@ -0,0 +1,29 @@ +import { describe, expect, it } from "vite-plus/test"; + +import { nativeHeaderScrollEdgeEffects, nativeTopScrollEdgeEffect } from "./scrollEdgeEffects"; + +describe("nativeTopScrollEdgeEffect", () => { + it("keeps the automatic native treatment on iOS 26", () => { + expect(nativeTopScrollEdgeEffect("ios", "26.5")).toBe("automatic"); + }); + + it("keeps UIKit automatic scroll-edge sampling on iOS 27 and later", () => { + expect(nativeTopScrollEdgeEffect("ios", "27.0")).toBe("automatic"); + expect(nativeTopScrollEdgeEffect("ios", 28)).toBe("automatic"); + }); + + it("does not apply the iOS workaround to other platforms", () => { + expect(nativeTopScrollEdgeEffect("android", 27)).toBe("automatic"); + }); +}); + +describe("nativeHeaderScrollEdgeEffects", () => { + it("keeps non-top header edges hidden while applying the platform top effect", () => { + expect(nativeHeaderScrollEdgeEffects("ios", "27.0")).toEqual({ + top: "automatic", + bottom: "hidden", + left: "hidden", + right: "hidden", + }); + }); +}); diff --git a/apps/mobile/src/native/scrollEdgeEffects.ts b/apps/mobile/src/native/scrollEdgeEffects.ts new file mode 100644 index 00000000000..cb6f74295de --- /dev/null +++ b/apps/mobile/src/native/scrollEdgeEffects.ts @@ -0,0 +1,37 @@ +// Pure helpers for native header scroll-edge effects. Kept free of +// react-native / react-navigation imports so they stay unit-testable in node +// (those packages ship untranspiled Flow syntax). + +export type NativeTopScrollEdgeEffect = "automatic" | "soft"; +export type NativeHeaderScrollEdgeEffects = { + readonly top: NativeTopScrollEdgeEffect; + readonly bottom: "hidden"; + readonly left: "hidden"; + readonly right: "hidden"; +}; + +export function nativeTopScrollEdgeEffect( + os: string, + _version: number | string, +): NativeTopScrollEdgeEffect { + if (os !== "ios") { + return "automatic"; + } + + // The standalone RNS/Mail spike that matched Messages/GitHub used UIKit's + // automatic scroll-edge behavior. Forcing `soft` on iOS 27 makes production + // look like a local overlay instead of sampling the app content edge-to-edge. + return "automatic"; +} + +export function nativeHeaderScrollEdgeEffects( + os: string, + version: number | string, +): NativeHeaderScrollEdgeEffects { + return { + top: nativeTopScrollEdgeEffect(os, version), + bottom: "hidden", + left: "hidden", + right: "hidden", + }; +} diff --git a/apps/mobile/src/state/use-thread-pr.ts b/apps/mobile/src/state/use-thread-pr.ts new file mode 100644 index 00000000000..b7d890bbe7f --- /dev/null +++ b/apps/mobile/src/state/use-thread-pr.ts @@ -0,0 +1,67 @@ +import type { EnvironmentThreadShell } from "@t3tools/client-runtime/state/shell"; +import type { VcsStatusResult } from "@t3tools/contracts"; +import { resolveChangeRequestPresentation } from "@t3tools/shared/sourceControl"; + +import { useEnvironmentQuery } from "./query"; +import { vcsEnvironment } from "./vcs"; + +export type ThreadPr = NonNullable; + +export interface ThreadPrPresentation { + readonly number: number; + readonly state: ThreadPr["state"]; + readonly url: string; + /** Compact chip label, e.g. "PR open" / "MR merged". */ + readonly label: string; + readonly textClassName: string; +} + +const PR_STATE_TEXT_CLASS: Record = { + open: "text-emerald-600 dark:text-emerald-400", + merged: "text-violet-600 dark:text-violet-400", + closed: "text-zinc-500 dark:text-zinc-400", +}; + +export function presentThreadPr( + pr: ThreadPr, + provider: VcsStatusResult["sourceControlProvider"] | null | undefined, +): ThreadPrPresentation { + const shortName = resolveChangeRequestPresentation(provider).shortName; + return { + number: pr.number, + state: pr.state, + url: pr.url, + label: `${shortName} ${pr.state}`, + textClassName: PR_STATE_TEXT_CLASS[pr.state], + }; +} + +/** + * Live PR status for a thread's branch. Subscriptions are deduplicated per + * (environmentId, cwd) by the atom family, so many rows on the same worktree + * or project root share one stream — and virtualization means only visible + * rows subscribe at all. + */ +export function useThreadPr( + thread: EnvironmentThreadShell, + projectCwd: string | null, +): ThreadPrPresentation | null { + const cwd = thread.worktreePath ?? projectCwd; + const gitStatus = useEnvironmentQuery( + thread.branch !== null && cwd !== null + ? vcsEnvironment.status({ + environmentId: thread.environmentId, + input: { cwd }, + }) + : null, + ); + + const status = gitStatus.data; + if (status === null || thread.branch === null || status.refName !== thread.branch) { + return null; + } + if (!status.pr) { + return null; + } + return presentThreadPr(status.pr, status.sourceControlProvider); +} diff --git a/apps/mobile/src/state/use-thread-selection.ts b/apps/mobile/src/state/use-thread-selection.ts index 06175b6d237..d369fbc4377 100644 --- a/apps/mobile/src/state/use-thread-selection.ts +++ b/apps/mobile/src/state/use-thread-selection.ts @@ -1,12 +1,25 @@ -import { useLocalSearchParams } from "expo-router"; -import { useMemo } from "react"; -import { EnvironmentId, ThreadId, type ScopedProjectRef } from "@t3tools/contracts"; +import { useRoute, type RouteProp } from "@react-navigation/native"; +import { useMemo, useRef } from "react"; +import { + EnvironmentId, + type OrchestrationThread, + ThreadId, + type ScopedProjectRef, + type ScopedThreadRef, +} from "@t3tools/contracts"; +import type { EnvironmentThreadShell } from "@t3tools/client-runtime/state/shell"; +import * as Option from "effect/Option"; import { useProject, useThreadShell } from "../state/entities"; +import { useEnvironmentThread } from "../state/threads"; import { useRemoteEnvironmentRuntime, useSavedRemoteConnection, } from "./use-remote-environment-registry"; +type ThreadSelectionRouteParams = { + readonly environmentId?: string | string[]; + readonly threadId?: string | string[]; +}; function firstRouteParam(value: string | string[] | undefined): string | null { if (Array.isArray(value)) { @@ -16,14 +29,48 @@ function firstRouteParam(value: string | string[] | undefined): string | null { return value ?? null; } -export function useThreadSelection() { - const params = useLocalSearchParams<{ - environmentId?: string | string[]; - threadId?: string | string[]; - }>(); - const selectedThreadRef = useMemo(() => { - const environmentId = firstRouteParam(params.environmentId); - const threadId = firstRouteParam(params.threadId); +function latestUserMessageAt(thread: OrchestrationThread): OrchestrationThread["updatedAt"] | null { + for (let index = thread.messages.length - 1; index >= 0; index -= 1) { + const message = thread.messages[index]; + if (message?.role === "user") { + return message.createdAt; + } + } + + return null; +} + +function threadDetailToShell( + environmentId: EnvironmentId, + thread: OrchestrationThread, +): EnvironmentThreadShell { + return { + environmentId, + id: thread.id, + projectId: thread.projectId, + title: thread.title, + modelSelection: thread.modelSelection, + runtimeMode: thread.runtimeMode, + interactionMode: thread.interactionMode, + branch: thread.branch, + worktreePath: thread.worktreePath, + latestTurn: thread.latestTurn, + createdAt: thread.createdAt, + updatedAt: thread.updatedAt, + archivedAt: thread.archivedAt, + session: thread.session, + latestUserMessageAt: latestUserMessageAt(thread), + hasPendingApprovals: false, + hasPendingUserInput: false, + hasActionableProposedPlan: false, + }; +} + +function useResolvedThreadSelection(params: ThreadSelectionRouteParams | undefined) { + const routeParams = params ?? {}; + const routeThreadRef = useMemo(() => { + const environmentId = firstRouteParam(routeParams.environmentId); + const threadId = firstRouteParam(routeParams.threadId); if (!environmentId || !threadId) { return null; } @@ -32,8 +79,26 @@ export function useThreadSelection() { environmentId: EnvironmentId.make(environmentId), threadId: ThreadId.make(threadId), }; - }, [params.environmentId, params.threadId]); - const selectedThread = useThreadShell(selectedThreadRef); + }, [routeParams.environmentId, routeParams.threadId]); + const lastRouteThreadRef = useRef(null); + if (routeThreadRef !== null) { + lastRouteThreadRef.current = routeThreadRef; + } + const selectedThreadRef = routeThreadRef ?? lastRouteThreadRef.current; + const selectedThreadShell = useThreadShell(selectedThreadRef); + const selectedThreadDetailState = useEnvironmentThread( + selectedThreadRef?.environmentId ?? null, + selectedThreadRef?.threadId ?? null, + ); + const selectedThreadDetail = Option.getOrNull(selectedThreadDetailState.data); + const selectedThread = useMemo( + () => + selectedThreadShell ?? + (selectedThreadRef !== null && selectedThreadDetail !== null + ? threadDetailToShell(selectedThreadRef.environmentId, selectedThreadDetail) + : null), + [selectedThreadDetail, selectedThreadRef, selectedThreadShell], + ); const selectedProjectRef = useMemo( () => selectedThread === null @@ -49,11 +114,27 @@ export function useThreadSelection() { const selectedEnvironmentConnection = useSavedRemoteConnection(selectedEnvironmentId); const selectedEnvironmentRuntime = useRemoteEnvironmentRuntime(selectedEnvironmentId); - return { - selectedThreadRef, - selectedThread, - selectedThreadProject, - selectedEnvironmentConnection, - selectedEnvironmentRuntime, - }; + return useMemo( + () => ({ + selectedThreadRef, + selectedThread, + selectedThreadProject, + selectedEnvironmentConnection, + selectedEnvironmentRuntime, + }), + [ + selectedEnvironmentConnection, + selectedEnvironmentRuntime, + selectedThread, + selectedThreadProject, + selectedThreadRef, + ], + ); +} + +type ThreadSelectionState = ReturnType; + +export function useThreadSelection(): ThreadSelectionState { + const route = useRoute>>(); + return useResolvedThreadSelection(route.params); } diff --git a/experiments/messages-glass-lab/MessagesGlassLab.xcodeproj/project.pbxproj b/experiments/messages-glass-lab/MessagesGlassLab.xcodeproj/project.pbxproj new file mode 100644 index 00000000000..dec9d28b85c --- /dev/null +++ b/experiments/messages-glass-lab/MessagesGlassLab.xcodeproj/project.pbxproj @@ -0,0 +1,285 @@ +// !$*UTF8*$! +{ + archiveVersion = 1; + classes = { + }; + objectVersion = 77; + objects = { + +/* Begin PBXBuildFile section */ + 8A01A0012D10000100A00001 /* MessagesGlassLabApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8A01A0002D10000100A00001 /* MessagesGlassLabApp.swift */; }; + 8A01A0032D10000100A00001 /* ContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8A01A0022D10000100A00001 /* ContentView.swift */; }; +/* End PBXBuildFile section */ + +/* Begin PBXFileReference section */ + 8A01A0002D10000100A00001 /* MessagesGlassLabApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MessagesGlassLabApp.swift; sourceTree = ""; }; + 8A01A0022D10000100A00001 /* ContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContentView.swift; sourceTree = ""; }; + 8A01A0102D10000100A00001 /* MessagesGlassLab.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = MessagesGlassLab.app; sourceTree = BUILT_PRODUCTS_DIR; }; +/* End PBXFileReference section */ + + 8A01A0202D10000100A00001 /* MessagesGlassLab */ = { + isa = PBXGroup; + children = ( + 8A01A0002D10000100A00001 /* MessagesGlassLabApp.swift */, + 8A01A0022D10000100A00001 /* ContentView.swift */, + ); + path = MessagesGlassLab; + sourceTree = ""; + }; + +/* Begin PBXFrameworksBuildPhase section */ + 8A01A0112D10000100A00001 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXFrameworksBuildPhase section */ + +/* Begin PBXGroup section */ + 8A01A0302D10000100A00001 = { + isa = PBXGroup; + children = ( + 8A01A0202D10000100A00001 /* MessagesGlassLab */, + 8A01A0312D10000100A00001 /* Products */, + ); + sourceTree = ""; + }; + 8A01A0312D10000100A00001 /* Products */ = { + isa = PBXGroup; + children = ( + 8A01A0102D10000100A00001 /* MessagesGlassLab.app */, + ); + name = Products; + sourceTree = ""; + }; +/* End PBXGroup section */ + +/* Begin PBXNativeTarget section */ + 8A01A0402D10000100A00001 /* MessagesGlassLab */ = { + isa = PBXNativeTarget; + buildConfigurationList = 8A01A0502D10000100A00001 /* Build configuration list for PBXNativeTarget "MessagesGlassLab" */; + buildPhases = ( + 8A01A0412D10000100A00001 /* Sources */, + 8A01A0112D10000100A00001 /* Frameworks */, + 8A01A0422D10000100A00001 /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + ); + name = MessagesGlassLab; + packageProductDependencies = ( + ); + productName = MessagesGlassLab; + productReference = 8A01A0102D10000100A00001 /* MessagesGlassLab.app */; + productType = "com.apple.product-type.application"; + }; +/* End PBXNativeTarget section */ + +/* Begin PBXProject section */ + 8A01A0602D10000100A00001 /* Project object */ = { + isa = PBXProject; + attributes = { + BuildIndependentTargetsInParallel = 1; + LastSwiftUpdateCheck = 2700; + LastUpgradeCheck = 2700; + TargetAttributes = { + 8A01A0402D10000100A00001 = { + CreatedOnToolsVersion = 27.0; + }; + }; + }; + buildConfigurationList = 8A01A0612D10000100A00001 /* Build configuration list for PBXProject "MessagesGlassLab" */; + compatibilityVersion = "Xcode 16.0"; + developmentRegion = en; + hasScannedForEncodings = 0; + knownRegions = ( + en, + Base, + ); + mainGroup = 8A01A0302D10000100A00001; + minimizedProjectReferenceProxies = 1; + preferredProjectObjectVersion = 77; + productRefGroup = 8A01A0312D10000100A00001 /* Products */; + projectDirPath = ""; + projectRoot = ""; + targets = ( + 8A01A0402D10000100A00001 /* MessagesGlassLab */, + ); + }; +/* End PBXProject section */ + +/* Begin PBXResourcesBuildPhase section */ + 8A01A0422D10000100A00001 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXResourcesBuildPhase section */ + +/* Begin PBXSourcesBuildPhase section */ + 8A01A0412D10000100A00001 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 8A01A0012D10000100A00001 /* MessagesGlassLabApp.swift in Sources */, + 8A01A0032D10000100A00001 /* ContentView.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXSourcesBuildPhase section */ + +/* Begin XCBuildConfiguration section */ + 8A01A0622D10000100A00001 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = dwarf; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_TESTABILITY = YES; + GCC_C_LANGUAGE_STANDARD = gnu17; + GCC_DYNAMIC_NO_PIC = NO; + GCC_NO_COMMON_BLOCKS = YES; + GCC_OPTIMIZATION_LEVEL = 0; + GCC_PREPROCESSOR_DEFINITIONS = ( + "DEBUG=1", + "$(inherited)", + ); + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 27.0; + MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; + MTL_FAST_MATH = YES; + ONLY_ACTIVE_ARCH = YES; + SDKROOT = iphoneos; + SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + SWIFT_VERSION = 6.0; + }; + name = Debug; + }; + 8A01A0632D10000100A00001 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; + ENABLE_NS_ASSERTIONS = NO; + GCC_C_LANGUAGE_STANDARD = gnu17; + IPHONEOS_DEPLOYMENT_TARGET = 27.0; + MTL_FAST_MATH = YES; + SDKROOT = iphoneos; + SWIFT_COMPILATION_MODE = wholemodule; + SWIFT_VERSION = 6.0; + VALIDATE_PRODUCT = YES; + }; + name = Release; + }; + 8A01A0512D10000100A00001 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = ""; + CODE_SIGNING_ALLOWED = NO; + CODE_SIGNING_REQUIRED = NO; + CODE_SIGN_STYLE = Automatic; + DEVELOPMENT_TEAM = ""; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_KEY_CFBundleDisplayName = "Messages Glass Lab"; + INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.developer-tools"; + INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES; + INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; + INFOPLIST_KEY_UILaunchScreen_Generation = YES; + INFOPLIST_KEY_UISupportedInterfaceOrientations = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; + IPHONEOS_DEPLOYMENT_TARGET = 27.0; + PRODUCT_BUNDLE_IDENTIFIER = com.t3tools.messagesglasslab; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_VERSION = 6.0; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Debug; + }; + 8A01A0522D10000100A00001 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = ""; + CODE_SIGNING_ALLOWED = NO; + CODE_SIGNING_REQUIRED = NO; + CODE_SIGN_STYLE = Automatic; + DEVELOPMENT_TEAM = ""; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_KEY_CFBundleDisplayName = "Messages Glass Lab"; + INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.developer-tools"; + INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES; + INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; + INFOPLIST_KEY_UILaunchScreen_Generation = YES; + INFOPLIST_KEY_UISupportedInterfaceOrientations = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; + IPHONEOS_DEPLOYMENT_TARGET = 27.0; + PRODUCT_BUNDLE_IDENTIFIER = com.t3tools.messagesglasslab; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_VERSION = 6.0; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Release; + }; +/* End XCBuildConfiguration section */ + +/* Begin XCConfigurationList section */ + 8A01A0612D10000100A00001 /* Build configuration list for PBXProject "MessagesGlassLab" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 8A01A0622D10000100A00001 /* Debug */, + 8A01A0632D10000100A00001 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Debug; + }; + 8A01A0502D10000100A00001 /* Build configuration list for PBXNativeTarget "MessagesGlassLab" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 8A01A0512D10000100A00001 /* Debug */, + 8A01A0522D10000100A00001 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Debug; + }; +/* End XCConfigurationList section */ + }; + rootObject = 8A01A0602D10000100A00001 /* Project object */; +} diff --git a/experiments/messages-glass-lab/MessagesGlassLab/ContentView.swift b/experiments/messages-glass-lab/MessagesGlassLab/ContentView.swift new file mode 100644 index 00000000000..9d19a6666f9 --- /dev/null +++ b/experiments/messages-glass-lab/MessagesGlassLab/ContentView.swift @@ -0,0 +1,386 @@ +import SwiftUI + +struct LabThread: Identifiable, Hashable { + let id = UUID() + let title: String + let subtitle: String + let time: String + let initials: String + let tint: Color + let preview: String +} + +private let threads: [LabThread] = [ + .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."), + .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."), + .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."), + .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."), + .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."), + .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."), + .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."), + .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."), + .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."), + .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."), + .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."), + .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."), + .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."), + .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."), + .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."), + .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."), + .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."), + .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."), + .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."), + .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."), +] + +struct ContentView: View { + @Environment(\.horizontalSizeClass) private var horizontalSizeClass + @State private var searchText = "" + @State private var selectedThread: LabThread? = threads[0] + + var body: some View { + if horizontalSizeClass == .regular { + NativeSplitLab(searchText: $searchText, selectedThread: $selectedThread) + } else { + NativePhoneLab(searchText: $searchText, selectedThread: $selectedThread) + } + } +} + +private var filteredThreads: [LabThread] { + threads +} + +private let glassDebugCodeLines: [String] = [ + "# Native RNS glass debug route", + "", + "This screen intentionally avoids Expo Router headers.", + "The native header below is owned by react-native-screens.", + "", + "Expected iOS 26 behavior:", + "- At rest: header should feel like app background", + "- While scrolled: content should blur behind the header", + "- No gray custom overlay", + "- No JS blur view", + "", + "Scroll edge effect should sample actual content:", + "const header = { translucent: true }", + "const scrollEdgeEffects = { top: 'soft' }", + "", + "Bright rows below make sampling failures obvious.", + "", + "node_modules", + "/.pnp", + ".pnp.*", + ".yarn/*", + "!.yarn/patches", + "!.yarn/plugins", + "!.yarn/releases", + "!.yarn/versions", + "", + "# testing", + "/coverage", + ".convex", + "e2e/.local-dev.json", + "e2e/playwright-report", + "e2e/test-results", + "", + "# app surfaces", + "threads", + "terminal", + "diff renderer", + "file explorer", + "composer", + "native header", + "scroll edge", + "liquid glass", +] + +private let glassDebugSwatches: [Color] = [ + .blue, + .green, + .orange, + .purple, + .cyan, + .pink, +] + +struct NativePhoneLab: View { + @Binding var searchText: String + @Binding var selectedThread: LabThread? + + var body: some View { + NavigationStack { + NativeThreadLab(thread: selectedThread ?? threads[0]) + } + } +} + +struct NativeSplitLab: View { + @Binding var searchText: String + @Binding var selectedThread: LabThread? + + var body: some View { + NavigationSplitView { + List(filteredThreads, selection: $selectedThread) { thread in + MessageSidebarRow(thread: thread) + .tag(thread) + } + .listStyle(.sidebar) + .navigationTitle("Threads") + .searchable(text: $searchText, placement: .sidebar, prompt: "Search") + .toolbar { + ToolbarItemGroup(placement: .topBarTrailing) { + Menu { + Button("All Threads", systemImage: "tray.full") {} + Button("Ready", systemImage: "checkmark.circle") {} + Button("Running", systemImage: "bolt.circle") {} + } label: { + Image(systemName: "line.3.horizontal.decrease") + } + .buttonStyle(.glass) + + Button {} label: { + Image(systemName: "gearshape") + } + .buttonStyle(.glass) + + Button {} label: { + Image(systemName: "square.and.pencil") + } + .buttonStyle(.glass) + } + } + } detail: { + if let selectedThread { + NativeThreadLab(thread: selectedThread) + } else { + ContentUnavailableView("Select a thread", systemImage: "sidebar.left") + } + } + } +} + +struct MessageListRow: View { + let thread: LabThread + + var body: some View { + HStack(alignment: .top, spacing: 14) { + Circle() + .fill(thread.tint.gradient) + .frame(width: 52, height: 52) + .overlay { + Text(thread.initials) + .font(.headline.weight(.bold)) + .foregroundStyle(.white) + } + + VStack(alignment: .leading, spacing: 4) { + HStack { + Text(thread.title) + .font(.headline.weight(.semibold)) + .lineLimit(1) + Spacer(minLength: 8) + Text(thread.time) + .foregroundStyle(.secondary) + Image(systemName: "chevron.right") + .font(.caption.weight(.semibold)) + .foregroundStyle(.tertiary) + } + + Text(thread.preview) + .font(.subheadline) + .foregroundStyle(.secondary) + .lineLimit(2) + } + .padding(.vertical, 12) + } + } +} + +struct MessageSidebarRow: View { + let thread: LabThread + + var body: some View { + VStack(alignment: .leading, spacing: 4) { + HStack { + Text(thread.title) + .font(.headline.weight(.semibold)) + .lineLimit(1) + Spacer() + Text(thread.time) + .font(.subheadline) + .foregroundStyle(.secondary) + } + Text(thread.subtitle) + .font(.subheadline) + .foregroundStyle(.secondary) + .lineLimit(1) + } + .padding(.vertical, 6) + } +} + +struct NativeThreadLab: View { + let thread: LabThread + @State private var draft = "Ask the repo agent, or run a command..." + @State private var scrollStep = 0 + + private let scrollTimer = Timer.publish(every: 2.8, on: .main, in: .common).autoconnect() + private let scrollTargets = ["top", "swatches", "top", "code", "card"] + + var body: some View { + ScrollViewReader { proxy in + ScrollView { + LazyVStack(alignment: .leading, spacing: 22) { + Color.clear + .frame(height: 0) + .id("top") + + hero + .padding(.top, 8) + + swatches + .id("swatches") + + explanationCard + .id("card") + + codeCard + .id("code") + } + .padding(.horizontal, 18) + .padding(.top, 8) + .padding(.bottom, 96) + } + .background(Color(uiColor: .systemBackground)) + .onReceive(scrollTimer) { _ in + guard !scrollTargets.isEmpty else { return } + + let target = scrollTargets[scrollStep % scrollTargets.count] + scrollStep += 1 + + withAnimation(.smooth(duration: 1.0)) { + proxy.scrollTo(target, anchor: .top) + } + } + } + .navigationTitle("RNS Glass") + .navigationSubtitle("plain react-native-screens") + .toolbar { + ToolbarItemGroup(placement: .topBarTrailing) { + Button {} label: { Image(systemName: "plus") } + .buttonStyle(.glass) + Button {} label: { Image(systemName: "magnifyingglass") } + .buttonStyle(.glass) + } + } + .safeAreaInset(edge: .bottom) { + composer + } + } + + private var hero: some View { + VStack(alignment: .leading, spacing: 10) { + Text("plain react-native-screens") + .font(.subheadline.weight(.bold)) + .textCase(.uppercase) + .tracking(0.5) + .foregroundStyle(.secondary) + + Text("Native scroll-edge glass") + .font(.system(size: 48, weight: .heavy, design: .default)) + .lineLimit(nil) + .minimumScaleFactor(0.72) + + 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.") + .font(.title3) + .foregroundStyle(.secondary) + } + } + + private var swatches: some View { + LazyVGrid( + columns: [GridItem(.adaptive(minimum: 60), spacing: 12)], + alignment: .leading, + spacing: 12 + ) { + ForEach(Array(glassDebugSwatches.enumerated()), id: \.offset) { index, color in + Circle() + .fill(color.gradient) + .frame(width: 60, height: 60) + .overlay { + Text("\(index + 1)") + .font(.title2.weight(.heavy)) + .foregroundStyle(.white) + } + } + } + } + + private var explanationCard: some View { + VStack(alignment: .leading, spacing: 10) { + Text("What this isolates") + .font(.title2.weight(.bold)) + + Text("Native transparent header + iOS 26 scroll edge effect. No Expo Router header config, no custom blur overlay, no large title requirement.") + .font(.title3) + .foregroundStyle(.secondary) + } + .padding(22) + .frame(maxWidth: .infinity, alignment: .leading) + .background(.thinMaterial, in: RoundedRectangle(cornerRadius: 28, style: .continuous)) + .overlay { + RoundedRectangle(cornerRadius: 28, style: .continuous) + .stroke(.separator.opacity(0.35), lineWidth: 0.5) + } + } + + private var codeCard: some View { + VStack(alignment: .leading, spacing: 0) { + ForEach(Array((glassDebugCodeLines + glassDebugCodeLines).enumerated()), id: \.offset) { index, line in + HStack(alignment: .top, spacing: 16) { + Text("\(index + 1)") + .foregroundStyle(.secondary) + .frame(width: 34, alignment: .trailing) + + Text(line.isEmpty ? " " : line) + .frame(maxWidth: .infinity, alignment: .leading) + } + .font(.system(size: 16, design: .monospaced)) + .lineSpacing(4) + .padding(.horizontal, 16) + .padding(.vertical, 3) + } + } + .padding(.vertical, 14) + .background(.thinMaterial, in: RoundedRectangle(cornerRadius: 24, style: .continuous)) + .overlay { + RoundedRectangle(cornerRadius: 24, style: .continuous) + .stroke(.separator.opacity(0.35), lineWidth: 0.5) + } + } + + private var composer: some View { + HStack(spacing: 10) { + Text(draft) + .foregroundStyle(.secondary) + .lineLimit(1) + Spacer() + Image(systemName: "arrow.up") + .font(.headline.weight(.bold)) + .frame(width: 44, height: 44) + .glassEffect(.regular.interactive(), in: Circle()) + } + .padding(.leading, 18) + .padding(.trailing, 6) + .frame(height: 56) + .glassEffect(.clear.interactive(), in: Capsule()) + .padding(.horizontal) + .padding(.vertical, 8) + } +} + +#Preview { + ContentView() +} diff --git a/experiments/messages-glass-lab/MessagesGlassLab/MessagesGlassLabApp.swift b/experiments/messages-glass-lab/MessagesGlassLab/MessagesGlassLabApp.swift new file mode 100644 index 00000000000..5cf4769194b --- /dev/null +++ b/experiments/messages-glass-lab/MessagesGlassLab/MessagesGlassLabApp.swift @@ -0,0 +1,10 @@ +import SwiftUI + +@main +struct MessagesGlassLabApp: App { + var body: some Scene { + WindowGroup { + ContentView() + } + } +} diff --git a/packages/client-runtime/src/state/threadDetail.ts b/packages/client-runtime/src/state/threadDetail.ts index f048573c2ef..430900bdbb0 100644 --- a/packages/client-runtime/src/state/threadDetail.ts +++ b/packages/client-runtime/src/state/threadDetail.ts @@ -13,7 +13,7 @@ import { AsyncResult, Atom } from "effect/unstable/reactivity"; import type { EnvironmentThread, EnvironmentThreadShell } from "./models.ts"; import { scopeThread } from "./models.ts"; -import { EMPTY_ENVIRONMENT_THREAD_STATE, type EnvironmentThreadState } from "./threads.ts"; +import { EMPTY_ENVIRONMENT_THREAD_STATE, type EnvironmentThreadState } from "./threadState.ts"; import { parseThreadKey, threadKey } from "./entities.ts"; import { THREAD_STATE_IDLE_TTL_MS } from "./threadRetention.ts"; diff --git a/packages/client-runtime/src/state/threadState.ts b/packages/client-runtime/src/state/threadState.ts new file mode 100644 index 00000000000..89be139e925 --- /dev/null +++ b/packages/client-runtime/src/state/threadState.ts @@ -0,0 +1,16 @@ +import type { OrchestrationThread } from "@t3tools/contracts"; +import * as Option from "effect/Option"; + +export type EnvironmentThreadStatus = "empty" | "cached" | "synchronizing" | "live" | "deleted"; + +export interface EnvironmentThreadState { + readonly data: Option.Option; + readonly status: EnvironmentThreadStatus; + readonly error: Option.Option; +} + +export const EMPTY_ENVIRONMENT_THREAD_STATE: EnvironmentThreadState = { + data: Option.none(), + status: "empty", + error: Option.none(), +}; diff --git a/packages/client-runtime/src/state/threads.ts b/packages/client-runtime/src/state/threads.ts index bbb38f8d4be..a01baf1594d 100644 --- a/packages/client-runtime/src/state/threads.ts +++ b/packages/client-runtime/src/state/threads.ts @@ -22,20 +22,11 @@ import { parseThreadKey, threadKey } from "./entities.ts"; import { applyThreadDetailEvent } from "./threadReducer.ts"; import { THREAD_STATE_IDLE_TTL_MS } from "./threadRetention.ts"; import { followStreamInEnvironment } from "./runtime.ts"; - -export type EnvironmentThreadStatus = "empty" | "cached" | "synchronizing" | "live" | "deleted"; - -export interface EnvironmentThreadState { - readonly data: Option.Option; - readonly status: EnvironmentThreadStatus; - readonly error: Option.Option; -} - -export const EMPTY_ENVIRONMENT_THREAD_STATE: EnvironmentThreadState = { - data: Option.none(), - status: "empty", - error: Option.none(), -}; +import { + EMPTY_ENVIRONMENT_THREAD_STATE, + type EnvironmentThreadState, + type EnvironmentThreadStatus, +} from "./threadState.ts"; function statusWithoutLiveData(data: Option.Option): EnvironmentThreadStatus { return Option.isSome(data) ? "cached" : "empty"; @@ -254,3 +245,4 @@ export * from "./threadCommands.ts"; export * from "./threadDetail.ts"; export * from "./threadReducer.ts"; export * from "./threadShell.ts"; +export * from "./threadState.ts"; diff --git a/packages/shared/src/dpop.ts b/packages/shared/src/dpop.ts index 88dcf8e3090..dabfaffa4cd 100644 --- a/packages/shared/src/dpop.ts +++ b/packages/shared/src/dpop.ts @@ -5,22 +5,20 @@ import * as Option from "effect/Option"; import * as Result from "effect/Result"; import * as Schema from "effect/Schema"; +import { DpopPublicJwk as DpopPublicJwkSchema, normalizeDpopHtu } from "./dpopCommon.ts"; +import type { DpopPublicJwk as DpopPublicJwkType } from "./dpopCommon.ts"; import { stableStringify } from "./relaySigning.ts"; const DPOP_TYP = "dpop+jwt"; const DPOP_ALG = "ES256"; const DEFAULT_MAX_AGE_SECONDS = 300; -export const DpopPublicJwk = Schema.Struct({ - kty: Schema.Literal("EC"), - crv: Schema.Literal("P-256"), - x: Schema.String.check(Schema.isNonEmpty()), - y: Schema.String.check(Schema.isNonEmpty()), -}); -export type DpopPublicJwk = typeof DpopPublicJwk.Type; +export const DpopPublicJwk = DpopPublicJwkSchema; +export type DpopPublicJwk = DpopPublicJwkType; +export { normalizeDpopHtu }; const DpopJwtHeaderPublicJwk = Schema.Struct({ - ...DpopPublicJwk.fields, + ...DpopPublicJwkSchema.fields, d: Schema.optionalKey(Schema.Never), }); @@ -68,7 +66,7 @@ function decodeBase64UrlDpopJwtPayload(value: string) { return decodeDpopJwtPayloadJson(Result.getOrThrow(Encoding.decodeBase64UrlString(value))); } -function dpopThumbprintInput(jwk: DpopPublicJwk): string { +function dpopThumbprintInput(jwk: DpopPublicJwkType): string { return stableStringify({ crv: jwk.crv, kty: jwk.kty, @@ -77,18 +75,7 @@ function dpopThumbprintInput(jwk: DpopPublicJwk): string { }); } -export function normalizeDpopHtu(url: string): string | null { - try { - const parsed = new URL(url); - parsed.hash = ""; - parsed.search = ""; - return parsed.toString(); - } catch { - return null; - } -} - -export function computeDpopJwkThumbprint(jwk: DpopPublicJwk): string { +export function computeDpopJwkThumbprint(jwk: DpopPublicJwkType): string { return Encoding.encodeBase64Url(sha256(new TextEncoder().encode(dpopThumbprintInput(jwk)))); } @@ -96,7 +83,7 @@ export function computeDpopAccessTokenHash(accessToken: string): string { return Encoding.encodeBase64Url(sha256(new TextEncoder().encode(accessToken))); } -function publicKeyBytesFromJwk(jwk: DpopPublicJwk): Uint8Array { +function publicKeyBytesFromJwk(jwk: DpopPublicJwkType): Uint8Array { const x = base64UrlToBytes(jwk.x); const y = base64UrlToBytes(jwk.y); if (x.length !== 32 || y.length !== 32) { diff --git a/patches/@expo%2Fmetro-config@56.0.13.patch b/patches/@expo%2Fmetro-config@56.0.14.patch similarity index 100% rename from patches/@expo%2Fmetro-config@56.0.13.patch rename to patches/@expo%2Fmetro-config@56.0.14.patch diff --git a/patches/@react-native-menu__menu@2.0.0.patch b/patches/@react-native-menu__menu@2.0.0.patch new file mode 100644 index 00000000000..f03ef60bb5b --- /dev/null +++ b/patches/@react-native-menu__menu@2.0.0.patch @@ -0,0 +1,46 @@ +diff --git a/ios/Shared/MenuViewImplementation.swift b/ios/Shared/MenuViewImplementation.swift +index 5c4e0da4292b15d3a27b5ea1555f11452a470815..ea19f2eec02dd78fbc78cc455c91b4e71e734d70 100644 +--- a/ios/Shared/MenuViewImplementation.swift ++++ b/ios/Shared/MenuViewImplementation.swift +@@ -88,6 +88,41 @@ public class MenuViewImplementation: UIButton { + + self.menu = menu + self.showsMenuAsPrimaryAction = !shouldOpenOnLongPress ++ // In long-press mode the button must not intercept touches: as a ++ // full-bounds contentView it sits IN FRONT of the React children of ++ // the hosting component view, which would swallow their taps (e.g. a ++ // row Pressable wrapped by MenuView). The context-menu interaction is ++ // hosted by the superview instead (see updateLongPressInteraction) so ++ // long-press and pointer secondary-click still present the menu, with ++ // the wrapped content as the zoom preview. ++ self.isUserInteractionEnabled = !shouldOpenOnLongPress ++ self.updateLongPressInteraction() ++ } ++ ++ private var longPressInteraction: UIContextMenuInteraction? ++ ++ public override func didMoveToSuperview() { ++ super.didMoveToSuperview() ++ self.updateLongPressInteraction() ++ } ++ ++ private func updateLongPressInteraction() { ++ if shouldOpenOnLongPress, let host = self.superview { ++ if let existing = longPressInteraction, existing.view === host { ++ return ++ } ++ if let existing = longPressInteraction { ++ existing.view?.removeInteraction(existing) ++ } ++ let interaction = UIContextMenuInteraction(delegate: self) ++ host.addInteraction(interaction) ++ longPressInteraction = interaction ++ return ++ } ++ if let existing = longPressInteraction { ++ existing.view?.removeInteraction(existing) ++ longPressInteraction = nil ++ } + } + + public override func reactSetFrame(_ frame: CGRect) { diff --git a/patches/@react-navigation%2Fnative-stack@7.17.6.patch b/patches/@react-navigation%2Fnative-stack@7.17.6.patch new file mode 100644 index 00000000000..cd2b86fb76c --- /dev/null +++ b/patches/@react-navigation%2Fnative-stack@7.17.6.patch @@ -0,0 +1,100 @@ +--- a/lib/module/views/useHeaderConfigProps.js ++++ b/lib/module/views/useHeaderConfigProps.js +@@ -19,6 +19,12 @@ + } + return item; + } ++ if (item.type === 'mailSearchToolbar') { ++ return { ++ ...item, ++ index ++ }; ++ } + if (item.type === 'button' || item.type === 'menu') { + if (item.type === 'menu' && item.menu == null) { + throw new Error(`Menu item must have a 'menu' property defined: ${JSON.stringify(item)}`); +@@ -79,7 +85,7 @@ + } + return processedItem; + } +- throw new Error(`Invalid item type: ${JSON.stringify(item)}. Valid types are 'button', 'menu', 'custom' and 'spacing'.`); ++ throw new Error(`Invalid item type: ${JSON.stringify(item)}. Valid types are 'button', 'menu', 'custom', 'spacing' and 'mailSearchToolbar'.`); + }).filter(item => item != null); + }; + const transformIcon = icon => { +@@ -158,8 +164,12 @@ + headerBack, + route, + title, ++ unstable_headerCenterItems: headerCenterItems, + unstable_headerLeftItems: headerLeftItems, +- unstable_headerRightItems: headerRightItems ++ unstable_headerRightItems: headerRightItems, ++ unstable_headerSubtitle: headerSubtitle, ++ unstable_headerToolbarItems: headerToolbarItems, ++ unstable_navigationItemStyle: navigationItemStyle + }) { + const { + direction +@@ -255,6 +265,10 @@ + tintColor, + canGoBack + }); ++ const centerItems = headerCenterItems?.({ ++ tintColor, ++ canGoBack ++ }); + let rightItems = headerRightItems?.({ + tintColor, + canGoBack +@@ -264,6 +278,10 @@ + // So we need to reverse them here to match the order + rightItems = [...rightItems].reverse(); + } ++ const toolbarItems = headerToolbarItems?.({ ++ tintColor, ++ canGoBack ++ }); + const children = /*#__PURE__*/_jsxs(_Fragment, { + children: [Platform.OS === 'ios' ? /*#__PURE__*/_jsxs(_Fragment, { + children: [leftItems ? leftItems.map((item, index) => { +@@ -278,7 +296,15 @@ + return null; + }) : headerLeftElement != null ? /*#__PURE__*/_jsx(ScreenStackHeaderLeftView, { + children: headerLeftElement +- }) : null, headerTitleElement != null ? /*#__PURE__*/_jsx(ScreenStackHeaderCenterView, { ++ }) : null, centerItems ? centerItems.map((item, index) => { ++ if (item.type === 'custom') { ++ return /*#__PURE__*/_jsx(ScreenStackHeaderCenterView, { ++ hidesSharedBackground: item.hidesSharedBackground, ++ children: item.element ++ }, index); ++ } ++ return null; ++ }) : headerTitleElement != null ? /*#__PURE__*/_jsx(ScreenStackHeaderCenterView, { + children: headerTitleElement + }) : null] + }) : /*#__PURE__*/_jsxs(_Fragment, { +@@ -356,6 +382,7 @@ + largeTitleFontWeight, + largeTitleHideShadow: headerLargeTitleShadowVisible === false, + title: titleText, ++ subtitle: headerSubtitle, + titleColor, + titleFontFamily, + titleFontSize, +@@ -364,9 +391,12 @@ + disableTopInsetApplication: !headerTopInsetEnabled, + translucent: translucent === true, + children, ++ navigationItemStyle, ++ headerToolbarItems: processBarButtonItems(toolbarItems, colors, fonts), ++ headerCenterBarButtonItems: processBarButtonItems(centerItems, colors, fonts), + headerLeftBarButtonItems: processBarButtonItems(leftItems, colors, fonts), + headerRightBarButtonItems: processBarButtonItems(rightItems, colors, fonts), + experimental_userInterfaceStyle: dark ? 'dark' : 'light' + }; + } +-//# sourceMappingURL=useHeaderConfigProps.js.map +\ No newline at end of file ++//# sourceMappingURL=useHeaderConfigProps.js.map diff --git a/patches/expo-modules-jsi@56.0.10.patch b/patches/expo-modules-jsi@56.0.10.patch new file mode 100644 index 00000000000..afb1da5caaa --- /dev/null +++ b/patches/expo-modules-jsi@56.0.10.patch @@ -0,0 +1,21 @@ +diff --git a/apple/Sources/ExpoModulesJSI/Runtime/JavaScriptRuntime.swift b/apple/Sources/ExpoModulesJSI/Runtime/JavaScriptRuntime.swift +index a0e3e24d6070c5ec0a220af3cb56fe3373fe1968..d610cc38dd8dee363ea2f1822aea0c4a492cf825 100644 +--- a/apple/Sources/ExpoModulesJSI/Runtime/JavaScriptRuntime.swift ++++ b/apple/Sources/ExpoModulesJSI/Runtime/JavaScriptRuntime.swift +@@ -215,8 +215,15 @@ open class JavaScriptRuntime: Equatable, @unchecked Sendable { + .toOpaque() + // Pass a null setter to C++ when the Swift setter is nil so that JS assignment + // raises a `jsi::JSError` directly, without crossing the Swift boundary. ++ if set == nil { ++ let callbacks = expo.HostObjectCallbacks( ++ context, getter, nil, propertyNamesGetter, deallocate) ++ let hostObject = expo.HostObject.makeObject(pointee, consume callbacks) ++ ++ return JavaScriptObject(self, hostObject) ++ } + let callbacks = expo.HostObjectCallbacks( +- context, getter, set == nil ? nil : setter, propertyNamesGetter, deallocate) ++ context, getter, setter, propertyNamesGetter, deallocate) + let hostObject = expo.HostObject.makeObject(pointee, consume callbacks) + + return JavaScriptObject(self, hostObject) diff --git a/patches/react-native-gesture-handler@2.31.2.patch b/patches/react-native-gesture-handler@2.31.2.patch new file mode 100644 index 00000000000..a4f345e9e33 --- /dev/null +++ b/patches/react-native-gesture-handler@2.31.2.patch @@ -0,0 +1,124 @@ +diff --git a/lib/commonjs/components/ReanimatedSwipeable/ReanimatedSwipeable.js b/lib/commonjs/components/ReanimatedSwipeable/ReanimatedSwipeable.js +index 551ab92bf58db0b79d428dcd2f2df898ef686493..f1c355b8e40cd4f583ef3a501b044fb6770f4a24 100644 +--- a/lib/commonjs/components/ReanimatedSwipeable/ReanimatedSwipeable.js ++++ b/lib/commonjs/components/ReanimatedSwipeable/ReanimatedSwipeable.js +@@ -34,6 +34,7 @@ const Swipeable = props => { + enableTrackpadTwoFingerGesture = DEFAULT_ENABLE_TRACKING_TWO_FINGER_GESTURE, + dragOffsetFromLeftEdge = DEFAULT_DRAG_OFFSET, + dragOffsetFromRightEdge = DEFAULT_DRAG_OFFSET, ++ failOffsetY, + friction = DEFAULT_FRICTION, + overshootFriction = DEFAULT_OVERSHOOT_FRICTION, + onSwipeableOpenStartDrag, +@@ -285,11 +286,14 @@ const Swipeable = props => { + }).onFinalize(() => { + dragStarted.value = false; + }); ++ if (failOffsetY !== undefined) { ++ pan.failOffsetY(failOffsetY); ++ } + Object.entries(relationProps).forEach(([relationName, relation]) => { + (0, _utils.applyRelationProp)(pan, relationName, relation); + }); + return pan; +- }, [enabled, hitSlop, enableTrackpadTwoFingerGesture, dragOffsetFromRightEdge, dragOffsetFromLeftEdge, updateElementWidths, relationProps, userDrag, rowState, dragStarted, updateAnimatedEvent, onSwipeableOpenStartDrag, onSwipeableCloseStartDrag, handleRelease]); ++ }, [enabled, hitSlop, enableTrackpadTwoFingerGesture, dragOffsetFromRightEdge, dragOffsetFromLeftEdge, failOffsetY, updateElementWidths, relationProps, userDrag, rowState, dragStarted, updateAnimatedEvent, onSwipeableOpenStartDrag, onSwipeableCloseStartDrag, handleRelease]); + (0, _react.useImperativeHandle)(ref, () => swipeableMethods, [swipeableMethods]); + const animatedStyle = (0, _reactNativeReanimated.useAnimatedStyle)(() => ({ + transform: [{ +diff --git a/lib/module/components/ReanimatedSwipeable/ReanimatedSwipeable.js b/lib/module/components/ReanimatedSwipeable/ReanimatedSwipeable.js +index a2835d5416ffd5cf9a04e98774516b9e6569691e..b73c177227bf3a232737b0eb1c0e2bb830493c22 100644 +--- a/lib/module/components/ReanimatedSwipeable/ReanimatedSwipeable.js ++++ b/lib/module/components/ReanimatedSwipeable/ReanimatedSwipeable.js +@@ -29,6 +29,7 @@ const Swipeable = props => { + enableTrackpadTwoFingerGesture = DEFAULT_ENABLE_TRACKING_TWO_FINGER_GESTURE, + dragOffsetFromLeftEdge = DEFAULT_DRAG_OFFSET, + dragOffsetFromRightEdge = DEFAULT_DRAG_OFFSET, ++ failOffsetY, + friction = DEFAULT_FRICTION, + overshootFriction = DEFAULT_OVERSHOOT_FRICTION, + onSwipeableOpenStartDrag, +@@ -280,11 +281,14 @@ const Swipeable = props => { + }).onFinalize(() => { + dragStarted.value = false; + }); ++ if (failOffsetY !== undefined) { ++ pan.failOffsetY(failOffsetY); ++ } + Object.entries(relationProps).forEach(([relationName, relation]) => { + applyRelationProp(pan, relationName, relation); + }); + return pan; +- }, [enabled, hitSlop, enableTrackpadTwoFingerGesture, dragOffsetFromRightEdge, dragOffsetFromLeftEdge, updateElementWidths, relationProps, userDrag, rowState, dragStarted, updateAnimatedEvent, onSwipeableOpenStartDrag, onSwipeableCloseStartDrag, handleRelease]); ++ }, [enabled, hitSlop, enableTrackpadTwoFingerGesture, dragOffsetFromRightEdge, dragOffsetFromLeftEdge, failOffsetY, updateElementWidths, relationProps, userDrag, rowState, dragStarted, updateAnimatedEvent, onSwipeableOpenStartDrag, onSwipeableCloseStartDrag, handleRelease]); + useImperativeHandle(ref, () => swipeableMethods, [swipeableMethods]); + const animatedStyle = useAnimatedStyle(() => ({ + transform: [{ +diff --git a/lib/typescript/components/ReanimatedSwipeable/ReanimatedSwipeableProps.d.ts b/lib/typescript/components/ReanimatedSwipeable/ReanimatedSwipeableProps.d.ts +index ac8b76830d468edfbc29052b452a36221323c3de..589e329d2aa706ddfff0d1037df0d85a050edbb0 100644 +--- a/lib/typescript/components/ReanimatedSwipeable/ReanimatedSwipeableProps.d.ts ++++ b/lib/typescript/components/ReanimatedSwipeable/ReanimatedSwipeableProps.d.ts +@@ -64,6 +64,13 @@ export interface SwipeableProps { + * a swipe. The default value is 10. + */ + dragOffsetFromRightEdge?: number; ++ /** ++ * Range along the Y axis (in points) that, once exceeded before the swipe ++ * activates, makes the pan fail. Lets vertically-dominant pans (list ++ * scrolling, especially trackpad two-finger pans) skip the swipe entirely. ++ * Passed straight to the underlying Pan gesture's `failOffsetY`. ++ */ ++ failOffsetY?: number | [start: number, end: number]; + /** + * Value indicating if the swipeable panel can be pulled further than the left + * actions panel's width. It is set to true by default as long as the left +diff --git a/src/components/ReanimatedSwipeable/ReanimatedSwipeable.tsx b/src/components/ReanimatedSwipeable/ReanimatedSwipeable.tsx +index b6134c908624adc590ebd264e90e558b88e235d5..41535348e937ad7762bd531d5b63ba6e092ea997 100644 +--- a/src/components/ReanimatedSwipeable/ReanimatedSwipeable.tsx ++++ b/src/components/ReanimatedSwipeable/ReanimatedSwipeable.tsx +@@ -58,6 +58,7 @@ const Swipeable = (props: SwipeableProps) => { + enableTrackpadTwoFingerGesture = DEFAULT_ENABLE_TRACKING_TWO_FINGER_GESTURE, + dragOffsetFromLeftEdge = DEFAULT_DRAG_OFFSET, + dragOffsetFromRightEdge = DEFAULT_DRAG_OFFSET, ++ failOffsetY, + friction = DEFAULT_FRICTION, + overshootFriction = DEFAULT_OVERSHOOT_FRICTION, + onSwipeableOpenStartDrag, +@@ -537,6 +538,10 @@ const Swipeable = (props: SwipeableProps) => { + dragStarted.value = false; + }); + ++ if (failOffsetY !== undefined) { ++ pan.failOffsetY(failOffsetY); ++ } ++ + Object.entries(relationProps).forEach(([relationName, relation]) => { + applyRelationProp( + pan, +@@ -552,6 +557,7 @@ const Swipeable = (props: SwipeableProps) => { + enableTrackpadTwoFingerGesture, + dragOffsetFromRightEdge, + dragOffsetFromLeftEdge, ++ failOffsetY, + updateElementWidths, + relationProps, + userDrag, +diff --git a/src/components/ReanimatedSwipeable/ReanimatedSwipeableProps.ts b/src/components/ReanimatedSwipeable/ReanimatedSwipeableProps.ts +index 0c0e517e0d340faf50ff78c3d48e7a2bbcf808ec..d4b6ff508077728c8be83762923d866dc95b144c 100644 +--- a/src/components/ReanimatedSwipeable/ReanimatedSwipeableProps.ts ++++ b/src/components/ReanimatedSwipeable/ReanimatedSwipeableProps.ts +@@ -77,6 +77,14 @@ export interface SwipeableProps { + */ + dragOffsetFromRightEdge?: number; + ++ /** ++ * Range along the Y axis (in points) that, once exceeded before the swipe ++ * activates, makes the pan fail. Lets vertically-dominant pans (list ++ * scrolling, especially trackpad two-finger pans) skip the swipe entirely. ++ * Passed straight to the underlying Pan gesture's `failOffsetY`. ++ */ ++ failOffsetY?: number | [start: number, end: number]; ++ + /** + * Value indicating if the swipeable panel can be pulled further than the left + * actions panel's width. It is set to true by default as long as the left diff --git a/patches/react-native-screens@4.25.2.patch b/patches/react-native-screens@4.25.2.patch new file mode 100644 index 00000000000..48457c10a0a --- /dev/null +++ b/patches/react-native-screens@4.25.2.patch @@ -0,0 +1,1542 @@ +diff --git a/ios/RNSBarButtonItem.h b/ios/RNSBarButtonItem.h +index ea5325ea8d17b1ddfa790ff8dab48ce83142d4d3..acd2fd7ceb7162ed300abc4d3c9f0c24f4d63898 100644 +--- a/ios/RNSBarButtonItem.h ++++ b/ios/RNSBarButtonItem.h +@@ -13,4 +13,8 @@ typedef void (^RNSBarButtonMenuItemAction)(NSString *menuId); + menuAction:(RNSBarButtonMenuItemAction)menuAction + imageLoader:(RCTImageLoader *)imageLoader; + +++ (UIMenu *)initUIMenuWithDict:(NSDictionary *)dict ++ menuAction:(RNSBarButtonMenuItemAction)menuAction ++ imageLoader:(RCTImageLoader *)imageLoader; ++ + @end +diff --git a/ios/RNSBarButtonItem.mm b/ios/RNSBarButtonItem.mm +index 0eb1f09dee82edd99e3e614938233db5cdeaebfe..73298f10807520970f625e166d7f5dd1338206f1 100644 +--- a/ios/RNSBarButtonItem.mm ++++ b/ios/RNSBarButtonItem.mm +@@ -110,6 +110,58 @@ - (instancetype)initWithConfig:(NSDictionary *)dict + if (dict[@"accessibilityHint"]) { + self.accessibilityHint = dict[@"accessibilityHint"]; + } ++#if !TARGET_OS_TV && RNS_IPHONE_OS_VERSION_AVAILABLE(26_0) ++ NSNumber *glassEffectNum = dict[@"glassEffect"]; ++ if (@available(iOS 26.0, *)) { ++ if ([glassEffectNum boolValue]) { ++ UIButtonConfiguration *configuration = [UIButtonConfiguration glassButtonConfiguration]; ++ configuration.cornerStyle = UIButtonConfigurationCornerStyleCapsule; ++ UIBackgroundConfiguration *background = configuration.background; ++ background.strokeWidth = 0.0; ++ background.strokeColor = UIColor.clearColor; ++ configuration.background = background; ++ configuration.contentInsets = NSDirectionalEdgeInsetsZero; ++ if (title != nil) { ++ configuration.title = title; ++ } ++ ++ UIButton *button = [UIButton buttonWithConfiguration:configuration primaryAction:nil]; ++ button.translatesAutoresizingMaskIntoConstraints = NO; ++ button.enabled = self.enabled; ++ button.accessibilityLabel = self.accessibilityLabel; ++ button.accessibilityHint = self.accessibilityHint; ++ ++ CGFloat resolvedWidth = width != nil ? width.doubleValue : 56.0; ++ [button.widthAnchor constraintEqualToConstant:resolvedWidth].active = YES; ++ [button.heightAnchor constraintEqualToConstant:50.0].active = YES; ++ ++ [[self class] resolveImageFromConfig:dict ++ imageLoader:imageLoader ++ completionBlock:^(UIImage *img) { ++ UIButtonConfiguration *updatedConfiguration = button.configuration; ++ updatedConfiguration.image = img; ++ button.configuration = updatedConfiguration; ++ }]; ++ ++ NSDictionary *menu = dict[@"menu"]; ++ if (menu) { ++ button.menu = [[self class] initUIMenuWithDict:menu menuAction:menuAction imageLoader:imageLoader]; ++ button.showsMenuAsPrimaryAction = YES; ++ } else { ++ NSString *buttonId = dict[@"buttonId"]; ++ if (buttonId && action) { ++ UIAction *pressAction = [UIAction actionWithHandler:^(__kindof UIAction *_Nonnull sender) { ++ action(buttonId); ++ }]; ++ [button addAction:pressAction forControlEvents:UIControlEventTouchUpInside]; ++ } ++ } ++ ++ self.customView = button; ++ return self; ++ } ++ } ++#endif + + #if !TARGET_OS_TV || __TV_OS_VERSION_MAX_ALLOWED >= 170000 + if (@available(tvOS 17.0, *)) { +diff --git a/ios/RNSScreenStackHeaderConfig.h b/ios/RNSScreenStackHeaderConfig.h +index 919b984edc9f91ee9ac26faf257d8a721e26457c..5bb0cd6736ed6bc51db57e2a9326f758f22c51d8 100644 +--- a/ios/RNSScreenStackHeaderConfig.h ++++ b/ios/RNSScreenStackHeaderConfig.h +@@ -21,6 +21,8 @@ + NS_ASSUME_NONNULL_BEGIN + + @property (nonatomic, retain) NSString *title; ++@property (nonatomic, retain) NSString *subtitle; ++@property (nonatomic, retain) NSString *largeSubtitle; + @property (nonatomic, retain) NSString *titleFontFamily; + @property (nonatomic, retain) NSNumber *titleFontSize; + @property (nonatomic, retain) NSString *titleFontWeight; +@@ -45,9 +47,12 @@ NS_ASSUME_NONNULL_BEGIN + @property (nonatomic) BOOL backButtonInCustomView; + @property (nonatomic) UISemanticContentAttribute direction; + @property (nonatomic) UINavigationItemBackButtonDisplayMode backButtonDisplayMode; ++@property (nonatomic) NSInteger navigationItemStyle; + @property (nonatomic) RNSBlurEffectStyle blurEffect; + @property (nonatomic, copy, nullable) NSArray *> *headerRightBarButtonItems; + @property (nonatomic, copy, nullable) NSArray *> *headerLeftBarButtonItems; ++@property (nonatomic, copy, nullable) NSArray *> *headerCenterBarButtonItems; ++@property (nonatomic, copy, nullable) NSArray *> *headerToolbarItems; + @property (nonatomic, readwrite) BOOL synchronousShadowStateUpdatesEnabled; + + NS_ASSUME_NONNULL_END +diff --git a/ios/RNSScreenStackHeaderConfig.mm b/ios/RNSScreenStackHeaderConfig.mm +index 5970e3e3a624b9498b8dedfc16831df03a274d0c..073a72a68688369f39e54b61d1381b8d652cbb83 100644 +--- a/ios/RNSScreenStackHeaderConfig.mm ++++ b/ios/RNSScreenStackHeaderConfig.mm +@@ -30,6 +30,20 @@ + static const NSNumber *const DEFAULT_TITLE_FONT_SIZE = @17; + static const NSNumber *const DEFAULT_TITLE_LARGE_FONT_SIZE = @34; + ++static NSInteger navigationItemStyleFromCppEquivalent( ++ react::RNSScreenStackHeaderConfigNavigationItemStyle navigationItemStyle) ++{ ++ switch (navigationItemStyle) { ++ case react::RNSScreenStackHeaderConfigNavigationItemStyle::Browser: ++ return UINavigationItemStyleBrowser; ++ case react::RNSScreenStackHeaderConfigNavigationItemStyle::Editor: ++ return UINavigationItemStyleEditor; ++ case react::RNSScreenStackHeaderConfigNavigationItemStyle::Navigator: ++ default: ++ return UINavigationItemStyleNavigator; ++ } ++} ++ + @interface RCTImageLoader (Private) + - (id)imageCache; + @end +@@ -47,6 +61,9 @@ + (BOOL)rnscreens_isBlankOrNull:(NSString *)string + @end + + @interface RNSScreenStackHeaderConfig () ++#if !TARGET_OS_TV ++- (NSArray *)barButtonItemGroupsFromItems:(NSArray *)items; ++#endif + @end + + @implementation RNSScreenStackHeaderConfig { +@@ -81,6 +98,7 @@ - (void)initProps + self.hidden = YES; + _reactSubviews = [NSMutableArray new]; + _backTitleVisible = YES; ++ _navigationItemStyle = UINavigationItemStyleNavigator; + _blurEffect = RNSBlurEffectStyleNone; + } + +@@ -496,6 +514,10 @@ + (void)updateViewController:(UIViewController *)vc + + if (shouldHide) { + navitem.title = config.title; ++ if (@available(iOS 26.0, *)) { ++ navitem.subtitle = config.subtitle; ++ navitem.largeSubtitle = config.largeSubtitle; ++ } + + // Setting navigation bar visibility is split to mitigate iOS 26 bug with bar button items. + [navctr setNavigationBarHidden:YES animated:animated]; +@@ -512,11 +534,19 @@ + (void)updateViewController:(UIViewController *)vc + } + navitem.largeTitleDisplayMode = + config.largeTitle ? UINavigationItemLargeTitleDisplayModeAlways : UINavigationItemLargeTitleDisplayModeNever; ++ ++ if (@available(iOS 16.0, *)) { ++ navitem.style = (UINavigationItemStyle)config.navigationItemStyle; ++ } + #endif + + UINavigationBarAppearance *appearance = [self buildAppearance:vc withConfig:config]; + navitem.standardAppearance = appearance; + navitem.compactAppearance = appearance; ++ if (@available(iOS 26.0, *)) { ++ navitem.subtitle = config.subtitle; ++ navitem.largeSubtitle = config.largeSubtitle; ++ } + + // appearance does not apply to the tvOS so we need to use lagacy customization + #if TARGET_OS_TV +@@ -637,10 +667,286 @@ + (void)updateViewController:(UIViewController *)vc + // This assignment should be done after `navitem.titleView = ...` assignment (iOS 16.0 bug). + // See: https://github.com/software-mansion/react-native-screens/issues/1570 (comments) + navitem.title = config.title; +- navitem.leftBarButtonItems = [config barButtonItemsFromConfigs:config.headerLeftBarButtonItems +- withCurrentItems:navitem.leftBarButtonItems]; +- navitem.rightBarButtonItems = [config barButtonItemsFromConfigs:config.headerRightBarButtonItems +- withCurrentItems:navitem.rightBarButtonItems]; ++ NSArray *leftBarButtonItems = [config barButtonItemsFromConfigs:config.headerLeftBarButtonItems ++ withCurrentItems:navitem.leftBarButtonItems ++ navigationItem:navitem]; ++ NSArray *rightBarButtonItems = [config barButtonItemsFromConfigs:config.headerRightBarButtonItems ++ withCurrentItems:navitem.rightBarButtonItems ++ navigationItem:navitem]; ++ NSArray *centerBarButtonItems = [config barButtonItemsFromConfigs:config.headerCenterBarButtonItems ++ withCurrentItems:@[] ++ navigationItem:navitem]; ++#if !TARGET_OS_TV ++ if (@available(iOS 16.0, *)) { ++ navitem.leadingItemGroups = [config barButtonItemGroupsFromItems:leftBarButtonItems]; ++ navitem.trailingItemGroups = [config barButtonItemGroupsFromItems:rightBarButtonItems]; ++ if (@available(iOS 26.0, *)) { ++ navitem.centerItemGroups = [config barButtonItemGroupsFromItems:centerBarButtonItems]; ++ } ++ navitem.leftBarButtonItems = nil; ++ navitem.rightBarButtonItems = nil; ++ } else { ++ navitem.leftBarButtonItems = leftBarButtonItems; ++ navitem.rightBarButtonItems = rightBarButtonItems; ++ } ++#else ++ navitem.leftBarButtonItems = leftBarButtonItems; ++ navitem.rightBarButtonItems = rightBarButtonItems; ++#endif ++ NSDictionary *mailSearchToolbarConfig = nil; ++ for (NSDictionary *toolbarConfig in config.headerToolbarItems) { ++ if (toolbarConfig[@"mailSearchToolbar"]) { ++ mailSearchToolbarConfig = toolbarConfig; ++ break; ++ } ++ } ++ if (mailSearchToolbarConfig == nil) { ++ for (NSDictionary *rightConfig in config.headerRightBarButtonItems) { ++ if ([rightConfig[@"bottomMailSearchToolbar"] boolValue]) { ++ mailSearchToolbarConfig = rightConfig; ++ break; ++ } ++ } ++ } ++ ++ static NSInteger const RNSMailSearchToolbarViewTag = 260628; ++ UIView *chromeHostView = vc.view; ++ UIView *existingMailSearchToolbar = [chromeHostView viewWithTag:RNSMailSearchToolbarViewTag]; ++ if (existingMailSearchToolbar == nil) { ++ existingMailSearchToolbar = [vc.view viewWithTag:RNSMailSearchToolbarViewTag]; ++ } ++ [existingMailSearchToolbar removeFromSuperview]; ++ ++ if (mailSearchToolbarConfig != nil) { ++#if RNS_IPHONE_OS_VERSION_AVAILABLE(26_0) ++ if (@available(iOS 26.0, *)) { ++ CGFloat horizontalInset = 18.0; ++ NSNumber *width = mailSearchToolbarConfig[@"width"]; ++ CGFloat hostWidth = chromeHostView.bounds.size.width; ++ if (hostWidth <= 0.0) { ++ hostWidth = vc.view.bounds.size.width; ++ } ++ if (hostWidth <= 0.0) { ++ hostWidth = [UIScreen mainScreen].bounds.size.width; ++ } ++ CGFloat fallbackWidth = MIN(560.0, MAX(300.0, hostWidth - (horizontalInset * 2.0))); ++ CGFloat resolvedWidth = width != nil ? width.doubleValue : fallbackWidth; ++ if (hostWidth > 0.0) { ++ resolvedWidth = MIN(resolvedWidth, MAX(260.0, hostWidth - 12.0)); ++ } ++ ++ CGFloat toolbarHeight = 48.0; ++ CGFloat toolbarRadius = toolbarHeight / 2.0; ++ CGFloat buttonSize = toolbarHeight; ++ CGFloat sideButtonReserve = buttonSize + 8.0; ++ CGFloat searchFieldHeight = 42.0; ++ CGFloat toolbarBottomSpacing = 4.0; ++ UIFont *searchFont = [UIFont preferredFontForTextStyle:UIFontTextStyleTitle3]; ++ NSDictionary *placeholderAttributes = @{ ++ NSForegroundColorAttributeName : UIColor.secondaryLabelColor, ++ NSFontAttributeName : searchFont, ++ }; ++ UIKeyboardLayoutGuide *keyboardLayoutGuide = chromeHostView.keyboardLayoutGuide; ++ ++ UIView *toolbarHost = [[UIView alloc] init]; ++ toolbarHost.tag = RNSMailSearchToolbarViewTag; ++ toolbarHost.translatesAutoresizingMaskIntoConstraints = NO; ++ [chromeHostView addSubview:toolbarHost]; ++ // Keyboard avoidance is best-effort: on the iOS 27 beta the keyboard ++ // layout guide no longer rests at the bottom safe-area edge while the ++ // keyboard is hidden, which pushed the toolbar offscreen. The required ++ // resting position is the safe area; the keyboard guide only pulls the ++ // toolbar up when it actually tracks a visible keyboard. ++ NSLayoutConstraint *keyboardAvoidConstraint = ++ [toolbarHost.bottomAnchor constraintEqualToAnchor:keyboardLayoutGuide.topAnchor ++ constant:-toolbarBottomSpacing]; ++ keyboardAvoidConstraint.priority = UILayoutPriorityDefaultHigh; ++ NSLayoutConstraint *restingBottomConstraint = ++ [toolbarHost.bottomAnchor constraintEqualToAnchor:chromeHostView.safeAreaLayoutGuide.bottomAnchor ++ constant:-toolbarBottomSpacing]; ++ restingBottomConstraint.priority = UILayoutPriorityDefaultLow; ++ [NSLayoutConstraint activateConstraints:@[ ++ [toolbarHost.centerXAnchor constraintEqualToAnchor:chromeHostView.centerXAnchor], ++ [toolbarHost.bottomAnchor constraintLessThanOrEqualToAnchor:chromeHostView.safeAreaLayoutGuide.bottomAnchor ++ constant:-toolbarBottomSpacing], ++ keyboardAvoidConstraint, ++ restingBottomConstraint, ++ [toolbarHost.widthAnchor constraintEqualToConstant:resolvedWidth], ++ [toolbarHost.heightAnchor constraintEqualToConstant:toolbarHeight], ++ ]]; ++ [chromeHostView bringSubviewToFront:toolbarHost]; ++ ++ UIGlassEffect *glassEffect = [UIGlassEffect effectWithStyle:UIGlassEffectStyleRegular]; ++ glassEffect.interactive = YES; ++ UIVisualEffectView *glassView = [[UIVisualEffectView alloc] initWithEffect:glassEffect]; ++ glassView.clipsToBounds = YES; ++ glassView.layer.cornerRadius = toolbarRadius; ++ glassView.translatesAutoresizingMaskIntoConstraints = NO; ++ [toolbarHost addSubview:glassView]; ++ BOOL hasFilterButton = ++ mailSearchToolbarConfig[@"filterButtonId"] != nil || mailSearchToolbarConfig[@"filterMenu"] != nil; ++ BOOL hasComposeButton = ++ mailSearchToolbarConfig[@"composeButtonId"] != nil || mailSearchToolbarConfig[@"composeMenu"] != nil; ++ CGFloat glassLeadingInset = hasFilterButton ? sideButtonReserve : 0.0; ++ CGFloat glassTrailingInset = hasComposeButton ? -sideButtonReserve : 0.0; ++ [NSLayoutConstraint activateConstraints:@[ ++ [glassView.leadingAnchor constraintEqualToAnchor:toolbarHost.leadingAnchor constant:glassLeadingInset], ++ [glassView.trailingAnchor constraintEqualToAnchor:toolbarHost.trailingAnchor constant:glassTrailingInset], ++ [glassView.centerYAnchor constraintEqualToAnchor:toolbarHost.centerYAnchor], ++ [glassView.heightAnchor constraintEqualToConstant:toolbarHeight], ++ ]]; ++ ++ void (^emitButtonPress)(NSString *) = ^(NSString *buttonId) { ++ auto eventEmitter = std::static_pointer_cast( ++ config->_eventEmitter); ++ if (eventEmitter && buttonId) { ++ eventEmitter->onPressHeaderBarButtonItem( ++ facebook::react::RNSScreenStackHeaderConfigEventEmitter::OnPressHeaderBarButtonItem{ ++ .buttonId = std::string([buttonId UTF8String])}); ++ } ++ }; ++ ++ void (^emitMenuPress)(NSString *) = ^(NSString *menuId) { ++ auto eventEmitter = std::static_pointer_cast( ++ config->_eventEmitter); ++ if (eventEmitter && menuId) { ++ eventEmitter->onPressHeaderBarButtonMenuItem( ++ facebook::react::RNSScreenStackHeaderConfigEventEmitter::OnPressHeaderBarButtonMenuItem{ ++ .menuId = std::string([menuId UTF8String])}); ++ } ++ }; ++ ++ UIButton *(^makeGlassButton)(NSString *, NSString *, NSDictionary *) = ++ ^UIButton *(NSString *systemImageName, NSString *buttonId, NSDictionary *menuConfig) { ++ UIButtonConfiguration *configuration = [UIButtonConfiguration glassButtonConfiguration]; ++ configuration.cornerStyle = UIButtonConfigurationCornerStyleCapsule; ++ configuration.image = [UIImage systemImageNamed:systemImageName ?: @"circle"]; ++ configuration.baseForegroundColor = UIColor.labelColor; ++ UIButton *button = [UIButton buttonWithConfiguration:configuration primaryAction:nil]; ++ if (menuConfig != nil) { ++ button.menu = [RNSBarButtonItem initUIMenuWithDict:menuConfig ++ menuAction:emitMenuPress ++ imageLoader:config->_imageLoader]; ++ button.showsMenuAsPrimaryAction = YES; ++ } else if (buttonId != nil) { ++ UIAction *action = [UIAction actionWithHandler:^(__kindof UIAction *_Nonnull action) { ++ emitButtonPress(buttonId); ++ }]; ++ [button addAction:action forControlEvents:UIControlEventTouchUpInside]; ++ } ++ return button; ++ }; ++ ++ BOOL useFallbackSearchField = [mailSearchToolbarConfig[@"useFallbackSearchField"] boolValue]; ++ UISearchBar *searchBar = ++ !useFallbackSearchField && navitem.searchController != nil ? navitem.searchController.searchBar : nil; ++ NSString *placeholder = mailSearchToolbarConfig[@"placeholder"]; ++ if (searchBar != nil) { ++ if (placeholder != nil) { ++ searchBar.placeholder = placeholder; ++ } ++ searchBar.hidden = NO; ++ searchBar.alpha = 1.0; ++ searchBar.userInteractionEnabled = YES; ++ searchBar.searchBarStyle = UISearchBarStyleMinimal; ++ searchBar.backgroundImage = [UIImage new]; ++ searchBar.searchTextField.hidden = NO; ++ searchBar.searchTextField.alpha = 1.0; ++ searchBar.searchTextField.backgroundColor = UIColor.clearColor; ++ searchBar.searchTextField.font = searchFont; ++ searchBar.searchTextField.adjustsFontForContentSizeCategory = YES; ++ searchBar.searchTextField.textColor = UIColor.labelColor; ++ searchBar.searchTextField.tintColor = UIColor.labelColor; ++ if (placeholder != nil) { ++ searchBar.searchTextField.attributedPlaceholder = ++ [[NSAttributedString alloc] initWithString:placeholder attributes:placeholderAttributes]; ++ } ++ searchBar.translatesAutoresizingMaskIntoConstraints = NO; ++ [glassView.contentView addSubview:searchBar]; ++ [NSLayoutConstraint activateConstraints:@[ ++ [searchBar.leadingAnchor constraintEqualToAnchor:glassView.contentView.leadingAnchor constant:8.0], ++ [searchBar.trailingAnchor constraintEqualToAnchor:glassView.contentView.trailingAnchor constant:-8.0], ++ [searchBar.centerYAnchor constraintEqualToAnchor:glassView.contentView.centerYAnchor], ++ [searchBar.heightAnchor constraintEqualToConstant:searchFieldHeight], ++ ]]; ++ } else { ++ UISearchTextField *searchField = [UISearchTextField new]; ++ if (placeholder != nil) { ++ searchField.placeholder = placeholder; ++ searchField.attributedPlaceholder = ++ [[NSAttributedString alloc] initWithString:placeholder attributes:placeholderAttributes]; ++ } ++ NSString *searchTextChangeId = mailSearchToolbarConfig[@"searchTextChangeId"]; ++ if (searchTextChangeId != nil) { ++ [searchField addAction:[UIAction actionWithHandler:^(__kindof UIAction *_Nonnull action) { ++ UISearchTextField *field = (UISearchTextField *)action.sender; ++ emitButtonPress([NSString stringWithFormat:@"%@:%@", searchTextChangeId, field.text ?: @""]); ++ }] ++ forControlEvents:UIControlEventEditingChanged]; ++ } ++ searchField.borderStyle = UITextBorderStyleNone; ++ searchField.font = searchFont; ++ searchField.adjustsFontForContentSizeCategory = YES; ++ searchField.textColor = UIColor.labelColor; ++ searchField.tintColor = UIColor.labelColor; ++ searchField.translatesAutoresizingMaskIntoConstraints = NO; ++ [glassView.contentView addSubview:searchField]; ++ [NSLayoutConstraint activateConstraints:@[ ++ [searchField.leadingAnchor constraintEqualToAnchor:glassView.contentView.leadingAnchor constant:14.0], ++ [searchField.trailingAnchor constraintEqualToAnchor:glassView.contentView.trailingAnchor constant:-14.0], ++ [searchField.centerYAnchor constraintEqualToAnchor:glassView.contentView.centerYAnchor], ++ [searchField.heightAnchor constraintEqualToConstant:searchFieldHeight], ++ ]]; ++ } ++ ++ if (hasFilterButton) { ++ UIButton *filterButton = makeGlassButton( ++ mailSearchToolbarConfig[@"filterSystemImageName"] ?: @"line.3.horizontal.decrease", ++ mailSearchToolbarConfig[@"filterButtonId"], ++ mailSearchToolbarConfig[@"filterMenu"]); ++ filterButton.translatesAutoresizingMaskIntoConstraints = NO; ++ [toolbarHost addSubview:filterButton]; ++ [NSLayoutConstraint activateConstraints:@[ ++ [filterButton.leadingAnchor constraintEqualToAnchor:toolbarHost.leadingAnchor], ++ [filterButton.centerYAnchor constraintEqualToAnchor:toolbarHost.centerYAnchor], ++ [filterButton.widthAnchor constraintEqualToConstant:buttonSize], ++ [filterButton.heightAnchor constraintEqualToConstant:buttonSize], ++ ]]; ++ } ++ ++ if (hasComposeButton) { ++ UIButton *composeButton = makeGlassButton( ++ mailSearchToolbarConfig[@"composeSystemImageName"] ?: @"square.and.pencil", ++ mailSearchToolbarConfig[@"composeButtonId"], ++ mailSearchToolbarConfig[@"composeMenu"]); ++ composeButton.translatesAutoresizingMaskIntoConstraints = NO; ++ [toolbarHost addSubview:composeButton]; ++ [NSLayoutConstraint activateConstraints:@[ ++ [composeButton.trailingAnchor constraintEqualToAnchor:toolbarHost.trailingAnchor], ++ [composeButton.centerYAnchor constraintEqualToAnchor:toolbarHost.centerYAnchor], ++ [composeButton.widthAnchor constraintEqualToConstant:buttonSize], ++ [composeButton.heightAnchor constraintEqualToConstant:buttonSize], ++ ]]; ++ } ++ } ++#endif ++ } ++ ++ NSArray *> *navigationToolbarConfigs = config.headerToolbarItems; ++ if (mailSearchToolbarConfig != nil) { ++ navigationToolbarConfigs = @[]; ++ } ++ ++ NSArray *toolbarItems = [config barButtonItemsFromConfigs:navigationToolbarConfigs ++ withCurrentItems:@[] ++ navigationItem:navitem]; ++ if (toolbarItems.count > 0) { ++ vc.toolbarItems = toolbarItems; ++ [navctr setToolbarHidden:NO animated:animated]; ++ } else { ++ vc.toolbarItems = nil; ++ [navctr setToolbarHidden:YES animated:animated]; ++ } + + // Setting navigation bar visibility is split to mitigate iOS 26 bug with bar button items + // (setting nav bar visibility should be done after `navitem.*BarButtonItems`). +@@ -773,6 +1079,7 @@ - (void)applySemanticContentAttributeIfNeededToNavCtrl:(UINavigationController * + + - (NSArray *)barButtonItemsFromConfigs:(NSArray *> *)dicts + withCurrentItems:(NSArray *)currentItems ++ navigationItem:(UINavigationItem *)navitem + { + if (dicts.count == 0) { + return currentItems; +@@ -781,7 +1088,197 @@ - (void)applySemanticContentAttributeIfNeededToNavCtrl:(UINavigationController * + [items addObjectsFromArray:currentItems]; + for (NSUInteger i = 0; i < dicts.count; i++) { + NSDictionary *dict = dicts[i]; +- if (dict[@"buttonId"] || dict[@"menu"]) { ++ if (dict[@"mailSearchToolbar"]) { ++#if RNS_IPHONE_OS_VERSION_AVAILABLE(26_0) ++ if (@available(iOS 26.0, *)) { ++ NSNumber *width = dict[@"width"]; ++ CGFloat resolvedWidth = width != nil ? width.doubleValue : MAX(320.0, [UIScreen mainScreen].bounds.size.width - 56.0); ++ CGFloat resolvedHeight = 58.0; ++ UIView *container = [[UIView alloc] initWithFrame:CGRectMake(0, 0, resolvedWidth, resolvedHeight)]; ++ container.translatesAutoresizingMaskIntoConstraints = NO; ++ [container.widthAnchor constraintEqualToConstant:resolvedWidth].active = YES; ++ [container.heightAnchor constraintEqualToConstant:resolvedHeight].active = YES; ++ ++ UIStackView *stackView = [[UIStackView alloc] initWithFrame:container.bounds]; ++ stackView.axis = UILayoutConstraintAxisHorizontal; ++ stackView.alignment = UIStackViewAlignmentCenter; ++ stackView.distribution = UIStackViewDistributionFill; ++ stackView.spacing = 10.0; ++ stackView.translatesAutoresizingMaskIntoConstraints = NO; ++ [container addSubview:stackView]; ++ [NSLayoutConstraint activateConstraints:@[ ++ [stackView.leadingAnchor constraintEqualToAnchor:container.leadingAnchor], ++ [stackView.trailingAnchor constraintEqualToAnchor:container.trailingAnchor], ++ [stackView.centerYAnchor constraintEqualToAnchor:container.centerYAnchor], ++ [stackView.heightAnchor constraintEqualToConstant:50.0], ++ ]]; ++ ++ void (^emitButtonPress)(NSString *) = ^(NSString *buttonId) { ++ auto eventEmitter = std::static_pointer_cast( ++ self->_eventEmitter); ++ if (eventEmitter && buttonId) { ++ eventEmitter->onPressHeaderBarButtonItem( ++ facebook::react::RNSScreenStackHeaderConfigEventEmitter::OnPressHeaderBarButtonItem{ ++ .buttonId = std::string([buttonId UTF8String])}); ++ } ++ }; ++ ++ void (^emitMenuPress)(NSString *) = ^(NSString *menuId) { ++ auto eventEmitter = std::static_pointer_cast( ++ self->_eventEmitter); ++ if (eventEmitter && menuId) { ++ eventEmitter->onPressHeaderBarButtonMenuItem( ++ facebook::react::RNSScreenStackHeaderConfigEventEmitter::OnPressHeaderBarButtonMenuItem{ ++ .menuId = std::string([menuId UTF8String])}); ++ } ++ }; ++ ++ UIButton *(^makeGlassButton)(NSString *, NSString *, NSDictionary *) = ++ ^UIButton *(NSString *systemImageName, NSString *buttonId, NSDictionary *menuConfig) { ++ UIButtonConfiguration *configuration = [UIButtonConfiguration glassButtonConfiguration]; ++ configuration.cornerStyle = UIButtonConfigurationCornerStyleCapsule; ++ configuration.image = [UIImage systemImageNamed:systemImageName ?: @"circle"]; ++ ++ UIButton *button = [UIButton buttonWithConfiguration:configuration primaryAction:nil]; ++ button.translatesAutoresizingMaskIntoConstraints = NO; ++ [button.widthAnchor constraintEqualToConstant:58.0].active = YES; ++ [button.heightAnchor constraintEqualToConstant:50.0].active = YES; ++ if (menuConfig != nil) { ++ button.menu = [RNSBarButtonItem initUIMenuWithDict:menuConfig ++ menuAction:emitMenuPress ++ imageLoader:self->_imageLoader]; ++ button.showsMenuAsPrimaryAction = YES; ++ } else if (buttonId != nil) { ++ UIAction *action = [UIAction actionWithHandler:^(__kindof UIAction *_Nonnull action) { ++ emitButtonPress(buttonId); ++ }]; ++ [button addAction:action forControlEvents:UIControlEventTouchUpInside]; ++ } ++ return button; ++ }; ++ ++ BOOL hasFilterButton = dict[@"filterButtonId"] != nil || dict[@"filterMenu"] != nil; ++ BOOL hasComposeButton = dict[@"composeButtonId"] != nil || dict[@"composeMenu"] != nil; ++ ++ if (hasFilterButton) { ++ UIButton *filterButton = makeGlassButton( ++ dict[@"filterSystemImageName"] ?: @"line.3.horizontal.decrease", ++ dict[@"filterButtonId"], ++ dict[@"filterMenu"]); ++ [stackView addArrangedSubview:filterButton]; ++ } ++ ++ UISearchBar *searchBar = navitem.searchController != nil ? navitem.searchController.searchBar : [UISearchBar new]; ++ NSString *placeholder = dict[@"placeholder"]; ++ if (placeholder != nil) { ++ searchBar.placeholder = placeholder; ++ } ++ searchBar.searchBarStyle = UISearchBarStyleMinimal; ++ searchBar.translatesAutoresizingMaskIntoConstraints = NO; ++ [searchBar.heightAnchor constraintEqualToConstant:50.0].active = YES; ++ [stackView addArrangedSubview:searchBar]; ++ ++ if (hasComposeButton) { ++ UIButton *composeButton = makeGlassButton( ++ dict[@"composeSystemImageName"] ?: @"square.and.pencil", ++ dict[@"composeButtonId"], ++ dict[@"composeMenu"]); ++ [stackView addArrangedSubview:composeButton]; ++ } ++ ++ UIBarButtonItem *item = [[UIBarButtonItem alloc] initWithCustomView:container]; ++ [items addObject:item]; ++ } ++#endif ++ } else if (dict[@"searchField"]) { ++#if RNS_IPHONE_OS_VERSION_AVAILABLE(26_0) ++ if (@available(iOS 26.0, *)) { ++ NSNumber *width = dict[@"width"]; ++ CGFloat resolvedWidth = width != nil ? width.doubleValue : 280.0; ++ UIView *container = [[UIView alloc] initWithFrame:CGRectMake(0, 0, resolvedWidth, 44.0)]; ++ container.translatesAutoresizingMaskIntoConstraints = NO; ++ [container.widthAnchor constraintEqualToConstant:resolvedWidth].active = YES; ++ [container.heightAnchor constraintEqualToConstant:44.0].active = YES; ++ ++ UISearchTextField *searchField = [UISearchTextField new]; ++ NSString *placeholder = dict[@"placeholder"]; ++ if (placeholder != nil) { ++ searchField.placeholder = placeholder; ++ } ++ searchField.translatesAutoresizingMaskIntoConstraints = NO; ++ [container addSubview:searchField]; ++ [NSLayoutConstraint activateConstraints:@[ ++ [searchField.leadingAnchor constraintEqualToAnchor:container.leadingAnchor], ++ [searchField.trailingAnchor constraintEqualToAnchor:container.trailingAnchor], ++ [searchField.centerYAnchor constraintEqualToAnchor:container.centerYAnchor], ++ [searchField.heightAnchor constraintEqualToConstant:40.0], ++ ]]; ++ ++ UIBarButtonItem *item = [[UIBarButtonItem alloc] initWithCustomView:container]; ++ [items addObject:item]; ++ } ++#endif ++ } else if (dict[@"searchBarPlacement"]) { ++#if RNS_IPHONE_OS_VERSION_AVAILABLE(26_0) ++ if (@available(iOS 26.0, *)) { ++ NSNumber *width = dict[@"width"]; ++ UIBarButtonItem *item = nil; ++ if ([dict[@"activatesSearchController"] boolValue]) { ++ UIButtonConfiguration *configuration = [UIButtonConfiguration glassButtonConfiguration]; ++ configuration.cornerStyle = UIButtonConfigurationCornerStyleCapsule; ++ configuration.image = [UIImage systemImageNamed:@"magnifyingglass"]; ++ configuration.baseForegroundColor = UIColor.labelColor; ++ UIButton *button = [UIButton buttonWithConfiguration:configuration primaryAction:nil]; ++ button.translatesAutoresizingMaskIntoConstraints = NO; ++ [button.widthAnchor constraintEqualToConstant:56.0].active = YES; ++ [button.heightAnchor constraintEqualToConstant:56.0].active = YES; ++ UIAction *action = [UIAction actionWithHandler:^(__kindof UIAction *_Nonnull action) { ++ if (navitem.searchController != nil) { ++ [navitem.searchController setActive:YES]; ++ [navitem.searchController.searchBar becomeFirstResponder]; ++ } ++ }]; ++ [button addAction:action forControlEvents:UIControlEventTouchUpInside]; ++ item = [[UIBarButtonItem alloc] initWithCustomView:button]; ++ } else if ([dict[@"customView"] boolValue] && navitem.searchController != nil) { ++ UISearchBar *searchBar = navitem.searchController.searchBar; ++ CGFloat resolvedWidth = width != nil ? width.doubleValue : searchBar.frame.size.width; ++ CGFloat resolvedHeight = searchBar.frame.size.height > 0 ? searchBar.frame.size.height : 44.0; ++ UIView *container = [[UIView alloc] initWithFrame:CGRectMake(0, 0, resolvedWidth, resolvedHeight)]; ++ container.translatesAutoresizingMaskIntoConstraints = NO; ++ [container.widthAnchor constraintEqualToConstant:resolvedWidth].active = YES; ++ [container.heightAnchor constraintEqualToConstant:resolvedHeight].active = YES; ++ searchBar.frame = container.bounds; ++ searchBar.autoresizingMask = UIViewAutoresizingFlexibleWidth | UIViewAutoresizingFlexibleHeight; ++ [container addSubview:searchBar]; ++ item = [[UIBarButtonItem alloc] initWithCustomView:container]; ++ } else { ++ item = navitem.searchBarPlacementBarButtonItem; ++ if (width != nil) { ++ item.width = width.doubleValue; ++ } ++ } ++ NSNumber *index = dict[@"index"]; ++#if RNS_IPHONE_OS_VERSION_AVAILABLE(26_0) ++ if (@available(iOS 26.0, *)) { ++ NSNumber *sharesBackground = dict[@"sharesBackground"]; ++ if (sharesBackground != nil) { ++ item.sharesBackground = sharesBackground.boolValue; ++ } ++ NSNumber *hidesSharedBackground = dict[@"hidesSharedBackground"]; ++ if (hidesSharedBackground != nil) { ++ item.hidesSharedBackground = hidesSharedBackground.boolValue; ++ } ++ } ++#endif ++ if (index != nil && index.integerValue < items.count) { ++ [items insertObject:item atIndex:index.integerValue]; ++ } else { ++ [items addObject:item]; ++ } ++ } ++#endif ++ } else if (dict[@"buttonId"] || dict[@"menu"]) { + RNSBarButtonItem *item = [[RNSBarButtonItem alloc] initWithConfig:dict + action:^(NSString *buttonId) { + auto eventEmitter = std::static_pointer_cast( +@@ -809,11 +1306,15 @@ - (void)applySemanticContentAttributeIfNeededToNavCtrl:(UINavigationController * + [items addObject:item]; + } + } else if (dict[@"spacing"]) { +- UIBarButtonItem *item = [[UIBarButtonItem alloc] initWithBarButtonSystemItem:UIBarButtonSystemItemFixedSpace +- target:nil +- action:nil]; ++ BOOL flexible = [dict[@"flexible"] boolValue]; ++ UIBarButtonItem *item = [[UIBarButtonItem alloc] ++ initWithBarButtonSystemItem:(flexible ? UIBarButtonSystemItemFlexibleSpace : UIBarButtonSystemItemFixedSpace) ++ target:nil ++ action:nil]; + NSNumber *spacingValue = dict[@"spacing"]; +- item.width = [spacingValue doubleValue]; ++ if (!flexible) { ++ item.width = [spacingValue doubleValue]; ++ } + NSNumber *index = dict[@"index"]; + if (index.integerValue < items.count) { + [items insertObject:item atIndex:index.integerValue]; +@@ -825,6 +1326,47 @@ - (void)applySemanticContentAttributeIfNeededToNavCtrl:(UINavigationController * + return items; + } + ++#if !TARGET_OS_TV ++- (NSArray *)barButtonItemGroupsFromItems:(NSArray *)items ++{ ++ if (items.count == 0) { ++ return @[]; ++ } ++ ++ NSMutableArray *groups = [NSMutableArray array]; ++ NSMutableArray *sharedRun = [NSMutableArray array]; ++ ++ void (^flushSharedRun)(void) = ^{ ++ if (sharedRun.count == 0) { ++ return; ++ } ++ [groups addObject:[UIBarButtonItemGroup fixedGroupWithRepresentativeItem:nil ++ items:[sharedRun copy]]]; ++ [sharedRun removeAllObjects]; ++ }; ++ ++ for (UIBarButtonItem *item in items) { ++ BOOL shouldShareBackground = NO; ++#if RNS_IPHONE_OS_VERSION_AVAILABLE(26_0) ++ if (@available(iOS 26.0, *)) { ++ shouldShareBackground = item.sharesBackground && !item.hidesSharedBackground; ++ } ++#endif ++ ++ if (shouldShareBackground) { ++ [sharedRun addObject:item]; ++ continue; ++ } ++ ++ flushSharedRun(); ++ [groups addObject:[UIBarButtonItemGroup fixedGroupWithRepresentativeItem:nil items:@[ item ]]]; ++ } ++ ++ flushSharedRun(); ++ return groups; ++} ++#endif ++ + RNS_IGNORE_SUPER_CALL_BEGIN + - (void)insertReactSubview:(RNSScreenStackHeaderSubview *)subview atIndex:(NSInteger)atIndex + { +@@ -1013,6 +1555,8 @@ - (void)updateProps:(react::Props::Shared const &)props oldProps:(react::Props:: + } + + _title = RCTNSStringFromStringNilIfEmpty(newScreenProps.title); ++ _subtitle = RCTNSStringFromStringNilIfEmpty(newScreenProps.subtitle); ++ _largeSubtitle = RCTNSStringFromStringNilIfEmpty(newScreenProps.largeSubtitle); + if (newScreenProps.titleFontFamily != oldScreenProps.titleFontFamily) { + _titleFontFamily = RCTNSStringFromStringNilIfEmpty(newScreenProps.titleFontFamily); + } +@@ -1038,6 +1582,7 @@ - (void)updateProps:(react::Props::Shared const &)props oldProps:(react::Props:: + _disableBackButtonMenu = newScreenProps.disableBackButtonMenu; + _backButtonDisplayMode = + [RNSConvert UINavigationItemBackButtonDisplayModeFromCppEquivalent:newScreenProps.backButtonDisplayMode]; ++ _navigationItemStyle = navigationItemStyleFromCppEquivalent(newScreenProps.navigationItemStyle); + + if (newScreenProps.userInterfaceStyle != oldScreenProps.userInterfaceStyle) { + _userInterfaceStyle = [RNSConvert UIUserInterfaceStyleFromCppEquivalent:newScreenProps.userInterfaceStyle]; +@@ -1084,6 +1629,30 @@ - (void)updateProps:(react::Props::Shared const &)props oldProps:(react::Props:: + _headerRightBarButtonItems = array; + } + ++ if (newScreenProps.headerCenterBarButtonItems != oldScreenProps.headerCenterBarButtonItems) { ++ const auto &vec = newScreenProps.headerCenterBarButtonItems; ++ NSMutableArray *> *array = [NSMutableArray arrayWithCapacity:vec.size()]; ++ for (const auto &item : vec) { ++ NSDictionary *dict = [RNSConvert idFromFollyDynamic:item]; ++ if ([dict isKindOfClass:[NSDictionary class]]) { ++ [array addObject:dict]; ++ } ++ } ++ _headerCenterBarButtonItems = array; ++ } ++ ++ if (newScreenProps.headerToolbarItems != oldScreenProps.headerToolbarItems) { ++ const auto &vec = newScreenProps.headerToolbarItems; ++ NSMutableArray *> *array = [NSMutableArray arrayWithCapacity:vec.size()]; ++ for (const auto &item : vec) { ++ NSDictionary *dict = [RNSConvert idFromFollyDynamic:item]; ++ if ([dict isKindOfClass:[NSDictionary class]]) { ++ [array addObject:dict]; ++ } ++ } ++ _headerToolbarItems = array; ++ } ++ + [self updateViewControllerIfNeeded]; + + if (needsNavigationControllerLayout) { +diff --git a/lib/commonjs/components/ScreenStackHeaderConfig.js b/lib/commonjs/components/ScreenStackHeaderConfig.js +index 16b979bb3dfb41ff247403f1632c300a9a60d549..01415d4cba66086a8d8e5af728f809aee5190d91 100644 +--- a/lib/commonjs/components/ScreenStackHeaderConfig.js ++++ b/lib/commonjs/components/ScreenStackHeaderConfig.js +@@ -23,18 +23,42 @@ const ScreenStackHeaderConfig = exports.ScreenStackHeaderConfig = /*#__PURE__*/_ + } = (0, _TopInsetApplicationContext.useTopInsetApplication)(!props.hidden, props.disableTopInsetApplication ?? false); + const { + headerLeftBarButtonItems, +- headerRightBarButtonItems ++ headerRightBarButtonItems, ++ headerCenterBarButtonItems, ++ headerToolbarItems + } = props; + const preparedHeaderLeftBarButtonItems = headerLeftBarButtonItems && _utils.isHeaderBarButtonsAvailableForCurrentPlatform ? (0, _prepareHeaderBarButtonItems.prepareHeaderBarButtonItems)(headerLeftBarButtonItems, 'left') : undefined; + const preparedHeaderRightBarButtonItems = headerRightBarButtonItems && _utils.isHeaderBarButtonsAvailableForCurrentPlatform ? (0, _prepareHeaderBarButtonItems.prepareHeaderBarButtonItems)(headerRightBarButtonItems, 'right') : undefined; +- const hasHeaderBarButtonItems = _utils.isHeaderBarButtonsAvailableForCurrentPlatform && (preparedHeaderLeftBarButtonItems?.length || preparedHeaderRightBarButtonItems?.length); ++ const preparedHeaderCenterBarButtonItems = headerCenterBarButtonItems && _utils.isHeaderBarButtonsAvailableForCurrentPlatform ? (0, _prepareHeaderBarButtonItems.prepareHeaderBarButtonItems)(headerCenterBarButtonItems, 'right') : undefined; ++ const preparedHeaderToolbarItems = headerToolbarItems && _utils.isHeaderBarButtonsAvailableForCurrentPlatform ? (0, _prepareHeaderBarButtonItems.prepareHeaderBarButtonItems)(headerToolbarItems, 'right') : undefined; ++ const hasHeaderBarButtonItems = _utils.isHeaderBarButtonsAvailableForCurrentPlatform && (preparedHeaderLeftBarButtonItems?.length || preparedHeaderRightBarButtonItems?.length || preparedHeaderCenterBarButtonItems?.length || preparedHeaderToolbarItems?.length); + + // Handle bar button item presses + const onPressHeaderBarButtonItem = hasHeaderBarButtonItems ? event => { +- const pressedItem = [...(preparedHeaderLeftBarButtonItems ?? []), ...(preparedHeaderRightBarButtonItems ?? [])].find(item => item && 'buttonId' in item && item.buttonId === event.nativeEvent.buttonId); ++ const buttonId = event.nativeEvent.buttonId; ++ const allItems = [...(preparedHeaderLeftBarButtonItems ?? []), ...(preparedHeaderRightBarButtonItems ?? []), ...(preparedHeaderCenterBarButtonItems ?? []), ...(preparedHeaderToolbarItems ?? [])]; ++ const pressedItem = allItems.find(item => item && 'buttonId' in item && item.buttonId === buttonId); + if (pressedItem && pressedItem.type === 'button' && pressedItem.onPress) { + pressedItem.onPress(); + } ++ for (const item of allItems) { ++ if (!item || item.type !== 'mailSearchToolbar') { ++ continue; ++ } ++ if (item.filterButtonId === buttonId) { ++ item.onFilterPress?.(); ++ return; ++ } ++ if (item.composeButtonId === buttonId) { ++ item.onComposePress?.(); ++ return; ++ } ++ const searchTextChangePrefix = item.searchTextChangeId ? `${item.searchTextChangeId}:` : undefined; ++ if (searchTextChangePrefix && buttonId.startsWith(searchTextChangePrefix)) { ++ item.onSearchTextChange?.(buttonId.slice(searchTextChangePrefix.length)); ++ return; ++ } ++ } + } : undefined; + + // Handle bar button menu item presses by deep-searching nested menus +@@ -56,7 +80,7 @@ const ScreenStackHeaderConfig = exports.ScreenStackHeaderConfig = /*#__PURE__*/_ + }; + + // Check each bar-button item with a menu +- const allItems = [...(preparedHeaderLeftBarButtonItems ?? []), ...(preparedHeaderRightBarButtonItems ?? [])]; ++ const allItems = [...(preparedHeaderLeftBarButtonItems ?? []), ...(preparedHeaderRightBarButtonItems ?? []), ...(preparedHeaderCenterBarButtonItems ?? []), ...(preparedHeaderToolbarItems ?? [])]; + for (const item of allItems) { + if (item && item.type === 'menu' && item.menu) { + const action = findInMenu(item.menu, event.nativeEvent.menuId); +@@ -64,6 +88,15 @@ const ScreenStackHeaderConfig = exports.ScreenStackHeaderConfig = /*#__PURE__*/_ + action.onPress(); + return; + } ++ } else if (item && item.type === 'mailSearchToolbar') { ++ const toolbarMenus = [item.filterMenu, item.composeMenu].filter(Boolean); ++ for (const toolbarMenu of toolbarMenus) { ++ const action = findInMenu(toolbarMenu, event.nativeEvent.menuId); ++ if (action) { ++ action.onPress(); ++ return; ++ } ++ } + } + } + } : undefined; +@@ -71,6 +104,8 @@ const ScreenStackHeaderConfig = exports.ScreenStackHeaderConfig = /*#__PURE__*/_ + userInterfaceStyle: props.experimental_userInterfaceStyle, + headerLeftBarButtonItems: preparedHeaderLeftBarButtonItems, + headerRightBarButtonItems: preparedHeaderRightBarButtonItems, ++ headerCenterBarButtonItems: preparedHeaderCenterBarButtonItems, ++ headerToolbarItems: preparedHeaderToolbarItems, + onPressHeaderBarButtonItem: onPressHeaderBarButtonItem, + onPressHeaderBarButtonMenuItem: onPressHeaderBarButtonMenuItem, + ref: ref, +diff --git a/lib/commonjs/components/helpers/prepareHeaderBarButtonItems.js b/lib/commonjs/components/helpers/prepareHeaderBarButtonItems.js +index ab93f62e4a7049d63c6681cecbd6cf8b1d07ce10..b5ea122473b023f84eabdb1914aef09a28c6d525 100644 +--- a/lib/commonjs/components/helpers/prepareHeaderBarButtonItems.js ++++ b/lib/commonjs/components/helpers/prepareHeaderBarButtonItems.js +@@ -41,10 +41,31 @@ const prepareMenu = (menu, index, side, path = '') => { + }; + }; + const prepareHeaderBarButtonItems = (barButtonItems, side) => { +- return barButtonItems?.map((item, index) => { ++ const items = Array.isArray(barButtonItems) ? barButtonItems : barButtonItems && typeof barButtonItems === 'object' && 'type' in barButtonItems ? [barButtonItems] : undefined; ++ return items?.map((item, index) => { + if (item.type === 'spacing') { + return item; + } ++ if (item.type === 'searchBarPlacement') { ++ return { ++ ...item, ++ searchBarPlacement: true ++ }; ++ } ++ if (item.type === 'searchField') { ++ return { ++ ...item, ++ searchField: true ++ }; ++ } ++ if (item.type === 'mailSearchToolbar') { ++ return { ++ ...item, ++ mailSearchToolbar: true, ++ filterMenu: item.filterMenu ? prepareMenu(item.filterMenu, index, side, 'filter') : undefined, ++ composeMenu: item.composeMenu ? prepareMenu(item.composeMenu, index, side, 'compose') : undefined ++ }; ++ } + let imageSource, templateSource; + if (item.icon?.type === 'imageSource') { + imageSource = _reactNative.Image.resolveAssetSource(item.icon.imageSource); +diff --git a/lib/module/components/ScreenStackHeaderConfig.js b/lib/module/components/ScreenStackHeaderConfig.js +index cf15f36f25e51d95c896fbc3a31ccba90156a5b6..dd66167c4ab0e5640ad27400e08dcfb4d20dce2a 100644 +--- a/lib/module/components/ScreenStackHeaderConfig.js ++++ b/lib/module/components/ScreenStackHeaderConfig.js +@@ -19,18 +19,42 @@ export const ScreenStackHeaderConfig = /*#__PURE__*/React.forwardRef((props, ref + } = useTopInsetApplication(!props.hidden, props.disableTopInsetApplication ?? false); + const { + headerLeftBarButtonItems, +- headerRightBarButtonItems ++ headerRightBarButtonItems, ++ headerCenterBarButtonItems, ++ headerToolbarItems + } = props; + const preparedHeaderLeftBarButtonItems = headerLeftBarButtonItems && isHeaderBarButtonsAvailableForCurrentPlatform ? prepareHeaderBarButtonItems(headerLeftBarButtonItems, 'left') : undefined; + const preparedHeaderRightBarButtonItems = headerRightBarButtonItems && isHeaderBarButtonsAvailableForCurrentPlatform ? prepareHeaderBarButtonItems(headerRightBarButtonItems, 'right') : undefined; +- const hasHeaderBarButtonItems = isHeaderBarButtonsAvailableForCurrentPlatform && (preparedHeaderLeftBarButtonItems?.length || preparedHeaderRightBarButtonItems?.length); ++ const preparedHeaderCenterBarButtonItems = headerCenterBarButtonItems && isHeaderBarButtonsAvailableForCurrentPlatform ? prepareHeaderBarButtonItems(headerCenterBarButtonItems, 'right') : undefined; ++ const preparedHeaderToolbarItems = headerToolbarItems && isHeaderBarButtonsAvailableForCurrentPlatform ? prepareHeaderBarButtonItems(headerToolbarItems, 'right') : undefined; ++ const hasHeaderBarButtonItems = isHeaderBarButtonsAvailableForCurrentPlatform && (preparedHeaderLeftBarButtonItems?.length || preparedHeaderRightBarButtonItems?.length || preparedHeaderCenterBarButtonItems?.length || preparedHeaderToolbarItems?.length); + + // Handle bar button item presses + const onPressHeaderBarButtonItem = hasHeaderBarButtonItems ? event => { +- const pressedItem = [...(preparedHeaderLeftBarButtonItems ?? []), ...(preparedHeaderRightBarButtonItems ?? [])].find(item => item && 'buttonId' in item && item.buttonId === event.nativeEvent.buttonId); ++ const buttonId = event.nativeEvent.buttonId; ++ const allItems = [...(preparedHeaderLeftBarButtonItems ?? []), ...(preparedHeaderRightBarButtonItems ?? []), ...(preparedHeaderCenterBarButtonItems ?? []), ...(preparedHeaderToolbarItems ?? [])]; ++ const pressedItem = allItems.find(item => item && 'buttonId' in item && item.buttonId === buttonId); + if (pressedItem && pressedItem.type === 'button' && pressedItem.onPress) { + pressedItem.onPress(); + } ++ for (const item of allItems) { ++ if (!item || item.type !== 'mailSearchToolbar') { ++ continue; ++ } ++ if (item.filterButtonId === buttonId) { ++ item.onFilterPress?.(); ++ return; ++ } ++ if (item.composeButtonId === buttonId) { ++ item.onComposePress?.(); ++ return; ++ } ++ const searchTextChangePrefix = item.searchTextChangeId ? `${item.searchTextChangeId}:` : undefined; ++ if (searchTextChangePrefix && buttonId.startsWith(searchTextChangePrefix)) { ++ item.onSearchTextChange?.(buttonId.slice(searchTextChangePrefix.length)); ++ return; ++ } ++ } + } : undefined; + + // Handle bar button menu item presses by deep-searching nested menus +@@ -52,7 +76,7 @@ export const ScreenStackHeaderConfig = /*#__PURE__*/React.forwardRef((props, ref + }; + + // Check each bar-button item with a menu +- const allItems = [...(preparedHeaderLeftBarButtonItems ?? []), ...(preparedHeaderRightBarButtonItems ?? [])]; ++ const allItems = [...(preparedHeaderLeftBarButtonItems ?? []), ...(preparedHeaderRightBarButtonItems ?? []), ...(preparedHeaderCenterBarButtonItems ?? []), ...(preparedHeaderToolbarItems ?? [])]; + for (const item of allItems) { + if (item && item.type === 'menu' && item.menu) { + const action = findInMenu(item.menu, event.nativeEvent.menuId); +@@ -60,6 +84,15 @@ export const ScreenStackHeaderConfig = /*#__PURE__*/React.forwardRef((props, ref + action.onPress(); + return; + } ++ } else if (item && item.type === 'mailSearchToolbar') { ++ const toolbarMenus = [item.filterMenu, item.composeMenu].filter(Boolean); ++ for (const toolbarMenu of toolbarMenus) { ++ const action = findInMenu(toolbarMenu, event.nativeEvent.menuId); ++ if (action) { ++ action.onPress(); ++ return; ++ } ++ } + } + } + } : undefined; +@@ -67,6 +100,8 @@ export const ScreenStackHeaderConfig = /*#__PURE__*/React.forwardRef((props, ref + userInterfaceStyle: props.experimental_userInterfaceStyle, + headerLeftBarButtonItems: preparedHeaderLeftBarButtonItems, + headerRightBarButtonItems: preparedHeaderRightBarButtonItems, ++ headerCenterBarButtonItems: preparedHeaderCenterBarButtonItems, ++ headerToolbarItems: preparedHeaderToolbarItems, + onPressHeaderBarButtonItem: onPressHeaderBarButtonItem, + onPressHeaderBarButtonMenuItem: onPressHeaderBarButtonMenuItem, + ref: ref, +diff --git a/lib/module/components/helpers/prepareHeaderBarButtonItems.js b/lib/module/components/helpers/prepareHeaderBarButtonItems.js +index 8a70ffd78617147418d628c03c580bb7ff9a8a72..8dbd0ba2ee61ee54d44cdf286a720ce26af01f26 100644 +--- a/lib/module/components/helpers/prepareHeaderBarButtonItems.js ++++ b/lib/module/components/helpers/prepareHeaderBarButtonItems.js +@@ -35,10 +35,31 @@ const prepareMenu = (menu, index, side, path = '') => { + }; + }; + export const prepareHeaderBarButtonItems = (barButtonItems, side) => { +- return barButtonItems?.map((item, index) => { ++ const items = Array.isArray(barButtonItems) ? barButtonItems : barButtonItems && typeof barButtonItems === 'object' && 'type' in barButtonItems ? [barButtonItems] : undefined; ++ return items?.map((item, index) => { + if (item.type === 'spacing') { + return item; + } ++ if (item.type === 'searchBarPlacement') { ++ return { ++ ...item, ++ searchBarPlacement: true ++ }; ++ } ++ if (item.type === 'searchField') { ++ return { ++ ...item, ++ searchField: true ++ }; ++ } ++ if (item.type === 'mailSearchToolbar') { ++ return { ++ ...item, ++ mailSearchToolbar: true, ++ filterMenu: item.filterMenu ? prepareMenu(item.filterMenu, index, side, 'filter') : undefined, ++ composeMenu: item.composeMenu ? prepareMenu(item.composeMenu, index, side, 'compose') : undefined ++ }; ++ } + let imageSource, templateSource; + if (item.icon?.type === 'imageSource') { + imageSource = Image.resolveAssetSource(item.icon.imageSource); +diff --git a/lib/typescript/fabric/ScreenStackHeaderConfigNativeComponent.d.ts b/lib/typescript/fabric/ScreenStackHeaderConfigNativeComponent.d.ts +index b7568ecfebd4f4420f2a8d3fec5184d7fc728dd1..41ca1f273f0175929e73105faa463978323837e3 100644 +--- a/lib/typescript/fabric/ScreenStackHeaderConfigNativeComponent.d.ts ++++ b/lib/typescript/fabric/ScreenStackHeaderConfigNativeComponent.d.ts +@@ -9,6 +9,7 @@ type OnPressHeaderBarButtonMenuItemEvent = Readonly<{ + menuId: string; + }>; + type BackButtonDisplayMode = 'minimal' | 'default' | 'generic'; ++type NavigationItemStyle = 'navigator' | 'browser' | 'editor'; + type BlurEffect = 'none' | 'extraLight' | 'light' | 'dark' | 'regular' | 'prominent' | 'systemUltraThinMaterial' | 'systemThinMaterial' | 'systemMaterial' | 'systemThickMaterial' | 'systemChromeMaterial' | 'systemUltraThinMaterialLight' | 'systemThinMaterialLight' | 'systemMaterialLight' | 'systemThickMaterialLight' | 'systemChromeMaterialLight' | 'systemUltraThinMaterialDark' | 'systemThinMaterialDark' | 'systemMaterialDark' | 'systemThickMaterialDark' | 'systemChromeMaterialDark'; + type UserInterfaceStyle = 'unspecified' | 'light' | 'dark'; + export interface NativeProps extends ViewProps { +@@ -32,18 +33,23 @@ export interface NativeProps extends ViewProps { + largeTitleColor?: ColorValue | undefined; + translucent?: boolean | undefined; + title?: string | undefined; ++ subtitle?: string | undefined; ++ largeSubtitle?: string | undefined; + titleFontFamily?: string | undefined; + titleFontSize?: CT.Int32 | undefined; + titleFontWeight?: string | undefined; + titleColor?: ColorValue | undefined; + disableBackButtonMenu?: boolean | undefined; + backButtonDisplayMode?: CT.WithDefault; ++ navigationItemStyle?: CT.WithDefault; + hideBackButton?: boolean | undefined; + backButtonInCustomView?: boolean | undefined; + blurEffect?: CT.WithDefault; + topInsetEnabled?: boolean | undefined; + headerLeftBarButtonItems?: CT.UnsafeMixed[] | undefined; + headerRightBarButtonItems?: CT.UnsafeMixed[] | undefined; ++ headerCenterBarButtonItems?: CT.UnsafeMixed[] | undefined; ++ headerToolbarItems?: CT.UnsafeMixed[] | undefined; + onPressHeaderBarButtonItem?: CT.DirectEventHandler | undefined; + onPressHeaderBarButtonMenuItem?: CT.DirectEventHandler | undefined; + synchronousShadowStateUpdatesEnabled?: CT.WithDefault; +diff --git a/lib/typescript/types.d.ts b/lib/typescript/types.d.ts +index 3b384e03891e38e936f370372a682d73440e7ec2..1e3a966806c8b8b312f75fb367f758efe49dde9d 100644 +--- a/lib/typescript/types.d.ts ++++ b/lib/typescript/types.d.ts +@@ -10,6 +10,7 @@ export type SearchBarCommands = { + cancelSearch: () => void; + }; + export type BackButtonDisplayMode = 'default' | 'generic' | 'minimal'; ++export type NavigationItemStyle = 'navigator' | 'browser' | 'editor'; + export type StackPresentationTypes = 'push' | 'modal' | 'transparentModal' | 'containedModal' | 'containedTransparentModal' | 'fullScreenModal' | 'formSheet' | 'pageSheet'; + 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'; + export type BlurEffectTypes = BlurEffect; +@@ -631,6 +632,14 @@ export interface ScreenStackHeaderConfigProps extends ViewProps { + * @platform ios + */ + backButtonDisplayMode?: BackButtonDisplayMode | undefined; ++ /** ++ * Controls the UIKit `UINavigationItem.style` used by native iOS headers. ++ * ++ * `editor` matches modern document/editor-style bars with leading-aligned compact titles and glass controls. ++ * ++ * @platform ios ++ */ ++ navigationItemStyle?: NavigationItemStyle | undefined; + /** + * Array of UIBarButtomItems to the left side of the header. + * +@@ -643,6 +652,26 @@ export interface ScreenStackHeaderConfigProps extends ViewProps { + * @platform ios + */ + headerRightBarButtonItems?: HeaderBarButtonItem[] | undefined; ++ /** ++ * Array of UIBarButtonItems to the centered item group in modern iOS headers. ++ * ++ * This maps to `UINavigationItem.centerItemGroups` on iOS 26+ and lets ++ * document/editor-style chrome keep primary actions visually centered while ++ * leaving trailing search separate. ++ * ++ * @platform ios ++ */ ++ headerCenterBarButtonItems?: HeaderBarButtonItem[] | undefined; ++ /** ++ * Array of UIBarButtonItems to display in the native toolbar attached to ++ * the current screen's navigation controller. ++ * ++ * On iOS 26+, this can contain a `searchBarPlacement` item to let UIKit ++ * render integrated Mail-style search chrome using the attached SearchBar. ++ * ++ * @platform ios ++ */ ++ headerToolbarItems?: HeaderBarButtonItem[] | undefined; + /** + * 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. + */ +@@ -704,6 +733,20 @@ export interface ScreenStackHeaderConfigProps extends ViewProps { + * String that can be displayed in the header as a fallback for `headerTitle`. + */ + title?: string | undefined; ++ /** ++ * String displayed below the compact navigation title on iOS 26+. ++ * ++ * @platform ios ++ */ ++ subtitle?: string | undefined; ++ /** ++ * String displayed below the large navigation title on iOS 26+. ++ * ++ * Falls back to `subtitle` when omitted. ++ * ++ * @platform ios ++ */ ++ largeSubtitle?: string | undefined; + /** + * Allows for setting text color of the title. + */ +@@ -1001,6 +1044,11 @@ interface SharedHeaderBarButtonItem { + * Read more: https://developer.apple.com/documentation/uikit/uibarbuttonitem/style-swift.property + */ + variant?: 'plain' | 'done' | 'prominent' | undefined; ++ /** ++ * Render the item with UIButtonConfiguration.glassButtonConfiguration. ++ * Only available from iOS 26.0 and later. ++ */ ++ glassEffect?: boolean | undefined; + /** + * The tint color to apply to the item. + * +@@ -1145,8 +1193,37 @@ export interface HeaderBarButtonItemWithMenu extends SharedHeaderBarButtonItem { + export interface HeaderBarButtonItemSpacing { + type: 'spacing'; + spacing: number; ++ flexible?: boolean | undefined; ++} ++export interface HeaderBarButtonSearchBarPlacementItem { ++ type: 'searchBarPlacement'; ++ activatesSearchController?: boolean | undefined; ++ customView?: boolean | undefined; ++ index?: number | undefined; ++ width?: number | undefined; ++} ++export interface HeaderBarButtonSearchFieldItem { ++ type: 'searchField'; ++ placeholder?: string | undefined; ++ width?: number | undefined; ++} ++export interface HeaderBarButtonMailSearchToolbarItem { ++ type: 'mailSearchToolbar'; ++ composeButtonId?: string | undefined; ++ composeMenu?: HeaderBarButtonItemWithMenu['menu'] | undefined; ++ composeSystemImageName?: string | undefined; ++ filterButtonId?: string | undefined; ++ filterMenu?: HeaderBarButtonItemWithMenu['menu'] | undefined; ++ filterSystemImageName?: string | undefined; ++ onComposePress?: (() => void) | undefined; ++ onFilterPress?: (() => void) | undefined; ++ onSearchTextChange?: ((text: string) => void) | undefined; ++ placeholder?: string | undefined; ++ searchTextChangeId?: string | undefined; ++ useFallbackSearchField?: boolean | undefined; ++ width?: number | undefined; + } +-export type HeaderBarButtonItem = HeaderBarButtonItemWithAction | HeaderBarButtonItemWithMenu | HeaderBarButtonItemSpacing; ++export type HeaderBarButtonItem = HeaderBarButtonItemWithAction | HeaderBarButtonItemWithMenu | HeaderBarButtonMailSearchToolbarItem | HeaderBarButtonSearchFieldItem | HeaderBarButtonSearchBarPlacementItem | HeaderBarButtonItemSpacing; + /** + * Custom Screen Transition + */ +diff --git a/src/components/ScreenStackHeaderConfig.tsx b/src/components/ScreenStackHeaderConfig.tsx +index 421b3c2545426ae957271bd6515bcf827437541c..0ca6f1d7f324eaf257891a3bbb0aab210aa30f6f 100644 +--- a/src/components/ScreenStackHeaderConfig.tsx ++++ b/src/components/ScreenStackHeaderConfig.tsx +@@ -39,7 +39,12 @@ export const ScreenStackHeaderConfig = React.forwardRef< + props.disableTopInsetApplication ?? false, + ); + +- const { headerLeftBarButtonItems, headerRightBarButtonItems } = props; ++ const { ++ headerLeftBarButtonItems, ++ headerRightBarButtonItems, ++ headerCenterBarButtonItems, ++ headerToolbarItems, ++ } = props; + + const preparedHeaderLeftBarButtonItems = + headerLeftBarButtonItems && isHeaderBarButtonsAvailableForCurrentPlatform +@@ -49,22 +54,36 @@ export const ScreenStackHeaderConfig = React.forwardRef< + headerRightBarButtonItems && isHeaderBarButtonsAvailableForCurrentPlatform + ? prepareHeaderBarButtonItems(headerRightBarButtonItems, 'right') + : undefined; ++ const preparedHeaderCenterBarButtonItems = ++ headerCenterBarButtonItems && isHeaderBarButtonsAvailableForCurrentPlatform ++ ? prepareHeaderBarButtonItems(headerCenterBarButtonItems, 'right') ++ : undefined; ++ const preparedHeaderToolbarItems = ++ headerToolbarItems && isHeaderBarButtonsAvailableForCurrentPlatform ++ ? prepareHeaderBarButtonItems(headerToolbarItems, 'right') ++ : undefined; + const hasHeaderBarButtonItems = + isHeaderBarButtonsAvailableForCurrentPlatform && + (preparedHeaderLeftBarButtonItems?.length || +- preparedHeaderRightBarButtonItems?.length); ++ preparedHeaderRightBarButtonItems?.length || ++ preparedHeaderCenterBarButtonItems?.length || ++ preparedHeaderToolbarItems?.length); + + // Handle bar button item presses + const onPressHeaderBarButtonItem = hasHeaderBarButtonItems + ? (event: NativeSyntheticEvent<{ buttonId: string }>) => { +- const pressedItem = [ ++ const buttonId = event.nativeEvent.buttonId; ++ const allItems = [ + ...(preparedHeaderLeftBarButtonItems ?? []), + ...(preparedHeaderRightBarButtonItems ?? []), +- ].find( ++ ...(preparedHeaderCenterBarButtonItems ?? []), ++ ...(preparedHeaderToolbarItems ?? []), ++ ]; ++ const pressedItem = allItems.find( + item => + item && + 'buttonId' in item && +- item.buttonId === event.nativeEvent.buttonId, ++ item.buttonId === buttonId, + ); + if ( + pressedItem && +@@ -73,6 +92,31 @@ export const ScreenStackHeaderConfig = React.forwardRef< + ) { + pressedItem.onPress(); + } ++ for (const item of allItems) { ++ if (!item || item.type !== 'mailSearchToolbar') { ++ continue; ++ } ++ if (item.filterButtonId === buttonId) { ++ item.onFilterPress?.(); ++ return; ++ } ++ if (item.composeButtonId === buttonId) { ++ item.onComposePress?.(); ++ return; ++ } ++ const searchTextChangePrefix = item.searchTextChangeId ++ ? `${item.searchTextChangeId}:` ++ : undefined; ++ if ( ++ searchTextChangePrefix && ++ buttonId.startsWith(searchTextChangePrefix) ++ ) { ++ item.onSearchTextChange?.( ++ buttonId.slice(searchTextChangePrefix.length), ++ ); ++ return; ++ } ++ } + } + : undefined; + +@@ -102,6 +146,8 @@ export const ScreenStackHeaderConfig = React.forwardRef< + const allItems = [ + ...(preparedHeaderLeftBarButtonItems ?? []), + ...(preparedHeaderRightBarButtonItems ?? []), ++ ...(preparedHeaderCenterBarButtonItems ?? []), ++ ...(preparedHeaderToolbarItems ?? []), + ]; + for (const item of allItems) { + if (item && item.type === 'menu' && item.menu) { +@@ -110,6 +156,17 @@ export const ScreenStackHeaderConfig = React.forwardRef< + action.onPress(); + return; + } ++ } else if (item && item.type === 'mailSearchToolbar') { ++ const toolbarMenus = [item.filterMenu, item.composeMenu].filter( ++ Boolean, ++ ); ++ for (const toolbarMenu of toolbarMenus) { ++ const action = findInMenu(toolbarMenu, event.nativeEvent.menuId); ++ if (action) { ++ action.onPress(); ++ return; ++ } ++ } + } + } + } +@@ -121,6 +178,8 @@ export const ScreenStackHeaderConfig = React.forwardRef< + userInterfaceStyle={props.experimental_userInterfaceStyle} + headerLeftBarButtonItems={preparedHeaderLeftBarButtonItems} + headerRightBarButtonItems={preparedHeaderRightBarButtonItems} ++ headerCenterBarButtonItems={preparedHeaderCenterBarButtonItems} ++ headerToolbarItems={preparedHeaderToolbarItems} + onPressHeaderBarButtonItem={onPressHeaderBarButtonItem} + onPressHeaderBarButtonMenuItem={onPressHeaderBarButtonMenuItem} + ref={ref} +diff --git a/src/components/helpers/prepareHeaderBarButtonItems.ts b/src/components/helpers/prepareHeaderBarButtonItems.ts +index be2c24dfee77f5883b5ab1d7473be80933b5dc6f..0b038d2005b2d51bbb2ab1d7dba7eaccda83d42f 100644 +--- a/src/components/helpers/prepareHeaderBarButtonItems.ts ++++ b/src/components/helpers/prepareHeaderBarButtonItems.ts +@@ -50,13 +50,43 @@ const prepareMenu = ( + }; + + export const prepareHeaderBarButtonItems = ( +- barButtonItems: HeaderBarButtonItem[], ++ barButtonItems: ++ | HeaderBarButtonItem[] ++ | HeaderBarButtonItem ++ | null ++ | undefined, + side: 'left' | 'right', + ) => { +- return barButtonItems?.map((item, index) => { ++ const items = Array.isArray(barButtonItems) ++ ? barButtonItems ++ : barButtonItems && typeof barButtonItems === 'object' && 'type' in barButtonItems ++ ? [barButtonItems] ++ : undefined; ++ ++ return items?.map((item, index) => { + if (item.type === 'spacing') { + return item; + } ++ if (item.type === 'searchBarPlacement') { ++ return { ++ ...item, ++ searchBarPlacement: true, ++ }; ++ } ++ if (item.type === 'searchField') { ++ return { ++ ...item, ++ searchField: true, ++ }; ++ } ++ if (item.type === 'mailSearchToolbar') { ++ return { ++ ...item, ++ mailSearchToolbar: true, ++ filterMenu: item.filterMenu ? prepareMenu(item.filterMenu, index, side, 'filter') : undefined, ++ composeMenu: item.composeMenu ? prepareMenu(item.composeMenu, index, side, 'compose') : undefined, ++ }; ++ } + let imageSource, templateSource; + if (item.icon?.type === 'imageSource') { + imageSource = Image.resolveAssetSource(item.icon.imageSource); +diff --git a/src/fabric/ScreenStackHeaderConfigNativeComponent.ts b/src/fabric/ScreenStackHeaderConfigNativeComponent.ts +index ba2479de0f49c112470f78c5084059ddd9e2f3e8..8677583a8f8daa4839340467466bfab8d73111ab 100644 +--- a/src/fabric/ScreenStackHeaderConfigNativeComponent.ts ++++ b/src/fabric/ScreenStackHeaderConfigNativeComponent.ts +@@ -14,6 +14,7 @@ type OnPressHeaderBarButtonItemEvent = Readonly<{ buttonId: string }>; + type OnPressHeaderBarButtonMenuItemEvent = Readonly<{ menuId: string }>; + + type BackButtonDisplayMode = 'minimal' | 'default' | 'generic'; ++type NavigationItemStyle = 'navigator' | 'browser' | 'editor'; + + type BlurEffect = + | 'none' +@@ -61,12 +62,15 @@ export interface NativeProps extends ViewProps { + largeTitleColor?: ColorValue | undefined; + translucent?: boolean | undefined; + title?: string | undefined; ++ subtitle?: string | undefined; ++ largeSubtitle?: string | undefined; + titleFontFamily?: string | undefined; + titleFontSize?: CT.Int32 | undefined; + titleFontWeight?: string | undefined; + titleColor?: ColorValue | undefined; + disableBackButtonMenu?: boolean | undefined; + backButtonDisplayMode?: CT.WithDefault; ++ navigationItemStyle?: CT.WithDefault; + hideBackButton?: boolean | undefined; + backButtonInCustomView?: boolean | undefined; + blurEffect?: CT.WithDefault; +@@ -74,6 +78,8 @@ export interface NativeProps extends ViewProps { + topInsetEnabled?: boolean | undefined; + headerLeftBarButtonItems?: CT.UnsafeMixed[] | undefined; + headerRightBarButtonItems?: CT.UnsafeMixed[] | undefined; ++ headerCenterBarButtonItems?: CT.UnsafeMixed[] | undefined; ++ headerToolbarItems?: CT.UnsafeMixed[] | undefined; + onPressHeaderBarButtonItem?: + | CT.DirectEventHandler + | undefined; +diff --git a/src/types.tsx b/src/types.tsx +index 76a83f3acb6fd3f0af7f027798848b7124100286..9e4499f076f9988e3266df4be7201e131d21242b 100644 +--- a/src/types.tsx ++++ b/src/types.tsx +@@ -26,6 +26,7 @@ export type SearchBarCommands = { + }; + + export type BackButtonDisplayMode = 'default' | 'generic' | 'minimal'; ++export type NavigationItemStyle = 'navigator' | 'browser' | 'editor'; + export type StackPresentationTypes = + | 'push' + | 'modal' +@@ -735,6 +736,14 @@ export interface ScreenStackHeaderConfigProps extends ViewProps { + * @platform ios + */ + backButtonDisplayMode?: BackButtonDisplayMode | undefined; ++ /** ++ * Controls the UIKit `UINavigationItem.style` used by native iOS headers. ++ * ++ * `editor` matches modern document/editor-style bars with leading-aligned compact titles and glass controls. ++ * ++ * @platform ios ++ */ ++ navigationItemStyle?: NavigationItemStyle | undefined; + /** + * Array of UIBarButtomItems to the left side of the header. + * +@@ -747,6 +756,26 @@ export interface ScreenStackHeaderConfigProps extends ViewProps { + * @platform ios + */ + headerRightBarButtonItems?: HeaderBarButtonItem[] | undefined; ++ /** ++ * Array of UIBarButtonItems to the centered item group in modern iOS headers. ++ * ++ * This maps to `UINavigationItem.centerItemGroups` on iOS 26+ and lets ++ * document/editor-style chrome keep primary actions visually centered while ++ * leaving trailing search separate. ++ * ++ * @platform ios ++ */ ++ headerCenterBarButtonItems?: HeaderBarButtonItem[] | undefined; ++ /** ++ * Array of UIBarButtonItems to display in the native toolbar attached to ++ * the current screen's navigation controller. ++ * ++ * On iOS 26+, this can contain a `searchBarPlacement` item to let UIKit ++ * render integrated Mail-style search chrome using the attached SearchBar. ++ * ++ * @platform ios ++ */ ++ headerToolbarItems?: HeaderBarButtonItem[] | undefined; + /** + * 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. + */ +@@ -808,6 +837,20 @@ export interface ScreenStackHeaderConfigProps extends ViewProps { + * String that can be displayed in the header as a fallback for `headerTitle`. + */ + title?: string | undefined; ++ /** ++ * String displayed below the compact navigation title on iOS 26+. ++ * ++ * @platform ios ++ */ ++ subtitle?: string | undefined; ++ /** ++ * String displayed below the large navigation title on iOS 26+. ++ * ++ * Falls back to `subtitle` when omitted. ++ * ++ * @platform ios ++ */ ++ largeSubtitle?: string | undefined; + /** + * Allows for setting text color of the title. + */ +@@ -1125,6 +1168,11 @@ interface SharedHeaderBarButtonItem { + * Read more: https://developer.apple.com/documentation/uikit/uibarbuttonitem/style-swift.property + */ + variant?: 'plain' | 'done' | 'prominent' | undefined; ++ /** ++ * Render the item with UIButtonConfiguration.glassButtonConfiguration. ++ * Only available from iOS 26.0 and later. ++ */ ++ glassEffect?: boolean | undefined; + /** + * The tint color to apply to the item. + * +@@ -1279,11 +1327,46 @@ export interface HeaderBarButtonItemWithMenu extends SharedHeaderBarButtonItem { + export interface HeaderBarButtonItemSpacing { + type: 'spacing'; + spacing: number; ++ flexible?: boolean | undefined; ++} ++ ++export interface HeaderBarButtonSearchBarPlacementItem { ++ type: 'searchBarPlacement'; ++ activatesSearchController?: boolean | undefined; ++ customView?: boolean | undefined; ++ index?: number | undefined; ++ width?: number | undefined; ++} ++ ++export interface HeaderBarButtonSearchFieldItem { ++ type: 'searchField'; ++ placeholder?: string | undefined; ++ width?: number | undefined; ++} ++ ++export interface HeaderBarButtonMailSearchToolbarItem { ++ type: 'mailSearchToolbar'; ++ composeButtonId?: string | undefined; ++ composeMenu?: HeaderBarButtonItemWithMenu['menu'] | undefined; ++ composeSystemImageName?: string | undefined; ++ filterButtonId?: string | undefined; ++ filterMenu?: HeaderBarButtonItemWithMenu['menu'] | undefined; ++ filterSystemImageName?: string | undefined; ++ onComposePress?: (() => void) | undefined; ++ onFilterPress?: (() => void) | undefined; ++ onSearchTextChange?: ((text: string) => void) | undefined; ++ placeholder?: string | undefined; ++ searchTextChangeId?: string | undefined; ++ useFallbackSearchField?: boolean | undefined; ++ width?: number | undefined; + } + + export type HeaderBarButtonItem = + | HeaderBarButtonItemWithAction + | HeaderBarButtonItemWithMenu ++ | HeaderBarButtonMailSearchToolbarItem ++ | HeaderBarButtonSearchFieldItem ++ | HeaderBarButtonSearchBarPlacementItem + | HeaderBarButtonItemSpacing; + + /** diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 9cde29cb661..e54d6056d48 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -35,19 +35,19 @@ catalogs: version: 0.2.1 overrides: - '@clerk/backend': 3.8.3-snapshot.v20260622234151 - '@clerk/clerk-js': 6.21.0-snapshot.v20260622234151 - '@clerk/electron': 0.0.2-snapshot.v20260622234151 - '@clerk/electron-passkeys': 0.0.2-snapshot.v20260622234151 - '@clerk/expo': 3.5.3-snapshot.v20260622234151 - '@clerk/react': 6.11.0-snapshot.v20260622234151 - '@clerk/shared': 4.21.0-snapshot.v20260622234151 + '@clerk/backend': 3.8.4 + '@clerk/clerk-js': 6.22.0 '@clerk/clerk-js>@base-org/account': '-' '@clerk/clerk-js>@coinbase/wallet-sdk': '-' '@clerk/clerk-js>@solana/wallet-adapter-base': '-' '@clerk/clerk-js>@solana/wallet-adapter-react': '-' '@clerk/clerk-js>@solana/wallet-standard': '-' '@clerk/clerk-js>@wallet-standard/core': '-' + '@clerk/electron': 0.0.5 + '@clerk/electron-passkeys': 0.0.3 + '@clerk/expo': 3.6.2 + '@clerk/react': 6.11.1 + '@clerk/shared': 4.22.0 '@effect/atom-react': 4.0.0-beta.78 '@effect/platform-bun': 4.0.0-beta.78 '@effect/platform-node': 4.0.0-beta.78 @@ -56,7 +56,7 @@ overrides: '@effect/sql-sqlite-bun': 4.0.0-beta.78 '@effect/vitest': 4.0.0-beta.78 '@effect/vitest>vitest': '-' - '@expo/metro-config': 56.0.13 + '@expo/metro-config': 56.0.14 '@pierre/diffs>@shikijs/transformers': ^4.2.0 '@types/node': 24.12.4 effect: 4.0.0-beta.78 @@ -69,21 +69,36 @@ patchedDependencies: '@effect/vitest@4.0.0-beta.78': hash: 42b87cc47e70d74e62496e7a8261b3fd298ecad4464d209348ab04b96f853a5f path: patches/@effect__vitest@4.0.0-beta.78.patch - '@expo/metro-config@56.0.13': + '@expo/metro-config@56.0.14': hash: 8cb08b5bb7051ed9d2dbe46a2c293c5a1e17f1bd6ddf30de27909e18c921ff46 - path: patches/@expo%2Fmetro-config@56.0.13.patch + path: patches/@expo%2Fmetro-config@56.0.14.patch '@ff-labs/fff-node@0.9.4': hash: 2b16019ce7ab61aec6478dd02f79ef468cc1d5c51e9d00764f7d2ab8167210c8 path: patches/@ff-labs__fff-node@0.9.4.patch '@pierre/diffs@1.3.0-beta.5': hash: 7cb6da88544119adda056b2f46f43956f99326227732da0b345081e285a6c53a path: patches/@pierre%2Fdiffs@1.3.0-beta.5.patch + '@react-native-menu/menu@2.0.0': + hash: 5ea3ae4bf1d9baf5443b65c269bb09621c27a68d556f713778f37b1e8d46aaae + path: patches/@react-native-menu__menu@2.0.0.patch + '@react-navigation/native-stack@7.17.6': + hash: 2d7fec7933e324ccf082b1ae0df3907c35d16d48c117d628e27d9c3921c26bca + path: patches/@react-navigation%2Fnative-stack@7.17.6.patch effect@4.0.0-beta.78: hash: c502bc684210b707dfceb87d8fe6ad6843395af6e19cfc02cd65854898bde2c5 path: patches/effect@4.0.0-beta.78.patch + expo-modules-jsi@56.0.10: + hash: 9170f8074ae4e35a0a086e756c8f815794fd3abe51eac67ca3ba02804225ec1f + path: patches/expo-modules-jsi@56.0.10.patch + react-native-gesture-handler@2.31.2: + hash: 808eb26f9e57cf4945efd3985af4d9c764da6f91f4c9764433cc868602bbf4d3 + path: patches/react-native-gesture-handler@2.31.2.patch react-native-nitro-modules@0.35.9: hash: 825622aae63a8fb5b904f3c77908a0e216261d727ea171709f2c0b6088422675 path: patches/react-native-nitro-modules@0.35.9.patch + react-native-screens@4.25.2: + hash: e1b49230abc20a663115cc3f34ca2f63764a873485ccb67f2bd43b85ce2a94dc + path: patches/react-native-screens@4.25.2.patch importers: @@ -111,11 +126,11 @@ importers: apps/desktop: dependencies: '@clerk/electron': - specifier: 0.0.2-snapshot.v20260622234151 - 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) + specifier: 0.0.5 + 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) '@clerk/electron-passkeys': - specifier: 0.0.2-snapshot.v20260622234151 - version: 0.0.2-snapshot.v20260622234151 + specifier: 0.0.3 + version: 0.0.3 '@effect/platform-node': specifier: 4.0.0-beta.78 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: dependencies: '@callstack/liquid-glass': specifier: ^0.7.1 - 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) + 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) '@clerk/expo': - specifier: 3.5.3-snapshot.v20260622234151 - 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) + specifier: 3.6.2 + 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) '@effect/atom-react': specifier: 4.0.0-beta.78 version: 4.0.0-beta.78(effect@4.0.0-beta.78(patch_hash=c502bc684210b707dfceb87d8fe6ad6843395af6e19cfc02cd65854898bde2c5))(react@19.2.3)(scheduler@0.27.0) '@expo-google-fonts/dm-sans': specifier: ^0.4.2 version: 0.4.2 + '@expo/metro-runtime': + specifier: ~56.0.15 + 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) '@expo/ui': - specifier: ~56.0.8 - version: 56.0.15(961c4aa6f32829b318e3c87ef20ad401) + specifier: ~56.0.18 + version: 56.0.18(19413efe5eaad64848598eedfe3a0fd3) '@legendapp/list': specifier: 3.2.0 - 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) + 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) '@noble/curves': specifier: 'catalog:' version: 1.9.1 @@ -219,7 +237,16 @@ importers: 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) '@react-native-menu/menu': specifier: ^2.0.0 - 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) + 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) + '@react-navigation/elements': + specifier: 2.9.26 + 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) + '@react-navigation/native': + specifier: 7.3.4 + 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) + '@react-navigation/native-stack': + specifier: 7.17.6 + version: 7.17.6(patch_hash=2d7fec7933e324ccf082b1ae0df3907c35d16d48c117d628e27d9c3921c26bca)(889fbbb381dfeb79ac64b7284ebf7fa4) '@shikijs/core': specifier: 4.2.0 version: 4.2.0 @@ -240,7 +267,7 @@ importers: version: link:../../packages/contracts '@t3tools/mobile-markdown-text': specifier: file:./modules/t3-markdown-text - version: file:apps/mobile/modules/t3-markdown-text(f0ff241d48c23db461fff5d47e450578) + version: file:apps/mobile/modules/t3-markdown-text(a6e4bd1e9376530ea93dbb7d8a53d32d) '@t3tools/mobile-review-diff-native': specifier: file:./modules/t3-review-diff version: file:apps/mobile/modules/t3-review-diff @@ -260,80 +287,77 @@ importers: specifier: 4.0.0-beta.78 version: 4.0.0-beta.78(patch_hash=c502bc684210b707dfceb87d8fe6ad6843395af6e19cfc02cd65854898bde2c5) expo: - specifier: ^56.0.0 - version: 56.0.8(63f7aade424ad9e7b1154b679fa2a14d) + specifier: ~56.0.12 + 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) expo-asset: - specifier: ~56.0.15 - 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) + specifier: ~56.0.17 + 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) expo-auth-session: - specifier: ~56.0.12 - 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) + specifier: ~56.0.14 + 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) expo-build-properties: - specifier: ~56.0.15 - version: 56.0.16(expo@56.0.8) + specifier: ~56.0.19 + version: 56.0.19(expo@56.0.12) expo-camera: - specifier: ~56.0.7 - 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) + specifier: ~56.0.8 + 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) expo-clipboard: - specifier: ~56.0.3 - 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) + specifier: ~56.0.4 + 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) expo-constants: - specifier: ~56.0.16 - 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)) + specifier: ~56.0.18 + 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)) expo-crypto: specifier: ~56.0.4 - version: 56.0.4(expo@56.0.8) + version: 56.0.4(expo@56.0.12) expo-dev-client: - specifier: ~56.0.16 - 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)) + specifier: ~56.0.20 + 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)) expo-file-system: - specifier: ~56.0.7 - 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)) + specifier: ~56.0.8 + 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)) expo-font: - specifier: ~56.0.5 - 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) + specifier: ~56.0.7 + 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) expo-glass-effect: specifier: ~56.0.4 - 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) + 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) expo-haptics: specifier: ~56.0.3 - version: 56.0.3(expo@56.0.8) + version: 56.0.3(expo@56.0.12) expo-image-picker: - specifier: ~56.0.14 - version: 56.0.15(expo@56.0.8) + specifier: ~56.0.18 + version: 56.0.18(expo@56.0.12) expo-linking: - specifier: ~56.0.12 - 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) + specifier: ~56.0.14 + 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) expo-network: specifier: ~56.0.5 - version: 56.0.5(expo@56.0.8)(react@19.2.3) + version: 56.0.5(expo@56.0.12)(react@19.2.3) expo-notifications: - specifier: ~56.0.14 - 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) + specifier: ~56.0.18 + 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) expo-paste-input: specifier: ^0.1.15 - 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) - expo-router: - specifier: ~56.2.7 - version: 56.2.8(5bfdf39b8f760e4dc2d5c3acffc97310) + 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) expo-secure-store: specifier: ~56.0.4 - version: 56.0.4(expo@56.0.8) + version: 56.0.4(expo@56.0.12) expo-splash-screen: specifier: ~56.0.10 - version: 56.0.10(expo@56.0.8)(typescript@6.0.3) + version: 56.0.10(expo@56.0.12)(typescript@6.0.3) expo-symbols: - specifier: ~56.0.5 - 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) + specifier: ~56.0.6 + 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) expo-updates: - specifier: ~56.0.17 - 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) + specifier: ~56.0.19 + 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) expo-web-browser: specifier: ~56.0.5 - 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)) + 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)) expo-widgets: - specifier: ~56.0.15 - version: 56.0.16(961c4aa6f32829b318e3c87ef20ad401) + specifier: ~56.0.19 + version: 56.0.19(19413efe5eaad64848598eedfe3a0fd3) punycode: specifier: ^2.3.1 version: 2.3.1 @@ -345,43 +369,43 @@ importers: version: 19.2.3(react@19.2.3) react-native: specifier: 0.85.3 - 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) + 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) react-native-gesture-handler: specifier: ~2.31.1 - 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) + 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) react-native-image-viewing: specifier: ^0.2.2 - 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) + 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) react-native-keyboard-controller: - specifier: 1.21.7 - 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) + specifier: 1.21.6 + 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) react-native-nitro-markdown: specifier: ^0.5.0 - 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) + 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) react-native-nitro-modules: specifier: 0.35.9 - 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) + 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) react-native-reanimated: specifier: 4.3.1 - 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) + 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) react-native-safe-area-context: specifier: ~5.7.0 - 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) + 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) react-native-screens: specifier: 4.25.2 - 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) + 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) react-native-shiki-engine: specifier: ^0.3.12 - 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) + 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) react-native-svg: specifier: 15.15.4 - 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) + 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) react-native-webview: specifier: ^13.16.1 - 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) + 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) react-native-worklets: specifier: 0.8.3 - 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) + 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) shiki: specifier: 4.2.0 version: 4.2.0 @@ -390,7 +414,7 @@ importers: version: 3.6.0 uniwind: specifier: ^1.6.2 - 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) + 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) devDependencies: '@effect/vitest': specifier: 4.0.0-beta.78 @@ -403,7 +427,7 @@ importers: version: 19.2.16 babel-preset-expo: specifier: ~56.0.0 - 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) + 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) tailwindcss: specifier: ^4.0.0 version: 4.3.0 @@ -481,11 +505,11 @@ importers: specifier: ^1.4.1 version: 1.5.0(@types/react@19.2.16)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) '@clerk/electron': - specifier: 0.0.2-snapshot.v20260622234151 - 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) + specifier: 0.0.5 + 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) '@clerk/react': - specifier: 6.11.0-snapshot.v20260622234151 - version: 6.11.0-snapshot.v20260622234151(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + specifier: 6.11.1 + version: 6.11.1(react-dom@19.2.6(react@19.2.6))(react@19.2.6) '@dnd-kit/core': specifier: ^6.3.1 version: 6.3.1(react-dom@19.2.6(react@19.2.6))(react@19.2.6) @@ -635,8 +659,8 @@ importers: infra/relay: dependencies: '@clerk/backend': - specifier: 3.8.3-snapshot.v20260622234151 - version: 3.8.3-snapshot.v20260622234151(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + specifier: 3.8.4 + version: 3.8.4(react-dom@19.2.6(react@19.2.6))(react@19.2.6) '@effect/sql-pg': specifier: 4.0.0-beta.78 version: 4.0.0-beta.78(effect@4.0.0-beta.78(patch_hash=c502bc684210b707dfceb87d8fe6ad6843395af6e19cfc02cd65854898bde2c5)) @@ -1650,43 +1674,43 @@ packages: resolution: {integrity: sha512-EYlRokl8szrP9Z25qT5aepMdBjzBvHF9ZEhzIiUBc9guz/T31EqRgvD0QSgZcpE93xiwrr+OkB4nz0BZyF6fSA==} engines: {node: '>= 20.12.0'} - '@clerk/backend@3.8.3-snapshot.v20260622234151': - resolution: {integrity: sha512-B5goX0n/5pibc4dMQOfMmn4mPq7eqtrbNV20tOqO9qMPzFA+TKAj9SnB0oJvFsZAXKO6+/n16uYPbqeM2PN6CA==} + '@clerk/backend@3.8.4': + resolution: {integrity: sha512-3G+kEu8kalqQokJ/n0IynhBh2i6TtiilRzuAg5UWMburkK658/elrG0Uqsapd5yKhmkNuvizHwOPWwKAMxP2EQ==} engines: {node: '>=20.9.0'} - '@clerk/clerk-js@6.21.0-snapshot.v20260622234151': - resolution: {integrity: sha512-mRNn6H8GbeEkcCIzZ03WZ9c1Uy8znf70okYmmeJKzK72gsdwnrxEfbu3DYE/5yRbX6lvL7ugWaNTT3DvPEbBzg==} + '@clerk/clerk-js@6.22.0': + resolution: {integrity: sha512-4kN1J1GHUv7utZn1UunfOi10ZVyHOVY+d7tMGXoBxrckpb4vT/rFYep8uf8Xc6Q5QNEmi1/J0DMxZqnS3xy+ww==} engines: {node: '>=20.9.0'} - '@clerk/electron-passkeys-darwin-arm64@0.0.2-snapshot.v20260622234151': - resolution: {integrity: sha512-NZjIVGitAf0yyQvs1WXIAYOle9RjGsExpfwje2U20POTnNueFy56M0g39UTFQXJ42saTZ0zauLD8sd0cHqpOjA==} + '@clerk/electron-passkeys-darwin-arm64@0.0.3': + resolution: {integrity: sha512-oAlrd+GLqP0oREP3hNRc6yNZhXGaQgJss9iCWRQqXjpw1yZD1ZU+L+E2Gfg84vMj/NSUhxbU0Djy0RXhmeUwgg==} cpu: [arm64] os: [darwin] - '@clerk/electron-passkeys-darwin-x64@0.0.2-snapshot.v20260622234151': - resolution: {integrity: sha512-gGAwinVoIxa9lefCJjhf5D6oA8uq1sXQ/JwqffkBPJrvczYyAv9FYuQ1/ihji3jTplC0/bpTuIrwBkBCzwj5ug==} + '@clerk/electron-passkeys-darwin-x64@0.0.3': + resolution: {integrity: sha512-0UPAWQEni7o8gjWwd2sP97fPshJmT2w5b8eV/jOPVSP8NqIcFCm5yIFSEx1SfB53LGfzB01A4iEGi2pxiCHqyQ==} cpu: [x64] os: [darwin] - '@clerk/electron-passkeys-win32-arm64-msvc@0.0.2-snapshot.v20260622234151': - resolution: {integrity: sha512-anBFktMTAF7of37nLQsqQuW489w563J75l4QolWtblKbrBjlwFsEif95uj3vlQj0qWVVRWpAcxD21oD6wdJcwQ==} + '@clerk/electron-passkeys-win32-arm64-msvc@0.0.3': + resolution: {integrity: sha512-bbRvifm9A/gfPkwC1UyMyF9sYGEs0aen7J2kCcaOIW3K0PXcAbHtsR/gnx4dT0bjFdd21vSa2zlIdERvHc1BiA==} cpu: [arm64] os: [win32] - '@clerk/electron-passkeys-win32-x64-msvc@0.0.2-snapshot.v20260622234151': - resolution: {integrity: sha512-Pq4FOklTJNpyMTJpxY+/gq6CukvWHFJfGYjuz985cPHup1OSsVmuO/GYil36vh7Qbe8vDivT91wNe73DbEOcVw==} + '@clerk/electron-passkeys-win32-x64-msvc@0.0.3': + resolution: {integrity: sha512-gFaVqKlOKTF2SJ3lMwkZ0oonycQn93p1qSrp5kzzeZBeQ+tGB3gQnjXecrhQy21AzDUyWqVJJ8KrP2HRAwifWg==} cpu: [x64] os: [win32] - '@clerk/electron-passkeys@0.0.2-snapshot.v20260622234151': - resolution: {integrity: sha512-UvBweTg9+FCbygxARV7RvJ4/lcQgLN+gJC6Lj++cLmbxpgRwRAgR9xXRlKV6dVg1p5k81a4DcFNMu/oR6zBdlQ==} + '@clerk/electron-passkeys@0.0.3': + resolution: {integrity: sha512-OHhIe88qDL+FxyBalXdXNHAS5eEramr6Rerp+6iNkfkjqT8rx4hHNmfpmjg5/T1/am8QfknbOBZkqoXZlCrjPg==} engines: {node: '>=20.9.0'} - '@clerk/electron@0.0.2-snapshot.v20260622234151': - resolution: {integrity: sha512-T0LUJeAPaAZxpk13Q14b9LSVKKio/X/zFo67hgdr35MaOChsn4T6lQ/rEL7laScQBduskcs1yyjr3e9ndy5ojg==} + '@clerk/electron@0.0.5': + resolution: {integrity: sha512-usPkmcKsSBuqs9zUctVZLlde3Fhu+pYJ+8fHZPL7aPYfupIe0qvsWOgJIb6GXSrEshcBg104MdSzuVT2w7Dzxg==} engines: {node: '>=20.9.0'} peerDependencies: - '@clerk/electron-passkeys': 0.0.2-snapshot.v20260622234151 + '@clerk/electron-passkeys': 0.0.3 electron: '>=28' electron-store: ^8.2.0 react: ^18.0.0 || ~19.0.3 || ~19.1.4 || ~19.2.3 || ~19.3.0-0 @@ -1699,11 +1723,11 @@ packages: react-dom: optional: true - '@clerk/expo@3.5.3-snapshot.v20260622234151': - resolution: {integrity: sha512-qeKTJYA7cTe5oCmRVCT4A2QEPRp2af0ox0VdXzIhWRqRVFNF5bnsZRNKE2e4QnsC16NYLsGHLR5uQGC15qBiug==} + '@clerk/expo@3.6.2': + resolution: {integrity: sha512-8h4j62YLEtKlAq6eptfwB6jauOy7QQA+lMqRCY00n9PL1oQG7Y+sTGDg6ylX66O/VG2YOsqnBdlZcNUgt3xd2A==} engines: {node: '>=20.9.0'} peerDependencies: - '@clerk/expo-passkeys': 1.1.9-snapshot.v20260622234151 + '@clerk/expo-passkeys': '>=0.0.6' expo: '>=53 <57' expo-apple-authentication: '>=7.0.0' expo-auth-session: '>=5' @@ -1735,15 +1759,15 @@ packages: react-dom: optional: true - '@clerk/react@6.11.0-snapshot.v20260622234151': - resolution: {integrity: sha512-yF4jQFJqEHAqZCpOtjQ/Kg9yqZlEG6vW2vmVR5kVwgESkpOR1KPJIEMU8o3b6W86RKQai4lt3CWtY+o7CJsDyw==} + '@clerk/react@6.11.1': + resolution: {integrity: sha512-iEWckisZa/V3siCFlvGTNg7Yj+kndRx7HKBZ/QMSd6uEU9kd2tZ8Ymvd4GFPCpClwcki4h7jC750FujN656Jqg==} engines: {node: '>=20.9.0'} peerDependencies: react: ^18.0.0 || ~19.0.3 || ~19.1.4 || ~19.2.3 || ~19.3.0-0 react-dom: ^18.0.0 || ~19.0.3 || ~19.1.4 || ~19.2.3 || ~19.3.0-0 - '@clerk/shared@4.21.0-snapshot.v20260622234151': - resolution: {integrity: sha512-OYu+hO0GHiHwYTwSFe/GCHmuQlyTHyNa7fJ8vrmtUyML09mf+dayRFeUnxgkwOYhGriyq40t1zxdVx53Td73xg==} + '@clerk/shared@4.22.0': + resolution: {integrity: sha512-GZ56kzUB2UBb8MCF+Eo8WIey+W4RP1tAkyOL+geiHxrQxJlUcgD+xalY2YPJLH8WVFwtOnfIl8KPEo0M0e/DRg==} engines: {node: '>=20.9.0'} peerDependencies: react: ^18.0.0 || ~19.0.3 || ~19.1.4 || ~19.2.3 || ~19.3.0-0 @@ -2387,8 +2411,8 @@ packages: '@expo-google-fonts/material-symbols@0.4.38': resolution: {integrity: sha512-IJkBtN1o8u9BW5fvSii1MyHPQ7Q0HxbWcVBvOrOzgMLpVtZw7R2w94wBTVR7kZwv3w1JNTESMmLA5Sqn1+Z36A==} - '@expo/cli@56.1.13': - resolution: {integrity: sha512-7n5VzlBr7TKW0BgWgpEopWy+v8buPhMvbSEsuXD+bI1YIJBopkfWAub0qTvlc357E8wWOvV5MJXYyoeRvoOjoQ==} + '@expo/cli@56.1.16': + resolution: {integrity: sha512-VBQn0mqAwc67b9Cn0RVXyeodghomAx5xGRhA/bXaQzuxDjMQk0zIOb6pXMZX7yiIwJW66UZt/zQiJNSv6aWJYw==} hasBin: true peerDependencies: expo: '*' @@ -2406,9 +2430,15 @@ packages: '@expo/config-plugins@56.0.8': resolution: {integrity: sha512-phTuyBhgVLfqUHMjQkAfRtbyoY6yTxoKja1awtpVnEkoJDxPJuXx1KX5uvq1eZtt4bJQ08OBJ6P95INqRSHpRg==} + '@expo/config-plugins@56.0.9': + resolution: {integrity: sha512-/6a/S9USwx8OC9tGjHxbviLFiBHyueN3aoNWMLvWDEJoZ1CIVW800ZBzwXq/FYNK2qzcN1LxFmQtzD1zeFQKNA==} + '@expo/config-types@56.0.5': resolution: {integrity: sha512-GsAHO/MwW9ZRdgnmyfRXqVGLCP/zejD6rWnp5OROp8mBGRObKm4HfrjlUyT1skjMwCj1OrURx9ZfIc6yeBAkIA==} + '@expo/config-types@56.0.6': + resolution: {integrity: sha512-4Y6Aum5J4Re5NnxGVofRNe1aDwUBOmWhQYkynZsqzRtX/zEA1ADUeyHXuEckv9YD9djiyT7bKtLt5gKL3mA6VQ==} + '@expo/config@56.0.9': resolution: {integrity: sha512-/lqFeWGSrhpKJVP8tTN8LjuoIe8u8q2w7FzBL0C+wHgl+WM8l1qUIEYWy/sMvsG/NbpUIUsDHJRhQvOkU58eIw==} @@ -2437,18 +2467,18 @@ packages: resolution: {integrity: sha512-9HnnIbzwTTdbwSjNLXTk0fPm9ZwMJ7c1/31tsni8HZ8Q62KzYCyspahH+V365vg5J6lr001DzNwBxVWSaYCQLg==} engines: {node: '>=20.12.0'} - '@expo/expo-modules-macros-plugin@0.0.9': - resolution: {integrity: sha512-odai6D7ng/gA7At8ukFcWcauNEeDdyVqzVPbQxDkyU2NTJ4kgphA4I5iigS5C4LXFicSIzEt2nzdlLM8sjsTdA==} + '@expo/expo-modules-macros-plugin@0.2.2': + resolution: {integrity: sha512-4IMzPDIo/VOXREQjsJtliSfqYVZvfzU2SLFS/9sKMWF848S8CHx+e/E+Vf0TcMvpWCCKX5umyqxb13KJJ+YUzg==} - '@expo/fingerprint@0.19.3': - resolution: {integrity: sha512-w9Au2IVrtc0Ct+WRa05DVHGNHXYq6VyPZWuFP+5x055OeZ5q6k5Yg+aJ1gfShmjdOhDftRcsvmWmTdTZlWaTZw==} + '@expo/fingerprint@0.19.4': + resolution: {integrity: sha512-PsowRlO8+S7JlO8go7yhNEXp7sqlsWDE2AlCwoss7zH0dcajXFo74Fy0KdXEc4UXK7kKoHD37oDgsZ8aHSLr7A==} hasBin: true '@expo/image-utils@0.10.1': resolution: {integrity: sha512-YDeefvmYdihS7Wp3ESDUVnOgOSWmj2Cczm9lVNDdm4MqQLdAKm/LPYg83HtFQPfefRlAxyHrQR/O9kIXN9C1Wg==} - '@expo/inline-modules@0.0.10': - resolution: {integrity: sha512-DKEfq877UTAmL/gOT5aFhlLNDg/EVmTSca7JQMKDGR6mjFSAcrmQf4GJNsi6ztiaqj6cTnIfoSz0hAYdnr6RJQ==} + '@expo/inline-modules@0.0.12': + resolution: {integrity: sha512-SNIZr/HWfIQPTZBwmukItxpc7ws1SgMUywYq1dnQvDknQDjJcuWAasIRFUjsK15yQ1xb4G5CP7VHtbN3V4lENg==} '@expo/json-file@10.2.0': resolution: {integrity: sha512-S6XzKe3R9GQeHiUPXc3xJjOv2VJhOEwFYf7xdC2z2cUqt3kZJ9mSO877sNQloVdnW/SUCtPY3bexlM7nwq+CAQ==} @@ -2456,16 +2486,16 @@ packages: '@expo/local-build-cache-provider@56.0.8': resolution: {integrity: sha512-UsuXwpNi57MNhzZ3be4XThc8xW6nzk3Wu37s1+2qcfZGeJcMLKDFfwO6n8YXeIiGlCsOi0Ee1rsTdgjrKt/YJQ==} - '@expo/log-box@56.0.12': - resolution: {integrity: sha512-budE6AGmJbpOJfGSOz+JVP3+FevElT82IEIg+ukQ4gZpW/dGO7QX1unFjanKdSaYgudBwJ4FCFGMwWhW/1tXVQ==} + '@expo/log-box@56.0.13': + resolution: {integrity: sha512-QWRZSpWPyjkDLVQio4R7oAzg/Av2MOt/DciFkfjr8qQ3qxGVn1Rt1oHP/80hvcWDcHFV7N6PqpyxRXw6nbxzKQ==} peerDependencies: '@expo/dom-webview': ^56.0.5 expo: '*' react: '*' react-native: '*' - '@expo/metro-config@56.0.13': - resolution: {integrity: sha512-OPyNYiex/6Ms8zT2POdIZsLhcAZYk7O+yJvpz5uG/4QRA7aiESfCy1I+0YHewMlR4P1YQeyxIrfTurs6m9xfZA==} + '@expo/metro-config@56.0.14': + resolution: {integrity: sha512-O3CIHruaTJhswPAf/nf3i8QQ3f2jl+mEwSea1eb3khuplabdy/wTQz+JvHN8VGUFyg7JKwUGU1QfO6T3JiSQqA==} peerDependencies: expo: '*' peerDependenciesMeta: @@ -2475,10 +2505,10 @@ packages: '@expo/metro-file-map@56.0.3': resolution: {integrity: sha512-5OGW3z8LgEYgMJOR7F3pC8llFLkb1fVqwAewbCl6S4Vkha8AFQMwOjT+9Wbka+V4rmpljpGqOnMhF4xZbD961w==} - '@expo/metro-runtime@56.0.13': - resolution: {integrity: sha512-aMaFa/RPYm2iQoyYOB5q8AxDmWvf4E2yFbZ6rmBIQWaIPDdixGVUlLQeV8DlDAfZ/j+pNYO7l5M+774WbgkTgg==} + '@expo/metro-runtime@56.0.15': + resolution: {integrity: sha512-WIWeVsL6kCSB57oYZdUA4MTkH7c67UFMIjdNoQzKXwxZYwBFE/xL2cGPDC3z8RWt0femzJTVxAVZUOW/hiqRzA==} peerDependencies: - '@expo/log-box': ^56.0.12 + '@expo/log-box': ^56.0.13 expo: '*' react: '*' react-dom: '*' @@ -2500,8 +2530,8 @@ packages: '@expo/plist@0.7.0': resolution: {integrity: sha512-vrpryU1GoqSIRNqRB2D3IjXDmzNYfiQpEF6AH/xknlD7eiYmEDt3mb26V7cLcedcPG8PY/1xWHdBXVQJfEAh6Q==} - '@expo/prebuild-config@56.0.14': - resolution: {integrity: sha512-JHdMqR7Mf5ApLC50ZwTL0Z86ezrHOMYwoSHcWT6Pha/+1TcC+/J+i7vjhP06wGXQ2Kvjt74p/3mKg2Pd12KjhQ==} + '@expo/prebuild-config@56.0.16': + resolution: {integrity: sha512-ce9ENfPWO4WUWUVQz0OaqL3KYZ7YofP8O35ncnn7CHCaKwQ7BqxcCGJbh+qvP1UjlWeNB3CjHPrXXJ3bnZwlJw==} '@expo/require-utils@56.1.3': resolution: {integrity: sha512-KyLeOn/zzQSvuPpV5YhB/FPKnpQytno4luN918bGdPDssLBoS3N/0UbC3W0rJAn9kSFu+XpfR81eABRVsSdfgQ==} @@ -2511,15 +2541,15 @@ packages: typescript: optional: true - '@expo/router-server@56.0.12': - resolution: {integrity: sha512-RqKV2/Z8BH/z8l0ngSpG6//5xxJPaF5dTQvSfPQ0nrvCjikGMeIvyj3B9BeLnmZZhxb3gBtXqrj3irAoiIp2aQ==} + '@expo/router-server@56.0.14': + resolution: {integrity: sha512-2UCTtZfcq1ZPgp3wk8/+sq9DvFI9UxrPr1jcEKMAF2DGAJLosnpc8GWNNg2hkjt6SHUOdFHIPxujWPYyho2y3A==} peerDependencies: - '@expo/metro-runtime': ^56.0.13 + '@expo/metro-runtime': ^56.0.15 expo: '*' - expo-constants: ^56.0.16 - expo-font: ^56.0.5 + expo-constants: ^56.0.18 + expo-font: ^56.0.6 expo-router: '*' - expo-server: ^56.0.4 + expo-server: ^56.0.5 react: '*' react-dom: '*' react-server-dom-webpack: ~19.0.1 || ~19.1.2 || ~19.2.1 @@ -2546,8 +2576,8 @@ packages: '@expo/sudo-prompt@9.3.2': resolution: {integrity: sha512-HHQigo3rQWKMDzYDLkubN5WQOYXJJE2eNqIQC2axC2iO3mHdwnIR7FgZVvHWtBwAdzBgAP0ECp8KqS8TiMKvgw==} - '@expo/ui@56.0.15': - resolution: {integrity: sha512-PFZBzztQGCp2bRFP8wIOb5ntP2ORH2GdQkJMSJcDOd4NldoWMe1pFqv7PdthjNlaaTHHTTHK+RsQrz+M6z6isw==} + '@expo/ui@56.0.18': + resolution: {integrity: sha512-2XgH5obigGtXm8zlb/V3g87NSiIcBcJ1xoQOEQYPoExL1DCNsHzaIecTh1XG/f/45ardo4OZNJwpbfYJ9X3qrQ==} peerDependencies: '@babel/core': '*' expo: '*' @@ -2566,8 +2596,10 @@ packages: react-native-worklets: optional: true - '@expo/ws-tunnel@1.0.6': - resolution: {integrity: sha512-nDRbLmSrJar7abvUjp3smDwH8HcbZcoOEa5jVPUv9/9CajgmWw20JNRwTuBRzWIWIkEJDkz20GoNA+tSwUqk0Q==} + '@expo/ws-tunnel@2.0.0': + resolution: {integrity: sha512-j+JfTRdCk820J9dU0sA2SqshQIKFOMo7ED84w9MJFcebfbNQgsLztEY/SABDkGnjatrW4xGqnUhVRxSBVyCkXw==} + peerDependencies: + ws: ^8.0.0 '@expo/xcpretty@4.4.4': resolution: {integrity: sha512-4aQzz9vgxcNXFfo/iyNgDDYfsU5XGKKxWxZopw0cVotHiW+U8IJbIxMaxsINs6bHhtkG3StKNPcOrn3eBuxKPw==} @@ -3842,6 +3874,41 @@ packages: '@types/react': optional: true + '@react-navigation/core@7.21.2': + resolution: {integrity: sha512-EsJhQnsL5NuIhXsWF7URijS5hd2AaJUREdq1fOIowlyNNQBA3jCnkGU0DkW7+eI40cQ+GXH8DQw+ci7TefLIxQ==} + peerDependencies: + react: '>= 18.2.0' + + '@react-navigation/elements@2.9.26': + resolution: {integrity: sha512-EaHSJf42MjnH5VFL+KZfQIyqJvdQWK9Z27J7WUSuIVX5NXlR925sPmhWf540DX9gD1ny8BfUGPZAuw/PO4tK2w==} + peerDependencies: + '@react-native-masked-view/masked-view': '>= 0.2.0' + '@react-navigation/native': ^7.3.4 + react: '>= 18.2.0' + react-native: '*' + react-native-safe-area-context: '>= 4.0.0' + peerDependenciesMeta: + '@react-native-masked-view/masked-view': + optional: true + + '@react-navigation/native-stack@7.17.6': + resolution: {integrity: sha512-CnNPnlSGnrxPyHguDp/xDg5w9STMmIoI6IFZe7cpvEeD0GF/LTzvZ0gmq+hPJTm2/KIrnuPx34emCofzpht45g==} + peerDependencies: + '@react-navigation/native': ^7.3.4 + react: '>= 18.2.0' + react-native: '*' + react-native-safe-area-context: '>= 4.0.0' + react-native-screens: '>= 4.0.0' + + '@react-navigation/native@7.3.4': + resolution: {integrity: sha512-zyAqFLeZuaakerMMnhYqBeGAzJ+WFQUTgezP3FxSlXNwFq5XxnjP28vi2XHGqm4zg4pGWls9Ay4JKshIHUOpwA==} + peerDependencies: + react: '>= 18.2.0' + react-native: '*' + + '@react-navigation/routers@7.6.0': + resolution: {integrity: sha512-lblhDXfS75jLc7G2K7BZGM+7cjqQXk13X/MA4fq/12r62zM+fBhhreLzYflSitrDDXFRJpSvJXy0ziiGU04Xow==} + '@rolldown/binding-android-arm64@1.0.0-rc.17': resolution: {integrity: sha512-s70pVGhw4zqGeFnXWvAzJDlvxhlRollagdCCKRgOsgUOH3N1l0LIxf83AtGzmb5SiVM4Hjl5HyarMRfdfj3DaQ==} engines: {node: ^20.19.0 || >=22.12.0} @@ -5456,6 +5523,21 @@ packages: expo-widgets: optional: true + babel-preset-expo@56.0.15: + resolution: {integrity: sha512-0MqbQoM6nBUbKvgu2xJ4VixZnUTGTq3HB2WwvOikdO4CiPxbQ+wGA25fOoHHSni5iEFW39wy6y1ookTWlq3wVw==} + peerDependencies: + '@babel/runtime': ^7.20.0 + expo: '*' + expo-widgets: ^56.0.18 + react-refresh: '>=0.14.0 <1.0.0' + peerDependenciesMeta: + '@babel/runtime': + optional: true + expo: + optional: true + expo-widgets: + optional: true + badgin@1.2.3: resolution: {integrity: sha512-NQGA7LcfCpSzIbGRbkgjgdWkjy7HI+Th5VLxTJfW5EeaAf3fnS+xWQaQOCYiny+q6QSvxqoSO04vCx+4u++EJw==} @@ -6384,26 +6466,26 @@ packages: peerDependencies: expo: '*' - expo-asset@56.0.15: - resolution: {integrity: sha512-BHGi2IAOPQTcOelkUdcz1WIknfCTRjkcpYHX1azjMwgYenrVC+J5qcqJGaC8eUOWLCRtkRJWGnmFQRYtLU1nUQ==} + expo-asset@56.0.17: + resolution: {integrity: sha512-GFN5j+8SPkyv0nfsiFHewmdB/D0tL237TsBE/gSfFOFy/J3a52py7IulcSqkA3sQE/u/UlD5BmvP5ssS4//nUg==} peerDependencies: expo: '*' react: '*' react-native: '*' - expo-auth-session@56.0.13: - resolution: {integrity: sha512-LR8Suq8BHKRFBUcAKTMmZufCcDcr0sQa8rIYit1r7kshrqAy9glIUU4aqHt8tflW/ISN0x1vU+HU8AQaackM0A==} + expo-auth-session@56.0.14: + resolution: {integrity: sha512-b6URDBKXVWBjHwypnbCPW6A3PrwYyFqzLXtTrrpTGpmDlsxk7xuz6wIA77sBNziz3hMxt11Nu70iY0fJZYT4jA==} peerDependencies: react: '*' react-native: '*' - expo-build-properties@56.0.16: - resolution: {integrity: sha512-C3avazYP2fR8efJBBmhx8yITjIRDaIe3ULPk0YfACP61QfnWC9u3LxaDNNaiIvYfZ+CLne30W+nS5F6pdgO/8g==} + expo-build-properties@56.0.19: + resolution: {integrity: sha512-InoviXcxWosNp4cC7L3SWoiY99Xr2HdgN+LYHb6mUm/BBVxy1mIMrZR+3PJ2gwDZzW6EJNDz8ioASWGHBTmzpA==} peerDependencies: expo: '*' - expo-camera@56.0.7: - resolution: {integrity: sha512-c8z+UheidFintQyP9XLEDP43aK4PS/o9+TFLW0zEOjdqkYCBgoWq6Mw/Ps62kjBeftFY7xrp5ZLITbenNvbTaw==} + expo-camera@56.0.8: + resolution: {integrity: sha512-UDOpUUMisFRmCv1XQV1MJCKGAH2CsIC1Rs6P9Bbc6JLVmbxEKAd5dK68y6cScOdWURxVfJ0PRcjYnSuc8ayyIQ==} peerDependencies: expo: '*' react: '*' @@ -6413,15 +6495,15 @@ packages: react-native-web: optional: true - expo-clipboard@56.0.3: - resolution: {integrity: sha512-8mCdhmAomm0yBIonJFjAhKUXvSkc2avdNh4+rBwoe7DSWF2AC4w3uy+pa419rvVFbTyVxOBmh83UHAbUwD6qAg==} + expo-clipboard@56.0.4: + resolution: {integrity: sha512-qb4DYlkiowHYHaUYVT2FN9nk/nI1xShXOUYsI7J9dVpQCOHcGFjCBPX1VAvEW4Ye4/Aagd6IuhOVAq/+scBOiA==} peerDependencies: expo: '*' react: '*' react-native: '*' - expo-constants@56.0.16: - resolution: {integrity: sha512-6tsiN+gmTUPp/atyA+uY9Tg8VOdXdmb4s/3TVGolfn6A/oCAraw1pcPZX5XllyD+xUguxB6eBSFAT8494hZVMA==} + expo-constants@56.0.18: + resolution: {integrity: sha512-8AMtbDGl/WVPnWlmbpGmvcdnNCy9E4PFnwdVwj600vljkMDPSxcAcjw8GVXEPk3PpZ+ngTqsrkltWyj0UKYAxw==} peerDependencies: expo: '*' react-native: '*' @@ -6431,13 +6513,13 @@ packages: peerDependencies: expo: '*' - expo-dev-client@56.0.18: - resolution: {integrity: sha512-pTfDcYTOvrs4vCgAaM+vP2OEO93oGkczgGpTAzCY7ZTIvtPhpekJURHBxfOnKvfn97IF3Hk+8J9tMozsNDj0Gw==} + expo-dev-client@56.0.20: + resolution: {integrity: sha512-KebW4r8HhIiRrPzs6ZqVhp/so8buyglAO1h4No0Ibr5C2XRnlIoGWCN4zC6rW7IsI3iKUXcofLAQV9OjoxjiwQ==} peerDependencies: expo: '*' - expo-dev-launcher@56.0.18: - resolution: {integrity: sha512-7acFJlkAbp3cMz7Uy787todMR/3A/Row2EOPD21RRoetvzJe4DTm9s7RwJ8PDtyNyued9rooD4+Q6rD8ijpTgw==} + expo-dev-launcher@56.0.20: + resolution: {integrity: sha512-cTuC3GkPl9CTwO3CKnVmEm9qoQ0WairhwvTh6qMlg+zr/QU/tdiU++uDBX67hf9+FuxQOkWGp5khFNosT+0cIg==} peerDependencies: expo: '*' react-native: '*' @@ -6447,8 +6529,8 @@ packages: peerDependencies: expo: '*' - expo-dev-menu@56.0.16: - resolution: {integrity: sha512-aVgoe+YGhrQnpwiB5BRI7G+uQnGHMUij32bBnEVdc6eJrVZCStxQlV9NeFbbXxrDhLJt6OSqbCHbLR+XToWUUA==} + expo-dev-menu@56.0.17: + resolution: {integrity: sha512-OofRkOOZnaDriSav3JDN4NP2lsLt2eOa/Ryptr5nMD62SwnFyK4R6n6PkPVaDU3LSsZqndAJHmN6inS+oziayQ==} peerDependencies: expo: '*' react-native: '*' @@ -6456,14 +6538,14 @@ packages: expo-eas-client@56.0.1: resolution: {integrity: sha512-r8h0ZIExacCrSRgY+ARfhMvFqosLHLJt1L7jyhvabfr1DN/ZDKDsYbovss2tzkpEUZGxZ3BPcB5epCwUsBBdOA==} - expo-file-system@56.0.7: - resolution: {integrity: sha512-dcKzo8ShPloM7jgfnMcJStgQebhP8owVjCkNI/aX6NMFV1CYB8bxKGMdnzJ3mXk5nfaiW+F/lSKr2UIJ02WAUA==} + expo-file-system@56.0.8: + resolution: {integrity: sha512-NrH41/8snGIBSbYicwVLB4txPdgCATd7ZYhMAGS3YJZ9GbnduhlAoV4/YCbGayjrbpE9bJb/6wegPL/zmvRMnQ==} peerDependencies: expo: '*' react-native: '*' - expo-font@56.0.5: - resolution: {integrity: sha512-WLoDu9hlEgPRKXJRR01HFLJ6Z2tFcORX/WFPRYBndmYc5kjQrFGH/j4BRaF3aBRPyYEAUXiUJybNLXkKCwEXQw==} + expo-font@56.0.7: + resolution: {integrity: sha512-hpU/vRwPzsby9lPGkA4blDqLIIXYzoWnCZHr6PxvcWbY/uPObAiyhh6q+e0WYsB65SthK+PLH95jEnVag7fwEg==} peerDependencies: expo: '*' react: '*' @@ -6486,8 +6568,8 @@ packages: peerDependencies: expo: '*' - expo-image-picker@56.0.15: - resolution: {integrity: sha512-7zmrUcqiNKwGGGL114gC+1aaquhmMbaXCNSHjUcoINdBzvIEVMJDm+DNCxkpftEYeb17X3shqInI4lCgJNVO5g==} + expo-image-picker@56.0.18: + resolution: {integrity: sha512-sCjQ8M27bhGUv2vUavIE+uWdYo79b2D7Q5h9B66BSDZ+Rd8YyLVSf7vYGfIzQ7nMVoENZ6c4xo/JiDkEeQ9iTg==} peerDependencies: expo: '*' @@ -6500,8 +6582,8 @@ packages: expo: '*' react: '*' - expo-linking@56.0.13: - resolution: {integrity: sha512-38YrpTh6xdiDxmYSDIUffDqev1hIcEggw2fZ3IZhNp2DVLF1xvqsbO6hJD1fuBKN8P34B3Ggc9Yy26fkqdfCOA==} + expo-linking@56.0.14: + resolution: {integrity: sha512-IvVQHWC+Cj4fK5qD3iEVYqpU2a4rLW0IpAAlGJ4MH+H1fyZiHh3eN6qg2WmoclOEPfYATSuEa+dQT6wfgVpXlQ==} peerDependencies: react: '*' react-native: '*' @@ -6511,12 +6593,12 @@ packages: peerDependencies: expo: '*' - expo-modules-autolinking@56.0.14: - resolution: {integrity: sha512-9ugtZkheNPYDkW4DZopY1rH2BCbUICaafUEPxRgbLDR5UNRF5K3cdHMIMEt8pxZPq2+eX4wCm+6pbSvdY/DPHg==} + expo-modules-autolinking@56.0.16: + resolution: {integrity: sha512-9JnL4N46P8ubDpDIfWolDn7nxU2j1rY67xY/dNVuyH0m+HG+r/JI16VYtjIf4COpZtEuFo4D3h3MBeFzGucMnw==} hasBin: true - expo-modules-core@56.0.14: - resolution: {integrity: sha512-dl1TlYRm1k7xk9QeAyDoMfFE2p6rNyzHUcH5ArcGwUzO8YKku+Z2tQ8+kG7zLe3OhfMoJcFR/czrFy7vGSVI6w==} + expo-modules-core@56.0.17: + resolution: {integrity: sha512-5J8whnT7Ccp+BrFClLmpF76omBqn95VZExroTm01Dgjm4vpty1Rb7U3we+ZUceNHtRd07Lw30u7FNfDgIhEbRQ==} peerDependencies: react: '*' react-native: '*' @@ -6525,8 +6607,8 @@ packages: react-native-worklets: optional: true - expo-modules-jsi@56.0.7: - resolution: {integrity: sha512-iBAj4Xeh/8HT201VVxFlmf+VBfmtQV1ZUoJdLQQENm0+j9gnD2QswZLJyNo3CmNNXl46esJeLR5lpGpYZts/zA==} + expo-modules-jsi@56.0.10: + resolution: {integrity: sha512-fHZcFpYO/o62GYa6fJyAQJZcAShzhoN0iMMDzbr7vD3ewET6e1vAlTonbEakN9F0VHEgBFJ4NREy87uwVcpCuA==} peerDependencies: react-native: '*' @@ -6536,8 +6618,8 @@ packages: expo: '*' react: '*' - expo-notifications@56.0.15: - resolution: {integrity: sha512-F+OasAePiVnHaPNKI9JAYV8fg8bdBwo7Mh9R3ydBp8S21fRQyxKOSgJvj8fX/HoPFFIC6V2B+y1LJbG5Ovh/Fg==} + expo-notifications@56.0.18: + resolution: {integrity: sha512-HHnrwyCLC5srFojcHYS2KskbNroy9o2fwPKdyhjrdjjrBu4sNRKm4LepcuZjDy98cZKEm89WIPW8O45vut8Rgw==} peerDependencies: expo: '*' react: '*' @@ -6550,15 +6632,15 @@ packages: react: '*' react-native: '*' - expo-router@56.2.8: - resolution: {integrity: sha512-l387I/ddPY/5SS+Rfpp1SrRV9gBKevxtPuZod7igMjR6L674QrxEwGiAILRq6AKCSbrP2I0ufKj7e5xz8JqA4Q==} + expo-router@56.2.11: + resolution: {integrity: sha512-08DBTrKv3QanOc9u1JNxSEChW9c/qNFbQ0dO28OLvufWWfdSRkSdHmh365D2FgoZg1qaOzZPCDuL3tM6nGSfkQ==} peerDependencies: - '@expo/log-box': ^56.0.12 - '@expo/metro-runtime': ^56.0.13 + '@expo/log-box': ^56.0.13 + '@expo/metro-runtime': ^56.0.15 '@testing-library/react-native': '>= 13.2.0' expo: '*' - expo-constants: ^56.0.16 - expo-linking: ^56.0.13 + expo-constants: ^56.0.18 + expo-linking: ^56.0.14 react: '*' react-dom: '*' react-native: '*' @@ -6587,8 +6669,8 @@ packages: peerDependencies: expo: '*' - expo-server@56.0.4: - resolution: {integrity: sha512-4dJ57KuAwDl7eQGD6aG9kTzBIftWAfHH1+6Zxy7NcPCBrKYis3/H5enGUz1asH8HHhONXfJ5BdJqfEWAEAgWxA==} + expo-server@56.0.5: + resolution: {integrity: sha512-SmM2p2g3Jrktpiazcst+OxhjSzOHXKAY4BPURHYHXvApzzoybMmrNF4IEZ8DKZ145BhSe4ydAmlEFCRTsdtgUQ==} engines: {node: '>=20.16.0'} expo-splash-screen@56.0.10: @@ -6599,8 +6681,8 @@ packages: expo-structured-headers@56.0.0: resolution: {integrity: sha512-Yv4x+SQxNnMQm4nu8NFfzx197YaDhdYH2N0u7tGErwWTmH9Tm1SAhqo7bLbBWLC9kf7W+kdzTshLU9rTiCXWGw==} - expo-symbols@56.0.5: - resolution: {integrity: sha512-RIukH0Xo80C7RU8qreipL2SPy2Py+Km8JFPbCmbPQpHkM3DW9Znlmg6VfhzbtUOlO5EuNSF0lAJ3l2VJi6qYrw==} + expo-symbols@56.0.6: + resolution: {integrity: sha512-BrA81DjcNafdj7gXVhdrExb9LtUiSVyOf/NavyMmDAHgHMY1GqeR5cnn1PSAZeYKnSgQhee/H89XUpAxtog5hg==} peerDependencies: expo: '*' expo-font: '*' @@ -6612,8 +6694,8 @@ packages: peerDependencies: expo: '*' - expo-updates@56.0.17: - resolution: {integrity: sha512-4005IRQQ7Vpwb01X3NSG0SwY61HTJzT2pZ0OrkeZ6guQE1WKiRfUKisekRgYXTI8sPbxSHUc9aOpIx6yC49XZQ==} + expo-updates@56.0.19: + resolution: {integrity: sha512-tTSPYO5h8wDA6a+wQ2v/SRdnOdz29x0npGHCv+4Ev31Fz5r05Ii1Wgfh3BlTXNz8mikMReDsZCf6YN71YeQKpw==} hasBin: true peerDependencies: expo: '*' @@ -6630,15 +6712,15 @@ packages: expo: '*' react-native: '*' - expo-widgets@56.0.16: - resolution: {integrity: sha512-EmWVvskad2XlgCk6j9wz/OqSAeaGxjD6x6Wji99TVjjFd+sTD6W3YJbz3QW/BKTVRcll95m6XjJDCa6B9t9f9A==} + expo-widgets@56.0.19: + resolution: {integrity: sha512-D2RWectoEalVdGwXyE2LX3L9T6q6jSKh8jjvk1K3JSE1qOcCVZk+TJtvBUveTq8OoLblkeFMXqQ2fHG+kg655w==} peerDependencies: expo: '*' react: '*' react-native: '*' - expo@56.0.8: - resolution: {integrity: sha512-GzQi5450yrCk5JRSlm0epsmtURBErh0wS77uWLZImFdnPICuX912MaRWooR+Q1Sw/7aQjp9F+KXH+dvrqGxpeQ==} + expo@56.0.12: + resolution: {integrity: sha512-FxgdI/Yqva6iJOThZIHfvxlKPxs4EC4uScUnEswwSArR/Fj9k430O13R590LcOQTsdNsjIs+GBHwjfoAY6vmAQ==} hasBin: true peerDependencies: '@expo/dom-webview': '*' @@ -8737,8 +8819,8 @@ packages: react: '*' react-native: '*' - react-native-keyboard-controller@1.21.7: - resolution: {integrity: sha512-gs+8nI8HYnRdDt4NWbk1iVuS6kDLf2taJvp+h/TjM1FBdtnQmlYLJ6buNiUqSnkIH4OFEAxdNr3/GOOYdLfkUQ==} + react-native-keyboard-controller@1.21.6: + resolution: {integrity: sha512-nAXCmar/W8Gn4iQV7O5fAVuTh57JszCsqTS+cfR95WFOLR/AfbwfPz/+sWyz/q2SOIe2VpyQzq6hzYiwErhqqw==} peerDependencies: react: '*' react-native: '*' @@ -9306,6 +9388,12 @@ packages: standard-as-callback@2.1.0: resolution: {integrity: sha512-qoRRSyROncaz1z0mvYqIE4lCd9p2R90i6GxW3uZv5ucSu8tU7B5HXUP1gG8pVZsYNVaXjk8ClXHPttLyxAL48A==} + standard-navigation@0.0.5: + resolution: {integrity: sha512-YAmzwAiiQVocZxO/VGPFiQHcu5pKiz09QIGC0MK6aRMoa3E0QkoTQgcqJr7ZZ3OMiNhu4DkaGElFI5htjOIDbw==} + + standard-navigation@0.0.7: + resolution: {integrity: sha512-NCGLCNyuXrFOkGHxdNZFnpsehGtiq1oXbPhKl7ZuxFO5J//H2evqqOchmD4YwEUJnkjO4kH9Xp4hQX6hdAYCKQ==} + standardwebhooks@1.0.0: resolution: {integrity: sha512-BbHGOQK9olHPMvQNHWul6MYlrRTAOKn03rOe4A8O3CLWhNf4YHBqq2HJKKC+sfqpxiBY52pNeesD6jIiLDz8jg==} @@ -10248,7 +10336,8 @@ snapshots: 7zip-bin@5.2.0: {} - '@adobe/css-tools@4.5.0': {} + '@adobe/css-tools@4.5.0': + optional: true '@alcalzone/ansi-tokenize@0.2.5': dependencies: @@ -11216,10 +11305,10 @@ snapshots: '@bruits/satteri-win32-x64-msvc@0.9.3': optional: true - '@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)': + '@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)': dependencies: 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-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) '@capsizecss/unpack@4.0.1': dependencies: @@ -11248,18 +11337,18 @@ snapshots: fast-wrap-ansi: 0.2.2 sisteransi: 1.0.5 - '@clerk/backend@3.8.3-snapshot.v20260622234151(react-dom@19.2.6(react@19.2.6))(react@19.2.6)': + '@clerk/backend@3.8.4(react-dom@19.2.6(react@19.2.6))(react@19.2.6)': dependencies: - '@clerk/shared': 4.21.0-snapshot.v20260622234151(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + '@clerk/shared': 4.22.0(react-dom@19.2.6(react@19.2.6))(react@19.2.6) standardwebhooks: 1.0.0 tslib: 2.8.1 transitivePeerDependencies: - react - react-dom - '@clerk/clerk-js@6.21.0-snapshot.v20260622234151(react-dom@19.2.3(react@19.2.3))(react@19.2.3)': + '@clerk/clerk-js@6.22.0(react-dom@19.2.3(react@19.2.3))(react@19.2.3)': dependencies: - '@clerk/shared': 4.21.0-snapshot.v20260622234151(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + '@clerk/shared': 4.22.0(react-dom@19.2.3(react@19.2.3))(react@19.2.3) '@stripe/stripe-js': 5.6.0 '@swc/helpers': 0.5.21 '@tanstack/query-core': 5.100.14 @@ -11274,9 +11363,9 @@ snapshots: - react - react-dom - '@clerk/clerk-js@6.21.0-snapshot.v20260622234151(react-dom@19.2.6(react@19.2.6))(react@19.2.6)': + '@clerk/clerk-js@6.22.0(react-dom@19.2.6(react@19.2.6))(react@19.2.6)': dependencies: - '@clerk/shared': 4.21.0-snapshot.v20260622234151(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + '@clerk/shared': 4.22.0(react-dom@19.2.6(react@19.2.6))(react@19.2.6) '@stripe/stripe-js': 5.6.0 '@swc/helpers': 0.5.21 '@tanstack/query-core': 5.100.14 @@ -11291,72 +11380,72 @@ snapshots: - react - react-dom - '@clerk/electron-passkeys-darwin-arm64@0.0.2-snapshot.v20260622234151': + '@clerk/electron-passkeys-darwin-arm64@0.0.3': optional: true - '@clerk/electron-passkeys-darwin-x64@0.0.2-snapshot.v20260622234151': + '@clerk/electron-passkeys-darwin-x64@0.0.3': optional: true - '@clerk/electron-passkeys-win32-arm64-msvc@0.0.2-snapshot.v20260622234151': + '@clerk/electron-passkeys-win32-arm64-msvc@0.0.3': optional: true - '@clerk/electron-passkeys-win32-x64-msvc@0.0.2-snapshot.v20260622234151': + '@clerk/electron-passkeys-win32-x64-msvc@0.0.3': optional: true - '@clerk/electron-passkeys@0.0.2-snapshot.v20260622234151': + '@clerk/electron-passkeys@0.0.3': optionalDependencies: - '@clerk/electron-passkeys-darwin-arm64': 0.0.2-snapshot.v20260622234151 - '@clerk/electron-passkeys-darwin-x64': 0.0.2-snapshot.v20260622234151 - '@clerk/electron-passkeys-win32-arm64-msvc': 0.0.2-snapshot.v20260622234151 - '@clerk/electron-passkeys-win32-x64-msvc': 0.0.2-snapshot.v20260622234151 + '@clerk/electron-passkeys-darwin-arm64': 0.0.3 + '@clerk/electron-passkeys-darwin-x64': 0.0.3 + '@clerk/electron-passkeys-win32-arm64-msvc': 0.0.3 + '@clerk/electron-passkeys-win32-x64-msvc': 0.0.3 - '@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)': + '@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)': dependencies: - '@clerk/clerk-js': 6.21.0-snapshot.v20260622234151(react-dom@19.2.6(react@19.2.6))(react@19.2.6) - '@clerk/react': 6.11.0-snapshot.v20260622234151(react-dom@19.2.6(react@19.2.6))(react@19.2.6) - '@clerk/shared': 4.21.0-snapshot.v20260622234151(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + '@clerk/clerk-js': 6.22.0(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + '@clerk/react': 6.11.1(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + '@clerk/shared': 4.22.0(react-dom@19.2.6(react@19.2.6))(react@19.2.6) electron: 41.5.0 react: 19.2.6 tslib: 2.8.1 optionalDependencies: - '@clerk/electron-passkeys': 0.0.2-snapshot.v20260622234151 + '@clerk/electron-passkeys': 0.0.3 electron-store: 8.2.0 react-dom: 19.2.6(react@19.2.6) - '@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)': + '@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)': dependencies: - '@clerk/clerk-js': 6.21.0-snapshot.v20260622234151(react-dom@19.2.3(react@19.2.3))(react@19.2.3) - '@clerk/react': 6.11.0-snapshot.v20260622234151(react-dom@19.2.3(react@19.2.3))(react@19.2.3) - '@clerk/shared': 4.21.0-snapshot.v20260622234151(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + '@clerk/clerk-js': 6.22.0(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + '@clerk/react': 6.11.1(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + '@clerk/shared': 4.22.0(react-dom@19.2.3(react@19.2.3))(react@19.2.3) base-64: 1.0.0 - expo: 56.0.8(63f7aade424ad9e7b1154b679fa2a14d) + 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) 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-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)) + react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) + 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)) tslib: 2.8.1 optionalDependencies: - 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@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-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-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@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-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)) react-dom: 19.2.3(react@19.2.3) - '@clerk/react@6.11.0-snapshot.v20260622234151(react-dom@19.2.3(react@19.2.3))(react@19.2.3)': + '@clerk/react@6.11.1(react-dom@19.2.3(react@19.2.3))(react@19.2.3)': dependencies: - '@clerk/shared': 4.21.0-snapshot.v20260622234151(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + '@clerk/shared': 4.22.0(react-dom@19.2.3(react@19.2.3))(react@19.2.3) react: 19.2.3 react-dom: 19.2.3(react@19.2.3) tslib: 2.8.1 - '@clerk/react@6.11.0-snapshot.v20260622234151(react-dom@19.2.6(react@19.2.6))(react@19.2.6)': + '@clerk/react@6.11.1(react-dom@19.2.6(react@19.2.6))(react@19.2.6)': dependencies: - '@clerk/shared': 4.21.0-snapshot.v20260622234151(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + '@clerk/shared': 4.22.0(react-dom@19.2.6(react@19.2.6))(react@19.2.6) react: 19.2.6 react-dom: 19.2.6(react@19.2.6) tslib: 2.8.1 - '@clerk/shared@4.21.0-snapshot.v20260622234151(react-dom@19.2.3(react@19.2.3))(react@19.2.3)': + '@clerk/shared@4.22.0(react-dom@19.2.3(react@19.2.3))(react@19.2.3)': dependencies: '@tanstack/query-core': 5.100.14 dequal: 2.0.3 @@ -11366,7 +11455,7 @@ snapshots: react: 19.2.3 react-dom: 19.2.3(react@19.2.3) - '@clerk/shared@4.21.0-snapshot.v20260622234151(react-dom@19.2.6(react@19.2.6))(react@19.2.6)': + '@clerk/shared@4.22.0(react-dom@19.2.6(react@19.2.6))(react@19.2.6)': dependencies: '@tanstack/query-core': 5.100.14 dequal: 2.0.3 @@ -11922,29 +12011,29 @@ snapshots: '@expo-google-fonts/material-symbols@0.4.38': {} - '@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)': + '@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)': dependencies: '@expo/code-signing-certificates': 0.0.6 '@expo/config': 56.0.9(typescript@6.0.3) - '@expo/config-plugins': 56.0.8(typescript@6.0.3) + '@expo/config-plugins': 56.0.9(typescript@6.0.3) '@expo/devcert': 1.2.1 '@expo/env': 2.3.0 '@expo/image-utils': 0.10.1(typescript@6.0.3) - '@expo/inline-modules': 0.0.10(typescript@6.0.3) + '@expo/inline-modules': 0.0.12(typescript@6.0.3) '@expo/json-file': 10.2.0 - '@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) + '@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) '@expo/metro': 56.0.0(bufferutil@4.1.0)(utf-8-validate@6.0.6) - '@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) + '@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) '@expo/metro-file-map': 56.0.3 '@expo/osascript': 2.6.0 '@expo/package-manager': 1.12.1 '@expo/plist': 0.7.0 - '@expo/prebuild-config': 56.0.14(typescript@6.0.3) + '@expo/prebuild-config': 56.0.16(typescript@6.0.3) '@expo/require-utils': 56.1.3(typescript@6.0.3) - '@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) + '@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) '@expo/schema-utils': 56.0.1 '@expo/spawn-async': 1.8.0 - '@expo/ws-tunnel': 1.0.6 + '@expo/ws-tunnel': 2.0.0(ws@8.21.0(bufferutil@4.1.0)(utf-8-validate@6.0.6)) '@expo/xcpretty': 4.4.4 '@react-native/dev-middleware': 0.85.3(bufferutil@4.1.0)(utf-8-validate@6.0.6) accepts: 1.3.8 @@ -11957,8 +12046,8 @@ snapshots: connect: 3.7.0 debug: 4.4.3 dnssd-advertise: 1.1.4 - expo: 56.0.8(63f7aade424ad9e7b1154b679fa2a14d) - expo-server: 56.0.4 + 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) + expo-server: 56.0.5 fetch-nodeshim: 0.4.10 getenv: 2.0.0 glob: 13.0.6 @@ -11983,8 +12072,8 @@ snapshots: ws: 8.21.0(bufferutil@4.1.0)(utf-8-validate@6.0.6) zod: 3.25.76 optionalDependencies: - expo-router: 56.2.8(5bfdf39b8f760e4dc2d5c3acffc97310) - react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-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-router: 56.2.11(3af01b44e50aae5a8ae60852f3cf7e3f) + react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) transitivePeerDependencies: - '@expo/dom-webview' - '@expo/metro-runtime' @@ -12021,11 +12110,32 @@ snapshots: - supports-color - typescript + '@expo/config-plugins@56.0.9(typescript@6.0.3)': + dependencies: + '@expo/config-types': 56.0.6 + '@expo/json-file': 10.2.0 + '@expo/plist': 0.7.0 + '@expo/require-utils': 56.1.3(typescript@6.0.3) + '@expo/sdk-runtime-versions': 1.0.0 + chalk: 4.1.2 + debug: 4.4.3 + getenv: 2.0.0 + glob: 13.0.6 + semver: 7.8.5 + slugify: 1.6.9 + xcode: 3.0.1 + xml2js: 0.6.0 + transitivePeerDependencies: + - supports-color + - typescript + '@expo/config-types@56.0.5': {} + '@expo/config-types@56.0.6': {} + '@expo/config@56.0.9(typescript@6.0.3)': dependencies: - '@expo/config-plugins': 56.0.8(typescript@6.0.3) + '@expo/config-plugins': 56.0.9(typescript@6.0.3) '@expo/config-types': 56.0.5 '@expo/json-file': 10.2.0 '@expo/require-utils': 56.1.3(typescript@6.0.3) @@ -12046,18 +12156,18 @@ snapshots: transitivePeerDependencies: - supports-color - '@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)': + '@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)': dependencies: chalk: 4.1.2 optionalDependencies: 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-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@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/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)': + '@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)': dependencies: - expo: 56.0.8(63f7aade424ad9e7b1154b679fa2a14d) + 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) 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-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@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/env@2.3.0': dependencies: @@ -12067,9 +12177,9 @@ snapshots: transitivePeerDependencies: - supports-color - '@expo/expo-modules-macros-plugin@0.0.9': {} + '@expo/expo-modules-macros-plugin@0.2.2': {} - '@expo/fingerprint@0.19.3': + '@expo/fingerprint@0.19.4': dependencies: '@expo/env': 2.3.0 '@expo/spawn-async': 1.8.0 @@ -12098,9 +12208,9 @@ snapshots: - supports-color - typescript - '@expo/inline-modules@0.0.10(typescript@6.0.3)': + '@expo/inline-modules@0.0.12(typescript@6.0.3)': dependencies: - '@expo/config-plugins': 56.0.8(typescript@6.0.3) + '@expo/config-plugins': 56.0.9(typescript@6.0.3) transitivePeerDependencies: - supports-color - typescript @@ -12118,16 +12228,16 @@ snapshots: - supports-color - typescript - '@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)': + '@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)': dependencies: - '@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) + '@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) anser: 1.4.10 - expo: 56.0.8(63f7aade424ad9e7b1154b679fa2a14d) + 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) 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-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) stacktrace-parser: 0.1.11 - '@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)': + '@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)': dependencies: '@babel/code-frame': 7.29.7 '@babel/core': 7.29.7 @@ -12149,12 +12259,11 @@ snapshots: hermes-parser: 0.33.3 jsc-safe-url: 0.2.4 lightningcss: 1.32.0 - msgpackr: 2.0.2 picomatch: 4.0.4 postcss: 8.5.15 resolve-from: 5.0.0 optionalDependencies: - expo: 56.0.8(63f7aade424ad9e7b1154b679fa2a14d) + 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) transitivePeerDependencies: - bufferutil - supports-color @@ -12172,14 +12281,14 @@ snapshots: transitivePeerDependencies: - supports-color - '@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)': + '@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)': dependencies: - '@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) + '@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) anser: 1.4.10 - expo: 56.0.8(63f7aade424ad9e7b1154b679fa2a14d) + 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) pretty-format: 29.7.0 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-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) stacktrace-parser: 0.1.11 whatwg-fetch: 3.6.20 optionalDependencies: @@ -12225,16 +12334,16 @@ snapshots: base64-js: 1.5.1 xmlbuilder: 15.1.1 - '@expo/prebuild-config@56.0.14(typescript@6.0.3)': + '@expo/prebuild-config@56.0.16(typescript@6.0.3)': dependencies: '@expo/config': 56.0.9(typescript@6.0.3) - '@expo/config-plugins': 56.0.8(typescript@6.0.3) - '@expo/config-types': 56.0.5 + '@expo/config-plugins': 56.0.9(typescript@6.0.3) + '@expo/config-types': 56.0.6 '@expo/image-utils': 0.10.1(typescript@6.0.3) '@expo/json-file': 10.2.0 '@react-native/normalize-colors': 0.85.3 debug: 4.4.3 - expo-modules-autolinking: 56.0.14(typescript@6.0.3) + expo-modules-autolinking: 56.0.16(typescript@6.0.3) resolve-from: 5.0.0 semver: 7.8.5 transitivePeerDependencies: @@ -12251,17 +12360,17 @@ snapshots: transitivePeerDependencies: - supports-color - '@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)': + '@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)': dependencies: debug: 4.4.3 - expo: 56.0.8(63f7aade424ad9e7b1154b679fa2a14d) - 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)) - 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) - expo-server: 56.0.4 + 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) + 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)) + 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) + expo-server: 56.0.5 react: 19.2.3 optionalDependencies: - '@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) - expo-router: 56.2.8(5bfdf39b8f760e4dc2d5c3acffc97310) + '@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) + expo-router: 56.2.11(3af01b44e50aae5a8ae60852f3cf7e3f) react-dom: 19.2.3(react@19.2.3) transitivePeerDependencies: - supports-color @@ -12276,23 +12385,25 @@ snapshots: '@expo/sudo-prompt@9.3.2': {} - '@expo/ui@56.0.15(961c4aa6f32829b318e3c87ef20ad401)': + '@expo/ui@56.0.18(19413efe5eaad64848598eedfe3a0fd3)': dependencies: - expo: 56.0.8(63f7aade424ad9e7b1154b679fa2a14d) + 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) 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-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) sf-symbols-typescript: 2.2.0 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) optionalDependencies: '@babel/core': 7.29.7 react-dom: 19.2.3(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)(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-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-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-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) transitivePeerDependencies: - '@types/react' - '@types/react-dom' - '@expo/ws-tunnel@1.0.6': {} + '@expo/ws-tunnel@2.0.0(ws@8.21.0(bufferutil@4.1.0)(utf-8-validate@6.0.6))': + dependencies: + ws: 8.21.0(bufferutil@4.1.0)(utf-8-validate@6.0.6) '@expo/xcpretty@4.4.4': dependencies: @@ -12548,13 +12659,13 @@ snapshots: dependencies: jsbi: 4.3.2 - '@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)': + '@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)': dependencies: react: 19.2.3 use-sync-external-store: 1.6.0(react@19.2.3) optionalDependencies: 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-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) '@legendapp/list@3.2.0(react-dom@19.2.6(react@19.2.6))(react@19.2.6)': dependencies: @@ -13178,6 +13289,7 @@ snapshots: optionalDependencies: '@types/react': 19.2.16 '@types/react-dom': 19.2.3(@types/react@19.2.16) + optional: true '@radix-ui/react-compose-refs@1.1.2(@types/react@19.2.16)(react@19.2.3)': dependencies: @@ -13218,6 +13330,7 @@ snapshots: react: 19.2.3 optionalDependencies: '@types/react': 19.2.16 + optional: true '@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)': dependencies: @@ -13301,6 +13414,7 @@ snapshots: optionalDependencies: '@types/react': 19.2.16 '@types/react-dom': 19.2.3(@types/react@19.2.16) + optional: true '@radix-ui/react-slot@1.2.3(@types/react@19.2.16)(react@19.2.3)': dependencies: @@ -13315,6 +13429,7 @@ snapshots: react: 19.2.3 optionalDependencies: '@types/react': 19.2.16 + optional: true '@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)': dependencies: @@ -13331,6 +13446,7 @@ snapshots: optionalDependencies: '@types/react': 19.2.16 '@types/react-dom': 19.2.3(@types/react@19.2.16) + optional: true '@radix-ui/react-use-callback-ref@1.1.1(@types/react@19.2.16)(react@19.2.3)': dependencies: @@ -13377,15 +13493,16 @@ snapshots: prompts: 2.4.2 tinyexec: 1.2.4 - '@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)': + '@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)': dependencies: 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-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) + optional: true - '@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)': + '@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)': dependencies: 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-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) '@react-native/assets-registry@0.85.3': {} @@ -13445,7 +13562,7 @@ snapshots: tinyglobby: 0.2.17 yargs: 17.7.2 - '@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)': + '@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)': dependencies: '@react-native/dev-middleware': 0.85.3(bufferutil@4.1.0)(utf-8-validate@6.0.6) debug: 4.4.3 @@ -13455,7 +13572,7 @@ snapshots: metro-core: 0.84.4 semver: 7.8.5 optionalDependencies: - '@react-native/metro-config': 0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6) + '@react-native/metro-config': 0.85.3(@babel/core@7.29.7) transitivePeerDependencies: - bufferutil - supports-color @@ -13503,7 +13620,7 @@ snapshots: transitivePeerDependencies: - supports-color - '@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6)': + '@react-native/metro-config@0.85.3(@babel/core@7.29.7)': dependencies: '@react-native/js-polyfills': 0.85.3 '@react-native/metro-babel-transformer': 0.85.3(@babel/core@7.29.7) @@ -13511,21 +13628,72 @@ snapshots: metro-runtime: 0.84.4 transitivePeerDependencies: - '@babel/core' - - bufferutil - supports-color - - utf-8-validate '@react-native/normalize-colors@0.85.3': {} - '@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)': + '@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)': dependencies: invariant: 2.2.4 nullthrows: 1.1.1 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-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) optionalDependencies: '@types/react': 19.2.16 + '@react-navigation/core@7.21.2(react@19.2.3)': + dependencies: + '@react-navigation/routers': 7.6.0 + escape-string-regexp: 4.0.0 + fast-deep-equal: 3.1.3 + nanoid: 3.3.12 + query-string: 7.1.3 + react: 19.2.3 + react-is: 19.2.7 + use-latest-callback: 0.2.6(react@19.2.3) + use-sync-external-store: 1.6.0(react@19.2.3) + + '@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)': + dependencies: + '@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) + color: 4.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-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) + use-latest-callback: 0.2.6(react@19.2.3) + use-sync-external-store: 1.6.0(react@19.2.3) + optionalDependencies: + '@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-stack@7.17.6(patch_hash=2d7fec7933e324ccf082b1ae0df3907c35d16d48c117d628e27d9c3921c26bca)(889fbbb381dfeb79ac64b7284ebf7fa4)': + dependencies: + '@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) + '@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) + color: 4.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-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-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) + sf-symbols-typescript: 2.2.0 + warn-once: 0.1.1 + transitivePeerDependencies: + - '@react-native-masked-view/masked-view' + + '@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)': + dependencies: + '@react-navigation/core': 7.21.2(react@19.2.3) + escape-string-regexp: 4.0.0 + fast-deep-equal: 3.1.3 + nanoid: 3.3.12 + 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) + standard-navigation: 0.0.7 + use-latest-callback: 0.2.6(react@19.2.3) + + '@react-navigation/routers@7.6.0': + dependencies: + nanoid: 3.3.12 + '@rolldown/binding-android-arm64@1.0.0-rc.17': optional: true @@ -13907,15 +14075,15 @@ snapshots: dependencies: defer-to-connect: 2.0.1 - '@t3tools/mobile-markdown-text@file:apps/mobile/modules/t3-markdown-text(f0ff241d48c23db461fff5d47e450578)': + '@t3tools/mobile-markdown-text@file:apps/mobile/modules/t3-markdown-text(a6e4bd1e9376530ea93dbb7d8a53d32d)': dependencies: - 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) - 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) - expo-haptics: 56.0.3(expo@56.0.8) - 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) + 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) + 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) + expo-haptics: 56.0.3(expo@56.0.12) + 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) 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-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) + react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) + 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) '@t3tools/mobile-review-diff-native@file:apps/mobile/modules/t3-review-diff': {} @@ -14171,6 +14339,7 @@ snapshots: dom-accessibility-api: 0.6.3 picocolors: 1.1.1 redent: 3.0.0 + optional: true '@testing-library/user-event@14.6.1(@testing-library/dom@10.4.1)': dependencies: @@ -14983,7 +15152,60 @@ snapshots: transitivePeerDependencies: - '@babel/core' - 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): + 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): + dependencies: + '@babel/generator': 7.29.7 + '@babel/helper-module-imports': 7.29.7 + '@babel/plugin-proposal-decorators': 7.29.7(@babel/core@7.29.7) + '@babel/plugin-proposal-export-default-from': 7.29.7(@babel/core@7.29.7) + '@babel/plugin-syntax-dynamic-import': 7.8.3(@babel/core@7.29.7) + '@babel/plugin-syntax-export-default-from': 7.29.7(@babel/core@7.29.7) + '@babel/plugin-syntax-nullish-coalescing-operator': 7.8.3(@babel/core@7.29.7) + '@babel/plugin-syntax-optional-chaining': 7.8.3(@babel/core@7.29.7) + '@babel/plugin-transform-async-generator-functions': 7.29.7(@babel/core@7.29.7) + '@babel/plugin-transform-async-to-generator': 7.29.7(@babel/core@7.29.7) + '@babel/plugin-transform-block-scoping': 7.29.7(@babel/core@7.29.7) + '@babel/plugin-transform-class-properties': 7.29.7(@babel/core@7.29.7) + '@babel/plugin-transform-class-static-block': 7.29.7(@babel/core@7.29.7) + '@babel/plugin-transform-classes': 7.29.7(@babel/core@7.29.7) + '@babel/plugin-transform-destructuring': 7.29.7(@babel/core@7.29.7) + '@babel/plugin-transform-export-namespace-from': 7.29.7(@babel/core@7.29.7) + '@babel/plugin-transform-flow-strip-types': 7.29.7(@babel/core@7.29.7) + '@babel/plugin-transform-for-of': 7.29.7(@babel/core@7.29.7) + '@babel/plugin-transform-logical-assignment-operators': 7.29.7(@babel/core@7.29.7) + '@babel/plugin-transform-modules-commonjs': 7.29.7(@babel/core@7.29.7) + '@babel/plugin-transform-named-capturing-groups-regex': 7.29.7(@babel/core@7.29.7) + '@babel/plugin-transform-nullish-coalescing-operator': 7.29.7(@babel/core@7.29.7) + '@babel/plugin-transform-object-rest-spread': 7.29.7(@babel/core@7.29.7) + '@babel/plugin-transform-optional-catch-binding': 7.29.7(@babel/core@7.29.7) + '@babel/plugin-transform-optional-chaining': 7.29.7(@babel/core@7.29.7) + '@babel/plugin-transform-parameters': 7.29.7(@babel/core@7.29.7) + '@babel/plugin-transform-private-methods': 7.29.7(@babel/core@7.29.7) + '@babel/plugin-transform-private-property-in-object': 7.29.7(@babel/core@7.29.7) + '@babel/plugin-transform-react-display-name': 7.29.7(@babel/core@7.29.7) + '@babel/plugin-transform-react-jsx': 7.28.6(@babel/core@7.29.7) + '@babel/plugin-transform-react-jsx-development': 7.29.7(@babel/core@7.29.7) + '@babel/plugin-transform-react-pure-annotations': 7.29.7(@babel/core@7.29.7) + '@babel/plugin-transform-runtime': 7.29.7(@babel/core@7.29.7) + '@babel/plugin-transform-typescript': 7.29.7(@babel/core@7.29.7) + '@babel/plugin-transform-unicode-regex': 7.29.7(@babel/core@7.29.7) + '@babel/preset-typescript': 7.29.7(@babel/core@7.29.7) + '@react-native/babel-plugin-codegen': 0.85.3(@babel/core@7.29.7) + babel-plugin-react-compiler: 1.0.0 + babel-plugin-react-native-web: 0.21.2 + babel-plugin-syntax-hermes-parser: 0.33.3 + babel-plugin-transform-flow-enums: 0.0.2(@babel/core@7.29.7) + debug: 4.4.3 + react-refresh: 0.14.2 + optionalDependencies: + '@babel/runtime': 7.29.7 + 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) + expo-widgets: 56.0.19(19413efe5eaad64848598eedfe3a0fd3) + transitivePeerDependencies: + - '@babel/core' + - supports-color + + 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): dependencies: '@babel/generator': 7.29.7 '@babel/helper-module-imports': 7.29.7 @@ -15030,8 +15252,8 @@ snapshots: react-refresh: 0.14.2 optionalDependencies: '@babel/runtime': 7.29.7 - expo: 56.0.8(63f7aade424ad9e7b1154b679fa2a14d) - expo-widgets: 56.0.16(961c4aa6f32829b318e3c87ef20ad401) + 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) + expo-widgets: 56.0.19(19413efe5eaad64848598eedfe3a0fd3) transitivePeerDependencies: - '@babel/core' - supports-color @@ -15307,7 +15529,8 @@ snapshots: cli-width@4.1.0: {} - client-only@0.0.1: {} + client-only@0.0.1: + optional: true cliui@8.0.1: dependencies: @@ -15499,7 +15722,8 @@ snapshots: css-what@6.2.2: {} - css.escape@1.5.1: {} + css.escape@1.5.1: + optional: true csso@5.0.5: dependencies: @@ -15622,7 +15846,8 @@ snapshots: dom-accessibility-api@0.5.16: {} - dom-accessibility-api@0.6.3: {} + dom-accessibility-api@0.6.3: + optional: true dom-serializer@2.0.0: dependencies: @@ -15926,154 +16151,154 @@ snapshots: expect-type@1.4.0: {} - expo-application@56.0.3(expo@56.0.8): + expo-application@56.0.3(expo@56.0.12): dependencies: - expo: 56.0.8(63f7aade424ad9e7b1154b679fa2a14d) + 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) - 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): + 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): dependencies: '@expo/image-utils': 0.10.1(typescript@6.0.3) - expo: 56.0.8(63f7aade424ad9e7b1154b679fa2a14d) - 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)) + 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) + 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)) 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-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) transitivePeerDependencies: - supports-color - typescript - 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-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): dependencies: - expo-application: 56.0.3(expo@56.0.8) - 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)) - expo-crypto: 56.0.4(expo@56.0.8) - 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) - 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-application: 56.0.3(expo@56.0.12) + 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)) + expo-crypto: 56.0.4(expo@56.0.12) + 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) + 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)) invariant: 2.2.4 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-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) transitivePeerDependencies: - expo - supports-color - expo-build-properties@56.0.16(expo@56.0.8): + expo-build-properties@56.0.19(expo@56.0.12): dependencies: '@expo/schema-utils': 56.0.1 - expo: 56.0.8(63f7aade424ad9e7b1154b679fa2a14d) + 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) resolve-from: 5.0.0 semver: 7.8.5 - 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): + 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): dependencies: barcode-detector: 3.2.0(@types/emscripten@1.41.5) - expo: 56.0.8(63f7aade424ad9e7b1154b679fa2a14d) + 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) 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-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) transitivePeerDependencies: - '@types/emscripten' - 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): + 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): dependencies: - expo: 56.0.8(63f7aade424ad9e7b1154b679fa2a14d) + 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) 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-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@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-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)): + 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)): dependencies: '@expo/env': 2.3.0 - expo: 56.0.8(63f7aade424ad9e7b1154b679fa2a14d) - react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-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.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) + react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) transitivePeerDependencies: - supports-color - expo-crypto@56.0.4(expo@56.0.8): + expo-crypto@56.0.4(expo@56.0.12): dependencies: - expo: 56.0.8(63f7aade424ad9e7b1154b679fa2a14d) + 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) - 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-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)): dependencies: - expo: 56.0.8(63f7aade424ad9e7b1154b679fa2a14d) - 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)) - 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)) - expo-dev-menu-interface: 56.0.1(expo@56.0.8) - expo-manifests: 56.0.4(expo@56.0.8) - expo-updates-interface: 56.0.2(expo@56.0.8) + 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) + 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)) + 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)) + expo-dev-menu-interface: 56.0.1(expo@56.0.12) + expo-manifests: 56.0.4(expo@56.0.12) + expo-updates-interface: 56.0.2(expo@56.0.12) transitivePeerDependencies: - react-native - 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)): + 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)): dependencies: '@expo/schema-utils': 56.0.1 - expo: 56.0.8(63f7aade424ad9e7b1154b679fa2a14d) - 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)) - expo-manifests: 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) + 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) + 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)) + expo-manifests: 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) - expo-dev-menu-interface@56.0.1(expo@56.0.8): + expo-dev-menu-interface@56.0.1(expo@56.0.12): dependencies: - expo: 56.0.8(63f7aade424ad9e7b1154b679fa2a14d) + 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) - 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)): + 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)): dependencies: - expo: 56.0.8(63f7aade424ad9e7b1154b679fa2a14d) - expo-dev-menu-interface: 56.0.1(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.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) + expo-dev-menu-interface: 56.0.1(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-eas-client@56.0.1: {} - 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)): + 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)): dependencies: - expo: 56.0.8(63f7aade424ad9e7b1154b679fa2a14d) - react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-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.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) + react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@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-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): + 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): dependencies: - expo: 56.0.8(63f7aade424ad9e7b1154b679fa2a14d) + 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) fontfaceobserver: 2.3.0 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-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@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-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): + 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): dependencies: - expo: 56.0.8(63f7aade424ad9e7b1154b679fa2a14d) + 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) 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-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@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-haptics@56.0.3(expo@56.0.8): + expo-haptics@56.0.3(expo@56.0.12): dependencies: - expo: 56.0.8(63f7aade424ad9e7b1154b679fa2a14d) + 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) - expo-image-loader@56.0.3(expo@56.0.8): + expo-image-loader@56.0.3(expo@56.0.12): dependencies: - expo: 56.0.8(63f7aade424ad9e7b1154b679fa2a14d) + 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) - expo-image-picker@56.0.15(expo@56.0.8): + expo-image-picker@56.0.18(expo@56.0.12): dependencies: - expo: 56.0.8(63f7aade424ad9e7b1154b679fa2a14d) - expo-image-loader: 56.0.3(expo@56.0.8) + 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) + expo-image-loader: 56.0.3(expo@56.0.12) expo-json-utils@56.0.0: {} - expo-keep-awake@56.0.3(expo@56.0.8)(react@19.2.3): + expo-keep-awake@56.0.3(expo@56.0.12)(react@19.2.3): dependencies: - expo: 56.0.8(63f7aade424ad9e7b1154b679fa2a14d) + 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) react: 19.2.3 - 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): + 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): dependencies: - 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)) + 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)) invariant: 2.2.4 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-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) transitivePeerDependencies: - expo - supports-color - expo-manifests@56.0.4(expo@56.0.8): + expo-manifests@56.0.4(expo@56.0.12): dependencies: - expo: 56.0.8(63f7aade424ad9e7b1154b679fa2a14d) + 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) expo-json-utils: 56.0.0 - expo-modules-autolinking@56.0.14(typescript@6.0.3): + expo-modules-autolinking@56.0.16(typescript@6.0.3): dependencies: '@expo/require-utils': 56.1.3(typescript@6.0.3) '@expo/spawn-async': 1.8.0 @@ -16083,66 +16308,66 @@ snapshots: - supports-color - typescript - 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): + 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): dependencies: - '@expo/expo-modules-macros-plugin': 0.0.9 - 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)) + '@expo/expo-modules-macros-plugin': 0.2.2 + 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)) invariant: 2.2.4 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-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) optionalDependencies: - 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-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) - 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)): + 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)): dependencies: - react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(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))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) - expo-network@56.0.5(expo@56.0.8)(react@19.2.3): + expo-network@56.0.5(expo@56.0.12)(react@19.2.3): dependencies: - expo: 56.0.8(63f7aade424ad9e7b1154b679fa2a14d) + 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) react: 19.2.3 - 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): + 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): dependencies: '@expo/image-utils': 0.10.1(typescript@6.0.3) abort-controller: 3.0.0 badgin: 1.2.3 - expo: 56.0.8(63f7aade424ad9e7b1154b679fa2a14d) - expo-application: 56.0.3(expo@56.0.8) - 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)) + 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) + expo-application: 56.0.3(expo@56.0.12) + 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)) 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-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) transitivePeerDependencies: - supports-color - typescript - 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): + 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): dependencies: - expo: 56.0.8(63f7aade424ad9e7b1154b679fa2a14d) + 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) 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-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@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-router@56.2.8(5bfdf39b8f760e4dc2d5c3acffc97310): + expo-router@56.2.11(3af01b44e50aae5a8ae60852f3cf7e3f): dependencies: - '@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) - '@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) + '@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) + '@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) '@expo/schema-utils': 56.0.1 - '@expo/ui': 56.0.15(961c4aa6f32829b318e3c87ef20ad401) + '@expo/ui': 56.0.18(19413efe5eaad64848598eedfe3a0fd3) '@radix-ui/react-slot': 1.2.4(@types/react@19.2.16)(react@19.2.3) '@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) - '@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) + '@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) '@testing-library/jest-dom': 6.9.1 '@testing-library/user-event': 14.6.1(@testing-library/dom@10.4.1) client-only: 0.0.1 color: 4.2.3 debug: 4.4.3 escape-string-regexp: 4.0.0 - expo: 56.0.8(63f7aade424ad9e7b1154b679fa2a14d) - 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)) - 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) - 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) - expo-server: 56.0.4 - 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) + 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) + 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)) + 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) + 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) + expo-server: 56.0.5 + 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) fast-deep-equal: 3.1.3 invariant: 2.2.4 nanoid: 3.3.12 @@ -16150,18 +16375,19 @@ snapshots: react: 19.2.3 react-fast-compare: 3.2.2 react-is: 19.2.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) - react-native-drawer-layout: 4.2.4(0e9729601f58a7a7ae26c76fe6017455) - 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) - 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) + react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) + 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) + 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-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) server-only: 0.0.1 sf-symbols-typescript: 2.2.0 shallowequal: 1.1.0 + standard-navigation: 0.0.5 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) optionalDependencies: react-dom: 19.2.3(react@19.2.3) - 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) - 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-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) transitivePeerDependencies: - '@babel/core' - '@testing-library/dom' @@ -16170,18 +16396,19 @@ snapshots: - expo-font - react-native-worklets - supports-color + optional: true - expo-secure-store@56.0.4(expo@56.0.8): + expo-secure-store@56.0.4(expo@56.0.12): dependencies: - expo: 56.0.8(63f7aade424ad9e7b1154b679fa2a14d) + 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) - expo-server@56.0.4: {} + expo-server@56.0.5: {} - expo-splash-screen@56.0.10(expo@56.0.8)(typescript@6.0.3): + expo-splash-screen@56.0.10(expo@56.0.12)(typescript@6.0.3): dependencies: '@expo/config-plugins': 56.0.8(typescript@6.0.3) '@expo/image-utils': 0.10.1(typescript@6.0.3) - expo: 56.0.8(63f7aade424ad9e7b1154b679fa2a14d) + 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) xml2js: 0.6.0 transitivePeerDependencies: - supports-color @@ -16189,20 +16416,20 @@ snapshots: expo-structured-headers@56.0.0: {} - 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): + 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): dependencies: '@expo-google-fonts/material-symbols': 0.4.38 - expo: 56.0.8(63f7aade424ad9e7b1154b679fa2a14d) - 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) + 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) + 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) 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-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) sf-symbols-typescript: 2.2.0 - expo-updates-interface@56.0.2(expo@56.0.8): + expo-updates-interface@56.0.2(expo@56.0.12): dependencies: - expo: 56.0.8(63f7aade424ad9e7b1154b679fa2a14d) + 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) - 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): + 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): dependencies: '@expo/code-signing-certificates': 0.0.6 '@expo/plist': 0.7.0 @@ -16210,35 +16437,35 @@ snapshots: arg: 4.1.3 chalk: 4.1.2 debug: 4.4.3 - expo: 56.0.8(63f7aade424ad9e7b1154b679fa2a14d) + 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) expo-eas-client: 56.0.1 - expo-manifests: 56.0.4(expo@56.0.8) + expo-manifests: 56.0.4(expo@56.0.12) expo-structured-headers: 56.0.0 - expo-updates-interface: 56.0.2(expo@56.0.8) + expo-updates-interface: 56.0.2(expo@56.0.12) getenv: 2.0.0 glob: 13.0.6 ignore: 5.3.2 nullthrows: 1.1.1 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-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) resolve-from: 5.0.0 optionalDependencies: - 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-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)) transitivePeerDependencies: - supports-color - 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-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)): dependencies: - expo: 56.0.8(63f7aade424ad9e7b1154b679fa2a14d) - react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-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.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) + react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@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-widgets@56.0.16(961c4aa6f32829b318e3c87ef20ad401): + expo-widgets@56.0.19(19413efe5eaad64848598eedfe3a0fd3): dependencies: '@expo/plist': 0.7.0 - '@expo/ui': 56.0.15(961c4aa6f32829b318e3c87ef20ad401) - expo: 56.0.8(63f7aade424ad9e7b1154b679fa2a14d) + '@expo/ui': 56.0.18(19413efe5eaad64848598eedfe3a0fd3) + 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) 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-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) transitivePeerDependencies: - '@babel/core' - '@types/react' @@ -16247,37 +16474,37 @@ snapshots: - react-native-reanimated - react-native-worklets - expo@56.0.8(63f7aade424ad9e7b1154b679fa2a14d): + 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): dependencies: '@babel/runtime': 7.29.7 - '@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) + '@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) '@expo/config': 56.0.9(typescript@6.0.3) - '@expo/config-plugins': 56.0.8(typescript@6.0.3) - '@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) - '@expo/fingerprint': 0.19.3 + '@expo/config-plugins': 56.0.9(typescript@6.0.3) + '@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) + '@expo/fingerprint': 0.19.4 '@expo/local-build-cache-provider': 56.0.8(typescript@6.0.3) - '@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) + '@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) '@expo/metro': 56.0.0(bufferutil@4.1.0)(utf-8-validate@6.0.6) - '@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) + '@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) '@ungap/structured-clone': 1.3.1 - 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) - 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) - 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)) - 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)) - 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) - expo-keep-awake: 56.0.3(expo@56.0.8)(react@19.2.3) - expo-modules-autolinking: 56.0.14(typescript@6.0.3) - 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) + 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) + 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) + 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)) + 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)) + 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) + expo-keep-awake: 56.0.3(expo@56.0.12)(react@19.2.3) + expo-modules-autolinking: 56.0.16(typescript@6.0.3) + 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) pretty-format: 29.7.0 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-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) react-refresh: 0.14.2 whatwg-url-minimum: 0.1.2 optionalDependencies: - '@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) - '@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) + '@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) + '@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) 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)(bufferutil@4.1.0)(utf-8-validate@6.0.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-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) transitivePeerDependencies: - '@babel/core' - bufferutil @@ -16878,7 +17105,8 @@ snapshots: immediate@3.0.6: {} - indent-string@4.0.0: {} + indent-string@4.0.0: + optional: true indent-string@5.0.0: {} @@ -17990,7 +18218,8 @@ snapshots: mimic-response@3.1.0: {} - min-indent@1.0.1: {} + min-indent@1.0.1: + optional: true minimatch@10.2.5: dependencies: @@ -18659,7 +18888,8 @@ snapshots: dependencies: react: 19.2.6 - react-fast-compare@3.2.2: {} + react-fast-compare@3.2.2: + optional: true react-freeze@1.0.4(react@19.2.3): dependencies: @@ -18698,102 +18928,103 @@ snapshots: transitivePeerDependencies: - supports-color - react-native-drawer-layout@4.2.4(0e9729601f58a7a7ae26c76fe6017455): - dependencies: + ? 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) + : dependencies: color: 4.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-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) - 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))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) + 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) use-latest-callback: 0.2.6(react@19.2.3) + optional: true - 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): + 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): dependencies: '@egjs/hammerjs': 2.0.17 '@types/react-test-renderer': 19.1.0 hoist-non-react-statics: 3.3.2 invariant: 2.2.4 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-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) - 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): + 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): dependencies: 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-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) - 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): + 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): dependencies: 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-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) - 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): + 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): dependencies: 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-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) - 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))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) + 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) + 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-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): + 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): dependencies: 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-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: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) + 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) optionalDependencies: - 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-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-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-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): dependencies: 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-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.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)(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-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): dependencies: 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-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) - 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))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) + 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) + 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) semver: 7.8.5 - 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): + 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): dependencies: 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-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) - 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): + 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): dependencies: react: 19.2.3 react-freeze: 1.0.4(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-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) warn-once: 0.1.1 - 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): + 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): dependencies: '@shikijs/types': 4.3.0 '@shikijs/vscode-textmate': 10.0.2 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-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) - 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-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): dependencies: css-select: 5.2.2 css-tree: 1.1.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-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) warn-once: 0.1.1 - 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)): + 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)): dependencies: - react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(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))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) whatwg-url-without-unicode: 8.0.0-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)(bufferutil@4.1.0)(utf-8-validate@6.0.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-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): dependencies: escape-string-regexp: 4.0.0 invariant: 2.2.4 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-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) - 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-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): dependencies: '@babel/core': 7.29.7 '@babel/plugin-transform-arrow-functions': 7.29.7(@babel/core@7.29.7) @@ -18805,23 +19036,23 @@ snapshots: '@babel/plugin-transform-template-literals': 7.29.7(@babel/core@7.29.7) '@babel/plugin-transform-unicode-regex': 7.29.7(@babel/core@7.29.7) '@babel/preset-typescript': 7.29.7(@babel/core@7.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/metro-config': 0.85.3(@babel/core@7.29.7) convert-source-map: 2.0.0 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-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) semver: 7.8.5 transitivePeerDependencies: - supports-color - react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(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))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6): dependencies: '@react-native/assets-registry': 0.85.3 '@react-native/codegen': 0.85.3(@babel/core@7.29.7) - '@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) + '@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) '@react-native/gradle-plugin': 0.85.3 '@react-native/js-polyfills': 0.85.3 '@react-native/normalize-colors': 0.85.3 - '@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) + '@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) abort-controller: 3.0.0 anser: 1.4.10 ansi-regex: 5.0.1 @@ -18920,6 +19151,7 @@ snapshots: dependencies: indent-string: 4.0.0 strip-indent: 3.0.0 + optional: true redis-errors@1.2.0: {} @@ -19351,7 +19583,8 @@ snapshots: transitivePeerDependencies: - supports-color - server-only@0.0.1: {} + server-only@0.0.1: + optional: true setimmediate@1.0.5: {} @@ -19359,7 +19592,8 @@ snapshots: sf-symbols-typescript@2.2.0: {} - shallowequal@1.1.0: {} + shallowequal@1.1.0: + optional: true sharp@0.34.5: dependencies: @@ -19523,6 +19757,11 @@ snapshots: standard-as-callback@2.1.0: {} + standard-navigation@0.0.5: + optional: true + + standard-navigation@0.0.7: {} + standardwebhooks@1.0.0: dependencies: '@stablelib/base64': 1.0.1 @@ -19585,6 +19824,7 @@ snapshots: strip-indent@3.0.0: dependencies: min-indent: 1.0.1 + optional: true strnum@2.3.0: {} @@ -19870,14 +20110,14 @@ snapshots: universalify@2.0.1: {} - 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): + 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): dependencies: '@tailwindcss/node': 4.2.1 '@tailwindcss/oxide': 4.2.1 culori: 4.0.2 lightningcss: 1.30.1 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-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) tailwindcss: 4.3.0 unpipe@1.0.0: {} diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index ef20a23d83a..9faa2f046d0 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -20,13 +20,13 @@ supportedArchitectures: - glibc catalog: - "@clerk/backend": 3.8.3-snapshot.v20260622234151 - "@clerk/clerk-js": 6.21.0-snapshot.v20260622234151 - "@clerk/electron": 0.0.2-snapshot.v20260622234151 - "@clerk/electron-passkeys": 0.0.2-snapshot.v20260622234151 - "@clerk/expo": 3.5.3-snapshot.v20260622234151 - "@clerk/react": 6.11.0-snapshot.v20260622234151 - "@clerk/shared": 4.21.0-snapshot.v20260622234151 + "@clerk/backend": 3.8.4 + "@clerk/clerk-js": 6.22.0 + "@clerk/electron": 0.0.5 + "@clerk/electron-passkeys": 0.0.3 + "@clerk/expo": 3.6.2 + "@clerk/react": 6.11.1 + "@clerk/shared": 4.22.0 "@effect/atom-react": 4.0.0-beta.78 "@effect/openapi-generator": 4.0.0-beta.78 "@effect/platform-bun": 4.0.0-beta.78 @@ -59,22 +59,19 @@ onlyBuiltDependencies: - sharp overrides: - # Keep every Clerk consumer on the same snapshot train. Clerk publishes wallet - # auth integrations as required dependencies, but T3 Code does not support - # wallet auth, so keep that unused dependency tree out of installs. "@clerk/backend": "catalog:" "@clerk/clerk-js": "catalog:" - "@clerk/electron": "catalog:" - "@clerk/electron-passkeys": "catalog:" - "@clerk/expo": "catalog:" - "@clerk/react": "catalog:" - "@clerk/shared": "catalog:" "@clerk/clerk-js>@base-org/account": "-" "@clerk/clerk-js>@coinbase/wallet-sdk": "-" "@clerk/clerk-js>@solana/wallet-adapter-base": "-" "@clerk/clerk-js>@solana/wallet-adapter-react": "-" "@clerk/clerk-js>@solana/wallet-standard": "-" "@clerk/clerk-js>@wallet-standard/core": "-" + "@clerk/electron": "catalog:" + "@clerk/electron-passkeys": "catalog:" + "@clerk/expo": "catalog:" + "@clerk/react": "catalog:" + "@clerk/shared": "catalog:" "@effect/atom-react": "catalog:" "@effect/platform-bun": "catalog:" "@effect/platform-node": "catalog:" @@ -82,10 +79,8 @@ overrides: "@effect/sql-pg": "catalog:" "@effect/sql-sqlite-bun": "catalog:" "@effect/vitest": "catalog:" - # @effect/vitest is patched to use vite-plus/test; do not auto-install its - # upstream Vitest peer and create a second Vite/Vitest toolchain. "@effect/vitest>vitest": "-" - "@expo/metro-config": 56.0.13 + "@expo/metro-config": 56.0.14 "@pierre/diffs>@shikijs/transformers": ^4.2.0 "@types/node": "catalog:" effect: "catalog:" @@ -105,11 +100,16 @@ packageExtensions: patchedDependencies: "@effect/vitest@4.0.0-beta.78": patches/@effect__vitest@4.0.0-beta.78.patch - "@expo/metro-config@56.0.13": patches/@expo%2Fmetro-config@56.0.13.patch + "@expo/metro-config@56.0.14": patches/@expo%2Fmetro-config@56.0.14.patch "@ff-labs/fff-node@0.9.4": patches/@ff-labs__fff-node@0.9.4.patch "@pierre/diffs@1.3.0-beta.5": patches/@pierre%2Fdiffs@1.3.0-beta.5.patch + "@react-native-menu/menu@2.0.0": patches/@react-native-menu__menu@2.0.0.patch + "@react-navigation/native-stack@7.17.6": patches/@react-navigation%2Fnative-stack@7.17.6.patch effect@4.0.0-beta.78: patches/effect@4.0.0-beta.78.patch + expo-modules-jsi@56.0.10: patches/expo-modules-jsi@56.0.10.patch + react-native-gesture-handler@2.31.2: patches/react-native-gesture-handler@2.31.2.patch react-native-nitro-modules@0.35.9: patches/react-native-nitro-modules@0.35.9.patch + react-native-screens@4.25.2: patches/react-native-screens@4.25.2.patch peerDependencyRules: allowAny: