| author | |
| committer | |
| log | 32d17d3db55187b48389c005a319135b0badfea2 |
| tree | 5fad831862906af47bb282cd512b9f1bced4c520 |
| parent | 6d35a87c5d0e6dcf16be5b1d9a236a1d5e12c8c7 |
| signature | Signed by PGP key B5690EEEBB952194 |
Co-authored-by: codex <codex@users.noreply.github.com>
Co-authored-by: Julius Marminge <julius@mac.lan>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>215 files changed, 18034 insertions(+), 6008 deletions(-)
apps/mobile/.swiftlint.yml+1| ... | @@ -1,6 +1,7 @@ | ... | @@ -1,6 +1,7 @@ |
| 1 | included: | 1 | included: |
| 2 | - ios/T3Code | 2 | - ios/T3Code |
| 3 | - modules/t3-composer-editor/ios | 3 | - modules/t3-composer-editor/ios |
| 4 | - modules/t3-native-controls/ios | ||
| 4 | - modules/t3-terminal/ios | 5 | - modules/t3-terminal/ios |
| 5 | - modules/t3-review-diff/ios | 6 | - modules/t3-review-diff/ios |
| 6 | 7 |
apps/mobile/app.config.ts+14-1| ... | @@ -17,6 +17,7 @@ const VARIANT_CONFIG: Record< | ... | @@ -17,6 +17,7 @@ const VARIANT_CONFIG: Record< |
| 17 | readonly iosIcon: string; | 17 | readonly iosIcon: string; |
| 18 | readonly iosBundleIdentifier: string; | 18 | readonly iosBundleIdentifier: string; |
| 19 | readonly androidPackage: string; | 19 | readonly androidPackage: string; |
| 20 | readonly relyingParty?: string; | ||
| 20 | } | 21 | } |
| 21 | > = { | 22 | > = { |
| 22 | development: { | 23 | development: { |
| ... | @@ -25,6 +26,7 @@ const VARIANT_CONFIG: Record< | ... | @@ -25,6 +26,7 @@ const VARIANT_CONFIG: Record< |
| 25 | iosIcon: "./assets/icon-composer-dev.icon", | 26 | iosIcon: "./assets/icon-composer-dev.icon", |
| 26 | iosBundleIdentifier: "com.t3tools.t3code.dev", | 27 | iosBundleIdentifier: "com.t3tools.t3code.dev", |
| 27 | androidPackage: "com.t3tools.t3code.dev", | 28 | androidPackage: "com.t3tools.t3code.dev", |
| 29 | relyingParty: "clerk.t3.codes", | ||
| 28 | }, | 30 | }, |
| 29 | preview: { | 31 | preview: { |
| 30 | appName: "T3 Code Preview", | 32 | appName: "T3 Code Preview", |
| ... | @@ -32,6 +34,7 @@ const VARIANT_CONFIG: Record< | ... | @@ -32,6 +34,7 @@ const VARIANT_CONFIG: Record< |
| 32 | iosIcon: "./assets/icon-composer-prod.icon", | 34 | iosIcon: "./assets/icon-composer-prod.icon", |
| 33 | iosBundleIdentifier: "com.t3tools.t3code.preview", | 35 | iosBundleIdentifier: "com.t3tools.t3code.preview", |
| 34 | androidPackage: "com.t3tools.t3code.preview", | 36 | androidPackage: "com.t3tools.t3code.preview", |
| 37 | relyingParty: "clerk.t3.codes", | ||
| 35 | }, | 38 | }, |
| 36 | production: { | 39 | production: { |
| 37 | appName: "T3 Code", | 40 | appName: "T3 Code", |
| ... | @@ -39,6 +42,7 @@ const VARIANT_CONFIG: Record< | ... | @@ -39,6 +42,7 @@ const VARIANT_CONFIG: Record< |
| 39 | iosIcon: "./assets/icon-composer-prod.icon", | 42 | iosIcon: "./assets/icon-composer-prod.icon", |
| 40 | iosBundleIdentifier: "com.t3tools.t3code", | 43 | iosBundleIdentifier: "com.t3tools.t3code", |
| 41 | androidPackage: "com.t3tools.t3code", | 44 | androidPackage: "com.t3tools.t3code", |
| 45 | relyingParty: "clerk.t3.codes", | ||
| 42 | }, | 46 | }, |
| 43 | }; | 47 | }; |
| 44 | 48 | ||
| ... | @@ -77,6 +81,14 @@ const config: ExpoConfig = { | ... | @@ -77,6 +81,14 @@ const config: ExpoConfig = { |
| 77 | icon: variant.iosIcon, | 81 | icon: variant.iosIcon, |
| 78 | supportsTablet: true, | 82 | supportsTablet: true, |
| 79 | bundleIdentifier: variant.iosBundleIdentifier, | 83 | bundleIdentifier: variant.iosBundleIdentifier, |
| 84 | // Pin code signing to the T3 Tools team so non-interactive `expo run:ios` | ||
| 85 | // does not fall back to a personal team (which cannot sign app groups, | ||
| 86 | // Sign in with Apple, or push notification entitlements). | ||
| 87 | appleTeamId: "ARK85ZXQ4Z", | ||
| 88 | associatedDomains: [ | ||
| 89 | `applinks:${variant.relyingParty}`, | ||
| 90 | `webcredentials:${variant.relyingParty}`, | ||
| 91 | ], | ||
| 80 | infoPlist: { | 92 | infoPlist: { |
| 81 | NSAppTransportSecurity: { | 93 | NSAppTransportSecurity: { |
| 82 | NSAllowsArbitraryLoads: true, | 94 | NSAllowsArbitraryLoads: true, |
| ... | @@ -101,7 +113,6 @@ const config: ExpoConfig = { | ... | @@ -101,7 +113,6 @@ const config: ExpoConfig = { |
| 101 | favicon: "./assets/favicon.png", | 113 | favicon: "./assets/favicon.png", |
| 102 | }, | 114 | }, |
| 103 | plugins: [ | 115 | plugins: [ |
| 104 | "expo-router", | ||
| 105 | "expo-font", | 116 | "expo-font", |
| 106 | "expo-secure-store", | 117 | "expo-secure-store", |
| 107 | ["@clerk/expo", { theme: "./clerk-theme.json" }], | 118 | ["@clerk/expo", { theme: "./clerk-theme.json" }], |
| ... | @@ -139,6 +150,7 @@ const config: ExpoConfig = { | ... | @@ -139,6 +150,7 @@ const config: ExpoConfig = { |
| 139 | }, | 150 | }, |
| 140 | }, | 151 | }, |
| 141 | ], | 152 | ], |
| 153 | "./plugins/withIosCocoaPodsUuidCache.cjs", | ||
| 142 | [ | 154 | [ |
| 143 | "expo-widgets", | 155 | "expo-widgets", |
| 144 | { | 156 | { |
| ... | @@ -155,6 +167,7 @@ const config: ExpoConfig = { | ... | @@ -155,6 +167,7 @@ const config: ExpoConfig = { |
| 155 | ], | 167 | ], |
| 156 | }, | 168 | }, |
| 157 | ], | 169 | ], |
| 170 | "./plugins/withIosSceneLifecycle.cjs", | ||
| 158 | "./plugins/withAndroidCleartextTraffic.cjs", | 171 | "./plugins/withAndroidCleartextTraffic.cjs", |
| 159 | ], | 172 | ], |
| 160 | extra: { | 173 | extra: { |
apps/mobile/global.css+18-18| ... | @@ -199,24 +199,24 @@ | ... | @@ -199,24 +199,24 @@ |
| 199 | --font-bold: "DMSans_700Bold"; | 199 | --font-bold: "DMSans_700Bold"; |
| 200 | 200 | ||
| 201 | /* Keep this scale aligned with src/lib/typography.ts for native style props. */ | 201 | /* Keep this scale aligned with src/lib/typography.ts for native style props. */ |
| 202 | --text-3xs: 10px; | 202 | --text-3xs: 11px; |
| 203 | --text-3xs--line-height: 13px; | 203 | --text-3xs--line-height: 14px; |
| 204 | --text-2xs: 11px; | 204 | --text-2xs: 12px; |
| 205 | --text-2xs--line-height: 15px; | 205 | --text-2xs--line-height: 16px; |
| 206 | --text-xs: 12px; | 206 | --text-xs: 13px; |
| 207 | --text-xs--line-height: 16px; | 207 | --text-xs--line-height: 17px; |
| 208 | --text-sm: 13px; | 208 | --text-sm: 14px; |
| 209 | --text-sm--line-height: 18px; | 209 | --text-sm--line-height: 19px; |
| 210 | --text-base: 15px; | 210 | --text-base: 16px; |
| 211 | --text-base--line-height: 22px; | 211 | --text-base--line-height: 23px; |
| 212 | --text-lg: 17px; | 212 | --text-lg: 18px; |
| 213 | --text-lg--line-height: 22px; | 213 | --text-lg--line-height: 23px; |
| 214 | --text-xl: 20px; | 214 | --text-xl: 21px; |
| 215 | --text-xl--line-height: 26px; | 215 | --text-xl--line-height: 28px; |
| 216 | --text-2xl: 24px; | 216 | --text-2xl: 26px; |
| 217 | --text-2xl--line-height: 30px; | 217 | --text-2xl--line-height: 32px; |
| 218 | --text-3xl: 28px; | 218 | --text-3xl: 30px; |
| 219 | --text-3xl--line-height: 34px; | 219 | --text-3xl--line-height: 36px; |
| 220 | } | 220 | } |
| 221 | 221 | ||
| 222 | /* ─── Custom utilities ──────────────────────────────────────────────── */ | 222 | /* ─── Custom utilities ──────────────────────────────────────────────── */ |
apps/mobile/index.ts+10-1| ... | @@ -1,2 +1,11 @@ | ... | @@ -1,2 +1,11 @@ |
| 1 | import { registerRootComponent } from "expo"; | ||
| 1 | import "react-native-gesture-handler"; | 2 | import "react-native-gesture-handler"; |
| 2 | import "expo-router/entry"; | 3 | import { featureFlags } from "react-native-screens"; |
| 4 | |||
| 5 | import App from "./src/App"; | ||
| 6 | |||
| 7 | // Required for react-native-screens' iOS FormSheet sizing fix when a nested | ||
| 8 | // native stack is rendered inside a non-fitToContents formSheet. | ||
| 9 | featureFlags.experiment.synchronousScreenUpdatesEnabled = true; | ||
| 10 | |||
| 11 | registerRootComponent(App); |
apps/mobile/modules/t3-composer-editor/ios/T3ComposerEditorModule.swift+1| ... | @@ -47,6 +47,7 @@ public class T3ComposerEditorModule: Module { | ... | @@ -47,6 +47,7 @@ public class T3ComposerEditorModule: Module { |
| 47 | "onComposerSelectionChange", | 47 | "onComposerSelectionChange", |
| 48 | "onComposerFocus", | 48 | "onComposerFocus", |
| 49 | "onComposerBlur", | 49 | "onComposerBlur", |
| 50 | "onComposerSubmit", | ||
| 50 | "onComposerPasteImages", | 51 | "onComposerPasteImages", |
| 51 | "onComposerContentSizeChange" | 52 | "onComposerContentSizeChange" |
| 52 | ) | 53 | ) |
apps/mobile/modules/t3-composer-editor/ios/T3ComposerEditorView.swift+22| ... | @@ -63,6 +63,24 @@ private final class ComposerTextView: UITextView { | ... | @@ -63,6 +63,24 @@ private final class ComposerTextView: UITextView { |
| 63 | 63 | ||
| 64 | var onPasteImages: (([String]) -> Void)? | 64 | var onPasteImages: (([String]) -> Void)? |
| 65 | var onAttributedMutation: (() -> Void)? | 65 | var onAttributedMutation: (() -> Void)? |
| 66 | var onSubmit: (() -> Void)? | ||
| 67 | |||
| 68 | override var keyCommands: [UIKeyCommand]? { | ||
| 69 | var commands = super.keyCommands ?? [] | ||
| 70 | let submit = UIKeyCommand( | ||
| 71 | input: "\r", | ||
| 72 | modifierFlags: .command, | ||
| 73 | action: #selector(submitMessage(_:)) | ||
| 74 | ) | ||
| 75 | submit.discoverabilityTitle = "Send Message" | ||
| 76 | submit.wantsPriorityOverSystemBehavior = true | ||
| 77 | commands.append(submit) | ||
| 78 | return commands | ||
| 79 | } | ||
| 80 | |||
| 81 | @objc private func submitMessage(_ sender: UIKeyCommand) { | ||
| 82 | onSubmit?() | ||
| 83 | } | ||
| 66 | 84 | ||
| 67 | override func canPerformAction(_ action: Selector, withSender sender: Any?) -> Bool { | 85 | override func canPerformAction(_ action: Selector, withSender sender: Any?) -> Bool { |
| 68 | if action == #selector(paste(_:)) { | 86 | if action == #selector(paste(_:)) { |
| ... | @@ -299,6 +317,7 @@ public final class T3ComposerEditorView: ExpoView, UITextViewDelegate { | ... | @@ -299,6 +317,7 @@ public final class T3ComposerEditorView: ExpoView, UITextViewDelegate { |
| 299 | let onComposerSelectionChange = EventDispatcher() | 317 | let onComposerSelectionChange = EventDispatcher() |
| 300 | let onComposerFocus = EventDispatcher() | 318 | let onComposerFocus = EventDispatcher() |
| 301 | let onComposerBlur = EventDispatcher() | 319 | let onComposerBlur = EventDispatcher() |
| 320 | let onComposerSubmit = EventDispatcher() | ||
| 302 | let onComposerPasteImages = EventDispatcher() | 321 | let onComposerPasteImages = EventDispatcher() |
| 303 | let onComposerContentSizeChange = EventDispatcher() | 322 | let onComposerContentSizeChange = EventDispatcher() |
| 304 | 323 | ||
| ... | @@ -320,6 +339,9 @@ public final class T3ComposerEditorView: ExpoView, UITextViewDelegate { | ... | @@ -320,6 +339,9 @@ public final class T3ComposerEditorView: ExpoView, UITextViewDelegate { |
| 320 | textView.onAttributedMutation = { [weak self] in | 339 | textView.onAttributedMutation = { [weak self] in |
| 321 | self?.emitTextChange() | 340 | self?.emitTextChange() |
| 322 | } | 341 | } |
| 342 | textView.onSubmit = { [weak self] in | ||
| 343 | self?.onComposerSubmit([:]) | ||
| 344 | } | ||
| 323 | addSubview(textView) | 345 | addSubview(textView) |
| 324 | 346 | ||
| 325 | placeholderLabel.numberOfLines = 0 | 347 | placeholderLabel.numberOfLines = 0 |
apps/mobile/modules/t3-markdown-text/src/NativeMarkdownBlock.ios.tsx+14-5| ... | @@ -26,6 +26,15 @@ function nodeKey(node: MarkdownNode, index: number): string { | ... | @@ -26,6 +26,15 @@ function nodeKey(node: MarkdownNode, index: number): string { |
| 26 | return `${node.type}:${node.beg ?? index}:${node.end ?? index}`; | 26 | return `${node.type}:${node.beg ?? index}:${node.end ?? index}`; |
| 27 | } | 27 | } |
| 28 | 28 | ||
| 29 | /** Code inside markdown scales with the base text size (12pt at the default 15pt body). */ | ||
| 30 | function codeBlockFontSize(textStyle: NativeMarkdownTextStyle): number { | ||
| 31 | return Math.max(10, Math.round(textStyle.fontSize * 0.8)); | ||
| 32 | } | ||
| 33 | |||
| 34 | function codeBlockLineHeight(textStyle: NativeMarkdownTextStyle): number { | ||
| 35 | return codeBlockFontSize(textStyle) + 6; | ||
| 36 | } | ||
| 37 | |||
| 29 | function nodeText(node: MarkdownNode): string { | 38 | function nodeText(node: MarkdownNode): string { |
| 30 | if (node.content !== undefined) { | 39 | if (node.content !== undefined) { |
| 31 | return node.content; | 40 | return node.content; |
| ... | @@ -161,8 +170,8 @@ function HighlightedCodeText(props: { | ... | @@ -161,8 +170,8 @@ function HighlightedCodeText(props: { |
| 161 | style={{ | 170 | style={{ |
| 162 | color: props.textStyle.codeColor, | 171 | color: props.textStyle.codeColor, |
| 163 | fontFamily: "ui-monospace", | 172 | fontFamily: "ui-monospace", |
| 164 | fontSize: 12, | 173 | fontSize: codeBlockFontSize(props.textStyle), |
| 165 | lineHeight: 18, | 174 | lineHeight: codeBlockLineHeight(props.textStyle), |
| 166 | }} | 175 | }} |
| 167 | > | 176 | > |
| 168 | {props.content} | 177 | {props.content} |
| ... | @@ -196,8 +205,8 @@ function HighlightedCodeText(props: { | ... | @@ -196,8 +205,8 @@ function HighlightedCodeText(props: { |
| 196 | style={{ | 205 | style={{ |
| 197 | color: props.textStyle.codeColor, | 206 | color: props.textStyle.codeColor, |
| 198 | fontFamily: "ui-monospace", | 207 | fontFamily: "ui-monospace", |
| 199 | fontSize: 12, | 208 | fontSize: codeBlockFontSize(props.textStyle), |
| 200 | lineHeight: 18, | 209 | lineHeight: codeBlockLineHeight(props.textStyle), |
| 201 | }} | 210 | }} |
| 202 | > | 211 | > |
| 203 | {keyedLines.map((line, lineIndex) => ( | 212 | {keyedLines.map((line, lineIndex) => ( |
| ... | @@ -264,7 +273,7 @@ function NativeCodeBlock(props: { | ... | @@ -264,7 +273,7 @@ function NativeCodeBlock(props: { |
| 264 | flex: 1, | 273 | flex: 1, |
| 265 | color: props.textStyle.mutedColor, | 274 | color: props.textStyle.mutedColor, |
| 266 | fontFamily: "ui-monospace", | 275 | fontFamily: "ui-monospace", |
| 267 | fontSize: 12, | 276 | fontSize: codeBlockFontSize(props.textStyle), |
| 268 | }} | 277 | }} |
| 269 | > | 278 | > |
| 270 | {languageLabel} | 279 | {languageLabel} |
apps/mobile/modules/t3-markdown-text/src/NativeMarkdownSelectableText.ios.tsx+21-4| ... | @@ -32,11 +32,25 @@ function runKeySignature(run: NativeMarkdownTextRun): string { | ... | @@ -32,11 +32,25 @@ function runKeySignature(run: NativeMarkdownTextRun): string { |
| 32 | ].join(":"); | 32 | ].join(":"); |
| 33 | } | 33 | } |
| 34 | 34 | ||
| 35 | const DEFAULT_BODY_FONT_SIZE = 15; | ||
| 36 | const DEFAULT_HEADING_FONT_SIZES = [22, 19, 17, 16, 15, 15] as const; | ||
| 37 | |||
| 38 | function resolveHeadingFontSize(textStyle: NativeMarkdownTextStyle, headingLevel: number): number { | ||
| 39 | const index = Math.max(0, Math.min(5, headingLevel - 1)); | ||
| 40 | const configured = textStyle.headingFontSizes?.[index]; | ||
| 41 | if (typeof configured === "number" && Number.isFinite(configured)) { | ||
| 42 | return configured; | ||
| 43 | } | ||
| 44 | |||
| 45 | const scale = textStyle.fontSize / DEFAULT_BODY_FONT_SIZE; | ||
| 46 | return Math.max(12, Math.round(DEFAULT_HEADING_FONT_SIZES[index] * scale)); | ||
| 47 | } | ||
| 48 | |||
| 35 | function runStyle(run: NativeMarkdownTextRun, textStyle: NativeMarkdownTextStyle): TextStyle { | 49 | function runStyle(run: NativeMarkdownTextRun, textStyle: NativeMarkdownTextStyle): TextStyle { |
| 36 | const isFile = run.fileIcon != null; | 50 | const isFile = run.fileIcon != null; |
| 37 | const isSkill = run.skillName != null; | 51 | const isSkill = run.skillName != null; |
| 38 | const headingLevel = Math.max(1, Math.min(6, run.headingLevel ?? 1)); | 52 | const headingLevel = Math.max(1, Math.min(6, run.headingLevel ?? 1)); |
| 39 | const headingFontSize = [22, 19, 17, 16, 15, 15][headingLevel - 1] ?? 15; | 53 | const headingFontSize = resolveHeadingFontSize(textStyle, headingLevel); |
| 40 | const isHeading = run.role === "heading"; | 54 | const isHeading = run.role === "heading"; |
| 41 | const isCodeBlock = run.role === "code-block" || run.role === "code-language"; | 55 | const isCodeBlock = run.role === "code-block" || run.role === "code-language"; |
| 42 | const hasParagraphStyle = run.headIndent !== undefined; | 56 | const hasParagraphStyle = run.headIndent !== undefined; |
| ... | @@ -88,7 +102,7 @@ function runStyle(run: NativeMarkdownTextRun, textStyle: NativeMarkdownTextStyle | ... | @@ -88,7 +102,7 @@ function runStyle(run: NativeMarkdownTextRun, textStyle: NativeMarkdownTextStyle |
| 88 | : isHeading | 102 | : isHeading |
| 89 | ? headingFontSize | 103 | ? headingFontSize |
| 90 | : run.role === "code-language" | 104 | : run.role === "code-language" |
| 91 | ? 11 | 105 | ? Math.max(10, Math.round(textStyle.fontSize * 0.73)) |
| 92 | : run.code || isCodeBlock | 106 | : run.code || isCodeBlock |
| 93 | ? Math.max(12, textStyle.fontSize - 2) | 107 | ? Math.max(12, textStyle.fontSize - 2) |
| 94 | : textStyle.fontSize, | 108 | : textStyle.fontSize, |
| ... | @@ -98,9 +112,9 @@ function runStyle(run: NativeMarkdownTextRun, textStyle: NativeMarkdownTextStyle | ... | @@ -98,9 +112,9 @@ function runStyle(run: NativeMarkdownTextRun, textStyle: NativeMarkdownTextStyle |
| 98 | : run.role === "list-break" | 112 | : run.role === "list-break" |
| 99 | ? textStyle.lineHeight + (run.spacing ?? 0) | 113 | ? textStyle.lineHeight + (run.spacing ?? 0) |
| 100 | : isHeading | 114 | : isHeading |
| 101 | ? Math.max(headingFontSize + 6, 20) | 115 | ? Math.max(headingFontSize + 6, textStyle.lineHeight + 2) |
| 102 | : isCodeBlock | 116 | : isCodeBlock |
| 103 | ? 18 | 117 | ? Math.max(16, textStyle.lineHeight - 2) |
| 104 | : textStyle.lineHeight, | 118 | : textStyle.lineHeight, |
| 105 | fontStyle: run.italic ? "italic" : "normal", | 119 | fontStyle: run.italic ? "italic" : "normal", |
| 106 | fontWeight: isHeading || run.bold || isFile || isSkill ? "700" : "400", | 120 | fontWeight: isHeading || run.bold || isFile || isSkill ? "700" : "400", |
| ... | @@ -148,6 +162,9 @@ export function NativeMarkdownSelectableText(props: { | ... | @@ -148,6 +162,9 @@ export function NativeMarkdownSelectableText(props: { |
| 148 | // color-only child update can otherwise leave the previous appearance cached. | 162 | // color-only child update can otherwise leave the previous appearance cached. |
| 149 | const appearanceKey = [ | 163 | const appearanceKey = [ |
| 150 | colorScheme ?? "unspecified", | 164 | colorScheme ?? "unspecified", |
| 165 | props.textStyle.fontSize, | ||
| 166 | props.textStyle.lineHeight, | ||
| 167 | props.textStyle.headingFontSizes?.join(","), | ||
| 151 | props.textStyle.color, | 168 | props.textStyle.color, |
| 152 | props.textStyle.strongColor, | 169 | props.textStyle.strongColor, |
| 153 | props.textStyle.mutedColor, | 170 | props.textStyle.mutedColor, |
apps/mobile/modules/t3-markdown-text/src/SelectableMarkdownText.types.ts+1| ... | @@ -16,6 +16,7 @@ export interface NativeMarkdownTextStyle { | ... | @@ -16,6 +16,7 @@ export interface NativeMarkdownTextStyle { |
| 16 | readonly fontFamily: string; | 16 | readonly fontFamily: string; |
| 17 | readonly headingFontFamily: string; | 17 | readonly headingFontFamily: string; |
| 18 | readonly boldFontFamily: string; | 18 | readonly boldFontFamily: string; |
| 19 | readonly headingFontSizes?: ReadonlyArray<number>; | ||
| 19 | } | 20 | } |
| 20 | 21 | ||
| 21 | export interface MarkdownHighlightedToken { | 22 | export interface MarkdownHighlightedToken { |
apps/mobile/modules/t3-native-controls/expo-module.config.json created+6| ... | @@ -0,0 +1,6 @@ | ||
| 1 | { | ||
| 2 | "platforms": ["apple"], | ||
| 3 | "apple": { | ||
| 4 | "modules": ["T3NativeControlsModule", "T3KeyboardCommandsModule"] | ||
| 5 | } | ||
| 6 | } | ||
apps/mobile/modules/t3-native-controls/ios/T3HeaderButtonView.swift created+62| ... | @@ -0,0 +1,62 @@ | ||
| 1 | import ExpoModulesCore | ||
| 2 | import UIKit | ||
| 3 | |||
| 4 | public final class T3HeaderButtonView: ExpoView { | ||
| 5 | private static let size: CGFloat = 44 | ||
| 6 | private static let symbolSize: CGFloat = 18 | ||
| 7 | |||
| 8 | private let button = UIButton(type: .system) | ||
| 9 | private var systemImage = "circle" | ||
| 10 | |||
| 11 | let onTriggered = EventDispatcher() | ||
| 12 | |||
| 13 | public required init(appContext: AppContext? = nil) { | ||
| 14 | super.init(appContext: appContext) | ||
| 15 | |||
| 16 | isAccessibilityElement = false | ||
| 17 | button.frame = bounds | ||
| 18 | button.autoresizingMask = [.flexibleWidth, .flexibleHeight] | ||
| 19 | button.addTarget(self, action: #selector(handlePress), for: .primaryActionTriggered) | ||
| 20 | addSubview(button) | ||
| 21 | applyConfiguration() | ||
| 22 | } | ||
| 23 | |||
| 24 | public override var intrinsicContentSize: CGSize { | ||
| 25 | CGSize(width: Self.size, height: Self.size) | ||
| 26 | } | ||
| 27 | |||
| 28 | public func setLabel(_ label: String) { | ||
| 29 | button.accessibilityLabel = label | ||
| 30 | } | ||
| 31 | |||
| 32 | public func setSystemImage(_ systemImage: String) { | ||
| 33 | guard self.systemImage != systemImage else { | ||
| 34 | return | ||
| 35 | } | ||
| 36 | self.systemImage = systemImage | ||
| 37 | applyConfiguration() | ||
| 38 | } | ||
| 39 | |||
| 40 | private func applyConfiguration() { | ||
| 41 | var configuration: UIButton.Configuration | ||
| 42 | if #available(iOS 26.0, *) { | ||
| 43 | configuration = .glass() | ||
| 44 | configuration.cornerStyle = .capsule | ||
| 45 | } else { | ||
| 46 | configuration = .plain() | ||
| 47 | } | ||
| 48 | |||
| 49 | configuration.baseForegroundColor = .label | ||
| 50 | configuration.contentInsets = .zero | ||
| 51 | configuration.image = UIImage(systemName: systemImage) | ||
| 52 | configuration.preferredSymbolConfigurationForImage = UIImage.SymbolConfiguration( | ||
| 53 | pointSize: Self.symbolSize, | ||
| 54 | weight: .regular | ||
| 55 | ) | ||
| 56 | button.configuration = configuration | ||
| 57 | } | ||
| 58 | |||
| 59 | @objc private func handlePress() { | ||
| 60 | onTriggered() | ||
| 61 | } | ||
| 62 | } | ||
apps/mobile/modules/t3-native-controls/ios/T3KeyboardCommandsModule.swift created+131| ... | @@ -0,0 +1,131 @@ | ||
| 1 | import ExpoModulesCore | ||
| 2 | import UIKit | ||
| 3 | |||
| 4 | public final class T3KeyboardCommandsModule: Module { | ||
| 5 | public func definition() -> ModuleDefinition { | ||
| 6 | Name("T3KeyboardCommands") | ||
| 7 | |||
| 8 | View(T3KeyboardCommandsView.self) { | ||
| 9 | Prop("enabledCommands") { (view: T3KeyboardCommandsView, commands: [String]) in | ||
| 10 | view.setEnabledCommands(commands) | ||
| 11 | } | ||
| 12 | Events("onCommand") | ||
| 13 | } | ||
| 14 | } | ||
| 15 | } | ||
| 16 | |||
| 17 | public final class T3KeyboardCommandsView: ExpoView { | ||
| 18 | let onCommand = EventDispatcher() | ||
| 19 | private var enabledCommands = Set<String>() | ||
| 20 | |||
| 21 | public override var canBecomeFirstResponder: Bool { true } | ||
| 22 | |||
| 23 | public override var keyCommands: [UIKeyCommand]? { | ||
| 24 | [ | ||
| 25 | enabledCommand("newTask", input: "n", modifiers: .command, action: #selector(newTask), title: "New Task"), | ||
| 26 | enabledCommand("focusSearch", input: "f", modifiers: .command, action: #selector(focusSearch), title: "Find"), | ||
| 27 | enabledCommand("focusSearch", input: "k", modifiers: .command, action: #selector(focusSearch), title: "Focus Search"), | ||
| 28 | enabledCommand("back", input: "[", modifiers: .command, action: #selector(goBack), title: "Back"), | ||
| 29 | enabledCommand("files", input: "f", modifiers: [.command, .shift], action: #selector(openFiles), title: "Open Files"), | ||
| 30 | enabledCommand("terminal", input: "t", modifiers: [.command, .shift], action: #selector(openTerminal), title: "Open Terminal"), | ||
| 31 | enabledCommand("review", input: "r", modifiers: [.command, .shift], action: #selector(openReview), title: "Open Review"), | ||
| 32 | enabledCommand("toggleSidebar", input: "\\", modifiers: .command, action: #selector(handleToggleSidebar), title: "Toggle Sidebar"), | ||
| 33 | ].compactMap { $0 } | ||
| 34 | } | ||
| 35 | |||
| 36 | func setEnabledCommands(_ commands: [String]) { | ||
| 37 | enabledCommands = Set(commands) | ||
| 38 | if isFirstResponder { | ||
| 39 | resignFirstResponder() | ||
| 40 | } | ||
| 41 | reclaimFirstResponderIfAvailable() | ||
| 42 | } | ||
| 43 | |||
| 44 | private func enabledCommand( | ||
| 45 | _ identifier: String, | ||
| 46 | input: String, | ||
| 47 | modifiers: UIKeyModifierFlags, | ||
| 48 | action: Selector, | ||
| 49 | title: String | ||
| 50 | ) -> UIKeyCommand? { | ||
| 51 | guard enabledCommands.contains(identifier) else { return nil } | ||
| 52 | return command(input, modifiers: modifiers, action: action, title: title) | ||
| 53 | } | ||
| 54 | |||
| 55 | public required init(appContext: AppContext? = nil) { | ||
| 56 | super.init(appContext: appContext) | ||
| 57 | NotificationCenter.default.addObserver( | ||
| 58 | self, | ||
| 59 | selector: #selector(reclaimFirstResponderIfAvailable), | ||
| 60 | name: UITextView.textDidEndEditingNotification, | ||
| 61 | object: nil | ||
| 62 | ) | ||
| 63 | NotificationCenter.default.addObserver( | ||
| 64 | self, | ||
| 65 | selector: #selector(reclaimFirstResponderIfAvailable), | ||
| 66 | name: UITextField.textDidEndEditingNotification, | ||
| 67 | object: nil | ||
| 68 | ) | ||
| 69 | NotificationCenter.default.addObserver( | ||
| 70 | self, | ||
| 71 | selector: #selector(reclaimFirstResponderIfAvailable), | ||
| 72 | name: UIApplication.didBecomeActiveNotification, | ||
| 73 | object: nil | ||
| 74 | ) | ||
| 75 | } | ||
| 76 | |||
| 77 | deinit { | ||
| 78 | NotificationCenter.default.removeObserver(self) | ||
| 79 | } | ||
| 80 | |||
| 81 | public override func didMoveToWindow() { | ||
| 82 | super.didMoveToWindow() | ||
| 83 | reclaimFirstResponderIfAvailable() | ||
| 84 | } | ||
| 85 | |||
| 86 | public override func layoutSubviews() { | ||
| 87 | super.layoutSubviews() | ||
| 88 | reclaimFirstResponderIfAvailable() | ||
| 89 | } | ||
| 90 | |||
| 91 | private func command( | ||
| 92 | _ input: String, | ||
| 93 | modifiers: UIKeyModifierFlags, | ||
| 94 | action: Selector, | ||
| 95 | title: String | ||
| 96 | ) -> UIKeyCommand { | ||
| 97 | let command = UIKeyCommand(input: input, modifierFlags: modifiers, action: action) | ||
| 98 | command.discoverabilityTitle = title | ||
| 99 | command.wantsPriorityOverSystemBehavior = true | ||
| 100 | return command | ||
| 101 | } | ||
| 102 | |||
| 103 | @objc private func newTask() { emit("newTask") } | ||
| 104 | @objc private func focusSearch() { emit("focusSearch") } | ||
| 105 | @objc private func goBack() { emit("back") } | ||
| 106 | @objc private func openFiles() { emit("files") } | ||
| 107 | @objc private func openTerminal() { emit("terminal") } | ||
| 108 | @objc private func openReview() { emit("review") } | ||
| 109 | @objc private func handleToggleSidebar() { emit("toggleSidebar") } | ||
| 110 | |||
| 111 | private func emit(_ command: String) { | ||
| 112 | onCommand(["command": command]) | ||
| 113 | } | ||
| 114 | |||
| 115 | @objc private func reclaimFirstResponderIfAvailable() { | ||
| 116 | DispatchQueue.main.async { [weak self] in | ||
| 117 | guard let self, self.window?.t3FirstResponder == nil else { return } | ||
| 118 | self.becomeFirstResponder() | ||
| 119 | } | ||
| 120 | } | ||
| 121 | } | ||
| 122 | |||
| 123 | private extension UIView { | ||
| 124 | var t3FirstResponder: UIResponder? { | ||
| 125 | if isFirstResponder { return self } | ||
| 126 | for subview in subviews { | ||
| 127 | if let responder = subview.t3FirstResponder { return responder } | ||
| 128 | } | ||
| 129 | return nil | ||
| 130 | } | ||
| 131 | } | ||
apps/mobile/modules/t3-native-controls/ios/T3NativeControls.podspec created+19| ... | @@ -0,0 +1,19 @@ | ||
| 1 | Pod::Spec.new do |s| | ||
| 2 | s.name = 'T3NativeControls' | ||
| 3 | s.version = '1.0.0' | ||
| 4 | s.summary = 'Native UIKit controls for T3 Code mobile.' | ||
| 5 | s.description = 'UIKit-backed controls that match native iOS navigation chrome.' | ||
| 6 | s.author = 'T3 Tools' | ||
| 7 | s.homepage = 'https://t3tools.com' | ||
| 8 | s.platforms = { | ||
| 9 | :ios => '18.0', | ||
| 10 | } | ||
| 11 | s.source = { :path => '.' } | ||
| 12 | s.static_framework = true | ||
| 13 | |||
| 14 | s.dependency 'ExpoModulesCore' | ||
| 15 | s.pod_target_xcconfig = { | ||
| 16 | 'DEFINES_MODULE' => 'YES', | ||
| 17 | } | ||
| 18 | s.source_files = '**/*.{h,m,mm,swift,hpp,cpp}' | ||
| 19 | end | ||
apps/mobile/modules/t3-native-controls/ios/T3NativeControlsModule.swift created+18| ... | @@ -0,0 +1,18 @@ | ||
| 1 | import ExpoModulesCore | ||
| 2 | |||
| 3 | public final class T3NativeControlsModule: Module { | ||
| 4 | public func definition() -> ModuleDefinition { | ||
| 5 | Name("T3NativeControls") | ||
| 6 | |||
| 7 | View(T3HeaderButtonView.self) { | ||
| 8 | Prop("label") { (view: T3HeaderButtonView, label: String) in | ||
| 9 | view.setLabel(label) | ||
| 10 | } | ||
| 11 | Prop("systemImage") { (view: T3HeaderButtonView, systemImage: String) in | ||
| 12 | view.setSystemImage(systemImage) | ||
| 13 | } | ||
| 14 | |||
| 15 | Events("onTriggered") | ||
| 16 | } | ||
| 17 | } | ||
| 18 | } | ||
apps/mobile/modules/t3-review-diff/ios/T3ReviewDiffModule.swift+40-13| ... | @@ -5,22 +5,14 @@ public class T3ReviewDiffModule: Module { | ... | @@ -5,22 +5,14 @@ public class T3ReviewDiffModule: Module { |
| 5 | Name("T3ReviewDiffSurface") | 5 | Name("T3ReviewDiffSurface") |
| 6 | 6 | ||
| 7 | View(T3ReviewDiffView.self) { | 7 | View(T3ReviewDiffView.self) { |
| 8 | Prop("rowsJson") { (view: T3ReviewDiffView, rowsJson: String) in | ||
| 9 | view.setRowsJson(rowsJson) | ||
| 10 | } | ||
| 11 | |||
| 12 | Prop("tokensJson") { (view: T3ReviewDiffView, tokensJson: String) in | ||
| 13 | view.setTokensJson(tokensJson) | ||
| 14 | } | ||
| 15 | |||
| 16 | Prop("tokensPatchJson") { (view: T3ReviewDiffView, tokensPatchJson: String) in | ||
| 17 | view.setTokensPatchJson(tokensPatchJson) | ||
| 18 | } | ||
| 19 | |||
| 20 | Prop("tokensResetKey") { (view: T3ReviewDiffView, tokensResetKey: String) in | 8 | Prop("tokensResetKey") { (view: T3ReviewDiffView, tokensResetKey: String) in |
| 21 | view.setTokensResetKey(tokensResetKey) | 9 | view.setTokensResetKey(tokensResetKey) |
| 22 | } | 10 | } |
| 23 | 11 | ||
| 12 | Prop("contentResetKey") { (view: T3ReviewDiffView, contentResetKey: String) in | ||
| 13 | view.setContentResetKey(contentResetKey) | ||
| 14 | } | ||
| 15 | |||
| 24 | Prop("collapsedFileIdsJson") { (view: T3ReviewDiffView, collapsedFileIdsJson: String) in | 16 | Prop("collapsedFileIdsJson") { (view: T3ReviewDiffView, collapsedFileIdsJson: String) in |
| 25 | view.setCollapsedFileIdsJson(collapsedFileIdsJson) | 17 | view.setCollapsedFileIdsJson(collapsedFileIdsJson) |
| 26 | } | 18 | } |
| ... | @@ -61,7 +53,42 @@ public class T3ReviewDiffModule: Module { | ... | @@ -61,7 +53,42 @@ public class T3ReviewDiffModule: Module { |
| 61 | view.setInitialRowIndex(initialRowIndex) | 53 | view.setInitialRowIndex(initialRowIndex) |
| 62 | } | 54 | } |
| 63 | 55 | ||
| 64 | Events("onDebug", "onToggleFile", "onToggleViewedFile", "onPressLine", "onToggleComment") | 56 | Prop("refreshing") { (view: T3ReviewDiffView, refreshing: Bool) in |
| 57 | view.setRefreshing(refreshing) | ||
| 58 | } | ||
| 59 | |||
| 60 | Events( | ||
| 61 | "onDebug", | ||
| 62 | "onVisibleFileChange", | ||
| 63 | "onToggleFile", | ||
| 64 | "onToggleViewedFile", | ||
| 65 | "onPressLine", | ||
| 66 | "onToggleComment", | ||
| 67 | "onPullToRefresh" | ||
| 68 | ) | ||
| 69 | |||
| 70 | AsyncFunction("scrollToFile") { (view: T3ReviewDiffView, fileId: String, animated: Bool) in | ||
| 71 | view.scrollToFile(fileId, animated: animated) | ||
| 72 | } | ||
| 73 | |||
| 74 | AsyncFunction("scrollToTop") { (view: T3ReviewDiffView, animated: Bool) in | ||
| 75 | view.scrollToTop(animated: animated) | ||
| 76 | } | ||
| 77 | |||
| 78 | // Large, frequently changing JSON values cannot be regular Fabric props. Expo's | ||
| 79 | // prop adapter compares strings on the main thread before invoking a setter, which | ||
| 80 | // makes a syntax-token patch capable of blocking a frame by itself. | ||
| 81 | AsyncFunction("setRowsJson") { (view: T3ReviewDiffView, rowsJson: String) in | ||
| 82 | view.setRowsJson(rowsJson) | ||
| 83 | } | ||
| 84 | |||
| 85 | AsyncFunction("setTokensJson") { (view: T3ReviewDiffView, tokensJson: String) in | ||
| 86 | view.setTokensJson(tokensJson) | ||
| 87 | } | ||
| 88 | |||
| 89 | AsyncFunction("setTokensPatchJson") { (view: T3ReviewDiffView, tokensPatchJson: String) in | ||
| 90 | view.setTokensPatchJson(tokensPatchJson) | ||
| 91 | } | ||
| 65 | } | 92 | } |
| 66 | } | 93 | } |
| 67 | } | 94 | } |
apps/mobile/modules/t3-review-diff/ios/T3ReviewDiffView.swift+270-60| ... | @@ -1,7 +1,7 @@ | ... | @@ -1,7 +1,7 @@ |
| 1 | import ExpoModulesCore | 1 | import ExpoModulesCore |
| 2 | import UIKit | 2 | import UIKit |
| 3 | 3 | ||
| 4 | private struct ReviewDiffNativeRow: Decodable { | 4 | private struct ReviewDiffNativeRow: Decodable, Sendable { |
| 5 | let kind: String | 5 | let kind: String |
| 6 | let id: String | 6 | let id: String |
| 7 | let fileId: String? | 7 | let fileId: String? |
| ... | @@ -21,18 +21,18 @@ private struct ReviewDiffNativeRow: Decodable { | ... | @@ -21,18 +21,18 @@ private struct ReviewDiffNativeRow: Decodable { |
| 21 | let commentSectionTitle: String? | 21 | let commentSectionTitle: String? |
| 22 | } | 22 | } |
| 23 | 23 | ||
| 24 | private struct ReviewDiffNativeWordDiffRange: Decodable { | 24 | private struct ReviewDiffNativeWordDiffRange: Decodable, Sendable { |
| 25 | let start: Int | 25 | let start: Int |
| 26 | let end: Int | 26 | let end: Int |
| 27 | } | 27 | } |
| 28 | 28 | ||
| 29 | private struct ReviewDiffNativeToken: Decodable { | 29 | private struct ReviewDiffNativeToken: Decodable, Sendable { |
| 30 | let content: String | 30 | let content: String |
| 31 | let color: String? | 31 | let color: String? |
| 32 | let fontStyle: Int? | 32 | let fontStyle: Int? |
| 33 | } | 33 | } |
| 34 | 34 | ||
| 35 | private struct ReviewDiffNativeTokenPatch: Decodable { | 35 | private struct ReviewDiffNativeTokenPatch: Decodable, Sendable { |
| 36 | let resetKey: String? | 36 | let resetKey: String? |
| 37 | let chunkIndex: Int? | 37 | let chunkIndex: Int? |
| 38 | let tokensByRowId: [String: [ReviewDiffNativeToken]]? | 38 | let tokensByRowId: [String: [ReviewDiffNativeToken]]? |
| ... | @@ -312,6 +312,10 @@ private struct ReviewDiffNativeStyle { | ... | @@ -312,6 +312,10 @@ private struct ReviewDiffNativeStyle { |
| 312 | } | 312 | } |
| 313 | 313 | ||
| 314 | public final class T3ReviewDiffView: ExpoView, UIScrollViewDelegate { | 314 | public final class T3ReviewDiffView: ExpoView, UIScrollViewDelegate { |
| 315 | private let payloadDecodeQueue = DispatchQueue( | ||
| 316 | label: "com.t3tools.review-diff.payload-decode", | ||
| 317 | qos: .userInitiated | ||
| 318 | ) | ||
| 315 | private let scrollView = UIScrollView() | 319 | private let scrollView = UIScrollView() |
| 316 | private let contentView = ReviewDiffContentView() | 320 | private let contentView = ReviewDiffContentView() |
| 317 | private var rows: [ReviewDiffNativeRow] = [] | 321 | private var rows: [ReviewDiffNativeRow] = [] |
| ... | @@ -323,14 +327,25 @@ public final class T3ReviewDiffView: ExpoView, UIScrollViewDelegate { | ... | @@ -323,14 +327,25 @@ public final class T3ReviewDiffView: ExpoView, UIScrollViewDelegate { |
| 323 | private var lastMetricsDebugKey = "" | 327 | private var lastMetricsDebugKey = "" |
| 324 | private var lastVisibleRangeDebugKey = "" | 328 | private var lastVisibleRangeDebugKey = "" |
| 325 | private var tokensResetKey = "" | 329 | private var tokensResetKey = "" |
| 330 | private var contentResetKey = "" | ||
| 326 | private var initialRowIndex: Int? | 331 | private var initialRowIndex: Int? |
| 327 | private var hasAppliedInitialRowIndex = false | 332 | private var hasAppliedInitialRowIndex = false |
| 333 | private var lastVisibleFileId: String? | ||
| 334 | private var pendingScrollFileId: String? | ||
| 335 | private var pendingScrollAnimated = false | ||
| 336 | private var isProgrammaticScrollActive = false | ||
| 337 | private var rowsDecodeGeneration = 0 | ||
| 338 | private var tokensDecodeGeneration = 0 | ||
| 328 | 339 | ||
| 329 | let onDebug = EventDispatcher() | 340 | let onDebug = EventDispatcher() |
| 341 | let onVisibleFileChange = EventDispatcher() | ||
| 330 | let onToggleFile = EventDispatcher() | 342 | let onToggleFile = EventDispatcher() |
| 331 | let onToggleViewedFile = EventDispatcher() | 343 | let onToggleViewedFile = EventDispatcher() |
| 332 | let onPressLine = EventDispatcher() | 344 | let onPressLine = EventDispatcher() |
| 333 | let onToggleComment = EventDispatcher() | 345 | let onToggleComment = EventDispatcher() |
| 346 | let onPullToRefresh = EventDispatcher() | ||
| 347 | |||
| 348 | private let pullRefreshControl = UIRefreshControl() | ||
| 334 | 349 | ||
| 335 | public required init(appContext: AppContext? = nil) { | 350 | public required init(appContext: AppContext? = nil) { |
| 336 | super.init(appContext: appContext) | 351 | super.init(appContext: appContext) |
| ... | @@ -345,6 +360,8 @@ public final class T3ReviewDiffView: ExpoView, UIScrollViewDelegate { | ... | @@ -345,6 +360,8 @@ public final class T3ReviewDiffView: ExpoView, UIScrollViewDelegate { |
| 345 | scrollView.showsVerticalScrollIndicator = true | 360 | scrollView.showsVerticalScrollIndicator = true |
| 346 | scrollView.showsHorizontalScrollIndicator = false | 361 | scrollView.showsHorizontalScrollIndicator = false |
| 347 | scrollView.backgroundColor = contentView.theme.background | 362 | scrollView.backgroundColor = contentView.theme.background |
| 363 | pullRefreshControl.addTarget(self, action: #selector(handlePullToRefresh), for: .valueChanged) | ||
| 364 | scrollView.refreshControl = pullRefreshControl | ||
| 348 | addSubview(scrollView) | 365 | addSubview(scrollView) |
| 349 | 366 | ||
| 350 | contentView.backgroundColor = contentView.theme.background | 367 | contentView.backgroundColor = contentView.theme.background |
| ... | @@ -379,6 +396,13 @@ public final class T3ReviewDiffView: ExpoView, UIScrollViewDelegate { | ... | @@ -379,6 +396,13 @@ public final class T3ReviewDiffView: ExpoView, UIScrollViewDelegate { |
| 379 | updateViewportFrame() | 396 | updateViewportFrame() |
| 380 | } | 397 | } |
| 381 | 398 | ||
| 399 | public func scrollViewWillBeginDragging(_ scrollView: UIScrollView) { | ||
| 400 | // A direct gesture takes ownership from any interrupted programmatic jump. | ||
| 401 | // Resume visible-file events immediately so the inspector follows the finger. | ||
| 402 | isProgrammaticScrollActive = false | ||
| 403 | contentView.isVerticalScrollActive = true | ||
| 404 | } | ||
| 405 | |||
| 382 | public func scrollViewDidEndDragging(_ scrollView: UIScrollView, willDecelerate decelerate: Bool) { | 406 | public func scrollViewDidEndDragging(_ scrollView: UIScrollView, willDecelerate decelerate: Bool) { |
| 383 | guard !decelerate else { | 407 | guard !decelerate else { |
| 384 | return | 408 | return |
| ... | @@ -396,77 +420,125 @@ public final class T3ReviewDiffView: ExpoView, UIScrollViewDelegate { | ... | @@ -396,77 +420,125 @@ public final class T3ReviewDiffView: ExpoView, UIScrollViewDelegate { |
| 396 | } | 420 | } |
| 397 | 421 | ||
| 398 | func setRowsJson(_ rowsJson: String) { | 422 | func setRowsJson(_ rowsJson: String) { |
| 399 | guard let data = rowsJson.data(using: .utf8) else { | 423 | rowsDecodeGeneration += 1 |
| 400 | return | 424 | let generation = rowsDecodeGeneration |
| 401 | } | ||
| 402 | 425 | ||
| 403 | do { | 426 | payloadDecodeQueue.async { [weak self] in |
| 404 | rows = try JSONDecoder().decode([ReviewDiffNativeRow].self, from: data) | 427 | guard let data = rowsJson.data(using: .utf8) else { |
| 405 | contentView.rows = rows | 428 | return |
| 406 | hasAppliedInitialRowIndex = false | 429 | } |
| 407 | emitDebug("rows-decoded", [ | 430 | |
| 408 | "rows": rows.count, | 431 | do { |
| 409 | "firstKind": rows.first?.kind ?? "none", | 432 | let decodedRows = try JSONDecoder().decode([ReviewDiffNativeRow].self, from: data) |
| 410 | ]) | 433 | DispatchQueue.main.async { [weak self] in |
| 411 | updateContentMetrics() | 434 | guard let self, generation == self.rowsDecodeGeneration else { |
| 412 | } catch { | 435 | return |
| 413 | rows = [] | 436 | } |
| 414 | contentView.rows = [] | 437 | self.rows = decodedRows |
| 415 | hasAppliedInitialRowIndex = false | 438 | self.contentView.rows = decodedRows |
| 416 | updateContentMetrics() | 439 | self.hasAppliedInitialRowIndex = false |
| 417 | emitDebug("rows-decode-failed", [ | 440 | self.lastVisibleFileId = nil |
| 418 | "error": error.localizedDescription, | 441 | self.emitDebug("rows-decoded", [ |
| 419 | ]) | 442 | "rows": decodedRows.count, |
| 443 | "firstKind": decodedRows.first?.kind ?? "none", | ||
| 444 | ]) | ||
| 445 | self.updateContentMetrics() | ||
| 446 | self.applyPendingScrollIfNeeded() | ||
| 447 | } | ||
| 448 | } catch { | ||
| 449 | let message = error.localizedDescription | ||
| 450 | DispatchQueue.main.async { [weak self] in | ||
| 451 | guard let self, generation == self.rowsDecodeGeneration else { | ||
| 452 | return | ||
| 453 | } | ||
| 454 | self.rows = [] | ||
| 455 | self.contentView.rows = [] | ||
| 456 | self.hasAppliedInitialRowIndex = false | ||
| 457 | self.lastVisibleFileId = nil | ||
| 458 | self.pendingScrollFileId = nil | ||
| 459 | self.updateContentMetrics() | ||
| 460 | self.emitDebug("rows-decode-failed", ["error": message]) | ||
| 461 | } | ||
| 462 | } | ||
| 420 | } | 463 | } |
| 421 | } | 464 | } |
| 422 | 465 | ||
| 423 | func setTokensJson(_ tokensJson: String) { | 466 | func setTokensJson(_ tokensJson: String) { |
| 424 | guard let data = tokensJson.data(using: .utf8) else { | 467 | tokensDecodeGeneration += 1 |
| 425 | return | 468 | let generation = tokensDecodeGeneration |
| 426 | } | ||
| 427 | 469 | ||
| 428 | do { | 470 | payloadDecodeQueue.async { [weak self] in |
| 429 | contentView.tokensByRowId = try JSONDecoder().decode( | 471 | guard let data = tokensJson.data(using: .utf8) else { |
| 430 | [String: [ReviewDiffNativeToken]].self, | 472 | return |
| 431 | from: data | 473 | } |
| 432 | ) | 474 | |
| 433 | } catch { | 475 | do { |
| 434 | contentView.tokensByRowId = [:] | 476 | let decodedTokens = try JSONDecoder().decode( |
| 435 | emitDebug("tokens-decode-failed", [ | 477 | [String: [ReviewDiffNativeToken]].self, |
| 436 | "error": error.localizedDescription, | 478 | from: data |
| 437 | ]) | 479 | ) |
| 480 | DispatchQueue.main.async { [weak self] in | ||
| 481 | guard let self, generation == self.tokensDecodeGeneration else { | ||
| 482 | return | ||
| 483 | } | ||
| 484 | self.contentView.tokensByRowId = decodedTokens | ||
| 485 | } | ||
| 486 | } catch { | ||
| 487 | let message = error.localizedDescription | ||
| 488 | DispatchQueue.main.async { [weak self] in | ||
| 489 | guard let self, generation == self.tokensDecodeGeneration else { | ||
| 490 | return | ||
| 491 | } | ||
| 492 | self.contentView.tokensByRowId = [:] | ||
| 493 | self.emitDebug("tokens-decode-failed", ["error": message]) | ||
| 494 | } | ||
| 495 | } | ||
| 438 | } | 496 | } |
| 439 | } | 497 | } |
| 440 | 498 | ||
| 441 | func setTokensPatchJson(_ tokensPatchJson: String) { | 499 | func setTokensPatchJson(_ tokensPatchJson: String) { |
| 442 | guard let data = tokensPatchJson.data(using: .utf8) else { | 500 | let contentResetKey = self.contentResetKey |
| 443 | return | ||
| 444 | } | ||
| 445 | 501 | ||
| 446 | do { | 502 | payloadDecodeQueue.async { [weak self] in |
| 447 | let patch = try JSONDecoder().decode(ReviewDiffNativeTokenPatch.self, from: data) | 503 | guard let data = tokensPatchJson.data(using: .utf8) else { |
| 448 | if let resetKey = patch.resetKey, resetKey != tokensResetKey { | ||
| 449 | tokensResetKey = resetKey | ||
| 450 | contentView.tokensByRowId = [:] | ||
| 451 | } | ||
| 452 | |||
| 453 | let tokensByRowId = patch.tokensByRowId ?? [:] | ||
| 454 | if tokensByRowId.isEmpty { | ||
| 455 | return | 504 | return |
| 456 | } | 505 | } |
| 457 | 506 | ||
| 458 | contentView.mergeTokensByRowId(tokensByRowId) | 507 | do { |
| 459 | if let chunkIndex = patch.chunkIndex, chunkIndex < 5 || chunkIndex.isMultiple(of: 10) { | 508 | let patch = try JSONDecoder().decode(ReviewDiffNativeTokenPatch.self, from: data) |
| 460 | emitDebug("tokens-patch-decoded", [ | 509 | DispatchQueue.main.async { [weak self] in |
| 461 | "chunkIndex": chunkIndex, | 510 | guard let self else { |
| 462 | "rows": tokensByRowId.count, | 511 | return |
| 463 | "totalRows": contentView.tokensByRowId.count, | 512 | } |
| 464 | ]) | 513 | guard contentResetKey == self.contentResetKey else { |
| 514 | return | ||
| 515 | } | ||
| 516 | // A highlighter request from the previous file can finish after the view has | ||
| 517 | // already reset. Never let that stale patch roll the native token state back. | ||
| 518 | if let resetKey = patch.resetKey, resetKey != self.tokensResetKey { | ||
| 519 | return | ||
| 520 | } | ||
| 521 | |||
| 522 | let tokensByRowId = patch.tokensByRowId ?? [:] | ||
| 523 | if tokensByRowId.isEmpty { | ||
| 524 | return | ||
| 525 | } | ||
| 526 | |||
| 527 | self.contentView.mergeTokensByRowId(tokensByRowId) | ||
| 528 | if let chunkIndex = patch.chunkIndex, chunkIndex < 5 || chunkIndex.isMultiple(of: 10) { | ||
| 529 | self.emitDebug("tokens-patch-decoded", [ | ||
| 530 | "chunkIndex": chunkIndex, | ||
| 531 | "rows": tokensByRowId.count, | ||
| 532 | "totalRows": self.contentView.tokensByRowId.count, | ||
| 533 | ]) | ||
| 534 | } | ||
| 535 | } | ||
| 536 | } catch { | ||
| 537 | let message = error.localizedDescription | ||
| 538 | DispatchQueue.main.async { [weak self] in | ||
| 539 | self?.emitDebug("tokens-patch-decode-failed", ["error": message]) | ||
| 540 | } | ||
| 465 | } | 541 | } |
| 466 | } catch { | ||
| 467 | emitDebug("tokens-patch-decode-failed", [ | ||
| 468 | "error": error.localizedDescription, | ||
| 469 | ]) | ||
| 470 | } | 542 | } |
| 471 | } | 543 | } |
| 472 | 544 | ||
| ... | @@ -482,6 +554,27 @@ public final class T3ReviewDiffView: ExpoView, UIScrollViewDelegate { | ... | @@ -482,6 +554,27 @@ public final class T3ReviewDiffView: ExpoView, UIScrollViewDelegate { |
| 482 | ]) | 554 | ]) |
| 483 | } | 555 | } |
| 484 | 556 | ||
| 557 | func setContentResetKey(_ contentResetKey: String) { | ||
| 558 | guard contentResetKey != self.contentResetKey else { | ||
| 559 | return | ||
| 560 | } | ||
| 561 | |||
| 562 | self.contentResetKey = contentResetKey | ||
| 563 | rowsDecodeGeneration += 1 | ||
| 564 | tokensDecodeGeneration += 1 | ||
| 565 | contentView.tokensByRowId = [:] | ||
| 566 | rows = [] | ||
| 567 | contentView.rows = [] | ||
| 568 | hasAppliedInitialRowIndex = false | ||
| 569 | pendingScrollFileId = nil | ||
| 570 | isProgrammaticScrollActive = false | ||
| 571 | scrollView.setContentOffset(.zero, animated: false) | ||
| 572 | updateContentMetrics() | ||
| 573 | updateViewportFrame() | ||
| 574 | lastVisibleFileId = nil | ||
| 575 | applyInitialRowIndexIfNeeded() | ||
| 576 | } | ||
| 577 | |||
| 485 | func setCollapsedFileIdsJson(_ collapsedFileIdsJson: String) { | 578 | func setCollapsedFileIdsJson(_ collapsedFileIdsJson: String) { |
| 486 | let nextCollapsedFileIds = decodeFileIdSet(collapsedFileIdsJson) | 579 | let nextCollapsedFileIds = decodeFileIdSet(collapsedFileIdsJson) |
| 487 | let changedFileIds = contentView.collapsedFileIds.symmetricDifference(nextCollapsedFileIds) | 580 | let changedFileIds = contentView.collapsedFileIds.symmetricDifference(nextCollapsedFileIds) |
| ... | @@ -573,6 +666,8 @@ public final class T3ReviewDiffView: ExpoView, UIScrollViewDelegate { | ... | @@ -573,6 +666,8 @@ public final class T3ReviewDiffView: ExpoView, UIScrollViewDelegate { |
| 573 | contentView.invalidateVisibleViewport() | 666 | contentView.invalidateVisibleViewport() |
| 574 | contentView.setNeedsDisplay() | 667 | contentView.setNeedsDisplay() |
| 575 | applyInitialRowIndexIfNeeded() | 668 | applyInitialRowIndexIfNeeded() |
| 669 | applyPendingScrollIfNeeded() | ||
| 670 | emitVisibleFileIfNeeded() | ||
| 576 | 671 | ||
| 577 | let debugKey = "\(rows.count):\(Int(bounds.width)):\(Int(bounds.height)):\(Int(height))" | 672 | let debugKey = "\(rows.count):\(Int(bounds.width)):\(Int(bounds.height)):\(Int(height))" |
| 578 | if debugKey != lastMetricsDebugKey { | 673 | if debugKey != lastMetricsDebugKey { |
| ... | @@ -596,11 +691,41 @@ public final class T3ReviewDiffView: ExpoView, UIScrollViewDelegate { | ... | @@ -596,11 +691,41 @@ public final class T3ReviewDiffView: ExpoView, UIScrollViewDelegate { |
| 596 | } | 691 | } |
| 597 | 692 | ||
| 598 | private func finishVerticalScroll() { | 693 | private func finishVerticalScroll() { |
| 694 | isProgrammaticScrollActive = false | ||
| 599 | contentView.isVerticalScrollActive = false | 695 | contentView.isVerticalScrollActive = false |
| 600 | updateViewportFrame() | 696 | updateViewportFrame() |
| 601 | emitVisibleRange(reason: "scroll-end") | 697 | emitVisibleRange(reason: "scroll-end") |
| 602 | } | 698 | } |
| 603 | 699 | ||
| 700 | private func emitVisibleFileIfNeeded() { | ||
| 701 | // Keep the explicit destination selected while UIKit animates through the files | ||
| 702 | // between the old and new offsets. Emitting every intermediate header forces a | ||
| 703 | // React render per crossing and makes the navigator visibly flash. | ||
| 704 | guard !isProgrammaticScrollActive else { | ||
| 705 | return | ||
| 706 | } | ||
| 707 | |||
| 708 | // The top of the combined diff is the explicit "All files" destination. | ||
| 709 | // Treat it as a first-class selection instead of immediately resolving the | ||
| 710 | // first file header and undoing the navigator's optimistic selection. | ||
| 711 | if scrollView.contentOffset.y <= 0.5 { | ||
| 712 | guard lastVisibleFileId != nil else { | ||
| 713 | return | ||
| 714 | } | ||
| 715 | lastVisibleFileId = nil | ||
| 716 | onVisibleFileChange(["fileId": NSNull()]) | ||
| 717 | return | ||
| 718 | } | ||
| 719 | |||
| 720 | guard let fileId = contentView.visibleFileId(atVerticalOffset: scrollView.contentOffset.y), | ||
| 721 | fileId != lastVisibleFileId else { | ||
| 722 | return | ||
| 723 | } | ||
| 724 | |||
| 725 | lastVisibleFileId = fileId | ||
| 726 | onVisibleFileChange(["fileId": fileId]) | ||
| 727 | } | ||
| 728 | |||
| 604 | private func emitVisibleRange(reason: String) { | 729 | private func emitVisibleRange(reason: String) { |
| 605 | guard let range = contentView.currentVisibleRowRange() else { | 730 | guard let range = contentView.currentVisibleRowRange() else { |
| 606 | return | 731 | return |
| ... | @@ -670,6 +795,33 @@ public final class T3ReviewDiffView: ExpoView, UIScrollViewDelegate { | ... | @@ -670,6 +795,33 @@ public final class T3ReviewDiffView: ExpoView, UIScrollViewDelegate { |
| 670 | applyInitialRowIndexIfNeeded() | 795 | applyInitialRowIndexIfNeeded() |
| 671 | } | 796 | } |
| 672 | 797 | ||
| 798 | func scrollToFile(_ fileId: String, animated: Bool) { | ||
| 799 | pendingScrollFileId = fileId | ||
| 800 | pendingScrollAnimated = animated | ||
| 801 | applyPendingScrollIfNeeded() | ||
| 802 | } | ||
| 803 | |||
| 804 | func scrollToTop(animated: Bool) { | ||
| 805 | pendingScrollFileId = nil | ||
| 806 | pendingScrollAnimated = false | ||
| 807 | setVerticalContentOffset(0, animated: animated) | ||
| 808 | } | ||
| 809 | |||
| 810 | @objc private func handlePullToRefresh() { | ||
| 811 | onPullToRefresh([:]) | ||
| 812 | } | ||
| 813 | |||
| 814 | /// Driven from JS: set to false once the reload completes to dismiss the spinner. | ||
| 815 | func setRefreshing(_ refreshing: Bool) { | ||
| 816 | if refreshing { | ||
| 817 | if !pullRefreshControl.isRefreshing { | ||
| 818 | pullRefreshControl.beginRefreshing() | ||
| 819 | } | ||
| 820 | } else if pullRefreshControl.isRefreshing { | ||
| 821 | pullRefreshControl.endRefreshing() | ||
| 822 | } | ||
| 823 | } | ||
| 824 | |||
| 673 | private func applyStyle() { | 825 | private func applyStyle() { |
| 674 | contentView.style = ReviewDiffNativeStyle | 826 | contentView.style = ReviewDiffNativeStyle |
| 675 | .resolve(stylePayload) | 827 | .resolve(stylePayload) |
| ... | @@ -686,6 +838,38 @@ public final class T3ReviewDiffView: ExpoView, UIScrollViewDelegate { | ... | @@ -686,6 +838,38 @@ public final class T3ReviewDiffView: ExpoView, UIScrollViewDelegate { |
| 686 | ) | 838 | ) |
| 687 | contentView.verticalOffset = scrollView.contentOffset.y | 839 | contentView.verticalOffset = scrollView.contentOffset.y |
| 688 | contentView.invalidateVisibleViewport() | 840 | contentView.invalidateVisibleViewport() |
| 841 | emitVisibleFileIfNeeded() | ||
| 842 | } | ||
| 843 | |||
| 844 | private func applyPendingScrollIfNeeded() { | ||
| 845 | guard let fileId = pendingScrollFileId, | ||
| 846 | bounds.height > 0 else { | ||
| 847 | return | ||
| 848 | } | ||
| 849 | |||
| 850 | guard let headerOffset = contentView.fileHeaderOffset(forFileId: fileId) else { | ||
| 851 | if !rows.isEmpty { | ||
| 852 | pendingScrollFileId = nil | ||
| 853 | } | ||
| 854 | return | ||
| 855 | } | ||
| 856 | |||
| 857 | let animated = pendingScrollAnimated | ||
| 858 | pendingScrollFileId = nil | ||
| 859 | pendingScrollAnimated = false | ||
| 860 | setVerticalContentOffset(headerOffset, animated: animated) | ||
| 861 | } | ||
| 862 | |||
| 863 | private func setVerticalContentOffset(_ targetOffset: CGFloat, animated: Bool) { | ||
| 864 | let maxOffset = max(scrollView.contentSize.height - scrollView.bounds.height, 0) | ||
| 865 | let clampedOffset = min(max(targetOffset, 0), maxOffset) | ||
| 866 | let shouldAnimate = animated && abs(scrollView.contentOffset.y - clampedOffset) > 0.5 | ||
| 867 | isProgrammaticScrollActive = shouldAnimate | ||
| 868 | contentView.isVerticalScrollActive = shouldAnimate | ||
| 869 | scrollView.setContentOffset(CGPoint(x: 0, y: clampedOffset), animated: shouldAnimate) | ||
| 870 | if !shouldAnimate { | ||
| 871 | updateViewportFrame() | ||
| 872 | } | ||
| 689 | } | 873 | } |
| 690 | 874 | ||
| 691 | private func applyInitialRowIndexIfNeeded() { | 875 | private func applyInitialRowIndexIfNeeded() { |
| ... | @@ -1255,6 +1439,32 @@ private final class ReviewDiffContentView: UIView, UIGestureRecognizerDelegate { | ... | @@ -1255,6 +1439,32 @@ private final class ReviewDiffContentView: UIView, UIGestureRecognizerDelegate { |
| 1255 | return rowOffsets[rowIndex] | 1439 | return rowOffsets[rowIndex] |
| 1256 | } | 1440 | } |
| 1257 | 1441 | ||
| 1442 | func visibleFileId(atVerticalOffset verticalOffset: CGFloat) -> String? { | ||
| 1443 | guard let firstHeaderRowIndex = fileHeaderRowIndices.first else { | ||
| 1444 | return nil | ||
| 1445 | } | ||
| 1446 | |||
| 1447 | var lowerBound = 0 | ||
| 1448 | var upperBound = fileHeaderRowIndices.count | ||
| 1449 | while lowerBound < upperBound { | ||
| 1450 | let midpoint = (lowerBound + upperBound) / 2 | ||
| 1451 | let rowIndex = fileHeaderRowIndices[midpoint] | ||
| 1452 | if rowOffsets[rowIndex] <= verticalOffset + 0.5 { | ||
| 1453 | lowerBound = midpoint + 1 | ||
| 1454 | } else { | ||
| 1455 | upperBound = midpoint | ||
| 1456 | } | ||
| 1457 | } | ||
| 1458 | |||
| 1459 | let rowIndex = lowerBound > 0 | ||
| 1460 | ? fileHeaderRowIndices[lowerBound - 1] | ||
| 1461 | : firstHeaderRowIndex | ||
| 1462 | guard rows.indices.contains(rowIndex) else { | ||
| 1463 | return nil | ||
| 1464 | } | ||
| 1465 | return resolvedFileId(for: rows[rowIndex]) | ||
| 1466 | } | ||
| 1467 | |||
| 1258 | private func fileHeaderRowIndex(forFileId fileId: String) -> Int? { | 1468 | private func fileHeaderRowIndex(forFileId fileId: String) -> Int? { |
| 1259 | fileHeaderRowIndices.first { rowIndex in | 1469 | fileHeaderRowIndices.first { rowIndex in |
| 1260 | rows.indices.contains(rowIndex) && resolvedFileId(for: rows[rowIndex]) == fileId | 1470 | rows.indices.contains(rowIndex) && resolvedFileId(for: rows[rowIndex]) == fileId |
apps/mobile/modules/t3-terminal/android/src/main/java/expo/modules/t3terminal/T3TerminalModule.kt+10| ... | @@ -7,6 +7,12 @@ class T3TerminalModule : Module() { | ... | @@ -7,6 +7,12 @@ class T3TerminalModule : Module() { |
| 7 | override fun definition() = ModuleDefinition { | 7 | override fun definition() = ModuleDefinition { |
| 8 | Name("T3TerminalSurface") | 8 | Name("T3TerminalSurface") |
| 9 | 9 | ||
| 10 | // Bumped when native hardware-keyboard handling changes; surfaced in the JS debug | ||
| 11 | // logs so a stale native binary is distinguishable from a broken key pipeline. | ||
| 12 | Constants( | ||
| 13 | "hardwareKeyRevision" to 2, | ||
| 14 | ) | ||
| 15 | |||
| 10 | View(T3TerminalView::class) { | 16 | View(T3TerminalView::class) { |
| 11 | Prop("terminalKey") { view: T3TerminalView, terminalKey: String -> | 17 | Prop("terminalKey") { view: T3TerminalView, terminalKey: String -> |
| 12 | view.terminalKey = terminalKey | 18 | view.terminalKey = terminalKey |
| ... | @@ -20,6 +26,10 @@ class T3TerminalModule : Module() { | ... | @@ -20,6 +26,10 @@ class T3TerminalModule : Module() { |
| 20 | view.fontSize = fontSize.toFloat() | 26 | view.fontSize = fontSize.toFloat() |
| 21 | } | 27 | } |
| 22 | 28 | ||
| 29 | Prop("focusRequest") { view: T3TerminalView, focusRequest: Double -> | ||
| 30 | view.focusRequest = focusRequest | ||
| 31 | } | ||
| 32 | |||
| 23 | Prop("appearanceScheme") { view: T3TerminalView, appearanceScheme: String -> | 33 | Prop("appearanceScheme") { view: T3TerminalView, appearanceScheme: String -> |
| 24 | view.appearanceScheme = appearanceScheme | 34 | view.appearanceScheme = appearanceScheme |
| 25 | } | 35 | } |
apps/mobile/modules/t3-terminal/android/src/main/java/expo/modules/t3terminal/T3TerminalView.kt+19-2| ... | @@ -62,6 +62,15 @@ class T3TerminalView(context: Context, appContext: AppContext) : ExpoView(contex | ... | @@ -62,6 +62,15 @@ class T3TerminalView(context: Context, appContext: AppContext) : ExpoView(contex |
| 62 | 62 | ||
| 63 | var themeConfig: String = "" | 63 | var themeConfig: String = "" |
| 64 | 64 | ||
| 65 | var focusRequest: Double = 0.0 | ||
| 66 | set(value) { | ||
| 67 | val previous = field | ||
| 68 | field = value | ||
| 69 | if (value != previous && value > 0) { | ||
| 70 | requestKeyboardFocus() | ||
| 71 | } | ||
| 72 | } | ||
| 73 | |||
| 65 | var backgroundColorHex: String = "#24292E" | 74 | var backgroundColorHex: String = "#24292E" |
| 66 | set(value) { | 75 | set(value) { |
| 67 | field = value | 76 | field = value |
| ... | @@ -113,11 +122,19 @@ class T3TerminalView(context: Context, appContext: AppContext) : ExpoView(contex | ... | @@ -113,11 +122,19 @@ class T3TerminalView(context: Context, appContext: AppContext) : ExpoView(contex |
| 113 | } | 122 | } |
| 114 | inputView.setOnKeyListener { _, keyCode, event -> | 123 | inputView.setOnKeyListener { _, keyCode, event -> |
| 115 | if (event.action != android.view.KeyEvent.ACTION_DOWN) return@setOnKeyListener false | 124 | if (event.action != android.view.KeyEvent.ACTION_DOWN) return@setOnKeyListener false |
| 116 | when (keyCode) { | 125 | when { |
| 117 | android.view.KeyEvent.KEYCODE_DEL -> { | 126 | keyCode == android.view.KeyEvent.KEYCODE_DEL -> { |
| 118 | onInput(mapOf("data" to "\u007F")) | 127 | onInput(mapOf("data" to "\u007F")) |
| 119 | true | 128 | true |
| 120 | } | 129 | } |
| 130 | // Hardware keyboard Ctrl+A..Z -> control bytes 0x01..0x1A (Ctrl+C, Ctrl+Z, ...). | ||
| 131 | event.isCtrlPressed && | ||
| 132 | keyCode in android.view.KeyEvent.KEYCODE_A..android.view.KeyEvent.KEYCODE_Z -> { | ||
| 133 | onInput( | ||
| 134 | mapOf("data" to (keyCode - android.view.KeyEvent.KEYCODE_A + 1).toChar().toString()), | ||
| 135 | ) | ||
| 136 | true | ||
| 137 | } | ||
| 121 | else -> false | 138 | else -> false |
| 122 | } | 139 | } |
| 123 | } | 140 | } |
apps/mobile/modules/t3-terminal/ios/T3TerminalModule.swift+10| ... | @@ -4,6 +4,12 @@ public class T3TerminalModule: Module { | ... | @@ -4,6 +4,12 @@ public class T3TerminalModule: Module { |
| 4 | public func definition() -> ModuleDefinition { | 4 | public func definition() -> ModuleDefinition { |
| 5 | Name("T3TerminalSurface") | 5 | Name("T3TerminalSurface") |
| 6 | 6 | ||
| 7 | // Bumped when native hardware-keyboard handling changes; surfaced in the JS debug | ||
| 8 | // logs so a stale native binary is distinguishable from a broken key pipeline. | ||
| 9 | Constants([ | ||
| 10 | "hardwareKeyRevision": 2, | ||
| 11 | ]) | ||
| 12 | |||
| 7 | View(T3TerminalView.self) { | 13 | View(T3TerminalView.self) { |
| 8 | Prop("terminalKey") { (view: T3TerminalView, terminalKey: String) in | 14 | Prop("terminalKey") { (view: T3TerminalView, terminalKey: String) in |
| 9 | view.terminalKey = terminalKey | 15 | view.terminalKey = terminalKey |
| ... | @@ -17,6 +23,10 @@ public class T3TerminalModule: Module { | ... | @@ -17,6 +23,10 @@ public class T3TerminalModule: Module { |
| 17 | view.fontSize = CGFloat(fontSize) | 23 | view.fontSize = CGFloat(fontSize) |
| 18 | } | 24 | } |
| 19 | 25 | ||
| 26 | Prop("focusRequest") { (view: T3TerminalView, focusRequest: Double) in | ||
| 27 | view.focusRequest = focusRequest | ||
| 28 | } | ||
| 29 | |||
| 20 | Prop("appearanceScheme") { (view: T3TerminalView, appearanceScheme: String) in | 30 | Prop("appearanceScheme") { (view: T3TerminalView, appearanceScheme: String) in |
| 21 | view.appearanceScheme = appearanceScheme | 31 | view.appearanceScheme = appearanceScheme |
| 22 | } | 32 | } |
apps/mobile/modules/t3-terminal/ios/T3TerminalView.swift+128| ... | @@ -22,13 +22,129 @@ private enum GhosttyRuntime { | ... | @@ -22,13 +22,129 @@ private enum GhosttyRuntime { |
| 22 | } | 22 | } |
| 23 | } | 23 | } |
| 24 | 24 | ||
| 25 | /// Encodes hardware-keyboard combos that UITextField never surfaces through its | ||
| 26 | /// text-editing delegate (control combos, Escape, Tab, arrow keys) into the byte | ||
| 27 | /// sequences a terminal expects. | ||
| 28 | /// | ||
| 29 | /// Capture uses UIKeyCommand with `wantsPriorityOverSystemBehavior` rather than | ||
| 30 | /// `pressesBegan`: while a text field is first responder, iPadOS routes hardware key | ||
| 31 | /// events through the text-input system, which can consume presses before they reach | ||
| 32 | /// responder press callbacks. Registered key commands are matched deterministically | ||
| 33 | /// before that happens. | ||
| 34 | private enum TerminalHardwareKeyEncoder { | ||
| 35 | /// Characters that produce a control byte when combined with Ctrl. | ||
| 36 | private static let controlInputs = "abcdefghijklmnopqrstuvwxyz@[\\]^_-? " | ||
| 37 | |||
| 38 | static func makeKeyCommands(action: Selector) -> [UIKeyCommand] { | ||
| 39 | var commands: [UIKeyCommand] = [] | ||
| 40 | |||
| 41 | let specialInputs = [ | ||
| 42 | UIKeyCommand.inputEscape, | ||
| 43 | UIKeyCommand.inputUpArrow, | ||
| 44 | UIKeyCommand.inputDownArrow, | ||
| 45 | UIKeyCommand.inputLeftArrow, | ||
| 46 | UIKeyCommand.inputRightArrow, | ||
| 47 | "\t", | ||
| 48 | ] | ||
| 49 | for input in specialInputs { | ||
| 50 | commands.append(makeCommand(input: input, modifierFlags: [], action: action)) | ||
| 51 | } | ||
| 52 | commands.append(makeCommand(input: "\t", modifierFlags: .shift, action: action)) | ||
| 53 | |||
| 54 | for character in controlInputs { | ||
| 55 | commands.append(makeCommand(input: String(character), modifierFlags: .control, action: action)) | ||
| 56 | commands.append( | ||
| 57 | makeCommand(input: String(character), modifierFlags: [.control, .shift], action: action) | ||
| 58 | ) | ||
| 59 | } | ||
| 60 | |||
| 61 | return commands | ||
| 62 | } | ||
| 63 | |||
| 64 | private static func makeCommand( | ||
| 65 | input: String, | ||
| 66 | modifierFlags: UIKeyModifierFlags, | ||
| 67 | action: Selector | ||
| 68 | ) -> UIKeyCommand { | ||
| 69 | let command = UIKeyCommand(input: input, modifierFlags: modifierFlags, action: action) | ||
| 70 | command.wantsPriorityOverSystemBehavior = true | ||
| 71 | return command | ||
| 72 | } | ||
| 73 | |||
| 74 | static func sequence(input: String, modifiers: UIKeyModifierFlags) -> String? { | ||
| 75 | switch input { | ||
| 76 | case UIKeyCommand.inputEscape: | ||
| 77 | return "\u{1B}" | ||
| 78 | case UIKeyCommand.inputUpArrow: | ||
| 79 | return "\u{1B}[A" | ||
| 80 | case UIKeyCommand.inputDownArrow: | ||
| 81 | return "\u{1B}[B" | ||
| 82 | case UIKeyCommand.inputRightArrow: | ||
| 83 | return "\u{1B}[C" | ||
| 84 | case UIKeyCommand.inputLeftArrow: | ||
| 85 | return "\u{1B}[D" | ||
| 86 | case "\t": | ||
| 87 | return modifiers.contains(.shift) ? "\u{1B}[Z" : "\t" | ||
| 88 | default: | ||
| 89 | break | ||
| 90 | } | ||
| 91 | |||
| 92 | guard modifiers.contains(.control) else { return nil } | ||
| 93 | guard let scalar = input.lowercased().unicodeScalars.first else { return nil } | ||
| 94 | return controlSequence(for: scalar) | ||
| 95 | } | ||
| 96 | |||
| 97 | private static func controlSequence(for scalar: Unicode.Scalar) -> String? { | ||
| 98 | switch scalar { | ||
| 99 | case "a"..."z": | ||
| 100 | // Ctrl+A..Z -> 0x01..0x1A (Ctrl+C = ETX, Ctrl+Z = SUB, ...). | ||
| 101 | return UnicodeScalar(scalar.value - 96).map(String.init) | ||
| 102 | case " ", "@": | ||
| 103 | return "\u{00}" | ||
| 104 | case "[": | ||
| 105 | return "\u{1B}" | ||
| 106 | case "\\": | ||
| 107 | return "\u{1C}" | ||
| 108 | case "]": | ||
| 109 | return "\u{1D}" | ||
| 110 | case "^": | ||
| 111 | return "\u{1E}" | ||
| 112 | case "_", "-": | ||
| 113 | return "\u{1F}" | ||
| 114 | case "?": | ||
| 115 | return "\u{7F}" | ||
| 116 | default: | ||
| 117 | return nil | ||
| 118 | } | ||
| 119 | } | ||
| 120 | } | ||
| 121 | |||
| 25 | private final class TerminalInputField: UITextField { | 122 | private final class TerminalInputField: UITextField { |
| 26 | var onDeleteBackward: (() -> Void)? | 123 | var onDeleteBackward: (() -> Void)? |
| 124 | var onInsert: ((String) -> Void)? | ||
| 125 | |||
| 126 | private static let hardwareKeyCommands = TerminalHardwareKeyEncoder.makeKeyCommands( | ||
| 127 | action: #selector(handleHardwareKeyCommand(_:)) | ||
| 128 | ) | ||
| 129 | |||
| 130 | override var keyCommands: [UIKeyCommand]? { | ||
| 131 | Self.hardwareKeyCommands | ||
| 132 | } | ||
| 27 | 133 | ||
| 28 | override func deleteBackward() { | 134 | override func deleteBackward() { |
| 29 | onDeleteBackward?() | 135 | onDeleteBackward?() |
| 30 | super.deleteBackward() | 136 | super.deleteBackward() |
| 31 | } | 137 | } |
| 138 | |||
| 139 | @objc | ||
| 140 | private func handleHardwareKeyCommand(_ command: UIKeyCommand) { | ||
| 141 | guard let input = command.input else { return } | ||
| 142 | guard let sequence = TerminalHardwareKeyEncoder.sequence( | ||
| 143 | input: input, | ||
| 144 | modifiers: command.modifierFlags | ||
| 145 | ) else { return } | ||
| 146 | onInsert?(sequence) | ||
| 147 | } | ||
| 32 | } | 148 | } |
| 33 | 149 | ||
| 34 | private enum TerminalAppearanceScheme: String { | 150 | private enum TerminalAppearanceScheme: String { |
| ... | @@ -108,6 +224,15 @@ public final class T3TerminalView: ExpoView, UITextFieldDelegate { | ... | @@ -108,6 +224,15 @@ public final class T3TerminalView: ExpoView, UITextFieldDelegate { |
| 108 | } | 224 | } |
| 109 | } | 225 | } |
| 110 | 226 | ||
| 227 | var focusRequest: Double = 0 { | ||
| 228 | didSet { | ||
| 229 | guard oldValue != focusRequest else { return } | ||
| 230 | DispatchQueue.main.async { [weak self] in | ||
| 231 | self?.requestKeyboardFocus() | ||
| 232 | } | ||
| 233 | } | ||
| 234 | } | ||
| 235 | |||
| 111 | var appearanceScheme: String = TerminalAppearanceScheme.dark.rawValue { | 236 | var appearanceScheme: String = TerminalAppearanceScheme.dark.rawValue { |
| 112 | didSet { | 237 | didSet { |
| 113 | guard oldValue != appearanceScheme else { return } | 238 | guard oldValue != appearanceScheme else { return } |
| ... | @@ -167,6 +292,9 @@ public final class T3TerminalView: ExpoView, UITextFieldDelegate { | ... | @@ -167,6 +292,9 @@ public final class T3TerminalView: ExpoView, UITextFieldDelegate { |
| 167 | inputField.onDeleteBackward = { [weak self] in | 292 | inputField.onDeleteBackward = { [weak self] in |
| 168 | self?.emitInput("\u{7F}") | 293 | self?.emitInput("\u{7F}") |
| 169 | } | 294 | } |
| 295 | inputField.onInsert = { [weak self] data in | ||
| 296 | self?.emitInput(data) | ||
| 297 | } | ||
| 170 | 298 | ||
| 171 | focusTapGesture.addTarget(self, action: #selector(handleViewportTap)) | 299 | focusTapGesture.addTarget(self, action: #selector(handleViewportTap)) |
| 172 | terminalViewport.addGestureRecognizer(focusTapGesture) | 300 | terminalViewport.addGestureRecognizer(focusTapGesture) |
apps/mobile/package.json+23-20| ... | @@ -5,7 +5,7 @@ | ... | @@ -5,7 +5,7 @@ |
| 5 | "main": "index.ts", | 5 | "main": "index.ts", |
| 6 | "scripts": { | 6 | "scripts": { |
| 7 | "dev": "expo start --clear", | 7 | "dev": "expo start --clear", |
| 8 | "dev:client": "APP_VARIANT=development expo start --dev-client --clear", | 8 | "dev:client": "APP_VARIANT=development expo start --dev-client --scheme t3code-dev --clear", |
| 9 | "start": "expo start", | 9 | "start": "expo start", |
| 10 | "start:dev": "APP_VARIANT=development expo start", | 10 | "start:dev": "APP_VARIANT=development expo start", |
| 11 | "start:preview": "APP_VARIANT=preview expo start", | 11 | "start:preview": "APP_VARIANT=preview expo start", |
| ... | @@ -43,12 +43,16 @@ | ... | @@ -43,12 +43,16 @@ |
| 43 | "@clerk/expo": "catalog:", | 43 | "@clerk/expo": "catalog:", |
| 44 | "@effect/atom-react": "catalog:", | 44 | "@effect/atom-react": "catalog:", |
| 45 | "@expo-google-fonts/dm-sans": "^0.4.2", | 45 | "@expo-google-fonts/dm-sans": "^0.4.2", |
| 46 | "@expo/ui": "~56.0.8", | 46 | "@expo/metro-runtime": "~56.0.15", |
| 47 | "@expo/ui": "~56.0.18", | ||
| 47 | "@legendapp/list": "3.2.0", | 48 | "@legendapp/list": "3.2.0", |
| 48 | "@noble/curves": "catalog:", | 49 | "@noble/curves": "catalog:", |
| 49 | "@noble/hashes": "catalog:", | 50 | "@noble/hashes": "catalog:", |
| 50 | "@pierre/diffs": "catalog:", | 51 | "@pierre/diffs": "catalog:", |
| 51 | "@react-native-menu/menu": "^2.0.0", | 52 | "@react-native-menu/menu": "^2.0.0", |
| 53 | "@react-navigation/elements": "2.9.26", | ||
| 54 | "@react-navigation/native": "7.3.4", | ||
| 55 | "@react-navigation/native-stack": "7.17.6", | ||
| 52 | "@shikijs/core": "4.2.0", | 56 | "@shikijs/core": "4.2.0", |
| 53 | "@shikijs/engine-javascript": "4.2.0", | 57 | "@shikijs/engine-javascript": "4.2.0", |
| 54 | "@shikijs/langs": "4.2.0", | 58 | "@shikijs/langs": "4.2.0", |
| ... | @@ -62,38 +66,37 @@ | ... | @@ -62,38 +66,37 @@ |
| 62 | "clsx": "^2.1.1", | 66 | "clsx": "^2.1.1", |
| 63 | "diff": "8.0.3", | 67 | "diff": "8.0.3", |
| 64 | "effect": "catalog:", | 68 | "effect": "catalog:", |
| 65 | "expo": "^56.0.0", | 69 | "expo": "~56.0.12", |
| 66 | "expo-asset": "~56.0.15", | 70 | "expo-asset": "~56.0.17", |
| 67 | "expo-auth-session": "~56.0.12", | 71 | "expo-auth-session": "~56.0.14", |
| 68 | "expo-build-properties": "~56.0.15", | 72 | "expo-build-properties": "~56.0.19", |
| 69 | "expo-camera": "~56.0.7", | 73 | "expo-camera": "~56.0.8", |
| 70 | "expo-clipboard": "~56.0.3", | 74 | "expo-clipboard": "~56.0.4", |
| 71 | "expo-constants": "~56.0.16", | 75 | "expo-constants": "~56.0.18", |
| 72 | "expo-crypto": "~56.0.4", | 76 | "expo-crypto": "~56.0.4", |
| 73 | "expo-dev-client": "~56.0.16", | 77 | "expo-dev-client": "~56.0.20", |
| 74 | "expo-file-system": "~56.0.7", | 78 | "expo-file-system": "~56.0.8", |
| 75 | "expo-font": "~56.0.5", | 79 | "expo-font": "~56.0.7", |
| 76 | "expo-glass-effect": "~56.0.4", | 80 | "expo-glass-effect": "~56.0.4", |
| 77 | "expo-haptics": "~56.0.3", | 81 | "expo-haptics": "~56.0.3", |
| 78 | "expo-image-picker": "~56.0.14", | 82 | "expo-image-picker": "~56.0.18", |
| 79 | "expo-linking": "~56.0.12", | 83 | "expo-linking": "~56.0.14", |
| 80 | "expo-network": "~56.0.5", | 84 | "expo-network": "~56.0.5", |
| 81 | "expo-notifications": "~56.0.14", | 85 | "expo-notifications": "~56.0.18", |
| 82 | "expo-paste-input": "^0.1.15", | 86 | "expo-paste-input": "^0.1.15", |
| 83 | "expo-router": "~56.2.7", | ||
| 84 | "expo-secure-store": "~56.0.4", | 87 | "expo-secure-store": "~56.0.4", |
| 85 | "expo-splash-screen": "~56.0.10", | 88 | "expo-splash-screen": "~56.0.10", |
| 86 | "expo-symbols": "~56.0.5", | 89 | "expo-symbols": "~56.0.6", |
| 87 | "expo-updates": "~56.0.17", | 90 | "expo-updates": "~56.0.19", |
| 88 | "expo-web-browser": "~56.0.5", | 91 | "expo-web-browser": "~56.0.5", |
| 89 | "expo-widgets": "~56.0.15", | 92 | "expo-widgets": "~56.0.19", |
| 90 | "punycode": "^2.3.1", | 93 | "punycode": "^2.3.1", |
| 91 | "react": "19.2.3", | 94 | "react": "19.2.3", |
| 92 | "react-dom": "19.2.3", | 95 | "react-dom": "19.2.3", |
| 93 | "react-native": "0.85.3", | 96 | "react-native": "0.85.3", |
| 94 | "react-native-gesture-handler": "~2.31.1", | 97 | "react-native-gesture-handler": "~2.31.1", |
| 95 | "react-native-image-viewing": "^0.2.2", | 98 | "react-native-image-viewing": "^0.2.2", |
| 96 | "react-native-keyboard-controller": "1.21.7", | 99 | "react-native-keyboard-controller": "1.21.6", |
| 97 | "react-native-nitro-markdown": "^0.5.0", | 100 | "react-native-nitro-markdown": "^0.5.0", |
| 98 | "react-native-nitro-modules": "0.35.9", | 101 | "react-native-nitro-modules": "0.35.9", |
| 99 | "react-native-reanimated": "4.3.1", | 102 | "react-native-reanimated": "4.3.1", |
apps/mobile/plugins/withIosCocoaPodsUuidCache.cjs created+52| ... | @@ -0,0 +1,52 @@ | ||
| 1 | const fs = require("node:fs"); | ||
| 2 | const path = require("node:path"); | ||
| 3 | |||
| 4 | const { withDangerousMod } = require("expo/config-plugins"); | ||
| 5 | |||
| 6 | const MARKER = "# t3code: repair cached CocoaPods UUID allocation before SPM integration"; | ||
| 7 | const UUID_REPAIR = `${MARKER} | ||
| 8 | pods_project = installer.pods_project | ||
| 9 | existing_uuids = pods_project.objects.map(&:uuid) | ||
| 10 | uuid_prefix = pods_project.instance_variable_get(:@uuid_prefix)[0, 6] | ||
| 11 | sequential_uuid = /\\A#{Regexp.escape(uuid_prefix)}([0-9A-F]{7})0\\z/ | ||
| 12 | highest_index = existing_uuids.filter_map do |uuid| | ||
| 13 | match = sequential_uuid.match(uuid) | ||
| 14 | match && match[1].to_i(16) | ||
| 15 | end.max || -1 | ||
| 16 | |||
| 17 | # Pod::Project generates sequential UUIDs without collision checks because CocoaPods | ||
| 18 | # normally creates the project in this process. EAS can restore Pods.xcodeproj from | ||
| 19 | # cache, leaving the allocator behind the loaded objects. React Native's SPM support | ||
| 20 | # then reuses an existing UUID and silently corrupts the project graph. | ||
| 21 | next_index = highest_index + 1 | ||
| 22 | pods_project.instance_variable_set(:@generated_uuids, Array.new(next_index)) | ||
| 23 | pods_project.instance_variable_set(:@available_uuids, []) | ||
| 24 | pods_project.generate_available_uuid_list(1_000) | ||
| 25 | Pod::UI.puts "T3Code: reset CocoaPods UUID allocator at #{next_index} (#{existing_uuids.length} existing objects)" | ||
| 26 | `; | ||
| 27 | |||
| 28 | module.exports = function withIosCocoaPodsUuidCache(config) { | ||
| 29 | return withDangerousMod(config, [ | ||
| 30 | "ios", | ||
| 31 | (nextConfig) => { | ||
| 32 | const podfilePath = path.join(nextConfig.modRequest.platformProjectRoot, "Podfile"); | ||
| 33 | const podfile = fs.readFileSync(podfilePath, "utf8"); | ||
| 34 | |||
| 35 | if (podfile.includes(MARKER)) { | ||
| 36 | return nextConfig; | ||
| 37 | } | ||
| 38 | |||
| 39 | const postInstallStart = "post_install do |installer|\n"; | ||
| 40 | if (!podfile.includes(postInstallStart)) { | ||
| 41 | throw new Error("Unable to repair CocoaPods UUID allocation: post_install is missing."); | ||
| 42 | } | ||
| 43 | |||
| 44 | fs.writeFileSync( | ||
| 45 | podfilePath, | ||
| 46 | podfile.replace(postInstallStart, `${postInstallStart}${UUID_REPAIR}`), | ||
| 47 | "utf8", | ||
| 48 | ); | ||
| 49 | return nextConfig; | ||
| 50 | }, | ||
| 51 | ]); | ||
| 52 | }; | ||
apps/mobile/plugins/withIosSceneLifecycle.cjs created+99| ... | @@ -0,0 +1,99 @@ | ||
| 1 | const { withAppDelegate, withInfoPlist } = require("expo/config-plugins"); | ||
| 2 | |||
| 3 | const SCENE_DELEGATE = ` | ||
| 4 | |||
| 5 | class SceneDelegate: UIResponder, UIWindowSceneDelegate { | ||
| 6 | var window: UIWindow? | ||
| 7 | |||
| 8 | func scene( | ||
| 9 | _ scene: UIScene, | ||
| 10 | willConnectTo session: UISceneSession, | ||
| 11 | options connectionOptions: UIScene.ConnectionOptions | ||
| 12 | ) { | ||
| 13 | guard | ||
| 14 | let windowScene = scene as? UIWindowScene, | ||
| 15 | let appDelegate = UIApplication.shared.delegate as? AppDelegate | ||
| 16 | else { | ||
| 17 | return | ||
| 18 | } | ||
| 19 | |||
| 20 | let appWindow: UIWindow | ||
| 21 | if let existingWindow = appDelegate.window { | ||
| 22 | appWindow = existingWindow | ||
| 23 | } else { | ||
| 24 | appWindow = UIWindow(windowScene: windowScene) | ||
| 25 | appDelegate.window = appWindow | ||
| 26 | appDelegate.reactNativeFactory?.startReactNative( | ||
| 27 | withModuleName: "main", | ||
| 28 | in: appWindow, | ||
| 29 | launchOptions: nil) | ||
| 30 | } | ||
| 31 | |||
| 32 | window = appWindow | ||
| 33 | appWindow.windowScene = windowScene | ||
| 34 | appWindow.makeKeyAndVisible() | ||
| 35 | |||
| 36 | if let url = connectionOptions.urlContexts.first?.url { | ||
| 37 | _ = appDelegate.application(UIApplication.shared, open: url, options: [:]) | ||
| 38 | } | ||
| 39 | |||
| 40 | if let userActivity = connectionOptions.userActivities.first { | ||
| 41 | _ = appDelegate.application( | ||
| 42 | UIApplication.shared, | ||
| 43 | continue: userActivity, | ||
| 44 | restorationHandler: { _ in }) | ||
| 45 | } | ||
| 46 | } | ||
| 47 | |||
| 48 | func scene(_ scene: UIScene, openURLContexts URLContexts: Set<UIOpenURLContext>) { | ||
| 49 | guard | ||
| 50 | let url = URLContexts.first?.url, | ||
| 51 | let appDelegate = UIApplication.shared.delegate as? AppDelegate | ||
| 52 | else { | ||
| 53 | return | ||
| 54 | } | ||
| 55 | |||
| 56 | _ = appDelegate.application(UIApplication.shared, open: url, options: [:]) | ||
| 57 | } | ||
| 58 | |||
| 59 | func scene(_ scene: UIScene, continue userActivity: NSUserActivity) { | ||
| 60 | guard let appDelegate = UIApplication.shared.delegate as? AppDelegate else { | ||
| 61 | return | ||
| 62 | } | ||
| 63 | |||
| 64 | _ = appDelegate.application( | ||
| 65 | UIApplication.shared, | ||
| 66 | continue: userActivity, | ||
| 67 | restorationHandler: { _ in }) | ||
| 68 | } | ||
| 69 | }`; | ||
| 70 | |||
| 71 | module.exports = function withIosSceneLifecycle(config) { | ||
| 72 | config = withInfoPlist(config, (nextConfig) => { | ||
| 73 | nextConfig.modResults.UIApplicationSceneManifest = { | ||
| 74 | UIApplicationSupportsMultipleScenes: false, | ||
| 75 | UISceneConfigurations: { | ||
| 76 | UIWindowSceneSessionRoleApplication: [ | ||
| 77 | { | ||
| 78 | UISceneConfigurationName: "Default Configuration", | ||
| 79 | UISceneDelegateClassName: "$(PRODUCT_MODULE_NAME).SceneDelegate", | ||
| 80 | }, | ||
| 81 | ], | ||
| 82 | }, | ||
| 83 | }; | ||
| 84 | |||
| 85 | return nextConfig; | ||
| 86 | }); | ||
| 87 | |||
| 88 | return withAppDelegate(config, (nextConfig) => { | ||
| 89 | if (nextConfig.modResults.language !== "swift") { | ||
| 90 | throw new Error("The iOS scene lifecycle plugin requires a Swift AppDelegate."); | ||
| 91 | } | ||
| 92 | |||
| 93 | if (!nextConfig.modResults.contents.includes("class SceneDelegate:")) { | ||
| 94 | nextConfig.modResults.contents += SCENE_DELEGATE; | ||
| 95 | } | ||
| 96 | |||
| 97 | return nextConfig; | ||
| 98 | }); | ||
| 99 | }; | ||
apps/mobile/src/App.tsx created+72| ... | @@ -0,0 +1,72 @@ | ||
| 1 | import { | ||
| 2 | DMSans_400Regular, | ||
| 3 | DMSans_500Medium, | ||
| 4 | DMSans_700Bold, | ||
| 5 | useFonts, | ||
| 6 | } from "@expo-google-fonts/dm-sans"; | ||
| 7 | import * as Linking from "expo-linking"; | ||
| 8 | import * as SplashScreen from "expo-splash-screen"; | ||
| 9 | import { StatusBar, useColorScheme } from "react-native"; | ||
| 10 | import { GestureHandlerRootView } from "react-native-gesture-handler"; | ||
| 11 | import { KeyboardProvider } from "react-native-keyboard-controller"; | ||
| 12 | import { SafeAreaProvider } from "react-native-safe-area-context"; | ||
| 13 | import { createStaticNavigation, DarkTheme, DefaultTheme } from "@react-navigation/native"; | ||
| 14 | |||
| 15 | import { RegistryContext } from "@effect/atom-react"; | ||
| 16 | import { useEffect } from "react"; | ||
| 17 | import { CloudAuthProvider } from "./features/cloud/CloudAuthProvider"; | ||
| 18 | import { AppearancePreferencesProvider } from "./features/settings/appearance/AppearancePreferencesProvider"; | ||
| 19 | import { RootStack } from "./Stack"; | ||
| 20 | import { appAtomRegistry } from "./state/atom-registry"; | ||
| 21 | import { useThemeColor } from "./lib/useThemeColor"; | ||
| 22 | |||
| 23 | import "../global.css"; | ||
| 24 | |||
| 25 | const appLinking = { | ||
| 26 | prefixes: [Linking.createURL("/"), "t3code://", "t3code-dev://", "t3code-preview://"], | ||
| 27 | }; | ||
| 28 | |||
| 29 | const Navigation = createStaticNavigation(RootStack); | ||
| 30 | |||
| 31 | export default function App() { | ||
| 32 | const [fontsLoaded] = useFonts({ | ||
| 33 | DMSans_400Regular, | ||
| 34 | DMSans_500Medium, | ||
| 35 | DMSans_700Bold, | ||
| 36 | }); | ||
| 37 | const colorScheme = useColorScheme(); | ||
| 38 | const statusBarBg = useThemeColor("--color-status-bar"); | ||
| 39 | |||
| 40 | useEffect(() => { | ||
| 41 | if (fontsLoaded) SplashScreen.hide(); | ||
| 42 | }, [fontsLoaded]); | ||
| 43 | |||
| 44 | return ( | ||
| 45 | <RegistryContext.Provider value={appAtomRegistry}> | ||
| 46 | <CloudAuthProvider> | ||
| 47 | <AppearancePreferencesProvider> | ||
| 48 | <GestureHandlerRootView style={{ flex: 1 }}> | ||
| 49 | <KeyboardProvider statusBarTranslucent> | ||
| 50 | <SafeAreaProvider> | ||
| 51 | <StatusBar | ||
| 52 | barStyle={colorScheme === "dark" ? "light-content" : "dark-content"} | ||
| 53 | backgroundColor={statusBarBg} | ||
| 54 | translucent | ||
| 55 | /> | ||
| 56 | {/* The navigation theme drives the NATIVE header appearance: native-stack | ||
| 57 | forwards `dark` as the nav bar's overrideUserInterfaceStyle. Without | ||
| 58 | this, React Navigation defaults to its light theme and every native | ||
| 59 | header (glass buttons, title, materials) is forced light even when | ||
| 60 | the system is in dark mode. */} | ||
| 61 | <Navigation | ||
| 62 | linking={appLinking} | ||
| 63 | theme={colorScheme === "dark" ? DarkTheme : DefaultTheme} | ||
| 64 | /> | ||
| 65 | </SafeAreaProvider> | ||
| 66 | </KeyboardProvider> | ||
| 67 | </GestureHandlerRootView> | ||
| 68 | </AppearancePreferencesProvider> | ||
| 69 | </CloudAuthProvider> | ||
| 70 | </RegistryContext.Provider> | ||
| 71 | ); | ||
| 72 | } | ||
apps/mobile/src/Stack.tsx created+463| ... | @@ -0,0 +1,463 @@ | ||
| 1 | import { | ||
| 2 | createPathConfigForStaticNavigation, | ||
| 3 | getPathFromState, | ||
| 4 | NavigationState, | ||
| 5 | StackActions, | ||
| 6 | useNavigation, | ||
| 7 | } from "@react-navigation/native"; | ||
| 8 | import { | ||
| 9 | createNativeStackNavigator, | ||
| 10 | createNativeStackScreen, | ||
| 11 | type NativeStackNavigationOptions, | ||
| 12 | } from "@react-navigation/native-stack"; | ||
| 13 | import { DynamicColorIOS, Platform, Pressable, ScrollView, StyleSheet } from "react-native"; | ||
| 14 | import { useResolveClassNames } from "uniwind"; | ||
| 15 | |||
| 16 | import { AppText as Text } from "./components/AppText"; | ||
| 17 | import { ArchivedThreadsRouteScreen } from "./features/archive/ArchivedThreadsRouteScreen"; | ||
| 18 | import { useAgentNotificationNavigation } from "./features/agent-awareness/notificationNavigation"; | ||
| 19 | import { ClerkSettingsSheetDetentProvider } from "./features/cloud/ClerkSettingsSheetDetent"; | ||
| 20 | import { ThreadFilesTreeScreen, ThreadFileScreen } from "./features/files/ThreadFilesRouteScreen"; | ||
| 21 | import { AdaptiveWorkspaceLayout } from "./features/layout/AdaptiveWorkspaceLayout"; | ||
| 22 | import { HardwareKeyboardCommandProvider } from "./features/keyboard/HardwareKeyboardCommandProvider"; | ||
| 23 | import { ReviewCommentComposerSheet } from "./features/review/ReviewCommentComposerSheet"; | ||
| 24 | import { ReviewSheet } from "./features/review/ReviewSheet"; | ||
| 25 | import { ThreadTerminalRouteScreen } from "./features/terminal/ThreadTerminalRouteScreen"; | ||
| 26 | import { GitBranchesSheet } from "./features/threads/git/GitBranchesSheet"; | ||
| 27 | import { GitCommitSheet } from "./features/threads/git/GitCommitSheet"; | ||
| 28 | import { GitConfirmSheet } from "./features/threads/git/GitConfirmSheet"; | ||
| 29 | import { GitOverviewSheet } from "./features/threads/git/GitOverviewSheet"; | ||
| 30 | import { ThreadRouteScreen } from "./features/threads/ThreadRouteScreen"; | ||
| 31 | import { ConnectionsRouteScreen } from "./features/connection/ConnectionsRouteScreen"; | ||
| 32 | import { ConnectionsNewRouteScreen } from "./features/connection/ConnectionsNewRouteScreen"; | ||
| 33 | import { HomeRouteScreen } from "./features/home/HomeRouteScreen"; | ||
| 34 | import { AddProjectDestinationRoute } from "./features/projects/AddProjectDestinationRoute"; | ||
| 35 | import { AddProjectLocalRoute } from "./features/projects/AddProjectLocalRoute"; | ||
| 36 | import { AddProjectRepositoryRoute } from "./features/projects/AddProjectRepositoryRoute"; | ||
| 37 | import { AddProjectSourceRoute } from "./features/projects/AddProjectSourceRoute"; | ||
| 38 | import { NewTaskDraftRouteScreen } from "./features/threads/NewTaskDraftRouteScreen"; | ||
| 39 | import { NewTaskFlowProvider } from "./features/threads/new-task-flow-provider"; | ||
| 40 | import { NewTaskRouteScreen } from "./features/threads/NewTaskRouteScreen"; | ||
| 41 | import { SettingsAppearanceRouteScreen } from "./features/settings/SettingsAppearanceRouteScreen"; | ||
| 42 | import { SettingsAuthRouteScreen } from "./features/settings/SettingsAuthRouteScreen"; | ||
| 43 | import { SettingsEnvironmentsRouteScreen } from "./features/settings/SettingsEnvironmentsRouteScreen"; | ||
| 44 | import { SettingsRouteScreen } from "./features/settings/SettingsRouteScreen"; | ||
| 45 | import { SettingsWaitlistRouteScreen } from "./features/settings/SettingsWaitlistRouteScreen"; | ||
| 46 | import { nativeHeaderScrollEdgeEffects } from "./native/StackHeader"; | ||
| 47 | import { useThreadOutboxDrain } from "./state/use-thread-outbox-drain"; | ||
| 48 | |||
| 49 | const HEADER_SCROLL_EDGE_EFFECTS = nativeHeaderScrollEdgeEffects(Platform.OS, Platform.Version); | ||
| 50 | |||
| 51 | // Matches --color-sheet in global.css (light/dark). DynamicColorIOS lets the header | ||
| 52 | // background stay STATIC config while still adapting to appearance changes. | ||
| 53 | const SHEET_BACKGROUND_COLOR = | ||
| 54 | Platform.OS === "ios" | ||
| 55 | ? DynamicColorIOS({ light: "rgba(242, 242, 247, 0.98)", dark: "rgba(14, 14, 14, 0.98)" }) | ||
| 56 | : undefined; | ||
| 57 | |||
| 58 | type AppScreenOptions = NativeStackNavigationOptions & { | ||
| 59 | readonly unstable_navigationItemStyle?: "editor"; | ||
| 60 | }; | ||
| 61 | |||
| 62 | // Shared header presets. Screens only override genuinely dynamic values (titles, | ||
| 63 | // subtitles, toolbar items, search callbacks) via NativeStackScreenOptions. | ||
| 64 | // | ||
| 65 | // GLASS: transparent header over the screen's primary scroll view, with the iOS 26 | ||
| 66 | // scroll-edge blur sampling the content (Home, Thread, Files tree, settings sheet). | ||
| 67 | const GLASS_HEADER_OPTIONS: AppScreenOptions = { | ||
| 68 | headerBackButtonDisplayMode: "minimal", | ||
| 69 | headerBackTitle: "", | ||
| 70 | headerLargeTitle: false, | ||
| 71 | headerShadowVisible: false, | ||
| 72 | headerShown: true, | ||
| 73 | headerStyle: Platform.OS === "ios" ? { backgroundColor: "transparent" } : undefined, | ||
| 74 | headerTitleStyle: { fontSize: 18, fontWeight: "800" }, | ||
| 75 | headerTransparent: Platform.OS === "ios", | ||
| 76 | scrollEdgeEffects: Platform.OS === "ios" ? HEADER_SCROLL_EDGE_EFFECTS : undefined, | ||
| 77 | unstable_navigationItemStyle: Platform.OS === "ios" ? "editor" : undefined, | ||
| 78 | }; | ||
| 79 | |||
| 80 | // SOLID: opaque sheet-colored header for surfaces whose content scrolls internally | ||
| 81 | // (file viewer, terminal, review) — there is nothing for glass to sample there. | ||
| 82 | const SOLID_HEADER_OPTIONS: AppScreenOptions = { | ||
| 83 | headerBackButtonDisplayMode: "minimal", | ||
| 84 | headerBackTitle: "", | ||
| 85 | headerLargeTitle: false, | ||
| 86 | headerShadowVisible: false, | ||
| 87 | headerShown: true, | ||
| 88 | headerStyle: | ||
| 89 | SHEET_BACKGROUND_COLOR !== undefined | ||
| 90 | ? // native-stack types this as `string`, but the native side accepts any | ||
| 91 | // ColorValue including DynamicColorIOS. | ||
| 92 | { backgroundColor: SHEET_BACKGROUND_COLOR as unknown as string } | ||
| 93 | : undefined, | ||
| 94 | headerTitleStyle: { fontSize: 18, fontWeight: "800" }, | ||
| 95 | headerTransparent: false, | ||
| 96 | unstable_navigationItemStyle: Platform.OS === "ios" ? "editor" : undefined, | ||
| 97 | }; | ||
| 98 | |||
| 99 | // Solid header variant for screens inside sheets (centered title, no editor style). | ||
| 100 | const SHEET_SOLID_HEADER_OPTIONS: AppScreenOptions = { | ||
| 101 | ...SOLID_HEADER_OPTIONS, | ||
| 102 | unstable_navigationItemStyle: undefined, | ||
| 103 | }; | ||
| 104 | |||
| 105 | const SettingsSheetStack = createNativeStackNavigator({ | ||
| 106 | initialRouteName: "Settings", | ||
| 107 | screenOptions: { | ||
| 108 | ...GLASS_HEADER_OPTIONS, | ||
| 109 | // Sheets read better with the iOS-default centered title (no editor style). | ||
| 110 | unstable_navigationItemStyle: undefined, | ||
| 111 | }, | ||
| 112 | screens: { | ||
| 113 | Settings: createNativeStackScreen({ | ||
| 114 | screen: SettingsRouteScreen, | ||
| 115 | linking: "", | ||
| 116 | options: { | ||
| 117 | title: "Settings", | ||
| 118 | }, | ||
| 119 | }), | ||
| 120 | SettingsEnvironments: createNativeStackScreen({ | ||
| 121 | screen: SettingsEnvironmentsRouteScreen, | ||
| 122 | linking: "environments", | ||
| 123 | options: { | ||
| 124 | title: "Environments", | ||
| 125 | }, | ||
| 126 | }), | ||
| 127 | SettingsEnvironmentNew: createNativeStackScreen({ | ||
| 128 | screen: ConnectionsNewRouteScreen, | ||
| 129 | linking: "environment-new", | ||
| 130 | options: { | ||
| 131 | title: "Add Environment", | ||
| 132 | }, | ||
| 133 | }), | ||
| 134 | SettingsArchive: createNativeStackScreen({ | ||
| 135 | screen: ArchivedThreadsRouteScreen, | ||
| 136 | linking: "archive", | ||
| 137 | options: { | ||
| 138 | title: "Archived Threads", | ||
| 139 | }, | ||
| 140 | }), | ||
| 141 | SettingsAppearance: createNativeStackScreen({ | ||
| 142 | screen: SettingsAppearanceRouteScreen, | ||
| 143 | linking: "appearance", | ||
| 144 | options: { | ||
| 145 | title: "Appearance", | ||
| 146 | }, | ||
| 147 | }), | ||
| 148 | SettingsAuth: createNativeStackScreen({ | ||
| 149 | screen: SettingsAuthRouteScreen, | ||
| 150 | linking: "auth", | ||
| 151 | options: { | ||
| 152 | title: "Sign in", | ||
| 153 | }, | ||
| 154 | }), | ||
| 155 | SettingsWaitlist: createNativeStackScreen({ | ||
| 156 | screen: SettingsWaitlistRouteScreen, | ||
| 157 | linking: "waitlist", | ||
| 158 | options: { | ||
| 159 | title: "Join the waitlist", | ||
| 160 | }, | ||
| 161 | }), | ||
| 162 | }, | ||
| 163 | }); | ||
| 164 | |||
| 165 | // Thread routes live FLAT in the root stack (not in a nested navigator). A nested | ||
| 166 | // stack means a second UINavigationController with its own UINavigationBar, which | ||
| 167 | // breaks iOS 26's shared-header morphing between Home and Thread (each pair inside | ||
| 168 | // one bar morphs; across two bars the whole screen slides). Flat linking paths keep | ||
| 169 | // the same deep-link URLs the nested config produced. | ||
| 170 | const THREAD_LINKING_PREFIX = "threads/:environmentId/:threadId"; | ||
| 171 | |||
| 172 | // New-task / add-project flow: nested navigator inside the formSheet (Settings-sheet | ||
| 173 | // pattern — a plain formSheet screen cannot render a stack header; the header and | ||
| 174 | // in-sheet pushes come from this nested stack). | ||
| 175 | const NewTaskSheetStack = createNativeStackNavigator({ | ||
| 176 | initialRouteName: "NewTask", | ||
| 177 | screenOptions: { | ||
| 178 | ...GLASS_HEADER_OPTIONS, | ||
| 179 | // Sheets read better with the iOS-default centered title (no editor style). | ||
| 180 | unstable_navigationItemStyle: undefined, | ||
| 181 | }, | ||
| 182 | screens: { | ||
| 183 | NewTask: createNativeStackScreen({ | ||
| 184 | screen: NewTaskRouteScreen, | ||
| 185 | linking: "", | ||
| 186 | options: { | ||
| 187 | title: "Choose project", | ||
| 188 | }, | ||
| 189 | }), | ||
| 190 | NewTaskDraft: createNativeStackScreen({ | ||
| 191 | screen: NewTaskDraftRouteScreen, | ||
| 192 | linking: "draft", | ||
| 193 | // The draft composer has no scroll view for glass to sample; a solid | ||
| 194 | // header also lays the content out below the bar (no manual inset). | ||
| 195 | options: SHEET_SOLID_HEADER_OPTIONS, | ||
| 196 | }), | ||
| 197 | AddProject: createNativeStackScreen({ | ||
| 198 | screen: AddProjectSourceRoute, | ||
| 199 | linking: "add-project", | ||
| 200 | options: { | ||
| 201 | title: "Add Project", | ||
| 202 | }, | ||
| 203 | }), | ||
| 204 | AddProjectRepository: createNativeStackScreen({ | ||
| 205 | screen: AddProjectRepositoryRoute, | ||
| 206 | linking: "add-project/repository", | ||
| 207 | }), | ||
| 208 | AddProjectDestination: createNativeStackScreen({ | ||
| 209 | screen: AddProjectDestinationRoute, | ||
| 210 | linking: "add-project/destination", | ||
| 211 | }), | ||
| 212 | AddProjectLocal: createNativeStackScreen({ | ||
| 213 | screen: AddProjectLocalRoute, | ||
| 214 | linking: "add-project/local", | ||
| 215 | }), | ||
| 216 | }, | ||
| 217 | }); | ||
| 218 | |||
| 219 | // Routes presented as sheets/overlays ON TOP of the workspace. They must not | ||
| 220 | // influence the adaptive workspace layout: opening Settings over Home should | ||
| 221 | // not flip the sidebar in or change the active thread. | ||
| 222 | const WORKSPACE_OVERLAY_ROUTES = new Set([ | ||
| 223 | "Connections", | ||
| 224 | "ConnectionsNew", | ||
| 225 | "GitBranches", | ||
| 226 | "GitCommit", | ||
| 227 | "GitConfirm", | ||
| 228 | "GitOverview", | ||
| 229 | "NewTaskSheet", | ||
| 230 | "SettingsSheet", | ||
| 231 | "ThreadReviewComment", | ||
| 232 | ]); | ||
| 233 | |||
| 234 | /** | ||
| 235 | * Pathname of the topmost NON-overlay route — the screen the workspace is | ||
| 236 | * actually "on", regardless of any sheets floating above it. | ||
| 237 | */ | ||
| 238 | function workspacePathFromState(state: NavigationState): string { | ||
| 239 | const routes = state.routes.filter((route) => !WORKSPACE_OVERLAY_ROUTES.has(route.name)); | ||
| 240 | const effectiveState = | ||
| 241 | routes.length > 0 && routes.length !== state.routes.length | ||
| 242 | ? ({ ...state, routes, index: routes.length - 1 } as NavigationState) | ||
| 243 | : state; | ||
| 244 | const path = getPathFromState(effectiveState, navigationPathConfig); | ||
| 245 | return path.startsWith("/") ? path : `/${path}`; | ||
| 246 | } | ||
| 247 | |||
| 248 | function RootStackLayout(props: { | ||
| 249 | readonly children: React.ReactNode; | ||
| 250 | readonly state: NavigationState; | ||
| 251 | }) { | ||
| 252 | useAgentNotificationNavigation(); | ||
| 253 | useThreadOutboxDrain(); | ||
| 254 | // Full pathname (sheets included) for keyboard-command scoping; the | ||
| 255 | // workspace layout only reacts to the underlying non-overlay route. | ||
| 256 | const path = getPathFromState(props.state, navigationPathConfig); | ||
| 257 | const pathname = path.startsWith("/") ? path : `/${path}`; | ||
| 258 | const workspacePathname = workspacePathFromState(props.state); | ||
| 259 | |||
| 260 | return ( | ||
| 261 | <HardwareKeyboardCommandProvider pathname={pathname}> | ||
| 262 | <ClerkSettingsSheetDetentProvider initiallyExpanded={false}> | ||
| 263 | <AdaptiveWorkspaceLayout pathname={workspacePathname}> | ||
| 264 | {props.children} | ||
| 265 | </AdaptiveWorkspaceLayout> | ||
| 266 | </ClerkSettingsSheetDetentProvider> | ||
| 267 | </HardwareKeyboardCommandProvider> | ||
| 268 | ); | ||
| 269 | } | ||
| 270 | |||
| 271 | function NotFoundScreen() { | ||
| 272 | const navigation = useNavigation(); | ||
| 273 | const screenBgStyle = StyleSheet.flatten(useResolveClassNames("bg-screen")); | ||
| 274 | const primaryBgStyle = StyleSheet.flatten(useResolveClassNames("bg-primary")); | ||
| 275 | const returnHomeButtonStyle = StyleSheet.flatten([ | ||
| 276 | { | ||
| 277 | borderRadius: 999, | ||
| 278 | paddingHorizontal: 20, | ||
| 279 | paddingVertical: 14, | ||
| 280 | }, | ||
| 281 | primaryBgStyle, | ||
| 282 | ]); | ||
| 283 | |||
| 284 | return ( | ||
| 285 | <ScrollView | ||
| 286 | contentInsetAdjustmentBehavior="automatic" | ||
| 287 | contentContainerStyle={{ | ||
| 288 | flexGrow: 1, | ||
| 289 | alignItems: "center", | ||
| 290 | justifyContent: "center", | ||
| 291 | gap: 16, | ||
| 292 | paddingHorizontal: 24, | ||
| 293 | paddingVertical: 32, | ||
| 294 | }} | ||
| 295 | style={[{ flex: 1 }, screenBgStyle]} | ||
| 296 | > | ||
| 297 | <Text className="text-3xl font-t3-bold text-foreground" selectable> | ||
| 298 | Route not found | ||
| 299 | </Text> | ||
| 300 | <Pressable | ||
| 301 | style={returnHomeButtonStyle} | ||
| 302 | onPress={() => navigation.dispatch(StackActions.replace("Home"))} | ||
| 303 | > | ||
| 304 | <Text className="text-base font-t3-bold text-primary-foreground">Return home</Text> | ||
| 305 | </Pressable> | ||
| 306 | </ScrollView> | ||
| 307 | ); | ||
| 308 | } | ||
| 309 | |||
| 310 | export const RootStack = createNativeStackNavigator({ | ||
| 311 | initialRouteName: "Home", | ||
| 312 | layout: RootStackLayout, | ||
| 313 | screenOptions: { | ||
| 314 | headerShown: false, | ||
| 315 | }, | ||
| 316 | screens: { | ||
| 317 | Home: createNativeStackScreen({ | ||
| 318 | screen: HomeRouteScreen, | ||
| 319 | linking: "", | ||
| 320 | options: { | ||
| 321 | ...GLASS_HEADER_OPTIONS, | ||
| 322 | contentStyle: { backgroundColor: "transparent" }, | ||
| 323 | headerBackVisible: false, | ||
| 324 | title: "Threads", | ||
| 325 | }, | ||
| 326 | }), | ||
| 327 | Thread: createNativeStackScreen({ | ||
| 328 | screen: ThreadRouteScreen, | ||
| 329 | linking: THREAD_LINKING_PREFIX, | ||
| 330 | options: GLASS_HEADER_OPTIONS, | ||
| 331 | }), | ||
| 332 | ThreadTerminal: createNativeStackScreen({ | ||
| 333 | screen: ThreadTerminalRouteScreen, | ||
| 334 | linking: `${THREAD_LINKING_PREFIX}/terminal`, | ||
| 335 | options: SOLID_HEADER_OPTIONS, | ||
| 336 | }), | ||
| 337 | ThreadReview: createNativeStackScreen({ | ||
| 338 | screen: ReviewSheet, | ||
| 339 | linking: `${THREAD_LINKING_PREFIX}/review`, | ||
| 340 | options: SOLID_HEADER_OPTIONS, | ||
| 341 | }), | ||
| 342 | ThreadReviewComment: createNativeStackScreen({ | ||
| 343 | screen: ReviewCommentComposerSheet, | ||
| 344 | linking: `${THREAD_LINKING_PREFIX}/review-comment`, | ||
| 345 | options: { | ||
| 346 | presentation: "formSheet", | ||
| 347 | sheetAllowedDetents: [0.55, 0.92], | ||
| 348 | sheetGrabberVisible: true, | ||
| 349 | }, | ||
| 350 | }), | ||
| 351 | ThreadFiles: createNativeStackScreen({ | ||
| 352 | screen: ThreadFilesTreeScreen, | ||
| 353 | linking: `${THREAD_LINKING_PREFIX}/files`, | ||
| 354 | options: { | ||
| 355 | ...GLASS_HEADER_OPTIONS, | ||
| 356 | contentStyle: | ||
| 357 | SHEET_BACKGROUND_COLOR !== undefined | ||
| 358 | ? { backgroundColor: SHEET_BACKGROUND_COLOR } | ||
| 359 | : undefined, | ||
| 360 | title: "Files", | ||
| 361 | }, | ||
| 362 | }), | ||
| 363 | ThreadFile: createNativeStackScreen({ | ||
| 364 | screen: ThreadFileScreen, | ||
| 365 | linking: `${THREAD_LINKING_PREFIX}/files/:path*`, | ||
| 366 | options: SOLID_HEADER_OPTIONS, | ||
| 367 | }), | ||
| 368 | GitOverview: createNativeStackScreen({ | ||
| 369 | screen: GitOverviewSheet, | ||
| 370 | linking: `${THREAD_LINKING_PREFIX}/git`, | ||
| 371 | options: { | ||
| 372 | presentation: "formSheet", | ||
| 373 | sheetAllowedDetents: [0.55, 0.92], | ||
| 374 | sheetGrabberVisible: true, | ||
| 375 | }, | ||
| 376 | }), | ||
| 377 | GitCommit: createNativeStackScreen({ | ||
| 378 | screen: GitCommitSheet, | ||
| 379 | linking: `${THREAD_LINKING_PREFIX}/git/commit`, | ||
| 380 | options: { | ||
| 381 | presentation: "formSheet", | ||
| 382 | sheetAllowedDetents: [0.55, 0.92], | ||
| 383 | sheetGrabberVisible: true, | ||
| 384 | }, | ||
| 385 | }), | ||
| 386 | GitBranches: createNativeStackScreen({ | ||
| 387 | screen: GitBranchesSheet, | ||
| 388 | linking: `${THREAD_LINKING_PREFIX}/git/branches`, | ||
| 389 | options: { | ||
| 390 | presentation: "formSheet", | ||
| 391 | sheetAllowedDetents: [0.55, 0.92], | ||
| 392 | sheetGrabberVisible: true, | ||
| 393 | }, | ||
| 394 | }), | ||
| 395 | GitConfirm: createNativeStackScreen({ | ||
| 396 | screen: GitConfirmSheet, | ||
| 397 | linking: `${THREAD_LINKING_PREFIX}/git-confirm`, | ||
| 398 | options: { | ||
| 399 | presentation: "formSheet", | ||
| 400 | sheetAllowedDetents: [0.45, 0.7], | ||
| 401 | sheetGrabberVisible: true, | ||
| 402 | }, | ||
| 403 | }), | ||
| 404 | SettingsSheet: createNativeStackScreen({ | ||
| 405 | screen: SettingsSheetStack, | ||
| 406 | linking: "settings", | ||
| 407 | options: { | ||
| 408 | gestureEnabled: true, | ||
| 409 | headerShown: false, | ||
| 410 | presentation: "formSheet", | ||
| 411 | sheetAllowedDetents: [0.7, 0.92], | ||
| 412 | sheetGrabberVisible: true, | ||
| 413 | }, | ||
| 414 | }), | ||
| 415 | Connections: createNativeStackScreen({ | ||
| 416 | screen: ConnectionsRouteScreen, | ||
| 417 | linking: "connections", | ||
| 418 | options: { | ||
| 419 | title: "Environments", | ||
| 420 | presentation: "formSheet", | ||
| 421 | sheetAllowedDetents: [0.55, 0.7], | ||
| 422 | sheetGrabberVisible: true, | ||
| 423 | }, | ||
| 424 | }), | ||
| 425 | ConnectionsNew: createNativeStackScreen({ | ||
| 426 | screen: ConnectionsNewRouteScreen, | ||
| 427 | linking: "connections/new", | ||
| 428 | options: { | ||
| 429 | presentation: "formSheet", | ||
| 430 | sheetAllowedDetents: [0.55, 0.7], | ||
| 431 | sheetGrabberVisible: true, | ||
| 432 | }, | ||
| 433 | }), | ||
| 434 | NewTaskSheet: createNativeStackScreen({ | ||
| 435 | screen: NewTaskSheetStack, | ||
| 436 | linking: "new", | ||
| 437 | // The whole new-task flow (choose project → draft → add project) shares | ||
| 438 | // draft state via NewTaskFlowProvider. The expo-router era mounted it in | ||
| 439 | // app/new/_layout.tsx; this layout wrapper is the native-stack equivalent. | ||
| 440 | layout: ({ children }) => <NewTaskFlowProvider>{children}</NewTaskFlowProvider>, | ||
| 441 | options: { | ||
| 442 | gestureEnabled: true, | ||
| 443 | headerShown: false, | ||
| 444 | presentation: "formSheet", | ||
| 445 | sheetAllowedDetents: [0.92], | ||
| 446 | sheetGrabberVisible: true, | ||
| 447 | }, | ||
| 448 | }), | ||
| 449 | NotFound: createNativeStackScreen({ | ||
| 450 | screen: NotFoundScreen, | ||
| 451 | linking: "*", | ||
| 452 | }), | ||
| 453 | }, | ||
| 454 | }); | ||
| 455 | type RootStackType = typeof RootStack; | ||
| 456 | |||
| 457 | const navigationPathConfig = { | ||
| 458 | screens: createPathConfigForStaticNavigation(RootStack) ?? {}, | ||
| 459 | }; | ||
| 460 | |||
| 461 | declare module "@react-navigation/native" { | ||
| 462 | interface RootNavigator extends RootStackType {} | ||
| 463 | } | ||
apps/mobile/src/app/+not-found.tsx deleted-43| ... | @@ -1,43 +0,0 @@ | ||
| 1 | import { Link } from "expo-router"; | ||
| 2 | import { Pressable, ScrollView, StyleSheet } from "react-native"; | ||
| 3 | import { useResolveClassNames } from "uniwind"; | ||
| 4 | |||
| 5 | import { AppText as Text } from "../components/AppText"; | ||
| 6 | |||
| 7 | export default function NotFoundRoute() { | ||
| 8 | const screenBgStyle = StyleSheet.flatten(useResolveClassNames("bg-screen")); | ||
| 9 | const primaryBgStyle = StyleSheet.flatten(useResolveClassNames("bg-primary")); | ||
| 10 | |||
| 11 | return ( | ||
| 12 | <ScrollView | ||
| 13 | contentInsetAdjustmentBehavior="automatic" | ||
| 14 | contentContainerStyle={{ | ||
| 15 | flexGrow: 1, | ||
| 16 | alignItems: "center", | ||
| 17 | justifyContent: "center", | ||
| 18 | gap: 16, | ||
| 19 | paddingHorizontal: 24, | ||
| 20 | paddingVertical: 32, | ||
| 21 | }} | ||
| 22 | style={[{ flex: 1 }, screenBgStyle]} | ||
| 23 | > | ||
| 24 | <Text className="text-3xl font-t3-bold text-foreground" selectable> | ||
| 25 | Route not found | ||
| 26 | </Text> | ||
| 27 | <Link href="/" asChild> | ||
| 28 | <Pressable | ||
| 29 | style={[ | ||
| 30 | { | ||
| 31 | borderRadius: 999, | ||
| 32 | paddingHorizontal: 20, | ||
| 33 | paddingVertical: 14, | ||
| 34 | }, | ||
| 35 | primaryBgStyle, | ||
| 36 | ]} | ||
| 37 | > | ||
| 38 | <Text className="text-base font-t3-bold text-primary-foreground">Return home</Text> | ||
| 39 | </Pressable> | ||
| 40 | </Link> | ||
| 41 | </ScrollView> | ||
| 42 | ); | ||
| 43 | } | ||
apps/mobile/src/app/_layout.tsx deleted-149| ... | @@ -1,149 +0,0 @@ | ||
| 1 | import "../../global.css"; | ||
| 2 | import { | ||
| 3 | DMSans_400Regular, | ||
| 4 | DMSans_500Medium, | ||
| 5 | DMSans_700Bold, | ||
| 6 | useFonts, | ||
| 7 | } from "@expo-google-fonts/dm-sans"; | ||
| 8 | import { usePathname } from "expo-router"; | ||
| 9 | import Stack from "expo-router/stack"; | ||
| 10 | import { useCallback } from "react"; | ||
| 11 | import { StatusBar, useColorScheme } from "react-native"; | ||
| 12 | import { GestureHandlerRootView } from "react-native-gesture-handler"; | ||
| 13 | import { KeyboardProvider } from "react-native-keyboard-controller"; | ||
| 14 | import { SafeAreaProvider } from "react-native-safe-area-context"; | ||
| 15 | import { useResolveClassNames } from "uniwind"; | ||
| 16 | |||
| 17 | import { LoadingScreen } from "../components/LoadingScreen"; | ||
| 18 | |||
| 19 | import { useWorkspaceState } from "../state/workspace"; | ||
| 20 | import { useThreadOutboxDrain } from "../state/use-thread-outbox-drain"; | ||
| 21 | import { RegistryContext } from "@effect/atom-react"; | ||
| 22 | import { appAtomRegistry } from "../state/atom-registry"; | ||
| 23 | import { CloudAuthProvider } from "../features/cloud/CloudAuthProvider"; | ||
| 24 | import { | ||
| 25 | ClerkSettingsSheetDetentProvider, | ||
| 26 | useClerkSettingsSheetDetent, | ||
| 27 | } from "../features/cloud/ClerkSettingsSheetDetent"; | ||
| 28 | import { useAgentNotificationNavigation } from "../features/agent-awareness/notificationNavigation"; | ||
| 29 | import { useThemeColor } from "../lib/useThemeColor"; | ||
| 30 | |||
| 31 | function AppNavigator() { | ||
| 32 | const pathname = usePathname(); | ||
| 33 | const expandedSettingsRouteIsActive = | ||
| 34 | pathname === "/settings/archive" || pathname === "/settings/auth"; | ||
| 35 | |||
| 36 | return ( | ||
| 37 | <ClerkSettingsSheetDetentProvider initiallyExpanded={expandedSettingsRouteIsActive}> | ||
| 38 | <AppNavigatorContent /> | ||
| 39 | </ClerkSettingsSheetDetentProvider> | ||
| 40 | ); | ||
| 41 | } | ||
| 42 | |||
| 43 | function AppNavigatorContent() { | ||
| 44 | const { state } = useWorkspaceState(); | ||
| 45 | const { collapse, isExpanded } = useClerkSettingsSheetDetent(); | ||
| 46 | const colorScheme = useColorScheme(); | ||
| 47 | const statusBarBg = useThemeColor("--color-status-bar"); | ||
| 48 | const sheetStyle = useResolveClassNames("bg-sheet"); | ||
| 49 | useAgentNotificationNavigation(); | ||
| 50 | useThreadOutboxDrain(); | ||
| 51 | |||
| 52 | const handleSettingsTransitionEnd = useCallback( | ||
| 53 | (event: { data: { closing: boolean } }) => { | ||
| 54 | if (event.data.closing) { | ||
| 55 | collapse(); | ||
| 56 | } | ||
| 57 | }, | ||
| 58 | [collapse], | ||
| 59 | ); | ||
| 60 | |||
| 61 | const newTaskScreenOptions = { | ||
| 62 | contentStyle: sheetStyle, | ||
| 63 | gestureEnabled: true, | ||
| 64 | headerShown: false, | ||
| 65 | presentation: "formSheet" as const, | ||
| 66 | sheetAllowedDetents: [0.92], | ||
| 67 | sheetGrabberVisible: true, | ||
| 68 | }; | ||
| 69 | |||
| 70 | const connectionSheetScreenOptions = { | ||
| 71 | contentStyle: sheetStyle, | ||
| 72 | gestureEnabled: true, | ||
| 73 | headerShown: false, | ||
| 74 | presentation: "formSheet" as const, | ||
| 75 | sheetAllowedDetents: [0.55, 0.7], | ||
| 76 | sheetGrabberVisible: true, | ||
| 77 | }; | ||
| 78 | |||
| 79 | const settingsSheetScreenOptions = { | ||
| 80 | ...connectionSheetScreenOptions, | ||
| 81 | sheetAllowedDetents: isExpanded ? [0.92] : [0.7], | ||
| 82 | }; | ||
| 83 | |||
| 84 | if (state.isLoadingConnections) { | ||
| 85 | return <LoadingScreen message="Loading remote workspace…" />; | ||
| 86 | } | ||
| 87 | |||
| 88 | return ( | ||
| 89 | <> | ||
| 90 | <StatusBar | ||
| 91 | barStyle={colorScheme === "dark" ? "light-content" : "dark-content"} | ||
| 92 | backgroundColor={String(statusBarBg)} | ||
| 93 | translucent | ||
| 94 | /> | ||
| 95 | <Stack screenOptions={{ headerShown: false }}> | ||
| 96 | <Stack.Screen | ||
| 97 | name="index" | ||
| 98 | options={{ | ||
| 99 | contentStyle: { backgroundColor: "transparent" }, | ||
| 100 | headerShown: true, | ||
| 101 | headerTransparent: true, | ||
| 102 | headerShadowVisible: false, | ||
| 103 | }} | ||
| 104 | /> | ||
| 105 | <Stack.Screen | ||
| 106 | name="settings" | ||
| 107 | listeners={{ transitionEnd: handleSettingsTransitionEnd }} | ||
| 108 | options={settingsSheetScreenOptions} | ||
| 109 | /> | ||
| 110 | <Stack.Screen name="connections" options={connectionSheetScreenOptions} /> | ||
| 111 | <Stack.Screen name="new" options={newTaskScreenOptions} /> | ||
| 112 | <Stack.Screen | ||
| 113 | name="threads/[environmentId]/[threadId]" | ||
| 114 | options={{ | ||
| 115 | animation: "slide_from_right", | ||
| 116 | contentStyle: { backgroundColor: "transparent" }, | ||
| 117 | gestureEnabled: true, | ||
| 118 | headerShown: false, | ||
| 119 | }} | ||
| 120 | /> | ||
| 121 | </Stack> | ||
| 122 | </> | ||
| 123 | ); | ||
| 124 | } | ||
| 125 | |||
| 126 | export default function RootLayout() { | ||
| 127 | const [fontsLoaded] = useFonts({ | ||
| 128 | DMSans_400Regular, | ||
| 129 | DMSans_500Medium, | ||
| 130 | DMSans_700Bold, | ||
| 131 | }); | ||
| 132 | return ( | ||
| 133 | <RegistryContext.Provider value={appAtomRegistry}> | ||
| 134 | <CloudAuthProvider> | ||
| 135 | <GestureHandlerRootView style={{ flex: 1 }}> | ||
| 136 | <KeyboardProvider statusBarTranslucent> | ||
| 137 | <SafeAreaProvider> | ||
| 138 | {fontsLoaded ? ( | ||
| 139 | <AppNavigator /> | ||
| 140 | ) : ( | ||
| 141 | <LoadingScreen message="Loading remote workspace…" /> | ||
| 142 | )} | ||
| 143 | </SafeAreaProvider> | ||
| 144 | </KeyboardProvider> | ||
| 145 | </GestureHandlerRootView> | ||
| 146 | </CloudAuthProvider> | ||
| 147 | </RegistryContext.Provider> | ||
| 148 | ); | ||
| 149 | } | ||
apps/mobile/src/app/connections/_layout.tsx deleted-29| ... | @@ -1,29 +0,0 @@ | ||
| 1 | import Stack from "expo-router/stack"; | ||
| 2 | import { useResolveClassNames } from "uniwind"; | ||
| 3 | import { useThemeColor } from "../../lib/useThemeColor"; | ||
| 4 | |||
| 5 | export const unstable_settings = { | ||
| 6 | anchor: "index", | ||
| 7 | }; | ||
| 8 | |||
| 9 | export default function ConnectionsLayout() { | ||
| 10 | const contentStyle = useResolveClassNames("bg-sheet"); | ||
| 11 | const connSheetBg = useThemeColor("--color-sheet"); | ||
| 12 | const headerTint = useThemeColor("--color-foreground"); | ||
| 13 | |||
| 14 | return ( | ||
| 15 | <Stack | ||
| 16 | screenOptions={{ | ||
| 17 | contentStyle, | ||
| 18 | headerStyle: { backgroundColor: connSheetBg }, | ||
| 19 | headerTintColor: headerTint, | ||
| 20 | headerTitleStyle: { fontFamily: "DMSans_700Bold" }, | ||
| 21 | headerShadowVisible: false, | ||
| 22 | headerBackButtonDisplayMode: "minimal", | ||
| 23 | }} | ||
| 24 | > | ||
| 25 | <Stack.Screen name="index" options={{ animation: "none" }} /> | ||
| 26 | <Stack.Screen name="new" options={{ animation: "slide_from_right" }} /> | ||
| 27 | </Stack> | ||
| 28 | ); | ||
| 29 | } | ||
apps/mobile/src/app/connections/index.tsx deleted-97| ... | @@ -1,97 +0,0 @@ | ||
| 1 | import { Stack, useRouter } from "expo-router"; | ||
| 2 | import { SymbolView } from "expo-symbols"; | ||
| 3 | import type { EnvironmentId } from "@t3tools/contracts"; | ||
| 4 | import { useCallback, useState } from "react"; | ||
| 5 | import { ScrollView, View } from "react-native"; | ||
| 6 | import { useSafeAreaInsets } from "react-native-safe-area-context"; | ||
| 7 | import { useThemeColor } from "../../lib/useThemeColor"; | ||
| 8 | |||
| 9 | import { AppText as Text } from "../../components/AppText"; | ||
| 10 | import { cn } from "../../lib/cn"; | ||
| 11 | import { useRemoteConnections } from "../../state/use-remote-environment-registry"; | ||
| 12 | import { ConnectionEnvironmentRow } from "../../features/connection/ConnectionEnvironmentRow"; | ||
| 13 | |||
| 14 | export default function ConnectionsRouteScreen() { | ||
| 15 | const { | ||
| 16 | connectedEnvironments, | ||
| 17 | onReconnectEnvironment, | ||
| 18 | onRemoveEnvironmentPress, | ||
| 19 | onUpdateEnvironment, | ||
| 20 | } = useRemoteConnections(); | ||
| 21 | const router = useRouter(); | ||
| 22 | const insets = useSafeAreaInsets(); | ||
| 23 | const hasEnvironments = connectedEnvironments.length > 0; | ||
| 24 | const [expandedId, setExpandedId] = useState<EnvironmentId | null>(null); | ||
| 25 | |||
| 26 | const accentColor = useThemeColor("--color-icon-muted"); | ||
| 27 | |||
| 28 | const handleToggle = useCallback((environmentId: EnvironmentId) => { | ||
| 29 | setExpandedId((prev) => (prev === environmentId ? null : environmentId)); | ||
| 30 | }, []); | ||
| 31 | |||
| 32 | return ( | ||
| 33 | <View collapsable={false} className="flex-1 bg-sheet"> | ||
| 34 | <Stack.Screen | ||
| 35 | options={{ | ||
| 36 | title: "Environments", | ||
| 37 | }} | ||
| 38 | /> | ||
| 39 | <Stack.Toolbar placement="right"> | ||
| 40 | <Stack.Toolbar.Button | ||
| 41 | icon="plus" | ||
| 42 | onPress={() => router.push("/connections/new")} | ||
| 43 | separateBackground | ||
| 44 | /> | ||
| 45 | </Stack.Toolbar> | ||
| 46 | <ScrollView | ||
| 47 | contentInsetAdjustmentBehavior="automatic" | ||
| 48 | showsVerticalScrollIndicator={false} | ||
| 49 | style={{ flex: 1 }} | ||
| 50 | contentInset={{ bottom: Math.max(insets.bottom, 18) + 18 }} | ||
| 51 | contentContainerStyle={{ | ||
| 52 | paddingHorizontal: 20, | ||
| 53 | paddingTop: 16, | ||
| 54 | }} | ||
| 55 | > | ||
| 56 | {hasEnvironments ? ( | ||
| 57 | <View collapsable={false} className="overflow-hidden rounded-[24px] bg-card"> | ||
| 58 | {connectedEnvironments.map((environment, index) => ( | ||
| 59 | <View | ||
| 60 | key={environment.environmentId} | ||
| 61 | collapsable={false} | ||
| 62 | style={{ | ||
| 63 | borderTopWidth: index === 0 ? 0 : 1, | ||
| 64 | }} | ||
| 65 | className={cn(index !== 0 && "border-border")} | ||
| 66 | > | ||
| 67 | <ConnectionEnvironmentRow | ||
| 68 | environment={environment} | ||
| 69 | expanded={expandedId === environment.environmentId} | ||
| 70 | onToggle={() => handleToggle(environment.environmentId)} | ||
| 71 | onReconnect={onReconnectEnvironment} | ||
| 72 | onRemove={onRemoveEnvironmentPress} | ||
| 73 | onUpdate={onUpdateEnvironment} | ||
| 74 | /> | ||
| 75 | </View> | ||
| 76 | ))} | ||
| 77 | </View> | ||
| 78 | ) : ( | ||
| 79 | <View collapsable={false} className="items-center gap-3 rounded-[24px] bg-card px-6 py-8"> | ||
| 80 | <View className="h-12 w-12 items-center justify-center rounded-[16px] bg-subtle"> | ||
| 81 | <SymbolView | ||
| 82 | name="point.3.connected.trianglepath.dotted" | ||
| 83 | size={20} | ||
| 84 | tintColor={accentColor} | ||
| 85 | type="monochrome" | ||
| 86 | /> | ||
| 87 | </View> | ||
| 88 | <Text className="text-center text-sm leading-[20px] text-foreground-muted"> | ||
| 89 | No environments connected yet.{"\n"}Tap{" "} | ||
| 90 | <Text className="font-t3-bold text-foreground">+</Text> to add one. | ||
| 91 | </Text> | ||
| 92 | </View> | ||
| 93 | )} | ||
| 94 | </ScrollView> | ||
| 95 | </View> | ||
| 96 | ); | ||
| 97 | } | ||
apps/mobile/src/app/connections/new.tsx deleted-244| ... | @@ -1,244 +0,0 @@ | ||
| 1 | import { CameraView, useCameraPermissions } from "expo-camera"; | ||
| 2 | import { Stack, useLocalSearchParams, useRouter } from "expo-router"; | ||
| 3 | import { AsyncResult } from "effect/unstable/reactivity"; | ||
| 4 | import { useCallback, useEffect, useState } from "react"; | ||
| 5 | import { Alert, ScrollView, View } from "react-native"; | ||
| 6 | import { useSafeAreaInsets } from "react-native-safe-area-context"; | ||
| 7 | import { useThemeColor } from "../../lib/useThemeColor"; | ||
| 8 | |||
| 9 | import { AppText as Text, AppTextInput as TextInput } from "../../components/AppText"; | ||
| 10 | import { ErrorBanner } from "../../components/ErrorBanner"; | ||
| 11 | import { dismissRoute } from "../../lib/routes"; | ||
| 12 | import { ConnectionSheetButton } from "../../features/connection/ConnectionSheetButton"; | ||
| 13 | import { extractPairingUrlFromQrPayload } from "../../features/connection/pairing"; | ||
| 14 | import { useRemoteConnections } from "../../state/use-remote-environment-registry"; | ||
| 15 | import { buildPairingUrl, parsePairingUrl } from "../../features/connection/pairing"; | ||
| 16 | |||
| 17 | export default function ConnectionsNewRouteScreen() { | ||
| 18 | const { | ||
| 19 | connectionPairingUrl, | ||
| 20 | onChangeConnectionPairingUrl, | ||
| 21 | onConnectPress, | ||
| 22 | pairingConnectionError, | ||
| 23 | } = useRemoteConnections(); | ||
| 24 | const router = useRouter(); | ||
| 25 | const params = useLocalSearchParams<{ mode?: string }>(); | ||
| 26 | const insets = useSafeAreaInsets(); | ||
| 27 | const [hostInput, setHostInput] = useState(""); | ||
| 28 | const [codeInput, setCodeInput] = useState(""); | ||
| 29 | const [isSubmitting, setIsSubmitting] = useState(false); | ||
| 30 | const [showScanner, setShowScanner] = useState(params.mode === "scan_qr"); | ||
| 31 | const [cameraPermission, requestCameraPermission] = useCameraPermissions(); | ||
| 32 | const [scannerLocked, setScannerLocked] = useState(false); | ||
| 33 | |||
| 34 | const placeholderColor = useThemeColor("--color-placeholder"); | ||
| 35 | |||
| 36 | const connectDisabled = isSubmitting || hostInput.trim().length === 0; | ||
| 37 | |||
| 38 | useEffect(() => { | ||
| 39 | const { host, code } = parsePairingUrl(connectionPairingUrl); | ||
| 40 | setHostInput(host); | ||
| 41 | setCodeInput(code); | ||
| 42 | }, [connectionPairingUrl]); | ||
| 43 | |||
| 44 | useEffect(() => { | ||
| 45 | if (pairingConnectionError) { | ||
| 46 | setIsSubmitting(false); | ||
| 47 | } | ||
| 48 | }, [pairingConnectionError]); | ||
| 49 | |||
| 50 | const handleHostChange = useCallback((value: string) => { | ||
| 51 | setHostInput(value); | ||
| 52 | }, []); | ||
| 53 | |||
| 54 | const handleCodeChange = useCallback((value: string) => { | ||
| 55 | setCodeInput(value); | ||
| 56 | }, []); | ||
| 57 | |||
| 58 | const openScanner = useCallback(async () => { | ||
| 59 | if (cameraPermission?.granted) { | ||
| 60 | setScannerLocked(false); | ||
| 61 | setShowScanner(true); | ||
| 62 | return; | ||
| 63 | } | ||
| 64 | |||
| 65 | const permission = await requestCameraPermission(); | ||
| 66 | if (permission.granted) { | ||
| 67 | setScannerLocked(false); | ||
| 68 | setShowScanner(true); | ||
| 69 | return; | ||
| 70 | } | ||
| 71 | |||
| 72 | Alert.alert( | ||
| 73 | "Camera access needed", | ||
| 74 | "Allow camera access to scan an environment pairing QR code.", | ||
| 75 | ); | ||
| 76 | }, [cameraPermission?.granted, requestCameraPermission]); | ||
| 77 | |||
| 78 | const closeScanner = useCallback(() => { | ||
| 79 | setShowScanner(false); | ||
| 80 | setScannerLocked(false); | ||
| 81 | }, []); | ||
| 82 | |||
| 83 | const handleQrScan = useCallback( | ||
| 84 | ({ data }: { readonly data: string }) => { | ||
| 85 | if (scannerLocked) { | ||
| 86 | return; | ||
| 87 | } | ||
| 88 | |||
| 89 | setScannerLocked(true); | ||
| 90 | |||
| 91 | try { | ||
| 92 | const pairingUrl = extractPairingUrlFromQrPayload(data); | ||
| 93 | const { host, code } = parsePairingUrl(pairingUrl); | ||
| 94 | setHostInput(host); | ||
| 95 | setCodeInput(code); | ||
| 96 | onChangeConnectionPairingUrl(pairingUrl); | ||
| 97 | setShowScanner(false); | ||
| 98 | } catch (error) { | ||
| 99 | Alert.alert( | ||
| 100 | "Invalid QR code", | ||
| 101 | error instanceof Error ? error.message : "Scanned QR code was not recognized.", | ||
| 102 | ); | ||
| 103 | } finally { | ||
| 104 | setTimeout(() => { | ||
| 105 | setScannerLocked(false); | ||
| 106 | }, 600); | ||
| 107 | } | ||
| 108 | }, | ||
| 109 | [onChangeConnectionPairingUrl, scannerLocked], | ||
| 110 | ); | ||
| 111 | |||
| 112 | const handleSubmit = useCallback(async () => { | ||
| 113 | setIsSubmitting(true); | ||
| 114 | |||
| 115 | const pairingUrl = buildPairingUrl(hostInput, codeInput); | ||
| 116 | onChangeConnectionPairingUrl(pairingUrl); | ||
| 117 | const result = await onConnectPress(pairingUrl); | ||
| 118 | if (AsyncResult.isSuccess(result)) { | ||
| 119 | dismissRoute(router); | ||
| 120 | } else { | ||
| 121 | setIsSubmitting(false); | ||
| 122 | } | ||
| 123 | }, [codeInput, hostInput, onChangeConnectionPairingUrl, onConnectPress, router]); | ||
| 124 | |||
| 125 | return ( | ||
| 126 | <View collapsable={false} className="flex-1 bg-sheet"> | ||
| 127 | <Stack.Screen | ||
| 128 | options={{ | ||
| 129 | title: showScanner ? "Scan QR Code" : "Add Environment", | ||
| 130 | }} | ||
| 131 | /> | ||
| 132 | <Stack.Toolbar placement="right"> | ||
| 133 | <Stack.Toolbar.Button | ||
| 134 | icon={showScanner ? "xmark" : "qrcode.viewfinder"} | ||
| 135 | onPress={() => { | ||
| 136 | if (showScanner) { | ||
| 137 | closeScanner(); | ||
| 138 | } else { | ||
| 139 | void openScanner(); | ||
| 140 | } | ||
| 141 | }} | ||
| 142 | separateBackground | ||
| 143 | /> | ||
| 144 | </Stack.Toolbar> | ||
| 145 | |||
| 146 | <ScrollView | ||
| 147 | contentInsetAdjustmentBehavior="automatic" | ||
| 148 | showsVerticalScrollIndicator={false} | ||
| 149 | style={{ flex: 1 }} | ||
| 150 | contentInset={{ bottom: Math.max(insets.bottom, 18) + 18 }} | ||
| 151 | contentContainerStyle={{ | ||
| 152 | paddingHorizontal: 20, | ||
| 153 | paddingTop: 16, | ||
| 154 | }} | ||
| 155 | > | ||
| 156 | <View collapsable={false} className="gap-5"> | ||
| 157 | {showScanner ? ( | ||
| 158 | cameraPermission?.granted ? ( | ||
| 159 | <View | ||
| 160 | className="overflow-hidden rounded-[24px]" | ||
| 161 | style={{ borderCurve: "continuous" }} | ||
| 162 | > | ||
| 163 | <CameraView | ||
| 164 | barcodeScannerSettings={{ barcodeTypes: ["qr"] }} | ||
| 165 | onBarcodeScanned={handleQrScan} | ||
| 166 | style={{ aspectRatio: 1, width: "100%" }} | ||
| 167 | /> | ||
| 168 | </View> | ||
| 169 | ) : ( | ||
| 170 | <View | ||
| 171 | className="items-center gap-3 rounded-[24px] bg-card px-5 py-8" | ||
| 172 | style={{ borderCurve: "continuous" }} | ||
| 173 | > | ||
| 174 | <Text className="text-center text-sm leading-[20px] text-foreground-muted"> | ||
| 175 | Camera permission is required to scan a QR code. | ||
| 176 | </Text> | ||
| 177 | <ConnectionSheetButton | ||
| 178 | compact | ||
| 179 | icon="camera" | ||
| 180 | label="Allow camera" | ||
| 181 | tone="secondary" | ||
| 182 | onPress={() => { | ||
| 183 | void openScanner(); | ||
| 184 | }} | ||
| 185 | /> | ||
| 186 | </View> | ||
| 187 | ) | ||
| 188 | ) : ( | ||
| 189 | <View collapsable={false} className="gap-4 rounded-[24px] bg-card p-4"> | ||
| 190 | <View collapsable={false} className="gap-1.5"> | ||
| 191 | <Text | ||
| 192 | className="text-2xs font-t3-bold uppercase text-foreground-muted" | ||
| 193 | style={{ letterSpacing: 0.8 }} | ||
| 194 | > | ||
| 195 | Host | ||
| 196 | </Text> | ||
| 197 | <TextInput | ||
| 198 | autoCapitalize="none" | ||
| 199 | autoCorrect={false} | ||
| 200 | keyboardType="url" | ||
| 201 | placeholder="192.168.1.100:8080" | ||
| 202 | placeholderTextColor={placeholderColor} | ||
| 203 | value={hostInput} | ||
| 204 | onChangeText={handleHostChange} | ||
| 205 | className="rounded-[14px] border border-input-border bg-input px-4 py-3.5 text-base text-foreground" | ||
| 206 | /> | ||
| 207 | </View> | ||
| 208 | |||
| 209 | <View collapsable={false} className="gap-1.5"> | ||
| 210 | <Text | ||
| 211 | className="text-2xs font-t3-bold uppercase text-foreground-muted" | ||
| 212 | style={{ letterSpacing: 0.8 }} | ||
| 213 | > | ||
| 214 | Pairing code | ||
| 215 | </Text> | ||
| 216 | <TextInput | ||
| 217 | autoCapitalize="none" | ||
| 218 | autoCorrect={false} | ||
| 219 | placeholder="abc-123-xyz" | ||
| 220 | placeholderTextColor={placeholderColor} | ||
| 221 | value={codeInput} | ||
| 222 | onChangeText={handleCodeChange} | ||
| 223 | className="rounded-[14px] border border-input-border bg-input px-4 py-3.5 text-base text-foreground" | ||
| 224 | /> | ||
| 225 | </View> | ||
| 226 | |||
| 227 | {pairingConnectionError ? <ErrorBanner message={pairingConnectionError} /> : null} | ||
| 228 | |||
| 229 | <ConnectionSheetButton | ||
| 230 | icon="plus" | ||
| 231 | label={isSubmitting ? "Pairing..." : "Add environment"} | ||
| 232 | disabled={connectDisabled} | ||
| 233 | tone="primary" | ||
| 234 | onPress={() => { | ||
| 235 | void handleSubmit(); | ||
| 236 | }} | ||
| 237 | /> | ||
| 238 | </View> | ||
| 239 | )} | ||
| 240 | </View> | ||
| 241 | </ScrollView> | ||
| 242 | </View> | ||
| 243 | ); | ||
| 244 | } | ||
apps/mobile/src/app/index.tsx deleted-120| ... | @@ -1,120 +0,0 @@ | ||
| 1 | import type { | ||
| 2 | EnvironmentId, | ||
| 3 | SidebarProjectGroupingMode, | ||
| 4 | SidebarThreadSortOrder, | ||
| 5 | } from "@t3tools/contracts"; | ||
| 6 | import { | ||
| 7 | DEFAULT_SIDEBAR_PROJECT_GROUPING_MODE, | ||
| 8 | DEFAULT_SIDEBAR_PROJECT_SORT_ORDER, | ||
| 9 | DEFAULT_SIDEBAR_THREAD_SORT_ORDER, | ||
| 10 | } from "@t3tools/contracts"; | ||
| 11 | import * as Arr from "effect/Array"; | ||
| 12 | import * as Order from "effect/Order"; | ||
| 13 | import { useRouter } from "expo-router"; | ||
| 14 | import { useCallback, useMemo, useState } from "react"; | ||
| 15 | |||
| 16 | import { useProjects, useThreadShells } from "../state/entities"; | ||
| 17 | import { useWorkspaceState } from "../state/workspace"; | ||
| 18 | import { buildThreadRoutePath } from "../lib/routes"; | ||
| 19 | import { useSavedRemoteConnections } from "../state/use-remote-environment-registry"; | ||
| 20 | import { HomeScreen } from "../features/home/HomeScreen"; | ||
| 21 | import { HomeHeader } from "../features/home/HomeHeader"; | ||
| 22 | import type { HomeProjectSortOrder } from "../features/home/homeThreadList"; | ||
| 23 | import { useThreadListActions } from "../features/home/useThreadListActions"; | ||
| 24 | |||
| 25 | interface HomeListOptions { | ||
| 26 | readonly selectedEnvironmentId: EnvironmentId | null; | ||
| 27 | readonly projectSortOrder: HomeProjectSortOrder; | ||
| 28 | readonly threadSortOrder: SidebarThreadSortOrder; | ||
| 29 | readonly projectGroupingMode: SidebarProjectGroupingMode; | ||
| 30 | } | ||
| 31 | |||
| 32 | /* ─── Route screen ───────────────────────────────────────────────────── */ | ||
| 33 | |||
| 34 | export default function HomeRouteScreen() { | ||
| 35 | const projects = useProjects(); | ||
| 36 | const threads = useThreadShells(); | ||
| 37 | const { state: catalogState } = useWorkspaceState(); | ||
| 38 | const { savedConnectionsById } = useSavedRemoteConnections(); | ||
| 39 | const router = useRouter(); | ||
| 40 | const [searchQuery, setSearchQuery] = useState(""); | ||
| 41 | const [listOptions, setListOptions] = useState<HomeListOptions>({ | ||
| 42 | selectedEnvironmentId: null, | ||
| 43 | projectSortOrder: | ||
| 44 | DEFAULT_SIDEBAR_PROJECT_SORT_ORDER === "manual" | ||
| 45 | ? "updated_at" | ||
| 46 | : DEFAULT_SIDEBAR_PROJECT_SORT_ORDER, | ||
| 47 | threadSortOrder: DEFAULT_SIDEBAR_THREAD_SORT_ORDER, | ||
| 48 | projectGroupingMode: DEFAULT_SIDEBAR_PROJECT_GROUPING_MODE, | ||
| 49 | }); | ||
| 50 | const { archiveThread, confirmDeleteThread } = useThreadListActions(); | ||
| 51 | const environments = useMemo( | ||
| 52 | () => | ||
| 53 | Arr.sort( | ||
| 54 | Object.values(savedConnectionsById).map((connection) => ({ | ||
| 55 | environmentId: connection.environmentId, | ||
| 56 | label: connection.environmentLabel, | ||
| 57 | })), | ||
| 58 | Order.mapInput( | ||
| 59 | Order.String, | ||
| 60 | (environment: { readonly label: string }) => environment.label, | ||
| 61 | ), | ||
| 62 | ), | ||
| 63 | [savedConnectionsById], | ||
| 64 | ); | ||
| 65 | const selectedEnvironmentId = environments.some( | ||
| 66 | (environment) => environment.environmentId === listOptions.selectedEnvironmentId, | ||
| 67 | ) | ||
| 68 | ? listOptions.selectedEnvironmentId | ||
| 69 | : null; | ||
| 70 | const setSelectedEnvironmentId = useCallback((environmentId: EnvironmentId | null) => { | ||
| 71 | setListOptions((current) => ({ ...current, selectedEnvironmentId: environmentId })); | ||
| 72 | }, []); | ||
| 73 | const setProjectSortOrder = useCallback((projectSortOrder: HomeProjectSortOrder) => { | ||
| 74 | setListOptions((current) => ({ ...current, projectSortOrder })); | ||
| 75 | }, []); | ||
| 76 | const setThreadSortOrder = useCallback((threadSortOrder: SidebarThreadSortOrder) => { | ||
| 77 | setListOptions((current) => ({ ...current, threadSortOrder })); | ||
| 78 | }, []); | ||
| 79 | const setProjectGroupingMode = useCallback((projectGroupingMode: SidebarProjectGroupingMode) => { | ||
| 80 | setListOptions((current) => ({ ...current, projectGroupingMode })); | ||
| 81 | }, []); | ||
| 82 | |||
| 83 | return ( | ||
| 84 | <> | ||
| 85 | <HomeHeader | ||
| 86 | environments={environments} | ||
| 87 | selectedEnvironmentId={selectedEnvironmentId} | ||
| 88 | projectSortOrder={listOptions.projectSortOrder} | ||
| 89 | threadSortOrder={listOptions.threadSortOrder} | ||
| 90 | projectGroupingMode={listOptions.projectGroupingMode} | ||
| 91 | onEnvironmentChange={setSelectedEnvironmentId} | ||
| 92 | onOpenSettings={() => router.push("/settings")} | ||
| 93 | onProjectGroupingModeChange={setProjectGroupingMode} | ||
| 94 | onProjectSortOrderChange={setProjectSortOrder} | ||
| 95 | onSearchQueryChange={setSearchQuery} | ||
| 96 | onStartNewTask={() => router.push("/new")} | ||
| 97 | onThreadSortOrderChange={setThreadSortOrder} | ||
| 98 | /> | ||
| 99 | |||
| 100 | <HomeScreen | ||
| 101 | catalogState={catalogState} | ||
| 102 | onAddConnection={() => router.push("/connections/new")} | ||
| 103 | onArchiveThread={archiveThread} | ||
| 104 | onDeleteThread={confirmDeleteThread} | ||
| 105 | onOpenEnvironments={() => router.push("/settings/environments")} | ||
| 106 | onSelectThread={(thread) => { | ||
| 107 | router.push(buildThreadRoutePath(thread)); | ||
| 108 | }} | ||
| 109 | projectGroupingMode={listOptions.projectGroupingMode} | ||
| 110 | projects={projects} | ||
| 111 | projectSortOrder={listOptions.projectSortOrder} | ||
| 112 | savedConnectionsById={savedConnectionsById} | ||
| 113 | searchQuery={searchQuery} | ||
| 114 | selectedEnvironmentId={selectedEnvironmentId} | ||
| 115 | threads={threads} | ||
| 116 | threadSortOrder={listOptions.threadSortOrder} | ||
| 117 | /> | ||
| 118 | </> | ||
| 119 | ); | ||
| 120 | } | ||
apps/mobile/src/app/new/_layout.tsx deleted-50| ... | @@ -1,50 +0,0 @@ | ||
| 1 | import Stack from "expo-router/stack"; | ||
| 2 | import { useResolveClassNames } from "uniwind"; | ||
| 3 | |||
| 4 | import { NewTaskFlowProvider } from "../../features/threads/new-task-flow-provider"; | ||
| 5 | import { useThemeColor } from "../../lib/useThemeColor"; | ||
| 6 | |||
| 7 | export const unstable_settings = { | ||
| 8 | anchor: "index", | ||
| 9 | }; | ||
| 10 | |||
| 11 | export default function NewTaskLayout() { | ||
| 12 | const sheetStyle = useResolveClassNames("bg-sheet"); | ||
| 13 | const sheetBg = useThemeColor("--color-sheet"); | ||
| 14 | const headerTint = useThemeColor("--color-foreground"); | ||
| 15 | |||
| 16 | return ( | ||
| 17 | <NewTaskFlowProvider> | ||
| 18 | <Stack | ||
| 19 | screenOptions={{ | ||
| 20 | contentStyle: sheetStyle, | ||
| 21 | headerBackButtonDisplayMode: "minimal", | ||
| 22 | headerLargeTitle: false, | ||
| 23 | headerShadowVisible: false, | ||
| 24 | headerStyle: { backgroundColor: sheetBg }, | ||
| 25 | headerTintColor: headerTint, | ||
| 26 | headerTitleStyle: { fontFamily: "DMSans_700Bold" }, | ||
| 27 | }} | ||
| 28 | > | ||
| 29 | <Stack.Screen name="index" options={{ animation: "none", title: "Choose project" }} /> | ||
| 30 | <Stack.Screen | ||
| 31 | name="add-project/index" | ||
| 32 | options={{ animation: "slide_from_right", title: "New project" }} | ||
| 33 | /> | ||
| 34 | <Stack.Screen | ||
| 35 | name="add-project/repository" | ||
| 36 | options={{ animation: "slide_from_right", title: "Repository" }} | ||
| 37 | /> | ||
| 38 | <Stack.Screen | ||
| 39 | name="add-project/destination" | ||
| 40 | options={{ animation: "slide_from_right", title: "Clone destination" }} | ||
| 41 | /> | ||
| 42 | <Stack.Screen | ||
| 43 | name="add-project/local" | ||
| 44 | options={{ animation: "slide_from_right", title: "Local folder" }} | ||
| 45 | /> | ||
| 46 | <Stack.Screen name="draft" options={{ animation: "slide_from_right", title: "New task" }} /> | ||
| 47 | </Stack> | ||
| 48 | </NewTaskFlowProvider> | ||
| 49 | ); | ||
| 50 | } | ||
apps/mobile/src/app/new/add-project/destination.tsx deleted-5| ... | @@ -1,5 +0,0 @@ | ||
| 1 | import { AddProjectDestinationScreen } from "../../../features/projects/AddProjectScreen"; | ||
| 2 | |||
| 3 | export default function AddProjectDestinationRoute() { | ||
| 4 | return <AddProjectDestinationScreen />; | ||
| 5 | } | ||
apps/mobile/src/app/new/add-project/index.tsx deleted-5| ... | @@ -1,5 +0,0 @@ | ||
| 1 | import { AddProjectSourceScreen } from "../../../features/projects/AddProjectScreen"; | ||
| 2 | |||
| 3 | export default function AddProjectRoute() { | ||
| 4 | return <AddProjectSourceScreen />; | ||
| 5 | } | ||
apps/mobile/src/app/new/add-project/local.tsx deleted-5| ... | @@ -1,5 +0,0 @@ | ||
| 1 | import { AddProjectLocalFolderScreen } from "../../../features/projects/AddProjectScreen"; | ||
| 2 | |||
| 3 | export default function AddProjectLocalRoute() { | ||
| 4 | return <AddProjectLocalFolderScreen />; | ||
| 5 | } | ||
apps/mobile/src/app/new/add-project/repository.tsx deleted-23| ... | @@ -1,23 +0,0 @@ | ||
| 1 | import { Stack, useLocalSearchParams } from "expo-router"; | ||
| 2 | import { addProjectRemoteSourceLabel } from "@t3tools/client-runtime/operations/projects"; | ||
| 3 | |||
| 4 | import { AddProjectRepositoryScreen } from "../../../features/projects/AddProjectScreen"; | ||
| 5 | |||
| 6 | export default function AddProjectRepositoryRoute() { | ||
| 7 | const params = useLocalSearchParams<{ source?: string | string[] }>(); | ||
| 8 | const source = Array.isArray(params.source) ? params.source[0] : params.source; | ||
| 9 | const title = | ||
| 10 | source === "github" || | ||
| 11 | source === "gitlab" || | ||
| 12 | source === "bitbucket" || | ||
| 13 | source === "azure-devops" | ||
| 14 | ? addProjectRemoteSourceLabel(source) | ||
| 15 | : "Git URL"; | ||
| 16 | |||
| 17 | return ( | ||
| 18 | <> | ||
| 19 | <Stack.Screen options={{ title }} /> | ||
| 20 | <AddProjectRepositoryScreen /> | ||
| 21 | </> | ||
| 22 | ); | ||
| 23 | } | ||
apps/mobile/src/app/new/draft.tsx deleted-29| ... | @@ -1,29 +0,0 @@ | ||
| 1 | import { Stack, useLocalSearchParams } from "expo-router"; | ||
| 2 | |||
| 3 | import { NewTaskDraftScreen } from "../../features/threads/NewTaskDraftScreen"; | ||
| 4 | |||
| 5 | export default function NewTaskDraftRoute() { | ||
| 6 | const params = useLocalSearchParams<{ | ||
| 7 | environmentId?: string | string[]; | ||
| 8 | projectId?: string | string[]; | ||
| 9 | title?: string | string[]; | ||
| 10 | }>(); | ||
| 11 | |||
| 12 | return ( | ||
| 13 | <> | ||
| 14 | <Stack.Screen | ||
| 15 | options={{ | ||
| 16 | title: Array.isArray(params.title) ? params.title[0] : (params.title ?? "New task"), | ||
| 17 | }} | ||
| 18 | /> | ||
| 19 | <NewTaskDraftScreen | ||
| 20 | initialProjectRef={{ | ||
| 21 | environmentId: Array.isArray(params.environmentId) | ||
| 22 | ? params.environmentId[0] | ||
| 23 | : params.environmentId, | ||
| 24 | projectId: Array.isArray(params.projectId) ? params.projectId[0] : params.projectId, | ||
| 25 | }} | ||
| 26 | /> | ||
| 27 | </> | ||
| 28 | ); | ||
| 29 | } | ||
apps/mobile/src/app/new/index.tsx deleted-218| ... | @@ -1,218 +0,0 @@ | ||
| 1 | import { Link, Stack, useRouter } from "expo-router"; | ||
| 2 | import { SymbolView } from "expo-symbols"; | ||
| 3 | import type { EnvironmentId, ProjectId } from "@t3tools/contracts"; | ||
| 4 | import { useMemo } from "react"; | ||
| 5 | import { ActivityIndicator, Pressable, ScrollView, View } from "react-native"; | ||
| 6 | import { useSafeAreaInsets } from "react-native-safe-area-context"; | ||
| 7 | import { useThemeColor } from "../../lib/useThemeColor"; | ||
| 8 | |||
| 9 | import { AppText as Text } from "../../components/AppText"; | ||
| 10 | import { ProjectFavicon } from "../../components/ProjectFavicon"; | ||
| 11 | import { useProjects, useThreadShells } from "../../state/entities"; | ||
| 12 | import type { WorkspaceState } from "../../state/workspaceModel"; | ||
| 13 | import { useWorkspaceState } from "../../state/workspace"; | ||
| 14 | import { groupProjectsByRepository } from "../../lib/repositoryGroups"; | ||
| 15 | |||
| 16 | function deriveProjectEmptyState(catalogState: WorkspaceState): { | ||
| 17 | readonly title: string; | ||
| 18 | readonly detail: string; | ||
| 19 | readonly loading: boolean; | ||
| 20 | } { | ||
| 21 | if (catalogState.isLoadingConnections) { | ||
| 22 | return { | ||
| 23 | title: "Loading environments", | ||
| 24 | detail: "Checking saved environments on this device.", | ||
| 25 | loading: true, | ||
| 26 | }; | ||
| 27 | } | ||
| 28 | |||
| 29 | if (!catalogState.hasConnections) { | ||
| 30 | return { | ||
| 31 | title: "No environments connected", | ||
| 32 | detail: "Add an environment before creating a task.", | ||
| 33 | loading: false, | ||
| 34 | }; | ||
| 35 | } | ||
| 36 | |||
| 37 | if ( | ||
| 38 | (catalogState.connectionState === "available" || | ||
| 39 | catalogState.connectionState === "offline" || | ||
| 40 | catalogState.connectionState === "error") && | ||
| 41 | !catalogState.hasLoadedShellSnapshot | ||
| 42 | ) { | ||
| 43 | return { | ||
| 44 | title: "Environment unavailable", | ||
| 45 | detail: | ||
| 46 | catalogState.connectionError ?? | ||
| 47 | "The saved environment is offline. Check the URL or start the environment, then retry.", | ||
| 48 | loading: false, | ||
| 49 | }; | ||
| 50 | } | ||
| 51 | |||
| 52 | if ( | ||
| 53 | catalogState.hasConnectingEnvironment && | ||
| 54 | !catalogState.hasLoadedShellSnapshot && | ||
| 55 | catalogState.connectionError === null | ||
| 56 | ) { | ||
| 57 | return { | ||
| 58 | title: "Connecting to environment", | ||
| 59 | detail: "Loading projects from the saved environment.", | ||
| 60 | loading: true, | ||
| 61 | }; | ||
| 62 | } | ||
| 63 | |||
| 64 | return { | ||
| 65 | title: "No projects found", | ||
| 66 | detail: "The connected environment did not report any projects.", | ||
| 67 | loading: false, | ||
| 68 | }; | ||
| 69 | } | ||
| 70 | |||
| 71 | export default function NewTaskRoute() { | ||
| 72 | const projects = useProjects(); | ||
| 73 | const threads = useThreadShells(); | ||
| 74 | const { state: catalogState } = useWorkspaceState(); | ||
| 75 | const router = useRouter(); | ||
| 76 | const insets = useSafeAreaInsets(); | ||
| 77 | const chevronColor = useThemeColor("--color-chevron"); | ||
| 78 | const accentColor = useThemeColor("--color-icon-muted"); | ||
| 79 | const borderSubtleColor = useThemeColor("--color-border-subtle"); | ||
| 80 | const repositoryGroups = useMemo( | ||
| 81 | () => groupProjectsByRepository({ projects, threads }), | ||
| 82 | [projects, threads], | ||
| 83 | ); | ||
| 84 | const items = useMemo(() => { | ||
| 85 | const nextItems: Array<{ | ||
| 86 | readonly environmentId: EnvironmentId; | ||
| 87 | readonly id: ProjectId; | ||
| 88 | readonly key: string; | ||
| 89 | readonly title: string; | ||
| 90 | readonly workspaceRoot: string; | ||
| 91 | }> = []; | ||
| 92 | for (const group of repositoryGroups) { | ||
| 93 | const project = group.projects[0]?.project; | ||
| 94 | if (!project) { | ||
| 95 | continue; | ||
| 96 | } | ||
| 97 | nextItems.push({ | ||
| 98 | environmentId: project.environmentId, | ||
| 99 | id: project.id, | ||
| 100 | key: group.key, | ||
| 101 | title: project.title, | ||
| 102 | workspaceRoot: project.workspaceRoot, | ||
| 103 | }); | ||
| 104 | } | ||
| 105 | return nextItems; | ||
| 106 | }, [repositoryGroups]); | ||
| 107 | const projectEmptyState = deriveProjectEmptyState(catalogState); | ||
| 108 | |||
| 109 | return ( | ||
| 110 | <View collapsable={false} className="flex-1 bg-sheet"> | ||
| 111 | <Stack.Screen options={{ title: "Choose project" }} /> | ||
| 112 | <Stack.Toolbar placement="right"> | ||
| 113 | <Stack.Toolbar.Button | ||
| 114 | icon="plus" | ||
| 115 | onPress={() => router.push("/new/add-project")} | ||
| 116 | separateBackground | ||
| 117 | /> | ||
| 118 | </Stack.Toolbar> | ||
| 119 | |||
| 120 | <ScrollView | ||
| 121 | showsVerticalScrollIndicator={false} | ||
| 122 | style={{ flex: 1 }} | ||
| 123 | contentInset={{ bottom: Math.max(insets.bottom, 18) + 18 }} | ||
| 124 | contentContainerStyle={{ | ||
| 125 | paddingHorizontal: 20, | ||
| 126 | paddingTop: 8, | ||
| 127 | }} | ||
| 128 | > | ||
| 129 | {items.length === 0 ? ( | ||
| 130 | <View collapsable={false} className="items-center gap-3 rounded-[24px] bg-card px-6 py-8"> | ||
| 131 | {projectEmptyState.loading ? <ActivityIndicator color={accentColor} /> : null} | ||
| 132 | <Text className="text-center text-lg font-t3-bold text-foreground"> | ||
| 133 | {projectEmptyState.title} | ||
| 134 | </Text> | ||
| 135 | <Text className="text-center text-sm leading-[20px] text-foreground-muted"> | ||
| 136 | {projectEmptyState.detail} | ||
| 137 | </Text> | ||
| 138 | {!catalogState.hasReadyEnvironment ? ( | ||
| 139 | <Pressable | ||
| 140 | className="mt-1 rounded-full bg-primary px-4 py-2.5 active:opacity-70" | ||
| 141 | onPress={() => router.push("/connections/new")} | ||
| 142 | > | ||
| 143 | <Text className="text-sm font-t3-bold text-primary-foreground"> | ||
| 144 | Add environment | ||
| 145 | </Text> | ||
| 146 | </Pressable> | ||
| 147 | ) : ( | ||
| 148 | <Pressable | ||
| 149 | className="mt-1 rounded-full bg-primary px-4 py-2.5 active:opacity-70" | ||
| 150 | onPress={() => router.push("/new/add-project")} | ||
| 151 | > | ||
| 152 | <Text className="text-sm font-t3-bold text-primary-foreground"> | ||
| 153 | Add new project | ||
| 154 | </Text> | ||
| 155 | </Pressable> | ||
| 156 | )} | ||
| 157 | </View> | ||
| 158 | ) : ( | ||
| 159 | <View collapsable={false} className="overflow-hidden rounded-[24px] bg-card"> | ||
| 160 | {items.map((item, index) => { | ||
| 161 | const isFirst = index === 0; | ||
| 162 | const isLast = index === items.length - 1; | ||
| 163 | |||
| 164 | return ( | ||
| 165 | <Link | ||
| 166 | key={item.key} | ||
| 167 | href={{ | ||
| 168 | pathname: "/new/draft", | ||
| 169 | params: { | ||
| 170 | environmentId: item.environmentId, | ||
| 171 | projectId: item.id, | ||
| 172 | title: item.title, | ||
| 173 | }, | ||
| 174 | }} | ||
| 175 | asChild | ||
| 176 | > | ||
| 177 | <Pressable | ||
| 178 | className="bg-card" | ||
| 179 | style={{ | ||
| 180 | paddingHorizontal: 16, | ||
| 181 | paddingVertical: 14, | ||
| 182 | borderTopWidth: isFirst ? 0 : 1, | ||
| 183 | borderTopColor: borderSubtleColor, | ||
| 184 | borderTopLeftRadius: isFirst ? 24 : 0, | ||
| 185 | borderTopRightRadius: isFirst ? 24 : 0, | ||
| 186 | borderBottomLeftRadius: isLast ? 24 : 0, | ||
| 187 | borderBottomRightRadius: isLast ? 24 : 0, | ||
| 188 | }} | ||
| 189 | > | ||
| 190 | <View className="flex-row items-center justify-between gap-3"> | ||
| 191 | <View className="h-7 w-7 items-center justify-center"> | ||
| 192 | <ProjectFavicon | ||
| 193 | environmentId={item.environmentId} | ||
| 194 | size={20} | ||
| 195 | projectTitle={item.title} | ||
| 196 | workspaceRoot={item.workspaceRoot} | ||
| 197 | /> | ||
| 198 | </View> | ||
| 199 | <View className="flex-1"> | ||
| 200 | <Text className="text-base leading-[21px] font-t3-bold">{item.title}</Text> | ||
| 201 | </View> | ||
| 202 | <SymbolView | ||
| 203 | name="chevron.right" | ||
| 204 | size={14} | ||
| 205 | tintColor={chevronColor} | ||
| 206 | type="monochrome" | ||
| 207 | /> | ||
| 208 | </View> | ||
| 209 | </Pressable> | ||
| 210 | </Link> | ||
| 211 | ); | ||
| 212 | })} | ||
| 213 | </View> | ||
| 214 | )} | ||
| 215 | </ScrollView> | ||
| 216 | </View> | ||
| 217 | ); | ||
| 218 | } | ||
apps/mobile/src/app/settings/_layout.tsx deleted-62| ... | @@ -1,62 +0,0 @@ | ||
| 1 | import Stack from "expo-router/stack"; | ||
| 2 | import { useCallback } from "react"; | ||
| 3 | import { useResolveClassNames } from "uniwind"; | ||
| 4 | |||
| 5 | import { useClerkSettingsSheetDetent } from "../../features/cloud/ClerkSettingsSheetDetent"; | ||
| 6 | import { useThemeColor } from "../../lib/useThemeColor"; | ||
| 7 | |||
| 8 | export const unstable_settings = { | ||
| 9 | anchor: "index", | ||
| 10 | }; | ||
| 11 | |||
| 12 | export default function SettingsLayout() { | ||
| 13 | const { collapse } = useClerkSettingsSheetDetent(); | ||
| 14 | const contentStyle = useResolveClassNames("bg-sheet"); | ||
| 15 | const sheetBg = useThemeColor("--color-sheet"); | ||
| 16 | const headerTint = useThemeColor("--color-foreground"); | ||
| 17 | const handleExpandedRouteTransitionEnd = useCallback( | ||
| 18 | (event: { data: { closing: boolean } }) => { | ||
| 19 | if (event.data.closing) { | ||
| 20 | collapse(); | ||
| 21 | } | ||
| 22 | }, | ||
| 23 | [collapse], | ||
| 24 | ); | ||
| 25 | |||
| 26 | return ( | ||
| 27 | <Stack | ||
| 28 | screenOptions={{ | ||
| 29 | contentStyle, | ||
| 30 | headerBackButtonDisplayMode: "minimal", | ||
| 31 | headerShadowVisible: false, | ||
| 32 | headerStyle: { backgroundColor: sheetBg }, | ||
| 33 | headerTintColor: headerTint, | ||
| 34 | headerTitleStyle: { fontFamily: "DMSans_700Bold" }, | ||
| 35 | }} | ||
| 36 | > | ||
| 37 | <Stack.Screen name="index" options={{ animation: "none", title: "Settings" }} /> | ||
| 38 | <Stack.Screen | ||
| 39 | name="environments" | ||
| 40 | options={{ animation: "slide_from_right", title: "Environments" }} | ||
| 41 | /> | ||
| 42 | <Stack.Screen | ||
| 43 | name="environment-new" | ||
| 44 | options={{ animation: "slide_from_right", title: "Add Environment" }} | ||
| 45 | /> | ||
| 46 | <Stack.Screen | ||
| 47 | name="waitlist" | ||
| 48 | options={{ animation: "slide_from_right", title: "Join the waitlist" }} | ||
| 49 | /> | ||
| 50 | <Stack.Screen | ||
| 51 | name="archive" | ||
| 52 | listeners={{ transitionEnd: handleExpandedRouteTransitionEnd }} | ||
| 53 | options={{ animation: "slide_from_right", title: "Archived Threads" }} | ||
| 54 | /> | ||
| 55 | <Stack.Screen | ||
| 56 | name="auth" | ||
| 57 | listeners={{ transitionEnd: handleExpandedRouteTransitionEnd }} | ||
| 58 | options={{ animation: "slide_from_right", title: "Sign in" }} | ||
| 59 | /> | ||
| 60 | </Stack> | ||
| 61 | ); | ||
| 62 | } | ||
apps/mobile/src/app/settings/archive.tsx deleted-3| ... | @@ -1,3 +0,0 @@ | ||
| 1 | import { ArchivedThreadsRouteScreen } from "../../features/archive/ArchivedThreadsRouteScreen"; | ||
| 2 | |||
| 3 | export default ArchivedThreadsRouteScreen; | ||
apps/mobile/src/app/settings/auth.tsx deleted-33| ... | @@ -1,33 +0,0 @@ | ||
| 1 | import { useAuth } from "@clerk/expo"; | ||
| 2 | import { AuthView, UserProfileView } from "@clerk/expo/native"; | ||
| 3 | import { Redirect, Stack } from "expo-router"; | ||
| 4 | import { View } from "react-native"; | ||
| 5 | |||
| 6 | import { hasCloudPublicConfig } from "../../features/cloud/publicConfig"; | ||
| 7 | |||
| 8 | export default function SettingsAuthRouteScreen() { | ||
| 9 | return hasCloudPublicConfig() ? ( | ||
| 10 | <ConfiguredSettingsAuthRouteScreen /> | ||
| 11 | ) : ( | ||
| 12 | <Redirect href="/settings" /> | ||
| 13 | ); | ||
| 14 | } | ||
| 15 | |||
| 16 | function ConfiguredSettingsAuthRouteScreen() { | ||
| 17 | const { isLoaded, isSignedIn } = useAuth({ treatPendingAsSignedOut: false }); | ||
| 18 | |||
| 19 | return ( | ||
| 20 | <> | ||
| 21 | <Stack.Screen options={{ title: isSignedIn ? "Account" : "Sign in" }} /> | ||
| 22 | <View collapsable={false} className="flex-1 overflow-hidden bg-sheet"> | ||
| 23 | {isLoaded ? ( | ||
| 24 | isSignedIn ? ( | ||
| 25 | <UserProfileView isDismissible={false} /> | ||
| 26 | ) : ( | ||
| 27 | <AuthView isDismissible={false} /> | ||
| 28 | ) | ||
| 29 | ) : null} | ||
| 30 | </View> | ||
| 31 | </> | ||
| 32 | ); | ||
| 33 | } | ||
apps/mobile/src/app/settings/environment-new.tsx deleted-1| ... | @@ -1 +0,0 @@ | ||
| 1 | export { default } from "../connections/new"; | ||
apps/mobile/src/app/settings/environments.tsx deleted-452| ... | @@ -1,452 +0,0 @@ | ||
| 1 | import { useAuth } from "@clerk/expo"; | ||
| 2 | import { Stack, useRouter } from "expo-router"; | ||
| 3 | import { SymbolView } from "expo-symbols"; | ||
| 4 | import { | ||
| 5 | connectionStatusText, | ||
| 6 | type EnvironmentConnectionPhase, | ||
| 7 | } from "@t3tools/client-runtime/connection"; | ||
| 8 | import type { EnvironmentId } from "@t3tools/contracts"; | ||
| 9 | import { useCallback, useState } from "react"; | ||
| 10 | import { | ||
| 11 | ActivityIndicator, | ||
| 12 | Pressable, | ||
| 13 | ScrollView, | ||
| 14 | Switch, | ||
| 15 | type NativeSyntheticEvent, | ||
| 16 | type TextLayoutEventData, | ||
| 17 | View, | ||
| 18 | } from "react-native"; | ||
| 19 | import { useSafeAreaInsets } from "react-native-safe-area-context"; | ||
| 20 | |||
| 21 | import { AppText as Text } from "../../components/AppText"; | ||
| 22 | import { | ||
| 23 | type RelayEnvironmentView, | ||
| 24 | useConnectionController, | ||
| 25 | } from "../../features/connection/useConnectionController"; | ||
| 26 | import { hasCloudPublicConfig } from "../../features/cloud/publicConfig"; | ||
| 27 | import { availableCloudEnvironmentPresentation } from "../../features/cloud/cloudEnvironmentPresentation"; | ||
| 28 | import { ConnectionEnvironmentRow } from "../../features/connection/ConnectionEnvironmentRow"; | ||
| 29 | import { ConnectionStatusDot } from "../../features/connection/ConnectionStatusDot"; | ||
| 30 | import { splitEnvironmentSections } from "../../features/connection/environmentSections"; | ||
| 31 | import { cn } from "../../lib/cn"; | ||
| 32 | import { copyTextWithHaptic } from "../../lib/copyTextWithHaptic"; | ||
| 33 | import { useThemeColor } from "../../lib/useThemeColor"; | ||
| 34 | import type { ConnectedEnvironmentSummary } from "../../state/remote-runtime-types"; | ||
| 35 | import { useRemoteConnections } from "../../state/use-remote-environment-registry"; | ||
| 36 | |||
| 37 | export default function SettingsEnvironmentsRouteScreen() { | ||
| 38 | const { | ||
| 39 | connectedEnvironments, | ||
| 40 | onReconnectEnvironment, | ||
| 41 | onRemoveEnvironmentPress, | ||
| 42 | onUpdateEnvironment, | ||
| 43 | } = useRemoteConnections(); | ||
| 44 | const router = useRouter(); | ||
| 45 | const insets = useSafeAreaInsets(); | ||
| 46 | const { localEnvironments, connectedCloudEnvironments } = splitEnvironmentSections({ | ||
| 47 | connectedEnvironments, | ||
| 48 | cloudEnvironments: null, | ||
| 49 | }); | ||
| 50 | const hasLocalEnvironments = localEnvironments.length > 0; | ||
| 51 | const [expandedId, setExpandedId] = useState<EnvironmentId | null>(null); | ||
| 52 | const accentColor = useThemeColor("--color-icon-muted"); | ||
| 53 | |||
| 54 | const handleToggle = useCallback((environmentId: EnvironmentId) => { | ||
| 55 | setExpandedId((prev) => (prev === environmentId ? null : environmentId)); | ||
| 56 | }, []); | ||
| 57 | |||
| 58 | return ( | ||
| 59 | <View collapsable={false} className="flex-1 bg-sheet"> | ||
| 60 | <Stack.Screen | ||
| 61 | options={{ | ||
| 62 | title: "Environments", | ||
| 63 | }} | ||
| 64 | /> | ||
| 65 | <Stack.Toolbar placement="right"> | ||
| 66 | <Stack.Toolbar.Button | ||
| 67 | icon="plus" | ||
| 68 | onPress={() => router.push("/settings/environment-new")} | ||
| 69 | separateBackground | ||
| 70 | /> | ||
| 71 | </Stack.Toolbar> | ||
| 72 | <ScrollView | ||
| 73 | contentInsetAdjustmentBehavior="automatic" | ||
| 74 | showsVerticalScrollIndicator={false} | ||
| 75 | style={{ flex: 1 }} | ||
| 76 | contentContainerStyle={{ | ||
| 77 | paddingBottom: Math.max(insets.bottom, 18) + 18, | ||
| 78 | paddingHorizontal: 20, | ||
| 79 | paddingTop: 16, | ||
| 80 | }} | ||
| 81 | > | ||
| 82 | {hasLocalEnvironments ? ( | ||
| 83 | <View collapsable={false} className="overflow-hidden rounded-[24px] bg-card"> | ||
| 84 | {localEnvironments.map((environment, index) => ( | ||
| 85 | <View | ||
| 86 | key={environment.environmentId} | ||
| 87 | collapsable={false} | ||
| 88 | style={{ | ||
| 89 | borderTopWidth: index === 0 ? 0 : 1, | ||
| 90 | }} | ||
| 91 | className={cn(index !== 0 && "border-border")} | ||
| 92 | > | ||
| 93 | <ConnectionEnvironmentRow | ||
| 94 | environment={environment} | ||
| 95 | expanded={expandedId === environment.environmentId} | ||
| 96 | onToggle={() => handleToggle(environment.environmentId)} | ||
| 97 | onReconnect={onReconnectEnvironment} | ||
| 98 | onRemove={onRemoveEnvironmentPress} | ||
| 99 | onUpdate={onUpdateEnvironment} | ||
| 100 | /> | ||
| 101 | </View> | ||
| 102 | ))} | ||
| 103 | </View> | ||
| 104 | ) : ( | ||
| 105 | <View collapsable={false} className="items-center gap-3 rounded-[24px] bg-card px-6 py-8"> | ||
| 106 | <View className="h-12 w-12 items-center justify-center rounded-[16px] bg-subtle"> | ||
| 107 | <SymbolView | ||
| 108 | name="point.3.connected.trianglepath.dotted" | ||
| 109 | size={20} | ||
| 110 | tintColor={accentColor} | ||
| 111 | type="monochrome" | ||
| 112 | /> | ||
| 113 | </View> | ||
| 114 | <Text className="text-center text-sm leading-[20px] text-foreground-muted"> | ||
| 115 | No environments connected yet.{"\n"}Tap{" "} | ||
| 116 | <Text className="font-t3-bold text-foreground">+</Text> to add one. | ||
| 117 | </Text> | ||
| 118 | </View> | ||
| 119 | )} | ||
| 120 | |||
| 121 | {hasCloudPublicConfig() ? ( | ||
| 122 | <ConfiguredCloudEnvironmentRows | ||
| 123 | connectedCloudEnvironments={connectedCloudEnvironments} | ||
| 124 | onReconnectEnvironment={onReconnectEnvironment} | ||
| 125 | /> | ||
| 126 | ) : null} | ||
| 127 | </ScrollView> | ||
| 128 | </View> | ||
| 129 | ); | ||
| 130 | } | ||
| 131 | |||
| 132 | function ConfiguredCloudEnvironmentRows(props: { | ||
| 133 | readonly connectedCloudEnvironments: ReadonlyArray<ConnectedEnvironmentSummary>; | ||
| 134 | readonly onReconnectEnvironment: (environmentId: EnvironmentId) => void; | ||
| 135 | }) { | ||
| 136 | const { isSignedIn } = useAuth({ treatPendingAsSignedOut: false }); | ||
| 137 | const controller = useConnectionController(); | ||
| 138 | const iconColor = useThemeColor("--color-icon"); | ||
| 139 | const availableCloudEnvironments = controller.availableRelayEnvironments; | ||
| 140 | const [expandedErrorId, setExpandedErrorId] = useState<string | null>(null); | ||
| 141 | const hasCloudRows = | ||
| 142 | props.connectedCloudEnvironments.length > 0 || availableCloudEnvironments.length > 0; | ||
| 143 | |||
| 144 | const handleConnectCloudEnvironment = useCallback( | ||
| 145 | (entry: RelayEnvironmentView) => controller.connectRelayEnvironment(entry.environment), | ||
| 146 | [controller], | ||
| 147 | ); | ||
| 148 | |||
| 149 | const handleDisconnectCloudEnvironment = useCallback( | ||
| 150 | (environmentId: EnvironmentId) => controller.removeEnvironment(environmentId), | ||
| 151 | [controller], | ||
| 152 | ); | ||
| 153 | |||
| 154 | const handleToggleCloudError = useCallback((environmentId: string) => { | ||
| 155 | setExpandedErrorId((current) => (current === environmentId ? null : environmentId)); | ||
| 156 | }, []); | ||
| 157 | |||
| 158 | if (!isSignedIn) return null; | ||
| 159 | |||
| 160 | return ( | ||
| 161 | <View collapsable={false} className="mt-5 gap-3"> | ||
| 162 | <View className="flex-row items-center justify-between px-1"> | ||
| 163 | <Text className="text-sm font-t3-bold uppercase text-foreground-muted">T3 Cloud</Text> | ||
| 164 | <Pressable | ||
| 165 | accessibilityRole="button" | ||
| 166 | disabled={controller.relayDiscovery.isRefreshing} | ||
| 167 | onPress={() => { | ||
| 168 | void controller.refreshRelayEnvironments(); | ||
| 169 | }} | ||
| 170 | className="h-9 w-9 items-center justify-center rounded-full bg-subtle active:opacity-70 disabled:opacity-50" | ||
| 171 | > | ||
| 172 | {controller.relayDiscovery.isRefreshing ? ( | ||
| 173 | <ActivityIndicator color={iconColor} size="small" /> | ||
| 174 | ) : ( | ||
| 175 | <SymbolView name="arrow.clockwise" size={14} tintColor={iconColor} type="monochrome" /> | ||
| 176 | )} | ||
| 177 | </Pressable> | ||
| 178 | </View> | ||
| 179 | |||
| 180 | {hasCloudRows ? ( | ||
| 181 | <View collapsable={false} className="overflow-hidden rounded-[24px] bg-card"> | ||
| 182 | {props.connectedCloudEnvironments.map((environment, index) => ( | ||
| 183 | <ConnectedCloudEnvironmentRow | ||
| 184 | key={environment.environmentId} | ||
| 185 | environment={environment} | ||
| 186 | borderTop={index !== 0} | ||
| 187 | onConnect={() => props.onReconnectEnvironment(environment.environmentId)} | ||
| 188 | onDisconnect={() => handleDisconnectCloudEnvironment(environment.environmentId)} | ||
| 189 | errorExpanded={expandedErrorId === environment.environmentId} | ||
| 190 | onToggleError={() => handleToggleCloudError(environment.environmentId)} | ||
| 191 | /> | ||
| 192 | ))} | ||
| 193 | {availableCloudEnvironments.map((environment, index) => ( | ||
| 194 | <CloudEnvironmentRow | ||
| 195 | key={environment.environment.environmentId} | ||
| 196 | environment={environment} | ||
| 197 | borderTop={props.connectedCloudEnvironments.length > 0 || index !== 0} | ||
| 198 | onConnect={() => handleConnectCloudEnvironment(environment)} | ||
| 199 | errorExpanded={expandedErrorId === environment.environment.environmentId} | ||
| 200 | onToggleError={() => handleToggleCloudError(environment.environment.environmentId)} | ||
| 201 | /> | ||
| 202 | ))} | ||
| 203 | </View> | ||
| 204 | ) : controller.relayDiscovery.isRefreshing ? ( | ||
| 205 | <View collapsable={false} className="items-center gap-3 rounded-[24px] bg-card p-6"> | ||
| 206 | <ActivityIndicator color={iconColor} /> | ||
| 207 | <Text className="text-center text-sm leading-[20px] text-foreground-muted"> | ||
| 208 | Loading linked cloud environments. | ||
| 209 | </Text> | ||
| 210 | </View> | ||
| 211 | ) : controller.relayDiscovery.error ? ( | ||
| 212 | <View collapsable={false} className="gap-3 rounded-[24px] bg-card p-5"> | ||
| 213 | <Text className="text-base font-t3-bold text-foreground"> | ||
| 214 | Could not load T3 Cloud environments | ||
| 215 | </Text> | ||
| 216 | <Text className="text-sm leading-[18px] text-foreground-muted"> | ||
| 217 | {controller.relayDiscovery.error} | ||
| 218 | </Text> | ||
| 219 | {controller.relayDiscovery.errorTraceId ? ( | ||
| 220 | <CopyTraceIdButton traceId={controller.relayDiscovery.errorTraceId} /> | ||
| 221 | ) : null} | ||
| 222 | </View> | ||
| 223 | ) : ( | ||
| 224 | <View collapsable={false} className="rounded-[24px] bg-card p-5"> | ||
| 225 | <Text className="text-sm leading-[20px] text-foreground-muted"> | ||
| 226 | No additional linked cloud environments. | ||
| 227 | </Text> | ||
| 228 | </View> | ||
| 229 | )} | ||
| 230 | </View> | ||
| 231 | ); | ||
| 232 | } | ||
| 233 | |||
| 234 | function ConnectedCloudEnvironmentRow(props: { | ||
| 235 | readonly environment: ConnectedEnvironmentSummary; | ||
| 236 | readonly borderTop: boolean; | ||
| 237 | readonly errorExpanded: boolean; | ||
| 238 | readonly onConnect: () => void; | ||
| 239 | readonly onDisconnect: () => void; | ||
| 240 | readonly onToggleError: () => void; | ||
| 241 | }) { | ||
| 242 | return ( | ||
| 243 | <CloudEnvironmentRowShell | ||
| 244 | borderTop={props.borderTop} | ||
| 245 | connectionError={props.environment.connectionError} | ||
| 246 | connectionErrorTraceId={props.environment.connectionErrorTraceId} | ||
| 247 | connectionState={props.environment.connectionState} | ||
| 248 | errorExpanded={props.errorExpanded} | ||
| 249 | label={props.environment.environmentLabel} | ||
| 250 | onValueChange={(enabled) => { | ||
| 251 | if (enabled) { | ||
| 252 | props.onConnect(); | ||
| 253 | return; | ||
| 254 | } | ||
| 255 | props.onDisconnect(); | ||
| 256 | }} | ||
| 257 | onToggleError={props.onToggleError} | ||
| 258 | value={props.environment.connectionState !== "available"} | ||
| 259 | /> | ||
| 260 | ); | ||
| 261 | } | ||
| 262 | |||
| 263 | function CloudEnvironmentRow(props: { | ||
| 264 | readonly environment: RelayEnvironmentView; | ||
| 265 | readonly borderTop: boolean; | ||
| 266 | readonly errorExpanded: boolean; | ||
| 267 | readonly onConnect: () => void; | ||
| 268 | readonly onToggleError: () => void; | ||
| 269 | }) { | ||
| 270 | const presentation = availableCloudEnvironmentPresentation({ | ||
| 271 | isStatusPending: props.environment.availability === "checking", | ||
| 272 | status: props.environment.status, | ||
| 273 | statusError: props.environment.error, | ||
| 274 | statusErrorTraceId: props.environment.traceId, | ||
| 275 | }); | ||
| 276 | |||
| 277 | return ( | ||
| 278 | <CloudEnvironmentRowShell | ||
| 279 | borderTop={props.borderTop} | ||
| 280 | connectionError={presentation.connectionError} | ||
| 281 | connectionErrorTraceId={presentation.connectionErrorTraceId} | ||
| 282 | connectionState={presentation.connectionState} | ||
| 283 | errorExpanded={props.errorExpanded} | ||
| 284 | label={props.environment.environment.label} | ||
| 285 | onValueChange={(enabled) => { | ||
| 286 | if (enabled) { | ||
| 287 | props.onConnect(); | ||
| 288 | } | ||
| 289 | }} | ||
| 290 | onToggleError={props.onToggleError} | ||
| 291 | statusText={presentation.statusText} | ||
| 292 | value={false} | ||
| 293 | /> | ||
| 294 | ); | ||
| 295 | } | ||
| 296 | |||
| 297 | function CloudEnvironmentRowShell(props: { | ||
| 298 | readonly borderTop: boolean; | ||
| 299 | readonly connectionError: string | null; | ||
| 300 | readonly connectionErrorTraceId: string | null; | ||
| 301 | readonly connectionState: EnvironmentConnectionPhase; | ||
| 302 | readonly disabled?: boolean; | ||
| 303 | readonly errorExpanded: boolean; | ||
| 304 | readonly label: string; | ||
| 305 | readonly onToggleError: () => void; | ||
| 306 | readonly onValueChange: (enabled: boolean) => void; | ||
| 307 | readonly statusText?: string; | ||
| 308 | readonly value: boolean; | ||
| 309 | }) { | ||
| 310 | const activeTrack = String(useThemeColor("--color-switch-active")); | ||
| 311 | const track = String(useThemeColor("--color-secondary-border")); | ||
| 312 | const chevron = useThemeColor("--color-chevron"); | ||
| 313 | const isRetrying = | ||
| 314 | props.connectionState === "connecting" || props.connectionState === "reconnecting"; | ||
| 315 | const shouldPulse = isRetrying; | ||
| 316 | const statusText = | ||
| 317 | props.statusText ?? | ||
| 318 | connectionStatusText({ | ||
| 319 | phase: props.connectionState, | ||
| 320 | error: props.connectionError, | ||
| 321 | traceId: props.connectionErrorTraceId, | ||
| 322 | }); | ||
| 323 | const statusClassName = props.connectionError | ||
| 324 | ? "text-rose-500 dark:text-rose-400" | ||
| 325 | : "text-foreground-muted"; | ||
| 326 | const [errorMeasurement, setErrorMeasurement] = useState<{ | ||
| 327 | readonly text: string; | ||
| 328 | readonly lineCount: number; | ||
| 329 | } | null>(null); | ||
| 330 | const errorTraceId = props.connectionErrorTraceId; | ||
| 331 | const measuredErrorText = errorTraceId ? `${statusText} Trace ID: ${errorTraceId}` : statusText; | ||
| 332 | const errorLineCount = | ||
| 333 | errorMeasurement?.text === measuredErrorText ? errorMeasurement.lineCount : 0; | ||
| 334 | const errorCanExpand = props.connectionError !== null && errorLineCount > 1; | ||
| 335 | const isErrorExpanded = errorCanExpand && props.errorExpanded; | ||
| 336 | const StatusContainer = errorCanExpand ? Pressable : View; | ||
| 337 | const onMeasuredErrorTextLayout = useCallback( | ||
| 338 | (event: NativeSyntheticEvent<TextLayoutEventData>) => { | ||
| 339 | if (!props.connectionError) { | ||
| 340 | return; | ||
| 341 | } | ||
| 342 | const nextLineCount = event.nativeEvent.lines.length; | ||
| 343 | setErrorMeasurement((currentMeasurement) => | ||
| 344 | currentMeasurement?.text === measuredErrorText && | ||
| 345 | currentMeasurement.lineCount === nextLineCount | ||
| 346 | ? currentMeasurement | ||
| 347 | : { text: measuredErrorText, lineCount: nextLineCount }, | ||
| 348 | ); | ||
| 349 | }, | ||
| 350 | [measuredErrorText, props.connectionError], | ||
| 351 | ); | ||
| 352 | return ( | ||
| 353 | <View | ||
| 354 | collapsable={false} | ||
| 355 | className={cn( | ||
| 356 | "flex-row items-center gap-3 bg-card px-4 py-3.5", | ||
| 357 | props.borderTop && "border-t border-border", | ||
| 358 | )} | ||
| 359 | > | ||
| 360 | <View className="min-w-0 flex-1 gap-0.5"> | ||
| 361 | <View className="min-w-0 flex-row items-center gap-2"> | ||
| 362 | <ConnectionStatusDot state={props.connectionState} pulse={shouldPulse} size={7} /> | ||
| 363 | <Text | ||
| 364 | className="min-w-0 flex-shrink text-base font-t3-bold leading-[21px] text-foreground" | ||
| 365 | numberOfLines={1} | ||
| 366 | > | ||
| 367 | {props.label} | ||
| 368 | </Text> | ||
| 369 | </View> | ||
| 370 | {props.connectionError ? ( | ||
| 371 | <Text | ||
| 372 | aria-hidden | ||
| 373 | className={cn("absolute left-0 right-0 text-xs leading-[16px]", statusClassName)} | ||
| 374 | onTextLayout={onMeasuredErrorTextLayout} | ||
| 375 | style={{ opacity: 0, zIndex: -1 }} | ||
| 376 | > | ||
| 377 | {measuredErrorText} | ||
| 378 | </Text> | ||
| 379 | ) : null} | ||
| 380 | <StatusContainer | ||
| 381 | {...(errorCanExpand | ||
| 382 | ? { accessibilityRole: "button" as const, onPress: props.onToggleError } | ||
| 383 | : {})} | ||
| 384 | className="min-w-0 flex-row items-start gap-1" | ||
| 385 | > | ||
| 386 | <Text | ||
| 387 | className={cn("min-w-0 flex-1 text-xs leading-[16px]", statusClassName)} | ||
| 388 | numberOfLines={isErrorExpanded ? undefined : 1} | ||
| 389 | > | ||
| 390 | {statusText} | ||
| 391 | {errorTraceId ? ( | ||
| 392 | <> | ||
| 393 | {" Trace ID: "} | ||
| 394 | <Text | ||
| 395 | accessibilityHint="Copies the trace ID" | ||
| 396 | accessibilityRole="button" | ||
| 397 | className={cn("text-xs leading-[16px] underline", statusClassName)} | ||
| 398 | onLongPress={(event) => { | ||
| 399 | event.stopPropagation(); | ||
| 400 | copyTextWithHaptic(errorTraceId, { target: "connection-trace-id" }); | ||
| 401 | }} | ||
| 402 | onPress={(event) => { | ||
| 403 | event.stopPropagation(); | ||
| 404 | }} | ||
| 405 | style={{ textDecorationStyle: "dotted" }} | ||
| 406 | > | ||
| 407 | {errorTraceId} | ||
| 408 | </Text> | ||
| 409 | </> | ||
| 410 | ) : null} | ||
| 411 | </Text> | ||
| 412 | {errorCanExpand ? ( | ||
| 413 | <SymbolView | ||
| 414 | name="chevron.down" | ||
| 415 | size={10} | ||
| 416 | tintColor={chevron} | ||
| 417 | type="monochrome" | ||
| 418 | style={{ | ||
| 419 | marginTop: 3, | ||
| 420 | transform: [{ rotate: isErrorExpanded ? "180deg" : "0deg" }], | ||
| 421 | }} | ||
| 422 | /> | ||
| 423 | ) : null} | ||
| 424 | </StatusContainer> | ||
| 425 | </View> | ||
| 426 | <Switch | ||
| 427 | disabled={props.disabled} | ||
| 428 | ios_backgroundColor={track} | ||
| 429 | onValueChange={props.onValueChange} | ||
| 430 | trackColor={{ false: track, true: activeTrack }} | ||
| 431 | value={props.value} | ||
| 432 | /> | ||
| 433 | </View> | ||
| 434 | ); | ||
| 435 | } | ||
| 436 | |||
| 437 | function CopyTraceIdButton(props: { readonly traceId: string }) { | ||
| 438 | const iconColor = useThemeColor("--color-icon"); | ||
| 439 | |||
| 440 | return ( | ||
| 441 | <Pressable | ||
| 442 | accessibilityRole="button" | ||
| 443 | onPress={() => { | ||
| 444 | copyTextWithHaptic(props.traceId, { target: "connection-trace-id" }); | ||
| 445 | }} | ||
| 446 | className="self-start flex-row items-center gap-1.5 rounded-full bg-subtle px-3 py-2 active:opacity-70" | ||
| 447 | > | ||
| 448 | <SymbolView name="doc.on.doc" size={12} tintColor={iconColor} type="monochrome" /> | ||
| 449 | <Text className="text-xs font-t3-bold text-foreground">Copy trace ID</Text> | ||
| 450 | </Pressable> | ||
| 451 | ); | ||
| 452 | } | ||
apps/mobile/src/app/settings/index.tsx deleted-515| ... | @@ -1,515 +0,0 @@ | ||
| 1 | import { useAuth, useUser } from "@clerk/expo"; | ||
| 2 | import * as Notifications from "expo-notifications"; | ||
| 3 | import { Link, Stack, useRouter } from "expo-router"; | ||
| 4 | import { SymbolView } from "expo-symbols"; | ||
| 5 | import * as Effect from "effect/Effect"; | ||
| 6 | import { useCallback, useEffect, useMemo, useState } from "react"; | ||
| 7 | import type { ComponentProps, ReactNode } from "react"; | ||
| 8 | import { Alert, Linking, Pressable, ScrollView, Switch, View } from "react-native"; | ||
| 9 | import { useSafeAreaInsets } from "react-native-safe-area-context"; | ||
| 10 | |||
| 11 | import { | ||
| 12 | isAtomCommandInterrupted, | ||
| 13 | reportAtomCommandResult, | ||
| 14 | settleAsyncResult, | ||
| 15 | settlePromise, | ||
| 16 | squashAtomCommandFailure, | ||
| 17 | } from "@t3tools/client-runtime/state/runtime"; | ||
| 18 | import { AppText as Text } from "../../components/AppText"; | ||
| 19 | import { setLiveActivityUpdatesEnabled } from "../../features/agent-awareness/liveActivityPreferences"; | ||
| 20 | import { requestAgentNotificationPermission } from "../../features/agent-awareness/notificationPermissions"; | ||
| 21 | import { refreshAgentAwarenessRegistration } from "../../features/agent-awareness/remoteRegistration"; | ||
| 22 | import { refreshManagedRelayEnvironments } from "../../features/cloud/managedRelayState"; | ||
| 23 | import { useClerkSettingsSheetDetent } from "../../features/cloud/ClerkSettingsSheetDetent"; | ||
| 24 | import { | ||
| 25 | hasCloudPublicConfig, | ||
| 26 | resolveRelayClerkTokenOptions, | ||
| 27 | } from "../../features/cloud/publicConfig"; | ||
| 28 | import { runtime } from "../../lib/runtime"; | ||
| 29 | import { loadPreferences } from "../../lib/storage"; | ||
| 30 | import { useThemeColor } from "../../lib/useThemeColor"; | ||
| 31 | import { useSavedRemoteConnections } from "../../state/use-remote-environment-registry"; | ||
| 32 | |||
| 33 | type NotificationStatus = "checking" | "enabled" | "disabled" | "unsupported"; | ||
| 34 | type LiveActivityStatus = "checking" | "enabled" | "disabled" | "signed-out" | "linking"; | ||
| 35 | |||
| 36 | export default function SettingsRouteScreen() { | ||
| 37 | return hasCloudPublicConfig() ? <ConfiguredSettingsRouteScreen /> : <LocalSettingsRouteScreen />; | ||
| 38 | } | ||
| 39 | |||
| 40 | function LocalSettingsRouteScreen() { | ||
| 41 | const insets = useSafeAreaInsets(); | ||
| 42 | const { savedConnectionsById } = useSavedRemoteConnections(); | ||
| 43 | const environmentCount = Object.keys(savedConnectionsById).length; | ||
| 44 | |||
| 45 | return ( | ||
| 46 | <View collapsable={false} className="flex-1 bg-sheet"> | ||
| 47 | <Stack.Screen options={{ title: "Settings" }} /> | ||
| 48 | <ScrollView | ||
| 49 | contentInsetAdjustmentBehavior="automatic" | ||
| 50 | showsVerticalScrollIndicator={false} | ||
| 51 | style={{ flex: 1 }} | ||
| 52 | contentContainerStyle={{ | ||
| 53 | gap: 24, | ||
| 54 | paddingBottom: Math.max(insets.bottom, 18) + 18, | ||
| 55 | paddingHorizontal: 20, | ||
| 56 | paddingTop: 16, | ||
| 57 | }} | ||
| 58 | > | ||
| 59 | <SettingsSection title="Configuration"> | ||
| 60 | <SettingsRow | ||
| 61 | icon="desktopcomputer" | ||
| 62 | label="Environments" | ||
| 63 | value={`${environmentCount}`} | ||
| 64 | href="/settings/environments" | ||
| 65 | /> | ||
| 66 | </SettingsSection> | ||
| 67 | |||
| 68 | <ArchivedThreadsSettingsSection /> | ||
| 69 | |||
| 70 | <AppSettingsSection /> | ||
| 71 | </ScrollView> | ||
| 72 | </View> | ||
| 73 | ); | ||
| 74 | } | ||
| 75 | |||
| 76 | function ConfiguredSettingsRouteScreen() { | ||
| 77 | const insets = useSafeAreaInsets(); | ||
| 78 | const { push } = useRouter(); | ||
| 79 | const { expand: expandClerkSheet } = useClerkSettingsSheetDetent(); | ||
| 80 | const { getToken, isLoaded, isSignedIn } = useAuth({ treatPendingAsSignedOut: false }); | ||
| 81 | const { user } = useUser(); | ||
| 82 | const { savedConnectionsById } = useSavedRemoteConnections(); | ||
| 83 | const [notificationStatus, setNotificationStatus] = useState<NotificationStatus>("checking"); | ||
| 84 | const [liveActivityStatus, setLiveActivityStatus] = useState<LiveActivityStatus>("checking"); | ||
| 85 | |||
| 86 | const connections = useMemo(() => Object.values(savedConnectionsById), [savedConnectionsById]); | ||
| 87 | const environmentCount = connections.length; | ||
| 88 | const accountLabel = useMemo(() => { | ||
| 89 | if (!isLoaded) return "Checking"; | ||
| 90 | if (!isSignedIn) return "Request access"; | ||
| 91 | return user?.primaryEmailAddress?.emailAddress ?? "Signed in"; | ||
| 92 | }, [isLoaded, isSignedIn, user?.primaryEmailAddress?.emailAddress]); | ||
| 93 | |||
| 94 | const refreshNotifications = useCallback(async () => { | ||
| 95 | if (process.env.EXPO_OS !== "ios") { | ||
| 96 | setNotificationStatus("unsupported"); | ||
| 97 | return; | ||
| 98 | } | ||
| 99 | const result = await settlePromise(() => Notifications.getPermissionsAsync()); | ||
| 100 | if (result._tag === "Failure") { | ||
| 101 | reportAtomCommandResult(result, { label: "notification permission refresh" }); | ||
| 102 | setNotificationStatus("disabled"); | ||
| 103 | return; | ||
| 104 | } | ||
| 105 | setNotificationStatus(result.value.granted ? "enabled" : "disabled"); | ||
| 106 | }, []); | ||
| 107 | |||
| 108 | useEffect(() => { | ||
| 109 | void refreshNotifications(); | ||
| 110 | }, [refreshNotifications]); | ||
| 111 | |||
| 112 | useEffect(() => { | ||
| 113 | if (!isLoaded) { | ||
| 114 | setLiveActivityStatus("checking"); | ||
| 115 | return; | ||
| 116 | } | ||
| 117 | if (!isSignedIn) { | ||
| 118 | setLiveActivityStatus("signed-out"); | ||
| 119 | return; | ||
| 120 | } | ||
| 121 | void (async () => { | ||
| 122 | const result = await settlePromise(() => loadPreferences()); | ||
| 123 | if (result._tag === "Failure") { | ||
| 124 | reportAtomCommandResult(result, { label: "live activity preference load" }); | ||
| 125 | setLiveActivityStatus("enabled"); | ||
| 126 | return; | ||
| 127 | } | ||
| 128 | setLiveActivityStatus(result.value.liveActivitiesEnabled === false ? "disabled" : "enabled"); | ||
| 129 | })(); | ||
| 130 | }, [isLoaded, isSignedIn]); | ||
| 131 | |||
| 132 | const requestNotifications = useCallback(async () => { | ||
| 133 | const result = await settleAsyncResult(() => | ||
| 134 | runtime.runPromiseExit( | ||
| 135 | requestAgentNotificationPermission.pipe( | ||
| 136 | Effect.tap((permission) => | ||
| 137 | permission.type === "granted" ? refreshAgentAwarenessRegistration() : Effect.void, | ||
| 138 | ), | ||
| 139 | ), | ||
| 140 | ), | ||
| 141 | ); | ||
| 142 | if (result._tag === "Failure") { | ||
| 143 | if (!isAtomCommandInterrupted(result)) { | ||
| 144 | const error = squashAtomCommandFailure(result); | ||
| 145 | Alert.alert( | ||
| 146 | "Notifications unavailable", | ||
| 147 | error instanceof Error ? error.message : "Could not request notification permission.", | ||
| 148 | ); | ||
| 149 | } | ||
| 150 | return; | ||
| 151 | } | ||
| 152 | if (result.value.type === "granted") { | ||
| 153 | setNotificationStatus("enabled"); | ||
| 154 | Alert.alert( | ||
| 155 | "Notifications enabled", | ||
| 156 | "Live Activity notifications are enabled for this device.", | ||
| 157 | ); | ||
| 158 | return; | ||
| 159 | } | ||
| 160 | if (result.value.type === "unsupported") { | ||
| 161 | setNotificationStatus("unsupported"); | ||
| 162 | Alert.alert( | ||
| 163 | "Notifications unavailable", | ||
| 164 | "Live Activity notifications are only available on iOS.", | ||
| 165 | ); | ||
| 166 | return; | ||
| 167 | } | ||
| 168 | setNotificationStatus("disabled"); | ||
| 169 | if (result.value.canAskAgain) { | ||
| 170 | Alert.alert("Notifications disabled", "Notifications were not enabled."); | ||
| 171 | return; | ||
| 172 | } | ||
| 173 | Alert.alert( | ||
| 174 | "Notifications disabled", | ||
| 175 | "Notifications were denied for this app. Open Settings to enable them.", | ||
| 176 | [ | ||
| 177 | { text: "Cancel", style: "cancel" }, | ||
| 178 | { text: "Open Settings", onPress: () => void Linking.openSettings() }, | ||
| 179 | ], | ||
| 180 | ); | ||
| 181 | }, []); | ||
| 182 | |||
| 183 | const promptSignIn = useCallback(() => { | ||
| 184 | Alert.alert( | ||
| 185 | "Request T3 Cloud access", | ||
| 186 | "Live Activity updates require approved T3 Cloud access so relay can deliver updates to this device.", | ||
| 187 | [ | ||
| 188 | { text: "Cancel", style: "cancel" }, | ||
| 189 | { text: "Continue", onPress: () => push("/settings/waitlist") }, | ||
| 190 | ], | ||
| 191 | ); | ||
| 192 | }, [push]); | ||
| 193 | |||
| 194 | const linkEnvironments = useCallback(async () => { | ||
| 195 | if (!isSignedIn) { | ||
| 196 | promptSignIn(); | ||
| 197 | return; | ||
| 198 | } | ||
| 199 | |||
| 200 | setLiveActivityStatus("linking"); | ||
| 201 | const tokenResult = await settlePromise(() => getToken(resolveRelayClerkTokenOptions())); | ||
| 202 | if (tokenResult._tag === "Failure") { | ||
| 203 | setLiveActivityStatus("disabled"); | ||
| 204 | const error = squashAtomCommandFailure(tokenResult); | ||
| 205 | Alert.alert( | ||
| 206 | "Live Activities unavailable", | ||
| 207 | error instanceof Error ? error.message : "Could not enable Live Activity updates.", | ||
| 208 | ); | ||
| 209 | return; | ||
| 210 | } | ||
| 211 | if (!tokenResult.value) { | ||
| 212 | promptSignIn(); | ||
| 213 | setLiveActivityStatus("signed-out"); | ||
| 214 | return; | ||
| 215 | } | ||
| 216 | |||
| 217 | const updateResult = await settleAsyncResult(() => | ||
| 218 | runtime.runPromiseExit( | ||
| 219 | setLiveActivityUpdatesEnabled({ | ||
| 220 | enabled: true, | ||
| 221 | clerkToken: tokenResult.value, | ||
| 222 | connections, | ||
| 223 | }), | ||
| 224 | ), | ||
| 225 | ); | ||
| 226 | if (updateResult._tag === "Failure") { | ||
| 227 | setLiveActivityStatus("disabled"); | ||
| 228 | if (!isAtomCommandInterrupted(updateResult)) { | ||
| 229 | const error = squashAtomCommandFailure(updateResult); | ||
| 230 | Alert.alert( | ||
| 231 | "Live Activities unavailable", | ||
| 232 | error instanceof Error ? error.message : "Could not enable Live Activity updates.", | ||
| 233 | ); | ||
| 234 | } | ||
| 235 | return; | ||
| 236 | } | ||
| 237 | |||
| 238 | refreshManagedRelayEnvironments(); | ||
| 239 | setLiveActivityStatus("enabled"); | ||
| 240 | Alert.alert( | ||
| 241 | "Live Activities enabled", | ||
| 242 | environmentCount > 0 | ||
| 243 | ? `${environmentCount} environment${environmentCount === 1 ? "" : "s"} linked for Live Activity updates.` | ||
| 244 | : "Live Activity updates are enabled. Add an environment to start receiving updates.", | ||
| 245 | ); | ||
| 246 | }, [connections, environmentCount, getToken, isSignedIn, promptSignIn]); | ||
| 247 | |||
| 248 | const handleDeviceNotificationsChange = useCallback( | ||
| 249 | (enabled: boolean) => { | ||
| 250 | if (enabled) { | ||
| 251 | void requestNotifications(); | ||
| 252 | return; | ||
| 253 | } | ||
| 254 | |||
| 255 | Alert.alert( | ||
| 256 | "Disable notifications", | ||
| 257 | "Notification permission is controlled by iOS. Open Settings to disable notifications for T3 Code.", | ||
| 258 | [ | ||
| 259 | { text: "Cancel", style: "cancel" }, | ||
| 260 | { text: "Open Settings", onPress: () => void Linking.openSettings() }, | ||
| 261 | ], | ||
| 262 | ); | ||
| 263 | }, | ||
| 264 | [requestNotifications], | ||
| 265 | ); | ||
| 266 | |||
| 267 | const handleLiveActivitiesChange = useCallback( | ||
| 268 | (enabled: boolean) => { | ||
| 269 | if (!enabled) { | ||
| 270 | setLiveActivityStatus("disabled"); | ||
| 271 | void (async () => { | ||
| 272 | let token: string | null = null; | ||
| 273 | if (isSignedIn) { | ||
| 274 | const tokenResult = await settlePromise(() => | ||
| 275 | getToken(resolveRelayClerkTokenOptions()), | ||
| 276 | ); | ||
| 277 | if (tokenResult._tag === "Failure") { | ||
| 278 | reportAtomCommandResult(tokenResult, { | ||
| 279 | label: "live activity disable token lookup", | ||
| 280 | }); | ||
| 281 | return; | ||
| 282 | } | ||
| 283 | token = tokenResult.value; | ||
| 284 | } | ||
| 285 | |||
| 286 | const updateResult = await settleAsyncResult(() => | ||
| 287 | runtime.runPromiseExit( | ||
| 288 | setLiveActivityUpdatesEnabled({ | ||
| 289 | enabled: false, | ||
| 290 | clerkToken: token, | ||
| 291 | connections, | ||
| 292 | }), | ||
| 293 | ), | ||
| 294 | ); | ||
| 295 | if (updateResult._tag === "Failure") { | ||
| 296 | reportAtomCommandResult(updateResult, { | ||
| 297 | label: "live activity disable", | ||
| 298 | }); | ||
| 299 | return; | ||
| 300 | } | ||
| 301 | refreshManagedRelayEnvironments(); | ||
| 302 | })(); | ||
| 303 | return; | ||
| 304 | } | ||
| 305 | |||
| 306 | if (!isSignedIn) { | ||
| 307 | promptSignIn(); | ||
| 308 | return; | ||
| 309 | } | ||
| 310 | |||
| 311 | void linkEnvironments(); | ||
| 312 | }, | ||
| 313 | [connections, getToken, isSignedIn, linkEnvironments, promptSignIn], | ||
| 314 | ); | ||
| 315 | |||
| 316 | const openAccount = useCallback(() => { | ||
| 317 | if (!isLoaded) return; | ||
| 318 | if (!isSignedIn) { | ||
| 319 | push("/settings/waitlist"); | ||
| 320 | return; | ||
| 321 | } | ||
| 322 | expandClerkSheet(); | ||
| 323 | push("/settings/auth"); | ||
| 324 | }, [expandClerkSheet, isLoaded, isSignedIn, push]); | ||
| 325 | |||
| 326 | return ( | ||
| 327 | <View collapsable={false} className="flex-1 bg-sheet"> | ||
| 328 | <Stack.Screen options={{ title: "Settings" }} /> | ||
| 329 | <ScrollView | ||
| 330 | contentInsetAdjustmentBehavior="automatic" | ||
| 331 | showsVerticalScrollIndicator={false} | ||
| 332 | style={{ flex: 1 }} | ||
| 333 | contentContainerStyle={{ | ||
| 334 | gap: 24, | ||
| 335 | paddingBottom: Math.max(insets.bottom, 18) + 18, | ||
| 336 | paddingHorizontal: 20, | ||
| 337 | paddingTop: 16, | ||
| 338 | }} | ||
| 339 | > | ||
| 340 | <View className="gap-3"> | ||
| 341 | <SettingsSection title="Account"> | ||
| 342 | <SettingsRow | ||
| 343 | icon="person.crop.circle" | ||
| 344 | label="T3 Account" | ||
| 345 | value={accountLabel} | ||
| 346 | onPress={openAccount} | ||
| 347 | /> | ||
| 348 | </SettingsSection> | ||
| 349 | <Text className="px-2 text-sm leading-[18px] text-foreground-muted"> | ||
| 350 | T3 Code works locally without signing in. Cloud features are optional. | ||
| 351 | </Text> | ||
| 352 | </View> | ||
| 353 | |||
| 354 | <SettingsSection title="Configuration"> | ||
| 355 | <SettingsRow | ||
| 356 | icon="desktopcomputer" | ||
| 357 | label="Environments" | ||
| 358 | value={`${environmentCount}`} | ||
| 359 | href="/settings/environments" | ||
| 360 | /> | ||
| 361 | <SettingsSwitchRow | ||
| 362 | icon="bell.badge" | ||
| 363 | label="Device Notifications" | ||
| 364 | disabled={notificationStatus === "checking" || notificationStatus === "unsupported"} | ||
| 365 | value={notificationStatus === "enabled"} | ||
| 366 | onValueChange={handleDeviceNotificationsChange} | ||
| 367 | /> | ||
| 368 | <SettingsSwitchRow | ||
| 369 | disabled={ | ||
| 370 | !isLoaded || liveActivityStatus === "checking" || liveActivityStatus === "linking" | ||
| 371 | } | ||
| 372 | icon="bolt.circle" | ||
| 373 | label="Live Activity Updates" | ||
| 374 | value={liveActivityStatus === "enabled" || liveActivityStatus === "linking"} | ||
| 375 | onValueChange={handleLiveActivitiesChange} | ||
| 376 | /> | ||
| 377 | </SettingsSection> | ||
| 378 | |||
| 379 | <ArchivedThreadsSettingsSection /> | ||
| 380 | |||
| 381 | <AppSettingsSection /> | ||
| 382 | </ScrollView> | ||
| 383 | </View> | ||
| 384 | ); | ||
| 385 | } | ||
| 386 | |||
| 387 | type SymbolName = ComponentProps<typeof SymbolView>["name"]; | ||
| 388 | |||
| 389 | function SettingsSection(props: { readonly title: string; readonly children: ReactNode }) { | ||
| 390 | return ( | ||
| 391 | <View className="gap-2"> | ||
| 392 | <Text className="px-2 text-sm font-t3-medium text-foreground-muted">{props.title}</Text> | ||
| 393 | <View | ||
| 394 | className="overflow-hidden rounded-[28px] bg-card" | ||
| 395 | style={{ borderCurve: "continuous" }} | ||
| 396 | > | ||
| 397 | {props.children} | ||
| 398 | </View> | ||
| 399 | </View> | ||
| 400 | ); | ||
| 401 | } | ||
| 402 | |||
| 403 | function AppSettingsSection() { | ||
| 404 | const icon = useThemeColor("--color-icon"); | ||
| 405 | |||
| 406 | return ( | ||
| 407 | <SettingsSection title="App"> | ||
| 408 | <View className="flex-row items-center gap-4 p-4"> | ||
| 409 | <SymbolView | ||
| 410 | name="info.circle" | ||
| 411 | size={22} | ||
| 412 | tintColor={icon} | ||
| 413 | type="monochrome" | ||
| 414 | weight="regular" | ||
| 415 | /> | ||
| 416 | <Text className="flex-1 text-lg text-foreground">Version</Text> | ||
| 417 | <Text className="text-lg text-foreground-muted">Alpha</Text> | ||
| 418 | </View> | ||
| 419 | </SettingsSection> | ||
| 420 | ); | ||
| 421 | } | ||
| 422 | |||
| 423 | function ArchivedThreadsSettingsSection() { | ||
| 424 | return ( | ||
| 425 | <SettingsSection title="Threads"> | ||
| 426 | <SettingsRow icon="archivebox" label="Archived Threads" href="/settings/archive" /> | ||
| 427 | </SettingsSection> | ||
| 428 | ); | ||
| 429 | } | ||
| 430 | |||
| 431 | function SettingsRow(props: { | ||
| 432 | readonly disabled?: boolean; | ||
| 433 | readonly icon: SymbolName; | ||
| 434 | readonly label: string; | ||
| 435 | readonly value?: string; | ||
| 436 | readonly href?: "/settings/archive" | "/settings/environments"; | ||
| 437 | readonly onPress?: () => void; | ||
| 438 | }) { | ||
| 439 | const icon = useThemeColor("--color-icon"); | ||
| 440 | const chevron = useThemeColor("--color-chevron"); | ||
| 441 | const content = ( | ||
| 442 | <View | ||
| 443 | className="flex-row items-center gap-4 p-4" | ||
| 444 | style={{ opacity: props.disabled ? 0.45 : 1 }} | ||
| 445 | > | ||
| 446 | <SymbolView name={props.icon} size={22} tintColor={icon} type="monochrome" weight="regular" /> | ||
| 447 | <Text className="shrink-0 text-lg text-foreground" numberOfLines={1}> | ||
| 448 | {props.label} | ||
| 449 | </Text> | ||
| 450 | <View className="min-w-0 flex-1 items-end"> | ||
| 451 | {props.value ? ( | ||
| 452 | <Text | ||
| 453 | className="max-w-[180px] text-right text-base text-foreground-muted" | ||
| 454 | ellipsizeMode="middle" | ||
| 455 | numberOfLines={1} | ||
| 456 | > | ||
| 457 | {props.value} | ||
| 458 | </Text> | ||
| 459 | ) : null} | ||
| 460 | </View> | ||
| 461 | <SymbolView | ||
| 462 | name="chevron.right" | ||
| 463 | size={16} | ||
| 464 | tintColor={chevron} | ||
| 465 | type="monochrome" | ||
| 466 | weight="semibold" | ||
| 467 | /> | ||
| 468 | </View> | ||
| 469 | ); | ||
| 470 | |||
| 471 | if (props.href) { | ||
| 472 | return ( | ||
| 473 | <Link href={props.href} asChild> | ||
| 474 | <Pressable accessibilityLabel={props.label} accessibilityRole="button"> | ||
| 475 | {content} | ||
| 476 | </Pressable> | ||
| 477 | </Link> | ||
| 478 | ); | ||
| 479 | } | ||
| 480 | |||
| 481 | return ( | ||
| 482 | <Pressable accessibilityRole="button" disabled={props.disabled} onPress={props.onPress}> | ||
| 483 | {content} | ||
| 484 | </Pressable> | ||
| 485 | ); | ||
| 486 | } | ||
| 487 | |||
| 488 | function SettingsSwitchRow(props: { | ||
| 489 | readonly disabled?: boolean; | ||
| 490 | readonly icon: SymbolName; | ||
| 491 | readonly label: string; | ||
| 492 | readonly value: boolean; | ||
| 493 | readonly onValueChange: (value: boolean) => void; | ||
| 494 | }) { | ||
| 495 | const icon = useThemeColor("--color-icon"); | ||
| 496 | const activeTrack = String(useThemeColor("--color-switch-active")); | ||
| 497 | const track = String(useThemeColor("--color-secondary-border")); | ||
| 498 | |||
| 499 | return ( | ||
| 500 | <View | ||
| 501 | className="flex-row items-center gap-4 p-4" | ||
| 502 | style={{ opacity: props.disabled ? 0.45 : 1 }} | ||
| 503 | > | ||
| 504 | <SymbolView name={props.icon} size={22} tintColor={icon} type="monochrome" weight="regular" /> | ||
| 505 | <Text className="flex-1 text-lg text-foreground">{props.label}</Text> | ||
| 506 | <Switch | ||
| 507 | disabled={props.disabled} | ||
| 508 | ios_backgroundColor={track} | ||
| 509 | onValueChange={props.onValueChange} | ||
| 510 | trackColor={{ false: track, true: activeTrack }} | ||
| 511 | value={props.value} | ||
| 512 | /> | ||
| 513 | </View> | ||
| 514 | ); | ||
| 515 | } | ||
apps/mobile/src/app/settings/waitlist.tsx deleted-55| ... | @@ -1,55 +0,0 @@ | ||
| 1 | import { useAuth } from "@clerk/expo"; | ||
| 2 | import { Redirect, Stack, useFocusEffect, useRouter } from "expo-router"; | ||
| 3 | import { useCallback } from "react"; | ||
| 4 | import { ScrollView } from "react-native"; | ||
| 5 | |||
| 6 | import { CloudWaitlistEnrollment } from "../../features/cloud/CloudWaitlistEnrollment"; | ||
| 7 | import { useClerkSettingsSheetDetent } from "../../features/cloud/ClerkSettingsSheetDetent"; | ||
| 8 | import { hasCloudPublicConfig } from "../../features/cloud/publicConfig"; | ||
| 9 | |||
| 10 | export default function SettingsWaitlistRouteScreen() { | ||
| 11 | return hasCloudPublicConfig() ? ( | ||
| 12 | <ConfiguredSettingsWaitlistRouteScreen /> | ||
| 13 | ) : ( | ||
| 14 | <Redirect href="/settings" /> | ||
| 15 | ); | ||
| 16 | } | ||
| 17 | |||
| 18 | function ConfiguredSettingsWaitlistRouteScreen() { | ||
| 19 | const { isLoaded, isSignedIn } = useAuth({ treatPendingAsSignedOut: false }); | ||
| 20 | const { expand } = useClerkSettingsSheetDetent(); | ||
| 21 | const router = useRouter(); | ||
| 22 | |||
| 23 | useFocusEffect( | ||
| 24 | useCallback(() => { | ||
| 25 | if (isLoaded && isSignedIn) { | ||
| 26 | router.replace("/settings"); | ||
| 27 | } | ||
| 28 | }, [isLoaded, isSignedIn, router]), | ||
| 29 | ); | ||
| 30 | |||
| 31 | return ( | ||
| 32 | <> | ||
| 33 | <Stack.Screen options={{ title: "Join the waitlist" }} /> | ||
| 34 | <ScrollView | ||
| 35 | automaticallyAdjustKeyboardInsets | ||
| 36 | contentContainerStyle={{ | ||
| 37 | paddingBottom: 32, | ||
| 38 | paddingHorizontal: 20, | ||
| 39 | paddingTop: 12, | ||
| 40 | }} | ||
| 41 | contentInsetAdjustmentBehavior="automatic" | ||
| 42 | keyboardDismissMode="interactive" | ||
| 43 | keyboardShouldPersistTaps="handled" | ||
| 44 | showsVerticalScrollIndicator={false} | ||
| 45 | > | ||
| 46 | <CloudWaitlistEnrollment | ||
| 47 | onSignIn={() => { | ||
| 48 | expand(); | ||
| 49 | router.push("/settings/auth"); | ||
| 50 | }} | ||
| 51 | /> | ||
| 52 | </ScrollView> | ||
| 53 | </> | ||
| 54 | ); | ||
| 55 | } | ||
apps/mobile/src/app/threads/[environmentId]/[threadId]/_layout.tsx deleted-107| ... | @@ -1,107 +0,0 @@ | ||
| 1 | import Stack from "expo-router/stack"; | ||
| 2 | import { StyleSheet } from "react-native"; | ||
| 3 | import { useResolveClassNames } from "uniwind"; | ||
| 4 | |||
| 5 | export default function ThreadLayout() { | ||
| 6 | const sheetStyle = StyleSheet.flatten(useResolveClassNames("bg-sheet")); | ||
| 7 | const headerBg = { | ||
| 8 | backgroundColor: (sheetStyle as { backgroundColor?: string })?.backgroundColor, | ||
| 9 | }; | ||
| 10 | |||
| 11 | return ( | ||
| 12 | <Stack screenOptions={{ headerShown: false }}> | ||
| 13 | <Stack.Screen | ||
| 14 | name="index" | ||
| 15 | options={{ | ||
| 16 | contentStyle: { backgroundColor: "transparent" }, | ||
| 17 | headerShown: true, | ||
| 18 | headerTransparent: true, | ||
| 19 | headerShadowVisible: false, | ||
| 20 | headerTitle: "", | ||
| 21 | }} | ||
| 22 | /> | ||
| 23 | <Stack.Screen | ||
| 24 | name="git" | ||
| 25 | options={{ | ||
| 26 | contentStyle: sheetStyle, | ||
| 27 | gestureEnabled: true, | ||
| 28 | headerShown: false, | ||
| 29 | presentation: "formSheet" as const, | ||
| 30 | sheetAllowedDetents: [0.85], | ||
| 31 | sheetGrabberVisible: true, | ||
| 32 | }} | ||
| 33 | /> | ||
| 34 | <Stack.Screen | ||
| 35 | name="git-confirm" | ||
| 36 | options={{ | ||
| 37 | contentStyle: sheetStyle, | ||
| 38 | gestureEnabled: true, | ||
| 39 | headerShown: false, | ||
| 40 | presentation: "formSheet" as const, | ||
| 41 | sheetAllowedDetents: [0.4], | ||
| 42 | sheetGrabberVisible: true, | ||
| 43 | }} | ||
| 44 | /> | ||
| 45 | <Stack.Screen | ||
| 46 | name="review" | ||
| 47 | options={{ | ||
| 48 | animation: "slide_from_right", | ||
| 49 | contentStyle: sheetStyle, | ||
| 50 | headerBackButtonDisplayMode: "minimal", | ||
| 51 | headerShown: true, | ||
| 52 | headerTitle: "Files changed", | ||
| 53 | headerBackTitle: "", | ||
| 54 | headerShadowVisible: false, | ||
| 55 | headerStyle: headerBg, | ||
| 56 | }} | ||
| 57 | /> | ||
| 58 | <Stack.Screen | ||
| 59 | name="files/index" | ||
| 60 | options={{ | ||
| 61 | animation: "slide_from_right", | ||
| 62 | contentStyle: sheetStyle, | ||
| 63 | headerBackButtonDisplayMode: "minimal", | ||
| 64 | headerShown: true, | ||
| 65 | headerTitle: "Files", | ||
| 66 | headerBackTitle: "", | ||
| 67 | headerShadowVisible: false, | ||
| 68 | headerStyle: headerBg, | ||
| 69 | }} | ||
| 70 | /> | ||
| 71 | <Stack.Screen | ||
| 72 | name="files/[...path]" | ||
| 73 | options={{ | ||
| 74 | animation: "slide_from_right", | ||
| 75 | contentStyle: sheetStyle, | ||
| 76 | headerBackButtonDisplayMode: "minimal", | ||
| 77 | headerShown: true, | ||
| 78 | headerTitle: "File", | ||
| 79 | headerBackTitle: "", | ||
| 80 | headerShadowVisible: false, | ||
| 81 | headerStyle: headerBg, | ||
| 82 | }} | ||
| 83 | /> | ||
| 84 | <Stack.Screen | ||
| 85 | name="review-comment" | ||
| 86 | options={{ | ||
| 87 | contentStyle: sheetStyle, | ||
| 88 | gestureEnabled: true, | ||
| 89 | headerShown: false, | ||
| 90 | presentation: "formSheet" as const, | ||
| 91 | sheetAllowedDetents: [0.72, 0.92], | ||
| 92 | sheetGrabberVisible: true, | ||
| 93 | }} | ||
| 94 | /> | ||
| 95 | <Stack.Screen | ||
| 96 | name="terminal" | ||
| 97 | options={{ | ||
| 98 | animation: "slide_from_right", | ||
| 99 | contentStyle: { backgroundColor: "#050505" }, | ||
| 100 | headerBackButtonDisplayMode: "minimal", | ||
| 101 | headerShown: true, | ||
| 102 | headerShadowVisible: false, | ||
| 103 | }} | ||
| 104 | /> | ||
| 105 | </Stack> | ||
| 106 | ); | ||
| 107 | } | ||
apps/mobile/src/app/threads/[environmentId]/[threadId]/files/[...path].tsx deleted-5| ... | @@ -1,5 +0,0 @@ | ||
| 1 | import { ThreadFileScreen } from "../../../../../features/files/ThreadFilesRouteScreen"; | ||
| 2 | |||
| 3 | export default function ThreadFileRoute() { | ||
| 4 | return <ThreadFileScreen />; | ||
| 5 | } | ||
apps/mobile/src/app/threads/[environmentId]/[threadId]/files/index.tsx deleted-5| ... | @@ -1,5 +0,0 @@ | ||
| 1 | import { ThreadFilesTreeScreen } from "../../../../../features/files/ThreadFilesRouteScreen"; | ||
| 2 | |||
| 3 | export default function ThreadFilesIndexRoute() { | ||
| 4 | return <ThreadFilesTreeScreen />; | ||
| 5 | } | ||
apps/mobile/src/app/threads/[environmentId]/[threadId]/git-confirm.tsx deleted-5| ... | @@ -1,5 +0,0 @@ | ||
| 1 | import { GitConfirmSheet } from "../../../../features/threads/git/GitConfirmSheet"; | ||
| 2 | |||
| 3 | export default function GitConfirmRoute() { | ||
| 4 | return <GitConfirmSheet />; | ||
| 5 | } | ||
apps/mobile/src/app/threads/[environmentId]/[threadId]/git/_layout.tsx deleted-58| ... | @@ -1,58 +0,0 @@ | ||
| 1 | import Stack from "expo-router/stack"; | ||
| 2 | import { StyleSheet } from "react-native"; | ||
| 3 | import { useResolveClassNames } from "uniwind"; | ||
| 4 | |||
| 5 | export const unstable_settings = { | ||
| 6 | anchor: "index", | ||
| 7 | }; | ||
| 8 | |||
| 9 | export default function GitSheetLayout() { | ||
| 10 | const sheetStyle = StyleSheet.flatten(useResolveClassNames("bg-sheet")); | ||
| 11 | const headerBg = { | ||
| 12 | backgroundColor: (sheetStyle as { backgroundColor?: string })?.backgroundColor, | ||
| 13 | }; | ||
| 14 | |||
| 15 | return ( | ||
| 16 | <Stack | ||
| 17 | screenOptions={{ | ||
| 18 | contentStyle: sheetStyle, | ||
| 19 | headerShown: false, | ||
| 20 | }} | ||
| 21 | > | ||
| 22 | <Stack.Screen name="index" options={{ animation: "none" }} /> | ||
| 23 | <Stack.Screen | ||
| 24 | name="commit" | ||
| 25 | options={{ | ||
| 26 | animation: "slide_from_right", | ||
| 27 | headerShown: true, | ||
| 28 | headerTitle: "Commit changes", | ||
| 29 | headerBackTitle: "", | ||
| 30 | headerShadowVisible: false, | ||
| 31 | headerStyle: headerBg, | ||
| 32 | }} | ||
| 33 | /> | ||
| 34 | <Stack.Screen | ||
| 35 | name="branches" | ||
| 36 | options={{ | ||
| 37 | animation: "slide_from_right", | ||
| 38 | headerShown: true, | ||
| 39 | headerTitle: "Branches & worktrees", | ||
| 40 | headerBackTitle: "", | ||
| 41 | headerShadowVisible: false, | ||
| 42 | headerStyle: headerBg, | ||
| 43 | }} | ||
| 44 | /> | ||
| 45 | <Stack.Screen | ||
| 46 | name="review" | ||
| 47 | options={{ | ||
| 48 | animation: "slide_from_right", | ||
| 49 | headerShown: true, | ||
| 50 | headerTitle: "Review changes", | ||
| 51 | headerBackTitle: "", | ||
| 52 | headerShadowVisible: false, | ||
| 53 | headerStyle: headerBg, | ||
| 54 | }} | ||
| 55 | /> | ||
| 56 | </Stack> | ||
| 57 | ); | ||
| 58 | } | ||
apps/mobile/src/app/threads/[environmentId]/[threadId]/git/branches.tsx deleted-5| ... | @@ -1,5 +0,0 @@ | ||
| 1 | import { GitBranchesSheet } from "../../../../../features/threads/git/GitBranchesSheet"; | ||
| 2 | |||
| 3 | export default function GitBranchesRoute() { | ||
| 4 | return <GitBranchesSheet />; | ||
| 5 | } | ||
apps/mobile/src/app/threads/[environmentId]/[threadId]/git/commit.tsx deleted-5| ... | @@ -1,5 +0,0 @@ | ||
| 1 | import { GitCommitSheet } from "../../../../../features/threads/git/GitCommitSheet"; | ||
| 2 | |||
| 3 | export default function GitCommitRoute() { | ||
| 4 | return <GitCommitSheet />; | ||
| 5 | } | ||
apps/mobile/src/app/threads/[environmentId]/[threadId]/git/index.tsx deleted-5| ... | @@ -1,5 +0,0 @@ | ||
| 1 | import { GitOverviewSheet } from "../../../../../features/threads/git/GitOverviewSheet"; | ||
| 2 | |||
| 3 | export default function GitRoute() { | ||
| 4 | return <GitOverviewSheet />; | ||
| 5 | } | ||
apps/mobile/src/app/threads/[environmentId]/[threadId]/git/review.tsx deleted-18| ... | @@ -1,18 +0,0 @@ | ||
| 1 | import { Redirect, useLocalSearchParams } from "expo-router"; | ||
| 2 | import type { EnvironmentId, ThreadId } from "@t3tools/contracts"; | ||
| 3 | |||
| 4 | export default function ReviewRoute() { | ||
| 5 | const { environmentId, threadId } = useLocalSearchParams<{ | ||
| 6 | environmentId: EnvironmentId; | ||
| 7 | threadId: ThreadId; | ||
| 8 | }>(); | ||
| 9 | |||
| 10 | return ( | ||
| 11 | <Redirect | ||
| 12 | href={{ | ||
| 13 | pathname: "/threads/[environmentId]/[threadId]/review", | ||
| 14 | params: { environmentId, threadId }, | ||
| 15 | }} | ||
| 16 | /> | ||
| 17 | ); | ||
| 18 | } | ||
apps/mobile/src/app/threads/[environmentId]/[threadId]/index.tsx deleted-5| ... | @@ -1,5 +0,0 @@ | ||
| 1 | import { ThreadRouteScreen } from "../../../../features/threads/ThreadRouteScreen"; | ||
| 2 | |||
| 3 | export default function ThreadRoute() { | ||
| 4 | return <ThreadRouteScreen />; | ||
| 5 | } | ||
apps/mobile/src/app/threads/[environmentId]/[threadId]/review-comment.tsx deleted-5| ... | @@ -1,5 +0,0 @@ | ||
| 1 | import { ReviewCommentComposerSheet } from "../../../../features/review/ReviewCommentComposerSheet"; | ||
| 2 | |||
| 3 | export default function ReviewCommentRoute() { | ||
| 4 | return <ReviewCommentComposerSheet />; | ||
| 5 | } | ||
apps/mobile/src/app/threads/[environmentId]/[threadId]/review.tsx deleted-10| ... | @@ -1,10 +0,0 @@ | ||
| 1 | import { ReviewHighlighterProvider } from "../../../../features/review/ReviewHighlighterProvider"; | ||
| 2 | import { ReviewSheet } from "../../../../features/review/ReviewSheet"; | ||
| 3 | |||
| 4 | export default function ReviewRoute() { | ||
| 5 | return ( | ||
| 6 | <ReviewHighlighterProvider> | ||
| 7 | <ReviewSheet /> | ||
| 8 | </ReviewHighlighterProvider> | ||
| 9 | ); | ||
| 10 | } | ||
apps/mobile/src/app/threads/[environmentId]/[threadId]/terminal.tsx deleted-5| ... | @@ -1,5 +0,0 @@ | ||
| 1 | import { ThreadTerminalRouteScreen } from "../../../../features/terminal/ThreadTerminalRouteScreen"; | ||
| 2 | |||
| 3 | export default function ThreadTerminalRoute() { | ||
| 4 | return <ThreadTerminalRouteScreen />; | ||
| 5 | } | ||
apps/mobile/src/components/AppText.tsx+2-3| ... | @@ -1,4 +1,3 @@ | ... | @@ -1,4 +1,3 @@ |
| 1 | import type { Ref } from "react"; | ||
| 2 | import { | 1 | import { |
| 3 | Text as RNText, | 2 | Text as RNText, |
| 4 | TextInput as RNTextInput, | 3 | TextInput as RNTextInput, |
| ... | @@ -21,7 +20,7 @@ export function AppText({ className, ...props }: AppTextProps) { | ... | @@ -21,7 +20,7 @@ export function AppText({ className, ...props }: AppTextProps) { |
| 21 | 20 | ||
| 22 | export type AppTextInputProps = RNTextInputProps & { | 21 | export type AppTextInputProps = RNTextInputProps & { |
| 23 | readonly className?: string; | 22 | readonly className?: string; |
| 24 | readonly ref?: Ref<RNTextInput>; | 23 | readonly ref?: React.Ref<RNTextInput>; |
| 25 | }; | 24 | }; |
| 26 | 25 | ||
| 27 | /** | 26 | /** |
| ... | @@ -40,7 +39,7 @@ export function AppTextInput({ | ... | @@ -40,7 +39,7 @@ export function AppTextInput({ |
| 40 | <RNTextInput | 39 | <RNTextInput |
| 41 | ref={ref} | 40 | ref={ref} |
| 42 | className={cn( | 41 | className={cn( |
| 43 | "min-h-[54px] rounded-2xl border border-input-border bg-input px-3.5 py-3 font-sans text-base text-foreground", | 42 | "min-h-13.5 rounded-2xl border border-input-border bg-input px-3.5 py-3 font-sans text-base text-foreground", |
| 44 | className, | 43 | className, |
| 45 | )} | 44 | )} |
| 46 | placeholderTextColor={placeholderTextColor ?? placeholderColor} | 45 | placeholderTextColor={placeholderTextColor ?? placeholderColor} |
apps/mobile/src/components/ControlPill.tsx+1-1| ... | @@ -75,7 +75,7 @@ export function ControlPill(props: { | ... | @@ -75,7 +75,7 @@ export function ControlPill(props: { |
| 75 | } | 75 | } |
| 76 | 76 | ||
| 77 | export function ControlPillMenu( | 77 | export function ControlPillMenu( |
| 78 | props: Omit<ComponentProps<typeof MenuView>, "children" | "style" | "themeVariant"> & { | 78 | props: Omit<ComponentProps<typeof MenuView>, "children" | "themeVariant"> & { |
| 79 | readonly children: ReactNode; | 79 | readonly children: ReactNode; |
| 80 | }, | 80 | }, |
| 81 | ) { | 81 | ) { |
apps/mobile/src/components/EmptyState.tsx+23-1| ... | @@ -7,11 +7,33 @@ export function EmptyState(props: { | ... | @@ -7,11 +7,33 @@ export function EmptyState(props: { |
| 7 | readonly detail: string; | 7 | readonly detail: string; |
| 8 | readonly actionLabel?: string; | 8 | readonly actionLabel?: string; |
| 9 | readonly onAction?: () => void; | 9 | readonly onAction?: () => void; |
| 10 | readonly variant?: "card" | "plain"; | ||
| 10 | }) { | 11 | }) { |
| 12 | if (props.variant === "plain") { | ||
| 13 | return ( | ||
| 14 | <View className="items-center px-8 py-8"> | ||
| 15 | <Text className="text-center text-xl font-t3-bold text-foreground">{props.title}</Text> | ||
| 16 | <Text className="mt-2 text-center font-sans text-base leading-normal text-foreground-muted"> | ||
| 17 | {props.detail} | ||
| 18 | </Text> | ||
| 19 | {props.actionLabel && props.onAction ? ( | ||
| 20 | <Pressable | ||
| 21 | className="mt-5 rounded-full bg-primary px-5 py-3 active:opacity-70" | ||
| 22 | onPress={props.onAction} | ||
| 23 | > | ||
| 24 | <Text className="text-sm font-t3-bold text-primary-foreground"> | ||
| 25 | {props.actionLabel} | ||
| 26 | </Text> | ||
| 27 | </Pressable> | ||
| 28 | ) : null} | ||
| 29 | </View> | ||
| 30 | ); | ||
| 31 | } | ||
| 32 | |||
| 11 | return ( | 33 | return ( |
| 12 | <View className="rounded-[22px] border border-border bg-card p-5"> | 34 | <View className="rounded-[22px] border border-border bg-card p-5"> |
| 13 | <Text className="font-t3-bold text-lg text-foreground">{props.title}</Text> | 35 | <Text className="font-t3-bold text-lg text-foreground">{props.title}</Text> |
| 14 | <Text className="mt-2 font-sans text-sm leading-[21px] text-foreground-muted"> | 36 | <Text className="mt-2 font-sans text-sm leading-relaxed text-foreground-muted"> |
| 15 | {props.detail} | 37 | {props.detail} |
| 16 | </Text> | 38 | </Text> |
| 17 | {props.actionLabel && props.onAction ? ( | 39 | {props.actionLabel && props.onAction ? ( |
apps/mobile/src/components/ErrorBanner.tsx+1-1| ... | @@ -4,7 +4,7 @@ import { AppText as Text } from "./AppText"; | ... | @@ -4,7 +4,7 @@ import { AppText as Text } from "./AppText"; |
| 4 | export function ErrorBanner(props: { readonly message: string }) { | 4 | export function ErrorBanner(props: { readonly message: string }) { |
| 5 | return ( | 5 | return ( |
| 6 | <View className="rounded-2xl border border-rose-300/70 bg-rose-100/80 px-3.5 py-3 dark:border-rose-400/28 dark:bg-rose-500/12"> | 6 | <View className="rounded-2xl border border-rose-300/70 bg-rose-100/80 px-3.5 py-3 dark:border-rose-400/28 dark:bg-rose-500/12"> |
| 7 | <Text className="font-t3-medium text-sm leading-[18px] text-rose-700 dark:text-rose-300"> | 7 | <Text className="font-t3-medium text-sm text-rose-700 dark:text-rose-300"> |
| 8 | {props.message} | 8 | {props.message} |
| 9 | </Text> | 9 | </Text> |
| 10 | </View> | 10 | </View> |
apps/mobile/src/features/agent-awareness/notificationNavigation.ts+4-4| ... | @@ -1,12 +1,12 @@ | ... | @@ -1,12 +1,12 @@ |
| 1 | import { useEffect, useRef } from "react"; | 1 | import { useEffect, useRef } from "react"; |
| 2 | import * as Notifications from "expo-notifications"; | 2 | import * as Notifications from "expo-notifications"; |
| 3 | import { useRouter } from "expo-router"; | 3 | import { useLinkTo } from "@react-navigation/native"; |
| 4 | 4 | ||
| 5 | import { routeAgentNotificationResponseOnce } from "./notificationPayload"; | 5 | import { routeAgentNotificationResponseOnce } from "./notificationPayload"; |
| 6 | import { consumeLastAgentNotificationResponse } from "./notificationResponseConsumer"; | 6 | import { consumeLastAgentNotificationResponse } from "./notificationResponseConsumer"; |
| 7 | 7 | ||
| 8 | export function useAgentNotificationNavigation(): void { | 8 | export function useAgentNotificationNavigation(): void { |
| 9 | const router = useRouter(); | 9 | const linkTo = useLinkTo(); |
| 10 | const handledResponseIds = useRef(new Set<string>()); | 10 | const handledResponseIds = useRef(new Set<string>()); |
| 11 | 11 | ||
| 12 | useEffect(() => { | 12 | useEffect(() => { |
| ... | @@ -14,7 +14,7 @@ export function useAgentNotificationNavigation(): void { | ... | @@ -14,7 +14,7 @@ export function useAgentNotificationNavigation(): void { |
| 14 | routeAgentNotificationResponseOnce({ | 14 | routeAgentNotificationResponseOnce({ |
| 15 | handledResponseIds: handledResponseIds.current, | 15 | handledResponseIds: handledResponseIds.current, |
| 16 | response, | 16 | response, |
| 17 | navigate: (deepLink) => router.push(deepLink as never), | 17 | navigate: linkTo, |
| 18 | }); | 18 | }); |
| 19 | }; | 19 | }; |
| 20 | 20 | ||
| ... | @@ -28,5 +28,5 @@ export function useAgentNotificationNavigation(): void { | ... | @@ -28,5 +28,5 @@ export function useAgentNotificationNavigation(): void { |
| 28 | return () => { | 28 | return () => { |
| 29 | subscription.remove(); | 29 | subscription.remove(); |
| 30 | }; | 30 | }; |
| 31 | }, [router]); | 31 | }, [linkTo]); |
| 32 | } | 32 | } |
apps/mobile/src/features/archive/ArchivedThreadsRouteScreen.tsx+1-1| ... | @@ -1,7 +1,7 @@ | ... | @@ -1,7 +1,7 @@ |
| 1 | import type { EnvironmentId } from "@t3tools/contracts"; | 1 | import type { EnvironmentId } from "@t3tools/contracts"; |
| 2 | import * as Arr from "effect/Array"; | 2 | import * as Arr from "effect/Array"; |
| 3 | import * as Order from "effect/Order"; | 3 | import * as Order from "effect/Order"; |
| 4 | import { useFocusEffect } from "expo-router"; | 4 | import { useFocusEffect } from "@react-navigation/native"; |
| 5 | import { useCallback, useMemo, useState } from "react"; | 5 | import { useCallback, useMemo, useState } from "react"; |
| 6 | 6 | ||
| 7 | import { useSavedRemoteConnections } from "../../state/use-remote-environment-registry"; | 7 | import { useSavedRemoteConnections } from "../../state/use-remote-environment-registry"; |
apps/mobile/src/features/archive/ArchivedThreadsScreen.tsx+361-245| ... | @@ -2,23 +2,22 @@ import type { | ... | @@ -2,23 +2,22 @@ import type { |
| 2 | EnvironmentProject, | 2 | EnvironmentProject, |
| 3 | EnvironmentThreadShell, | 3 | EnvironmentThreadShell, |
| 4 | } from "@t3tools/client-runtime/state/shell"; | 4 | } from "@t3tools/client-runtime/state/shell"; |
| 5 | import { LegendList } from "@legendapp/list/react-native"; | ||
| 5 | import type { EnvironmentId } from "@t3tools/contracts"; | 6 | import type { EnvironmentId } from "@t3tools/contracts"; |
| 6 | import type { MenuAction } from "@react-native-menu/menu"; | 7 | import type { MenuAction } from "@react-native-menu/menu"; |
| 7 | import * as Haptics from "expo-haptics"; | 8 | import { NativeHeaderToolbar, NativeStackScreenOptions } from "../../native/StackHeader"; |
| 8 | import { Stack } from "expo-router"; | ||
| 9 | import { SymbolView } from "expo-symbols"; | 9 | import { SymbolView } from "expo-symbols"; |
| 10 | import { useCallback, useRef } from "react"; | 10 | import { useCallback, useMemo, useRef, type ComponentProps } from "react"; |
| 11 | import { | 11 | import { |
| 12 | ActivityIndicator, | 12 | ActivityIndicator, |
| 13 | Platform, | ||
| 13 | Pressable, | 14 | Pressable, |
| 14 | RefreshControl, | 15 | RefreshControl, |
| 15 | ScrollView, | ||
| 16 | useWindowDimensions, | 16 | useWindowDimensions, |
| 17 | View, | 17 | View, |
| 18 | } from "react-native"; | 18 | } from "react-native"; |
| 19 | import ReanimatedSwipeable, { | 19 | import { Gesture, GestureDetector } from "react-native-gesture-handler"; |
| 20 | type SwipeableMethods, | 20 | import type { SwipeableMethods } from "react-native-gesture-handler/ReanimatedSwipeable"; |
| 21 | } from "react-native-gesture-handler/ReanimatedSwipeable"; | ||
| 22 | 21 | ||
| 23 | import { AppText as Text } from "../../components/AppText"; | 22 | import { AppText as Text } from "../../components/AppText"; |
| 24 | import { ControlPillMenu } from "../../components/ControlPill"; | 23 | import { ControlPillMenu } from "../../components/ControlPill"; |
| ... | @@ -26,11 +25,8 @@ import { EmptyState } from "../../components/EmptyState"; | ... | @@ -26,11 +25,8 @@ import { EmptyState } from "../../components/EmptyState"; |
| 26 | import { ProjectFavicon } from "../../components/ProjectFavicon"; | 25 | import { ProjectFavicon } from "../../components/ProjectFavicon"; |
| 27 | import { relativeTime } from "../../lib/time"; | 26 | import { relativeTime } from "../../lib/time"; |
| 28 | import { useThemeColor } from "../../lib/useThemeColor"; | 27 | import { useThemeColor } from "../../lib/useThemeColor"; |
| 29 | import { | 28 | import { ThreadSwipeable } from "../home/thread-swipe-actions"; |
| 30 | THREAD_SWIPE_ACTIONS_WIDTH, | 29 | import { createNativeMailSearchToolbarItem } from "../layout/native-mail-search-toolbar"; |
| 31 | THREAD_SWIPE_SPRING, | ||
| 32 | ThreadSwipeActions, | ||
| 33 | } from "../home/thread-swipe-actions"; | ||
| 34 | import type { ArchivedThreadGroup, ArchivedThreadSortOrder } from "./archivedThreadList"; | 30 | import type { ArchivedThreadGroup, ArchivedThreadSortOrder } from "./archivedThreadList"; |
| 35 | 31 | ||
| 36 | export interface ArchivedThreadsHeaderEnvironment { | 32 | export interface ArchivedThreadsHeaderEnvironment { |
| ... | @@ -52,84 +48,185 @@ const THREAD_ACTIONS: MenuAction[] = [ | ... | @@ -52,84 +48,185 @@ const THREAD_ACTIONS: MenuAction[] = [ |
| 52 | }, | 48 | }, |
| 53 | ]; | 49 | ]; |
| 54 | 50 | ||
| 51 | type ArchivedThreadListItem = | ||
| 52 | | { | ||
| 53 | readonly kind: "project"; | ||
| 54 | readonly key: string; | ||
| 55 | readonly environmentLabel: string | null; | ||
| 56 | readonly project: EnvironmentProject; | ||
| 57 | } | ||
| 58 | | { | ||
| 59 | readonly kind: "thread"; | ||
| 60 | readonly key: string; | ||
| 61 | readonly environmentLabel: string | null; | ||
| 62 | readonly isFirst: boolean; | ||
| 63 | readonly isLast: boolean; | ||
| 64 | readonly thread: EnvironmentThreadShell; | ||
| 65 | }; | ||
| 66 | |||
| 55 | function ArchivedThreadsHeader(props: { | 67 | function ArchivedThreadsHeader(props: { |
| 56 | readonly environments: ReadonlyArray<ArchivedThreadsHeaderEnvironment>; | 68 | readonly environments: ReadonlyArray<ArchivedThreadsHeaderEnvironment>; |
| 57 | readonly selectedEnvironmentId: EnvironmentId | null; | 69 | readonly selectedEnvironmentId: EnvironmentId | null; |
| 58 | readonly sortOrder: ArchivedThreadSortOrder; | 70 | readonly sortOrder: ArchivedThreadSortOrder; |
| 59 | readonly onEnvironmentChange: (environmentId: EnvironmentId | null) => void; | 71 | readonly onEnvironmentChange: (environmentId: EnvironmentId | null) => void; |
| 72 | readonly onRefresh: () => void; | ||
| 60 | readonly onSearchQueryChange: (query: string) => void; | 73 | readonly onSearchQueryChange: (query: string) => void; |
| 61 | readonly onSortOrderChange: (sortOrder: ArchivedThreadSortOrder) => void; | 74 | readonly onSortOrderChange: (sortOrder: ArchivedThreadSortOrder) => void; |
| 62 | }) { | 75 | }) { |
| 76 | const { width } = useWindowDimensions(); | ||
| 63 | const hasCustomFilter = props.selectedEnvironmentId !== null || props.sortOrder !== "newest"; | 77 | const hasCustomFilter = props.selectedEnvironmentId !== null || props.sortOrder !== "newest"; |
| 78 | const usesNativeChrome = Platform.OS === "ios"; | ||
| 79 | const usesCompactMailToolbar = Platform.OS === "ios" && width < 700; | ||
| 80 | const archiveFilterMenu = { | ||
| 81 | title: "Archived thread options", | ||
| 82 | items: [ | ||
| 83 | { | ||
| 84 | type: "submenu" as const, | ||
| 85 | title: "Environment", | ||
| 86 | items: [ | ||
| 87 | { | ||
| 88 | type: "action" as const, | ||
| 89 | title: "All environments", | ||
| 90 | state: props.selectedEnvironmentId === null ? ("on" as const) : ("off" as const), | ||
| 91 | onPress: () => props.onEnvironmentChange(null), | ||
| 92 | }, | ||
| 93 | ...props.environments.map((environment) => ({ | ||
| 94 | type: "action" as const, | ||
| 95 | title: environment.label, | ||
| 96 | state: | ||
| 97 | props.selectedEnvironmentId === environment.environmentId | ||
| 98 | ? ("on" as const) | ||
| 99 | : ("off" as const), | ||
| 100 | onPress: () => props.onEnvironmentChange(environment.environmentId), | ||
| 101 | })), | ||
| 102 | ], | ||
| 103 | }, | ||
| 104 | { | ||
| 105 | type: "submenu" as const, | ||
| 106 | title: "Sort by archived date", | ||
| 107 | items: [ | ||
| 108 | { | ||
| 109 | type: "action" as const, | ||
| 110 | title: "Newest first", | ||
| 111 | state: props.sortOrder === "newest" ? ("on" as const) : ("off" as const), | ||
| 112 | onPress: () => props.onSortOrderChange("newest"), | ||
| 113 | }, | ||
| 114 | { | ||
| 115 | type: "action" as const, | ||
| 116 | title: "Oldest first", | ||
| 117 | state: props.sortOrder === "oldest" ? ("on" as const) : ("off" as const), | ||
| 118 | onPress: () => props.onSortOrderChange("oldest"), | ||
| 119 | }, | ||
| 120 | ], | ||
| 121 | }, | ||
| 122 | ], | ||
| 123 | }; | ||
| 64 | 124 | ||
| 65 | return ( | 125 | return ( |
| 66 | <> | 126 | <> |
| 67 | <Stack.Screen | 127 | {/* Static header config (glass preset + title) lives in Stack.tsx; only |
| 128 | dynamic toolbar/search wiring is set here. */} | ||
| 129 | <NativeStackScreenOptions | ||
| 68 | options={{ | 130 | options={{ |
| 69 | title: "Archived Threads", | 131 | unstable_headerToolbarItems: usesCompactMailToolbar |
| 70 | headerSearchBarOptions: { | 132 | ? () => [ |
| 71 | autoCapitalize: "none", | 133 | createNativeMailSearchToolbarItem({ |
| 72 | hideNavigationBar: false, | 134 | composeButtonId: "archived-refresh", |
| 73 | obscureBackground: false, | 135 | composeSystemImageName: "arrow.clockwise", |
| 74 | placeholder: "Search archived threads", | 136 | filterMenu: archiveFilterMenu, |
| 75 | placement: "stacked", | 137 | filterButtonId: "archived-filter", |
| 76 | onChangeText: (event) => { | 138 | filterSystemImageName: hasCustomFilter |
| 77 | props.onSearchQueryChange(event.nativeEvent.text); | 139 | ? "line.3.horizontal.decrease.circle.fill" |
| 78 | }, | 140 | : "line.3.horizontal.decrease", |
| 79 | onCancelButtonPress: () => { | 141 | onComposePress: props.onRefresh, |
| 80 | props.onSearchQueryChange(""); | 142 | onSearchTextChange: props.onSearchQueryChange, |
| 81 | }, | 143 | placeholder: "Search", |
| 82 | }, | 144 | searchTextChangeId: "archived-search-text", |
| 145 | }), | ||
| 146 | ] | ||
| 147 | : undefined, | ||
| 148 | headerSearchBarOptions: usesCompactMailToolbar | ||
| 149 | ? undefined | ||
| 150 | : { | ||
| 151 | ...(usesNativeChrome | ||
| 152 | ? { | ||
| 153 | allowToolbarIntegration: true, | ||
| 154 | placement: "integratedButton" as const, | ||
| 155 | } | ||
| 156 | : { | ||
| 157 | placement: "stacked" as const, | ||
| 158 | }), | ||
| 159 | autoCapitalize: "none", | ||
| 160 | hideNavigationBar: false, | ||
| 161 | obscureBackground: false, | ||
| 162 | placeholder: "Search archived threads", | ||
| 163 | onChangeText: (event) => { | ||
| 164 | props.onSearchQueryChange(event.nativeEvent.text); | ||
| 165 | }, | ||
| 166 | onCancelButtonPress: () => { | ||
| 167 | props.onSearchQueryChange(""); | ||
| 168 | }, | ||
| 169 | }, | ||
| 83 | }} | 170 | }} |
| 84 | /> | 171 | /> |
| 85 | 172 | ||
| 86 | <Stack.Toolbar placement="right"> | 173 | {usesCompactMailToolbar ? null : ( |
| 87 | <Stack.Toolbar.Menu | 174 | <NativeHeaderToolbar placement="right"> |
| 88 | accessibilityLabel="Filter and sort archived threads" | 175 | {usesNativeChrome ? ( |
| 89 | icon={ | 176 | <NativeHeaderToolbar.Button |
| 90 | hasCustomFilter | 177 | accessibilityLabel="Refresh archived threads" |
| 91 | ? "line.3.horizontal.decrease.circle.fill" | 178 | icon="arrow.clockwise" |
| 92 | : "line.3.horizontal.decrease.circle" | 179 | onPress={props.onRefresh} |
| 93 | } | 180 | separateBackground |
| 94 | separateBackground | 181 | /> |
| 95 | title="Archived thread options" | 182 | ) : null} |
| 96 | > | 183 | <NativeHeaderToolbar.Menu |
| 97 | <Stack.Toolbar.Menu title="Environment"> | 184 | accessibilityLabel="Filter and sort archived threads" |
| 98 | <Stack.Toolbar.Label>Environment</Stack.Toolbar.Label> | 185 | icon={ |
| 99 | <Stack.Toolbar.MenuAction | 186 | hasCustomFilter |
| 100 | isOn={props.selectedEnvironmentId === null} | 187 | ? "line.3.horizontal.decrease.circle.fill" |
| 101 | onPress={() => props.onEnvironmentChange(null)} | 188 | : "line.3.horizontal.decrease.circle" |
| 102 | > | 189 | } |
| 103 | <Stack.Toolbar.Label>All environments</Stack.Toolbar.Label> | 190 | separateBackground |
| 104 | </Stack.Toolbar.MenuAction> | 191 | title="Archived thread options" |
| 105 | {props.environments.map((environment) => ( | 192 | > |
| 106 | <Stack.Toolbar.MenuAction | 193 | <NativeHeaderToolbar.Menu title="Environment"> |
| 107 | key={environment.environmentId} | 194 | <NativeHeaderToolbar.Label>Environment</NativeHeaderToolbar.Label> |
| 108 | isOn={props.selectedEnvironmentId === environment.environmentId} | 195 | <NativeHeaderToolbar.MenuAction |
| 109 | onPress={() => props.onEnvironmentChange(environment.environmentId)} | 196 | isOn={props.selectedEnvironmentId === null} |
| 197 | onPress={() => props.onEnvironmentChange(null)} | ||
| 110 | > | 198 | > |
| 111 | <Stack.Toolbar.Label>{environment.label}</Stack.Toolbar.Label> | 199 | <NativeHeaderToolbar.Label>All environments</NativeHeaderToolbar.Label> |
| 112 | </Stack.Toolbar.MenuAction> | 200 | </NativeHeaderToolbar.MenuAction> |
| 113 | ))} | 201 | {props.environments.map((environment) => ( |
| 114 | </Stack.Toolbar.Menu> | 202 | <NativeHeaderToolbar.MenuAction |
| 203 | key={environment.environmentId} | ||
| 204 | isOn={props.selectedEnvironmentId === environment.environmentId} | ||
| 205 | onPress={() => props.onEnvironmentChange(environment.environmentId)} | ||
| 206 | > | ||
| 207 | <NativeHeaderToolbar.Label>{environment.label}</NativeHeaderToolbar.Label> | ||
| 208 | </NativeHeaderToolbar.MenuAction> | ||
| 209 | ))} | ||
| 210 | </NativeHeaderToolbar.Menu> | ||
| 115 | 211 | ||
| 116 | <Stack.Toolbar.Menu title="Sort by archived date"> | 212 | <NativeHeaderToolbar.Menu title="Sort by archived date"> |
| 117 | <Stack.Toolbar.Label>Sort by archived date</Stack.Toolbar.Label> | 213 | <NativeHeaderToolbar.Label>Sort by archived date</NativeHeaderToolbar.Label> |
| 118 | <Stack.Toolbar.MenuAction | 214 | <NativeHeaderToolbar.MenuAction |
| 119 | isOn={props.sortOrder === "newest"} | 215 | isOn={props.sortOrder === "newest"} |
| 120 | onPress={() => props.onSortOrderChange("newest")} | 216 | onPress={() => props.onSortOrderChange("newest")} |
| 121 | > | 217 | > |
| 122 | <Stack.Toolbar.Label>Newest first</Stack.Toolbar.Label> | 218 | <NativeHeaderToolbar.Label>Newest first</NativeHeaderToolbar.Label> |
| 123 | </Stack.Toolbar.MenuAction> | 219 | </NativeHeaderToolbar.MenuAction> |
| 124 | <Stack.Toolbar.MenuAction | 220 | <NativeHeaderToolbar.MenuAction |
| 125 | isOn={props.sortOrder === "oldest"} | 221 | isOn={props.sortOrder === "oldest"} |
| 126 | onPress={() => props.onSortOrderChange("oldest")} | 222 | onPress={() => props.onSortOrderChange("oldest")} |
| 127 | > | 223 | > |
| 128 | <Stack.Toolbar.Label>Oldest first</Stack.Toolbar.Label> | 224 | <NativeHeaderToolbar.Label>Oldest first</NativeHeaderToolbar.Label> |
| 129 | </Stack.Toolbar.MenuAction> | 225 | </NativeHeaderToolbar.MenuAction> |
| 130 | </Stack.Toolbar.Menu> | 226 | </NativeHeaderToolbar.Menu> |
| 131 | </Stack.Toolbar.Menu> | 227 | </NativeHeaderToolbar.Menu> |
| 132 | </Stack.Toolbar> | 228 | </NativeHeaderToolbar> |
| 229 | )} | ||
| 133 | </> | 230 | </> |
| 134 | ); | 231 | ); |
| 135 | } | 232 | } |
| ... | @@ -164,30 +261,25 @@ function ProjectGroupLabel(props: { | ... | @@ -164,30 +261,25 @@ function ProjectGroupLabel(props: { |
| 164 | 261 | ||
| 165 | function ArchivedThreadRow(props: { | 262 | function ArchivedThreadRow(props: { |
| 166 | readonly environmentLabel: string | null; | 263 | readonly environmentLabel: string | null; |
| 264 | readonly isFirst: boolean; | ||
| 167 | readonly isLast: boolean; | 265 | readonly isLast: boolean; |
| 168 | readonly onDelete: () => void; | 266 | readonly onDelete: () => void; |
| 169 | readonly onSwipeableClose: (methods: SwipeableMethods) => void; | 267 | readonly onSwipeableClose: (methods: SwipeableMethods) => void; |
| 170 | readonly onSwipeableWillOpen: (methods: SwipeableMethods) => void; | 268 | readonly onSwipeableWillOpen: (methods: SwipeableMethods) => void; |
| 269 | readonly simultaneousSwipeGesture?: ComponentProps< | ||
| 270 | typeof ThreadSwipeable | ||
| 271 | >["simultaneousWithExternalGesture"]; | ||
| 171 | readonly onUnarchive: () => void; | 272 | readonly onUnarchive: () => void; |
| 172 | readonly thread: EnvironmentThreadShell; | 273 | readonly thread: EnvironmentThreadShell; |
| 173 | }) { | 274 | }) { |
| 174 | const swipeableRef = useRef<SwipeableMethods | null>(null); | ||
| 175 | const fullSwipeArmedRef = useRef(false); | ||
| 176 | const { width: windowWidth } = useWindowDimensions(); | 275 | const { width: windowWidth } = useWindowDimensions(); |
| 177 | const cardColor = useThemeColor("--color-card"); | 276 | const cardColor = useThemeColor("--color-card"); |
| 178 | const iconColor = useThemeColor("--color-icon-subtle"); | 277 | const iconColor = useThemeColor("--color-icon-subtle"); |
| 179 | const separatorColor = useThemeColor("--color-separator"); | 278 | const separatorColor = useThemeColor("--color-separator"); |
| 180 | const fullSwipeThreshold = Math.max(THREAD_SWIPE_ACTIONS_WIDTH + 44, (windowWidth - 32) * 0.58); | ||
| 181 | const timestamp = relativeTime(props.thread.archivedAt ?? props.thread.updatedAt); | 279 | const timestamp = relativeTime(props.thread.archivedAt ?? props.thread.updatedAt); |
| 182 | const subtitle = [props.environmentLabel, props.thread.branch].filter((part): part is string => | 280 | const subtitle = [props.environmentLabel, props.thread.branch].filter((part): part is string => |
| 183 | Boolean(part), | 281 | Boolean(part), |
| 184 | ); | 282 | ); |
| 185 | const handleFullSwipeArmedChange = useCallback((armed: boolean) => { | ||
| 186 | if (armed && !fullSwipeArmedRef.current && process.env.EXPO_OS === "ios") { | ||
| 187 | void Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Medium); | ||
| 188 | } | ||
| 189 | fullSwipeArmedRef.current = armed; | ||
| 190 | }, []); | ||
| 191 | const handleMenuAction = useCallback( | 283 | const handleMenuAction = useCallback( |
| 192 | (event: { nativeEvent: { event: string } }) => { | 284 | (event: { nativeEvent: { event: string } }) => { |
| 193 | if (event.nativeEvent.event === "unarchive") { | 285 | if (event.nativeEvent.event === "unarchive") { |
| ... | @@ -200,114 +292,84 @@ function ArchivedThreadRow(props: { | ... | @@ -200,114 +292,84 @@ function ArchivedThreadRow(props: { |
| 200 | ); | 292 | ); |
| 201 | 293 | ||
| 202 | return ( | 294 | return ( |
| 203 | <ReanimatedSwipeable | 295 | <ThreadSwipeable |
| 204 | ref={swipeableRef} | 296 | backgroundColor={cardColor} |
| 205 | animationOptions={THREAD_SWIPE_SPRING} | 297 | fullSwipeWidth={windowWidth - 32} |
| 206 | childrenContainerStyle={{ backgroundColor: cardColor }} | 298 | onDelete={props.onDelete} |
| 207 | containerStyle={{ backgroundColor: cardColor }} | 299 | onSwipeableClose={props.onSwipeableClose} |
| 208 | dragOffsetFromRightEdge={8} | 300 | onSwipeableWillOpen={props.onSwipeableWillOpen} |
| 209 | enableTrackpadTwoFingerGesture | 301 | primaryAction={{ |
| 210 | friction={1} | 302 | accessibilityLabel: `Unarchive ${props.thread.title}`, |
| 211 | onSwipeableClose={() => { | 303 | icon: "arrow.uturn.backward", |
| 212 | fullSwipeArmedRef.current = false; | 304 | label: "Unarchive", |
| 213 | if (swipeableRef.current) { | 305 | onPress: props.onUnarchive, |
| 214 | props.onSwipeableClose(swipeableRef.current); | ||
| 215 | } | ||
| 216 | }} | ||
| 217 | onSwipeableOpenStartDrag={() => { | ||
| 218 | if (swipeableRef.current) { | ||
| 219 | props.onSwipeableWillOpen(swipeableRef.current); | ||
| 220 | } | ||
| 221 | }} | 306 | }} |
| 222 | onSwipeableWillOpen={() => { | 307 | simultaneousWithExternalGesture={props.simultaneousSwipeGesture} |
| 223 | const methods = swipeableRef.current; | 308 | threadTitle={props.thread.title} |
| 224 | if (!methods) return; | ||
| 225 | |||
| 226 | props.onSwipeableWillOpen(methods); | ||
| 227 | if (fullSwipeArmedRef.current) { | ||
| 228 | fullSwipeArmedRef.current = false; | ||
| 229 | methods.close(); | ||
| 230 | props.onDelete(); | ||
| 231 | } | ||
| 232 | }} | ||
| 233 | overshootFriction={1} | ||
| 234 | overshootRight | ||
| 235 | renderRightActions={(_progress, translation, methods) => ( | ||
| 236 | <ThreadSwipeActions | ||
| 237 | backgroundColor={cardColor} | ||
| 238 | fullSwipeThreshold={fullSwipeThreshold} | ||
| 239 | onDelete={props.onDelete} | ||
| 240 | onFullSwipeArmedChange={handleFullSwipeArmedChange} | ||
| 241 | primaryAction={{ | ||
| 242 | accessibilityLabel: `Unarchive ${props.thread.title}`, | ||
| 243 | icon: "arrow.uturn.backward", | ||
| 244 | label: "Unarchive", | ||
| 245 | onPress: props.onUnarchive, | ||
| 246 | }} | ||
| 247 | swipeableMethods={methods} | ||
| 248 | threadTitle={props.thread.title} | ||
| 249 | translation={translation} | ||
| 250 | /> | ||
| 251 | )} | ||
| 252 | rightThreshold={THREAD_SWIPE_ACTIONS_WIDTH * 0.42} | ||
| 253 | > | 309 | > |
| 254 | <View | 310 | {() => ( |
| 255 | className="flex-row items-center gap-3 bg-card px-4 py-3" | 311 | <View |
| 256 | style={{ | 312 | className="flex-row items-center gap-3 bg-card px-4 py-3" |
| 257 | borderBottomColor: separatorColor, | 313 | style={{ |
| 258 | borderBottomWidth: props.isLast ? 0 : 1, | 314 | borderBottomColor: separatorColor, |
| 259 | }} | 315 | borderBottomWidth: props.isLast ? 0 : 1, |
| 260 | > | 316 | borderTopLeftRadius: props.isFirst ? 20 : 0, |
| 261 | <View className="h-[34px] w-[34px] items-center justify-center rounded-[11px] bg-subtle"> | 317 | borderTopRightRadius: props.isFirst ? 20 : 0, |
| 262 | <SymbolView name="archivebox.fill" size={15} tintColor={iconColor} type="monochrome" /> | 318 | borderBottomLeftRadius: props.isLast ? 20 : 0, |
| 263 | </View> | 319 | borderBottomRightRadius: props.isLast ? 20 : 0, |
| 264 | 320 | }} | |
| 265 | <View className="min-w-0 flex-1 gap-1"> | 321 | > |
| 266 | <View className="flex-row items-center gap-2"> | 322 | <View className="h-[34px] w-[34px] items-center justify-center rounded-[11px] bg-subtle"> |
| 267 | <Text | 323 | <SymbolView name="archivebox.fill" size={15} tintColor={iconColor} type="monochrome" /> |
| 268 | className="min-w-0 flex-1 text-base font-t3-bold leading-[20px] text-foreground" | ||
| 269 | numberOfLines={1} | ||
| 270 | > | ||
| 271 | {props.thread.title} | ||
| 272 | </Text> | ||
| 273 | <Text | ||
| 274 | className="text-xs text-foreground-tertiary" | ||
| 275 | style={{ fontVariant: ["tabular-nums"] }} | ||
| 276 | > | ||
| 277 | {timestamp} | ||
| 278 | </Text> | ||
| 279 | </View> | 324 | </View> |
| 280 | {subtitle.length > 0 ? ( | 325 | |
| 281 | <View className="flex-row items-center gap-1.5"> | 326 | <View className="min-w-0 flex-1 gap-1"> |
| 282 | <SymbolView | 327 | <View className="flex-row items-center gap-2"> |
| 283 | name="arrow.triangle.branch" | ||
| 284 | size={10} | ||
| 285 | tintColor={iconColor} | ||
| 286 | type="monochrome" | ||
| 287 | /> | ||
| 288 | <Text | 328 | <Text |
| 289 | className="min-w-0 flex-1 text-2xs text-foreground-tertiary" | 329 | className="min-w-0 flex-1 text-base font-t3-bold leading-snug text-foreground" |
| 290 | numberOfLines={1} | 330 | numberOfLines={1} |
| 291 | style={{ fontFamily: "monospace" }} | ||
| 292 | > | 331 | > |
| 293 | {subtitle.join(" · ")} | 332 | {props.thread.title} |
| 333 | </Text> | ||
| 334 | <Text | ||
| 335 | className="text-xs text-foreground-tertiary" | ||
| 336 | style={{ fontVariant: ["tabular-nums"] }} | ||
| 337 | > | ||
| 338 | {timestamp} | ||
| 294 | </Text> | 339 | </Text> |
| 295 | </View> | 340 | </View> |
| 296 | ) : null} | 341 | {subtitle.length > 0 ? ( |
| 297 | </View> | 342 | <View className="flex-row items-center gap-1.5"> |
| 343 | <SymbolView | ||
| 344 | name="arrow.triangle.branch" | ||
| 345 | size={10} | ||
| 346 | tintColor={iconColor} | ||
| 347 | type="monochrome" | ||
| 348 | /> | ||
| 349 | <Text | ||
| 350 | className="min-w-0 flex-1 text-2xs text-foreground-tertiary" | ||
| 351 | numberOfLines={1} | ||
| 352 | style={{ fontFamily: "monospace" }} | ||
| 353 | > | ||
| 354 | {subtitle.join(" · ")} | ||
| 355 | </Text> | ||
| 356 | </View> | ||
| 357 | ) : null} | ||
| 358 | </View> | ||
| 298 | 359 | ||
| 299 | <ControlPillMenu actions={THREAD_ACTIONS} onPressAction={handleMenuAction}> | 360 | <ControlPillMenu actions={THREAD_ACTIONS} onPressAction={handleMenuAction}> |
| 300 | <Pressable | 361 | <Pressable |
| 301 | accessibilityLabel={`Actions for ${props.thread.title}`} | 362 | accessibilityLabel={`Actions for ${props.thread.title}`} |
| 302 | accessibilityRole="button" | 363 | accessibilityRole="button" |
| 303 | className="h-8 w-8 items-center justify-center rounded-full active:bg-subtle" | 364 | className="h-8 w-8 items-center justify-center rounded-full active:bg-subtle" |
| 304 | hitSlop={6} | 365 | hitSlop={6} |
| 305 | > | 366 | > |
| 306 | <SymbolView name="ellipsis" size={16} tintColor={iconColor} type="monochrome" /> | 367 | <SymbolView name="ellipsis" size={16} tintColor={iconColor} type="monochrome" /> |
| 307 | </Pressable> | 368 | </Pressable> |
| 308 | </ControlPillMenu> | 369 | </ControlPillMenu> |
| 309 | </View> | 370 | </View> |
| 310 | </ReanimatedSwipeable> | 371 | )} |
| 372 | </ThreadSwipeable> | ||
| 311 | ); | 373 | ); |
| 312 | } | 374 | } |
| 313 | 375 | ||
| ... | @@ -317,7 +379,7 @@ function ArchiveError(props: { readonly message: string; readonly onRetry: () => | ... | @@ -317,7 +379,7 @@ function ArchiveError(props: { readonly message: string; readonly onRetry: () => |
| 317 | <Text className="text-base font-t3-bold text-danger-foreground"> | 379 | <Text className="text-base font-t3-bold text-danger-foreground"> |
| 318 | Could not load every archive | 380 | Could not load every archive |
| 319 | </Text> | 381 | </Text> |
| 320 | <Text className="mt-1 text-sm leading-[18px] text-foreground-muted">{props.message}</Text> | 382 | <Text className="mt-1 text-sm text-foreground-muted">{props.message}</Text> |
| 321 | <Pressable className="mt-3 self-start active:opacity-60" onPress={props.onRetry}> | 383 | <Pressable className="mt-3 self-start active:opacity-60" onPress={props.onRetry}> |
| 322 | <Text className="text-sm font-t3-bold text-danger-foreground">Try again</Text> | 384 | <Text className="text-sm font-t3-bold text-danger-foreground">Try again</Text> |
| 323 | </Pressable> | 385 | </Pressable> |
| ... | @@ -340,8 +402,41 @@ export function ArchivedThreadsScreen(props: { | ... | @@ -340,8 +402,41 @@ export function ArchivedThreadsScreen(props: { |
| 340 | readonly onSortOrderChange: (sortOrder: ArchivedThreadSortOrder) => void; | 402 | readonly onSortOrderChange: (sortOrder: ArchivedThreadSortOrder) => void; |
| 341 | readonly onUnarchiveThread: (thread: EnvironmentThreadShell) => void; | 403 | readonly onUnarchiveThread: (thread: EnvironmentThreadShell) => void; |
| 342 | }) { | 404 | }) { |
| 405 | const { onDeleteThread, onUnarchiveThread } = props; | ||
| 343 | const openSwipeableRef = useRef<SwipeableMethods | null>(null); | 406 | const openSwipeableRef = useRef<SwipeableMethods | null>(null); |
| 407 | const archiveScrollGesture = useMemo(() => Gesture.Native(), []); | ||
| 344 | const refreshTint = useThemeColor("--color-icon"); | 408 | const refreshTint = useThemeColor("--color-icon"); |
| 409 | const environmentLabelsById = useMemo( | ||
| 410 | () => | ||
| 411 | new Map( | ||
| 412 | props.environments.map((environment) => [environment.environmentId, environment.label]), | ||
| 413 | ), | ||
| 414 | [props.environments], | ||
| 415 | ); | ||
| 416 | const listItems = useMemo<ReadonlyArray<ArchivedThreadListItem>>(() => { | ||
| 417 | const items: ArchivedThreadListItem[] = []; | ||
| 418 | for (const group of props.groups) { | ||
| 419 | const environmentLabel = environmentLabelsById.get(group.project.environmentId) ?? null; | ||
| 420 | items.push({ | ||
| 421 | kind: "project", | ||
| 422 | key: `${group.key}:project`, | ||
| 423 | environmentLabel, | ||
| 424 | project: group.project, | ||
| 425 | }); | ||
| 426 | |||
| 427 | group.threads.forEach((thread, index) => { | ||
| 428 | items.push({ | ||
| 429 | kind: "thread", | ||
| 430 | key: `${thread.environmentId}:${thread.id}`, | ||
| 431 | environmentLabel, | ||
| 432 | isFirst: index === 0, | ||
| 433 | isLast: index === group.threads.length - 1, | ||
| 434 | thread, | ||
| 435 | }); | ||
| 436 | }); | ||
| 437 | } | ||
| 438 | return items; | ||
| 439 | }, [environmentLabelsById, props.groups]); | ||
| 345 | const handleSwipeableWillOpen = useCallback((methods: SwipeableMethods) => { | 440 | const handleSwipeableWillOpen = useCallback((methods: SwipeableMethods) => { |
| 346 | if (openSwipeableRef.current && openSwipeableRef.current !== methods) { | 441 | if (openSwipeableRef.current && openSwipeableRef.current !== methods) { |
| 347 | openSwipeableRef.current.close(); | 442 | openSwipeableRef.current.close(); |
| ... | @@ -355,82 +450,103 @@ export function ArchivedThreadsScreen(props: { | ... | @@ -355,82 +450,103 @@ export function ArchivedThreadsScreen(props: { |
| 355 | }, []); | 450 | }, []); |
| 356 | const isInitialLoad = props.isLoading && props.groups.length === 0 && props.error === null; | 451 | const isInitialLoad = props.isLoading && props.groups.length === 0 && props.error === null; |
| 357 | const isFiltered = props.searchQuery.trim().length > 0 || props.selectedEnvironmentId !== null; | 452 | const isFiltered = props.searchQuery.trim().length > 0 || props.selectedEnvironmentId !== null; |
| 453 | const renderListItem = useCallback( | ||
| 454 | ({ item }: { item: ArchivedThreadListItem }) => { | ||
| 455 | if (item.kind === "project") { | ||
| 456 | return ( | ||
| 457 | <View className="pt-4"> | ||
| 458 | <ProjectGroupLabel environmentLabel={item.environmentLabel} project={item.project} /> | ||
| 459 | </View> | ||
| 460 | ); | ||
| 461 | } | ||
| 462 | |||
| 463 | return ( | ||
| 464 | <ArchivedThreadRow | ||
| 465 | environmentLabel={item.environmentLabel} | ||
| 466 | isFirst={item.isFirst} | ||
| 467 | isLast={item.isLast} | ||
| 468 | onDelete={() => onDeleteThread(item.thread)} | ||
| 469 | onSwipeableClose={handleSwipeableClose} | ||
| 470 | onSwipeableWillOpen={handleSwipeableWillOpen} | ||
| 471 | onUnarchive={() => onUnarchiveThread(item.thread)} | ||
| 472 | simultaneousSwipeGesture={archiveScrollGesture} | ||
| 473 | thread={item.thread} | ||
| 474 | /> | ||
| 475 | ); | ||
| 476 | }, | ||
| 477 | [ | ||
| 478 | archiveScrollGesture, | ||
| 479 | handleSwipeableClose, | ||
| 480 | handleSwipeableWillOpen, | ||
| 481 | onDeleteThread, | ||
| 482 | onUnarchiveThread, | ||
| 483 | ], | ||
| 484 | ); | ||
| 485 | const listEmptyComponent = useMemo(() => { | ||
| 486 | if (isInitialLoad) { | ||
| 487 | return ( | ||
| 488 | <View className="items-center py-16"> | ||
| 489 | <ActivityIndicator color={refreshTint} /> | ||
| 490 | <Text className="mt-3 text-sm text-foreground-muted">Loading archive...</Text> | ||
| 491 | </View> | ||
| 492 | ); | ||
| 493 | } | ||
| 494 | |||
| 495 | return ( | ||
| 496 | <EmptyState | ||
| 497 | detail={ | ||
| 498 | isFiltered | ||
| 499 | ? "Try another search or environment." | ||
| 500 | : "Threads you archive will appear here." | ||
| 501 | } | ||
| 502 | title={isFiltered ? "No matching threads" : "No archived threads"} | ||
| 503 | /> | ||
| 504 | ); | ||
| 505 | }, [isFiltered, isInitialLoad, refreshTint]); | ||
| 358 | 506 | ||
| 359 | return ( | 507 | return ( |
| 360 | <View className="flex-1 bg-sheet"> | 508 | <View className="flex-1 bg-sheet"> |
| 361 | <ArchivedThreadsHeader | 509 | <ArchivedThreadsHeader |
| 362 | environments={props.environments} | 510 | environments={props.environments} |
| 363 | onEnvironmentChange={props.onEnvironmentChange} | 511 | onEnvironmentChange={props.onEnvironmentChange} |
| 512 | onRefresh={props.onRefresh} | ||
| 364 | onSearchQueryChange={props.onSearchQueryChange} | 513 | onSearchQueryChange={props.onSearchQueryChange} |
| 365 | onSortOrderChange={props.onSortOrderChange} | 514 | onSortOrderChange={props.onSortOrderChange} |
| 366 | selectedEnvironmentId={props.selectedEnvironmentId} | 515 | selectedEnvironmentId={props.selectedEnvironmentId} |
| 367 | sortOrder={props.sortOrder} | 516 | sortOrder={props.sortOrder} |
| 368 | /> | 517 | /> |
| 369 | 518 | ||
| 370 | <ScrollView | 519 | <GestureDetector gesture={archiveScrollGesture}> |
| 371 | className="flex-1" | 520 | <LegendList |
| 372 | contentContainerStyle={{ gap: 20, paddingBottom: 32, paddingHorizontal: 16, paddingTop: 8 }} | 521 | className="flex-1" |
| 373 | contentInsetAdjustmentBehavior="automatic" | 522 | contentContainerStyle={{ |
| 374 | keyboardDismissMode="on-drag" | 523 | paddingBottom: 32, |
| 375 | keyboardShouldPersistTaps="handled" | 524 | paddingHorizontal: 16, |
| 376 | onScrollBeginDrag={() => openSwipeableRef.current?.close()} | 525 | paddingTop: 4, |
| 377 | refreshControl={ | 526 | }} |
| 378 | <RefreshControl | 527 | contentInsetAdjustmentBehavior="automatic" |
| 379 | onRefresh={props.onRefresh} | 528 | data={listItems} |
| 380 | refreshing={props.isLoading && !isInitialLoad} | 529 | estimatedItemSize={62} |
| 381 | tintColor={String(refreshTint)} | 530 | getItemType={(item) => item.kind} |
| 382 | /> | 531 | keyboardDismissMode="on-drag" |
| 383 | } | 532 | keyboardShouldPersistTaps="handled" |
| 384 | showsVerticalScrollIndicator={false} | 533 | keyExtractor={(item) => item.key} |
| 385 | > | 534 | ListEmptyComponent={listEmptyComponent} |
| 386 | {props.error ? <ArchiveError message={props.error} onRetry={props.onRefresh} /> : null} | 535 | ListHeaderComponent={ |
| 387 | 536 | props.error ? <ArchiveError message={props.error} onRetry={props.onRefresh} /> : null | |
| 388 | {isInitialLoad ? ( | 537 | } |
| 389 | <View className="items-center py-16"> | 538 | onScrollBeginDrag={() => openSwipeableRef.current?.close()} |
| 390 | <ActivityIndicator color={refreshTint} /> | 539 | refreshControl={ |
| 391 | <Text className="mt-3 text-sm text-foreground-muted">Loading archive…</Text> | 540 | <RefreshControl |
| 392 | </View> | 541 | onRefresh={props.onRefresh} |
| 393 | ) : props.groups.length === 0 ? ( | 542 | refreshing={props.isLoading && !isInitialLoad} |
| 394 | <EmptyState | 543 | tintColor={String(refreshTint)} |
| 395 | detail={ | 544 | /> |
| 396 | isFiltered | 545 | } |
| 397 | ? "Try another search or environment." | 546 | renderItem={renderListItem} |
| 398 | : "Threads you archive will appear here." | 547 | showsVerticalScrollIndicator={false} |
| 399 | } | 548 | /> |
| 400 | title={isFiltered ? "No matching threads" : "No archived threads"} | 549 | </GestureDetector> |
| 401 | /> | ||
| 402 | ) : ( | ||
| 403 | props.groups.map((group) => { | ||
| 404 | const environmentLabel = | ||
| 405 | props.environments.find( | ||
| 406 | (environment) => environment.environmentId === group.project.environmentId, | ||
| 407 | )?.label ?? null; | ||
| 408 | |||
| 409 | return ( | ||
| 410 | <View key={group.key} collapsable={false}> | ||
| 411 | <ProjectGroupLabel environmentLabel={environmentLabel} project={group.project} /> | ||
| 412 | <View | ||
| 413 | className="overflow-hidden rounded-[20px] bg-card" | ||
| 414 | style={{ borderCurve: "continuous" }} | ||
| 415 | > | ||
| 416 | {group.threads.map((thread, index) => ( | ||
| 417 | <ArchivedThreadRow | ||
| 418 | key={`${thread.environmentId}:${thread.id}`} | ||
| 419 | environmentLabel={environmentLabel} | ||
| 420 | isLast={index === group.threads.length - 1} | ||
| 421 | onDelete={() => props.onDeleteThread(thread)} | ||
| 422 | onSwipeableClose={handleSwipeableClose} | ||
| 423 | onSwipeableWillOpen={handleSwipeableWillOpen} | ||
| 424 | onUnarchive={() => props.onUnarchiveThread(thread)} | ||
| 425 | thread={thread} | ||
| 426 | /> | ||
| 427 | ))} | ||
| 428 | </View> | ||
| 429 | </View> | ||
| 430 | ); | ||
| 431 | }) | ||
| 432 | )} | ||
| 433 | </ScrollView> | ||
| 434 | </View> | 550 | </View> |
| 435 | ); | 551 | ); |
| 436 | } | 552 | } |
apps/mobile/src/features/cloud/CloudWaitlistEnrollment.tsx+11-43| ... | @@ -2,7 +2,6 @@ import { useWaitlist } from "@clerk/expo"; | ... | @@ -2,7 +2,6 @@ import { useWaitlist } from "@clerk/expo"; |
| 2 | import { ActivityIndicator, Pressable, StyleSheet, Text, TextInput, View } from "react-native"; | 2 | import { ActivityIndicator, Pressable, StyleSheet, Text, TextInput, View } from "react-native"; |
| 3 | import { useState } from "react"; | 3 | import { useState } from "react"; |
| 4 | 4 | ||
| 5 | import { MOBILE_TYPOGRAPHY } from "../../lib/typography"; | ||
| 6 | import { useThemeColor } from "../../lib/useThemeColor"; | 5 | import { useThemeColor } from "../../lib/useThemeColor"; |
| 7 | import { CloudWaitlistJoinRejectedError, joinCloudWaitlist } from "./cloudWaitlistJoin"; | 6 | import { CloudWaitlistJoinRejectedError, joinCloudWaitlist } from "./cloudWaitlistJoin"; |
| 8 | 7 | ||
| ... | @@ -36,8 +35,10 @@ export function CloudWaitlistEnrollment(props: { readonly onSignIn: () => void } | ... | @@ -36,8 +35,10 @@ export function CloudWaitlistEnrollment(props: { readonly onSignIn: () => void } |
| 36 | if (waitlist.id) { | 35 | if (waitlist.id) { |
| 37 | return ( | 36 | return ( |
| 38 | <View style={styles.content}> | 37 | <View style={styles.content}> |
| 39 | <Text style={[styles.title, { color: colors.foreground }]}>You are on the waitlist</Text> | 38 | <Text className="text-center font-t3-bold text-xl text-foreground"> |
| 40 | <Text style={[styles.body, styles.confirmationBody, { color: colors.secondaryForeground }]}> | 39 | You are on the waitlist |
| 40 | </Text> | ||
| 41 | <Text className="text-center font-sans text-base text-foreground-secondary"> | ||
| 41 | We will email you when your T3 Cloud access is ready. | 42 | We will email you when your T3 Cloud access is ready. |
| 42 | </Text> | 43 | </Text> |
| 43 | <SignInAction onPress={props.onSignIn} /> | 44 | <SignInAction onPress={props.onSignIn} /> |
| ... | @@ -47,17 +48,18 @@ export function CloudWaitlistEnrollment(props: { readonly onSignIn: () => void } | ... | @@ -47,17 +48,18 @@ export function CloudWaitlistEnrollment(props: { readonly onSignIn: () => void } |
| 47 | 48 | ||
| 48 | return ( | 49 | return ( |
| 49 | <View style={styles.content}> | 50 | <View style={styles.content}> |
| 50 | <Text style={[styles.body, { color: colors.secondaryForeground }]}> | 51 | <Text className="font-sans text-base text-foreground-secondary"> |
| 51 | Enter your email and we will let you know when access is ready. | 52 | Enter your email and we will let you know when access is ready. |
| 52 | </Text> | 53 | </Text> |
| 53 | 54 | ||
| 54 | <View style={styles.field}> | 55 | <View style={styles.field}> |
| 55 | <Text style={[styles.label, { color: colors.secondaryForeground }]}>Email address</Text> | 56 | <Text className="font-t3-bold text-sm text-foreground-secondary">Email address</Text> |
| 56 | <TextInput | 57 | <TextInput |
| 57 | accessibilityLabel="Email address" | 58 | accessibilityLabel="Email address" |
| 58 | autoCapitalize="none" | 59 | autoCapitalize="none" |
| 59 | autoComplete="email" | 60 | autoComplete="email" |
| 60 | autoCorrect={false} | 61 | autoCorrect={false} |
| 62 | className="font-sans text-lg text-foreground" | ||
| 61 | keyboardType="email-address" | 63 | keyboardType="email-address" |
| 62 | onChangeText={(value) => { | 64 | onChangeText={(value) => { |
| 63 | setEmailAddress(value); | 65 | setEmailAddress(value); |
| ... | @@ -73,7 +75,6 @@ export function CloudWaitlistEnrollment(props: { readonly onSignIn: () => void } | ... | @@ -73,7 +75,6 @@ export function CloudWaitlistEnrollment(props: { readonly onSignIn: () => void } |
| 73 | backgroundColor: colors.input, | 75 | backgroundColor: colors.input, |
| 74 | borderColor: | 76 | borderColor: |
| 75 | fieldError || requestError ? colors.dangerForeground : colors.inputBorder, | 77 | fieldError || requestError ? colors.dangerForeground : colors.inputBorder, |
| 76 | color: colors.foreground, | ||
| 77 | }, | 78 | }, |
| 78 | ]} | 79 | ]} |
| 79 | textContentType="emailAddress" | 80 | textContentType="emailAddress" |
| ... | @@ -82,7 +83,7 @@ export function CloudWaitlistEnrollment(props: { readonly onSignIn: () => void } | ... | @@ -82,7 +83,7 @@ export function CloudWaitlistEnrollment(props: { readonly onSignIn: () => void } |
| 82 | {fieldError || requestError ? ( | 83 | {fieldError || requestError ? ( |
| 83 | <Text | 84 | <Text |
| 84 | accessibilityLiveRegion="polite" | 85 | accessibilityLiveRegion="polite" |
| 85 | style={[styles.error, { color: colors.dangerForeground }]} | 86 | className="font-sans text-sm text-danger-foreground" |
| 86 | selectable | 87 | selectable |
| 87 | > | 88 | > |
| 88 | {fieldError ?? requestError} | 89 | {fieldError ?? requestError} |
| ... | @@ -107,7 +108,7 @@ export function CloudWaitlistEnrollment(props: { readonly onSignIn: () => void } | ... | @@ -107,7 +108,7 @@ export function CloudWaitlistEnrollment(props: { readonly onSignIn: () => void } |
| 107 | ]} | 108 | ]} |
| 108 | > | 109 | > |
| 109 | {isSubmitting ? <ActivityIndicator color={colors.primaryForeground} size="small" /> : null} | 110 | {isSubmitting ? <ActivityIndicator color={colors.primaryForeground} size="small" /> : null} |
| 110 | <Text style={[styles.buttonText, { color: colors.primaryForeground }]}> | 111 | <Text className="font-t3-bold text-base text-primary-foreground"> |
| 111 | {isSubmitting ? "Joining" : "Join the waitlist"} | 112 | {isSubmitting ? "Joining" : "Join the waitlist"} |
| 112 | </Text> | 113 | </Text> |
| 113 | </Pressable> | 114 | </Pressable> |
| ... | @@ -118,12 +119,11 @@ export function CloudWaitlistEnrollment(props: { readonly onSignIn: () => void } | ... | @@ -118,12 +119,11 @@ export function CloudWaitlistEnrollment(props: { readonly onSignIn: () => void } |
| 118 | } | 119 | } |
| 119 | 120 | ||
| 120 | function SignInAction(props: { readonly onPress: () => void }) { | 121 | function SignInAction(props: { readonly onPress: () => void }) { |
| 121 | const colors = useCloudWaitlistColors(); | ||
| 122 | return ( | 122 | return ( |
| 123 | <View style={styles.signInRow}> | 123 | <View style={styles.signInRow}> |
| 124 | <Text style={[styles.body, { color: colors.secondaryForeground }]}>Already have access?</Text> | 124 | <Text className="font-sans text-base text-foreground-secondary">Already have access?</Text> |
| 125 | <Pressable accessibilityRole="button" hitSlop={8} onPress={props.onPress}> | 125 | <Pressable accessibilityRole="button" hitSlop={8} onPress={props.onPress}> |
| 126 | <Text style={[styles.signInText, { color: colors.foreground }]}>Sign in</Text> | 126 | <Text className="font-t3-bold text-base text-foreground">Sign in</Text> |
| 127 | </Pressable> | 127 | </Pressable> |
| 128 | </View> | 128 | </View> |
| 129 | ); | 129 | ); |
| ... | @@ -132,35 +132,18 @@ function SignInAction(props: { readonly onPress: () => void }) { | ... | @@ -132,35 +132,18 @@ function SignInAction(props: { readonly onPress: () => void }) { |
| 132 | function useCloudWaitlistColors() { | 132 | function useCloudWaitlistColors() { |
| 133 | return { | 133 | return { |
| 134 | dangerForeground: String(useThemeColor("--color-danger-foreground")), | 134 | dangerForeground: String(useThemeColor("--color-danger-foreground")), |
| 135 | foreground: String(useThemeColor("--color-foreground")), | ||
| 136 | input: String(useThemeColor("--color-input")), | 135 | input: String(useThemeColor("--color-input")), |
| 137 | inputBorder: String(useThemeColor("--color-input-border")), | 136 | inputBorder: String(useThemeColor("--color-input-border")), |
| 138 | placeholder: String(useThemeColor("--color-placeholder")), | 137 | placeholder: String(useThemeColor("--color-placeholder")), |
| 139 | primary: String(useThemeColor("--color-primary")), | 138 | primary: String(useThemeColor("--color-primary")), |
| 140 | primaryForeground: String(useThemeColor("--color-primary-foreground")), | 139 | primaryForeground: String(useThemeColor("--color-primary-foreground")), |
| 141 | secondaryForeground: String(useThemeColor("--color-foreground-secondary")), | ||
| 142 | }; | 140 | }; |
| 143 | } | 141 | } |
| 144 | 142 | ||
| 145 | const styles = StyleSheet.create({ | 143 | const styles = StyleSheet.create({ |
| 146 | body: { | ||
| 147 | fontFamily: "DMSans_400Regular", | ||
| 148 | ...MOBILE_TYPOGRAPHY.body, | ||
| 149 | }, | ||
| 150 | buttonText: { | ||
| 151 | fontFamily: "DMSans_700Bold", | ||
| 152 | fontSize: MOBILE_TYPOGRAPHY.body.fontSize, | ||
| 153 | }, | ||
| 154 | content: { | 144 | content: { |
| 155 | gap: 18, | 145 | gap: 18, |
| 156 | }, | 146 | }, |
| 157 | confirmationBody: { | ||
| 158 | textAlign: "center", | ||
| 159 | }, | ||
| 160 | error: { | ||
| 161 | fontFamily: "DMSans_400Regular", | ||
| 162 | ...MOBILE_TYPOGRAPHY.footnote, | ||
| 163 | }, | ||
| 164 | field: { | 147 | field: { |
| 165 | gap: 8, | 148 | gap: 8, |
| 166 | }, | 149 | }, |
| ... | @@ -168,16 +151,10 @@ const styles = StyleSheet.create({ | ... | @@ -168,16 +151,10 @@ const styles = StyleSheet.create({ |
| 168 | borderCurve: "continuous", | 151 | borderCurve: "continuous", |
| 169 | borderRadius: 16, | 152 | borderRadius: 16, |
| 170 | borderWidth: 1, | 153 | borderWidth: 1, |
| 171 | fontFamily: "DMSans_400Regular", | ||
| 172 | fontSize: MOBILE_TYPOGRAPHY.headline.fontSize, | ||
| 173 | minHeight: 54, | 154 | minHeight: 54, |
| 174 | paddingHorizontal: 16, | 155 | paddingHorizontal: 16, |
| 175 | paddingVertical: 14, | 156 | paddingVertical: 14, |
| 176 | }, | 157 | }, |
| 177 | label: { | ||
| 178 | fontFamily: "DMSans_700Bold", | ||
| 179 | ...MOBILE_TYPOGRAPHY.footnote, | ||
| 180 | }, | ||
| 181 | primaryButton: { | 158 | primaryButton: { |
| 182 | alignItems: "center", | 159 | alignItems: "center", |
| 183 | borderRadius: 999, | 160 | borderRadius: 999, |
| ... | @@ -195,13 +172,4 @@ const styles = StyleSheet.create({ | ... | @@ -195,13 +172,4 @@ const styles = StyleSheet.create({ |
| 195 | justifyContent: "center", | 172 | justifyContent: "center", |
| 196 | paddingTop: 4, | 173 | paddingTop: 4, |
| 197 | }, | 174 | }, |
| 198 | signInText: { | ||
| 199 | fontFamily: "DMSans_700Bold", | ||
| 200 | ...MOBILE_TYPOGRAPHY.body, | ||
| 201 | }, | ||
| 202 | title: { | ||
| 203 | fontFamily: "DMSans_700Bold", | ||
| 204 | ...MOBILE_TYPOGRAPHY.title, | ||
| 205 | textAlign: "center", | ||
| 206 | }, | ||
| 207 | }); | 175 | }); |
apps/mobile/src/features/cloud/dpop.ts+41-9| ... | @@ -8,12 +8,7 @@ import * as Schema from "effect/Schema"; | ... | @@ -8,12 +8,7 @@ import * as Schema from "effect/Schema"; |
| 8 | import * as ExpoCrypto from "expo-crypto"; | 8 | import * as ExpoCrypto from "expo-crypto"; |
| 9 | import * as SecureStore from "expo-secure-store"; | 9 | import * as SecureStore from "expo-secure-store"; |
| 10 | import { p256 } from "@noble/curves/nist"; | 10 | import { p256 } from "@noble/curves/nist"; |
| 11 | import { | 11 | import { DpopPublicJwk, normalizeDpopHtu } from "@t3tools/shared/dpopCommon"; |
| 12 | computeDpopAccessTokenHash, | ||
| 13 | computeDpopJwkThumbprint, | ||
| 14 | DpopPublicJwk, | ||
| 15 | normalizeDpopHtu, | ||
| 16 | } from "@t3tools/shared/dpop"; | ||
| 17 | import * as Layer from "effect/Layer"; | 12 | import * as Layer from "effect/Layer"; |
| 18 | 13 | ||
| 19 | export class CloudDpopError extends Data.TaggedError("CloudDpopError")<{ | 14 | export class CloudDpopError extends Data.TaggedError("CloudDpopError")<{ |
| ... | @@ -107,6 +102,40 @@ function sha256Digest( | ... | @@ -107,6 +102,40 @@ function sha256Digest( |
| 107 | ); | 102 | ); |
| 108 | } | 103 | } |
| 109 | 104 | ||
| 105 | function base64UrlSha256( | ||
| 106 | data: Uint8Array, | ||
| 107 | message: string, | ||
| 108 | ): Effect.Effect<string, CloudDpopError, Crypto.Crypto> { | ||
| 109 | return sha256Digest(data, message).pipe(Effect.map(Encoding.encodeBase64Url)); | ||
| 110 | } | ||
| 111 | |||
| 112 | function dpopThumbprintInput(jwk: DpopPublicJwk): string { | ||
| 113 | return JSON.stringify({ | ||
| 114 | crv: jwk.crv, | ||
| 115 | kty: jwk.kty, | ||
| 116 | x: jwk.x, | ||
| 117 | y: jwk.y, | ||
| 118 | }); | ||
| 119 | } | ||
| 120 | |||
| 121 | function computeDpopJwkThumbprintEffect( | ||
| 122 | jwk: DpopPublicJwk, | ||
| 123 | ): Effect.Effect<string, CloudDpopError, Crypto.Crypto> { | ||
| 124 | return base64UrlSha256( | ||
| 125 | new TextEncoder().encode(dpopThumbprintInput(jwk)), | ||
| 126 | "Could not hash the DPoP public key thumbprint.", | ||
| 127 | ); | ||
| 128 | } | ||
| 129 | |||
| 130 | function computeDpopAccessTokenHashEffect( | ||
| 131 | accessToken: string, | ||
| 132 | ): Effect.Effect<string, CloudDpopError, Crypto.Crypto> { | ||
| 133 | return base64UrlSha256( | ||
| 134 | new TextEncoder().encode(accessToken), | ||
| 135 | "Could not hash the DPoP access token.", | ||
| 136 | ); | ||
| 137 | } | ||
| 138 | |||
| 110 | function secureRandomBytes( | 139 | function secureRandomBytes( |
| 111 | byteCount: number, | 140 | byteCount: number, |
| 112 | message: string, | 141 | message: string, |
| ... | @@ -153,7 +182,7 @@ export function generateDpopProofKeyPair(): Effect.Effect< | ... | @@ -153,7 +182,7 @@ export function generateDpopProofKeyPair(): Effect.Effect< |
| 153 | try: () => publicJwkFromUncompressedPublicKey(p256.getPublicKey(privateKey, false)), | 182 | try: () => publicJwkFromUncompressedPublicKey(p256.getPublicKey(privateKey, false)), |
| 154 | catch: cloudDpopError("Generated DPoP public key is invalid."), | 183 | catch: cloudDpopError("Generated DPoP public key is invalid."), |
| 155 | }); | 184 | }); |
| 156 | const thumbprint = computeDpopJwkThumbprint(publicJwk); | 185 | const thumbprint = yield* computeDpopJwkThumbprintEffect(publicJwk); |
| 157 | return { | 186 | return { |
| 158 | privateJwk: privateJwkFromPrivateKey(privateKey, publicJwk), | 187 | privateJwk: privateJwkFromPrivateKey(privateKey, publicJwk), |
| 159 | publicJwk, | 188 | publicJwk, |
| ... | @@ -189,9 +218,10 @@ export function loadOrCreateDpopProofKeyPair(): Effect.Effect< | ... | @@ -189,9 +218,10 @@ export function loadOrCreateDpopProofKeyPair(): Effect.Effect< |
| 189 | }, | 218 | }, |
| 190 | catch: cloudDpopError("Stored DPoP proof key is invalid."), | 219 | catch: cloudDpopError("Stored DPoP proof key is invalid."), |
| 191 | }); | 220 | }); |
| 221 | const thumbprint = yield* computeDpopJwkThumbprintEffect(restored.publicJwk); | ||
| 192 | return { | 222 | return { |
| 193 | ...restored, | 223 | ...restored, |
| 194 | thumbprint: computeDpopJwkThumbprint(restored.publicJwk), | 224 | thumbprint, |
| 195 | }; | 225 | }; |
| 196 | } | 226 | } |
| 197 | const generated = yield* generateDpopProofKeyPair(); | 227 | const generated = yield* generateDpopProofKeyPair(); |
| ... | @@ -243,7 +273,9 @@ export function createDpopProof(input: { | ... | @@ -243,7 +273,9 @@ export function createDpopProof(input: { |
| 243 | Effect.map(Encoding.encodeBase64Url), | 273 | Effect.map(Encoding.encodeBase64Url), |
| 244 | Effect.mapError(cloudDpopError("Could not encode DPoP proof header.")), | 274 | Effect.mapError(cloudDpopError("Could not encode DPoP proof header.")), |
| 245 | ); | 275 | ); |
| 246 | const ath = input.accessToken ? computeDpopAccessTokenHash(input.accessToken) : null; | 276 | const ath = input.accessToken |
| 277 | ? yield* computeDpopAccessTokenHashEffect(input.accessToken) | ||
| 278 | : null; | ||
| 247 | const payload = yield* encodeDpopJwtPayloadJson({ | 279 | const payload = yield* encodeDpopJwtPayloadJson({ |
| 248 | htm: input.method.toUpperCase(), | 280 | htm: input.method.toUpperCase(), |
| 249 | htu, | 281 | htu, |
apps/mobile/src/features/connection/ConnectionEnvironmentRow.tsx+4-4| ... | @@ -76,16 +76,16 @@ export function ConnectionEnvironmentRow(props: { | ... | @@ -76,16 +76,16 @@ export function ConnectionEnvironmentRow(props: { |
| 76 | /> | 76 | /> |
| 77 | 77 | ||
| 78 | <View className="flex-1 gap-0.5"> | 78 | <View className="flex-1 gap-0.5"> |
| 79 | <Text className="text-base font-t3-bold leading-[21px] text-foreground" numberOfLines={1}> | 79 | <Text className="text-base font-t3-bold leading-snug text-foreground" numberOfLines={1}> |
| 80 | {props.environment.environmentLabel} | 80 | {props.environment.environmentLabel} |
| 81 | </Text> | 81 | </Text> |
| 82 | <Text className="text-xs leading-[16px] text-foreground-muted" numberOfLines={1}> | 82 | <Text className="text-xs text-foreground-muted" numberOfLines={1}> |
| 83 | {props.environment.displayUrl} | 83 | {props.environment.displayUrl} |
| 84 | </Text> | 84 | </Text> |
| 85 | {statusLabel ? ( | 85 | {statusLabel ? ( |
| 86 | <Text | 86 | <Text |
| 87 | className={cn( | 87 | className={cn( |
| 88 | "text-xs leading-[16px]", | 88 | "text-xs", |
| 89 | hasConnectionFailure ? "text-rose-500 dark:text-rose-400" : "text-foreground-muted", | 89 | hasConnectionFailure ? "text-rose-500 dark:text-rose-400" : "text-foreground-muted", |
| 90 | )} | 90 | )} |
| 91 | numberOfLines={props.expanded ? undefined : 1} | 91 | numberOfLines={props.expanded ? undefined : 1} |
| ... | @@ -134,7 +134,7 @@ export function ConnectionEnvironmentRow(props: { | ... | @@ -134,7 +134,7 @@ export function ConnectionEnvironmentRow(props: { |
| 134 | className="gap-3 px-4 pb-4" | 134 | className="gap-3 px-4 pb-4" |
| 135 | > | 135 | > |
| 136 | {props.environment.isRelayManaged ? ( | 136 | {props.environment.isRelayManaged ? ( |
| 137 | <Text className="text-sm leading-[18px] text-foreground-muted"> | 137 | <Text className="text-sm text-foreground-muted"> |
| 138 | Managed by T3 Cloud. Tunnel details update automatically. | 138 | Managed by T3 Cloud. Tunnel details update automatically. |
| 139 | </Text> | 139 | </Text> |
| 140 | ) : ( | 140 | ) : ( |
apps/mobile/src/features/connection/ConnectionsNewRouteScreen.tsx created+256| ... | @@ -0,0 +1,256 @@ | ||
| 1 | import { CameraView, useCameraPermissions } from "expo-camera"; | ||
| 2 | import { NativeHeaderToolbar, NativeStackScreenOptions } from "../../native/StackHeader"; | ||
| 3 | import { StackActions, useNavigation, type StaticScreenProps } from "@react-navigation/native"; | ||
| 4 | import { AsyncResult } from "effect/unstable/reactivity"; | ||
| 5 | import { useCallback, useEffect, useState } from "react"; | ||
| 6 | import { Alert, ScrollView, View } from "react-native"; | ||
| 7 | import { useSafeAreaInsets } from "react-native-safe-area-context"; | ||
| 8 | import { useThemeColor } from "../../lib/useThemeColor"; | ||
| 9 | |||
| 10 | import { AppText as Text, AppTextInput as TextInput } from "../../components/AppText"; | ||
| 11 | import { ErrorBanner } from "../../components/ErrorBanner"; | ||
| 12 | import { ConnectionSheetButton } from "./ConnectionSheetButton"; | ||
| 13 | import { extractPairingUrlFromQrPayload } from "./pairing"; | ||
| 14 | import { useRemoteConnections } from "../../state/use-remote-environment-registry"; | ||
| 15 | import { buildPairingUrl, parsePairingUrl } from "./pairing"; | ||
| 16 | |||
| 17 | type ConnectionsNewRouteParams = { | ||
| 18 | readonly mode?: string; | ||
| 19 | }; | ||
| 20 | |||
| 21 | export function ConnectionsNewRouteScreen({ | ||
| 22 | route, | ||
| 23 | }: StaticScreenProps<ConnectionsNewRouteParams | undefined>) { | ||
| 24 | const { | ||
| 25 | connectionPairingUrl, | ||
| 26 | onChangeConnectionPairingUrl, | ||
| 27 | onConnectPress, | ||
| 28 | pairingConnectionError, | ||
| 29 | } = useRemoteConnections(); | ||
| 30 | const navigation = useNavigation(); | ||
| 31 | const params = route.params ?? {}; | ||
| 32 | const insets = useSafeAreaInsets(); | ||
| 33 | const [hostInput, setHostInput] = useState(""); | ||
| 34 | const [codeInput, setCodeInput] = useState(""); | ||
| 35 | const [isSubmitting, setIsSubmitting] = useState(false); | ||
| 36 | const [showScanner, setShowScanner] = useState(params.mode === "scan_qr"); | ||
| 37 | const [cameraPermission, requestCameraPermission] = useCameraPermissions(); | ||
| 38 | const [scannerLocked, setScannerLocked] = useState(false); | ||
| 39 | |||
| 40 | const headerIconColor = useThemeColor("--color-icon"); | ||
| 41 | const placeholderColor = useThemeColor("--color-placeholder"); | ||
| 42 | |||
| 43 | const connectDisabled = isSubmitting || hostInput.trim().length === 0; | ||
| 44 | |||
| 45 | useEffect(() => { | ||
| 46 | const { host, code } = parsePairingUrl(connectionPairingUrl); | ||
| 47 | setHostInput(host); | ||
| 48 | setCodeInput(code); | ||
| 49 | }, [connectionPairingUrl]); | ||
| 50 | |||
| 51 | useEffect(() => { | ||
| 52 | if (pairingConnectionError) { | ||
| 53 | setIsSubmitting(false); | ||
| 54 | } | ||
| 55 | }, [pairingConnectionError]); | ||
| 56 | |||
| 57 | const handleHostChange = useCallback((value: string) => { | ||
| 58 | setHostInput(value); | ||
| 59 | }, []); | ||
| 60 | |||
| 61 | const handleCodeChange = useCallback((value: string) => { | ||
| 62 | setCodeInput(value); | ||
| 63 | }, []); | ||
| 64 | |||
| 65 | const openScanner = useCallback(async () => { | ||
| 66 | if (cameraPermission?.granted) { | ||
| 67 | setScannerLocked(false); | ||
| 68 | setShowScanner(true); | ||
| 69 | return; | ||
| 70 | } | ||
| 71 | |||
| 72 | const permission = await requestCameraPermission(); | ||
| 73 | if (permission.granted) { | ||
| 74 | setScannerLocked(false); | ||
| 75 | setShowScanner(true); | ||
| 76 | return; | ||
| 77 | } | ||
| 78 | |||
| 79 | Alert.alert( | ||
| 80 | "Camera access needed", | ||
| 81 | "Allow camera access to scan an environment pairing QR code.", | ||
| 82 | ); | ||
| 83 | }, [cameraPermission?.granted, requestCameraPermission]); | ||
| 84 | |||
| 85 | const closeScanner = useCallback(() => { | ||
| 86 | setShowScanner(false); | ||
| 87 | setScannerLocked(false); | ||
| 88 | }, []); | ||
| 89 | |||
| 90 | const handleQrScan = useCallback( | ||
| 91 | ({ data }: { readonly data: string }) => { | ||
| 92 | if (scannerLocked) { | ||
| 93 | return; | ||
| 94 | } | ||
| 95 | |||
| 96 | setScannerLocked(true); | ||
| 97 | |||
| 98 | try { | ||
| 99 | const pairingUrl = extractPairingUrlFromQrPayload(data); | ||
| 100 | const { host, code } = parsePairingUrl(pairingUrl); | ||
| 101 | setHostInput(host); | ||
| 102 | setCodeInput(code); | ||
| 103 | onChangeConnectionPairingUrl(pairingUrl); | ||
| 104 | setShowScanner(false); | ||
| 105 | } catch (error) { | ||
| 106 | Alert.alert( | ||
| 107 | "Invalid QR code", | ||
| 108 | error instanceof Error ? error.message : "Scanned QR code was not recognized.", | ||
| 109 | ); | ||
| 110 | } finally { | ||
| 111 | setTimeout(() => { | ||
| 112 | setScannerLocked(false); | ||
| 113 | }, 600); | ||
| 114 | } | ||
| 115 | }, | ||
| 116 | [onChangeConnectionPairingUrl, scannerLocked], | ||
| 117 | ); | ||
| 118 | |||
| 119 | const handleSubmit = useCallback(async () => { | ||
| 120 | setIsSubmitting(true); | ||
| 121 | |||
| 122 | const pairingUrl = buildPairingUrl(hostInput, codeInput); | ||
| 123 | onChangeConnectionPairingUrl(pairingUrl); | ||
| 124 | const result = await onConnectPress(pairingUrl); | ||
| 125 | if (AsyncResult.isSuccess(result)) { | ||
| 126 | if (navigation.canGoBack()) { | ||
| 127 | navigation.goBack(); | ||
| 128 | } else { | ||
| 129 | navigation.dispatch(StackActions.replace("Home")); | ||
| 130 | } | ||
| 131 | } else { | ||
| 132 | setIsSubmitting(false); | ||
| 133 | } | ||
| 134 | }, [codeInput, hostInput, onChangeConnectionPairingUrl, onConnectPress, navigation]); | ||
| 135 | |||
| 136 | return ( | ||
| 137 | <View collapsable={false} className="flex-1 bg-sheet"> | ||
| 138 | <NativeStackScreenOptions | ||
| 139 | options={{ | ||
| 140 | title: showScanner ? "Scan QR Code" : "Add Environment", | ||
| 141 | }} | ||
| 142 | /> | ||
| 143 | <NativeHeaderToolbar placement="right"> | ||
| 144 | <NativeHeaderToolbar.Button | ||
| 145 | icon={showScanner ? "xmark" : "qrcode.viewfinder"} | ||
| 146 | onPress={() => { | ||
| 147 | if (showScanner) { | ||
| 148 | closeScanner(); | ||
| 149 | } else { | ||
| 150 | void openScanner(); | ||
| 151 | } | ||
| 152 | }} | ||
| 153 | separateBackground | ||
| 154 | tintColor={headerIconColor} | ||
| 155 | /> | ||
| 156 | </NativeHeaderToolbar> | ||
| 157 | |||
| 158 | <ScrollView | ||
| 159 | contentInsetAdjustmentBehavior="automatic" | ||
| 160 | showsVerticalScrollIndicator={false} | ||
| 161 | style={{ flex: 1 }} | ||
| 162 | contentInset={{ bottom: Math.max(insets.bottom, 18) + 18 }} | ||
| 163 | contentContainerStyle={{ | ||
| 164 | paddingHorizontal: 20, | ||
| 165 | paddingTop: 16, | ||
| 166 | }} | ||
| 167 | > | ||
| 168 | <View collapsable={false} className="gap-5"> | ||
| 169 | {showScanner ? ( | ||
| 170 | cameraPermission?.granted ? ( | ||
| 171 | <View | ||
| 172 | className="overflow-hidden rounded-[24px]" | ||
| 173 | style={{ borderCurve: "continuous" }} | ||
| 174 | > | ||
| 175 | <CameraView | ||
| 176 | barcodeScannerSettings={{ barcodeTypes: ["qr"] }} | ||
| 177 | onBarcodeScanned={handleQrScan} | ||
| 178 | style={{ aspectRatio: 1, width: "100%" }} | ||
| 179 | /> | ||
| 180 | </View> | ||
| 181 | ) : ( | ||
| 182 | <View | ||
| 183 | className="items-center gap-3 rounded-[24px] bg-card px-5 py-8" | ||
| 184 | style={{ borderCurve: "continuous" }} | ||
| 185 | > | ||
| 186 | <Text className="text-center text-sm leading-normal text-foreground-muted"> | ||
| 187 | Camera permission is required to scan a QR code. | ||
| 188 | </Text> | ||
| 189 | <ConnectionSheetButton | ||
| 190 | compact | ||
| 191 | icon="camera" | ||
| 192 | label="Allow camera" | ||
| 193 | tone="secondary" | ||
| 194 | onPress={() => { | ||
| 195 | void openScanner(); | ||
| 196 | }} | ||
| 197 | /> | ||
| 198 | </View> | ||
| 199 | ) | ||
| 200 | ) : ( | ||
| 201 | <View collapsable={false} className="gap-4 rounded-[24px] bg-card p-4"> | ||
| 202 | <View collapsable={false} className="gap-1.5"> | ||
| 203 | <Text | ||
| 204 | className="text-2xs font-t3-bold uppercase text-foreground-muted" | ||
| 205 | style={{ letterSpacing: 0.8 }} | ||
| 206 | > | ||
| 207 | Host | ||
| 208 | </Text> | ||
| 209 | <TextInput | ||
| 210 | autoCapitalize="none" | ||
| 211 | autoCorrect={false} | ||
| 212 | keyboardType="url" | ||
| 213 | placeholder="192.168.1.100:8080" | ||
| 214 | placeholderTextColor={placeholderColor} | ||
| 215 | value={hostInput} | ||
| 216 | onChangeText={handleHostChange} | ||
| 217 | className="rounded-[14px] border border-input-border bg-input px-4 py-3.5 text-base text-foreground" | ||
| 218 | /> | ||
| 219 | </View> | ||
| 220 | |||
| 221 | <View collapsable={false} className="gap-1.5"> | ||
| 222 | <Text | ||
| 223 | className="text-2xs font-t3-bold uppercase text-foreground-muted" | ||
| 224 | style={{ letterSpacing: 0.8 }} | ||
| 225 | > | ||
| 226 | Pairing code | ||
| 227 | </Text> | ||
| 228 | <TextInput | ||
| 229 | autoCapitalize="none" | ||
| 230 | autoCorrect={false} | ||
| 231 | placeholder="abc-123-xyz" | ||
| 232 | placeholderTextColor={placeholderColor} | ||
| 233 | value={codeInput} | ||
| 234 | onChangeText={handleCodeChange} | ||
| 235 | className="rounded-[14px] border border-input-border bg-input px-4 py-3.5 text-base text-foreground" | ||
| 236 | /> | ||
| 237 | </View> | ||
| 238 | |||
| 239 | {pairingConnectionError ? <ErrorBanner message={pairingConnectionError} /> : null} | ||
| 240 | |||
| 241 | <ConnectionSheetButton | ||
| 242 | icon="plus" | ||
| 243 | label={isSubmitting ? "Pairing..." : "Add environment"} | ||
| 244 | disabled={connectDisabled} | ||
| 245 | tone="primary" | ||
| 246 | onPress={() => { | ||
| 247 | void handleSubmit(); | ||
| 248 | }} | ||
| 249 | /> | ||
| 250 | </View> | ||
| 251 | )} | ||
| 252 | </View> | ||
| 253 | </ScrollView> | ||
| 254 | </View> | ||
| 255 | ); | ||
| 256 | } | ||
apps/mobile/src/features/connection/ConnectionsRouteScreen.tsx created+93| ... | @@ -0,0 +1,93 @@ | ||
| 1 | import { NativeHeaderToolbar } from "../../native/StackHeader"; | ||
| 2 | import { useNavigation } from "@react-navigation/native"; | ||
| 3 | import { SymbolView } from "expo-symbols"; | ||
| 4 | import type { EnvironmentId } from "@t3tools/contracts"; | ||
| 5 | import { useCallback, useState } from "react"; | ||
| 6 | import { ScrollView, View } from "react-native"; | ||
| 7 | import { useSafeAreaInsets } from "react-native-safe-area-context"; | ||
| 8 | import { useThemeColor } from "../../lib/useThemeColor"; | ||
| 9 | |||
| 10 | import { AppText as Text } from "../../components/AppText"; | ||
| 11 | import { cn } from "../../lib/cn"; | ||
| 12 | import { useRemoteConnections } from "../../state/use-remote-environment-registry"; | ||
| 13 | import { ConnectionEnvironmentRow } from "./ConnectionEnvironmentRow"; | ||
| 14 | |||
| 15 | export function ConnectionsRouteScreen() { | ||
| 16 | const { | ||
| 17 | connectedEnvironments, | ||
| 18 | onReconnectEnvironment, | ||
| 19 | onRemoveEnvironmentPress, | ||
| 20 | onUpdateEnvironment, | ||
| 21 | } = useRemoteConnections(); | ||
| 22 | const navigation = useNavigation(); | ||
| 23 | const insets = useSafeAreaInsets(); | ||
| 24 | const hasEnvironments = connectedEnvironments.length > 0; | ||
| 25 | const [expandedId, setExpandedId] = useState<EnvironmentId | null>(null); | ||
| 26 | |||
| 27 | const accentColor = useThemeColor("--color-icon-muted"); | ||
| 28 | |||
| 29 | const handleToggle = useCallback((environmentId: EnvironmentId) => { | ||
| 30 | setExpandedId((prev) => (prev === environmentId ? null : environmentId)); | ||
| 31 | }, []); | ||
| 32 | |||
| 33 | return ( | ||
| 34 | <View collapsable={false} className="flex-1 bg-sheet"> | ||
| 35 | <NativeHeaderToolbar placement="right"> | ||
| 36 | <NativeHeaderToolbar.Button | ||
| 37 | icon="plus" | ||
| 38 | onPress={() => navigation.navigate("ConnectionsNew")} | ||
| 39 | separateBackground | ||
| 40 | /> | ||
| 41 | </NativeHeaderToolbar> | ||
| 42 | <ScrollView | ||
| 43 | contentInsetAdjustmentBehavior="automatic" | ||
| 44 | showsVerticalScrollIndicator={false} | ||
| 45 | style={{ flex: 1 }} | ||
| 46 | contentInset={{ bottom: Math.max(insets.bottom, 18) + 18 }} | ||
| 47 | contentContainerStyle={{ | ||
| 48 | paddingHorizontal: 20, | ||
| 49 | paddingTop: 16, | ||
| 50 | }} | ||
| 51 | > | ||
| 52 | {hasEnvironments ? ( | ||
| 53 | <View collapsable={false} className="overflow-hidden rounded-[24px] bg-card"> | ||
| 54 | {connectedEnvironments.map((environment, index) => ( | ||
| 55 | <View | ||
| 56 | key={environment.environmentId} | ||
| 57 | collapsable={false} | ||
| 58 | style={{ | ||
| 59 | borderTopWidth: index === 0 ? 0 : 1, | ||
| 60 | }} | ||
| 61 | className={cn(index !== 0 && "border-border")} | ||
| 62 | > | ||
| 63 | <ConnectionEnvironmentRow | ||
| 64 | environment={environment} | ||
| 65 | expanded={expandedId === environment.environmentId} | ||
| 66 | onToggle={() => handleToggle(environment.environmentId)} | ||
| 67 | onReconnect={onReconnectEnvironment} | ||
| 68 | onRemove={onRemoveEnvironmentPress} | ||
| 69 | onUpdate={onUpdateEnvironment} | ||
| 70 | /> | ||
| 71 | </View> | ||
| 72 | ))} | ||
| 73 | </View> | ||
| 74 | ) : ( | ||
| 75 | <View collapsable={false} className="items-center gap-3 rounded-[24px] bg-card px-6 py-8"> | ||
| 76 | <View className="h-12 w-12 items-center justify-center rounded-[16px] bg-subtle"> | ||
| 77 | <SymbolView | ||
| 78 | name="point.3.connected.trianglepath.dotted" | ||
| 79 | size={20} | ||
| 80 | tintColor={accentColor} | ||
| 81 | type="monochrome" | ||
| 82 | /> | ||
| 83 | </View> | ||
| 84 | <Text className="text-center text-sm leading-normal text-foreground-muted"> | ||
| 85 | No environments connected yet.{"\n"}Tap{" "} | ||
| 86 | <Text className="font-t3-bold text-foreground">+</Text> to add one. | ||
| 87 | </Text> | ||
| 88 | </View> | ||
| 89 | )} | ||
| 90 | </ScrollView> | ||
| 91 | </View> | ||
| 92 | ); | ||
| 93 | } | ||
apps/mobile/src/features/connection/EnvironmentConnectionNotice.tsx+2-2| ... | @@ -73,10 +73,10 @@ export function EnvironmentConnectionNotice(props: { | ... | @@ -73,10 +73,10 @@ export function EnvironmentConnectionNotice(props: { |
| 73 | /> | 73 | /> |
| 74 | )} | 74 | )} |
| 75 | 75 | ||
| 76 | <Text className="text-center text-lg font-t3-bold leading-[22px] text-foreground"> | 76 | <Text className="text-center text-lg font-t3-bold text-foreground"> |
| 77 | {noticeTitle(props.connection.phase, props.environmentLabel)} | 77 | {noticeTitle(props.connection.phase, props.environmentLabel)} |
| 78 | </Text> | 78 | </Text> |
| 79 | <Text className="text-center text-sm leading-[20px] text-foreground-muted"> | 79 | <Text className="text-center text-sm leading-normal text-foreground-muted"> |
| 80 | {noticeDetail(props.connection.phase, props.resourceName, props.connection.error)} | 80 | {noticeDetail(props.connection.phase, props.resourceName, props.connection.error)} |
| 81 | {props.connection.traceId ? ( | 81 | {props.connection.traceId ? ( |
| 82 | <> | 82 | <> |
apps/mobile/src/features/diffs/nativeReviewDiffSurface.test.ts+22-2| ... | @@ -33,11 +33,14 @@ describe("resolveNativeReviewDiffView", () => { | ... | @@ -33,11 +33,14 @@ describe("resolveNativeReviewDiffView", () => { |
| 33 | expect(expoMocks.requireNativeView).not.toHaveBeenCalled(); | 33 | expect(expoMocks.requireNativeView).not.toHaveBeenCalled(); |
| 34 | }); | 34 | }); |
| 35 | 35 | ||
| 36 | it("returns the native review diff view when the view config is installed", async () => { | 36 | it("returns the payload bridge when the native review diff view is installed", async () => { |
| 37 | setExpoViewConfigAvailable(); | 37 | setExpoViewConfigAvailable(); |
| 38 | expoMocks.requireNativeView.mockReturnValue(nativeView); | 38 | expoMocks.requireNativeView.mockReturnValue(nativeView); |
| 39 | const { resolveNativeReviewDiffView } = await import("./nativeReviewDiffSurface"); | 39 | const { resolveNativeReviewDiffView } = await import("./nativeReviewDiffSurface"); |
| 40 | expect(resolveNativeReviewDiffView()).toBe(nativeView); | 40 | const resolvedView = resolveNativeReviewDiffView(); |
| 41 | expect(resolvedView).not.toBeNull(); | ||
| 42 | expect(resolvedView).not.toBe(nativeView); | ||
| 43 | expect(resolveNativeReviewDiffView()).toBe(resolvedView); | ||
| 41 | expect(expoMocks.requireNativeView).toHaveBeenCalledWith("T3ReviewDiffSurface"); | 44 | expect(expoMocks.requireNativeView).toHaveBeenCalledWith("T3ReviewDiffSurface"); |
| 42 | }); | 45 | }); |
| 43 | 46 | ||
| ... | @@ -78,3 +81,20 @@ describe("resolveNativeReviewDiffView", () => { | ... | @@ -78,3 +81,20 @@ describe("resolveNativeReviewDiffView", () => { |
| 78 | expect(consoleError).toHaveBeenCalledTimes(1); | 81 | expect(consoleError).toHaveBeenCalledTimes(1); |
| 79 | }); | 82 | }); |
| 80 | }); | 83 | }); |
| 84 | |||
| 85 | describe("isPendingNativeViewRegistration", () => { | ||
| 86 | it("recognizes registration races for the installed native view name", async () => { | ||
| 87 | const { isPendingNativeViewRegistration } = await import("./nativeReviewDiffSurface"); | ||
| 88 | |||
| 89 | expect( | ||
| 90 | isPendingNativeViewRegistration( | ||
| 91 | new Error("Unable to find the 'T3ReviewDiffSurface' view for this native tag"), | ||
| 92 | ), | ||
| 93 | ).toBe(true); | ||
| 94 | expect( | ||
| 95 | isPendingNativeViewRegistration( | ||
| 96 | new Error("Unable to find the 'T3ReviewDiffView' view for this native tag"), | ||
| 97 | ), | ||
| 98 | ).toBe(false); | ||
| 99 | }); | ||
| 100 | }); |
apps/mobile/src/features/diffs/nativeReviewDiffSurface.ts+133-6| ... | @@ -1,10 +1,18 @@ | ... | @@ -1,10 +1,18 @@ |
| 1 | import type { ComponentType } from "react"; | 1 | import { |
| 2 | createElement, | ||
| 3 | useEffect, | ||
| 4 | useImperativeHandle, | ||
| 5 | useRef, | ||
| 6 | type ComponentType, | ||
| 7 | type Ref, | ||
| 8 | } from "react"; | ||
| 2 | import type { NativeSyntheticEvent, ViewProps } from "react-native"; | 9 | import type { NativeSyntheticEvent, ViewProps } from "react-native"; |
| 3 | import { requireNativeView } from "expo"; | 10 | import { requireNativeView } from "expo"; |
| 4 | 11 | ||
| 5 | import { NativeViewResolutionError } from "../../native/nativeViewResolutionError"; | 12 | import { NativeViewResolutionError } from "../../native/nativeViewResolutionError"; |
| 6 | 13 | ||
| 7 | const NATIVE_REVIEW_DIFF_MODULE_NAME = "T3ReviewDiffSurface"; | 14 | const NATIVE_REVIEW_DIFF_MODULE_NAME = "T3ReviewDiffSurface"; |
| 15 | const NATIVE_REVIEW_DIFF_PAYLOAD_RETRY_FRAMES = 60; | ||
| 8 | 16 | ||
| 9 | interface ExpoGlobalWithViewConfig { | 17 | interface ExpoGlobalWithViewConfig { |
| 10 | readonly expo?: { | 18 | readonly expo?: { |
| ... | @@ -103,6 +111,7 @@ export interface NativeReviewDiffViewProps extends ViewProps { | ... | @@ -103,6 +111,7 @@ export interface NativeReviewDiffViewProps extends ViewProps { |
| 103 | readonly tokensJson?: string; | 111 | readonly tokensJson?: string; |
| 104 | readonly tokensPatchJson?: string; | 112 | readonly tokensPatchJson?: string; |
| 105 | readonly tokensResetKey?: string; | 113 | readonly tokensResetKey?: string; |
| 114 | readonly contentResetKey?: string; | ||
| 106 | readonly collapsedFileIdsJson?: string; | 115 | readonly collapsedFileIdsJson?: string; |
| 107 | readonly viewedFileIdsJson?: string; | 116 | readonly viewedFileIdsJson?: string; |
| 108 | readonly selectedRowIdsJson?: string; | 117 | readonly selectedRowIdsJson?: string; |
| ... | @@ -113,7 +122,13 @@ export interface NativeReviewDiffViewProps extends ViewProps { | ... | @@ -113,7 +122,13 @@ export interface NativeReviewDiffViewProps extends ViewProps { |
| 113 | readonly rowHeight: number; | 122 | readonly rowHeight: number; |
| 114 | readonly contentWidth: number; | 123 | readonly contentWidth: number; |
| 115 | readonly initialRowIndex?: number; | 124 | readonly initialRowIndex?: number; |
| 125 | readonly refreshing?: boolean; | ||
| 126 | readonly nativeViewRef?: Ref<NativeReviewDiffViewHandle>; | ||
| 127 | readonly onPullToRefresh?: (event: NativeSyntheticEvent<Record<string, never>>) => void; | ||
| 116 | readonly onDebug?: (event: NativeSyntheticEvent<Record<string, unknown>>) => void; | 128 | readonly onDebug?: (event: NativeSyntheticEvent<Record<string, unknown>>) => void; |
| 129 | readonly onVisibleFileChange?: ( | ||
| 130 | event: NativeSyntheticEvent<{ readonly fileId?: string | null }>, | ||
| 131 | ) => void; | ||
| 117 | readonly onToggleFile?: (event: NativeSyntheticEvent<{ readonly fileId?: string }>) => void; | 132 | readonly onToggleFile?: (event: NativeSyntheticEvent<{ readonly fileId?: string }>) => void; |
| 118 | readonly onToggleViewedFile?: (event: NativeSyntheticEvent<{ readonly fileId?: string }>) => void; | 133 | readonly onToggleViewedFile?: (event: NativeSyntheticEvent<{ readonly fileId?: string }>) => void; |
| 119 | readonly onPressLine?: ( | 134 | readonly onPressLine?: ( |
| ... | @@ -129,18 +144,130 @@ export interface NativeReviewDiffViewProps extends ViewProps { | ... | @@ -129,18 +144,130 @@ export interface NativeReviewDiffViewProps extends ViewProps { |
| 129 | readonly onToggleComment?: (event: NativeSyntheticEvent<{ readonly commentId?: string }>) => void; | 144 | readonly onToggleComment?: (event: NativeSyntheticEvent<{ readonly commentId?: string }>) => void; |
| 130 | } | 145 | } |
| 131 | 146 | ||
| 132 | let cachedNativeReviewDiffView: ComponentType<NativeReviewDiffViewProps> | undefined; | 147 | export interface NativeReviewDiffViewHandle { |
| 148 | readonly scrollToFile: (fileId: string, animated?: boolean) => Promise<void>; | ||
| 149 | readonly scrollToTop: (animated?: boolean) => Promise<void>; | ||
| 150 | } | ||
| 151 | |||
| 152 | interface NativeReviewDiffViewRef { | ||
| 153 | readonly setRowsJson: (rowsJson: string) => Promise<void>; | ||
| 154 | readonly setTokensJson: (tokensJson: string) => Promise<void>; | ||
| 155 | readonly setTokensPatchJson: (tokensPatchJson: string) => Promise<void>; | ||
| 156 | readonly scrollToFile: (fileId: string, animated: boolean) => Promise<void>; | ||
| 157 | readonly scrollToTop: (animated: boolean) => Promise<void>; | ||
| 158 | } | ||
| 159 | |||
| 160 | type NativeReviewDiffRawViewProps = Omit< | ||
| 161 | NativeReviewDiffViewProps, | ||
| 162 | "nativeViewRef" | "rowsJson" | "tokensJson" | "tokensPatchJson" | ||
| 163 | > & { | ||
| 164 | readonly ref?: Ref<NativeReviewDiffViewRef>; | ||
| 165 | }; | ||
| 166 | |||
| 167 | let cachedNativeReviewDiffRawView: ComponentType<NativeReviewDiffRawViewProps> | undefined; | ||
| 133 | let nativeReviewDiffViewResolutionFailed = false; | 168 | let nativeReviewDiffViewResolutionFailed = false; |
| 134 | 169 | ||
| 170 | type NativeReviewDiffPayloadMethod = "setRowsJson" | "setTokensJson" | "setTokensPatchJson"; | ||
| 171 | |||
| 172 | export function isPendingNativeViewRegistration(error: unknown): boolean { | ||
| 173 | return ( | ||
| 174 | error instanceof Error && | ||
| 175 | error.message.includes(`Unable to find the '${NATIVE_REVIEW_DIFF_MODULE_NAME}' view`) | ||
| 176 | ); | ||
| 177 | } | ||
| 178 | |||
| 179 | function useNativeReviewDiffPayload( | ||
| 180 | nativeRef: React.RefObject<NativeReviewDiffViewRef | null>, | ||
| 181 | method: NativeReviewDiffPayloadMethod, | ||
| 182 | payload: string | undefined, | ||
| 183 | ) { | ||
| 184 | useEffect(() => { | ||
| 185 | if (payload === undefined) { | ||
| 186 | return; | ||
| 187 | } | ||
| 188 | |||
| 189 | let cancelled = false; | ||
| 190 | let frame: number | null = null; | ||
| 191 | let attempts = 0; | ||
| 192 | |||
| 193 | const dispatch = () => { | ||
| 194 | if (cancelled) { | ||
| 195 | return; | ||
| 196 | } | ||
| 197 | |||
| 198 | const view = nativeRef.current; | ||
| 199 | const command = view?.[method]; | ||
| 200 | if (!view || !command) { | ||
| 201 | if (attempts < NATIVE_REVIEW_DIFF_PAYLOAD_RETRY_FRAMES) { | ||
| 202 | attempts += 1; | ||
| 203 | frame = requestAnimationFrame(dispatch); | ||
| 204 | } | ||
| 205 | return; | ||
| 206 | } | ||
| 207 | |||
| 208 | void command.call(view, payload).catch((error: unknown) => { | ||
| 209 | if ( | ||
| 210 | !cancelled && | ||
| 211 | attempts < NATIVE_REVIEW_DIFF_PAYLOAD_RETRY_FRAMES && | ||
| 212 | isPendingNativeViewRegistration(error) | ||
| 213 | ) { | ||
| 214 | attempts += 1; | ||
| 215 | frame = requestAnimationFrame(dispatch); | ||
| 216 | return; | ||
| 217 | } | ||
| 218 | console.error(`[native-review-diff] ${method} failed`, error); | ||
| 219 | }); | ||
| 220 | }; | ||
| 221 | |||
| 222 | // Fabric attaches the React ref before Expo registers the native tag used by | ||
| 223 | // view functions. Starting on the next frame avoids racing that registration. | ||
| 224 | frame = requestAnimationFrame(dispatch); | ||
| 225 | |||
| 226 | return () => { | ||
| 227 | cancelled = true; | ||
| 228 | if (frame !== null) { | ||
| 229 | cancelAnimationFrame(frame); | ||
| 230 | } | ||
| 231 | }; | ||
| 232 | }, [method, nativeRef, payload]); | ||
| 233 | } | ||
| 234 | |||
| 135 | function getExpoViewConfig(moduleName: string) { | 235 | function getExpoViewConfig(moduleName: string) { |
| 136 | return (globalThis as typeof globalThis & ExpoGlobalWithViewConfig).expo?.getViewConfig?.( | 236 | return (globalThis as typeof globalThis & ExpoGlobalWithViewConfig).expo?.getViewConfig?.( |
| 137 | moduleName, | 237 | moduleName, |
| 138 | ); | 238 | ); |
| 139 | } | 239 | } |
| 140 | 240 | ||
| 241 | function NativeReviewDiffView(props: NativeReviewDiffViewProps) { | ||
| 242 | const { nativeViewRef, rowsJson, tokensJson, tokensPatchJson, ...nativeProps } = props; | ||
| 243 | const nativeRef = useRef<NativeReviewDiffViewRef>(null); | ||
| 244 | useNativeReviewDiffPayload(nativeRef, "setRowsJson", rowsJson); | ||
| 245 | useNativeReviewDiffPayload(nativeRef, "setTokensJson", tokensJson); | ||
| 246 | useNativeReviewDiffPayload(nativeRef, "setTokensPatchJson", tokensPatchJson); | ||
| 247 | useImperativeHandle( | ||
| 248 | nativeViewRef, | ||
| 249 | () => ({ | ||
| 250 | scrollToFile: async (fileId, animated = true) => { | ||
| 251 | await nativeRef.current?.scrollToFile(fileId, animated); | ||
| 252 | }, | ||
| 253 | scrollToTop: async (animated = true) => { | ||
| 254 | await nativeRef.current?.scrollToTop(animated); | ||
| 255 | }, | ||
| 256 | }), | ||
| 257 | [], | ||
| 258 | ); | ||
| 259 | |||
| 260 | const RawNativeView = cachedNativeReviewDiffRawView; | ||
| 261 | if (!RawNativeView) { | ||
| 262 | return null; | ||
| 263 | } | ||
| 264 | |||
| 265 | return createElement(RawNativeView, { ...nativeProps, ref: nativeRef }); | ||
| 266 | } | ||
| 267 | |||
| 141 | export function resolveNativeReviewDiffView(): ComponentType<NativeReviewDiffViewProps> | null { | 268 | export function resolveNativeReviewDiffView(): ComponentType<NativeReviewDiffViewProps> | null { |
| 142 | if (cachedNativeReviewDiffView) { | 269 | if (cachedNativeReviewDiffRawView) { |
| 143 | return cachedNativeReviewDiffView; | 270 | return NativeReviewDiffView; |
| 144 | } | 271 | } |
| 145 | 272 | ||
| 146 | if (nativeReviewDiffViewResolutionFailed) { | 273 | if (nativeReviewDiffViewResolutionFailed) { |
| ... | @@ -152,7 +279,7 @@ export function resolveNativeReviewDiffView(): ComponentType<NativeReviewDiffVie | ... | @@ -152,7 +279,7 @@ export function resolveNativeReviewDiffView(): ComponentType<NativeReviewDiffVie |
| 152 | } | 279 | } |
| 153 | 280 | ||
| 154 | try { | 281 | try { |
| 155 | cachedNativeReviewDiffView = requireNativeView<NativeReviewDiffViewProps>( | 282 | cachedNativeReviewDiffRawView = requireNativeView<NativeReviewDiffRawViewProps>( |
| 156 | NATIVE_REVIEW_DIFF_MODULE_NAME, | 283 | NATIVE_REVIEW_DIFF_MODULE_NAME, |
| 157 | ); | 284 | ); |
| 158 | } catch (cause) { | 285 | } catch (cause) { |
| ... | @@ -166,5 +293,5 @@ export function resolveNativeReviewDiffView(): ComponentType<NativeReviewDiffVie | ... | @@ -166,5 +293,5 @@ export function resolveNativeReviewDiffView(): ComponentType<NativeReviewDiffVie |
| 166 | return null; | 293 | return null; |
| 167 | } | 294 | } |
| 168 | 295 | ||
| 169 | return cachedNativeReviewDiffView ?? null; | 296 | return cachedNativeReviewDiffRawView ? NativeReviewDiffView : null; |
| 170 | } | 297 | } |
apps/mobile/src/features/files/FileMarkdownPreview.tsx+51-7| ... | @@ -1,15 +1,19 @@ | ... | @@ -1,15 +1,19 @@ |
| 1 | import { useCallback, useMemo } from "react"; | 1 | import { useCallback, useMemo, useState } from "react"; |
| 2 | import { | 2 | import { |
| 3 | Markdown, | 3 | Markdown, |
| 4 | type CustomRenderers, | 4 | type CustomRenderers, |
| 5 | type NodeStyleOverrides, | 5 | type NodeStyleOverrides, |
| 6 | type PartialMarkdownTheme, | 6 | type PartialMarkdownTheme, |
| 7 | } from "react-native-nitro-markdown"; | 7 | } from "react-native-nitro-markdown"; |
| 8 | import { ScrollView, Text as NativeText, View } from "react-native"; | 8 | import { RefreshControl, ScrollView, Text as NativeText, View } from "react-native"; |
| 9 | 9 | ||
| 10 | import { tryOpenExternalUrl } from "../../lib/openExternalUrl"; | 10 | import { tryOpenExternalUrl } from "../../lib/openExternalUrl"; |
| 11 | import { MOBILE_TYPOGRAPHY } from "../../lib/typography"; | 11 | import { |
| 12 | resolveMarkdownFontSizes, | ||
| 13 | resolveNativeMarkdownTypography, | ||
| 14 | } from "../../lib/appearancePreferences"; | ||
| 12 | import { useThemeColor } from "../../lib/useThemeColor"; | 15 | import { useThemeColor } from "../../lib/useThemeColor"; |
| 16 | import { useAppearancePreferences } from "../settings/appearance/AppearancePreferencesProvider"; | ||
| 13 | import { | 17 | import { |
| 14 | hasNativeSelectableMarkdownText, | 18 | hasNativeSelectableMarkdownText, |
| 15 | SelectableMarkdownText, | 19 | SelectableMarkdownText, |
| ... | @@ -24,6 +28,15 @@ interface MarkdownPreviewStyles { | ... | @@ -24,6 +28,15 @@ interface MarkdownPreviewStyles { |
| 24 | } | 28 | } |
| 25 | 29 | ||
| 26 | function useMarkdownPreviewStyles(): MarkdownPreviewStyles { | 30 | function useMarkdownPreviewStyles(): MarkdownPreviewStyles { |
| 31 | const { appearance } = useAppearancePreferences(); | ||
| 32 | const markdownFontSizes = useMemo( | ||
| 33 | () => resolveMarkdownFontSizes(appearance.baseFontSize), | ||
| 34 | [appearance.baseFontSize], | ||
| 35 | ); | ||
| 36 | const nativeMarkdownTypography = useMemo( | ||
| 37 | () => resolveNativeMarkdownTypography(appearance.baseFontSize), | ||
| 38 | [appearance.baseFontSize], | ||
| 39 | ); | ||
| 27 | const body = String(useThemeColor("--color-md-body")); | 40 | const body = String(useThemeColor("--color-md-body")); |
| 28 | const strong = String(useThemeColor("--color-md-strong")); | 41 | const strong = String(useThemeColor("--color-md-strong")); |
| 29 | const link = String(useThemeColor("--color-md-link")); | 42 | const link = String(useThemeColor("--color-md-link")); |
| ... | @@ -74,7 +87,8 @@ function useMarkdownPreviewStyles(): MarkdownPreviewStyles { | ... | @@ -74,7 +87,8 @@ function useMarkdownPreviewStyles(): MarkdownPreviewStyles { |
| 74 | text: { | 87 | text: { |
| 75 | color: body, | 88 | color: body, |
| 76 | fontFamily: "DMSans_400Regular", | 89 | fontFamily: "DMSans_400Regular", |
| 77 | ...MOBILE_TYPOGRAPHY.body, | 90 | fontSize: markdownFontSizes.m, |
| 91 | lineHeight: markdownFontSizes.bodyLineHeight, | ||
| 78 | }, | 92 | }, |
| 79 | heading: { | 93 | heading: { |
| 80 | color: strong, | 94 | color: strong, |
| ... | @@ -124,7 +138,9 @@ function useMarkdownPreviewStyles(): MarkdownPreviewStyles { | ... | @@ -124,7 +138,9 @@ function useMarkdownPreviewStyles(): MarkdownPreviewStyles { |
| 124 | skillTextColor: codeText, | 138 | skillTextColor: codeText, |
| 125 | quoteMarkerColor: blockquoteBorder, | 139 | quoteMarkerColor: blockquoteBorder, |
| 126 | dividerColor: horizontalRule, | 140 | dividerColor: horizontalRule, |
| 127 | ...MOBILE_TYPOGRAPHY.body, | 141 | fontSize: nativeMarkdownTypography.fontSize, |
| 142 | lineHeight: nativeMarkdownTypography.lineHeight, | ||
| 143 | headingFontSizes: nativeMarkdownTypography.headingFontSizes, | ||
| 128 | fontFamily: "DMSans_400Regular", | 144 | fontFamily: "DMSans_400Regular", |
| 129 | headingFontFamily: "DMSans_700Bold", | 145 | headingFontFamily: "DMSans_700Bold", |
| 130 | boldFontFamily: "DMSans_700Bold", | 146 | boldFontFamily: "DMSans_700Bold", |
| ... | @@ -138,18 +154,46 @@ function useMarkdownPreviewStyles(): MarkdownPreviewStyles { | ... | @@ -138,18 +154,46 @@ function useMarkdownPreviewStyles(): MarkdownPreviewStyles { |
| 138 | codeText, | 154 | codeText, |
| 139 | horizontalRule, | 155 | horizontalRule, |
| 140 | link, | 156 | link, |
| 157 | markdownFontSizes, | ||
| 158 | nativeMarkdownTypography, | ||
| 141 | strong, | 159 | strong, |
| 142 | ]); | 160 | ]); |
| 143 | } | 161 | } |
| 144 | 162 | ||
| 145 | export function FileMarkdownPreview(props: { readonly markdown: string }) { | 163 | export function FileMarkdownPreview(props: { |
| 164 | readonly markdown: string; | ||
| 165 | readonly onRefresh?: () => Promise<void> | void; | ||
| 166 | }) { | ||
| 167 | const [isPullRefreshing, setIsPullRefreshing] = useState(false); | ||
| 168 | const handlePullToRefresh = useCallback(async () => { | ||
| 169 | if (!props.onRefresh) { | ||
| 170 | return; | ||
| 171 | } | ||
| 172 | setIsPullRefreshing(true); | ||
| 173 | try { | ||
| 174 | await props.onRefresh(); | ||
| 175 | } finally { | ||
| 176 | setIsPullRefreshing(false); | ||
| 177 | } | ||
| 178 | }, [props.onRefresh]); | ||
| 146 | const styles = useMarkdownPreviewStyles(); | 179 | const styles = useMarkdownPreviewStyles(); |
| 147 | const onLinkPress = useCallback((href: string) => { | 180 | const onLinkPress = useCallback((href: string) => { |
| 148 | void tryOpenExternalUrl(href, "markdown-link"); | 181 | void tryOpenExternalUrl(href, "markdown-link"); |
| 149 | }, []); | 182 | }, []); |
| 150 | 183 | ||
| 151 | return ( | 184 | return ( |
| 152 | <ScrollView className="flex-1 bg-card" contentContainerStyle={{ padding: 18 }}> | 185 | <ScrollView |
| 186 | className="flex-1 bg-sheet" | ||
| 187 | contentContainerStyle={{ padding: 18 }} | ||
| 188 | refreshControl={ | ||
| 189 | props.onRefresh ? ( | ||
| 190 | <RefreshControl | ||
| 191 | refreshing={isPullRefreshing} | ||
| 192 | onRefresh={() => void handlePullToRefresh()} | ||
| 193 | /> | ||
| 194 | ) : undefined | ||
| 195 | } | ||
| 196 | > | ||
| 153 | <View className="mx-auto w-full max-w-[760px]"> | 197 | <View className="mx-auto w-full max-w-[760px]"> |
| 154 | {hasNativeSelectableMarkdownText() ? ( | 198 | {hasNativeSelectableMarkdownText() ? ( |
| 155 | <SelectableMarkdownText | 199 | <SelectableMarkdownText |
apps/mobile/src/features/files/FileTreeBrowser.tsx+149-55| ... | @@ -1,7 +1,15 @@ | ... | @@ -1,7 +1,15 @@ |
| 1 | import type { ProjectEntry } from "@t3tools/contracts"; | 1 | import type { ProjectEntry } from "@t3tools/contracts"; |
| 2 | import { SymbolView } from "expo-symbols"; | 2 | import { SymbolView } from "expo-symbols"; |
| 3 | import { memo, useCallback, useEffect, useMemo, useState } from "react"; | 3 | import { memo, useCallback, useEffect, useMemo, useRef, useState } from "react"; |
| 4 | import { ActivityIndicator, FlatList, Pressable, RefreshControl, View } from "react-native"; | 4 | import { |
| 5 | ActivityIndicator, | ||
| 6 | FlatList, | ||
| 7 | Platform, | ||
| 8 | Pressable, | ||
| 9 | RefreshControl, | ||
| 10 | View, | ||
| 11 | } from "react-native"; | ||
| 12 | import { useSafeAreaInsets } from "react-native-safe-area-context"; | ||
| 5 | 13 | ||
| 6 | import { AppText as Text } from "../../components/AppText"; | 14 | import { AppText as Text } from "../../components/AppText"; |
| 7 | import { PierreEntryIcon } from "../../components/PierreEntryIcon"; | 15 | import { PierreEntryIcon } from "../../components/PierreEntryIcon"; |
| ... | @@ -11,9 +19,25 @@ import { | ... | @@ -11,9 +19,25 @@ import { |
| 11 | buildFileTree, | 19 | buildFileTree, |
| 12 | defaultExpandedTreePaths, | 20 | defaultExpandedTreePaths, |
| 13 | flattenFileTree, | 21 | flattenFileTree, |
| 22 | type FileTreeNode, | ||
| 14 | type VisibleFileTreeNode, | 23 | type VisibleFileTreeNode, |
| 15 | } from "./fileTree"; | 24 | } from "./fileTree"; |
| 16 | 25 | ||
| 26 | const fileTreeCache = new WeakMap<ReadonlyArray<ProjectEntry>, ReadonlyArray<FileTreeNode>>(); | ||
| 27 | const FILE_TREE_INITIAL_RENDER_COUNT = 20; | ||
| 28 | const FILE_TREE_RENDER_BATCH_SIZE = 12; | ||
| 29 | const OPTIMISTIC_SELECTION_TIMEOUT_MS = 1_000; | ||
| 30 | |||
| 31 | function cachedFileTree(entries: ReadonlyArray<ProjectEntry>): ReadonlyArray<FileTreeNode> { | ||
| 32 | const cached = fileTreeCache.get(entries); | ||
| 33 | if (cached !== undefined) { | ||
| 34 | return cached; | ||
| 35 | } | ||
| 36 | const tree = buildFileTree(entries); | ||
| 37 | fileTreeCache.set(entries, tree); | ||
| 38 | return tree; | ||
| 39 | } | ||
| 40 | |||
| 17 | function ancestorPaths(path: string): ReadonlyArray<string> { | 41 | function ancestorPaths(path: string): ReadonlyArray<string> { |
| 18 | const parts = path.split("/").filter(Boolean); | 42 | const parts = path.split("/").filter(Boolean); |
| 19 | const ancestors: string[] = []; | 43 | const ancestors: string[] = []; |
| ... | @@ -25,19 +49,24 @@ function ancestorPaths(path: string): ReadonlyArray<string> { | ... | @@ -25,19 +49,24 @@ function ancestorPaths(path: string): ReadonlyArray<string> { |
| 25 | 49 | ||
| 26 | const FileTreeRow = memo(function FileTreeRow(props: { | 50 | const FileTreeRow = memo(function FileTreeRow(props: { |
| 27 | readonly item: VisibleFileTreeNode; | 51 | readonly item: VisibleFileTreeNode; |
| 28 | readonly selectedPath: string | null; | 52 | readonly selected: boolean; |
| 29 | readonly expanded: boolean; | 53 | readonly expanded: boolean; |
| 30 | readonly iconColor: string; | 54 | readonly iconColor: string; |
| 31 | readonly onPressDirectory: (path: string) => void; | 55 | readonly onPressDirectory: (path: string) => void; |
| 56 | readonly onPreviewFile?: (path: string) => void; | ||
| 32 | readonly onPressFile: (path: string) => void; | 57 | readonly onPressFile: (path: string) => void; |
| 33 | }) { | 58 | }) { |
| 34 | const { node, depth } = props.item; | 59 | const { node, depth } = props.item; |
| 35 | const selected = node.kind === "file" && node.path === props.selectedPath; | ||
| 36 | 60 | ||
| 37 | return ( | 61 | return ( |
| 38 | <Pressable | 62 | <Pressable |
| 39 | accessibilityRole="button" | 63 | accessibilityRole="button" |
| 40 | accessibilityLabel={node.path} | 64 | accessibilityLabel={node.path} |
| 65 | onPressIn={() => { | ||
| 66 | if (node.kind === "file") { | ||
| 67 | props.onPreviewFile?.(node.path); | ||
| 68 | } | ||
| 69 | }} | ||
| 41 | onPress={() => { | 70 | onPress={() => { |
| 42 | if (node.kind === "directory") { | 71 | if (node.kind === "directory") { |
| 43 | props.onPressDirectory(node.path); | 72 | props.onPressDirectory(node.path); |
| ... | @@ -47,7 +76,7 @@ const FileTreeRow = memo(function FileTreeRow(props: { | ... | @@ -47,7 +76,7 @@ const FileTreeRow = memo(function FileTreeRow(props: { |
| 47 | }} | 76 | }} |
| 48 | className={cn( | 77 | className={cn( |
| 49 | "mx-2 min-h-[42px] flex-row items-center gap-2 rounded-[12px] px-2 active:bg-subtle", | 78 | "mx-2 min-h-[42px] flex-row items-center gap-2 rounded-[12px] px-2 active:bg-subtle", |
| 50 | selected && "bg-subtle-strong", | 79 | props.selected && "bg-subtle-strong", |
| 51 | )} | 80 | )} |
| 52 | style={{ paddingLeft: 8 + depth * 18 }} | 81 | style={{ paddingLeft: 8 + depth * 18 }} |
| 53 | > | 82 | > |
| ... | @@ -64,8 +93,10 @@ const FileTreeRow = memo(function FileTreeRow(props: { | ... | @@ -64,8 +93,10 @@ const FileTreeRow = memo(function FileTreeRow(props: { |
| 64 | <PierreEntryIcon path={node.path} kind={node.kind} size={17} /> | 93 | <PierreEntryIcon path={node.path} kind={node.kind} size={17} /> |
| 65 | <Text | 94 | <Text |
| 66 | className={cn( | 95 | className={cn( |
| 67 | "min-w-0 flex-1 text-sm leading-[19px]", | 96 | "min-w-0 flex-1 text-sm leading-normal", |
| 68 | selected ? "font-t3-bold text-foreground" : "font-t3-medium text-foreground-secondary", | 97 | props.selected |
| 98 | ? "font-t3-bold text-foreground" | ||
| 99 | : "font-t3-medium text-foreground-secondary", | ||
| 69 | )} | 100 | )} |
| 70 | numberOfLines={1} | 101 | numberOfLines={1} |
| 71 | > | 102 | > |
| ... | @@ -86,13 +117,30 @@ export function FileTreeBrowser(props: { | ... | @@ -86,13 +117,30 @@ export function FileTreeBrowser(props: { |
| 86 | readonly isPending: boolean; | 117 | readonly isPending: boolean; |
| 87 | readonly searchQuery: string; | 118 | readonly searchQuery: string; |
| 88 | readonly selectedPath: string | null; | 119 | readonly selectedPath: string | null; |
| 120 | readonly onPreviewFile?: (path: string) => void; | ||
| 89 | readonly onRefresh: () => void; | 121 | readonly onRefresh: () => void; |
| 90 | readonly onSelectFile: (path: string) => void; | 122 | readonly onSelectFile: (path: string) => void; |
| 91 | }) { | 123 | }) { |
| 92 | const [expandedPaths, setExpandedPaths] = useState<ReadonlySet<string>>(() => new Set()); | 124 | const [expandedPaths, setExpandedPaths] = useState<ReadonlySet<string>>(() => new Set()); |
| 125 | const [pendingSelection, setPendingSelection] = useState<{ | ||
| 126 | readonly path: string; | ||
| 127 | readonly selectedPathAtPress: string | null; | ||
| 128 | } | null>(null); | ||
| 129 | const insets = useSafeAreaInsets(); | ||
| 130 | // Native transparent-header height ≈ safe-area top + nav bar (~44). Matches the | ||
| 131 | // observed adjustedContentInset bottom (~102) seen in the native trace. | ||
| 132 | const headerInset = Platform.OS === "ios" ? insets.top + 44 : 0; | ||
| 93 | const iconColor = String(useThemeColor("--color-icon-muted")); | 133 | const iconColor = String(useThemeColor("--color-icon-muted")); |
| 134 | const { onPreviewFile, onSelectFile, selectedPath: controlledSelectedPath } = props; | ||
| 135 | const controlledSelectedPathRef = useRef(controlledSelectedPath); | ||
| 136 | const pendingSelectionTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null); | ||
| 137 | controlledSelectedPathRef.current = controlledSelectedPath; | ||
| 94 | 138 | ||
| 95 | const tree = useMemo(() => buildFileTree(props.entries), [props.entries]); | 139 | const selectedPath = |
| 140 | pendingSelection?.selectedPathAtPress === controlledSelectedPath | ||
| 141 | ? pendingSelection.path | ||
| 142 | : controlledSelectedPath; | ||
| 143 | const tree = useMemo(() => cachedFileTree(props.entries), [props.entries]); | ||
| 96 | const defaultExpanded = useMemo(() => defaultExpandedTreePaths(tree), [tree]); | 144 | const defaultExpanded = useMemo(() => defaultExpandedTreePaths(tree), [tree]); |
| 97 | const visibleNodes = useMemo( | 145 | const visibleNodes = useMemo( |
| 98 | () => | 146 | () => |
| ... | @@ -114,17 +162,30 @@ export function FileTreeBrowser(props: { | ... | @@ -114,17 +162,30 @@ export function FileTreeBrowser(props: { |
| 114 | }, [defaultExpanded]); | 162 | }, [defaultExpanded]); |
| 115 | 163 | ||
| 116 | useEffect(() => { | 164 | useEffect(() => { |
| 117 | if (!props.selectedPath) { | 165 | if (!controlledSelectedPath) { |
| 118 | return; | 166 | return; |
| 119 | } | 167 | } |
| 120 | setExpandedPaths((current) => { | 168 | setExpandedPaths((current) => { |
| 169 | const ancestors = ancestorPaths(controlledSelectedPath); | ||
| 170 | if (ancestors.every((ancestor) => current.has(ancestor))) { | ||
| 171 | return current; | ||
| 172 | } | ||
| 121 | const next = new Set(current); | 173 | const next = new Set(current); |
| 122 | for (const ancestor of ancestorPaths(props.selectedPath ?? "")) { | 174 | for (const ancestor of ancestors) { |
| 123 | next.add(ancestor); | 175 | next.add(ancestor); |
| 124 | } | 176 | } |
| 125 | return next; | 177 | return next; |
| 126 | }); | 178 | }); |
| 127 | }, [props.selectedPath]); | 179 | }, [controlledSelectedPath]); |
| 180 | |||
| 181 | useEffect( | ||
| 182 | () => () => { | ||
| 183 | if (pendingSelectionTimeoutRef.current !== null) { | ||
| 184 | clearTimeout(pendingSelectionTimeoutRef.current); | ||
| 185 | } | ||
| 186 | }, | ||
| 187 | [], | ||
| 188 | ); | ||
| 128 | 189 | ||
| 129 | const toggleDirectory = useCallback((path: string) => { | 190 | const toggleDirectory = useCallback((path: string) => { |
| 130 | setExpandedPaths((current) => { | 191 | setExpandedPaths((current) => { |
| ... | @@ -137,53 +198,86 @@ export function FileTreeBrowser(props: { | ... | @@ -137,53 +198,86 @@ export function FileTreeBrowser(props: { |
| 137 | return next; | 198 | return next; |
| 138 | }); | 199 | }); |
| 139 | }, []); | 200 | }, []); |
| 201 | const handleSelectFile = useCallback( | ||
| 202 | (path: string) => { | ||
| 203 | if (pendingSelectionTimeoutRef.current !== null) { | ||
| 204 | clearTimeout(pendingSelectionTimeoutRef.current); | ||
| 205 | } | ||
| 206 | setPendingSelection({ | ||
| 207 | path, | ||
| 208 | selectedPathAtPress: controlledSelectedPathRef.current, | ||
| 209 | }); | ||
| 210 | pendingSelectionTimeoutRef.current = setTimeout(() => { | ||
| 211 | pendingSelectionTimeoutRef.current = null; | ||
| 212 | setPendingSelection((current) => (current?.path === path ? null : current)); | ||
| 213 | }, OPTIMISTIC_SELECTION_TIMEOUT_MS); | ||
| 214 | onSelectFile(path); | ||
| 215 | }, | ||
| 216 | [onSelectFile], | ||
| 217 | ); | ||
| 218 | const renderItem = useCallback( | ||
| 219 | ({ item }: { readonly item: VisibleFileTreeNode }) => ( | ||
| 220 | <FileTreeRow | ||
| 221 | item={item} | ||
| 222 | selected={item.node.kind === "file" && item.node.path === selectedPath} | ||
| 223 | expanded={expandedPaths.has(item.node.path)} | ||
| 224 | iconColor={iconColor} | ||
| 225 | onPressDirectory={toggleDirectory} | ||
| 226 | onPreviewFile={onPreviewFile} | ||
| 227 | onPressFile={handleSelectFile} | ||
| 228 | /> | ||
| 229 | ), | ||
| 230 | [expandedPaths, handleSelectFile, iconColor, onPreviewFile, selectedPath, toggleDirectory], | ||
| 231 | ); | ||
| 140 | 232 | ||
| 233 | if (props.error && props.entries.length === 0) { | ||
| 234 | return ( | ||
| 235 | <View className="flex-1 bg-sheet px-4 py-5"> | ||
| 236 | <Text className="text-sm font-t3-bold text-foreground">Files unavailable</Text> | ||
| 237 | <Text className="mt-1 text-xs leading-normal text-foreground-muted">{props.error}</Text> | ||
| 238 | </View> | ||
| 239 | ); | ||
| 240 | } | ||
| 241 | |||
| 242 | // SPIKE: render the FlatList as the screen's DIRECT content (no wrapping View), and | ||
| 243 | // mirror the Home ScrollView exactly — `contentInsetAdjustmentBehavior: "automatic"` | ||
| 244 | // with NO manual contentInset. iOS only applies the nav-bar top inset + scroll-edge | ||
| 245 | // blur to a scroll view in the screen's primary position; a scroll view buried in | ||
| 246 | // flex-1 Views is ignored, which is why the tree rendered under the header with no blur. | ||
| 141 | return ( | 247 | return ( |
| 142 | <View className="flex-1 bg-sheet"> | 248 | <FlatList |
| 143 | {props.error && props.entries.length === 0 ? ( | 249 | style={{ flex: 1 }} |
| 250 | data={visibleNodes} | ||
| 251 | keyExtractor={(item) => item.node.path} | ||
| 252 | contentInsetAdjustmentBehavior={Platform.OS === "ios" ? "automatic" : "never"} | ||
| 253 | scrollIndicatorInsets={ | ||
| 254 | Platform.OS === "ios" ? { top: headerInset, left: 0, right: 0, bottom: 0 } : undefined | ||
| 255 | } | ||
| 256 | keyboardDismissMode="on-drag" | ||
| 257 | keyboardShouldPersistTaps="handled" | ||
| 258 | initialNumToRender={FILE_TREE_INITIAL_RENDER_COUNT} | ||
| 259 | maxToRenderPerBatch={FILE_TREE_RENDER_BATCH_SIZE} | ||
| 260 | updateCellsBatchingPeriod={16} | ||
| 261 | windowSize={5} | ||
| 262 | contentContainerStyle={{ paddingTop: 8, paddingBottom: 8 }} | ||
| 263 | refreshControl={<RefreshControl refreshing={props.isPending} onRefresh={props.onRefresh} />} | ||
| 264 | renderItem={renderItem} | ||
| 265 | ListEmptyComponent={ | ||
| 144 | <View className="px-4 py-5"> | 266 | <View className="px-4 py-5"> |
| 145 | <Text className="text-sm font-t3-bold text-foreground">Files unavailable</Text> | 267 | {props.isPending ? ( |
| 146 | <Text className="mt-1 text-xs leading-[18px] text-foreground-muted">{props.error}</Text> | 268 | <ActivityIndicator size="small" /> |
| 147 | </View> | 269 | ) : ( |
| 148 | ) : ( | 270 | <> |
| 149 | <FlatList | 271 | <Text className="text-sm font-t3-bold text-foreground">No files found</Text> |
| 150 | data={visibleNodes} | 272 | <Text className="mt-1 text-xs leading-normal text-foreground-muted"> |
| 151 | keyExtractor={(item) => item.node.path} | 273 | {props.searchQuery.trim().length > 0 |
| 152 | contentInsetAdjustmentBehavior="automatic" | 274 | ? "Try a different search." |
| 153 | keyboardDismissMode="on-drag" | 275 | : "The workspace file index is empty."} |
| 154 | keyboardShouldPersistTaps="handled" | 276 | </Text> |
| 155 | contentContainerStyle={{ paddingVertical: 8 }} | 277 | </> |
| 156 | refreshControl={ | ||
| 157 | <RefreshControl refreshing={props.isPending} onRefresh={props.onRefresh} /> | ||
| 158 | } | ||
| 159 | renderItem={({ item }) => ( | ||
| 160 | <FileTreeRow | ||
| 161 | item={item} | ||
| 162 | selectedPath={props.selectedPath} | ||
| 163 | expanded={expandedPaths.has(item.node.path)} | ||
| 164 | iconColor={iconColor} | ||
| 165 | onPressDirectory={toggleDirectory} | ||
| 166 | onPressFile={props.onSelectFile} | ||
| 167 | /> | ||
| 168 | )} | 278 | )} |
| 169 | ListEmptyComponent={ | 279 | </View> |
| 170 | <View className="px-4 py-5"> | 280 | } |
| 171 | {props.isPending ? ( | 281 | /> |
| 172 | <ActivityIndicator size="small" /> | ||
| 173 | ) : ( | ||
| 174 | <> | ||
| 175 | <Text className="text-sm font-t3-bold text-foreground">No files found</Text> | ||
| 176 | <Text className="mt-1 text-xs leading-[18px] text-foreground-muted"> | ||
| 177 | {props.searchQuery.trim().length > 0 | ||
| 178 | ? "Try a different search." | ||
| 179 | : "The workspace file index is empty."} | ||
| 180 | </Text> | ||
| 181 | </> | ||
| 182 | )} | ||
| 183 | </View> | ||
| 184 | } | ||
| 185 | /> | ||
| 186 | )} | ||
| 187 | </View> | ||
| 188 | ); | 282 | ); |
| 189 | } | 283 | } |
apps/mobile/src/features/files/SourceFileSurface.tsx+98-65| ... | @@ -1,8 +1,15 @@ | ... | @@ -1,8 +1,15 @@ |
| 1 | import { useAtomValue } from "@effect/atom-react"; | 1 | import { useAtomValue } from "@effect/atom-react"; |
| 2 | import { AsyncResult } from "effect/unstable/reactivity"; | 2 | import { AsyncResult } from "effect/unstable/reactivity"; |
| 3 | import type { ComponentType } from "react"; | 3 | import type { ComponentType } from "react"; |
| 4 | import { memo, useCallback, useEffect, useMemo, useRef } from "react"; | 4 | import { memo, useCallback, useEffect, useMemo, useRef, useState } from "react"; |
| 5 | import { FlatList, ScrollView, Text as NativeText, useColorScheme, View } from "react-native"; | 5 | import { |
| 6 | FlatList, | ||
| 7 | ScrollView, | ||
| 8 | Text as NativeText, | ||
| 9 | useColorScheme, | ||
| 10 | useWindowDimensions, | ||
| 11 | View, | ||
| 12 | } from "react-native"; | ||
| 6 | 13 | ||
| 7 | import { AppText as Text } from "../../components/AppText"; | 14 | import { AppText as Text } from "../../components/AppText"; |
| 8 | import { LoadingStrip } from "../../components/LoadingStrip"; | 15 | import { LoadingStrip } from "../../components/LoadingStrip"; |
| ... | @@ -11,71 +18,62 @@ import { | ... | @@ -11,71 +18,62 @@ import { |
| 11 | resolveNativeReviewDiffView, | 18 | resolveNativeReviewDiffView, |
| 12 | } from "../diffs/nativeReviewDiffSurface"; | 19 | } from "../diffs/nativeReviewDiffSurface"; |
| 13 | import { createNativeReviewDiffTheme } from "../review/nativeReviewDiffAdapter"; | 20 | import { createNativeReviewDiffTheme } from "../review/nativeReviewDiffAdapter"; |
| 14 | import { | 21 | import { REVIEW_MONO_FONT_FAMILY, renderVisibleWhitespace } from "../review/reviewDiffRendering"; |
| 15 | REVIEW_DIFF_LINE_HEIGHT, | ||
| 16 | REVIEW_MONO_FONT_FAMILY, | ||
| 17 | renderVisibleWhitespace, | ||
| 18 | } from "../review/reviewDiffRendering"; | ||
| 19 | import type { ReviewHighlightedToken } from "../review/shikiReviewHighlighter"; | 22 | import type { ReviewHighlightedToken } from "../review/shikiReviewHighlighter"; |
| 20 | import { cn } from "../../lib/cn"; | 23 | import { cn } from "../../lib/cn"; |
| 21 | import { MOBILE_CODE_SURFACE } from "../../lib/typography"; | 24 | import type { ResolvedMobileCodeSurface } from "../../lib/appearancePreferences"; |
| 25 | import { useAppearanceCodeSurface } from "../settings/appearance/useAppearanceCodeSurface"; | ||
| 22 | import { | 26 | import { |
| 23 | buildNativeSourceRows, | ||
| 24 | buildNativeSourceTokens, | 27 | buildNativeSourceTokens, |
| 25 | NATIVE_SOURCE_CONTENT_WIDTH, | 28 | NATIVE_SOURCE_CONTENT_WIDTH, |
| 26 | NATIVE_SOURCE_ROW_HEIGHT, | ||
| 27 | NATIVE_SOURCE_STYLE, | ||
| 28 | nativeSourceRowId, | 29 | nativeSourceRowId, |
| 29 | } from "./nativeSourceFileAdapter"; | 30 | } from "./nativeSourceFileAdapter"; |
| 31 | import { prepareSourceFileDocument } from "./source-file-document"; | ||
| 30 | import { sourceHighlightAtom } from "./sourceHighlightingState"; | 32 | import { sourceHighlightAtom } from "./sourceHighlightingState"; |
| 31 | 33 | ||
| 32 | const SOURCE_LINE_HEIGHT = MOBILE_CODE_SURFACE.rowHeight; | ||
| 33 | const SOURCE_LINE_NUMBER_WIDTH = MOBILE_CODE_SURFACE.gutterWidth; | ||
| 34 | const NATIVE_SOURCE_STYLE_JSON = JSON.stringify(NATIVE_SOURCE_STYLE); | ||
| 35 | |||
| 36 | interface SourceFileSurfaceProps { | 34 | interface SourceFileSurfaceProps { |
| 37 | readonly contents: string; | 35 | readonly contents: string; |
| 38 | readonly path: string; | 36 | readonly path: string; |
| 39 | readonly initialLine?: number | null; | 37 | readonly initialLine?: number | null; |
| 38 | /** Enables native pull-to-refresh on the source surface. */ | ||
| 39 | readonly onRefresh?: () => Promise<void> | void; | ||
| 40 | } | 40 | } |
| 41 | 41 | ||
| 42 | type SourceHighlightStatus = "highlighting" | "ready" | "error"; | 42 | type SourceHighlightStatus = "highlighting" | "ready" | "error"; |
| 43 | 43 | ||
| 44 | function splitSourceLines(contents: string): ReadonlyArray<string> { | ||
| 45 | return contents.replace(/\r\n?/g, "\n").split("\n"); | ||
| 46 | } | ||
| 47 | |||
| 48 | const HighlightedSourceLine = memo(function HighlightedSourceLine(props: { | 44 | const HighlightedSourceLine = memo(function HighlightedSourceLine(props: { |
| 45 | readonly codeSurface: ResolvedMobileCodeSurface; | ||
| 49 | readonly index: number; | 46 | readonly index: number; |
| 50 | readonly line: string; | 47 | readonly line: string; |
| 51 | readonly tokens: ReadonlyArray<ReviewHighlightedToken> | null; | 48 | readonly tokens: ReadonlyArray<ReviewHighlightedToken> | null; |
| 52 | readonly highlighted: boolean; | 49 | readonly highlighted: boolean; |
| 50 | readonly wordBreak: boolean; | ||
| 53 | }) { | 51 | }) { |
| 54 | return ( | 52 | return ( |
| 55 | <View | 53 | <View |
| 56 | className={cn("flex-row", props.highlighted && "bg-primary/10")} | 54 | className={cn("flex-row", props.highlighted && "bg-primary/10")} |
| 57 | style={{ minHeight: SOURCE_LINE_HEIGHT }} | 55 | style={{ minHeight: props.codeSurface.rowHeight }} |
| 58 | > | 56 | > |
| 59 | <NativeText | 57 | <NativeText |
| 60 | className="select-none pr-3 text-right text-foreground-tertiary" | 58 | className="select-none pr-3 text-right text-foreground-tertiary" |
| 61 | style={{ | 59 | style={{ |
| 62 | width: SOURCE_LINE_NUMBER_WIDTH, | 60 | width: props.codeSurface.gutterWidth, |
| 63 | fontFamily: REVIEW_MONO_FONT_FAMILY, | 61 | fontFamily: REVIEW_MONO_FONT_FAMILY, |
| 64 | fontSize: MOBILE_CODE_SURFACE.lineNumberFontSize, | 62 | fontSize: props.codeSurface.lineNumberFontSize, |
| 65 | lineHeight: MOBILE_CODE_SURFACE.rowHeight, | 63 | lineHeight: props.codeSurface.rowHeight, |
| 66 | }} | 64 | }} |
| 67 | > | 65 | > |
| 68 | {props.index + 1} | 66 | {props.index + 1} |
| 69 | </NativeText> | 67 | </NativeText> |
| 70 | <NativeText | 68 | <NativeText |
| 71 | selectable | 69 | selectable |
| 72 | numberOfLines={1} | 70 | numberOfLines={props.wordBreak ? undefined : 1} |
| 73 | className="font-normal text-foreground" | 71 | className="flex-1 font-normal text-foreground" |
| 74 | style={{ | 72 | style={{ |
| 75 | fontFamily: REVIEW_MONO_FONT_FAMILY, | 73 | fontFamily: REVIEW_MONO_FONT_FAMILY, |
| 76 | fontSize: MOBILE_CODE_SURFACE.fontSize, | 74 | fontSize: props.codeSurface.fontSize, |
| 77 | lineHeight: MOBILE_CODE_SURFACE.rowHeight, | 75 | lineHeight: props.codeSurface.rowHeight, |
| 78 | minWidth: 320, | 76 | minWidth: props.wordBreak ? undefined : 320, |
| 79 | }} | 77 | }} |
| 80 | > | 78 | > |
| 81 | {props.tokens && props.tokens.length > 0 | 79 | {props.tokens && props.tokens.length > 0 |
| ... | @@ -119,11 +117,8 @@ const HighlightedSourceLine = memo(function HighlightedSourceLine(props: { | ... | @@ -119,11 +117,8 @@ const HighlightedSourceLine = memo(function HighlightedSourceLine(props: { |
| 119 | function useSourceFileModel(props: SourceFileSurfaceProps) { | 117 | function useSourceFileModel(props: SourceFileSurfaceProps) { |
| 120 | const colorScheme = useColorScheme(); | 118 | const colorScheme = useColorScheme(); |
| 121 | const theme: "dark" | "light" = colorScheme === "dark" ? "dark" : "light"; | 119 | const theme: "dark" | "light" = colorScheme === "dark" ? "dark" : "light"; |
| 122 | const normalizedContents = useMemo( | 120 | const document = useMemo(() => prepareSourceFileDocument(props.contents), [props.contents]); |
| 123 | () => props.contents.replace(/\r\n?/g, "\n"), | 121 | const { contents: normalizedContents, lines, rowsJson } = document; |
| 124 | [props.contents], | ||
| 125 | ); | ||
| 126 | const lines = useMemo(() => splitSourceLines(normalizedContents), [normalizedContents]); | ||
| 127 | const targetIndex = | 122 | const targetIndex = |
| 128 | props.initialLine !== null && props.initialLine !== undefined && props.initialLine > 0 | 123 | props.initialLine !== null && props.initialLine !== undefined && props.initialLine > 0 |
| 129 | ? Math.min(Math.floor(props.initialLine) - 1, Math.max(0, lines.length - 1)) | 124 | ? Math.min(Math.floor(props.initialLine) - 1, Math.max(0, lines.length - 1)) |
| ... | @@ -140,7 +135,7 @@ function useSourceFileModel(props: SourceFileSurfaceProps) { | ... | @@ -140,7 +135,7 @@ function useSourceFileModel(props: SourceFileSurfaceProps) { |
| 140 | ? "ready" | 135 | ? "ready" |
| 141 | : "highlighting"; | 136 | : "highlighting"; |
| 142 | 137 | ||
| 143 | return { lines, status, targetIndex, theme, tokens }; | 138 | return { lines, rowsJson, status, targetIndex, theme, tokens }; |
| 144 | } | 139 | } |
| 145 | 140 | ||
| 146 | function SourceHighlightStatusView(props: { readonly status: SourceHighlightStatus }) { | 141 | function SourceHighlightStatusView(props: { readonly status: SourceHighlightStatus }) { |
| ... | @@ -162,39 +157,63 @@ function NativeSourceFileSurface( | ... | @@ -162,39 +157,63 @@ function NativeSourceFileSurface( |
| 162 | readonly NativeView: ComponentType<NativeReviewDiffViewProps>; | 157 | readonly NativeView: ComponentType<NativeReviewDiffViewProps>; |
| 163 | }, | 158 | }, |
| 164 | ) { | 159 | ) { |
| 165 | const { NativeView } = props; | 160 | const { NativeView, onRefresh } = props; |
| 166 | const { lines, status, targetIndex, theme, tokens } = useSourceFileModel(props); | 161 | const { codeSurface, codeWordBreak, nativeSourceStyle } = useAppearanceCodeSurface(); |
| 167 | const rowsJson = useMemo(() => JSON.stringify(buildNativeSourceRows(lines)), [lines]); | 162 | const { width: viewportWidth } = useWindowDimensions(); |
| 163 | const { rowsJson, status, targetIndex, theme, tokens } = useSourceFileModel(props); | ||
| 164 | const [isPullRefreshing, setIsPullRefreshing] = useState(false); | ||
| 165 | const handlePullToRefresh = useCallback(async () => { | ||
| 166 | if (!onRefresh) { | ||
| 167 | return; | ||
| 168 | } | ||
| 169 | setIsPullRefreshing(true); | ||
| 170 | try { | ||
| 171 | await onRefresh(); | ||
| 172 | } finally { | ||
| 173 | setIsPullRefreshing(false); | ||
| 174 | } | ||
| 175 | }, [onRefresh]); | ||
| 168 | const tokensJson = useMemo(() => JSON.stringify(buildNativeSourceTokens(tokens)), [tokens]); | 176 | const tokensJson = useMemo(() => JSON.stringify(buildNativeSourceTokens(tokens)), [tokens]); |
| 169 | const selectedRowIdsJson = useMemo( | 177 | const selectedRowIdsJson = useMemo( |
| 170 | () => JSON.stringify(targetIndex === null ? [] : [nativeSourceRowId(targetIndex)]), | 178 | () => JSON.stringify(targetIndex === null ? [] : [nativeSourceRowId(targetIndex)]), |
| 171 | [targetIndex], | 179 | [targetIndex], |
| 172 | ); | 180 | ); |
| 173 | const themeJson = useMemo(() => JSON.stringify(createNativeReviewDiffTheme(theme)), [theme]); | 181 | const themeJson = useMemo(() => JSON.stringify(createNativeReviewDiffTheme(theme)), [theme]); |
| 182 | const styleJson = useMemo(() => JSON.stringify(nativeSourceStyle), [nativeSourceStyle]); | ||
| 183 | const contentWidth = codeWordBreak | ||
| 184 | ? Math.max(240, viewportWidth - codeSurface.gutterWidth - 24) | ||
| 185 | : NATIVE_SOURCE_CONTENT_WIDTH; | ||
| 174 | 186 | ||
| 175 | return ( | 187 | return ( |
| 176 | <View className="relative flex-1 bg-card"> | 188 | <View className="relative flex-1 bg-sheet"> |
| 177 | <SourceHighlightStatusView status={status} /> | 189 | <SourceHighlightStatusView status={status} /> |
| 178 | <NativeView | 190 | <NativeView |
| 179 | key={props.path} | ||
| 180 | collapsable={false} | 191 | collapsable={false} |
| 181 | testID="source-native-code-view" | 192 | testID="source-native-code-view" |
| 182 | style={{ flex: 1 }} | 193 | style={{ flex: 1 }} |
| 183 | appearanceScheme={theme} | 194 | appearanceScheme={theme} |
| 184 | contentWidth={NATIVE_SOURCE_CONTENT_WIDTH} | 195 | contentResetKey={props.path} |
| 196 | contentWidth={contentWidth} | ||
| 185 | initialRowIndex={targetIndex ?? -1} | 197 | initialRowIndex={targetIndex ?? -1} |
| 186 | rowHeight={NATIVE_SOURCE_ROW_HEIGHT} | 198 | rowHeight={nativeSourceStyle.rowHeight ?? codeSurface.rowHeight} |
| 187 | rowsJson={rowsJson} | 199 | rowsJson={rowsJson} |
| 188 | selectedRowIdsJson={selectedRowIdsJson} | 200 | selectedRowIdsJson={selectedRowIdsJson} |
| 189 | styleJson={NATIVE_SOURCE_STYLE_JSON} | 201 | styleJson={styleJson} |
| 190 | themeJson={themeJson} | 202 | themeJson={themeJson} |
| 191 | tokensJson={tokensJson} | 203 | tokensJson={tokensJson} |
| 204 | {...(onRefresh | ||
| 205 | ? { | ||
| 206 | refreshing: isPullRefreshing, | ||
| 207 | onPullToRefresh: () => void handlePullToRefresh(), | ||
| 208 | } | ||
| 209 | : {})} | ||
| 192 | /> | 210 | /> |
| 193 | </View> | 211 | </View> |
| 194 | ); | 212 | ); |
| 195 | } | 213 | } |
| 196 | 214 | ||
| 197 | function JavaScriptSourceFileSurface(props: SourceFileSurfaceProps) { | 215 | function JavaScriptSourceFileSurface(props: SourceFileSurfaceProps) { |
| 216 | const { codeSurface, codeWordBreak } = useAppearanceCodeSurface(); | ||
| 198 | const { lines, status, targetIndex, tokens } = useSourceFileModel(props); | 217 | const { lines, status, targetIndex, tokens } = useSourceFileModel(props); |
| 199 | const listRef = useRef<FlatList<string>>(null); | 218 | const listRef = useRef<FlatList<string>>(null); |
| 200 | 219 | ||
| ... | @@ -211,39 +230,53 @@ function JavaScriptSourceFileSurface(props: SourceFileSurfaceProps) { | ... | @@ -211,39 +230,53 @@ function JavaScriptSourceFileSurface(props: SourceFileSurfaceProps) { |
| 211 | const renderLine = useCallback( | 230 | const renderLine = useCallback( |
| 212 | ({ item, index }: { item: string; index: number }) => ( | 231 | ({ item, index }: { item: string; index: number }) => ( |
| 213 | <HighlightedSourceLine | 232 | <HighlightedSourceLine |
| 233 | codeSurface={codeSurface} | ||
| 214 | index={index} | 234 | index={index} |
| 215 | line={item} | 235 | line={item} |
| 216 | tokens={tokens?.[index] ?? null} | 236 | tokens={tokens?.[index] ?? null} |
| 217 | highlighted={index === targetIndex} | 237 | highlighted={index === targetIndex} |
| 238 | wordBreak={codeWordBreak} | ||
| 218 | /> | 239 | /> |
| 219 | ), | 240 | ), |
| 220 | [targetIndex, tokens], | 241 | [codeSurface, codeWordBreak, targetIndex, tokens], |
| 242 | ); | ||
| 243 | |||
| 244 | const list = ( | ||
| 245 | <FlatList | ||
| 246 | ref={listRef} | ||
| 247 | data={lines} | ||
| 248 | keyExtractor={(_line, index) => String(index)} | ||
| 249 | initialNumToRender={80} | ||
| 250 | maxToRenderPerBatch={80} | ||
| 251 | windowSize={12} | ||
| 252 | {...(codeWordBreak | ||
| 253 | ? {} | ||
| 254 | : { | ||
| 255 | getItemLayout: (_data, index) => ({ | ||
| 256 | length: codeSurface.rowHeight, | ||
| 257 | offset: codeSurface.rowHeight * index, | ||
| 258 | index, | ||
| 259 | }), | ||
| 260 | })} | ||
| 261 | contentContainerStyle={{ | ||
| 262 | minWidth: codeWordBreak ? undefined : "100%", | ||
| 263 | paddingBottom: codeSurface.rowHeight, | ||
| 264 | paddingTop: 8, | ||
| 265 | }} | ||
| 266 | renderItem={renderLine} | ||
| 267 | /> | ||
| 221 | ); | 268 | ); |
| 222 | 269 | ||
| 223 | return ( | 270 | return ( |
| 224 | <View className="relative flex-1 bg-card"> | 271 | <View className="relative flex-1 bg-sheet"> |
| 225 | <SourceHighlightStatusView status={status} /> | 272 | <SourceHighlightStatusView status={status} /> |
| 226 | <ScrollView horizontal bounces={false} className="flex-1"> | 273 | {codeWordBreak ? ( |
| 227 | <FlatList | 274 | list |
| 228 | ref={listRef} | 275 | ) : ( |
| 229 | data={lines} | 276 | <ScrollView horizontal bounces={false} className="flex-1"> |
| 230 | keyExtractor={(_line, index) => String(index)} | 277 | {list} |
| 231 | initialNumToRender={80} | 278 | </ScrollView> |
| 232 | maxToRenderPerBatch={80} | 279 | )} |
| 233 | windowSize={12} | ||
| 234 | getItemLayout={(_data, index) => ({ | ||
| 235 | length: SOURCE_LINE_HEIGHT, | ||
| 236 | offset: SOURCE_LINE_HEIGHT * index, | ||
| 237 | index, | ||
| 238 | })} | ||
| 239 | contentContainerStyle={{ | ||
| 240 | minWidth: "100%", | ||
| 241 | paddingBottom: REVIEW_DIFF_LINE_HEIGHT, | ||
| 242 | paddingTop: 8, | ||
| 243 | }} | ||
| 244 | renderItem={renderLine} | ||
| 245 | /> | ||
| 246 | </ScrollView> | ||
| 247 | </View> | 280 | </View> |
| 248 | ); | 281 | ); |
| 249 | } | 282 | } |
apps/mobile/src/features/files/ThreadFilesRouteScreen.tsx+327-308| ... | @@ -1,8 +1,15 @@ | ... | @@ -1,8 +1,15 @@ |
| 1 | import Stack from "expo-router/stack"; | 1 | import { NativeHeaderToolbar, NativeStackScreenOptions } from "../../native/StackHeader"; |
| 2 | import { SymbolView } from "expo-symbols"; | 2 | import { StackActions, useNavigation, type StaticScreenProps } from "@react-navigation/native"; |
| 3 | import { useLocalSearchParams, useRouter } from "expo-router"; | 3 | import { useCallback, useEffect, useMemo, useRef, useState } from "react"; |
| 4 | import { useCallback, useMemo, useRef, useState } from "react"; | 4 | import { |
| 5 | import { ActivityIndicator, Pressable, ScrollView, Text as RNText, View } from "react-native"; | 5 | ActivityIndicator, |
| 6 | Platform, | ||
| 7 | Pressable, | ||
| 8 | ScrollView, | ||
| 9 | useColorScheme, | ||
| 10 | View, | ||
| 11 | } from "react-native"; | ||
| 12 | import { useSafeAreaInsets } from "react-native-safe-area-context"; | ||
| 6 | import Svg, { Defs, LinearGradient, Rect, Stop } from "react-native-svg"; | 13 | import Svg, { Defs, LinearGradient, Rect, Stop } from "react-native-svg"; |
| 7 | import { | 14 | import { |
| 8 | EnvironmentId, | 15 | EnvironmentId, |
| ... | @@ -12,22 +19,31 @@ import { | ... | @@ -12,22 +19,31 @@ import { |
| 12 | } from "@t3tools/contracts"; | 19 | } from "@t3tools/contracts"; |
| 13 | 20 | ||
| 14 | import { AppText as Text } from "../../components/AppText"; | 21 | import { AppText as Text } from "../../components/AppText"; |
| 15 | import { CopyTextButton } from "../../components/CopyTextButton"; | ||
| 16 | import { EmptyState } from "../../components/EmptyState"; | 22 | import { EmptyState } from "../../components/EmptyState"; |
| 17 | import { LoadingScreen } from "../../components/LoadingScreen"; | 23 | import { LoadingScreen } from "../../components/LoadingScreen"; |
| 18 | import { cn } from "../../lib/cn"; | 24 | import { cn } from "../../lib/cn"; |
| 25 | import { resolveFileSelectionNavigationAction } from "../../lib/adaptive-navigation"; | ||
| 26 | import { copyTextWithHaptic } from "../../lib/copyTextWithHaptic"; | ||
| 19 | import { tryOpenExternalUrl } from "../../lib/openExternalUrl"; | 27 | import { tryOpenExternalUrl } from "../../lib/openExternalUrl"; |
| 20 | import { buildThreadFilesNavigation } from "../../lib/routes"; | ||
| 21 | import { MOBILE_TYPOGRAPHY } from "../../lib/typography"; | ||
| 22 | import { useThemeColor } from "../../lib/useThemeColor"; | 28 | import { useThemeColor } from "../../lib/useThemeColor"; |
| 23 | import { useThreadSelection } from "../../state/use-thread-selection"; | 29 | import { useThreadSelection } from "../../state/use-thread-selection"; |
| 24 | import { useSelectedThreadWorktree } from "../../state/use-selected-thread-worktree"; | 30 | import { useSelectedThreadWorktree } from "../../state/use-selected-thread-worktree"; |
| 25 | import { useEnvironmentQuery } from "../../state/query"; | 31 | import { useEnvironmentQuery } from "../../state/query"; |
| 26 | import { projectEnvironment } from "../../state/projects"; | 32 | import { projectEnvironment } from "../../state/projects"; |
| 33 | import { | ||
| 34 | useAdaptiveWorkspaceLayout, | ||
| 35 | useAdaptiveWorkspacePaneRole, | ||
| 36 | useRegisterWorkspaceInspector, | ||
| 37 | } from "../layout/AdaptiveWorkspaceLayout"; | ||
| 38 | import { createNativeMailSearchToolbarItem } from "../layout/native-mail-search-toolbar"; | ||
| 39 | import { WorkspaceSidebarToolbar } from "../layout/workspace-sidebar-toolbar"; | ||
| 27 | import { ReviewHighlighterProvider } from "../review/ReviewHighlighterProvider"; | 40 | import { ReviewHighlighterProvider } from "../review/ReviewHighlighterProvider"; |
| 41 | import { ThreadRouteScreen } from "../threads/ThreadRouteScreen"; | ||
| 28 | import { FileMarkdownPreview } from "./FileMarkdownPreview"; | 42 | import { FileMarkdownPreview } from "./FileMarkdownPreview"; |
| 29 | import { FileTreeBrowser } from "./FileTreeBrowser"; | 43 | import { FileTreeBrowser } from "./FileTreeBrowser"; |
| 44 | import { preloadWorkspaceFileContents } from "./preload-workspace-file"; | ||
| 30 | import { SourceFileSurface } from "./SourceFileSurface"; | 45 | import { SourceFileSurface } from "./SourceFileSurface"; |
| 46 | import { ThreadFileNavigatorPane } from "./thread-file-navigator-pane"; | ||
| 31 | import { WorkspaceFileImagePreview } from "./WorkspaceFileImagePreview"; | 47 | import { WorkspaceFileImagePreview } from "./WorkspaceFileImagePreview"; |
| 32 | import { WorkspaceFileWebPreview } from "./WorkspaceFileWebPreview"; | 48 | import { WorkspaceFileWebPreview } from "./WorkspaceFileWebPreview"; |
| 33 | import { | 49 | import { |
| ... | @@ -72,196 +88,6 @@ function defaultViewMode(path: string | null): FileViewMode { | ... | @@ -72,196 +88,6 @@ function defaultViewMode(path: string | null): FileViewMode { |
| 72 | : "source"; | 88 | : "source"; |
| 73 | } | 89 | } |
| 74 | 90 | ||
| 75 | function ModeButton(props: { | ||
| 76 | readonly active: boolean; | ||
| 77 | readonly icon: "doc.text" | "eye"; | ||
| 78 | readonly label: string; | ||
| 79 | readonly onPress: () => void; | ||
| 80 | }) { | ||
| 81 | const iconColor = String( | ||
| 82 | useThemeColor(props.active ? "--color-primary-foreground" : "--color-icon-muted"), | ||
| 83 | ); | ||
| 84 | |||
| 85 | return ( | ||
| 86 | <Pressable | ||
| 87 | accessibilityRole="button" | ||
| 88 | accessibilityState={{ selected: props.active }} | ||
| 89 | className={cn( | ||
| 90 | "h-8 flex-row items-center justify-center gap-1.5 rounded-full px-3 active:opacity-70", | ||
| 91 | props.active ? "bg-primary" : "bg-subtle", | ||
| 92 | )} | ||
| 93 | onPress={props.onPress} | ||
| 94 | > | ||
| 95 | <SymbolView name={props.icon} size={13} tintColor={iconColor} type="monochrome" /> | ||
| 96 | <Text | ||
| 97 | className={cn( | ||
| 98 | "text-xs font-t3-bold", | ||
| 99 | props.active ? "text-primary-foreground" : "text-foreground-muted", | ||
| 100 | )} | ||
| 101 | > | ||
| 102 | {props.label} | ||
| 103 | </Text> | ||
| 104 | </Pressable> | ||
| 105 | ); | ||
| 106 | } | ||
| 107 | |||
| 108 | function BreadcrumbFade(props: { readonly color: string; readonly side: "left" | "right" }) { | ||
| 109 | const gradientId = `file-breadcrumb-${props.side}-fade`; | ||
| 110 | const isLeft = props.side === "left"; | ||
| 111 | |||
| 112 | return ( | ||
| 113 | <View | ||
| 114 | pointerEvents="none" | ||
| 115 | className={cn("absolute inset-y-0 w-7", isLeft ? "left-0" : "right-0")} | ||
| 116 | > | ||
| 117 | <Svg width="100%" height="100%"> | ||
| 118 | <Defs> | ||
| 119 | <LinearGradient id={gradientId} x1="0%" x2="100%" y1="0%" y2="0%"> | ||
| 120 | <Stop offset="0%" stopColor={props.color} stopOpacity={isLeft ? 1 : 0} /> | ||
| 121 | <Stop offset="100%" stopColor={props.color} stopOpacity={isLeft ? 0 : 1} /> | ||
| 122 | </LinearGradient> | ||
| 123 | </Defs> | ||
| 124 | <Rect width="100%" height="100%" fill={`url(#${gradientId})`} /> | ||
| 125 | </Svg> | ||
| 126 | </View> | ||
| 127 | ); | ||
| 128 | } | ||
| 129 | |||
| 130 | function FileBreadcrumbs(props: { readonly projectName: string; readonly relativePath: string }) { | ||
| 131 | const iconColor = String(useThemeColor("--color-icon-muted")); | ||
| 132 | const cardColor = String(useThemeColor("--color-card")); | ||
| 133 | const scrollMetrics = useRef({ contentWidth: 0, offsetX: 0, viewportWidth: 0 }); | ||
| 134 | const [fadeVisibility, setFadeVisibility] = useState({ left: false, right: false }); | ||
| 135 | const breadcrumbs = useMemo( | ||
| 136 | () => fileBreadcrumbs(props.projectName, props.relativePath), | ||
| 137 | [props.projectName, props.relativePath], | ||
| 138 | ); | ||
| 139 | const updateFadeVisibility = useCallback( | ||
| 140 | (metrics: Partial<(typeof scrollMetrics)["current"]>) => { | ||
| 141 | Object.assign(scrollMetrics.current, metrics); | ||
| 142 | const { contentWidth, offsetX, viewportWidth } = scrollMetrics.current; | ||
| 143 | const maxOffset = Math.max(0, contentWidth - viewportWidth); | ||
| 144 | const next = { | ||
| 145 | left: maxOffset > 1 && offsetX > 1, | ||
| 146 | right: maxOffset > 1 && offsetX < maxOffset - 1, | ||
| 147 | }; | ||
| 148 | |||
| 149 | setFadeVisibility((current) => | ||
| 150 | current.left === next.left && current.right === next.right ? current : next, | ||
| 151 | ); | ||
| 152 | }, | ||
| 153 | [], | ||
| 154 | ); | ||
| 155 | |||
| 156 | return ( | ||
| 157 | <View className="min-w-0 flex-1"> | ||
| 158 | <ScrollView | ||
| 159 | horizontal | ||
| 160 | showsHorizontalScrollIndicator={false} | ||
| 161 | onContentSizeChange={(contentWidth) => { | ||
| 162 | updateFadeVisibility({ contentWidth }); | ||
| 163 | }} | ||
| 164 | onLayout={(event) => { | ||
| 165 | updateFadeVisibility({ viewportWidth: event.nativeEvent.layout.width }); | ||
| 166 | }} | ||
| 167 | onScroll={(event) => { | ||
| 168 | updateFadeVisibility({ offsetX: event.nativeEvent.contentOffset.x }); | ||
| 169 | }} | ||
| 170 | scrollEventThrottle={16} | ||
| 171 | > | ||
| 172 | <View className="h-8 flex-row items-center"> | ||
| 173 | {breadcrumbs.map((crumb, index) => ( | ||
| 174 | <View key={crumb.path || "project"} className="flex-row items-center"> | ||
| 175 | {index > 0 ? ( | ||
| 176 | <SymbolView | ||
| 177 | name="chevron.right" | ||
| 178 | size={10} | ||
| 179 | tintColor={iconColor} | ||
| 180 | type="monochrome" | ||
| 181 | /> | ||
| 182 | ) : null} | ||
| 183 | <Text | ||
| 184 | className={cn( | ||
| 185 | "max-w-[180px] px-1 text-xs", | ||
| 186 | crumb.kind === "file" | ||
| 187 | ? "font-t3-bold text-foreground" | ||
| 188 | : "font-t3-medium text-foreground-muted", | ||
| 189 | )} | ||
| 190 | numberOfLines={1} | ||
| 191 | > | ||
| 192 | {crumb.label} | ||
| 193 | </Text> | ||
| 194 | </View> | ||
| 195 | ))} | ||
| 196 | </View> | ||
| 197 | </ScrollView> | ||
| 198 | {fadeVisibility.left ? <BreadcrumbFade color={cardColor} side="left" /> : null} | ||
| 199 | {fadeVisibility.right ? <BreadcrumbFade color={cardColor} side="right" /> : null} | ||
| 200 | </View> | ||
| 201 | ); | ||
| 202 | } | ||
| 203 | |||
| 204 | function FilePreviewHeader(props: { | ||
| 205 | readonly activeMode: FileViewMode; | ||
| 206 | readonly showModeSelector: boolean; | ||
| 207 | readonly externalPreviewUri?: string | null; | ||
| 208 | readonly projectName: string; | ||
| 209 | readonly relativePath: string; | ||
| 210 | readonly onSetMode: (mode: FileViewMode) => void; | ||
| 211 | }) { | ||
| 212 | const iconColor = String(useThemeColor("--color-icon-muted")); | ||
| 213 | |||
| 214 | return ( | ||
| 215 | <View className="border-b border-border bg-card px-3 py-2"> | ||
| 216 | <View className="flex-row items-center gap-2"> | ||
| 217 | <FileBreadcrumbs projectName={props.projectName} relativePath={props.relativePath} /> | ||
| 218 | <CopyTextButton | ||
| 219 | accessibilityLabel="Copy file path" | ||
| 220 | text={props.relativePath} | ||
| 221 | tintColor={iconColor} | ||
| 222 | buttonSize={32} | ||
| 223 | iconSize={13} | ||
| 224 | /> | ||
| 225 | </View> | ||
| 226 | {props.showModeSelector ? ( | ||
| 227 | <View className="mt-2 flex-row items-center gap-2"> | ||
| 228 | <ModeButton | ||
| 229 | active={props.activeMode === "preview"} | ||
| 230 | icon="eye" | ||
| 231 | label="Preview" | ||
| 232 | onPress={() => props.onSetMode("preview")} | ||
| 233 | /> | ||
| 234 | <ModeButton | ||
| 235 | active={props.activeMode === "source"} | ||
| 236 | icon="doc.text" | ||
| 237 | label="Source" | ||
| 238 | onPress={() => props.onSetMode("source")} | ||
| 239 | /> | ||
| 240 | {props.externalPreviewUri !== undefined ? ( | ||
| 241 | <Pressable | ||
| 242 | accessibilityRole="button" | ||
| 243 | accessibilityLabel="Open preview in Safari" | ||
| 244 | disabled={props.externalPreviewUri === null} | ||
| 245 | hitSlop={8} | ||
| 246 | className={cn( | ||
| 247 | "ml-auto h-8 w-8 items-center justify-center rounded-full bg-subtle active:opacity-70", | ||
| 248 | props.externalPreviewUri === null && "opacity-40", | ||
| 249 | )} | ||
| 250 | onPress={() => { | ||
| 251 | if (typeof props.externalPreviewUri === "string") { | ||
| 252 | void tryOpenExternalUrl(props.externalPreviewUri, "file-preview"); | ||
| 253 | } | ||
| 254 | }} | ||
| 255 | > | ||
| 256 | <SymbolView name="safari" size={15} tintColor={iconColor} type="monochrome" /> | ||
| 257 | </Pressable> | ||
| 258 | ) : null} | ||
| 259 | </View> | ||
| 260 | ) : null} | ||
| 261 | </View> | ||
| 262 | ); | ||
| 263 | } | ||
| 264 | |||
| 265 | function FileContent(props: { | 91 | function FileContent(props: { |
| 266 | readonly activeMode: FileViewMode; | 92 | readonly activeMode: FileViewMode; |
| 267 | readonly previewUri: string | null; | 93 | readonly previewUri: string | null; |
| ... | @@ -270,6 +96,7 @@ function FileContent(props: { | ... | @@ -270,6 +96,7 @@ function FileContent(props: { |
| 270 | readonly relativePath: string; | 96 | readonly relativePath: string; |
| 271 | readonly initialLine: number | null; | 97 | readonly initialLine: number | null; |
| 272 | readonly truncated: boolean; | 98 | readonly truncated: boolean; |
| 99 | readonly onRefresh?: () => Promise<void> | void; | ||
| 273 | }) { | 100 | }) { |
| 274 | const isMarkdown = isMarkdownPreviewFile(props.relativePath); | 101 | const isMarkdown = isMarkdownPreviewFile(props.relativePath); |
| 275 | const isBrowserFile = isBrowserPreviewFile(props.relativePath); | 102 | const isBrowserFile = isBrowserPreviewFile(props.relativePath); |
| ... | @@ -293,7 +120,7 @@ function FileContent(props: { | ... | @@ -293,7 +120,7 @@ function FileContent(props: { |
| 293 | 120 | ||
| 294 | if (props.fileError && props.fileContents === null) { | 121 | if (props.fileError && props.fileContents === null) { |
| 295 | return ( | 122 | return ( |
| 296 | <View className="flex-1 items-center justify-center bg-card px-6"> | 123 | <View className="flex-1 items-center justify-center bg-sheet px-6"> |
| 297 | <EmptyState title="File unavailable" detail={props.fileError} /> | 124 | <EmptyState title="File unavailable" detail={props.fileError} /> |
| 298 | </View> | 125 | </View> |
| 299 | ); | 126 | ); |
| ... | @@ -301,7 +128,7 @@ function FileContent(props: { | ... | @@ -301,7 +128,7 @@ function FileContent(props: { |
| 301 | 128 | ||
| 302 | if (props.fileContents === null) { | 129 | if (props.fileContents === null) { |
| 303 | return ( | 130 | return ( |
| 304 | <View className="flex-1 items-center justify-center gap-3 bg-card px-6"> | 131 | <View className="flex-1 items-center justify-center gap-3 bg-sheet px-6"> |
| 305 | <ActivityIndicator /> | 132 | <ActivityIndicator /> |
| 306 | <Text className="text-center text-sm text-foreground-muted">Loading file...</Text> | 133 | <Text className="text-center text-sm text-foreground-muted">Loading file...</Text> |
| 307 | </View> | 134 | </View> |
| ... | @@ -309,35 +136,47 @@ function FileContent(props: { | ... | @@ -309,35 +136,47 @@ function FileContent(props: { |
| 309 | } | 136 | } |
| 310 | 137 | ||
| 311 | return ( | 138 | return ( |
| 312 | <View className="flex-1 bg-card"> | 139 | <View className="flex-1 bg-sheet"> |
| 313 | {props.truncated ? ( | 140 | {props.truncated ? ( |
| 314 | <View className="border-b border-amber-200 bg-amber-50 px-4 py-2 dark:border-amber-900/60 dark:bg-amber-950/40"> | 141 | <View className="border-b border-amber-200 bg-amber-50 px-4 py-2 dark:border-amber-900/60 dark:bg-amber-950/40"> |
| 315 | <Text className="text-2xs font-t3-bold uppercase text-amber-700 dark:text-amber-300"> | 142 | <Text className="text-2xs font-t3-bold uppercase text-amber-700 dark:text-amber-300"> |
| 316 | Partial file | 143 | Partial file |
| 317 | </Text> | 144 | </Text> |
| 318 | <Text className="text-xs leading-[17px] text-amber-800 dark:text-amber-200"> | 145 | <Text className="text-xs leading-snug text-amber-800 dark:text-amber-200"> |
| 319 | Preview limited to the first 1 MB of a truncated file. | 146 | Preview limited to the first 1 MB of a truncated file. |
| 320 | </Text> | 147 | </Text> |
| 321 | </View> | 148 | </View> |
| 322 | ) : null} | 149 | ) : null} |
| 323 | {props.activeMode === "preview" && isMarkdown ? ( | 150 | {props.activeMode === "preview" && isMarkdown ? ( |
| 324 | <FileMarkdownPreview markdown={props.fileContents} /> | 151 | <FileMarkdownPreview markdown={props.fileContents} onRefresh={props.onRefresh} /> |
| 325 | ) : ( | 152 | ) : ( |
| 326 | <SourceFileSurface | 153 | <SourceFileSurface |
| 327 | contents={props.fileContents} | 154 | contents={props.fileContents} |
| 328 | path={props.relativePath} | 155 | path={props.relativePath} |
| 329 | initialLine={props.initialLine} | 156 | initialLine={props.initialLine} |
| 157 | onRefresh={props.onRefresh} | ||
| 330 | /> | 158 | /> |
| 331 | )} | 159 | )} |
| 332 | </View> | 160 | </View> |
| 333 | ); | 161 | ); |
| 334 | } | 162 | } |
| 335 | 163 | ||
| 336 | function useThreadFilesWorkspace() { | 164 | type ThreadFilesRouteScreenProps = StaticScreenProps<{ |
| 337 | const params = useLocalSearchParams<{ | 165 | readonly environmentId: string; |
| 338 | environmentId?: string | string[]; | 166 | readonly threadId: string; |
| 339 | threadId?: string | string[]; | 167 | }>; |
| 340 | }>(); | 168 | |
| 169 | type ThreadFileRouteScreenProps = StaticScreenProps<{ | ||
| 170 | readonly environmentId: string; | ||
| 171 | readonly threadId: string; | ||
| 172 | readonly path: string[]; | ||
| 173 | readonly line?: string; | ||
| 174 | }>; | ||
| 175 | |||
| 176 | function useThreadFilesWorkspace(params: { | ||
| 177 | readonly environmentId?: string | string[]; | ||
| 178 | readonly threadId?: string | string[]; | ||
| 179 | }) { | ||
| 341 | const routeEnvironmentId = firstRouteParam(params.environmentId); | 180 | const routeEnvironmentId = firstRouteParam(params.environmentId); |
| 342 | const routeThreadId = firstRouteParam(params.threadId); | 181 | const routeThreadId = firstRouteParam(params.threadId); |
| 343 | const { selectedThread, selectedThreadProject } = useThreadSelection(); | 182 | const { selectedThread, selectedThreadProject } = useThreadSelection(); |
| ... | @@ -364,7 +203,7 @@ function useThreadFilesWorkspace() { | ... | @@ -364,7 +203,7 @@ function useThreadFilesWorkspace() { |
| 364 | function FilesUnavailable() { | 203 | function FilesUnavailable() { |
| 365 | return ( | 204 | return ( |
| 366 | <View className="flex-1 items-center justify-center bg-sheet px-6"> | 205 | <View className="flex-1 items-center justify-center bg-sheet px-6"> |
| 367 | <Stack.Screen options={{ title: "Files" }} /> | 206 | <NativeStackScreenOptions options={{ title: "Files" }} /> |
| 368 | <EmptyState | 207 | <EmptyState |
| 369 | title="Files unavailable" | 208 | title="Files unavailable" |
| 370 | detail="This thread does not have an active workspace path." | 209 | detail="This thread does not have an active workspace path." |
| ... | @@ -373,40 +212,6 @@ function FilesUnavailable() { | ... | @@ -373,40 +212,6 @@ function FilesUnavailable() { |
| 373 | ); | 212 | ); |
| 374 | } | 213 | } |
| 375 | 214 | ||
| 376 | function FilesHeaderTitle(props: { readonly projectName: string }) { | ||
| 377 | const foregroundColor = String(useThemeColor("--color-foreground")); | ||
| 378 | const secondaryForegroundColor = String(useThemeColor("--color-foreground-secondary")); | ||
| 379 | |||
| 380 | return ( | ||
| 381 | <View style={{ alignItems: "center", maxWidth: 220 }}> | ||
| 382 | <RNText | ||
| 383 | numberOfLines={1} | ||
| 384 | style={{ | ||
| 385 | color: foregroundColor, | ||
| 386 | fontFamily: "DMSans_700Bold", | ||
| 387 | fontSize: MOBILE_TYPOGRAPHY.headline.fontSize, | ||
| 388 | fontWeight: "900", | ||
| 389 | letterSpacing: -0.4, | ||
| 390 | }} | ||
| 391 | > | ||
| 392 | Files | ||
| 393 | </RNText> | ||
| 394 | <RNText | ||
| 395 | numberOfLines={1} | ||
| 396 | style={{ | ||
| 397 | color: secondaryForegroundColor, | ||
| 398 | fontFamily: "DMSans_500Medium", | ||
| 399 | fontSize: MOBILE_TYPOGRAPHY.label.fontSize, | ||
| 400 | fontWeight: "500", | ||
| 401 | letterSpacing: 0.2, | ||
| 402 | }} | ||
| 403 | > | ||
| 404 | {props.projectName} | ||
| 405 | </RNText> | ||
| 406 | </View> | ||
| 407 | ); | ||
| 408 | } | ||
| 409 | |||
| 410 | function FilesToolbarBottomFade() { | 215 | function FilesToolbarBottomFade() { |
| 411 | const sheetColor = String(useThemeColor("--color-sheet")); | 216 | const sheetColor = String(useThemeColor("--color-sheet")); |
| 412 | 217 | ||
| ... | @@ -442,12 +247,20 @@ function FilesToolbarBottomFade() { | ... | @@ -442,12 +247,20 @@ function FilesToolbarBottomFade() { |
| 442 | ); | 247 | ); |
| 443 | } | 248 | } |
| 444 | 249 | ||
| 445 | export function ThreadFilesTreeScreen() { | 250 | export function ThreadFilesTreeScreen(props: ThreadFilesRouteScreenProps) { |
| 446 | const router = useRouter(); | 251 | useAdaptiveWorkspacePaneRole("inspector"); |
| 252 | const navigation = useNavigation(); | ||
| 253 | const { fileInspector, layout, panes, showAuxiliaryPane, togglePrimarySidebar } = | ||
| 254 | useAdaptiveWorkspaceLayout(); | ||
| 447 | const [searchQuery, setSearchQuery] = useState(""); | 255 | const [searchQuery, setSearchQuery] = useState(""); |
| 448 | const { cwd, environmentId, projectName, selectedThread, threadId } = useThreadFilesWorkspace(); | 256 | const colorScheme = useColorScheme(); |
| 257 | const highlightTheme = colorScheme === "dark" ? "dark" : "light"; | ||
| 258 | const { cwd, environmentId, projectName, selectedThread, threadId } = useThreadFilesWorkspace( | ||
| 259 | props.route.params, | ||
| 260 | ); | ||
| 261 | const revealedInspectorRef = useRef(false); | ||
| 449 | const entriesQuery = useEnvironmentQuery( | 262 | const entriesQuery = useEnvironmentQuery( |
| 450 | environmentId !== null && cwd !== null | 263 | environmentId !== null && cwd !== null && !fileInspector.supported |
| 451 | ? projectEnvironment.listEntries({ | 264 | ? projectEnvironment.listEntries({ |
| 452 | environmentId, | 265 | environmentId, |
| 453 | input: { cwd }, | 266 | input: { cwd }, |
| ... | @@ -455,18 +268,87 @@ export function ThreadFilesTreeScreen() { | ... | @@ -455,18 +268,87 @@ export function ThreadFilesTreeScreen() { |
| 455 | : null, | 268 | : null, |
| 456 | ); | 269 | ); |
| 457 | const entriesData = entriesQuery.data as ProjectListEntriesResult | null; | 270 | const entriesData = entriesQuery.data as ProjectListEntriesResult | null; |
| 271 | const handleReturnToThread = useCallback(() => { | ||
| 272 | if (navigation.canGoBack()) { | ||
| 273 | navigation.goBack(); | ||
| 274 | return; | ||
| 275 | } | ||
| 276 | if (environmentId !== null && threadId !== null) { | ||
| 277 | navigation.dispatch( | ||
| 278 | StackActions.replace("Thread", { | ||
| 279 | environmentId: String(environmentId), | ||
| 280 | threadId: String(threadId), | ||
| 281 | }), | ||
| 282 | ); | ||
| 283 | } | ||
| 284 | }, [environmentId, navigation, threadId]); | ||
| 458 | 285 | ||
| 459 | const handleSelectFile = useCallback( | 286 | const handleSelectFile = useCallback( |
| 460 | (path: string) => { | 287 | (path: string) => { |
| 461 | if (environmentId === null || threadId === null) { | 288 | if (environmentId === null || threadId === null) { |
| 462 | return; | 289 | return; |
| 463 | } | 290 | } |
| 464 | router.push(buildThreadFilesNavigation({ environmentId, threadId }, path)); | 291 | const params = { |
| 292 | environmentId: String(environmentId), | ||
| 293 | threadId: String(threadId), | ||
| 294 | path: path.split("/").filter((segment) => segment.length > 0), | ||
| 295 | }; | ||
| 296 | const navigationAction = resolveFileSelectionNavigationAction({ | ||
| 297 | hasPersistentFileInspector: fileInspector.supported, | ||
| 298 | }); | ||
| 299 | if (navigationAction === "replace") { | ||
| 300 | navigation.dispatch(StackActions.replace("ThreadFile", params)); | ||
| 301 | return; | ||
| 302 | } | ||
| 303 | navigation.navigate("ThreadFile", params); | ||
| 465 | }, | 304 | }, |
| 466 | [environmentId, router, threadId], | 305 | [environmentId, fileInspector.supported, navigation, threadId], |
| 467 | ); | 306 | ); |
| 307 | const renderInspector = useCallback( | ||
| 308 | (headerInset: number) => | ||
| 309 | environmentId !== null && cwd !== null ? ( | ||
| 310 | <ThreadFileNavigatorPane | ||
| 311 | cwd={cwd} | ||
| 312 | environmentId={environmentId} | ||
| 313 | headerInset={headerInset} | ||
| 314 | projectName={projectName} | ||
| 315 | selectedPath={null} | ||
| 316 | onSelectFile={handleSelectFile} | ||
| 317 | /> | ||
| 318 | ) : null, | ||
| 319 | [cwd, environmentId, handleSelectFile, projectName], | ||
| 320 | ); | ||
| 321 | const handlePreviewFile = useCallback( | ||
| 322 | (relativePath: string) => { | ||
| 323 | if (environmentId === null || cwd === null) { | ||
| 324 | return; | ||
| 325 | } | ||
| 326 | preloadWorkspaceFileContents({ | ||
| 327 | cwd, | ||
| 328 | environmentId, | ||
| 329 | relativePath, | ||
| 330 | theme: highlightTheme, | ||
| 331 | }); | ||
| 332 | }, | ||
| 333 | [cwd, environmentId, highlightTheme], | ||
| 334 | ); | ||
| 335 | useEffect(() => { | ||
| 336 | if (fileInspector.supported && cwd !== null && !revealedInspectorRef.current) { | ||
| 337 | revealedInspectorRef.current = true; | ||
| 338 | showAuxiliaryPane("inspector"); | ||
| 339 | } | ||
| 340 | }, [cwd, fileInspector.supported, showAuxiliaryPane]); | ||
| 468 | 341 | ||
| 469 | if (selectedThread === null || environmentId === null || threadId === null) { | 342 | if (selectedThread === null || environmentId === null || threadId === null) { |
| 343 | if (fileInspector.supported) { | ||
| 344 | return ( | ||
| 345 | <ThreadRouteScreen | ||
| 346 | onReturnToThread={handleReturnToThread} | ||
| 347 | renderInspector={renderInspector} | ||
| 348 | route={props.route} | ||
| 349 | /> | ||
| 350 | ); | ||
| 351 | } | ||
| 470 | return <LoadingScreen message="Opening files..." messagePlacement="above-spinner" />; | 352 | return <LoadingScreen message="Opening files..." messagePlacement="above-spinner" />; |
| 471 | } | 353 | } |
| 472 | 354 | ||
| ... | @@ -474,62 +356,95 @@ export function ThreadFilesTreeScreen() { | ... | @@ -474,62 +356,95 @@ export function ThreadFilesTreeScreen() { |
| 474 | return <FilesUnavailable />; | 356 | return <FilesUnavailable />; |
| 475 | } | 357 | } |
| 476 | 358 | ||
| 359 | if (fileInspector.supported) { | ||
| 360 | return ( | ||
| 361 | <ThreadRouteScreen | ||
| 362 | onReturnToThread={handleReturnToThread} | ||
| 363 | renderInspector={renderInspector} | ||
| 364 | route={props.route} | ||
| 365 | /> | ||
| 366 | ); | ||
| 367 | } | ||
| 368 | |||
| 369 | const usesCompactMailToolbar = Platform.OS === "ios" && !layout.usesSplitView; | ||
| 370 | |||
| 477 | return ( | 371 | return ( |
| 478 | <View className="flex-1 bg-sheet"> | 372 | <> |
| 479 | <Stack.Screen | 373 | {/* Static header config (glass preset, title, contentStyle) lives in Stack.tsx. |
| 374 | Only genuinely dynamic options are set here. */} | ||
| 375 | <NativeStackScreenOptions | ||
| 480 | options={{ | 376 | options={{ |
| 481 | title: "Files", | 377 | unstable_headerSubtitle: |
| 482 | headerShown: true, | 378 | Platform.OS === "ios" && projectName.length > 0 ? projectName : undefined, |
| 483 | headerTransparent: true, | 379 | // No refresh button: the list already supports pull-to-refresh. |
| 484 | headerStyle: { backgroundColor: "transparent" }, | 380 | unstable_headerToolbarItems: usesCompactMailToolbar |
| 485 | headerShadowVisible: false, | 381 | ? () => [ |
| 486 | headerTitle: () => <FilesHeaderTitle projectName={projectName} />, | 382 | createNativeMailSearchToolbarItem({ |
| 487 | headerSearchBarOptions: { | 383 | onSearchTextChange: setSearchQuery, |
| 488 | allowToolbarIntegration: true, | 384 | placeholder: "Search files", |
| 489 | autoCapitalize: "none", | 385 | searchTextChangeId: "files-search-text", |
| 490 | hideNavigationBar: false, | 386 | }), |
| 491 | placeholder: "Search files", | 387 | ] |
| 492 | onChangeText: (event) => { | 388 | : undefined, |
| 493 | setSearchQuery(event.nativeEvent.text); | 389 | headerSearchBarOptions: usesCompactMailToolbar |
| 494 | }, | 390 | ? undefined |
| 495 | onCancelButtonPress: () => { | 391 | : { |
| 496 | setSearchQuery(""); | 392 | allowToolbarIntegration: true, |
| 497 | }, | 393 | autoCapitalize: "none", |
| 498 | }, | 394 | hideNavigationBar: false, |
| 395 | placeholder: "Search files", | ||
| 396 | onChangeText: (event) => { | ||
| 397 | setSearchQuery(event.nativeEvent.text); | ||
| 398 | }, | ||
| 399 | onCancelButtonPress: () => { | ||
| 400 | setSearchQuery(""); | ||
| 401 | }, | ||
| 402 | }, | ||
| 499 | }} | 403 | }} |
| 500 | /> | 404 | /> |
| 501 | <Stack.Toolbar placement="right"> | 405 | {layout.usesSplitView ? ( |
| 502 | <Stack.Toolbar.Button | 406 | <NativeHeaderToolbar placement="left"> |
| 503 | accessibilityLabel="Refresh files" | 407 | <NativeHeaderToolbar.Button |
| 504 | icon="arrow.clockwise" | 408 | accessibilityLabel={panes.primarySidebarVisible ? "Maximize files" : "Show threads"} |
| 505 | onPress={entriesQuery.refresh} | 409 | icon={ |
| 506 | /> | 410 | panes.primarySidebarVisible ? "arrow.up.left.and.arrow.down.right" : "sidebar.left" |
| 507 | </Stack.Toolbar> | 411 | } |
| 508 | <Stack.Toolbar placement="bottom"> | 412 | onPress={togglePrimarySidebar} |
| 509 | <Stack.Toolbar.SearchBarSlot /> | 413 | separateBackground |
| 510 | </Stack.Toolbar> | 414 | /> |
| 415 | </NativeHeaderToolbar> | ||
| 416 | ) : null} | ||
| 417 | {usesCompactMailToolbar ? null : ( | ||
| 418 | <NativeHeaderToolbar placement="bottom"> | ||
| 419 | <NativeHeaderToolbar.SearchBarSlot /> | ||
| 420 | </NativeHeaderToolbar> | ||
| 421 | )} | ||
| 511 | <FileTreeBrowser | 422 | <FileTreeBrowser |
| 512 | entries={entriesData?.entries ?? []} | 423 | entries={entriesData?.entries ?? []} |
| 513 | error={entriesQuery.error} | 424 | error={entriesQuery.error} |
| 514 | isPending={entriesQuery.isPending} | 425 | isPending={entriesQuery.isPending} |
| 515 | searchQuery={searchQuery} | 426 | searchQuery={searchQuery} |
| 516 | selectedPath={null} | 427 | selectedPath={null} |
| 428 | onPreviewFile={handlePreviewFile} | ||
| 517 | onRefresh={entriesQuery.refresh} | 429 | onRefresh={entriesQuery.refresh} |
| 518 | onSelectFile={handleSelectFile} | 430 | onSelectFile={handleSelectFile} |
| 519 | /> | 431 | /> |
| 520 | <FilesToolbarBottomFade /> | 432 | <FilesToolbarBottomFade /> |
| 521 | </View> | 433 | </> |
| 522 | ); | 434 | ); |
| 523 | } | 435 | } |
| 524 | 436 | ||
| 525 | export function ThreadFileScreen() { | 437 | export function ThreadFileScreen(props: ThreadFileRouteScreenProps) { |
| 526 | const params = useLocalSearchParams<{ | 438 | useAdaptiveWorkspacePaneRole("inspector"); |
| 527 | line?: string | string[]; | 439 | const navigation = useNavigation(); |
| 528 | path?: string | string[]; | 440 | const { fileInspector, panes, toggleAuxiliaryPane } = useAdaptiveWorkspaceLayout(); |
| 529 | }>(); | 441 | const iconColor = useThemeColor("--color-icon"); |
| 442 | const params = props.route.params; | ||
| 530 | const relativePath = normalizeRoutePath(params.path); | 443 | const relativePath = normalizeRoutePath(params.path); |
| 531 | const targetLine = normalizeRouteLine(firstRouteParam(params.line)); | 444 | const targetLine = normalizeRouteLine(firstRouteParam(params.line)); |
| 532 | const { cwd, environmentId, projectName, selectedThread, threadId } = useThreadFilesWorkspace(); | 445 | const { cwd, environmentId, projectName, selectedThread, threadId } = useThreadFilesWorkspace( |
| 446 | props.route.params, | ||
| 447 | ); | ||
| 533 | const [modeOverride, setModeOverride] = useState<{ | 448 | const [modeOverride, setModeOverride] = useState<{ |
| 534 | readonly path: string; | 449 | readonly path: string; |
| 535 | readonly mode: FileViewMode; | 450 | readonly mode: FileViewMode; |
| ... | @@ -568,6 +483,43 @@ export function ThreadFileScreen() { | ... | @@ -568,6 +483,43 @@ export function ThreadFileScreen() { |
| 568 | ); | 483 | ); |
| 569 | const fileData = fileQuery.data as ProjectReadFileResult | null; | 484 | const fileData = fileQuery.data as ProjectReadFileResult | null; |
| 570 | 485 | ||
| 486 | const handleSelectFile = useCallback( | ||
| 487 | (path: string) => { | ||
| 488 | navigation.navigate("ThreadFile", { | ||
| 489 | environmentId: String(environmentId), | ||
| 490 | threadId: String(threadId), | ||
| 491 | path: path.split("/").filter(Boolean), | ||
| 492 | }); | ||
| 493 | }, | ||
| 494 | [environmentId, navigation, threadId], | ||
| 495 | ); | ||
| 496 | const renderInspector = useCallback( | ||
| 497 | (headerInset: number) => | ||
| 498 | fileInspector.supported && environmentId !== null && cwd !== null ? ( | ||
| 499 | <ThreadFileNavigatorPane | ||
| 500 | cwd={cwd} | ||
| 501 | environmentId={environmentId} | ||
| 502 | headerInset={headerInset} | ||
| 503 | projectName={projectName} | ||
| 504 | selectedPath={relativePath} | ||
| 505 | onSelectFile={handleSelectFile} | ||
| 506 | /> | ||
| 507 | ) : undefined, | ||
| 508 | [cwd, environmentId, fileInspector.supported, handleSelectFile, projectName, relativePath], | ||
| 509 | ); | ||
| 510 | // The workspace inspector column spans the full window height. On iOS the | ||
| 511 | // pane brings its own nested native header; elsewhere it pads itself below | ||
| 512 | // the top inset. | ||
| 513 | const safeAreaInsets = useSafeAreaInsets(); | ||
| 514 | const inspectorHeaderInset = Platform.OS === "ios" ? 0 : safeAreaInsets.top; | ||
| 515 | // Hand the file navigator to the workspace so it renders beside the | ||
| 516 | // navigator, outside this screen's native header. | ||
| 517 | const renderWorkspaceInspector = useCallback( | ||
| 518 | () => renderInspector(inspectorHeaderInset), | ||
| 519 | [inspectorHeaderInset, renderInspector], | ||
| 520 | ); | ||
| 521 | useRegisterWorkspaceInspector(fileInspector.supported ? renderWorkspaceInspector : undefined); | ||
| 522 | |||
| 571 | if (selectedThread === null || environmentId === null || threadId === null) { | 523 | if (selectedThread === null || environmentId === null || threadId === null) { |
| 572 | return <LoadingScreen message="Opening file..." messagePlacement="above-spinner" />; | 524 | return <LoadingScreen message="Opening file..." messagePlacement="above-spinner" />; |
| 573 | } | 525 | } |
| ... | @@ -579,38 +531,104 @@ export function ThreadFileScreen() { | ... | @@ -579,38 +531,104 @@ export function ThreadFileScreen() { |
| 579 | if (relativePath === null) { | 531 | if (relativePath === null) { |
| 580 | return ( | 532 | return ( |
| 581 | <View className="flex-1 items-center justify-center bg-sheet px-6"> | 533 | <View className="flex-1 items-center justify-center bg-sheet px-6"> |
| 582 | <Stack.Screen options={{ title: "Files" }} /> | 534 | <NativeStackScreenOptions options={{ title: "Files" }} /> |
| 583 | <EmptyState title="File unavailable" detail="This file path is invalid." /> | 535 | <EmptyState title="File unavailable" detail="This file path is invalid." /> |
| 584 | </View> | 536 | </View> |
| 585 | ); | 537 | ); |
| 586 | } | 538 | } |
| 587 | 539 | ||
| 540 | const parentDir = relativePath.split("/").slice(0, -1).join("/"); | ||
| 541 | const headerSubtitle = [projectName, parentDir].filter(Boolean).join(" · "); | ||
| 542 | |||
| 588 | return ( | 543 | return ( |
| 589 | <ReviewHighlighterProvider> | 544 | <ReviewHighlighterProvider> |
| 590 | <View className="flex-1 bg-sheet"> | 545 | <View className="flex-1 bg-sheet"> |
| 591 | <Stack.Screen options={{ title: basename(relativePath) }} /> | 546 | <NativeStackScreenOptions |
| 592 | <Stack.Toolbar placement="right"> | 547 | options={{ |
| 593 | <Stack.Toolbar.Button | 548 | // Static header config lives in Stack.tsx (SOLID_HEADER_OPTIONS: solid |
| 594 | icon="arrow.clockwise" | 549 | // sheet-colored header — this route's content scrolls internally, so |
| 595 | onPress={() => { | 550 | // there is nothing for glass to sample). Only dynamic values here. |
| 596 | if (resolvedActiveMode === "preview" && (isBrowserFile || isImageFile)) { | 551 | headerTintColor: iconColor, |
| 597 | setPreviewRevision((current) => current + 1); | 552 | headerTitle: basename(relativePath), |
| 598 | return; | 553 | title: basename(relativePath), |
| 599 | } | 554 | unstable_headerSubtitle: |
| 600 | fileQuery.refresh(); | 555 | Platform.OS === "ios" && headerSubtitle.length > 0 ? headerSubtitle : undefined, |
| 601 | }} | ||
| 602 | /> | ||
| 603 | </Stack.Toolbar> | ||
| 604 | <FilePreviewHeader | ||
| 605 | activeMode={resolvedActiveMode} | ||
| 606 | showModeSelector={canPreview && !isImageFile} | ||
| 607 | externalPreviewUri={isBrowserFile ? assetPreviewUri : undefined} | ||
| 608 | projectName={projectName} | ||
| 609 | relativePath={relativePath} | ||
| 610 | onSetMode={(mode) => { | ||
| 611 | setModeOverride({ path: relativePath, mode }); | ||
| 612 | }} | 556 | }} |
| 613 | /> | 557 | /> |
| 558 | <WorkspaceSidebarToolbar> | ||
| 559 | {fileInspector.supported ? ( | ||
| 560 | <NativeHeaderToolbar.Button | ||
| 561 | accessibilityLabel="Return to chat" | ||
| 562 | icon="chevron.left" | ||
| 563 | onPress={() => { | ||
| 564 | navigation.dispatch( | ||
| 565 | StackActions.replace("Thread", { | ||
| 566 | environmentId: String(environmentId), | ||
| 567 | threadId: String(threadId), | ||
| 568 | }), | ||
| 569 | ); | ||
| 570 | }} | ||
| 571 | /> | ||
| 572 | ) : null} | ||
| 573 | </WorkspaceSidebarToolbar> | ||
| 574 | <NativeHeaderToolbar placement="right"> | ||
| 575 | {fileInspector.supported ? ( | ||
| 576 | <NativeHeaderToolbar.Button | ||
| 577 | accessibilityLabel={ | ||
| 578 | panes.auxiliaryPaneVisible ? "Hide file navigator" : "Show file navigator" | ||
| 579 | } | ||
| 580 | icon="sidebar.right" | ||
| 581 | onPress={toggleAuxiliaryPane} | ||
| 582 | separateBackground | ||
| 583 | /> | ||
| 584 | ) : null} | ||
| 585 | <NativeHeaderToolbar.Menu accessibilityLabel="File actions" icon="ellipsis"> | ||
| 586 | {canPreview && !isImageFile ? ( | ||
| 587 | <NativeHeaderToolbar.Menu inline> | ||
| 588 | <NativeHeaderToolbar.MenuAction | ||
| 589 | icon="eye" | ||
| 590 | isOn={resolvedActiveMode === "preview"} | ||
| 591 | onPress={() => setModeOverride({ path: relativePath, mode: "preview" })} | ||
| 592 | > | ||
| 593 | Preview | ||
| 594 | </NativeHeaderToolbar.MenuAction> | ||
| 595 | <NativeHeaderToolbar.MenuAction | ||
| 596 | icon="doc.text" | ||
| 597 | isOn={resolvedActiveMode === "source"} | ||
| 598 | onPress={() => setModeOverride({ path: relativePath, mode: "source" })} | ||
| 599 | > | ||
| 600 | Source | ||
| 601 | </NativeHeaderToolbar.MenuAction> | ||
| 602 | </NativeHeaderToolbar.Menu> | ||
| 603 | ) : null} | ||
| 604 | <NativeHeaderToolbar.MenuAction | ||
| 605 | icon="doc.on.doc" | ||
| 606 | onPress={() => copyTextWithHaptic(relativePath)} | ||
| 607 | > | ||
| 608 | Copy path | ||
| 609 | </NativeHeaderToolbar.MenuAction> | ||
| 610 | {isBrowserFile && typeof assetPreviewUri === "string" ? ( | ||
| 611 | <NativeHeaderToolbar.MenuAction | ||
| 612 | icon="safari" | ||
| 613 | onPress={() => { | ||
| 614 | void tryOpenExternalUrl(assetPreviewUri, "file-preview"); | ||
| 615 | }} | ||
| 616 | > | ||
| 617 | Open in Safari | ||
| 618 | </NativeHeaderToolbar.MenuAction> | ||
| 619 | ) : null} | ||
| 620 | {resolvedActiveMode === "preview" && (isBrowserFile || isImageFile) ? ( | ||
| 621 | <NativeHeaderToolbar.MenuAction | ||
| 622 | icon="arrow.clockwise" | ||
| 623 | onPress={() => { | ||
| 624 | setPreviewRevision((current) => current + 1); | ||
| 625 | }} | ||
| 626 | > | ||
| 627 | Refresh | ||
| 628 | </NativeHeaderToolbar.MenuAction> | ||
| 629 | ) : null} | ||
| 630 | </NativeHeaderToolbar.Menu> | ||
| 631 | </NativeHeaderToolbar> | ||
| 614 | <FileContent | 632 | <FileContent |
| 615 | activeMode={resolvedActiveMode} | 633 | activeMode={resolvedActiveMode} |
| 616 | previewUri={previewUri} | 634 | previewUri={previewUri} |
| ... | @@ -619,6 +637,7 @@ export function ThreadFileScreen() { | ... | @@ -619,6 +637,7 @@ export function ThreadFileScreen() { |
| 619 | initialLine={targetLine} | 637 | initialLine={targetLine} |
| 620 | relativePath={relativePath} | 638 | relativePath={relativePath} |
| 621 | truncated={fileData?.truncated ?? false} | 639 | truncated={fileData?.truncated ?? false} |
| 640 | onRefresh={() => fileQuery.refresh()} | ||
| 622 | /> | 641 | /> |
| 623 | </View> | 642 | </View> |
| 624 | </ReviewHighlighterProvider> | 643 | </ReviewHighlighterProvider> |
apps/mobile/src/features/files/WorkspaceFileWebPreview.tsx+1-1| ... | @@ -24,7 +24,7 @@ export function WorkspaceFileWebPreview(props: { readonly uri: string | null }) | ... | @@ -24,7 +24,7 @@ export function WorkspaceFileWebPreview(props: { readonly uri: string | null }) |
| 24 | {loadError ? ( | 24 | {loadError ? ( |
| 25 | <View className="border-b border-border bg-card px-4 py-2"> | 25 | <View className="border-b border-border bg-card px-4 py-2"> |
| 26 | <Text className="text-xs font-t3-bold text-foreground">Preview failed</Text> | 26 | <Text className="text-xs font-t3-bold text-foreground">Preview failed</Text> |
| 27 | <Text className="mt-0.5 text-xs leading-[17px] text-foreground-muted">{loadError}</Text> | 27 | <Text className="mt-0.5 text-xs leading-snug text-foreground-muted">{loadError}</Text> |
| 28 | </View> | 28 | </View> |
| 29 | ) : null} | 29 | ) : null} |
| 30 | <WebView | 30 | <WebView |
apps/mobile/src/features/files/nativeSourceFileAdapter.ts+26-14| ... | @@ -3,26 +3,38 @@ import type { | ... | @@ -3,26 +3,38 @@ import type { |
| 3 | NativeReviewDiffStyle, | 3 | NativeReviewDiffStyle, |
| 4 | NativeReviewDiffToken, | 4 | NativeReviewDiffToken, |
| 5 | } from "../diffs/nativeReviewDiffSurface"; | 5 | } from "../diffs/nativeReviewDiffSurface"; |
| 6 | import type { ResolvedMobileCodeSurface } from "../../lib/appearancePreferences"; | ||
| 7 | import { resolveMobileCodeSurface } from "../../lib/appearancePreferences"; | ||
| 6 | import { MOBILE_CODE_SURFACE, MOBILE_TYPOGRAPHY } from "../../lib/typography"; | 8 | import { MOBILE_CODE_SURFACE, MOBILE_TYPOGRAPHY } from "../../lib/typography"; |
| 7 | import type { SourceHighlightTokens } from "./sourceHighlightingState"; | 9 | import type { SourceHighlightTokens } from "./sourceHighlightingState"; |
| 8 | 10 | ||
| 9 | export const NATIVE_SOURCE_ROW_HEIGHT = MOBILE_CODE_SURFACE.rowHeight; | 11 | export const NATIVE_SOURCE_ROW_HEIGHT = MOBILE_CODE_SURFACE.rowHeight; |
| 10 | export const NATIVE_SOURCE_CONTENT_WIDTH = 32_000; | 12 | export const NATIVE_SOURCE_CONTENT_WIDTH = 32_000; |
| 11 | 13 | ||
| 12 | export const NATIVE_SOURCE_STYLE: NativeReviewDiffStyle = { | 14 | export const NATIVE_SOURCE_STYLE: NativeReviewDiffStyle = createNativeSourceStyle( |
| 13 | rowHeight: NATIVE_SOURCE_ROW_HEIGHT, | 15 | resolveMobileCodeSurface(MOBILE_CODE_SURFACE.fontSize), |
| 14 | contentWidth: NATIVE_SOURCE_CONTENT_WIDTH, | 16 | ); |
| 15 | changeBarWidth: 0, | 17 | |
| 16 | gutterWidth: MOBILE_CODE_SURFACE.gutterWidth, | 18 | export function createNativeSourceStyle( |
| 17 | codePadding: MOBILE_CODE_SURFACE.codePadding, | 19 | codeSurface: ResolvedMobileCodeSurface, |
| 18 | textVerticalInset: MOBILE_CODE_SURFACE.textVerticalInset, | 20 | ): NativeReviewDiffStyle { |
| 19 | codeFontSize: MOBILE_CODE_SURFACE.fontSize, | 21 | return { |
| 20 | codeFontWeight: "regular", | 22 | rowHeight: codeSurface.rowHeight, |
| 21 | lineNumberFontSize: MOBILE_CODE_SURFACE.lineNumberFontSize, | 23 | contentWidth: NATIVE_SOURCE_CONTENT_WIDTH, |
| 22 | lineNumberFontWeight: "regular", | 24 | changeBarWidth: 0, |
| 23 | emptyStateFontSize: MOBILE_TYPOGRAPHY.label.fontSize, | 25 | gutterWidth: codeSurface.gutterWidth, |
| 24 | emptyStateFontWeight: "medium", | 26 | codePadding: codeSurface.codePadding, |
| 25 | }; | 27 | textVerticalInset: codeSurface.textVerticalInset, |
| 28 | codeFontSize: codeSurface.fontSize, | ||
| 29 | codeFontWeight: "regular", | ||
| 30 | lineNumberFontSize: codeSurface.lineNumberFontSize, | ||
| 31 | lineNumberFontWeight: "regular", | ||
| 32 | emptyStateFontSize: Math.round( | ||
| 33 | MOBILE_TYPOGRAPHY.label.fontSize * (codeSurface.fontSize / MOBILE_CODE_SURFACE.fontSize), | ||
| 34 | ), | ||
| 35 | emptyStateFontWeight: "medium", | ||
| 36 | }; | ||
| 37 | } | ||
| 26 | 38 | ||
| 27 | const SOURCE_FILE_ID = "source-file"; | 39 | const SOURCE_FILE_ID = "source-file"; |
| 28 | 40 |
apps/mobile/src/features/files/preload-workspace-file.ts created+74| ... | @@ -0,0 +1,74 @@ | ||
| 1 | import { executeAtomQuery } from "@t3tools/client-runtime/state/runtime"; | ||
| 2 | import type { EnvironmentId } from "@t3tools/contracts"; | ||
| 3 | |||
| 4 | import { appAtomRegistry } from "../../state/atom-registry"; | ||
| 5 | import { projectEnvironment } from "../../state/projects"; | ||
| 6 | import { isBrowserPreviewFile, isImagePreviewFile } from "./filePath"; | ||
| 7 | import { prepareSourceFileDocument } from "./source-file-document"; | ||
| 8 | import { sourceHighlightAtom } from "./sourceHighlightingState"; | ||
| 9 | import type { ReviewDiffTheme } from "../review/shikiReviewHighlighter"; | ||
| 10 | |||
| 11 | const inFlightPreloads = new Map<string, Promise<void>>(); | ||
| 12 | const MAX_HIGHLIGHT_PRELOAD_CHARACTERS = 256 * 1024; | ||
| 13 | |||
| 14 | function preloadKey(input: { | ||
| 15 | readonly cwd: string; | ||
| 16 | readonly environmentId: EnvironmentId; | ||
| 17 | readonly relativePath: string; | ||
| 18 | }): string { | ||
| 19 | return JSON.stringify([input.environmentId, input.cwd, input.relativePath]); | ||
| 20 | } | ||
| 21 | |||
| 22 | export function preloadWorkspaceFileContents(input: { | ||
| 23 | readonly cwd: string; | ||
| 24 | readonly environmentId: EnvironmentId; | ||
| 25 | readonly relativePath: string; | ||
| 26 | readonly theme: ReviewDiffTheme; | ||
| 27 | }): void { | ||
| 28 | if (isBrowserPreviewFile(input.relativePath) || isImagePreviewFile(input.relativePath)) { | ||
| 29 | return; | ||
| 30 | } | ||
| 31 | |||
| 32 | const key = preloadKey(input); | ||
| 33 | if (inFlightPreloads.has(key)) { | ||
| 34 | return; | ||
| 35 | } | ||
| 36 | |||
| 37 | const preload = executeAtomQuery( | ||
| 38 | appAtomRegistry, | ||
| 39 | projectEnvironment.readFile({ | ||
| 40 | environmentId: input.environmentId, | ||
| 41 | input: { cwd: input.cwd, relativePath: input.relativePath }, | ||
| 42 | }), | ||
| 43 | { | ||
| 44 | label: "workspace file preload", | ||
| 45 | reportDefect: false, | ||
| 46 | reportFailure: false, | ||
| 47 | }, | ||
| 48 | ) | ||
| 49 | .then(async (result) => { | ||
| 50 | if (result._tag === "Success") { | ||
| 51 | const document = prepareSourceFileDocument(result.value.contents); | ||
| 52 | if (document.contents.length <= MAX_HIGHLIGHT_PRELOAD_CHARACTERS) { | ||
| 53 | await executeAtomQuery( | ||
| 54 | appAtomRegistry, | ||
| 55 | sourceHighlightAtom({ | ||
| 56 | path: input.relativePath, | ||
| 57 | contents: document.contents, | ||
| 58 | theme: input.theme, | ||
| 59 | }), | ||
| 60 | { | ||
| 61 | label: "workspace source highlight preload", | ||
| 62 | reportDefect: false, | ||
| 63 | reportFailure: false, | ||
| 64 | }, | ||
| 65 | ); | ||
| 66 | } | ||
| 67 | } | ||
| 68 | }) | ||
| 69 | .finally(() => { | ||
| 70 | inFlightPreloads.delete(key); | ||
| 71 | }); | ||
| 72 | |||
| 73 | inFlightPreloads.set(key, preload); | ||
| 74 | } | ||
apps/mobile/src/features/files/source-file-document.test.ts created+16| ... | @@ -0,0 +1,16 @@ | ||
| 1 | import { describe, expect, it } from "vite-plus/test"; | ||
| 2 | |||
| 3 | import { prepareSourceFileDocument } from "./source-file-document"; | ||
| 4 | |||
| 5 | describe("prepareSourceFileDocument", () => { | ||
| 6 | it("normalizes and serializes source rows once for repeated consumers", () => { | ||
| 7 | const first = prepareSourceFileDocument("const value = 1;\r\n\tvalue;\r"); | ||
| 8 | const second = prepareSourceFileDocument("const value = 1;\r\n\tvalue;\r"); | ||
| 9 | const rows = JSON.parse(first.rowsJson) as ReadonlyArray<{ readonly content: string }>; | ||
| 10 | |||
| 11 | expect(first.contents).toBe("const value = 1;\n\tvalue;\n"); | ||
| 12 | expect(first.lines).toEqual(["const value = 1;", "\tvalue;", ""]); | ||
| 13 | expect(rows.map((row) => row.content)).toEqual(["const value = 1;", " value;", ""]); | ||
| 14 | expect(second).toBe(first); | ||
| 15 | }); | ||
| 16 | }); | ||
apps/mobile/src/features/files/source-file-document.ts created+54| ... | @@ -0,0 +1,54 @@ | ||
| 1 | import { buildNativeSourceRows } from "./nativeSourceFileAdapter"; | ||
| 2 | |||
| 3 | const MAX_CACHED_DOCUMENTS = 8; | ||
| 4 | const MAX_CACHED_CHARACTERS = 4 * 1024 * 1024; | ||
| 5 | |||
| 6 | export interface SourceFileDocument { | ||
| 7 | readonly contents: string; | ||
| 8 | readonly lines: ReadonlyArray<string>; | ||
| 9 | readonly rowsJson: string; | ||
| 10 | } | ||
| 11 | |||
| 12 | const documentCache = new Map<string, SourceFileDocument>(); | ||
| 13 | let cachedCharacterCount = 0; | ||
| 14 | |||
| 15 | function removeOldestCachedDocument(): void { | ||
| 16 | const oldestKey = documentCache.keys().next().value; | ||
| 17 | if (typeof oldestKey !== "string") { | ||
| 18 | return; | ||
| 19 | } | ||
| 20 | const document = documentCache.get(oldestKey); | ||
| 21 | documentCache.delete(oldestKey); | ||
| 22 | cachedCharacterCount -= (document?.contents.length ?? 0) + (document?.rowsJson.length ?? 0); | ||
| 23 | } | ||
| 24 | |||
| 25 | export function prepareSourceFileDocument(contents: string): SourceFileDocument { | ||
| 26 | const cached = documentCache.get(contents); | ||
| 27 | if (cached !== undefined) { | ||
| 28 | documentCache.delete(contents); | ||
| 29 | documentCache.set(contents, cached); | ||
| 30 | return cached; | ||
| 31 | } | ||
| 32 | |||
| 33 | const normalizedContents = contents.replace(/\r\n?/g, "\n"); | ||
| 34 | const lines = normalizedContents.split("\n"); | ||
| 35 | const document = { | ||
| 36 | contents: normalizedContents, | ||
| 37 | lines, | ||
| 38 | rowsJson: JSON.stringify(buildNativeSourceRows(lines)), | ||
| 39 | } satisfies SourceFileDocument; | ||
| 40 | const characterCount = document.contents.length + document.rowsJson.length; | ||
| 41 | |||
| 42 | if (characterCount <= MAX_CACHED_CHARACTERS) { | ||
| 43 | while ( | ||
| 44 | documentCache.size >= MAX_CACHED_DOCUMENTS || | ||
| 45 | cachedCharacterCount + characterCount > MAX_CACHED_CHARACTERS | ||
| 46 | ) { | ||
| 47 | removeOldestCachedDocument(); | ||
| 48 | } | ||
| 49 | documentCache.set(contents, document); | ||
| 50 | cachedCharacterCount += characterCount; | ||
| 51 | } | ||
| 52 | |||
| 53 | return document; | ||
| 54 | } | ||
apps/mobile/src/features/files/sourceHighlightingState.test.ts+1-1| ... | @@ -88,7 +88,7 @@ describe("sourceHighlightingState", () => { | ... | @@ -88,7 +88,7 @@ describe("sourceHighlightingState", () => { |
| 88 | expect(AsyncResult.isSuccess(registry.get(atom))).toBe(true); | 88 | expect(AsyncResult.isSuccess(registry.get(atom))).toBe(true); |
| 89 | }); | 89 | }); |
| 90 | firstUnmount(); | 90 | firstUnmount(); |
| 91 | await new Promise((resolve) => setTimeout(resolve, 25)); | 91 | await new Promise((resolve) => setTimeout(resolve, 100)); |
| 92 | 92 | ||
| 93 | const secondUnmount = registry.mount(atom); | 93 | const secondUnmount = registry.mount(atom); |
| 94 | await vi.waitFor(() => { | 94 | await vi.waitFor(() => { |
apps/mobile/src/features/files/thread-file-navigator-pane.tsx created+175| ... | @@ -0,0 +1,175 @@ | ||
| 1 | import type { EnvironmentId, ProjectListEntriesResult } from "@t3tools/contracts"; | ||
| 2 | import { SymbolView } from "expo-symbols"; | ||
| 3 | import { useCallback, useMemo, useState, type ComponentProps } from "react"; | ||
| 4 | import { Platform, Pressable, useColorScheme, View, type NativeSyntheticEvent } from "react-native"; | ||
| 5 | import { | ||
| 6 | Screen, | ||
| 7 | ScreenStack, | ||
| 8 | ScreenStackHeaderConfig, | ||
| 9 | ScreenStackHeaderSearchBarView, | ||
| 10 | SearchBar, | ||
| 11 | } from "react-native-screens"; | ||
| 12 | |||
| 13 | import { AppText as Text, AppTextInput as TextInput } from "../../components/AppText"; | ||
| 14 | import { nativeHeaderScrollEdgeEffects } from "../../native/StackHeader"; | ||
| 15 | import { useThemeColor } from "../../lib/useThemeColor"; | ||
| 16 | import { projectEnvironment } from "../../state/projects"; | ||
| 17 | import { useEnvironmentQuery } from "../../state/query"; | ||
| 18 | import { FileTreeBrowser } from "./FileTreeBrowser"; | ||
| 19 | import { preloadWorkspaceFileContents } from "./preload-workspace-file"; | ||
| 20 | |||
| 21 | export function ThreadFileNavigatorPane(props: { | ||
| 22 | readonly cwd: string; | ||
| 23 | readonly environmentId: EnvironmentId; | ||
| 24 | readonly headerInset: number; | ||
| 25 | readonly projectName: string; | ||
| 26 | readonly selectedPath: string | null; | ||
| 27 | readonly onSelectFile: (path: string) => void; | ||
| 28 | }) { | ||
| 29 | const [searchQuery, setSearchQuery] = useState(""); | ||
| 30 | const colorScheme = useColorScheme(); | ||
| 31 | const highlightTheme = colorScheme === "dark" ? "dark" : "light"; | ||
| 32 | const iconColor = String(useThemeColor("--color-icon-muted")); | ||
| 33 | const foregroundColor = String(useThemeColor("--color-foreground")); | ||
| 34 | const sheetColor = String(useThemeColor("--color-sheet")); | ||
| 35 | const headerScrollEdgeEffects = nativeHeaderScrollEdgeEffects(Platform.OS, Platform.Version); | ||
| 36 | const entriesQuery = useEnvironmentQuery( | ||
| 37 | projectEnvironment.listEntries({ | ||
| 38 | environmentId: props.environmentId, | ||
| 39 | input: { cwd: props.cwd }, | ||
| 40 | }), | ||
| 41 | ); | ||
| 42 | const entriesData = entriesQuery.data as ProjectListEntriesResult | null; | ||
| 43 | const handlePreviewFile = useCallback( | ||
| 44 | (relativePath: string) => { | ||
| 45 | preloadWorkspaceFileContents({ | ||
| 46 | cwd: props.cwd, | ||
| 47 | environmentId: props.environmentId, | ||
| 48 | relativePath, | ||
| 49 | theme: highlightTheme, | ||
| 50 | }); | ||
| 51 | }, | ||
| 52 | [highlightTheme, props.cwd, props.environmentId], | ||
| 53 | ); | ||
| 54 | const nativeHeaderRightBarButtonItems = useMemo( | ||
| 55 | () => | ||
| 56 | [ | ||
| 57 | { | ||
| 58 | accessibilityLabel: "Refresh files", | ||
| 59 | icon: { name: "arrow.clockwise", type: "sfSymbol" as const }, | ||
| 60 | identifier: "thread-file-navigator-refresh", | ||
| 61 | onPress: entriesQuery.refresh, | ||
| 62 | sharesBackground: false, | ||
| 63 | tintColor: foregroundColor, | ||
| 64 | type: "button" as const, | ||
| 65 | width: 44, | ||
| 66 | }, | ||
| 67 | ] as ComponentProps<typeof ScreenStackHeaderConfig>["headerRightBarButtonItems"], | ||
| 68 | [entriesQuery.refresh, foregroundColor], | ||
| 69 | ); | ||
| 70 | |||
| 71 | const fileTree = ( | ||
| 72 | <FileTreeBrowser | ||
| 73 | entries={entriesData?.entries ?? []} | ||
| 74 | error={entriesQuery.error} | ||
| 75 | isPending={entriesQuery.isPending} | ||
| 76 | searchQuery={searchQuery} | ||
| 77 | selectedPath={props.selectedPath} | ||
| 78 | onPreviewFile={handlePreviewFile} | ||
| 79 | onRefresh={entriesQuery.refresh} | ||
| 80 | onSelectFile={props.onSelectFile} | ||
| 81 | /> | ||
| 82 | ); | ||
| 83 | |||
| 84 | if (Platform.OS === "ios") { | ||
| 85 | return ( | ||
| 86 | <View className="flex-1 border-l border-border bg-sheet"> | ||
| 87 | <ScreenStack style={{ flex: 1 }}> | ||
| 88 | <Screen | ||
| 89 | activityState={2} | ||
| 90 | enabled | ||
| 91 | isNativeStack | ||
| 92 | screenId="thread-file-navigator-native" | ||
| 93 | scrollEdgeEffects={headerScrollEdgeEffects} | ||
| 94 | style={{ backgroundColor: sheetColor, flex: 1 }} | ||
| 95 | > | ||
| 96 | {fileTree} | ||
| 97 | <ScreenStackHeaderConfig | ||
| 98 | backgroundColor="rgba(0,0,0,0)" | ||
| 99 | color={foregroundColor} | ||
| 100 | headerRightBarButtonItems={nativeHeaderRightBarButtonItems} | ||
| 101 | hideBackButton | ||
| 102 | hideShadow={false} | ||
| 103 | navigationItemStyle="editor" | ||
| 104 | subtitle={props.projectName} | ||
| 105 | title="Files" | ||
| 106 | titleColor={foregroundColor} | ||
| 107 | titleFontSize={17} | ||
| 108 | titleFontWeight="700" | ||
| 109 | translucent | ||
| 110 | > | ||
| 111 | <ScreenStackHeaderSearchBarView> | ||
| 112 | <SearchBar | ||
| 113 | allowToolbarIntegration | ||
| 114 | autoCapitalize="none" | ||
| 115 | barTintColor={sheetColor} | ||
| 116 | hideNavigationBar={false} | ||
| 117 | hideWhenScrolling={false} | ||
| 118 | obscureBackground={false} | ||
| 119 | onCancelButtonPress={() => { | ||
| 120 | setSearchQuery(""); | ||
| 121 | }} | ||
| 122 | onChangeText={(event: NativeSyntheticEvent<{ readonly text?: string }>) => { | ||
| 123 | setSearchQuery(event.nativeEvent.text ?? ""); | ||
| 124 | }} | ||
| 125 | placement="integratedButton" | ||
| 126 | placeholder="Search files" | ||
| 127 | textColor={foregroundColor} | ||
| 128 | tintColor={foregroundColor} | ||
| 129 | /> | ||
| 130 | </ScreenStackHeaderSearchBarView> | ||
| 131 | </ScreenStackHeaderConfig> | ||
| 132 | </Screen> | ||
| 133 | </ScreenStack> | ||
| 134 | </View> | ||
| 135 | ); | ||
| 136 | } | ||
| 137 | |||
| 138 | return ( | ||
| 139 | <View className="flex-1 border-l border-border bg-sheet"> | ||
| 140 | <View className="border-b border-border" style={{ paddingTop: props.headerInset }}> | ||
| 141 | <View className="h-12 flex-row items-center gap-2 px-3"> | ||
| 142 | <View className="min-w-0 flex-1"> | ||
| 143 | <Text className="text-sm font-t3-bold text-foreground">Files</Text> | ||
| 144 | <Text className="text-xs text-foreground-muted" numberOfLines={1}> | ||
| 145 | {props.projectName} | ||
| 146 | </Text> | ||
| 147 | </View> | ||
| 148 | <Pressable | ||
| 149 | accessibilityRole="button" | ||
| 150 | accessibilityLabel="Refresh files" | ||
| 151 | hitSlop={8} | ||
| 152 | className="h-8 w-8 items-center justify-center rounded-full active:bg-subtle" | ||
| 153 | onPress={entriesQuery.refresh} | ||
| 154 | > | ||
| 155 | <SymbolView name="arrow.clockwise" size={14} tintColor={iconColor} type="monochrome" /> | ||
| 156 | </Pressable> | ||
| 157 | </View> | ||
| 158 | <View className="flex-row items-center gap-2 border-t border-border px-3 py-2"> | ||
| 159 | <SymbolView name="magnifyingglass" size={15} tintColor={iconColor} type="monochrome" /> | ||
| 160 | <TextInput | ||
| 161 | accessibilityLabel="Search files" | ||
| 162 | autoCapitalize="none" | ||
| 163 | autoCorrect={false} | ||
| 164 | clearButtonMode="while-editing" | ||
| 165 | className="min-h-10 flex-1 rounded-xl py-2 text-sm" | ||
| 166 | placeholder="Search files" | ||
| 167 | value={searchQuery} | ||
| 168 | onChangeText={setSearchQuery} | ||
| 169 | /> | ||
| 170 | </View> | ||
| 171 | </View> | ||
| 172 | {fileTree} | ||
| 173 | </View> | ||
| 174 | ); | ||
| 175 | } | ||
apps/mobile/src/features/home/HomeHeader.tsx+169-203| ... | @@ -3,60 +3,30 @@ import type { | ... | @@ -3,60 +3,30 @@ import type { |
| 3 | SidebarProjectGroupingMode, | 3 | SidebarProjectGroupingMode, |
| 4 | SidebarThreadSortOrder, | 4 | SidebarThreadSortOrder, |
| 5 | } from "@t3tools/contracts"; | 5 | } from "@t3tools/contracts"; |
| 6 | import { | 6 | import { NativeHeaderToolbar, NativeStackScreenOptions } from "../../native/StackHeader"; |
| 7 | DEFAULT_SIDEBAR_PROJECT_GROUPING_MODE, | 7 | import { useCallback, useRef } from "react"; |
| 8 | DEFAULT_SIDEBAR_PROJECT_SORT_ORDER, | 8 | import { Platform } from "react-native"; |
| 9 | DEFAULT_SIDEBAR_THREAD_SORT_ORDER, | 9 | import type { SearchBarCommands } from "react-native-screens"; |
| 10 | } from "@t3tools/contracts"; | ||
| 11 | import { Stack } from "expo-router"; | ||
| 12 | import { Text as RNText, View } from "react-native"; | ||
| 13 | 10 | ||
| 11 | import { nativeHeaderScrollEdgeEffects } from "../../native/StackHeader"; | ||
| 14 | import { useThemeColor } from "../../lib/useThemeColor"; | 12 | import { useThemeColor } from "../../lib/useThemeColor"; |
| 15 | import { MOBILE_TYPOGRAPHY } from "../../lib/typography"; | 13 | import { useHardwareKeyboardCommand } from "../keyboard/hardwareKeyboardCommands"; |
| 14 | import { withNativeGlassHeaderItem } from "../layout/native-glass-header-items"; | ||
| 15 | import { createNativeMailSearchToolbarItem } from "../layout/native-mail-search-toolbar"; | ||
| 16 | import type { HomeProjectSortOrder } from "./homeThreadList"; | 16 | import type { HomeProjectSortOrder } from "./homeThreadList"; |
| 17 | import { | ||
| 18 | buildHomeListFilterMenu, | ||
| 19 | type HomeListFilterMenuEnvironment, | ||
| 20 | } from "./home-list-filter-menu"; | ||
| 21 | import { | ||
| 22 | hasCustomHomeListOptions, | ||
| 23 | PROJECT_GROUPING_OPTIONS, | ||
| 24 | PROJECT_SORT_OPTIONS, | ||
| 25 | THREAD_SORT_OPTIONS, | ||
| 26 | } from "./home-list-options"; | ||
| 17 | 27 | ||
| 18 | export interface HomeHeaderEnvironment { | 28 | export type HomeHeaderEnvironment = HomeListFilterMenuEnvironment; |
| 19 | readonly environmentId: EnvironmentId; | 29 | const HEADER_SCROLL_EDGE_EFFECTS = nativeHeaderScrollEdgeEffects(Platform.OS, Platform.Version); |
| 20 | readonly label: string; | ||
| 21 | } | ||
| 22 | |||
| 23 | const PROJECT_SORT_OPTIONS: ReadonlyArray<{ | ||
| 24 | readonly value: HomeProjectSortOrder; | ||
| 25 | readonly label: string; | ||
| 26 | }> = [ | ||
| 27 | { value: "updated_at", label: "Last user message" }, | ||
| 28 | { value: "created_at", label: "Created at" }, | ||
| 29 | ]; | ||
| 30 | |||
| 31 | const THREAD_SORT_OPTIONS: ReadonlyArray<{ | ||
| 32 | readonly value: SidebarThreadSortOrder; | ||
| 33 | readonly label: string; | ||
| 34 | }> = [ | ||
| 35 | { value: "updated_at", label: "Last user message" }, | ||
| 36 | { value: "created_at", label: "Created at" }, | ||
| 37 | ]; | ||
| 38 | |||
| 39 | const PROJECT_GROUPING_OPTIONS: ReadonlyArray<{ | ||
| 40 | readonly value: SidebarProjectGroupingMode; | ||
| 41 | readonly label: string; | ||
| 42 | readonly subtitle: string; | ||
| 43 | }> = [ | ||
| 44 | { | ||
| 45 | value: "repository", | ||
| 46 | label: "Group by repository", | ||
| 47 | subtitle: "Combine matching repositories across environments", | ||
| 48 | }, | ||
| 49 | { | ||
| 50 | value: "repository_path", | ||
| 51 | label: "Group by repository path", | ||
| 52 | subtitle: "Combine only matching paths within a repository", | ||
| 53 | }, | ||
| 54 | { | ||
| 55 | value: "separate", | ||
| 56 | label: "Keep separate", | ||
| 57 | subtitle: "Show every project path separately", | ||
| 58 | }, | ||
| 59 | ]; | ||
| 60 | 30 | ||
| 61 | export function HomeHeader(props: { | 31 | export function HomeHeader(props: { |
| 62 | readonly environments: ReadonlyArray<HomeHeaderEnvironment>; | 32 | readonly environments: ReadonlyArray<HomeHeaderEnvironment>; |
| ... | @@ -72,174 +42,170 @@ export function HomeHeader(props: { | ... | @@ -72,174 +42,170 @@ export function HomeHeader(props: { |
| 72 | readonly onOpenSettings: () => void; | 42 | readonly onOpenSettings: () => void; |
| 73 | readonly onStartNewTask: () => void; | 43 | readonly onStartNewTask: () => void; |
| 74 | }) { | 44 | }) { |
| 45 | const searchBarRef = useRef<SearchBarCommands>(null); | ||
| 75 | const iconColor = useThemeColor("--color-icon"); | 46 | const iconColor = useThemeColor("--color-icon"); |
| 76 | const mutedColor = useThemeColor("--color-foreground-muted"); | 47 | const hasCustomListOptions = hasCustomHomeListOptions(props); |
| 77 | const subtleColor = useThemeColor("--color-subtle"); | 48 | const focusSearch = useCallback(() => { |
| 78 | const hasCustomListOptions = | 49 | searchBarRef.current?.focus(); |
| 79 | props.selectedEnvironmentId !== null || | 50 | return searchBarRef.current !== null; |
| 80 | props.projectSortOrder !== DEFAULT_SIDEBAR_PROJECT_SORT_ORDER || | 51 | }, []); |
| 81 | props.threadSortOrder !== DEFAULT_SIDEBAR_THREAD_SORT_ORDER || | 52 | useHardwareKeyboardCommand("focusSearch", focusSearch); |
| 82 | props.projectGroupingMode !== DEFAULT_SIDEBAR_PROJECT_GROUPING_MODE; | 53 | const filterMenu = buildHomeListFilterMenu(props); |
| 83 | 54 | ||
| 84 | return ( | 55 | return ( |
| 85 | <> | 56 | <> |
| 86 | <Stack.Screen | 57 | <NativeStackScreenOptions |
| 87 | options={{ | 58 | options={{ |
| 88 | headerShown: true, | 59 | // Static header config (glass, title, fonts) lives in Stack.tsx |
| 89 | headerTransparent: true, | 60 | // (GLASS_HEADER_OPTIONS). Only dynamic values are set here. |
| 90 | headerStyle: { backgroundColor: "transparent" }, | ||
| 91 | headerShadowVisible: false, | ||
| 92 | headerTintColor: iconColor, | 61 | headerTintColor: iconColor, |
| 93 | headerTitle: "", | 62 | unstable_headerRightItems: |
| 94 | headerSearchBarOptions: { | 63 | Platform.OS === "ios" |
| 95 | placeholder: "Search threads", | 64 | ? () => [ |
| 96 | hideNavigationBar: false, | 65 | withNativeGlassHeaderItem({ |
| 97 | onChangeText: (event) => { | 66 | accessibilityLabel: "Open settings", |
| 98 | props.onSearchQueryChange(event.nativeEvent.text); | 67 | icon: { name: "ellipsis", type: "sfSymbol" } as const, |
| 99 | }, | 68 | identifier: "home-settings", |
| 100 | onCancelButtonPress: () => { | 69 | label: "", |
| 101 | props.onSearchQueryChange(""); | 70 | onPress: props.onOpenSettings, |
| 102 | }, | 71 | type: "button", |
| 103 | allowToolbarIntegration: true, | 72 | }), |
| 104 | }, | 73 | ] |
| 74 | : undefined, | ||
| 75 | unstable_headerToolbarItems: | ||
| 76 | Platform.OS === "ios" | ||
| 77 | ? () => [ | ||
| 78 | createNativeMailSearchToolbarItem({ | ||
| 79 | composeButtonId: "home-new-task", | ||
| 80 | composeSystemImageName: "square.and.pencil", | ||
| 81 | filterMenu, | ||
| 82 | filterButtonId: "home-filter", | ||
| 83 | filterSystemImageName: hasCustomListOptions | ||
| 84 | ? "line.3.horizontal.decrease.circle.fill" | ||
| 85 | : "line.3.horizontal.decrease", | ||
| 86 | onComposePress: props.onStartNewTask, | ||
| 87 | onSearchTextChange: props.onSearchQueryChange, | ||
| 88 | placeholder: "Search", | ||
| 89 | searchTextChangeId: "home-search-text", | ||
| 90 | }), | ||
| 91 | ] | ||
| 92 | : undefined, | ||
| 93 | headerSearchBarOptions: | ||
| 94 | Platform.OS === "ios" | ||
| 95 | ? undefined | ||
| 96 | : { | ||
| 97 | ref: searchBarRef, | ||
| 98 | allowToolbarIntegration: true, | ||
| 99 | hideNavigationBar: false, | ||
| 100 | placeholder: "Search", | ||
| 101 | onCancelButtonPress: () => { | ||
| 102 | props.onSearchQueryChange(""); | ||
| 103 | }, | ||
| 104 | onChangeText: (event) => { | ||
| 105 | props.onSearchQueryChange(event.nativeEvent.text); | ||
| 106 | }, | ||
| 107 | }, | ||
| 105 | }} | 108 | }} |
| 106 | /> | 109 | /> |
| 107 | 110 | ||
| 108 | <Stack.Toolbar placement="left"> | 111 | {Platform.OS === "ios" ? null : ( |
| 109 | <Stack.Toolbar.View hidesSharedBackground> | 112 | <NativeHeaderToolbar placement="right"> |
| 110 | <View | 113 | <NativeHeaderToolbar.Button |
| 111 | style={{ | 114 | accessibilityLabel="Open settings" |
| 112 | width: 128, | 115 | icon="gearshape" |
| 113 | height: 32, | 116 | onPress={props.onOpenSettings} |
| 114 | flexDirection: "row", | 117 | separateBackground |
| 115 | alignItems: "center", | 118 | /> |
| 116 | gap: 8, | 119 | </NativeHeaderToolbar> |
| 117 | }} | 120 | )} |
| 118 | > | ||
| 119 | <RNText | ||
| 120 | style={{ | ||
| 121 | fontFamily: "DMSans_700Bold", | ||
| 122 | fontSize: MOBILE_TYPOGRAPHY.headline.fontSize, | ||
| 123 | color: iconColor, | ||
| 124 | letterSpacing: -0.4, | ||
| 125 | }} | ||
| 126 | > | ||
| 127 | T3 Code | ||
| 128 | </RNText> | ||
| 129 | <View | ||
| 130 | style={{ | ||
| 131 | backgroundColor: subtleColor, | ||
| 132 | borderRadius: 99, | ||
| 133 | paddingHorizontal: 8, | ||
| 134 | paddingVertical: 3, | ||
| 135 | }} | ||
| 136 | > | ||
| 137 | <RNText | ||
| 138 | style={{ | ||
| 139 | fontFamily: "DMSans_700Bold", | ||
| 140 | fontSize: MOBILE_TYPOGRAPHY.micro.fontSize, | ||
| 141 | color: mutedColor, | ||
| 142 | letterSpacing: 1.1, | ||
| 143 | textTransform: "uppercase", | ||
| 144 | }} | ||
| 145 | > | ||
| 146 | Alpha | ||
| 147 | </RNText> | ||
| 148 | </View> | ||
| 149 | </View> | ||
| 150 | </Stack.Toolbar.View> | ||
| 151 | </Stack.Toolbar> | ||
| 152 | |||
| 153 | <Stack.Toolbar placement="right"> | ||
| 154 | <Stack.Toolbar.Menu | ||
| 155 | accessibilityLabel="Filter and sort threads" | ||
| 156 | icon={ | ||
| 157 | hasCustomListOptions | ||
| 158 | ? "line.3.horizontal.decrease.circle.fill" | ||
| 159 | : "line.3.horizontal.decrease.circle" | ||
| 160 | } | ||
| 161 | separateBackground | ||
| 162 | title="Thread list options" | ||
| 163 | > | ||
| 164 | <Stack.Toolbar.Menu title="Environment"> | ||
| 165 | <Stack.Toolbar.Label>Environment</Stack.Toolbar.Label> | ||
| 166 | <Stack.Toolbar.MenuAction | ||
| 167 | isOn={props.selectedEnvironmentId === null} | ||
| 168 | onPress={() => props.onEnvironmentChange(null)} | ||
| 169 | subtitle="Show threads from every environment" | ||
| 170 | > | ||
| 171 | <Stack.Toolbar.Label>All environments</Stack.Toolbar.Label> | ||
| 172 | </Stack.Toolbar.MenuAction> | ||
| 173 | {props.environments.map((environment) => ( | ||
| 174 | <Stack.Toolbar.MenuAction | ||
| 175 | key={environment.environmentId} | ||
| 176 | isOn={props.selectedEnvironmentId === environment.environmentId} | ||
| 177 | onPress={() => props.onEnvironmentChange(environment.environmentId)} | ||
| 178 | > | ||
| 179 | <Stack.Toolbar.Label>{environment.label}</Stack.Toolbar.Label> | ||
| 180 | </Stack.Toolbar.MenuAction> | ||
| 181 | ))} | ||
| 182 | </Stack.Toolbar.Menu> | ||
| 183 | 121 | ||
| 184 | <Stack.Toolbar.Menu title="Sort projects"> | 122 | {Platform.OS === "ios" ? null : ( |
| 185 | <Stack.Toolbar.Label>Sort projects</Stack.Toolbar.Label> | 123 | <NativeHeaderToolbar placement="bottom"> |
| 186 | {PROJECT_SORT_OPTIONS.map((option) => ( | 124 | <NativeHeaderToolbar.Menu |
| 187 | <Stack.Toolbar.MenuAction | 125 | accessibilityLabel="Filter and sort threads" |
| 188 | key={option.value} | 126 | icon={ |
| 189 | isOn={props.projectSortOrder === option.value} | 127 | hasCustomListOptions |
| 190 | onPress={() => props.onProjectSortOrderChange(option.value)} | 128 | ? "line.3.horizontal.decrease.circle.fill" |
| 191 | > | 129 | : "line.3.horizontal.decrease.circle" |
| 192 | <Stack.Toolbar.Label>{option.label}</Stack.Toolbar.Label> | 130 | } |
| 193 | </Stack.Toolbar.MenuAction> | 131 | title="Thread list options" |
| 194 | ))} | 132 | separateBackground |
| 195 | </Stack.Toolbar.Menu> | 133 | > |
| 134 | <NativeHeaderToolbar.MenuAction onPress={props.onOpenSettings}> | ||
| 135 | <NativeHeaderToolbar.Label>Settings</NativeHeaderToolbar.Label> | ||
| 136 | </NativeHeaderToolbar.MenuAction> | ||
| 196 | 137 | ||
| 197 | <Stack.Toolbar.Menu title="Sort threads"> | 138 | <NativeHeaderToolbar.Menu title="Environment"> |
| 198 | <Stack.Toolbar.Label>Sort threads</Stack.Toolbar.Label> | 139 | <NativeHeaderToolbar.Label>Environment</NativeHeaderToolbar.Label> |
| 199 | {THREAD_SORT_OPTIONS.map((option) => ( | 140 | <NativeHeaderToolbar.MenuAction |
| 200 | <Stack.Toolbar.MenuAction | 141 | isOn={props.selectedEnvironmentId === null} |
| 201 | key={option.value} | 142 | onPress={() => props.onEnvironmentChange(null)} |
| 202 | isOn={props.threadSortOrder === option.value} | 143 | subtitle="Show threads from every environment" |
| 203 | onPress={() => props.onThreadSortOrderChange(option.value)} | ||
| 204 | > | 144 | > |
| 205 | <Stack.Toolbar.Label>{option.label}</Stack.Toolbar.Label> | 145 | <NativeHeaderToolbar.Label>All environments</NativeHeaderToolbar.Label> |
| 206 | </Stack.Toolbar.MenuAction> | 146 | </NativeHeaderToolbar.MenuAction> |
| 207 | ))} | 147 | {props.environments.map((environment) => ( |
| 208 | </Stack.Toolbar.Menu> | 148 | <NativeHeaderToolbar.MenuAction |
| 149 | key={environment.environmentId} | ||
| 150 | isOn={props.selectedEnvironmentId === environment.environmentId} | ||
| 151 | onPress={() => props.onEnvironmentChange(environment.environmentId)} | ||
| 152 | > | ||
| 153 | <NativeHeaderToolbar.Label>{environment.label}</NativeHeaderToolbar.Label> | ||
| 154 | </NativeHeaderToolbar.MenuAction> | ||
| 155 | ))} | ||
| 156 | </NativeHeaderToolbar.Menu> | ||
| 209 | 157 | ||
| 210 | <Stack.Toolbar.Menu title="Group projects"> | 158 | <NativeHeaderToolbar.Menu title="Sort projects"> |
| 211 | <Stack.Toolbar.Label>Group projects</Stack.Toolbar.Label> | 159 | <NativeHeaderToolbar.Label>Sort projects</NativeHeaderToolbar.Label> |
| 212 | {PROJECT_GROUPING_OPTIONS.map((option) => ( | 160 | {PROJECT_SORT_OPTIONS.map((option) => ( |
| 213 | <Stack.Toolbar.MenuAction | 161 | <NativeHeaderToolbar.MenuAction |
| 214 | key={option.value} | 162 | key={option.value} |
| 215 | isOn={props.projectGroupingMode === option.value} | 163 | isOn={props.projectSortOrder === option.value} |
| 216 | onPress={() => props.onProjectGroupingModeChange(option.value)} | 164 | onPress={() => props.onProjectSortOrderChange(option.value)} |
| 217 | subtitle={option.subtitle} | 165 | > |
| 218 | > | 166 | <NativeHeaderToolbar.Label>{option.label}</NativeHeaderToolbar.Label> |
| 219 | <Stack.Toolbar.Label>{option.label}</Stack.Toolbar.Label> | 167 | </NativeHeaderToolbar.MenuAction> |
| 220 | </Stack.Toolbar.MenuAction> | 168 | ))} |
| 221 | ))} | 169 | </NativeHeaderToolbar.Menu> |
| 222 | </Stack.Toolbar.Menu> | ||
| 223 | </Stack.Toolbar.Menu> | ||
| 224 | 170 | ||
| 225 | <Stack.Toolbar.Button | 171 | <NativeHeaderToolbar.Menu title="Sort threads"> |
| 226 | accessibilityLabel="Open settings" | 172 | <NativeHeaderToolbar.Label>Sort threads</NativeHeaderToolbar.Label> |
| 227 | icon="gearshape" | 173 | {THREAD_SORT_OPTIONS.map((option) => ( |
| 228 | onPress={props.onOpenSettings} | 174 | <NativeHeaderToolbar.MenuAction |
| 229 | separateBackground | 175 | key={option.value} |
| 230 | /> | 176 | isOn={props.threadSortOrder === option.value} |
| 231 | </Stack.Toolbar> | 177 | onPress={() => props.onThreadSortOrderChange(option.value)} |
| 178 | > | ||
| 179 | <NativeHeaderToolbar.Label>{option.label}</NativeHeaderToolbar.Label> | ||
| 180 | </NativeHeaderToolbar.MenuAction> | ||
| 181 | ))} | ||
| 182 | </NativeHeaderToolbar.Menu> | ||
| 232 | 183 | ||
| 233 | <Stack.Toolbar placement="bottom"> | 184 | <NativeHeaderToolbar.Menu title="Group projects"> |
| 234 | <Stack.Toolbar.SearchBarSlot /> | 185 | <NativeHeaderToolbar.Label>Group projects</NativeHeaderToolbar.Label> |
| 235 | <Stack.Toolbar.Spacer width={8} sharesBackground={false} /> | 186 | {PROJECT_GROUPING_OPTIONS.map((option) => ( |
| 236 | <Stack.Toolbar.Button | 187 | <NativeHeaderToolbar.MenuAction |
| 237 | accessibilityLabel="New task" | 188 | key={option.value} |
| 238 | icon="square.and.pencil" | 189 | isOn={props.projectGroupingMode === option.value} |
| 239 | onPress={props.onStartNewTask} | 190 | onPress={() => props.onProjectGroupingModeChange(option.value)} |
| 240 | separateBackground | 191 | subtitle={option.subtitle} |
| 241 | /> | 192 | > |
| 242 | </Stack.Toolbar> | 193 | <NativeHeaderToolbar.Label>{option.label}</NativeHeaderToolbar.Label> |
| 194 | </NativeHeaderToolbar.MenuAction> | ||
| 195 | ))} | ||
| 196 | </NativeHeaderToolbar.Menu> | ||
| 197 | </NativeHeaderToolbar.Menu> | ||
| 198 | <NativeHeaderToolbar.Spacer width={8} sharesBackground={false} /> | ||
| 199 | <NativeHeaderToolbar.SearchBarSlot /> | ||
| 200 | <NativeHeaderToolbar.Spacer width={8} sharesBackground={false} /> | ||
| 201 | <NativeHeaderToolbar.Button | ||
| 202 | accessibilityLabel="New task" | ||
| 203 | icon="square.and.pencil" | ||
| 204 | onPress={props.onStartNewTask} | ||
| 205 | separateBackground | ||
| 206 | /> | ||
| 207 | </NativeHeaderToolbar> | ||
| 208 | )} | ||
| 243 | </> | 209 | </> |
| 244 | ); | 210 | ); |
| 245 | } | 211 | } |
apps/mobile/src/features/home/HomeRouteScreen.tsx created+132| ... | @@ -0,0 +1,132 @@ | ||
| 1 | import * as Arr from "effect/Array"; | ||
| 2 | import * as Order from "effect/Order"; | ||
| 3 | import { useNavigation } from "@react-navigation/native"; | ||
| 4 | import { useMemo, useState } from "react"; | ||
| 5 | |||
| 6 | import { NativeHeaderToolbar, NativeStackScreenOptions } from "../../native/StackHeader"; | ||
| 7 | import { useProjects, useThreadShells } from "../../state/entities"; | ||
| 8 | import { useWorkspaceState } from "../../state/workspace"; | ||
| 9 | import { useSavedRemoteConnections } from "../../state/use-remote-environment-registry"; | ||
| 10 | import { useAdaptiveWorkspaceLayout } from "../layout/AdaptiveWorkspaceLayout"; | ||
| 11 | import { WorkspaceEmptyDetail } from "../layout/WorkspaceEmptyDetail"; | ||
| 12 | import { WorkspaceSidebarToolbar } from "../layout/workspace-sidebar-toolbar"; | ||
| 13 | import { HomeScreen } from "./HomeScreen"; | ||
| 14 | import { HomeHeader } from "./HomeHeader"; | ||
| 15 | import { useHomeListOptions } from "./home-list-options"; | ||
| 16 | import { useThreadListActions } from "./useThreadListActions"; | ||
| 17 | |||
| 18 | /* ─── Route screen ───────────────────────────────────────────────────── */ | ||
| 19 | |||
| 20 | export function HomeRouteScreen() { | ||
| 21 | const { layout } = useAdaptiveWorkspaceLayout(); | ||
| 22 | const projects = useProjects(); | ||
| 23 | const threads = useThreadShells(); | ||
| 24 | const { state: catalogState } = useWorkspaceState(); | ||
| 25 | const { savedConnectionsById } = useSavedRemoteConnections(); | ||
| 26 | const navigation = useNavigation(); | ||
| 27 | const [searchQuery, setSearchQuery] = useState(""); | ||
| 28 | const { archiveThread, confirmDeleteThread } = useThreadListActions(); | ||
| 29 | const environments = useMemo( | ||
| 30 | () => | ||
| 31 | Arr.sort( | ||
| 32 | Object.values(savedConnectionsById).map((connection) => ({ | ||
| 33 | environmentId: connection.environmentId, | ||
| 34 | label: connection.environmentLabel, | ||
| 35 | })), | ||
| 36 | Order.mapInput( | ||
| 37 | Order.String, | ||
| 38 | (environment: { readonly label: string }) => environment.label, | ||
| 39 | ), | ||
| 40 | ), | ||
| 41 | [savedConnectionsById], | ||
| 42 | ); | ||
| 43 | const availableEnvironmentIds = useMemo( | ||
| 44 | () => new Set(environments.map((environment) => environment.environmentId)), | ||
| 45 | [environments], | ||
| 46 | ); | ||
| 47 | const { | ||
| 48 | options: listOptions, | ||
| 49 | setSelectedEnvironmentId, | ||
| 50 | setProjectGroupingMode, | ||
| 51 | setProjectSortOrder, | ||
| 52 | setThreadSortOrder, | ||
| 53 | } = useHomeListOptions(availableEnvironmentIds); | ||
| 54 | const selectedEnvironmentId = listOptions.selectedEnvironmentId; | ||
| 55 | |||
| 56 | // In split layouts the persistent sidebar IS the thread list — Home becomes | ||
| 57 | // an empty detail pane so selecting a thread never transitions layouts. | ||
| 58 | if (layout.usesSplitView) { | ||
| 59 | return ( | ||
| 60 | <> | ||
| 61 | <NativeStackScreenOptions options={{ title: "", headerTitle: "" }} /> | ||
| 62 | <WorkspaceSidebarToolbar | ||
| 63 | afterSidebarButton={ | ||
| 64 | <NativeHeaderToolbar.Button | ||
| 65 | accessibilityLabel="New task" | ||
| 66 | icon="square.and.pencil" | ||
| 67 | onPress={() => navigation.navigate("NewTaskSheet", { screen: "NewTask" })} | ||
| 68 | /> | ||
| 69 | } | ||
| 70 | /> | ||
| 71 | <WorkspaceEmptyDetail | ||
| 72 | onStartNewTask={() => navigation.navigate("NewTaskSheet", { screen: "NewTask" })} | ||
| 73 | /> | ||
| 74 | </> | ||
| 75 | ); | ||
| 76 | } | ||
| 77 | |||
| 78 | return ( | ||
| 79 | <> | ||
| 80 | {/* Restore the compact title in case the split branch blanked it. */} | ||
| 81 | <NativeStackScreenOptions options={{ title: "Threads", headerTitle: "Threads" }} /> | ||
| 82 | <HomeHeader | ||
| 83 | environments={environments} | ||
| 84 | selectedEnvironmentId={selectedEnvironmentId} | ||
| 85 | projectSortOrder={listOptions.projectSortOrder} | ||
| 86 | threadSortOrder={listOptions.threadSortOrder} | ||
| 87 | projectGroupingMode={listOptions.projectGroupingMode} | ||
| 88 | onEnvironmentChange={setSelectedEnvironmentId} | ||
| 89 | onOpenSettings={() => navigation.navigate("SettingsSheet", { screen: "Settings" })} | ||
| 90 | onProjectGroupingModeChange={setProjectGroupingMode} | ||
| 91 | onProjectSortOrderChange={setProjectSortOrder} | ||
| 92 | onSearchQueryChange={setSearchQuery} | ||
| 93 | onStartNewTask={() => navigation.navigate("NewTaskSheet", { screen: "NewTask" })} | ||
| 94 | onThreadSortOrderChange={setThreadSortOrder} | ||
| 95 | /> | ||
| 96 | |||
| 97 | <HomeScreen | ||
| 98 | catalogState={catalogState} | ||
| 99 | environments={environments} | ||
| 100 | onAddConnection={() => | ||
| 101 | navigation.navigate("SettingsSheet", { screen: "SettingsEnvironmentNew" }) | ||
| 102 | } | ||
| 103 | onArchiveThread={archiveThread} | ||
| 104 | onDeleteThread={confirmDeleteThread} | ||
| 105 | onEnvironmentChange={setSelectedEnvironmentId} | ||
| 106 | onOpenEnvironments={() => | ||
| 107 | navigation.navigate("SettingsSheet", { screen: "SettingsEnvironments" }) | ||
| 108 | } | ||
| 109 | onOpenSettings={() => navigation.navigate("SettingsSheet", { screen: "Settings" })} | ||
| 110 | onProjectGroupingModeChange={setProjectGroupingMode} | ||
| 111 | onProjectSortOrderChange={setProjectSortOrder} | ||
| 112 | onSearchQueryChange={setSearchQuery} | ||
| 113 | onSelectThread={(thread) => { | ||
| 114 | navigation.navigate("Thread", { | ||
| 115 | environmentId: thread.environmentId, | ||
| 116 | threadId: thread.id, | ||
| 117 | }); | ||
| 118 | }} | ||
| 119 | onStartNewTask={() => navigation.navigate("NewTaskSheet", { screen: "NewTask" })} | ||
| 120 | onThreadSortOrderChange={setThreadSortOrder} | ||
| 121 | projectGroupingMode={listOptions.projectGroupingMode} | ||
| 122 | projects={projects} | ||
| 123 | projectSortOrder={listOptions.projectSortOrder} | ||
| 124 | savedConnectionsById={savedConnectionsById} | ||
| 125 | searchQuery={searchQuery} | ||
| 126 | selectedEnvironmentId={selectedEnvironmentId} | ||
| 127 | threads={threads} | ||
| 128 | threadSortOrder={listOptions.threadSortOrder} | ||
| 129 | /> | ||
| 130 | </> | ||
| 131 | ); | ||
| 132 | } | ||
apps/mobile/src/features/home/HomeScreen.tsx+248-446| ... | @@ -1,3 +1,8 @@ | ... | @@ -1,3 +1,8 @@ |
| 1 | import { | ||
| 2 | LegendList, | ||
| 3 | type LegendListRef, | ||
| 4 | type LegendListRenderItemProps, | ||
| 5 | } from "@legendapp/list/react-native"; | ||
| 1 | import { | 6 | import { |
| 2 | type EnvironmentProject, | 7 | type EnvironmentProject, |
| 3 | type EnvironmentThreadShell, | 8 | type EnvironmentThreadShell, |
| ... | @@ -7,36 +12,35 @@ import type { | ... | @@ -7,36 +12,35 @@ import type { |
| 7 | SidebarProjectGroupingMode, | 12 | SidebarProjectGroupingMode, |
| 8 | SidebarThreadSortOrder, | 13 | SidebarThreadSortOrder, |
| 9 | } from "@t3tools/contracts"; | 14 | } from "@t3tools/contracts"; |
| 10 | import * as Haptics from "expo-haptics"; | ||
| 11 | import { SymbolView } from "expo-symbols"; | ||
| 12 | import { useCallback, useMemo, useRef, useState } from "react"; | 15 | import { useCallback, useMemo, useRef, useState } from "react"; |
| 13 | import { ActivityIndicator, Pressable, ScrollView, useWindowDimensions, View } from "react-native"; | 16 | import { ActivityIndicator, Platform, View } from "react-native"; |
| 14 | import ReanimatedSwipeable, { | 17 | import type { SwipeableMethods } from "react-native-gesture-handler/ReanimatedSwipeable"; |
| 15 | type SwipeableMethods, | ||
| 16 | } from "react-native-gesture-handler/ReanimatedSwipeable"; | ||
| 17 | import Animated, { | ||
| 18 | Easing, | ||
| 19 | LinearTransition, | ||
| 20 | type ExitAnimationsValues, | ||
| 21 | withDelay, | ||
| 22 | withTiming, | ||
| 23 | } from "react-native-reanimated"; | ||
| 24 | import { useSafeAreaInsets } from "react-native-safe-area-context"; | 18 | import { useSafeAreaInsets } from "react-native-safe-area-context"; |
| 25 | import { useThemeColor } from "../../lib/useThemeColor"; | 19 | import { useThemeColor } from "../../lib/useThemeColor"; |
| 26 | 20 | ||
| 27 | import { AppText as Text } from "../../components/AppText"; | ||
| 28 | import { EmptyState } from "../../components/EmptyState"; | 21 | import { EmptyState } from "../../components/EmptyState"; |
| 29 | import { ProjectFavicon } from "../../components/ProjectFavicon"; | ||
| 30 | import type { WorkspaceState } from "../../state/workspaceModel"; | 22 | import type { WorkspaceState } from "../../state/workspaceModel"; |
| 31 | import type { SavedRemoteConnection } from "../../lib/connection"; | 23 | import type { SavedRemoteConnection } from "../../lib/connection"; |
| 32 | import { relativeTime } from "../../lib/time"; | 24 | import { scopedProjectKey } from "../../lib/scopedEntities"; |
| 33 | import { threadStatusTone } from "../threads/threadPresentation"; | ||
| 34 | import { buildHomeThreadGroups, type HomeProjectSortOrder } from "./homeThreadList"; | ||
| 35 | import { | 25 | import { |
| 36 | THREAD_SWIPE_ACTIONS_WIDTH, | 26 | ThreadListGroupHeader, |
| 37 | THREAD_SWIPE_SPRING, | 27 | ThreadListRow, |
| 38 | ThreadSwipeActions, | 28 | ThreadListShowMoreRow, |
| 39 | } from "./thread-swipe-actions"; | 29 | } from "../threads/thread-list-items"; |
| 30 | import type { HomeListFilterMenuEnvironment } from "./home-list-filter-menu"; | ||
| 31 | import { | ||
| 32 | buildHomeListLayout, | ||
| 33 | DEFAULT_GROUP_DISPLAY_STATE, | ||
| 34 | homeListItemsAreEqual, | ||
| 35 | nextGroupDisplayState, | ||
| 36 | type HomeGroupDisplayAction, | ||
| 37 | type HomeGroupDisplayState, | ||
| 38 | type HomeListItem, | ||
| 39 | } from "./homeListItems"; | ||
| 40 | import { buildHomeThreadGroups, type HomeProjectSortOrder } from "./homeThreadList"; | ||
| 41 | import { SwipeableScrollGateProvider, useSwipeableScrollGate } from "./thread-swipe-actions"; | ||
| 42 | import { WorkspaceConnectionStatus } from "./WorkspaceConnectionStatus"; | ||
| 43 | import { shouldShowWorkspaceConnectionStatus } from "./workspace-connection-status"; | ||
| 40 | 44 | ||
| 41 | /* ─── Types ──────────────────────────────────────────────────────────── */ | 45 | /* ─── Types ──────────────────────────────────────────────────────────── */ |
| 42 | 46 | ||
| ... | @@ -45,63 +49,31 @@ interface HomeScreenProps { | ... | @@ -45,63 +49,31 @@ interface HomeScreenProps { |
| 45 | readonly threads: ReadonlyArray<EnvironmentThreadShell>; | 49 | readonly threads: ReadonlyArray<EnvironmentThreadShell>; |
| 46 | readonly catalogState: WorkspaceState; | 50 | readonly catalogState: WorkspaceState; |
| 47 | readonly savedConnectionsById: Readonly<Record<string, SavedRemoteConnection>>; | 51 | readonly savedConnectionsById: Readonly<Record<string, SavedRemoteConnection>>; |
| 52 | readonly environments: ReadonlyArray<HomeListFilterMenuEnvironment>; | ||
| 48 | readonly searchQuery: string; | 53 | readonly searchQuery: string; |
| 49 | readonly selectedEnvironmentId: EnvironmentId | null; | 54 | readonly selectedEnvironmentId: EnvironmentId | null; |
| 50 | readonly projectSortOrder: HomeProjectSortOrder; | 55 | readonly projectSortOrder: HomeProjectSortOrder; |
| 51 | readonly threadSortOrder: SidebarThreadSortOrder; | 56 | readonly threadSortOrder: SidebarThreadSortOrder; |
| 52 | readonly projectGroupingMode: SidebarProjectGroupingMode; | 57 | readonly projectGroupingMode: SidebarProjectGroupingMode; |
| 58 | readonly onSearchQueryChange: (query: string) => void; | ||
| 59 | readonly onEnvironmentChange: (environmentId: EnvironmentId | null) => void; | ||
| 60 | readonly onProjectSortOrderChange: (sortOrder: HomeProjectSortOrder) => void; | ||
| 61 | readonly onThreadSortOrderChange: (sortOrder: SidebarThreadSortOrder) => void; | ||
| 62 | readonly onProjectGroupingModeChange: (mode: SidebarProjectGroupingMode) => void; | ||
| 53 | readonly onAddConnection: () => void; | 63 | readonly onAddConnection: () => void; |
| 54 | readonly onOpenEnvironments: () => void; | 64 | readonly onOpenEnvironments: () => void; |
| 65 | readonly onOpenSettings: () => void; | ||
| 66 | readonly onStartNewTask: () => void; | ||
| 55 | readonly onSelectThread: (thread: EnvironmentThreadShell) => void; | 67 | readonly onSelectThread: (thread: EnvironmentThreadShell) => void; |
| 56 | readonly onArchiveThread: (thread: EnvironmentThreadShell) => void; | 68 | readonly onArchiveThread: (thread: EnvironmentThreadShell) => void; |
| 57 | readonly onDeleteThread: (thread: EnvironmentThreadShell) => void; | 69 | readonly onDeleteThread: (thread: EnvironmentThreadShell) => void; |
| 58 | } | 70 | } |
| 59 | 71 | ||
| 60 | /* ─── Status indicator colors ────────────────────────────────────────── */ | 72 | /* ─── Layout constants ───────────────────────────────────────────────── */ |
| 61 | |||
| 62 | function statusColors(thread: EnvironmentThreadShell): { bg: string; fg: string } { | ||
| 63 | switch (thread.session?.status) { | ||
| 64 | case "running": | ||
| 65 | return { bg: "rgba(249,115,22,0.14)", fg: "#f97316" }; | ||
| 66 | case "ready": | ||
| 67 | return { bg: "rgba(34,197,94,0.14)", fg: "#22c55e" }; | ||
| 68 | case "starting": | ||
| 69 | return { bg: "rgba(59,130,246,0.14)", fg: "#3b82f6" }; | ||
| 70 | case "error": | ||
| 71 | return { bg: "rgba(239,68,68,0.14)", fg: "#ef4444" }; | ||
| 72 | default: | ||
| 73 | return { bg: "rgba(163,163,163,0.10)", fg: "#a3a3a3" }; | ||
| 74 | } | ||
| 75 | } | ||
| 76 | 73 | ||
| 77 | const COLLAPSED_THREAD_LIMIT = 6; | 74 | const ESTIMATED_THREAD_ROW_HEIGHT = 72; |
| 78 | const THREAD_LAYOUT_TRANSITION = LinearTransition.duration(220).easing(Easing.out(Easing.cubic)); | 75 | /** Height of the floating custom header on non-iOS platforms. */ |
| 79 | 76 | const CUSTOM_HEADER_HEIGHT = 78; | |
| 80 | function threadRowExit(values: ExitAnimationsValues) { | ||
| 81 | "worklet"; | ||
| 82 | |||
| 83 | return { | ||
| 84 | initialValues: { | ||
| 85 | height: values.currentHeight, | ||
| 86 | opacity: 1, | ||
| 87 | originX: values.currentOriginX, | ||
| 88 | }, | ||
| 89 | animations: { | ||
| 90 | height: withDelay( | ||
| 91 | 90, | ||
| 92 | withTiming(0, { | ||
| 93 | duration: 170, | ||
| 94 | easing: Easing.inOut(Easing.cubic), | ||
| 95 | }), | ||
| 96 | ), | ||
| 97 | opacity: withDelay(80, withTiming(0, { duration: 100 })), | ||
| 98 | originX: withTiming(values.currentOriginX - values.windowWidth, { | ||
| 99 | duration: 190, | ||
| 100 | easing: Easing.out(Easing.cubic), | ||
| 101 | }), | ||
| 102 | }, | ||
| 103 | }; | ||
| 104 | } | ||
| 105 | 77 | ||
| 106 | function deriveEmptyState(props: { | 78 | function deriveEmptyState(props: { |
| 107 | readonly catalogState: WorkspaceState; | 79 | readonly catalogState: WorkspaceState; |
| ... | @@ -166,287 +138,28 @@ function deriveEmptyState(props: { | ... | @@ -166,287 +138,28 @@ function deriveEmptyState(props: { |
| 166 | }; | 138 | }; |
| 167 | } | 139 | } |
| 168 | 140 | ||
| 169 | /* ─── Project group header ───────────────────────────────────────────── */ | 141 | function HomeTopContentSpacer(props: { readonly topInset: number }) { |
| 170 | 142 | return <View style={{ height: props.topInset + CUSTOM_HEADER_HEIGHT }} />; | |
| 171 | function ProjectGroupLabel(props: { | ||
| 172 | readonly project: EnvironmentProject; | ||
| 173 | readonly title: string; | ||
| 174 | readonly totalThreadCount: number; | ||
| 175 | readonly isExpanded: boolean; | ||
| 176 | readonly onToggleExpand: () => void; | ||
| 177 | }) { | ||
| 178 | const hiddenCount = props.totalThreadCount - COLLAPSED_THREAD_LIMIT; | ||
| 179 | |||
| 180 | return ( | ||
| 181 | <View className="flex-row items-center gap-2.5 px-1 pb-2"> | ||
| 182 | <ProjectFavicon | ||
| 183 | environmentId={props.project.environmentId} | ||
| 184 | size={18} | ||
| 185 | projectTitle={props.project.title} | ||
| 186 | workspaceRoot={props.project.workspaceRoot} | ||
| 187 | /> | ||
| 188 | <Text | ||
| 189 | className="flex-1 text-xs font-t3-medium uppercase text-foreground-muted" | ||
| 190 | style={{ letterSpacing: 0.5 }} | ||
| 191 | numberOfLines={1} | ||
| 192 | > | ||
| 193 | {props.title} | ||
| 194 | </Text> | ||
| 195 | |||
| 196 | {hiddenCount > 0 ? ( | ||
| 197 | <Pressable onPress={props.onToggleExpand} hitSlop={8}> | ||
| 198 | <Text | ||
| 199 | className="text-xs font-t3-medium text-foreground-muted" | ||
| 200 | style={{ letterSpacing: 0.4 }} | ||
| 201 | > | ||
| 202 | {props.isExpanded ? "Show less" : `${hiddenCount} more`} | ||
| 203 | </Text> | ||
| 204 | </Pressable> | ||
| 205 | ) : null} | ||
| 206 | </View> | ||
| 207 | ); | ||
| 208 | } | ||
| 209 | |||
| 210 | /* ─── Thread row ─────────────────────────────────────────────────────── */ | ||
| 211 | |||
| 212 | function ThreadRow(props: { | ||
| 213 | readonly thread: EnvironmentThreadShell; | ||
| 214 | readonly environmentLabel: string | null; | ||
| 215 | readonly onPress: () => void; | ||
| 216 | readonly onArchive: () => void; | ||
| 217 | readonly onDelete: () => void; | ||
| 218 | readonly onSwipeableWillOpen: (methods: SwipeableMethods) => void; | ||
| 219 | readonly onSwipeableClose: (methods: SwipeableMethods) => void; | ||
| 220 | readonly isLast: boolean; | ||
| 221 | }) { | ||
| 222 | const swipeableRef = useRef<SwipeableMethods | null>(null); | ||
| 223 | const fullSwipeArmedRef = useRef(false); | ||
| 224 | const { width: windowWidth } = useWindowDimensions(); | ||
| 225 | const separatorColor = useThemeColor("--color-separator"); | ||
| 226 | const iconSubtleColor = useThemeColor("--color-icon-subtle"); | ||
| 227 | const cardColor = useThemeColor("--color-card"); | ||
| 228 | const fullSwipeThreshold = Math.max(THREAD_SWIPE_ACTIONS_WIDTH + 44, (windowWidth - 32) * 0.58); | ||
| 229 | const { bg, fg } = statusColors(props.thread); | ||
| 230 | const tone = threadStatusTone(props.thread); | ||
| 231 | const timestamp = relativeTime( | ||
| 232 | props.thread.latestUserMessageAt ?? props.thread.updatedAt ?? props.thread.createdAt, | ||
| 233 | ); | ||
| 234 | const branch = props.thread.branch; | ||
| 235 | const subtitleParts = [props.environmentLabel, branch].filter((part): part is string => | ||
| 236 | Boolean(part), | ||
| 237 | ); | ||
| 238 | const handleFullSwipeArmedChange = useCallback((armed: boolean) => { | ||
| 239 | if (armed && !fullSwipeArmedRef.current && process.env.EXPO_OS === "ios") { | ||
| 240 | void Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Medium); | ||
| 241 | } | ||
| 242 | fullSwipeArmedRef.current = armed; | ||
| 243 | }, []); | ||
| 244 | |||
| 245 | return ( | ||
| 246 | <ReanimatedSwipeable | ||
| 247 | ref={swipeableRef} | ||
| 248 | animationOptions={THREAD_SWIPE_SPRING} | ||
| 249 | childrenContainerStyle={{ backgroundColor: cardColor }} | ||
| 250 | containerStyle={{ backgroundColor: cardColor }} | ||
| 251 | dragOffsetFromRightEdge={8} | ||
| 252 | enableTrackpadTwoFingerGesture | ||
| 253 | friction={1} | ||
| 254 | onSwipeableClose={() => { | ||
| 255 | fullSwipeArmedRef.current = false; | ||
| 256 | if (swipeableRef.current) { | ||
| 257 | props.onSwipeableClose(swipeableRef.current); | ||
| 258 | } | ||
| 259 | }} | ||
| 260 | onSwipeableOpenStartDrag={() => { | ||
| 261 | if (swipeableRef.current) { | ||
| 262 | props.onSwipeableWillOpen(swipeableRef.current); | ||
| 263 | } | ||
| 264 | }} | ||
| 265 | onSwipeableWillOpen={() => { | ||
| 266 | const methods = swipeableRef.current; | ||
| 267 | if (!methods) { | ||
| 268 | return; | ||
| 269 | } | ||
| 270 | |||
| 271 | props.onSwipeableWillOpen(methods); | ||
| 272 | if (fullSwipeArmedRef.current) { | ||
| 273 | fullSwipeArmedRef.current = false; | ||
| 274 | methods.close(); | ||
| 275 | props.onDelete(); | ||
| 276 | } | ||
| 277 | }} | ||
| 278 | overshootFriction={1} | ||
| 279 | overshootRight | ||
| 280 | renderRightActions={(_progress, translation, methods) => ( | ||
| 281 | <ThreadSwipeActions | ||
| 282 | backgroundColor={cardColor} | ||
| 283 | fullSwipeThreshold={fullSwipeThreshold} | ||
| 284 | onDelete={props.onDelete} | ||
| 285 | onFullSwipeArmedChange={handleFullSwipeArmedChange} | ||
| 286 | primaryAction={{ | ||
| 287 | accessibilityLabel: `Archive ${props.thread.title}`, | ||
| 288 | icon: "archivebox", | ||
| 289 | label: "Archive", | ||
| 290 | onPress: props.onArchive, | ||
| 291 | }} | ||
| 292 | swipeableMethods={methods} | ||
| 293 | threadTitle={props.thread.title} | ||
| 294 | translation={translation} | ||
| 295 | /> | ||
| 296 | )} | ||
| 297 | rightThreshold={THREAD_SWIPE_ACTIONS_WIDTH * 0.42} | ||
| 298 | > | ||
| 299 | <Pressable | ||
| 300 | accessibilityHint="Swipe left for archive and delete actions" | ||
| 301 | accessibilityLabel={props.thread.title} | ||
| 302 | accessibilityRole="button" | ||
| 303 | className="bg-card" | ||
| 304 | onPress={() => { | ||
| 305 | swipeableRef.current?.close(); | ||
| 306 | props.onPress(); | ||
| 307 | }} | ||
| 308 | style={({ pressed }) => ({ opacity: pressed ? 0.7 : 1 })} | ||
| 309 | > | ||
| 310 | <View | ||
| 311 | style={{ | ||
| 312 | flexDirection: "row", | ||
| 313 | paddingLeft: 16, | ||
| 314 | paddingRight: 16, | ||
| 315 | paddingVertical: 10, | ||
| 316 | gap: 12, | ||
| 317 | borderBottomWidth: props.isLast ? 0 : 1, | ||
| 318 | borderBottomColor: separatorColor, | ||
| 319 | }} | ||
| 320 | > | ||
| 321 | <View | ||
| 322 | style={{ | ||
| 323 | width: 30, | ||
| 324 | height: 30, | ||
| 325 | borderRadius: 9, | ||
| 326 | backgroundColor: bg, | ||
| 327 | alignItems: "center", | ||
| 328 | justifyContent: "center", | ||
| 329 | marginTop: 2, | ||
| 330 | }} | ||
| 331 | > | ||
| 332 | <SymbolView name="arrow.triangle.branch" size={13} tintColor={fg} type="monochrome" /> | ||
| 333 | </View> | ||
| 334 | |||
| 335 | <View style={{ flex: 1, gap: 3 }}> | ||
| 336 | <View className="flex-row items-center justify-between gap-2"> | ||
| 337 | <Text | ||
| 338 | className="flex-1 text-base font-t3-bold leading-[20px] text-foreground" | ||
| 339 | numberOfLines={1} | ||
| 340 | > | ||
| 341 | {props.thread.title} | ||
| 342 | </Text> | ||
| 343 | <View className="flex-row items-center gap-2"> | ||
| 344 | <View | ||
| 345 | className={tone.pillClassName} | ||
| 346 | style={{ borderRadius: 99, paddingHorizontal: 6, paddingVertical: 2 }} | ||
| 347 | > | ||
| 348 | <Text className={`text-3xs font-t3-bold ${tone.textClassName}`}> | ||
| 349 | {tone.label} | ||
| 350 | </Text> | ||
| 351 | </View> | ||
| 352 | <Text | ||
| 353 | className="text-xs text-foreground-tertiary" | ||
| 354 | style={{ fontVariant: ["tabular-nums"] }} | ||
| 355 | > | ||
| 356 | {timestamp} | ||
| 357 | </Text> | ||
| 358 | </View> | ||
| 359 | </View> | ||
| 360 | |||
| 361 | {subtitleParts.length > 0 ? ( | ||
| 362 | <View className="flex-row items-center gap-1.5" style={{ marginTop: 1 }}> | ||
| 363 | <SymbolView | ||
| 364 | name="arrow.triangle.branch" | ||
| 365 | size={10} | ||
| 366 | tintColor={iconSubtleColor} | ||
| 367 | type="monochrome" | ||
| 368 | /> | ||
| 369 | <Text | ||
| 370 | className="text-2xs text-foreground-tertiary" | ||
| 371 | numberOfLines={1} | ||
| 372 | style={{ fontFamily: "monospace" }} | ||
| 373 | > | ||
| 374 | {subtitleParts.join(" · ")} | ||
| 375 | </Text> | ||
| 376 | </View> | ||
| 377 | ) : null} | ||
| 378 | </View> | ||
| 379 | </View> | ||
| 380 | </Pressable> | ||
| 381 | </ReanimatedSwipeable> | ||
| 382 | ); | ||
| 383 | } | 143 | } |
| 384 | 144 | ||
| 385 | /* ─── Main screen ────────────────────────────────────────────────────── */ | 145 | /* ─── Main screen ────────────────────────────────────────────────────── */ |
| 386 | 146 | ||
| 387 | function staleCatalogPillLabel(props: { readonly catalogState: WorkspaceState }): string { | ||
| 388 | if (props.catalogState.networkStatus === "offline") { | ||
| 389 | return "You are offline"; | ||
| 390 | } | ||
| 391 | const connectingEnvironments = props.catalogState.connectingEnvironments; | ||
| 392 | if (connectingEnvironments.length === 1) { | ||
| 393 | return `Reconnecting to ${connectingEnvironments[0]!.environmentLabel}`; | ||
| 394 | } | ||
| 395 | if (connectingEnvironments.length > 1) { | ||
| 396 | return `Reconnecting ${connectingEnvironments.length} environments`; | ||
| 397 | } | ||
| 398 | return "Not connected"; | ||
| 399 | } | ||
| 400 | |||
| 401 | function StaleCatalogStatusPill(props: { | ||
| 402 | readonly catalogState: WorkspaceState; | ||
| 403 | readonly onPress: () => void; | ||
| 404 | }) { | ||
| 405 | const iconColor = useThemeColor("--color-icon-muted"); | ||
| 406 | const label = staleCatalogPillLabel(props); | ||
| 407 | const isReconnecting = props.catalogState.connectingEnvironments.length > 0; | ||
| 408 | |||
| 409 | return ( | ||
| 410 | <Pressable | ||
| 411 | accessibilityRole="button" | ||
| 412 | onPress={props.onPress} | ||
| 413 | className="flex-row items-center gap-2 rounded-full bg-card px-4 py-2.5" | ||
| 414 | style={{ | ||
| 415 | shadowColor: "#000", | ||
| 416 | shadowOffset: { width: 0, height: 10 }, | ||
| 417 | shadowOpacity: 0.12, | ||
| 418 | shadowRadius: 24, | ||
| 419 | }} | ||
| 420 | > | ||
| 421 | {isReconnecting ? ( | ||
| 422 | <ActivityIndicator color={iconColor} size="small" /> | ||
| 423 | ) : ( | ||
| 424 | <SymbolView | ||
| 425 | name="wifi.slash" | ||
| 426 | size={15} | ||
| 427 | tintColor={iconColor} | ||
| 428 | type="monochrome" | ||
| 429 | weight="semibold" | ||
| 430 | /> | ||
| 431 | )} | ||
| 432 | <Text className="max-w-[260px] text-sm font-t3-bold text-foreground" numberOfLines={1}> | ||
| 433 | {label} | ||
| 434 | </Text> | ||
| 435 | </Pressable> | ||
| 436 | ); | ||
| 437 | } | ||
| 438 | |||
| 439 | export function HomeScreen(props: HomeScreenProps) { | 147 | export function HomeScreen(props: HomeScreenProps) { |
| 440 | const [expandedProjects, setExpandedProjects] = useState<Set<string>>(() => new Set()); | 148 | const [groupDisplayStates, setGroupDisplayStates] = useState< |
| 149 | ReadonlyMap<string, HomeGroupDisplayState> | ||
| 150 | >(() => new Map()); | ||
| 441 | const openSwipeableRef = useRef<SwipeableMethods | null>(null); | 151 | const openSwipeableRef = useRef<SwipeableMethods | null>(null); |
| 152 | const listRef = useRef<LegendListRef | null>(null); | ||
| 442 | const insets = useSafeAreaInsets(); | 153 | const insets = useSafeAreaInsets(); |
| 443 | const accentColor = useThemeColor("--color-icon-muted"); | 154 | const accentColor = useThemeColor("--color-icon-muted"); |
| 444 | 155 | ||
| 445 | const toggleExpanded = useCallback((key: string) => { | 156 | const updateGroupDisplay = useCallback((key: string, action: HomeGroupDisplayAction) => { |
| 446 | setExpandedProjects((prev) => { | 157 | setGroupDisplayStates((previous) => { |
| 447 | const next = new Set(prev); | 158 | const next = new Map(previous); |
| 448 | if (next.has(key)) next.delete(key); | 159 | next.set( |
| 449 | else next.add(key); | 160 | key, |
| 161 | nextGroupDisplayState(previous.get(key) ?? DEFAULT_GROUP_DISPLAY_STATE, action), | ||
| 162 | ); | ||
| 450 | return next; | 163 | return next; |
| 451 | }); | 164 | }); |
| 452 | }, []); | 165 | }, []); |
| ... | @@ -464,6 +177,13 @@ export function HomeScreen(props: HomeScreenProps) { | ... | @@ -464,6 +177,13 @@ export function HomeScreen(props: HomeScreenProps) { |
| 464 | } | 177 | } |
| 465 | }, []); | 178 | }, []); |
| 466 | 179 | ||
| 180 | const handleScrollBeginDrag = useCallback(() => { | ||
| 181 | openSwipeableRef.current?.close(); | ||
| 182 | }, []); | ||
| 183 | const { swipeEnabled, scrollGateHandlers } = useSwipeableScrollGate({ | ||
| 184 | onScrollBeginDrag: handleScrollBeginDrag, | ||
| 185 | }); | ||
| 186 | |||
| 467 | const projectGroups = useMemo( | 187 | const projectGroups = useMemo( |
| 468 | () => | 188 | () => |
| 469 | buildHomeThreadGroups({ | 189 | buildHomeThreadGroups({ |
| ... | @@ -486,6 +206,94 @@ export function HomeScreen(props: HomeScreenProps) { | ... | @@ -486,6 +206,94 @@ export function HomeScreen(props: HomeScreenProps) { |
| 486 | ], | 206 | ], |
| 487 | ); | 207 | ); |
| 488 | 208 | ||
| 209 | const hasSearchQuery = props.searchQuery.trim().length > 0; | ||
| 210 | const listLayout = useMemo( | ||
| 211 | () => | ||
| 212 | buildHomeListLayout({ | ||
| 213 | groups: projectGroups, | ||
| 214 | displayStates: groupDisplayStates, | ||
| 215 | showAllThreads: hasSearchQuery, | ||
| 216 | }), | ||
| 217 | [projectGroups, groupDisplayStates, hasSearchQuery], | ||
| 218 | ); | ||
| 219 | |||
| 220 | const projectCwdByKey = useMemo(() => { | ||
| 221 | const map = new Map<string, string>(); | ||
| 222 | for (const project of props.projects) { | ||
| 223 | map.set(scopedProjectKey(project.environmentId, project.id), project.workspaceRoot); | ||
| 224 | } | ||
| 225 | return map; | ||
| 226 | }, [props.projects]); | ||
| 227 | |||
| 228 | const extraData = useMemo( | ||
| 229 | () => ({ savedConnectionsById: props.savedConnectionsById, projectCwdByKey }), | ||
| 230 | [props.savedConnectionsById, projectCwdByKey], | ||
| 231 | ); | ||
| 232 | |||
| 233 | const renderItem = useCallback( | ||
| 234 | ({ item }: LegendListRenderItemProps<HomeListItem>) => { | ||
| 235 | switch (item.type) { | ||
| 236 | case "header": | ||
| 237 | return ( | ||
| 238 | <ThreadListGroupHeader | ||
| 239 | variant="compact" | ||
| 240 | collapsed={item.collapsed} | ||
| 241 | isFirst={item.isFirst} | ||
| 242 | groupKey={item.group.key} | ||
| 243 | onGroupAction={updateGroupDisplay} | ||
| 244 | project={item.group.representative} | ||
| 245 | threadCount={item.group.threads.length} | ||
| 246 | title={item.group.title} | ||
| 247 | /> | ||
| 248 | ); | ||
| 249 | case "thread": { | ||
| 250 | const thread = item.thread; | ||
| 251 | return ( | ||
| 252 | <ThreadListRow | ||
| 253 | variant="compact" | ||
| 254 | thread={thread} | ||
| 255 | environmentLabel={ | ||
| 256 | props.savedConnectionsById[thread.environmentId]?.environmentLabel ?? null | ||
| 257 | } | ||
| 258 | projectCwd={ | ||
| 259 | projectCwdByKey.get(scopedProjectKey(thread.environmentId, thread.projectId)) ?? | ||
| 260 | null | ||
| 261 | } | ||
| 262 | isLast={item.isLast} | ||
| 263 | onArchiveThread={props.onArchiveThread} | ||
| 264 | onDeleteThread={props.onDeleteThread} | ||
| 265 | onSelectThread={props.onSelectThread} | ||
| 266 | onSwipeableClose={handleSwipeableClose} | ||
| 267 | onSwipeableWillOpen={handleSwipeableWillOpen} | ||
| 268 | /> | ||
| 269 | ); | ||
| 270 | } | ||
| 271 | case "show-more": | ||
| 272 | return ( | ||
| 273 | <ThreadListShowMoreRow | ||
| 274 | variant="compact" | ||
| 275 | hiddenCount={item.hiddenCount} | ||
| 276 | canShowLess={item.canShowLess} | ||
| 277 | groupKey={item.groupKey} | ||
| 278 | onGroupAction={updateGroupDisplay} | ||
| 279 | /> | ||
| 280 | ); | ||
| 281 | } | ||
| 282 | }, | ||
| 283 | [ | ||
| 284 | handleSwipeableClose, | ||
| 285 | handleSwipeableWillOpen, | ||
| 286 | projectCwdByKey, | ||
| 287 | props.onArchiveThread, | ||
| 288 | props.onDeleteThread, | ||
| 289 | props.onSelectThread, | ||
| 290 | props.savedConnectionsById, | ||
| 291 | updateGroupDisplay, | ||
| 292 | ], | ||
| 293 | ); | ||
| 294 | |||
| 295 | const keyExtractor = useCallback((item: HomeListItem) => item.key, []); | ||
| 296 | |||
| 489 | /* Empty states */ | 297 | /* Empty states */ |
| 490 | const hasAnyThreads = props.threads.some((thread) => thread.archivedAt === null); | 298 | const hasAnyThreads = props.threads.some((thread) => thread.archivedAt === null); |
| 491 | const hasResults = projectGroups.length > 0; | 299 | const hasResults = projectGroups.length > 0; |
| ... | @@ -494,127 +302,121 @@ export function HomeScreen(props: HomeScreenProps) { | ... | @@ -494,127 +302,121 @@ export function HomeScreen(props: HomeScreenProps) { |
| 494 | ? null | 302 | ? null |
| 495 | : (props.savedConnectionsById[props.selectedEnvironmentId]?.environmentLabel ?? | 303 | : (props.savedConnectionsById[props.selectedEnvironmentId]?.environmentLabel ?? |
| 496 | "this environment"); | 304 | "this environment"); |
| 497 | const hasSearchQuery = props.searchQuery.trim().length > 0; | 305 | const shouldShowConnectionStatus = shouldShowWorkspaceConnectionStatus(props.catalogState); |
| 498 | const shouldShowConnectionStatus = | ||
| 499 | props.catalogState.networkStatus === "offline" || | ||
| 500 | props.catalogState.hasConnectingEnvironment || | ||
| 501 | (props.catalogState.hasLoadedShellSnapshot && !props.catalogState.hasReadyEnvironment); | ||
| 502 | const emptyState = deriveEmptyState({ | 306 | const emptyState = deriveEmptyState({ |
| 503 | catalogState: props.catalogState, | 307 | catalogState: props.catalogState, |
| 504 | projectCount: props.projects.length, | 308 | projectCount: props.projects.length, |
| 505 | }); | 309 | }); |
| 506 | 310 | const connectionStatus = | |
| 507 | return ( | 311 | shouldShowConnectionStatus && Platform.OS !== "ios" ? ( |
| 508 | <View className="flex-1 bg-screen"> | 312 | <View |
| 509 | <ScrollView | 313 | className="absolute left-0 right-0 items-center" |
| 510 | contentInsetAdjustmentBehavior="automatic" | 314 | style={{ bottom: Math.max(insets.bottom, 18) + 76 }} |
| 511 | showsVerticalScrollIndicator={false} | 315 | > |
| 512 | keyboardDismissMode="on-drag" | 316 | <WorkspaceConnectionStatus state={props.catalogState} onPress={props.onOpenEnvironments} /> |
| 513 | keyboardShouldPersistTaps="handled" | 317 | </View> |
| 514 | onScrollBeginDrag={() => openSwipeableRef.current?.close()} | 318 | ) : null; |
| 515 | className="flex-1" | 319 | |
| 516 | contentContainerStyle={{ | 320 | if (!hasAnyThreads) { |
| 517 | paddingHorizontal: 16, | 321 | return ( |
| 518 | paddingTop: 8, | 322 | <View |
| 519 | paddingBottom: 24, | 323 | className="flex-1 items-center justify-center bg-screen px-8" |
| 520 | gap: 20, | 324 | style={{ |
| 325 | paddingBottom: Math.max(insets.bottom, 24), | ||
| 326 | paddingTop: Platform.OS === "ios" ? insets.top + 72 : insets.top, | ||
| 521 | }} | 327 | }} |
| 522 | > | 328 | > |
| 523 | {!hasAnyThreads ? ( | 329 | <View className="w-full max-w-[430px]"> |
| 524 | <View> | ||
| 525 | <EmptyState | ||
| 526 | title={emptyState.title} | ||
| 527 | detail={emptyState.detail} | ||
| 528 | actionLabel={!props.catalogState.hasReadyEnvironment ? "Add environment" : undefined} | ||
| 529 | onAction={!props.catalogState.hasReadyEnvironment ? props.onAddConnection : undefined} | ||
| 530 | /> | ||
| 531 | {emptyState.loading ? ( | ||
| 532 | <View className="absolute right-5 top-5"> | ||
| 533 | <ActivityIndicator color={accentColor} /> | ||
| 534 | </View> | ||
| 535 | ) : null} | ||
| 536 | </View> | ||
| 537 | ) : !hasResults && hasSearchQuery ? ( | ||
| 538 | <EmptyState title="No results" detail={`No threads matching "${props.searchQuery}".`} /> | ||
| 539 | ) : !hasResults && selectedEnvironmentLabel ? ( | ||
| 540 | <EmptyState | ||
| 541 | title={`No threads in ${selectedEnvironmentLabel}`} | ||
| 542 | detail="Choose another environment or create a new task." | ||
| 543 | /> | ||
| 544 | ) : !hasResults ? ( | ||
| 545 | <EmptyState | 330 | <EmptyState |
| 546 | title="No threads yet" | 331 | title={emptyState.title} |
| 547 | detail="Create a task to start a new coding session." | 332 | detail={emptyState.detail} |
| 333 | actionLabel={!props.catalogState.hasReadyEnvironment ? "Add environment" : undefined} | ||
| 334 | onAction={!props.catalogState.hasReadyEnvironment ? props.onAddConnection : undefined} | ||
| 335 | variant="plain" | ||
| 548 | /> | 336 | /> |
| 549 | ) : ( | 337 | {emptyState.loading ? ( |
| 550 | projectGroups.map((group) => { | 338 | <View className="mt-4 items-center"> |
| 551 | const isExpanded = expandedProjects.has(group.key); | 339 | <ActivityIndicator color={accentColor} /> |
| 552 | const visibleThreads = isExpanded | 340 | </View> |
| 553 | ? group.threads | 341 | ) : null} |
| 554 | : group.threads.slice(0, COLLAPSED_THREAD_LIMIT); | 342 | </View> |
| 343 | {connectionStatus} | ||
| 344 | </View> | ||
| 345 | ); | ||
| 346 | } | ||
| 555 | 347 | ||
| 556 | return ( | 348 | const listHeader = ( |
| 557 | <Animated.View | 349 | <> |
| 558 | key={group.key} | 350 | {Platform.OS === "ios" ? null : <HomeTopContentSpacer topInset={insets.top} />} |
| 559 | collapsable={false} | 351 | |
| 560 | exiting={threadRowExit} | 352 | {shouldShowConnectionStatus && Platform.OS === "ios" ? ( |
| 561 | layout={THREAD_LAYOUT_TRANSITION} | 353 | <View style={{ paddingBottom: 16 }}> |
| 562 | style={{ overflow: "hidden" }} | 354 | <WorkspaceConnectionStatus |
| 563 | > | 355 | state={props.catalogState} |
| 564 | <ProjectGroupLabel | ||
| 565 | isExpanded={isExpanded} | ||
| 566 | onToggleExpand={() => toggleExpanded(group.key)} | ||
| 567 | project={group.representative} | ||
| 568 | title={group.title} | ||
| 569 | totalThreadCount={group.threads.length} | ||
| 570 | /> | ||
| 571 | <View | ||
| 572 | className="overflow-hidden rounded-[20px] bg-card" | ||
| 573 | style={{ borderCurve: "continuous" }} | ||
| 574 | > | ||
| 575 | {visibleThreads.map((thread, i) => { | ||
| 576 | const threadKey = `${thread.environmentId}:${thread.id}`; | ||
| 577 | return ( | ||
| 578 | <Animated.View | ||
| 579 | key={threadKey} | ||
| 580 | collapsable={false} | ||
| 581 | exiting={threadRowExit} | ||
| 582 | layout={THREAD_LAYOUT_TRANSITION} | ||
| 583 | style={{ overflow: "hidden" }} | ||
| 584 | > | ||
| 585 | <ThreadRow | ||
| 586 | thread={thread} | ||
| 587 | environmentLabel={ | ||
| 588 | props.savedConnectionsById[thread.environmentId]?.environmentLabel ?? | ||
| 589 | null | ||
| 590 | } | ||
| 591 | isLast={i === visibleThreads.length - 1} | ||
| 592 | onArchive={() => props.onArchiveThread(thread)} | ||
| 593 | onDelete={() => props.onDeleteThread(thread)} | ||
| 594 | onPress={() => props.onSelectThread(thread)} | ||
| 595 | onSwipeableClose={handleSwipeableClose} | ||
| 596 | onSwipeableWillOpen={handleSwipeableWillOpen} | ||
| 597 | /> | ||
| 598 | </Animated.View> | ||
| 599 | ); | ||
| 600 | })} | ||
| 601 | </View> | ||
| 602 | </Animated.View> | ||
| 603 | ); | ||
| 604 | }) | ||
| 605 | )} | ||
| 606 | </ScrollView> | ||
| 607 | {shouldShowConnectionStatus ? ( | ||
| 608 | <View | ||
| 609 | className="absolute left-0 right-0 items-center" | ||
| 610 | style={{ bottom: Math.max(insets.bottom, 18) + 76 }} | ||
| 611 | > | ||
| 612 | <StaleCatalogStatusPill | ||
| 613 | catalogState={props.catalogState} | ||
| 614 | onPress={props.onOpenEnvironments} | 356 | onPress={props.onOpenEnvironments} |
| 357 | variant="sidebar" | ||
| 615 | /> | 358 | /> |
| 616 | </View> | 359 | </View> |
| 617 | ) : null} | 360 | ) : null} |
| 361 | </> | ||
| 362 | ); | ||
| 363 | |||
| 364 | const listEmpty = !hasResults ? ( | ||
| 365 | hasSearchQuery ? ( | ||
| 366 | <EmptyState title="No results" detail={`No threads matching "${props.searchQuery}".`} /> | ||
| 367 | ) : selectedEnvironmentLabel ? ( | ||
| 368 | <EmptyState | ||
| 369 | title={`No threads in ${selectedEnvironmentLabel}`} | ||
| 370 | detail="Choose another environment or create a new task." | ||
| 371 | /> | ||
| 372 | ) : ( | ||
| 373 | <EmptyState title="No threads yet" detail="Create a task to start a new coding session." /> | ||
| 374 | ) | ||
| 375 | ) : null; | ||
| 376 | |||
| 377 | return ( | ||
| 378 | <View className="flex-1 bg-screen"> | ||
| 379 | {/* Sticky headers are deliberately not wired up: LegendList's JS sticky | ||
| 380 | implementation mispositions pinned headers at mount under iOS | ||
| 381 | automatic content insets (headers render one nav-inset too low until | ||
| 382 | the first scroll event) and blanks non-pinned headers after | ||
| 383 | collapse/expand data changes. The flattened layout still exposes | ||
| 384 | `stickyHeaderIndices` if this gets revisited. */} | ||
| 385 | <SwipeableScrollGateProvider enabled={swipeEnabled}> | ||
| 386 | <LegendList | ||
| 387 | ref={listRef} | ||
| 388 | data={listLayout.items} | ||
| 389 | renderItem={renderItem} | ||
| 390 | keyExtractor={keyExtractor} | ||
| 391 | itemsAreEqual={homeListItemsAreEqual} | ||
| 392 | drawDistance={500} | ||
| 393 | estimatedItemSize={ESTIMATED_THREAD_ROW_HEIGHT} | ||
| 394 | extraData={extraData} | ||
| 395 | ListHeaderComponent={listHeader} | ||
| 396 | ListEmptyComponent={listEmpty} | ||
| 397 | style={{ flex: 1 }} | ||
| 398 | automaticallyAdjustsScrollIndicatorInsets={Platform.OS === "ios"} | ||
| 399 | contentInsetAdjustmentBehavior={Platform.OS === "ios" ? "automatic" : "never"} | ||
| 400 | showsVerticalScrollIndicator={false} | ||
| 401 | keyboardDismissMode="on-drag" | ||
| 402 | keyboardShouldPersistTaps="handled" | ||
| 403 | {...scrollGateHandlers} | ||
| 404 | recycleItems | ||
| 405 | scrollEventThrottle={16} | ||
| 406 | contentContainerStyle={{ | ||
| 407 | paddingBottom: Platform.OS === "ios" ? Math.max(insets.bottom, 24) + 24 : 24, | ||
| 408 | }} | ||
| 409 | scrollIndicatorInsets={ | ||
| 410 | Platform.OS === "ios" | ||
| 411 | ? { | ||
| 412 | bottom: Math.max(insets.bottom, 16) + 24, | ||
| 413 | top: 0, | ||
| 414 | } | ||
| 415 | : undefined | ||
| 416 | } | ||
| 417 | /> | ||
| 418 | </SwipeableScrollGateProvider> | ||
| 419 | {connectionStatus} | ||
| 618 | </View> | 420 | </View> |
| 619 | ); | 421 | ); |
| 620 | } | 422 | } |
apps/mobile/src/features/home/WorkspaceConnectionStatus.test.ts created+70| ... | @@ -0,0 +1,70 @@ | ||
| 1 | import { describe, expect, it } from "vite-plus/test"; | ||
| 2 | |||
| 3 | import type { WorkspaceState } from "../../state/workspaceModel"; | ||
| 4 | import { | ||
| 5 | shouldShowWorkspaceConnectionStatus, | ||
| 6 | workspaceConnectionStatusLabel, | ||
| 7 | } from "./workspace-connection-status"; | ||
| 8 | |||
| 9 | function workspaceState(overrides: Partial<WorkspaceState> = {}): WorkspaceState { | ||
| 10 | return { | ||
| 11 | isLoadingConnections: false, | ||
| 12 | hasConnections: true, | ||
| 13 | hasLoadedShellSnapshot: true, | ||
| 14 | hasPendingShellSnapshot: false, | ||
| 15 | hasReadyEnvironment: true, | ||
| 16 | hasConnectingEnvironment: false, | ||
| 17 | connectingEnvironments: [], | ||
| 18 | connectionState: "connected", | ||
| 19 | connectionError: null, | ||
| 20 | shellSnapshotError: null, | ||
| 21 | latestCachedSnapshotReceivedAt: null, | ||
| 22 | networkStatus: "online", | ||
| 23 | ...overrides, | ||
| 24 | }; | ||
| 25 | } | ||
| 26 | |||
| 27 | describe("workspace connection status", () => { | ||
| 28 | it("stays hidden while a ready environment is connected", () => { | ||
| 29 | expect(shouldShowWorkspaceConnectionStatus(workspaceState())).toBe(false); | ||
| 30 | }); | ||
| 31 | |||
| 32 | it("surfaces offline snapshots", () => { | ||
| 33 | const state = workspaceState({ networkStatus: "offline", hasReadyEnvironment: false }); | ||
| 34 | |||
| 35 | expect(shouldShowWorkspaceConnectionStatus(state)).toBe(true); | ||
| 36 | expect(workspaceConnectionStatusLabel(state)).toBe("You are offline"); | ||
| 37 | }); | ||
| 38 | |||
| 39 | it("names the environment while reconnecting", () => { | ||
| 40 | const state = workspaceState({ | ||
| 41 | hasConnectingEnvironment: true, | ||
| 42 | hasReadyEnvironment: false, | ||
| 43 | connectingEnvironments: [ | ||
| 44 | { | ||
| 45 | environmentId: "environment-1" as never, | ||
| 46 | environmentLabel: "Julius’s Mac mini", | ||
| 47 | displayUrl: "", | ||
| 48 | isRelayManaged: false, | ||
| 49 | connectionState: "reconnecting", | ||
| 50 | connectionError: null, | ||
| 51 | connectionErrorTraceId: null, | ||
| 52 | }, | ||
| 53 | ], | ||
| 54 | }); | ||
| 55 | |||
| 56 | expect(shouldShowWorkspaceConnectionStatus(state)).toBe(true); | ||
| 57 | expect(workspaceConnectionStatusLabel(state)).toBe("Reconnecting to Julius’s Mac mini"); | ||
| 58 | }); | ||
| 59 | |||
| 60 | it("surfaces connection errors before the generic disconnected fallback", () => { | ||
| 61 | const state = workspaceState({ | ||
| 62 | connectionError: "Could not reach Julius’s Mac mini", | ||
| 63 | hasLoadedShellSnapshot: false, | ||
| 64 | hasReadyEnvironment: false, | ||
| 65 | }); | ||
| 66 | |||
| 67 | expect(shouldShowWorkspaceConnectionStatus(state)).toBe(true); | ||
| 68 | expect(workspaceConnectionStatusLabel(state)).toBe("Could not reach Julius’s Mac mini"); | ||
| 69 | }); | ||
| 70 | }); | ||
apps/mobile/src/features/home/WorkspaceConnectionStatus.tsx created+53| ... | @@ -0,0 +1,53 @@ | ||
| 1 | import { SymbolView } from "expo-symbols"; | ||
| 2 | import { ActivityIndicator, Pressable } from "react-native"; | ||
| 3 | |||
| 4 | import { AppText as Text } from "../../components/AppText"; | ||
| 5 | import { useThemeColor } from "../../lib/useThemeColor"; | ||
| 6 | import type { WorkspaceState } from "../../state/workspaceModel"; | ||
| 7 | import { workspaceConnectionStatusLabel } from "./workspace-connection-status"; | ||
| 8 | |||
| 9 | export function WorkspaceConnectionStatus(props: { | ||
| 10 | readonly state: WorkspaceState; | ||
| 11 | readonly onPress: () => void; | ||
| 12 | readonly variant?: "floating" | "sidebar"; | ||
| 13 | }) { | ||
| 14 | const iconColor = useThemeColor("--color-icon-muted"); | ||
| 15 | const isReconnecting = props.state.connectingEnvironments.length > 0; | ||
| 16 | const variant = props.variant ?? "floating"; | ||
| 17 | |||
| 18 | return ( | ||
| 19 | <Pressable | ||
| 20 | accessibilityHint="Opens environment settings" | ||
| 21 | accessibilityLabel={workspaceConnectionStatusLabel(props.state)} | ||
| 22 | accessibilityRole="button" | ||
| 23 | onPress={props.onPress} | ||
| 24 | className={ | ||
| 25 | variant === "sidebar" | ||
| 26 | ? "mx-3 flex-row items-center gap-2 rounded-xl bg-subtle px-3 py-2.5" | ||
| 27 | : "flex-row items-center gap-2 rounded-full bg-card px-4 py-2.5" | ||
| 28 | } | ||
| 29 | style={ | ||
| 30 | variant === "floating" | ||
| 31 | ? { | ||
| 32 | shadowColor: "#000", | ||
| 33 | shadowOffset: { width: 0, height: 10 }, | ||
| 34 | shadowOpacity: 0.12, | ||
| 35 | shadowRadius: 24, | ||
| 36 | } | ||
| 37 | : undefined | ||
| 38 | } | ||
| 39 | > | ||
| 40 | {isReconnecting ? ( | ||
| 41 | <ActivityIndicator color={iconColor} size="small" /> | ||
| 42 | ) : ( | ||
| 43 | <SymbolView name="wifi.slash" size={15} tintColor={iconColor} type="monochrome" /> | ||
| 44 | )} | ||
| 45 | <Text className="min-w-0 flex-1 text-sm font-t3-bold text-foreground" numberOfLines={1}> | ||
| 46 | {workspaceConnectionStatusLabel(props.state)} | ||
| 47 | </Text> | ||
| 48 | {variant === "sidebar" ? ( | ||
| 49 | <SymbolView name="chevron.right" size={11} tintColor={iconColor} type="monochrome" /> | ||
| 50 | ) : null} | ||
| 51 | </Pressable> | ||
| 52 | ); | ||
| 53 | } | ||
apps/mobile/src/features/home/home-list-filter-menu.ts created+120| ... | @@ -0,0 +1,120 @@ | ||
| 1 | import type { | ||
| 2 | EnvironmentId, | ||
| 3 | SidebarProjectGroupingMode, | ||
| 4 | SidebarThreadSortOrder, | ||
| 5 | } from "@t3tools/contracts"; | ||
| 6 | |||
| 7 | import type { HomeProjectSortOrder } from "./homeThreadList"; | ||
| 8 | import { | ||
| 9 | PROJECT_GROUPING_OPTIONS, | ||
| 10 | PROJECT_SORT_OPTIONS, | ||
| 11 | THREAD_SORT_OPTIONS, | ||
| 12 | } from "./home-list-options"; | ||
| 13 | |||
| 14 | export interface HomeListFilterMenuEnvironment { | ||
| 15 | readonly environmentId: EnvironmentId; | ||
| 16 | readonly label: string; | ||
| 17 | } | ||
| 18 | |||
| 19 | type HomeListFilterMenuAction = { | ||
| 20 | readonly type: "action"; | ||
| 21 | readonly title: string; | ||
| 22 | readonly subtitle?: string; | ||
| 23 | readonly state?: "on" | "off"; | ||
| 24 | readonly onPress: () => void; | ||
| 25 | }; | ||
| 26 | |||
| 27 | type HomeListFilterMenuSubmenu = { | ||
| 28 | readonly type: "submenu"; | ||
| 29 | readonly title: string; | ||
| 30 | readonly items: HomeListFilterMenuAction[]; | ||
| 31 | }; | ||
| 32 | |||
| 33 | export interface HomeListFilterMenu { | ||
| 34 | readonly title: string; | ||
| 35 | readonly items: Array<HomeListFilterMenuAction | HomeListFilterMenuSubmenu>; | ||
| 36 | } | ||
| 37 | |||
| 38 | export function buildHomeListFilterMenu(props: { | ||
| 39 | readonly environments: ReadonlyArray<HomeListFilterMenuEnvironment>; | ||
| 40 | readonly selectedEnvironmentId: EnvironmentId | null; | ||
| 41 | readonly projectSortOrder: HomeProjectSortOrder; | ||
| 42 | readonly threadSortOrder: SidebarThreadSortOrder; | ||
| 43 | readonly projectGroupingMode: SidebarProjectGroupingMode; | ||
| 44 | readonly onEnvironmentChange: (environmentId: EnvironmentId | null) => void; | ||
| 45 | readonly onProjectSortOrderChange: (sortOrder: HomeProjectSortOrder) => void; | ||
| 46 | readonly onThreadSortOrderChange: (sortOrder: SidebarThreadSortOrder) => void; | ||
| 47 | readonly onProjectGroupingModeChange: (mode: SidebarProjectGroupingMode) => void; | ||
| 48 | readonly onOpenSettings?: () => void; | ||
| 49 | }): HomeListFilterMenu { | ||
| 50 | const items: Array<HomeListFilterMenuAction | HomeListFilterMenuSubmenu> = []; | ||
| 51 | |||
| 52 | if (props.onOpenSettings) { | ||
| 53 | items.push({ | ||
| 54 | type: "action", | ||
| 55 | title: "Settings", | ||
| 56 | onPress: props.onOpenSettings, | ||
| 57 | }); | ||
| 58 | } | ||
| 59 | |||
| 60 | items.push( | ||
| 61 | { | ||
| 62 | type: "submenu", | ||
| 63 | title: "Environment", | ||
| 64 | items: [ | ||
| 65 | { | ||
| 66 | type: "action", | ||
| 67 | title: "All environments", | ||
| 68 | subtitle: "Show threads from every environment", | ||
| 69 | state: props.selectedEnvironmentId === null ? "on" : "off", | ||
| 70 | onPress: () => props.onEnvironmentChange(null), | ||
| 71 | }, | ||
| 72 | ...props.environments.map((environment) => ({ | ||
| 73 | type: "action" as const, | ||
| 74 | title: environment.label, | ||
| 75 | state: | ||
| 76 | props.selectedEnvironmentId === environment.environmentId | ||
| 77 | ? ("on" as const) | ||
| 78 | : ("off" as const), | ||
| 79 | onPress: () => props.onEnvironmentChange(environment.environmentId), | ||
| 80 | })), | ||
| 81 | ], | ||
| 82 | }, | ||
| 83 | { | ||
| 84 | type: "submenu", | ||
| 85 | title: "Sort projects", | ||
| 86 | items: PROJECT_SORT_OPTIONS.map((option) => ({ | ||
| 87 | type: "action", | ||
| 88 | title: option.label, | ||
| 89 | state: props.projectSortOrder === option.value ? "on" : "off", | ||
| 90 | onPress: () => props.onProjectSortOrderChange(option.value), | ||
| 91 | })), | ||
| 92 | }, | ||
| 93 | { | ||
| 94 | type: "submenu", | ||
| 95 | title: "Sort threads", | ||
| 96 | items: THREAD_SORT_OPTIONS.map((option) => ({ | ||
| 97 | type: "action", | ||
| 98 | title: option.label, | ||
| 99 | state: props.threadSortOrder === option.value ? "on" : "off", | ||
| 100 | onPress: () => props.onThreadSortOrderChange(option.value), | ||
| 101 | })), | ||
| 102 | }, | ||
| 103 | { | ||
| 104 | type: "submenu", | ||
| 105 | title: "Group projects", | ||
| 106 | items: PROJECT_GROUPING_OPTIONS.map((option) => ({ | ||
| 107 | type: "action", | ||
| 108 | title: option.label, | ||
| 109 | subtitle: option.subtitle, | ||
| 110 | state: props.projectGroupingMode === option.value ? "on" : "off", | ||
| 111 | onPress: () => props.onProjectGroupingModeChange(option.value), | ||
| 112 | })), | ||
| 113 | }, | ||
| 114 | ); | ||
| 115 | |||
| 116 | return { | ||
| 117 | title: "Thread list options", | ||
| 118 | items, | ||
| 119 | }; | ||
| 120 | } | ||
apps/mobile/src/features/home/home-list-options.test.ts created+31| ... | @@ -0,0 +1,31 @@ | ||
| 1 | import { | ||
| 2 | DEFAULT_SIDEBAR_PROJECT_GROUPING_MODE, | ||
| 3 | DEFAULT_SIDEBAR_PROJECT_SORT_ORDER, | ||
| 4 | DEFAULT_SIDEBAR_THREAD_SORT_ORDER, | ||
| 5 | } from "@t3tools/contracts"; | ||
| 6 | import { describe, expect, it } from "vite-plus/test"; | ||
| 7 | |||
| 8 | import { hasCustomHomeListOptions, type HomeListOptions } from "./home-list-options"; | ||
| 9 | |||
| 10 | const defaults: HomeListOptions = { | ||
| 11 | selectedEnvironmentId: null, | ||
| 12 | projectSortOrder: | ||
| 13 | DEFAULT_SIDEBAR_PROJECT_SORT_ORDER === "manual" | ||
| 14 | ? "updated_at" | ||
| 15 | : DEFAULT_SIDEBAR_PROJECT_SORT_ORDER, | ||
| 16 | threadSortOrder: DEFAULT_SIDEBAR_THREAD_SORT_ORDER, | ||
| 17 | projectGroupingMode: DEFAULT_SIDEBAR_PROJECT_GROUPING_MODE, | ||
| 18 | }; | ||
| 19 | |||
| 20 | describe("home list options", () => { | ||
| 21 | it("recognizes default options", () => { | ||
| 22 | expect(hasCustomHomeListOptions(defaults)).toBe(false); | ||
| 23 | }); | ||
| 24 | |||
| 25 | it("marks environment filters and grouping changes as customized", () => { | ||
| 26 | expect( | ||
| 27 | hasCustomHomeListOptions({ ...defaults, selectedEnvironmentId: "environment-1" as never }), | ||
| 28 | ).toBe(true); | ||
| 29 | expect(hasCustomHomeListOptions({ ...defaults, projectGroupingMode: "separate" })).toBe(true); | ||
| 30 | }); | ||
| 31 | }); | ||
apps/mobile/src/features/home/home-list-options.ts created+144| ... | @@ -0,0 +1,144 @@ | ||
| 1 | import type { | ||
| 2 | EnvironmentId, | ||
| 3 | SidebarProjectGroupingMode, | ||
| 4 | SidebarThreadSortOrder, | ||
| 5 | } from "@t3tools/contracts"; | ||
| 6 | import { | ||
| 7 | DEFAULT_SIDEBAR_PROJECT_GROUPING_MODE, | ||
| 8 | DEFAULT_SIDEBAR_PROJECT_SORT_ORDER, | ||
| 9 | DEFAULT_SIDEBAR_THREAD_SORT_ORDER, | ||
| 10 | } from "@t3tools/contracts"; | ||
| 11 | import { | ||
| 12 | createContext, | ||
| 13 | createElement, | ||
| 14 | useCallback, | ||
| 15 | useContext, | ||
| 16 | useMemo, | ||
| 17 | useState, | ||
| 18 | type PropsWithChildren, | ||
| 19 | type Dispatch, | ||
| 20 | type SetStateAction, | ||
| 21 | } from "react"; | ||
| 22 | |||
| 23 | import type { HomeProjectSortOrder } from "./homeThreadList"; | ||
| 24 | |||
| 25 | export interface HomeListOptions { | ||
| 26 | readonly selectedEnvironmentId: EnvironmentId | null; | ||
| 27 | readonly projectSortOrder: HomeProjectSortOrder; | ||
| 28 | readonly threadSortOrder: SidebarThreadSortOrder; | ||
| 29 | readonly projectGroupingMode: SidebarProjectGroupingMode; | ||
| 30 | } | ||
| 31 | |||
| 32 | export const PROJECT_SORT_OPTIONS: ReadonlyArray<{ | ||
| 33 | readonly value: HomeProjectSortOrder; | ||
| 34 | readonly label: string; | ||
| 35 | }> = [ | ||
| 36 | { value: "updated_at", label: "Last user message" }, | ||
| 37 | { value: "created_at", label: "Created at" }, | ||
| 38 | ]; | ||
| 39 | |||
| 40 | export const THREAD_SORT_OPTIONS: ReadonlyArray<{ | ||
| 41 | readonly value: SidebarThreadSortOrder; | ||
| 42 | readonly label: string; | ||
| 43 | }> = [ | ||
| 44 | { value: "updated_at", label: "Last user message" }, | ||
| 45 | { value: "created_at", label: "Created at" }, | ||
| 46 | ]; | ||
| 47 | |||
| 48 | export const PROJECT_GROUPING_OPTIONS: ReadonlyArray<{ | ||
| 49 | readonly value: SidebarProjectGroupingMode; | ||
| 50 | readonly label: string; | ||
| 51 | readonly subtitle: string; | ||
| 52 | }> = [ | ||
| 53 | { | ||
| 54 | value: "repository", | ||
| 55 | label: "Group by repository", | ||
| 56 | subtitle: "Combine matching repositories across environments", | ||
| 57 | }, | ||
| 58 | { | ||
| 59 | value: "repository_path", | ||
| 60 | label: "Group by repository path", | ||
| 61 | subtitle: "Combine only matching paths within a repository", | ||
| 62 | }, | ||
| 63 | { | ||
| 64 | value: "separate", | ||
| 65 | label: "Keep separate", | ||
| 66 | subtitle: "Show every project path separately", | ||
| 67 | }, | ||
| 68 | ]; | ||
| 69 | |||
| 70 | function defaultHomeListOptions(): HomeListOptions { | ||
| 71 | return { | ||
| 72 | selectedEnvironmentId: null, | ||
| 73 | projectSortOrder: | ||
| 74 | DEFAULT_SIDEBAR_PROJECT_SORT_ORDER === "manual" | ||
| 75 | ? "updated_at" | ||
| 76 | : DEFAULT_SIDEBAR_PROJECT_SORT_ORDER, | ||
| 77 | threadSortOrder: DEFAULT_SIDEBAR_THREAD_SORT_ORDER, | ||
| 78 | projectGroupingMode: DEFAULT_SIDEBAR_PROJECT_GROUPING_MODE, | ||
| 79 | }; | ||
| 80 | } | ||
| 81 | |||
| 82 | interface HomeListOptionsContextValue { | ||
| 83 | readonly options: HomeListOptions; | ||
| 84 | readonly setOptions: Dispatch<SetStateAction<HomeListOptions>>; | ||
| 85 | } | ||
| 86 | |||
| 87 | const HomeListOptionsContext = createContext<HomeListOptionsContextValue | null>(null); | ||
| 88 | |||
| 89 | /** Keeps list preferences stable while the app moves between compact and split shells. */ | ||
| 90 | export function HomeListOptionsProvider({ children }: PropsWithChildren) { | ||
| 91 | const [options, setOptions] = useState<HomeListOptions>(defaultHomeListOptions); | ||
| 92 | const value = useMemo(() => ({ options, setOptions }), [options]); | ||
| 93 | return createElement(HomeListOptionsContext, { value }, children); | ||
| 94 | } | ||
| 95 | |||
| 96 | export function hasCustomHomeListOptions(options: HomeListOptions): boolean { | ||
| 97 | const defaultProjectSortOrder = | ||
| 98 | DEFAULT_SIDEBAR_PROJECT_SORT_ORDER === "manual" | ||
| 99 | ? "updated_at" | ||
| 100 | : DEFAULT_SIDEBAR_PROJECT_SORT_ORDER; | ||
| 101 | return ( | ||
| 102 | options.selectedEnvironmentId !== null || | ||
| 103 | options.projectSortOrder !== defaultProjectSortOrder || | ||
| 104 | options.threadSortOrder !== DEFAULT_SIDEBAR_THREAD_SORT_ORDER || | ||
| 105 | options.projectGroupingMode !== DEFAULT_SIDEBAR_PROJECT_GROUPING_MODE | ||
| 106 | ); | ||
| 107 | } | ||
| 108 | |||
| 109 | export function useHomeListOptions(availableEnvironmentIds: ReadonlySet<EnvironmentId>) { | ||
| 110 | const shared = useContext(HomeListOptionsContext); | ||
| 111 | const [localOptions, setLocalOptions] = useState<HomeListOptions>(defaultHomeListOptions); | ||
| 112 | const options = shared?.options ?? localOptions; | ||
| 113 | const setOptions = shared?.setOptions ?? setLocalOptions; | ||
| 114 | const selectedEnvironmentId = | ||
| 115 | options.selectedEnvironmentId !== null && | ||
| 116 | availableEnvironmentIds.has(options.selectedEnvironmentId) | ||
| 117 | ? options.selectedEnvironmentId | ||
| 118 | : null; | ||
| 119 | const resolvedOptions = | ||
| 120 | selectedEnvironmentId === options.selectedEnvironmentId | ||
| 121 | ? options | ||
| 122 | : { ...options, selectedEnvironmentId }; | ||
| 123 | |||
| 124 | const setSelectedEnvironmentId = useCallback((value: EnvironmentId | null) => { | ||
| 125 | setOptions((current) => ({ ...current, selectedEnvironmentId: value })); | ||
| 126 | }, []); | ||
| 127 | const setProjectSortOrder = useCallback((value: HomeProjectSortOrder) => { | ||
| 128 | setOptions((current) => ({ ...current, projectSortOrder: value })); | ||
| 129 | }, []); | ||
| 130 | const setThreadSortOrder = useCallback((value: SidebarThreadSortOrder) => { | ||
| 131 | setOptions((current) => ({ ...current, threadSortOrder: value })); | ||
| 132 | }, []); | ||
| 133 | const setProjectGroupingMode = useCallback((value: SidebarProjectGroupingMode) => { | ||
| 134 | setOptions((current) => ({ ...current, projectGroupingMode: value })); | ||
| 135 | }, []); | ||
| 136 | |||
| 137 | return { | ||
| 138 | options: resolvedOptions, | ||
| 139 | setSelectedEnvironmentId, | ||
| 140 | setProjectSortOrder, | ||
| 141 | setThreadSortOrder, | ||
| 142 | setProjectGroupingMode, | ||
| 143 | } as const; | ||
| 144 | } | ||
apps/mobile/src/features/home/homeListItems.test.ts created+192| ... | @@ -0,0 +1,192 @@ | ||
| 1 | import type { | ||
| 2 | EnvironmentProject, | ||
| 3 | EnvironmentThreadShell, | ||
| 4 | } from "@t3tools/client-runtime/state/shell"; | ||
| 5 | import { EnvironmentId, ProjectId, ProviderInstanceId, ThreadId } from "@t3tools/contracts"; | ||
| 6 | import { describe, expect, it } from "vite-plus/test"; | ||
| 7 | |||
| 8 | import { | ||
| 9 | buildHomeListLayout, | ||
| 10 | DEFAULT_GROUP_DISPLAY_STATE, | ||
| 11 | HOME_INITIAL_VISIBLE_THREADS, | ||
| 12 | HOME_SHOW_MORE_STEP, | ||
| 13 | nextGroupDisplayState, | ||
| 14 | type HomeGroupDisplayState, | ||
| 15 | type HomeListItem, | ||
| 16 | } from "./homeListItems"; | ||
| 17 | import type { HomeThreadGroup } from "./homeThreadList"; | ||
| 18 | |||
| 19 | const environmentId = EnvironmentId.make("environment-1"); | ||
| 20 | |||
| 21 | function makeProject(id: string, title: string): EnvironmentProject { | ||
| 22 | return { | ||
| 23 | environmentId, | ||
| 24 | id: ProjectId.make(id), | ||
| 25 | title, | ||
| 26 | workspaceRoot: `/workspaces/${id}`, | ||
| 27 | repositoryIdentity: null, | ||
| 28 | defaultModelSelection: null, | ||
| 29 | scripts: [], | ||
| 30 | createdAt: "2026-06-01T00:00:00.000Z", | ||
| 31 | updatedAt: "2026-06-01T00:00:00.000Z", | ||
| 32 | }; | ||
| 33 | } | ||
| 34 | |||
| 35 | function makeThread(id: string, projectId: ProjectId): EnvironmentThreadShell { | ||
| 36 | return { | ||
| 37 | environmentId, | ||
| 38 | id: ThreadId.make(id), | ||
| 39 | projectId, | ||
| 40 | title: `Thread ${id}`, | ||
| 41 | modelSelection: { instanceId: ProviderInstanceId.make("codex"), model: "gpt-5.4" }, | ||
| 42 | runtimeMode: "full-access", | ||
| 43 | interactionMode: "default", | ||
| 44 | branch: null, | ||
| 45 | worktreePath: null, | ||
| 46 | latestTurn: null, | ||
| 47 | createdAt: "2026-06-01T00:00:00.000Z", | ||
| 48 | updatedAt: "2026-06-01T00:00:00.000Z", | ||
| 49 | archivedAt: null, | ||
| 50 | session: null, | ||
| 51 | latestUserMessageAt: null, | ||
| 52 | hasPendingApprovals: false, | ||
| 53 | hasPendingUserInput: false, | ||
| 54 | hasActionableProposedPlan: false, | ||
| 55 | }; | ||
| 56 | } | ||
| 57 | |||
| 58 | function makeGroup(key: string, threadCount: number): HomeThreadGroup { | ||
| 59 | const project = makeProject(key, key); | ||
| 60 | return { | ||
| 61 | key, | ||
| 62 | title: key, | ||
| 63 | representative: project, | ||
| 64 | projects: [project], | ||
| 65 | threads: Array.from({ length: threadCount }, (_, index) => | ||
| 66 | makeThread(`${key}-thread-${index}`, project.id), | ||
| 67 | ), | ||
| 68 | }; | ||
| 69 | } | ||
| 70 | |||
| 71 | function itemTypes(items: ReadonlyArray<HomeListItem>): string[] { | ||
| 72 | return items.map((item) => item.type); | ||
| 73 | } | ||
| 74 | |||
| 75 | function displayStates( | ||
| 76 | entries: Record<string, HomeGroupDisplayState>, | ||
| 77 | ): ReadonlyMap<string, HomeGroupDisplayState> { | ||
| 78 | return new Map(Object.entries(entries)); | ||
| 79 | } | ||
| 80 | |||
| 81 | describe("buildHomeListLayout", () => { | ||
| 82 | it("renders a header plus all threads for a small group without a show-more row", () => { | ||
| 83 | const layout = buildHomeListLayout({ | ||
| 84 | groups: [makeGroup("alpha", 3)], | ||
| 85 | displayStates: displayStates({}), | ||
| 86 | }); | ||
| 87 | |||
| 88 | expect(itemTypes(layout.items)).toEqual(["header", "thread", "thread", "thread"]); | ||
| 89 | expect(layout.stickyHeaderIndices).toEqual([0]); | ||
| 90 | expect(layout.items.at(-1)).toMatchObject({ type: "thread", isLast: true }); | ||
| 91 | }); | ||
| 92 | |||
| 93 | it("limits large groups to the initial visible count with a show-more row", () => { | ||
| 94 | const layout = buildHomeListLayout({ | ||
| 95 | groups: [makeGroup("alpha", 133)], | ||
| 96 | displayStates: displayStates({}), | ||
| 97 | }); | ||
| 98 | |||
| 99 | const threadItems = layout.items.filter((item) => item.type === "thread"); | ||
| 100 | expect(threadItems).toHaveLength(HOME_INITIAL_VISIBLE_THREADS); | ||
| 101 | expect(layout.items.at(-1)).toMatchObject({ | ||
| 102 | type: "show-more", | ||
| 103 | groupKey: "alpha", | ||
| 104 | hiddenCount: 133 - HOME_INITIAL_VISIBLE_THREADS, | ||
| 105 | canShowLess: false, | ||
| 106 | }); | ||
| 107 | // The show-more row takes over the last slot, so no thread is marked last. | ||
| 108 | expect(threadItems.every((item) => item.type === "thread" && !item.isLast)).toBe(true); | ||
| 109 | }); | ||
| 110 | |||
| 111 | it("reveals more threads per show-more step and offers show-less when exhausted", () => { | ||
| 112 | const group = makeGroup("alpha", 20); | ||
| 113 | |||
| 114 | const expandedOnce = buildHomeListLayout({ | ||
| 115 | groups: [group], | ||
| 116 | displayStates: displayStates({ | ||
| 117 | alpha: nextGroupDisplayState(DEFAULT_GROUP_DISPLAY_STATE, "show-more"), | ||
| 118 | }), | ||
| 119 | }); | ||
| 120 | expect(expandedOnce.items.filter((item) => item.type === "thread")).toHaveLength( | ||
| 121 | HOME_INITIAL_VISIBLE_THREADS + HOME_SHOW_MORE_STEP, | ||
| 122 | ); | ||
| 123 | expect(expandedOnce.items.at(-1)).toMatchObject({ | ||
| 124 | type: "show-more", | ||
| 125 | hiddenCount: 4, | ||
| 126 | canShowLess: true, | ||
| 127 | }); | ||
| 128 | |||
| 129 | const fullyExpanded = buildHomeListLayout({ | ||
| 130 | groups: [group], | ||
| 131 | displayStates: displayStates({ | ||
| 132 | alpha: nextGroupDisplayState( | ||
| 133 | nextGroupDisplayState(DEFAULT_GROUP_DISPLAY_STATE, "show-more"), | ||
| 134 | "show-more", | ||
| 135 | ), | ||
| 136 | }), | ||
| 137 | }); | ||
| 138 | expect(fullyExpanded.items.filter((item) => item.type === "thread")).toHaveLength(20); | ||
| 139 | expect(fullyExpanded.items.at(-1)).toMatchObject({ | ||
| 140 | type: "show-more", | ||
| 141 | hiddenCount: 0, | ||
| 142 | canShowLess: true, | ||
| 143 | }); | ||
| 144 | |||
| 145 | const reset = nextGroupDisplayState( | ||
| 146 | nextGroupDisplayState( | ||
| 147 | nextGroupDisplayState(DEFAULT_GROUP_DISPLAY_STATE, "show-more"), | ||
| 148 | "show-more", | ||
| 149 | ), | ||
| 150 | "show-less", | ||
| 151 | ); | ||
| 152 | expect(reset.visibleCount).toBe(HOME_INITIAL_VISIBLE_THREADS); | ||
| 153 | }); | ||
| 154 | |||
| 155 | it("hides threads and the show-more row for collapsed groups", () => { | ||
| 156 | const layout = buildHomeListLayout({ | ||
| 157 | groups: [makeGroup("alpha", 12), makeGroup("beta", 2)], | ||
| 158 | displayStates: displayStates({ | ||
| 159 | alpha: nextGroupDisplayState(DEFAULT_GROUP_DISPLAY_STATE, "toggle-collapsed"), | ||
| 160 | }), | ||
| 161 | }); | ||
| 162 | |||
| 163 | expect(itemTypes(layout.items)).toEqual(["header", "header", "thread", "thread"]); | ||
| 164 | expect(layout.items[0]).toMatchObject({ type: "header", collapsed: true, isFirst: true }); | ||
| 165 | expect(layout.items[1]).toMatchObject({ type: "header", collapsed: false, isFirst: false }); | ||
| 166 | expect(layout.stickyHeaderIndices).toEqual([0, 1]); | ||
| 167 | }); | ||
| 168 | |||
| 169 | it("suspends collapse and pagination while searching", () => { | ||
| 170 | const layout = buildHomeListLayout({ | ||
| 171 | groups: [makeGroup("alpha", 12)], | ||
| 172 | displayStates: displayStates({ | ||
| 173 | alpha: nextGroupDisplayState(DEFAULT_GROUP_DISPLAY_STATE, "toggle-collapsed"), | ||
| 174 | }), | ||
| 175 | showAllThreads: true, | ||
| 176 | }); | ||
| 177 | |||
| 178 | expect(layout.items.filter((item) => item.type === "thread")).toHaveLength(12); | ||
| 179 | expect(layout.items.some((item) => item.type === "show-more")).toBe(false); | ||
| 180 | }); | ||
| 181 | |||
| 182 | it("keeps sticky indices aligned across multiple expanded groups", () => { | ||
| 183 | const layout = buildHomeListLayout({ | ||
| 184 | groups: [makeGroup("alpha", 8), makeGroup("beta", 1)], | ||
| 185 | displayStates: displayStates({}), | ||
| 186 | }); | ||
| 187 | |||
| 188 | // header + 6 threads + show-more = 8 items, so beta's header is index 8. | ||
| 189 | expect(layout.stickyHeaderIndices).toEqual([0, 8]); | ||
| 190 | expect(layout.items[8]).toMatchObject({ type: "header", isFirst: false }); | ||
| 191 | }); | ||
| 192 | }); | ||
apps/mobile/src/features/home/homeListItems.ts created+158| ... | @@ -0,0 +1,158 @@ | ||
| 1 | import type { EnvironmentThreadShell } from "@t3tools/client-runtime/state/shell"; | ||
| 2 | |||
| 3 | import type { HomeThreadGroup } from "./homeThreadList"; | ||
| 4 | |||
| 5 | /** Threads shown per project before the "Show more" affordance appears. */ | ||
| 6 | export const HOME_INITIAL_VISIBLE_THREADS = 6; | ||
| 7 | /** Additional threads revealed per "Show more" tap. */ | ||
| 8 | export const HOME_SHOW_MORE_STEP = 10; | ||
| 9 | |||
| 10 | export interface HomeGroupDisplayState { | ||
| 11 | readonly collapsed: boolean; | ||
| 12 | /** How many threads are currently revealed (clamped to the group size). */ | ||
| 13 | readonly visibleCount: number; | ||
| 14 | } | ||
| 15 | |||
| 16 | export const DEFAULT_GROUP_DISPLAY_STATE: HomeGroupDisplayState = { | ||
| 17 | collapsed: false, | ||
| 18 | visibleCount: HOME_INITIAL_VISIBLE_THREADS, | ||
| 19 | }; | ||
| 20 | |||
| 21 | export interface HomeHeaderListItem { | ||
| 22 | readonly type: "header"; | ||
| 23 | readonly key: string; | ||
| 24 | readonly group: HomeThreadGroup; | ||
| 25 | readonly collapsed: boolean; | ||
| 26 | readonly isFirst: boolean; | ||
| 27 | } | ||
| 28 | |||
| 29 | export interface HomeThreadListItem { | ||
| 30 | readonly type: "thread"; | ||
| 31 | readonly key: string; | ||
| 32 | readonly thread: EnvironmentThreadShell; | ||
| 33 | readonly isLast: boolean; | ||
| 34 | } | ||
| 35 | |||
| 36 | export interface HomeShowMoreListItem { | ||
| 37 | readonly type: "show-more"; | ||
| 38 | readonly key: string; | ||
| 39 | readonly groupKey: string; | ||
| 40 | /** Threads still hidden. 0 means the group is fully expanded. */ | ||
| 41 | readonly hiddenCount: number; | ||
| 42 | /** Whether more than the initial count is revealed, so "Show less" applies. */ | ||
| 43 | readonly canShowLess: boolean; | ||
| 44 | } | ||
| 45 | |||
| 46 | export type HomeListItem = HomeHeaderListItem | HomeThreadListItem | HomeShowMoreListItem; | ||
| 47 | |||
| 48 | export interface HomeListLayout { | ||
| 49 | readonly items: ReadonlyArray<HomeListItem>; | ||
| 50 | readonly stickyHeaderIndices: ReadonlyArray<number>; | ||
| 51 | } | ||
| 52 | |||
| 53 | export type HomeGroupDisplayAction = "toggle-collapsed" | "show-more" | "show-less"; | ||
| 54 | |||
| 55 | export function nextGroupDisplayState( | ||
| 56 | current: HomeGroupDisplayState, | ||
| 57 | action: HomeGroupDisplayAction, | ||
| 58 | ): HomeGroupDisplayState { | ||
| 59 | switch (action) { | ||
| 60 | case "toggle-collapsed": | ||
| 61 | return { ...current, collapsed: !current.collapsed }; | ||
| 62 | case "show-more": | ||
| 63 | return { ...current, visibleCount: current.visibleCount + HOME_SHOW_MORE_STEP }; | ||
| 64 | case "show-less": | ||
| 65 | return { ...current, visibleCount: HOME_INITIAL_VISIBLE_THREADS }; | ||
| 66 | } | ||
| 67 | } | ||
| 68 | |||
| 69 | /** | ||
| 70 | * Structural equality for list items. Item objects are rebuilt on every | ||
| 71 | * collapse/show-more toggle; without this the lists would consider every | ||
| 72 | * mounted row changed and re-render all of them (each carrying a swipeable + | ||
| 73 | * a vcs-status subscription). Group/thread references are stable across | ||
| 74 | * toggles. | ||
| 75 | */ | ||
| 76 | export function homeListItemsAreEqual(previous: HomeListItem, item: HomeListItem): boolean { | ||
| 77 | switch (item.type) { | ||
| 78 | case "header": | ||
| 79 | return ( | ||
| 80 | previous.type === "header" && | ||
| 81 | previous.group === item.group && | ||
| 82 | previous.collapsed === item.collapsed && | ||
| 83 | previous.isFirst === item.isFirst | ||
| 84 | ); | ||
| 85 | case "thread": | ||
| 86 | return ( | ||
| 87 | previous.type === "thread" && | ||
| 88 | previous.thread === item.thread && | ||
| 89 | previous.isLast === item.isLast | ||
| 90 | ); | ||
| 91 | case "show-more": | ||
| 92 | return ( | ||
| 93 | previous.type === "show-more" && | ||
| 94 | previous.groupKey === item.groupKey && | ||
| 95 | previous.hiddenCount === item.hiddenCount && | ||
| 96 | previous.canShowLess === item.canShowLess | ||
| 97 | ); | ||
| 98 | } | ||
| 99 | } | ||
| 100 | |||
| 101 | export function buildHomeListLayout(input: { | ||
| 102 | readonly groups: ReadonlyArray<HomeThreadGroup>; | ||
| 103 | readonly displayStates: ReadonlyMap<string, HomeGroupDisplayState>; | ||
| 104 | /** | ||
| 105 | * When searching, pagination is suspended so every match stays visible. | ||
| 106 | */ | ||
| 107 | readonly showAllThreads?: boolean; | ||
| 108 | }): HomeListLayout { | ||
| 109 | const items: HomeListItem[] = []; | ||
| 110 | const stickyHeaderIndices: number[] = []; | ||
| 111 | |||
| 112 | for (const [groupIndex, group] of input.groups.entries()) { | ||
| 113 | const display = input.displayStates.get(group.key) ?? DEFAULT_GROUP_DISPLAY_STATE; | ||
| 114 | const collapsed = display.collapsed && input.showAllThreads !== true; | ||
| 115 | |||
| 116 | stickyHeaderIndices.push(items.length); | ||
| 117 | items.push({ | ||
| 118 | type: "header", | ||
| 119 | key: `header:${group.key}`, | ||
| 120 | group, | ||
| 121 | collapsed, | ||
| 122 | isFirst: groupIndex === 0, | ||
| 123 | }); | ||
| 124 | |||
| 125 | if (collapsed) { | ||
| 126 | continue; | ||
| 127 | } | ||
| 128 | |||
| 129 | const totalCount = group.threads.length; | ||
| 130 | const visibleCount = input.showAllThreads | ||
| 131 | ? totalCount | ||
| 132 | : Math.min(Math.max(display.visibleCount, HOME_INITIAL_VISIBLE_THREADS), totalCount); | ||
| 133 | const visibleThreads = group.threads.slice(0, visibleCount); | ||
| 134 | const hiddenCount = totalCount - visibleCount; | ||
| 135 | const hasShowMoreRow = !input.showAllThreads && totalCount > HOME_INITIAL_VISIBLE_THREADS; | ||
| 136 | |||
| 137 | for (const [threadIndex, thread] of visibleThreads.entries()) { | ||
| 138 | items.push({ | ||
| 139 | type: "thread", | ||
| 140 | key: `thread:${thread.environmentId}:${thread.id}`, | ||
| 141 | thread, | ||
| 142 | isLast: threadIndex === visibleThreads.length - 1 && !hasShowMoreRow, | ||
| 143 | }); | ||
| 144 | } | ||
| 145 | |||
| 146 | if (hasShowMoreRow) { | ||
| 147 | items.push({ | ||
| 148 | type: "show-more", | ||
| 149 | key: `show-more:${group.key}`, | ||
| 150 | groupKey: group.key, | ||
| 151 | hiddenCount, | ||
| 152 | canShowLess: visibleCount > HOME_INITIAL_VISIBLE_THREADS, | ||
| 153 | }); | ||
| 154 | } | ||
| 155 | } | ||
| 156 | |||
| 157 | return { items, stickyHeaderIndices }; | ||
| 158 | } | ||
apps/mobile/src/features/home/thread-swipe-actions.tsx+247-9| ... | @@ -1,8 +1,26 @@ | ... | @@ -1,8 +1,26 @@ |
| 1 | import { SymbolView } from "expo-symbols"; | 1 | import { SymbolView } from "expo-symbols"; |
| 2 | import type { ComponentProps } from "react"; | 2 | import * as Haptics from "expo-haptics"; |
| 3 | import type { ColorValue } from "react-native"; | 3 | import { |
| 4 | createContext, | ||
| 5 | use, | ||
| 6 | useCallback, | ||
| 7 | useEffect, | ||
| 8 | useRef, | ||
| 9 | useState, | ||
| 10 | type ComponentProps, | ||
| 11 | type ReactNode, | ||
| 12 | } from "react"; | ||
| 13 | import type { | ||
| 14 | ColorValue, | ||
| 15 | NativeScrollEvent, | ||
| 16 | NativeSyntheticEvent, | ||
| 17 | StyleProp, | ||
| 18 | ViewStyle, | ||
| 19 | } from "react-native"; | ||
| 4 | import { Pressable, View } from "react-native"; | 20 | import { Pressable, View } from "react-native"; |
| 5 | import type { SwipeableMethods } from "react-native-gesture-handler/ReanimatedSwipeable"; | 21 | import ReanimatedSwipeable, { |
| 22 | type SwipeableMethods, | ||
| 23 | } from "react-native-gesture-handler/ReanimatedSwipeable"; | ||
| 6 | import Animated, { | 24 | import Animated, { |
| 7 | Extrapolation, | 25 | Extrapolation, |
| 8 | interpolate, | 26 | interpolate, |
| ... | @@ -26,6 +44,231 @@ export const THREAD_SWIPE_SPRING = { | ... | @@ -26,6 +44,231 @@ export const THREAD_SWIPE_SPRING = { |
| 26 | stiffness: 330, | 44 | stiffness: 330, |
| 27 | }; | 45 | }; |
| 28 | 46 | ||
| 47 | interface ThreadSwipePrimaryAction { | ||
| 48 | readonly accessibilityLabel: string; | ||
| 49 | readonly icon: ComponentProps<typeof SymbolView>["name"]; | ||
| 50 | readonly label: string; | ||
| 51 | readonly onPress: () => void; | ||
| 52 | } | ||
| 53 | |||
| 54 | /** | ||
| 55 | * Delivers the scroll gate to swipeables via context so that flipping it does | ||
| 56 | * NOT re-render whole rows: putting the flag in list extraData/renderItem deps | ||
| 57 | * re-rendered every visible row (hooks, subscriptions and all) exactly at | ||
| 58 | * scroll start — peak frame pressure. As a context value only the | ||
| 59 | * ThreadSwipeable consumers re-render. | ||
| 60 | */ | ||
| 61 | const SwipeableScrollGateContext = createContext(true); | ||
| 62 | |||
| 63 | export function SwipeableScrollGateProvider(props: { | ||
| 64 | readonly enabled: boolean; | ||
| 65 | readonly children: ReactNode; | ||
| 66 | }) { | ||
| 67 | return ( | ||
| 68 | <SwipeableScrollGateContext.Provider value={props.enabled}> | ||
| 69 | {props.children} | ||
| 70 | </SwipeableScrollGateContext.Provider> | ||
| 71 | ); | ||
| 72 | } | ||
| 73 | |||
| 74 | /** | ||
| 75 | * Gates row swipes on list scroll activity, mirroring UIKit's own swipe | ||
| 76 | * actions (`!isDragging && !isDecelerating`). failOffsetY on the swipe pan | ||
| 77 | * covers the first pan of a scroll, but trackpad scroll sessions spawn fresh | ||
| 78 | * gesture sessions (momentum catch, direction changes) whose reset | ||
| 79 | * translation can re-activate a swipe mid-scroll — so while the list has | ||
| 80 | * moved vertically during an active drag/momentum phase, row swipes are | ||
| 81 | * disabled entirely. | ||
| 82 | * | ||
| 83 | * Spread the returned handlers onto the list and pass `swipeEnabled` to rows. | ||
| 84 | */ | ||
| 85 | export function useSwipeableScrollGate(options?: { | ||
| 86 | readonly onScroll?: (event: NativeSyntheticEvent<NativeScrollEvent>) => void; | ||
| 87 | readonly onScrollBeginDrag?: (event: NativeSyntheticEvent<NativeScrollEvent>) => void; | ||
| 88 | }) { | ||
| 89 | const [gateActive, setGateActive] = useState(false); | ||
| 90 | const gateActiveRef = useRef(false); | ||
| 91 | const draggingRef = useRef(false); | ||
| 92 | const dragStartYRef = useRef(0); | ||
| 93 | const settleTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null); | ||
| 94 | const externalOnScroll = options?.onScroll; | ||
| 95 | const externalOnScrollBeginDrag = options?.onScrollBeginDrag; | ||
| 96 | |||
| 97 | const update = useCallback((next: boolean) => { | ||
| 98 | if (gateActiveRef.current !== next) { | ||
| 99 | gateActiveRef.current = next; | ||
| 100 | setGateActive(next); | ||
| 101 | } | ||
| 102 | }, []); | ||
| 103 | const clearSettle = useCallback(() => { | ||
| 104 | if (settleTimerRef.current !== null) { | ||
| 105 | clearTimeout(settleTimerRef.current); | ||
| 106 | settleTimerRef.current = null; | ||
| 107 | } | ||
| 108 | }, []); | ||
| 109 | useEffect(() => clearSettle, [clearSettle]); | ||
| 110 | |||
| 111 | const onScrollBeginDrag = useCallback( | ||
| 112 | (event: NativeSyntheticEvent<NativeScrollEvent>) => { | ||
| 113 | draggingRef.current = true; | ||
| 114 | dragStartYRef.current = event.nativeEvent.contentOffset.y; | ||
| 115 | clearSettle(); | ||
| 116 | externalOnScrollBeginDrag?.(event); | ||
| 117 | }, | ||
| 118 | [clearSettle, externalOnScrollBeginDrag], | ||
| 119 | ); | ||
| 120 | const onScroll = useCallback( | ||
| 121 | (event: NativeSyntheticEvent<NativeScrollEvent>) => { | ||
| 122 | // Only vertical movement during a user drag arms the gate — a purely | ||
| 123 | // horizontal row swipe never moves contentOffset.y, and inset-driven | ||
| 124 | // offset changes at mount happen outside a drag. | ||
| 125 | if ( | ||
| 126 | draggingRef.current && | ||
| 127 | !gateActiveRef.current && | ||
| 128 | Math.abs(event.nativeEvent.contentOffset.y - dragStartYRef.current) > 4 | ||
| 129 | ) { | ||
| 130 | update(true); | ||
| 131 | } | ||
| 132 | externalOnScroll?.(event); | ||
| 133 | }, | ||
| 134 | [externalOnScroll, update], | ||
| 135 | ); | ||
| 136 | const onScrollEndDrag = useCallback(() => { | ||
| 137 | draggingRef.current = false; | ||
| 138 | clearSettle(); | ||
| 139 | // If momentum follows, onMomentumScrollBegin cancels this and the gate | ||
| 140 | // stays armed until the deceleration finishes. | ||
| 141 | settleTimerRef.current = setTimeout(() => update(false), 160); | ||
| 142 | }, [clearSettle, update]); | ||
| 143 | const onMomentumScrollBegin = useCallback(() => { | ||
| 144 | clearSettle(); | ||
| 145 | }, [clearSettle]); | ||
| 146 | const onMomentumScrollEnd = useCallback(() => { | ||
| 147 | update(false); | ||
| 148 | }, [update]); | ||
| 149 | |||
| 150 | return { | ||
| 151 | swipeEnabled: !gateActive, | ||
| 152 | scrollGateHandlers: { | ||
| 153 | onScroll, | ||
| 154 | onScrollBeginDrag, | ||
| 155 | onScrollEndDrag, | ||
| 156 | onMomentumScrollBegin, | ||
| 157 | onMomentumScrollEnd, | ||
| 158 | }, | ||
| 159 | }; | ||
| 160 | } | ||
| 161 | |||
| 162 | export function ThreadSwipeable(props: { | ||
| 163 | readonly backgroundColor: ColorValue; | ||
| 164 | readonly children: (close: () => void) => ReactNode; | ||
| 165 | readonly containerStyle?: StyleProp<ViewStyle>; | ||
| 166 | /** Disables NEW swipe activations (e.g. while the list scrolls). */ | ||
| 167 | readonly enabled?: boolean; | ||
| 168 | readonly enableTrackpadSwipe?: boolean; | ||
| 169 | readonly fullSwipeWidth: number; | ||
| 170 | readonly onDelete: () => void; | ||
| 171 | readonly onSwipeableClose?: (methods: SwipeableMethods) => void; | ||
| 172 | readonly onSwipeableWillOpen?: (methods: SwipeableMethods) => void; | ||
| 173 | readonly primaryAction: ThreadSwipePrimaryAction; | ||
| 174 | /** | ||
| 175 | * Identity of the content being wrapped. When a recycled list reuses this | ||
| 176 | * component for a different item, the swipeable snaps back to closed so an | ||
| 177 | * open/mid-drag state can't leak onto another row. | ||
| 178 | */ | ||
| 179 | readonly resetKey?: string; | ||
| 180 | readonly simultaneousWithExternalGesture?: ComponentProps< | ||
| 181 | typeof ReanimatedSwipeable | ||
| 182 | >["simultaneousWithExternalGesture"]; | ||
| 183 | readonly threadTitle: string; | ||
| 184 | }) { | ||
| 185 | const swipeableRef = useRef<SwipeableMethods | null>(null); | ||
| 186 | const fullSwipeArmedRef = useRef(false); | ||
| 187 | const fullSwipeThreshold = Math.max(THREAD_SWIPE_ACTIONS_WIDTH + 44, props.fullSwipeWidth * 0.58); | ||
| 188 | const close = useCallback(() => swipeableRef.current?.close(), []); | ||
| 189 | const gateEnabled = use(SwipeableScrollGateContext); | ||
| 190 | const resetKey = props.resetKey; | ||
| 191 | useEffect(() => { | ||
| 192 | if (resetKey === undefined) { | ||
| 193 | return; | ||
| 194 | } | ||
| 195 | fullSwipeArmedRef.current = false; | ||
| 196 | swipeableRef.current?.reset(); | ||
| 197 | }, [resetKey]); | ||
| 198 | const handleFullSwipeArmedChange = useCallback((armed: boolean) => { | ||
| 199 | if (armed && !fullSwipeArmedRef.current && process.env.EXPO_OS === "ios") { | ||
| 200 | void Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Medium); | ||
| 201 | } | ||
| 202 | fullSwipeArmedRef.current = armed; | ||
| 203 | }, []); | ||
| 204 | |||
| 205 | return ( | ||
| 206 | <ReanimatedSwipeable | ||
| 207 | ref={swipeableRef} | ||
| 208 | animationOptions={THREAD_SWIPE_SPRING} | ||
| 209 | childrenContainerStyle={{ backgroundColor: props.backgroundColor }} | ||
| 210 | containerStyle={[{ backgroundColor: props.backgroundColor }, props.containerStyle]} | ||
| 211 | dragOffsetFromRightEdge={8} | ||
| 212 | enabled={props.enabled !== false && gateEnabled} | ||
| 213 | enableTrackpadTwoFingerGesture={props.enableTrackpadSwipe ?? true} | ||
| 214 | // Fail the swipe once the pan is vertically dominant (patched-in RNGH | ||
| 215 | // prop) — otherwise trackpad scrolls with ~8px of horizontal drift | ||
| 216 | // start opening rows because the swipe pan runs simultaneously with | ||
| 217 | // the list scroll gesture and never gets disqualified by Y movement. | ||
| 218 | failOffsetY={[-10, 10]} | ||
| 219 | friction={1} | ||
| 220 | onSwipeableClose={() => { | ||
| 221 | fullSwipeArmedRef.current = false; | ||
| 222 | if (swipeableRef.current) { | ||
| 223 | props.onSwipeableClose?.(swipeableRef.current); | ||
| 224 | } | ||
| 225 | }} | ||
| 226 | onSwipeableOpenStartDrag={() => { | ||
| 227 | if (swipeableRef.current) { | ||
| 228 | props.onSwipeableWillOpen?.(swipeableRef.current); | ||
| 229 | } | ||
| 230 | }} | ||
| 231 | onSwipeableWillOpen={() => { | ||
| 232 | const methods = swipeableRef.current; | ||
| 233 | if (!methods) { | ||
| 234 | return; | ||
| 235 | } | ||
| 236 | |||
| 237 | props.onSwipeableWillOpen?.(methods); | ||
| 238 | if (fullSwipeArmedRef.current) { | ||
| 239 | fullSwipeArmedRef.current = false; | ||
| 240 | methods.close(); | ||
| 241 | props.onDelete(); | ||
| 242 | } | ||
| 243 | }} | ||
| 244 | overshootFriction={1} | ||
| 245 | overshootRight | ||
| 246 | renderRightActions={(_progress, translation, methods) => ( | ||
| 247 | <ThreadSwipeActions | ||
| 248 | backgroundColor={props.backgroundColor} | ||
| 249 | fullSwipeThreshold={fullSwipeThreshold} | ||
| 250 | onDelete={props.onDelete} | ||
| 251 | onFullSwipeArmedChange={handleFullSwipeArmedChange} | ||
| 252 | primaryAction={{ | ||
| 253 | ...props.primaryAction, | ||
| 254 | onPress: () => { | ||
| 255 | methods.close(); | ||
| 256 | props.primaryAction.onPress(); | ||
| 257 | }, | ||
| 258 | }} | ||
| 259 | swipeableMethods={methods} | ||
| 260 | threadTitle={props.threadTitle} | ||
| 261 | translation={translation} | ||
| 262 | /> | ||
| 263 | )} | ||
| 264 | rightThreshold={THREAD_SWIPE_ACTIONS_WIDTH * 0.42} | ||
| 265 | simultaneousWithExternalGesture={props.simultaneousWithExternalGesture} | ||
| 266 | > | ||
| 267 | {props.children(close)} | ||
| 268 | </ReanimatedSwipeable> | ||
| 269 | ); | ||
| 270 | } | ||
| 271 | |||
| 29 | function SwipeActionButton(props: { | 272 | function SwipeActionButton(props: { |
| 30 | readonly accessibilityLabel: string; | 273 | readonly accessibilityLabel: string; |
| 31 | readonly backgroundColor: string; | 274 | readonly backgroundColor: string; |
| ... | @@ -179,12 +422,7 @@ export function ThreadSwipeActions(props: { | ... | @@ -179,12 +422,7 @@ export function ThreadSwipeActions(props: { |
| 179 | readonly fullSwipeThreshold: number; | 422 | readonly fullSwipeThreshold: number; |
| 180 | readonly onDelete: () => void; | 423 | readonly onDelete: () => void; |
| 181 | readonly onFullSwipeArmedChange: (armed: boolean) => void; | 424 | readonly onFullSwipeArmedChange: (armed: boolean) => void; |
| 182 | readonly primaryAction: { | 425 | readonly primaryAction: ThreadSwipePrimaryAction; |
| 183 | readonly accessibilityLabel: string; | ||
| 184 | readonly icon: ComponentProps<typeof SymbolView>["name"]; | ||
| 185 | readonly label: string; | ||
| 186 | readonly onPress: () => void; | ||
| 187 | }; | ||
| 188 | readonly swipeableMethods: SwipeableMethods; | 426 | readonly swipeableMethods: SwipeableMethods; |
| 189 | readonly threadTitle: string; | 427 | readonly threadTitle: string; |
| 190 | readonly translation: SharedValue<number>; | 428 | readonly translation: SharedValue<number>; |
apps/mobile/src/features/home/workspace-connection-status.ts created+22| ... | @@ -0,0 +1,22 @@ | ||
| 1 | import type { WorkspaceState } from "../../state/workspaceModel"; | ||
| 2 | |||
| 3 | export function shouldShowWorkspaceConnectionStatus(state: WorkspaceState): boolean { | ||
| 4 | return ( | ||
| 5 | state.networkStatus === "offline" || | ||
| 6 | state.connectionError !== null || | ||
| 7 | state.hasConnectingEnvironment || | ||
| 8 | (state.hasLoadedShellSnapshot && !state.hasReadyEnvironment) | ||
| 9 | ); | ||
| 10 | } | ||
| 11 | |||
| 12 | export function workspaceConnectionStatusLabel(state: WorkspaceState): string { | ||
| 13 | if (state.networkStatus === "offline") return "You are offline"; | ||
| 14 | if (state.connectingEnvironments.length === 1) { | ||
| 15 | return `Reconnecting to ${state.connectingEnvironments[0]!.environmentLabel}`; | ||
| 16 | } | ||
| 17 | if (state.connectingEnvironments.length > 1) { | ||
| 18 | return `Reconnecting ${state.connectingEnvironments.length} environments`; | ||
| 19 | } | ||
| 20 | if (state.connectionError !== null) return state.connectionError; | ||
| 21 | return "Not connected"; | ||
| 22 | } | ||
apps/mobile/src/features/keyboard/HardwareKeyboardCommandProvider.tsx created+73| ... | @@ -0,0 +1,73 @@ | ||
| 1 | import { StackActions, useNavigation } from "@react-navigation/native"; | ||
| 2 | import { useCallback, useMemo, useSyncExternalStore, type PropsWithChildren } from "react"; | ||
| 3 | |||
| 4 | import { T3KeyboardCommands } from "../../native/T3KeyboardCommands"; | ||
| 5 | import { | ||
| 6 | dispatchHardwareKeyboardCommand, | ||
| 7 | getHardwareKeyboardCommandRegistrationVersion, | ||
| 8 | getRegisteredHardwareKeyboardCommands, | ||
| 9 | parseActiveThreadPath, | ||
| 10 | subscribeToHardwareKeyboardCommandRegistrations, | ||
| 11 | type HardwareKeyboardCommand, | ||
| 12 | } from "./hardwareKeyboardCommands"; | ||
| 13 | |||
| 14 | export function HardwareKeyboardCommandProvider({ | ||
| 15 | children, | ||
| 16 | pathname, | ||
| 17 | }: PropsWithChildren<{ readonly pathname: string }>) { | ||
| 18 | const navigation = useNavigation(); | ||
| 19 | const registrationVersion = useSyncExternalStore( | ||
| 20 | subscribeToHardwareKeyboardCommandRegistrations, | ||
| 21 | getHardwareKeyboardCommandRegistrationVersion, | ||
| 22 | getHardwareKeyboardCommandRegistrationVersion, | ||
| 23 | ); | ||
| 24 | const enabledCommands = useMemo(() => { | ||
| 25 | const commands = new Set<HardwareKeyboardCommand>(getRegisteredHardwareKeyboardCommands()); | ||
| 26 | commands.add("newTask"); | ||
| 27 | if (pathname !== "/" || navigation.canGoBack()) commands.add("back"); | ||
| 28 | if (parseActiveThreadPath(pathname)) { | ||
| 29 | commands.add("files"); | ||
| 30 | commands.add("terminal"); | ||
| 31 | commands.add("review"); | ||
| 32 | } | ||
| 33 | return [...commands]; | ||
| 34 | }, [pathname, registrationVersion, navigation]); | ||
| 35 | |||
| 36 | const onCommand = useCallback( | ||
| 37 | (command: HardwareKeyboardCommand) => { | ||
| 38 | if (dispatchHardwareKeyboardCommand(command)) return; | ||
| 39 | |||
| 40 | if (command === "newTask") { | ||
| 41 | navigation.navigate("NewTaskSheet", { screen: "NewTask" }); | ||
| 42 | return; | ||
| 43 | } | ||
| 44 | if (command === "back") { | ||
| 45 | if (navigation.canGoBack()) { | ||
| 46 | navigation.goBack(); | ||
| 47 | } else { | ||
| 48 | navigation.dispatch(StackActions.replace("Home")); | ||
| 49 | } | ||
| 50 | return; | ||
| 51 | } | ||
| 52 | |||
| 53 | const thread = parseActiveThreadPath(pathname); | ||
| 54 | if (!thread) return; | ||
| 55 | if (command === "files" && !/\/files(?:\/|$)/.test(pathname)) { | ||
| 56 | navigation.navigate("ThreadFiles", thread); | ||
| 57 | } | ||
| 58 | if (command === "terminal" && !/\/terminal(?:\/|$)/.test(pathname)) { | ||
| 59 | navigation.navigate("ThreadTerminal", thread); | ||
| 60 | } | ||
| 61 | if (command === "review" && !/\/review(?:\/|$)/.test(pathname)) { | ||
| 62 | navigation.navigate("ThreadReview", thread); | ||
| 63 | } | ||
| 64 | }, | ||
| 65 | [pathname, navigation], | ||
| 66 | ); | ||
| 67 | |||
| 68 | return ( | ||
| 69 | <T3KeyboardCommands enabledCommands={enabledCommands} onCommand={onCommand}> | ||
| 70 | {children} | ||
| 71 | </T3KeyboardCommands> | ||
| 72 | ); | ||
| 73 | } | ||
apps/mobile/src/features/keyboard/hardwareKeyboardCommands.test.ts created+28| ... | @@ -0,0 +1,28 @@ | ||
| 1 | import { describe, expect, it } from "vite-plus/test"; | ||
| 2 | |||
| 3 | import { parseActiveThreadPath } from "./hardwareKeyboardCommands"; | ||
| 4 | |||
| 5 | describe("parseActiveThreadPath", () => { | ||
| 6 | it("extracts the active thread from thread subroutes", () => { | ||
| 7 | expect(parseActiveThreadPath("/threads/environment-1/thread-1/files/src/index.ts")).toEqual({ | ||
| 8 | environmentId: "environment-1", | ||
| 9 | threadId: "thread-1", | ||
| 10 | }); | ||
| 11 | }); | ||
| 12 | |||
| 13 | it("decodes route components", () => { | ||
| 14 | expect(parseActiveThreadPath("/threads/local%20machine/thread%2Fone/review")).toEqual({ | ||
| 15 | environmentId: "local machine", | ||
| 16 | threadId: "thread/one", | ||
| 17 | }); | ||
| 18 | }); | ||
| 19 | |||
| 20 | it("ignores non-thread routes", () => { | ||
| 21 | expect(parseActiveThreadPath("/settings")).toBeNull(); | ||
| 22 | expect(parseActiveThreadPath("/threads/environment-only")).toBeNull(); | ||
| 23 | }); | ||
| 24 | |||
| 25 | it("ignores malformed encoded route components", () => { | ||
| 26 | expect(parseActiveThreadPath("/threads/%E0%A4%A/thread-1")).toBeNull(); | ||
| 27 | }); | ||
| 28 | }); | ||
apps/mobile/src/features/keyboard/hardwareKeyboardCommands.ts created+78| ... | @@ -0,0 +1,78 @@ | ||
| 1 | import { EnvironmentId, ThreadId } from "@t3tools/contracts"; | ||
| 2 | import { useEffect } from "react"; | ||
| 3 | |||
| 4 | export type HardwareKeyboardCommand = | ||
| 5 | | "newTask" | ||
| 6 | | "focusSearch" | ||
| 7 | | "back" | ||
| 8 | | "files" | ||
| 9 | | "terminal" | ||
| 10 | | "review" | ||
| 11 | | "toggleSidebar"; | ||
| 12 | |||
| 13 | type CommandHandler = () => boolean | void; | ||
| 14 | |||
| 15 | const handlers = new Map<HardwareKeyboardCommand, Set<CommandHandler>>(); | ||
| 16 | const registrationListeners = new Set<() => void>(); | ||
| 17 | let registrationVersion = 0; | ||
| 18 | |||
| 19 | /** | ||
| 20 | * Registers a context-specific hardware-keyboard action. The most recently mounted handler gets | ||
| 21 | * the first chance to consume the command, allowing focused screens to override app defaults. | ||
| 22 | */ | ||
| 23 | export function useHardwareKeyboardCommand( | ||
| 24 | command: HardwareKeyboardCommand, | ||
| 25 | handler: CommandHandler, | ||
| 26 | ): void { | ||
| 27 | useEffect(() => { | ||
| 28 | const commandHandlers = handlers.get(command) ?? new Set<CommandHandler>(); | ||
| 29 | commandHandlers.add(handler); | ||
| 30 | handlers.set(command, commandHandlers); | ||
| 31 | registrationVersion += 1; | ||
| 32 | registrationListeners.forEach((listener) => listener()); | ||
| 33 | return () => { | ||
| 34 | commandHandlers.delete(handler); | ||
| 35 | if (commandHandlers.size === 0) handlers.delete(command); | ||
| 36 | registrationVersion += 1; | ||
| 37 | registrationListeners.forEach((listener) => listener()); | ||
| 38 | }; | ||
| 39 | }, [command, handler]); | ||
| 40 | } | ||
| 41 | |||
| 42 | export function getRegisteredHardwareKeyboardCommands(): ReadonlySet<HardwareKeyboardCommand> { | ||
| 43 | return new Set(handlers.keys()); | ||
| 44 | } | ||
| 45 | |||
| 46 | export function getHardwareKeyboardCommandRegistrationVersion(): number { | ||
| 47 | return registrationVersion; | ||
| 48 | } | ||
| 49 | |||
| 50 | export function subscribeToHardwareKeyboardCommandRegistrations(listener: () => void): () => void { | ||
| 51 | registrationListeners.add(listener); | ||
| 52 | return () => registrationListeners.delete(listener); | ||
| 53 | } | ||
| 54 | |||
| 55 | export function dispatchHardwareKeyboardCommand(command: HardwareKeyboardCommand): boolean { | ||
| 56 | const commandHandlers = handlers.get(command); | ||
| 57 | if (!commandHandlers) return false; | ||
| 58 | for (const handler of [...commandHandlers].toReversed()) { | ||
| 59 | if (handler() !== false) return true; | ||
| 60 | } | ||
| 61 | return false; | ||
| 62 | } | ||
| 63 | |||
| 64 | export function parseActiveThreadPath(pathname: string): { | ||
| 65 | readonly environmentId: EnvironmentId; | ||
| 66 | readonly threadId: ThreadId; | ||
| 67 | } | null { | ||
| 68 | const match = /^\/threads\/([^/]+)\/([^/]+)(?:\/|$)/.exec(pathname); | ||
| 69 | if (!match?.[1] || !match[2]) return null; | ||
| 70 | try { | ||
| 71 | return { | ||
| 72 | environmentId: EnvironmentId.make(decodeURIComponent(match[1])), | ||
| 73 | threadId: ThreadId.make(decodeURIComponent(match[2])), | ||
| 74 | }; | ||
| 75 | } catch { | ||
| 76 | return null; | ||
| 77 | } | ||
| 78 | } | ||
apps/mobile/src/features/layout/AdaptiveWorkspaceLayout.tsx created+507| ... | @@ -0,0 +1,507 @@ | ||
| 1 | import type { EnvironmentThreadShell } from "@t3tools/client-runtime/state/shell"; | ||
| 2 | import { EnvironmentId, ThreadId } from "@t3tools/contracts"; | ||
| 3 | import { useFocusEffect } from "@react-navigation/native"; | ||
| 4 | import { | ||
| 5 | NavigationContext, | ||
| 6 | NavigationRouteContext, | ||
| 7 | StackActions, | ||
| 8 | useNavigation, | ||
| 9 | } from "@react-navigation/native"; | ||
| 10 | import { | ||
| 11 | createContext, | ||
| 12 | use, | ||
| 13 | useCallback, | ||
| 14 | useEffect, | ||
| 15 | useMemo, | ||
| 16 | useRef, | ||
| 17 | useState, | ||
| 18 | type ReactNode, | ||
| 19 | } from "react"; | ||
| 20 | import { useWindowDimensions, View } from "react-native"; | ||
| 21 | import Animated, { useAnimatedStyle, useSharedValue, withTiming } from "react-native-reanimated"; | ||
| 22 | |||
| 23 | import { | ||
| 24 | deriveFileInspectorPaneLayout, | ||
| 25 | deriveLayout, | ||
| 26 | deriveWorkspacePaneLayout, | ||
| 27 | type FileInspectorPaneLayout, | ||
| 28 | type Layout, | ||
| 29 | type WorkspaceAuxiliaryPaneRole, | ||
| 30 | type WorkspacePaneLayout, | ||
| 31 | } from "../../lib/layout"; | ||
| 32 | import { resolveThreadSelectionNavigationAction } from "../../lib/adaptive-navigation"; | ||
| 33 | import { scopedThreadKey } from "../../lib/scopedEntities"; | ||
| 34 | import { | ||
| 35 | parseActiveThreadPath, | ||
| 36 | useHardwareKeyboardCommand, | ||
| 37 | } from "../keyboard/hardwareKeyboardCommands"; | ||
| 38 | import { HomeListOptionsProvider } from "../home/home-list-options"; | ||
| 39 | import { ThreadNavigationSidebar } from "../threads/ThreadNavigationSidebar"; | ||
| 40 | import { WORKSPACE_PANE_TIMING } from "./workspace-pane-animation"; | ||
| 41 | import { WorkspaceInspectorPane } from "./workspace-inspector-pane"; | ||
| 42 | |||
| 43 | interface AdaptiveWorkspaceContextValue { | ||
| 44 | readonly layout: Layout; | ||
| 45 | readonly panes: WorkspacePaneLayout; | ||
| 46 | readonly fileInspector: FileInspectorPaneLayout; | ||
| 47 | readonly primarySidebarSearchQuery: string; | ||
| 48 | readonly activateAuxiliaryPaneRole: (role: WorkspaceAuxiliaryPaneRole) => () => void; | ||
| 49 | /** | ||
| 50 | * Route screens hand their inspector pane content to the workspace so it | ||
| 51 | * renders BESIDE the navigator (outside the native stack header) instead of | ||
| 52 | * inside the route. Returns a deactivate callback: the pane animates closed | ||
| 53 | * (content kept mounted for the exit transition) unless a newer | ||
| 54 | * registration already took over — stale deactivates never clobber it. | ||
| 55 | * Prefer useRegisterWorkspaceInspector over calling this directly. | ||
| 56 | */ | ||
| 57 | readonly registerWorkspaceInspector: (render: () => ReactNode) => () => void; | ||
| 58 | readonly setPrimarySidebarSearchQuery: (query: string) => void; | ||
| 59 | readonly showAuxiliaryPane: (role: WorkspaceAuxiliaryPaneRole) => void; | ||
| 60 | readonly toggleAuxiliaryPane: () => void; | ||
| 61 | readonly togglePrimarySidebar: () => void; | ||
| 62 | readonly setAuxiliaryPaneWidth: (width: number) => void; | ||
| 63 | } | ||
| 64 | |||
| 65 | const compactLayout = deriveLayout({ width: 0, height: 0 }); | ||
| 66 | const compactPanes = deriveWorkspacePaneLayout({ | ||
| 67 | layout: compactLayout, | ||
| 68 | viewportWidth: 0, | ||
| 69 | primarySidebarPreferredVisible: true, | ||
| 70 | auxiliaryPanePreferredVisible: true, | ||
| 71 | }); | ||
| 72 | const compactFileInspector = deriveFileInspectorPaneLayout({ | ||
| 73 | layout: compactLayout, | ||
| 74 | viewportWidth: 0, | ||
| 75 | }); | ||
| 76 | const AdaptiveWorkspaceContext = createContext<AdaptiveWorkspaceContextValue>({ | ||
| 77 | layout: compactLayout, | ||
| 78 | panes: compactPanes, | ||
| 79 | fileInspector: compactFileInspector, | ||
| 80 | primarySidebarSearchQuery: "", | ||
| 81 | activateAuxiliaryPaneRole: () => () => undefined, | ||
| 82 | registerWorkspaceInspector: () => () => undefined, | ||
| 83 | setPrimarySidebarSearchQuery: () => undefined, | ||
| 84 | showAuxiliaryPane: () => undefined, | ||
| 85 | toggleAuxiliaryPane: () => undefined, | ||
| 86 | togglePrimarySidebar: () => undefined, | ||
| 87 | setAuxiliaryPaneWidth: () => undefined, | ||
| 88 | }); | ||
| 89 | |||
| 90 | export function useAdaptiveWorkspaceLayout(): AdaptiveWorkspaceContextValue { | ||
| 91 | return use(AdaptiveWorkspaceContext); | ||
| 92 | } | ||
| 93 | |||
| 94 | export function useAdaptiveWorkspacePaneRole(role: WorkspaceAuxiliaryPaneRole) { | ||
| 95 | const { activateAuxiliaryPaneRole } = useAdaptiveWorkspaceLayout(); | ||
| 96 | |||
| 97 | useFocusEffect( | ||
| 98 | useCallback(() => activateAuxiliaryPaneRole(role), [activateAuxiliaryPaneRole, role]), | ||
| 99 | ); | ||
| 100 | } | ||
| 101 | |||
| 102 | /** | ||
| 103 | * Register this screen's inspector pane content with the workspace column. | ||
| 104 | * | ||
| 105 | * The column renders BESIDE the navigator — outside any screen — so the | ||
| 106 | * registering screen's navigation and route contexts are captured here and | ||
| 107 | * re-provided around the portal content. Without them, useNavigation/useRoute | ||
| 108 | * inside the pane (e.g. GitOverviewSheet via useThreadSelection) throw | ||
| 109 | * "Couldn't find a route object". | ||
| 110 | * | ||
| 111 | * Registration is FOCUS-scoped, driven by navigation events rather than the | ||
| 112 | * screen's own render cycle: react-native-screens freezes blurred screens, so | ||
| 113 | * a cleanup that depends on the blurred subtree re-rendering never runs and | ||
| 114 | * would leak the pane into the next route. Blur deactivates the pane (it | ||
| 115 | * animates closed, or is replaced seamlessly when the next route registers in | ||
| 116 | * the same commit); focus re-registers it. | ||
| 117 | */ | ||
| 118 | export function useRegisterWorkspaceInspector(render: (() => ReactNode) | undefined) { | ||
| 119 | const { registerWorkspaceInspector } = useAdaptiveWorkspaceLayout(); | ||
| 120 | // Raw context values (not the useNavigation/useRoute wrappers) so the | ||
| 121 | // portal re-provides exactly what this screen sees. | ||
| 122 | const navigation = use(NavigationContext); | ||
| 123 | const route = use(NavigationRouteContext); | ||
| 124 | |||
| 125 | const wrappedRender = useMemo(() => { | ||
| 126 | if (render === undefined) { | ||
| 127 | return undefined; | ||
| 128 | } | ||
| 129 | return () => ( | ||
| 130 | <NavigationContext.Provider value={navigation}> | ||
| 131 | <NavigationRouteContext.Provider value={route}>{render()}</NavigationRouteContext.Provider> | ||
| 132 | </NavigationContext.Provider> | ||
| 133 | ); | ||
| 134 | }, [navigation, render, route]); | ||
| 135 | |||
| 136 | const wrappedRenderRef = useRef(wrappedRender); | ||
| 137 | wrappedRenderRef.current = wrappedRender; | ||
| 138 | const focusedRef = useRef(false); | ||
| 139 | const deactivateRef = useRef<(() => void) | null>(null); | ||
| 140 | |||
| 141 | const syncRegistration = useCallback(() => { | ||
| 142 | if (!focusedRef.current || wrappedRenderRef.current === undefined) { | ||
| 143 | deactivateRef.current?.(); | ||
| 144 | return; | ||
| 145 | } | ||
| 146 | deactivateRef.current = registerWorkspaceInspector(wrappedRenderRef.current); | ||
| 147 | }, [registerWorkspaceInspector]); | ||
| 148 | |||
| 149 | // Focus lifecycle. Blur/focus events fire even when the blurred subtree is | ||
| 150 | // frozen (events are navigation-driven, renders are not). | ||
| 151 | useFocusEffect( | ||
| 152 | useCallback(() => { | ||
| 153 | focusedRef.current = true; | ||
| 154 | syncRegistration(); | ||
| 155 | return () => { | ||
| 156 | focusedRef.current = false; | ||
| 157 | syncRegistration(); | ||
| 158 | }; | ||
| 159 | }, [syncRegistration]), | ||
| 160 | ); | ||
| 161 | |||
| 162 | // Content changes while focused re-register in place. | ||
| 163 | useEffect(() => { | ||
| 164 | if (focusedRef.current) { | ||
| 165 | syncRegistration(); | ||
| 166 | } | ||
| 167 | }, [syncRegistration, wrappedRender]); | ||
| 168 | |||
| 169 | // Unmount: hand the pane back (owner-guarded, so a route that already | ||
| 170 | // took over is unaffected). | ||
| 171 | useEffect( | ||
| 172 | () => () => { | ||
| 173 | deactivateRef.current?.(); | ||
| 174 | deactivateRef.current = null; | ||
| 175 | }, | ||
| 176 | [], | ||
| 177 | ); | ||
| 178 | } | ||
| 179 | |||
| 180 | export function AdaptiveWorkspaceLayout(props: { | ||
| 181 | readonly children: ReactNode; | ||
| 182 | readonly pathname: string; | ||
| 183 | }) { | ||
| 184 | const { width, height } = useWindowDimensions(); | ||
| 185 | const pathname = props.pathname; | ||
| 186 | const navigation = useNavigation(); | ||
| 187 | const activeRoleOwner = useRef<symbol | null>(null); | ||
| 188 | const [primarySidebarPreferredVisible, setPrimarySidebarPreferredVisible] = useState(true); | ||
| 189 | const [supplementaryPanePreferredVisible, setSupplementaryPanePreferredVisible] = useState(true); | ||
| 190 | const [supplementaryPanePreferredWidth, setSupplementaryPanePreferredWidth] = useState< | ||
| 191 | number | null | ||
| 192 | >(null); | ||
| 193 | const [fileInspectorPreferredVisible, setFileInspectorPreferredVisible] = useState(true); | ||
| 194 | const [fileInspectorPreferredWidth, setFileInspectorPreferredWidth] = useState<number | null>( | ||
| 195 | null, | ||
| 196 | ); | ||
| 197 | const [primarySidebarSearchQuery, setPrimarySidebarSearchQuery] = useState(""); | ||
| 198 | const [focusedAuxiliaryPaneRole, setFocusedAuxiliaryPaneRole] = | ||
| 199 | useState<WorkspaceAuxiliaryPaneRole | null>(null); | ||
| 200 | const baseLayout = useMemo(() => deriveLayout({ width, height }), [height, width]); | ||
| 201 | const layout = baseLayout; | ||
| 202 | // In split layouts the sidebar IS the thread list — it renders on every | ||
| 203 | // route, including Home (which shows an empty-detail pane instead of the | ||
| 204 | // compact list). | ||
| 205 | const shouldRenderPrimarySidebar = layout.usesSplitView; | ||
| 206 | const fileInspector = useMemo( | ||
| 207 | () => | ||
| 208 | deriveFileInspectorPaneLayout({ | ||
| 209 | layout, | ||
| 210 | viewportWidth: width, | ||
| 211 | preferredWidth: fileInspectorPreferredWidth ?? undefined, | ||
| 212 | reservedLeadingWidth: | ||
| 213 | shouldRenderPrimarySidebar && primarySidebarPreferredVisible | ||
| 214 | ? (layout.listPaneWidth ?? 0) | ||
| 215 | : 0, | ||
| 216 | }), | ||
| 217 | [ | ||
| 218 | fileInspectorPreferredWidth, | ||
| 219 | layout, | ||
| 220 | primarySidebarPreferredVisible, | ||
| 221 | shouldRenderPrimarySidebar, | ||
| 222 | width, | ||
| 223 | ], | ||
| 224 | ); | ||
| 225 | const auxiliaryPaneRole: WorkspaceAuxiliaryPaneRole = | ||
| 226 | focusedAuxiliaryPaneRole ?? (/\/files(?:\/|$)/.test(pathname) ? "inspector" : "supplementary"); | ||
| 227 | const auxiliaryPanePreferredVisible = | ||
| 228 | auxiliaryPaneRole === "inspector" | ||
| 229 | ? fileInspectorPreferredVisible | ||
| 230 | : supplementaryPanePreferredVisible; | ||
| 231 | const auxiliaryPanePreferredWidth = | ||
| 232 | auxiliaryPaneRole === "inspector" | ||
| 233 | ? fileInspectorPreferredWidth | ||
| 234 | : supplementaryPanePreferredWidth; | ||
| 235 | const panes = useMemo( | ||
| 236 | () => | ||
| 237 | deriveWorkspacePaneLayout({ | ||
| 238 | layout, | ||
| 239 | viewportWidth: width, | ||
| 240 | primarySidebarPreferredVisible, | ||
| 241 | auxiliaryPanePreferredVisible, | ||
| 242 | auxiliaryPaneRole, | ||
| 243 | auxiliaryPanePreferredWidth: auxiliaryPanePreferredWidth ?? undefined, | ||
| 244 | }), | ||
| 245 | [ | ||
| 246 | auxiliaryPanePreferredVisible, | ||
| 247 | auxiliaryPaneRole, | ||
| 248 | auxiliaryPanePreferredWidth, | ||
| 249 | layout, | ||
| 250 | primarySidebarPreferredVisible, | ||
| 251 | width, | ||
| 252 | ], | ||
| 253 | ); | ||
| 254 | const activeThread = parseActiveThreadPath(pathname); | ||
| 255 | const environmentId = activeThread?.environmentId ?? null; | ||
| 256 | const threadId = activeThread?.threadId ?? null; | ||
| 257 | const selectedThreadKey = useMemo(() => { | ||
| 258 | if (environmentId === null || threadId === null) { | ||
| 259 | return null; | ||
| 260 | } | ||
| 261 | try { | ||
| 262 | return scopedThreadKey(EnvironmentId.make(environmentId), ThreadId.make(threadId)); | ||
| 263 | } catch { | ||
| 264 | return null; | ||
| 265 | } | ||
| 266 | }, [environmentId, threadId]); | ||
| 267 | // Wrapped in an object: bare functions in useState would be treated as | ||
| 268 | // lazy initializers/updaters. `active: false` keeps the outgoing route's | ||
| 269 | // content mounted so the pane can animate closed (or be replaced | ||
| 270 | // seamlessly by the next route's registration in the same commit). | ||
| 271 | const [workspaceInspector, setWorkspaceInspector] = useState<{ | ||
| 272 | readonly render: () => ReactNode; | ||
| 273 | readonly active: boolean; | ||
| 274 | } | null>(null); | ||
| 275 | const workspaceInspectorOwner = useRef<symbol | null>(null); | ||
| 276 | const registerWorkspaceInspector = useCallback((render: () => ReactNode) => { | ||
| 277 | const owner = Symbol("workspace-inspector"); | ||
| 278 | workspaceInspectorOwner.current = owner; | ||
| 279 | setWorkspaceInspector({ render, active: true }); | ||
| 280 | |||
| 281 | return () => { | ||
| 282 | // During a push/replace the outgoing screen deactivates AFTER the | ||
| 283 | // incoming screen registered — only the current owner may deactivate. | ||
| 284 | if (workspaceInspectorOwner.current !== owner) { | ||
| 285 | return; | ||
| 286 | } | ||
| 287 | setWorkspaceInspector((current) => (current === null ? null : { ...current, active: false })); | ||
| 288 | }; | ||
| 289 | }, []); | ||
| 290 | // Once the close animation settles, drop the stale content entirely. | ||
| 291 | const handleWorkspaceInspectorClosed = useCallback(() => { | ||
| 292 | setWorkspaceInspector((current) => (current !== null && !current.active ? null : current)); | ||
| 293 | }, []); | ||
| 294 | const activateAuxiliaryPaneRole = useCallback((role: WorkspaceAuxiliaryPaneRole) => { | ||
| 295 | const owner = Symbol(role); | ||
| 296 | activeRoleOwner.current = owner; | ||
| 297 | setFocusedAuxiliaryPaneRole(role); | ||
| 298 | |||
| 299 | return () => { | ||
| 300 | if (activeRoleOwner.current !== owner) { | ||
| 301 | return; | ||
| 302 | } | ||
| 303 | activeRoleOwner.current = null; | ||
| 304 | setFocusedAuxiliaryPaneRole(null); | ||
| 305 | }; | ||
| 306 | }, []); | ||
| 307 | const togglePrimarySidebar = useCallback(() => { | ||
| 308 | if (!panes.primarySidebarVisible && panes.primarySidebarSuppressedByAuxiliary) { | ||
| 309 | setFileInspectorPreferredVisible(false); | ||
| 310 | setPrimarySidebarPreferredVisible(true); | ||
| 311 | return; | ||
| 312 | } | ||
| 313 | setPrimarySidebarPreferredVisible((current) => !current); | ||
| 314 | }, [panes.primarySidebarSuppressedByAuxiliary, panes.primarySidebarVisible]); | ||
| 315 | const revealPrimarySidebar = useCallback(() => { | ||
| 316 | if (panes.primarySidebarSuppressedByAuxiliary) { | ||
| 317 | setFileInspectorPreferredVisible(false); | ||
| 318 | } | ||
| 319 | setPrimarySidebarPreferredVisible(true); | ||
| 320 | }, [panes.primarySidebarSuppressedByAuxiliary]); | ||
| 321 | const handleToggleSidebarCommand = useCallback(() => { | ||
| 322 | togglePrimarySidebar(); | ||
| 323 | return true; | ||
| 324 | }, [togglePrimarySidebar]); | ||
| 325 | useHardwareKeyboardCommand("toggleSidebar", handleToggleSidebarCommand); | ||
| 326 | const showAuxiliaryPane = useCallback((role: WorkspaceAuxiliaryPaneRole) => { | ||
| 327 | if (role === "inspector") { | ||
| 328 | setFocusedAuxiliaryPaneRole("inspector"); | ||
| 329 | setFileInspectorPreferredVisible(true); | ||
| 330 | return; | ||
| 331 | } | ||
| 332 | setFocusedAuxiliaryPaneRole("supplementary"); | ||
| 333 | setSupplementaryPanePreferredVisible(true); | ||
| 334 | }, []); | ||
| 335 | const handleOpenFilesCommand = useCallback(() => { | ||
| 336 | const activeThread = parseActiveThreadPath(pathname); | ||
| 337 | if (!layout.usesSplitView || !fileInspector.supported || activeThread === null) { | ||
| 338 | return false; | ||
| 339 | } | ||
| 340 | showAuxiliaryPane("inspector"); | ||
| 341 | if (/\/files(?:\/|$)/.test(pathname)) { | ||
| 342 | return true; | ||
| 343 | } | ||
| 344 | navigation.navigate("ThreadFiles", activeThread); | ||
| 345 | return true; | ||
| 346 | }, [fileInspector.supported, layout.usesSplitView, pathname, navigation, showAuxiliaryPane]); | ||
| 347 | useHardwareKeyboardCommand("files", handleOpenFilesCommand); | ||
| 348 | const toggleAuxiliaryPane = useCallback(() => { | ||
| 349 | if (auxiliaryPaneRole === "inspector") { | ||
| 350 | setFileInspectorPreferredVisible((current) => !current); | ||
| 351 | return; | ||
| 352 | } | ||
| 353 | setSupplementaryPanePreferredVisible((current) => !current); | ||
| 354 | }, [auxiliaryPaneRole]); | ||
| 355 | const setAuxiliaryPaneWidth = useCallback( | ||
| 356 | (nextWidth: number) => { | ||
| 357 | if (auxiliaryPaneRole === "inspector") { | ||
| 358 | setFileInspectorPreferredWidth(nextWidth); | ||
| 359 | return; | ||
| 360 | } | ||
| 361 | setSupplementaryPanePreferredWidth(nextWidth); | ||
| 362 | }, | ||
| 363 | [auxiliaryPaneRole], | ||
| 364 | ); | ||
| 365 | const contextValue = useMemo( | ||
| 366 | () => ({ | ||
| 367 | layout, | ||
| 368 | panes, | ||
| 369 | fileInspector, | ||
| 370 | primarySidebarSearchQuery, | ||
| 371 | activateAuxiliaryPaneRole, | ||
| 372 | registerWorkspaceInspector, | ||
| 373 | setPrimarySidebarSearchQuery, | ||
| 374 | showAuxiliaryPane, | ||
| 375 | toggleAuxiliaryPane, | ||
| 376 | togglePrimarySidebar, | ||
| 377 | setAuxiliaryPaneWidth, | ||
| 378 | }), | ||
| 379 | [ | ||
| 380 | activateAuxiliaryPaneRole, | ||
| 381 | fileInspector, | ||
| 382 | layout, | ||
| 383 | panes, | ||
| 384 | primarySidebarSearchQuery, | ||
| 385 | registerWorkspaceInspector, | ||
| 386 | showAuxiliaryPane, | ||
| 387 | setPrimarySidebarSearchQuery, | ||
| 388 | setAuxiliaryPaneWidth, | ||
| 389 | toggleAuxiliaryPane, | ||
| 390 | togglePrimarySidebar, | ||
| 391 | ], | ||
| 392 | ); | ||
| 393 | |||
| 394 | const handleOpenSettings = useCallback(() => { | ||
| 395 | navigation.navigate("SettingsSheet", { screen: "Settings" }); | ||
| 396 | }, [navigation]); | ||
| 397 | |||
| 398 | // Minted here (root stack navigation) so the sidebar pane stays free of | ||
| 399 | // navigation hooks — on iOS it renders inside an independent nav tree. | ||
| 400 | const handleOpenEnvironmentSettings = useCallback(() => { | ||
| 401 | navigation.navigate("SettingsSheet", { screen: "SettingsEnvironments" }); | ||
| 402 | }, [navigation]); | ||
| 403 | |||
| 404 | const renderedSidebarWidth = useSharedValue( | ||
| 405 | panes.primarySidebarVisible ? (layout.listPaneWidth ?? 0) : 0, | ||
| 406 | ); | ||
| 407 | useEffect(() => { | ||
| 408 | const targetWidth = panes.primarySidebarVisible ? (layout.listPaneWidth ?? 0) : 0; | ||
| 409 | renderedSidebarWidth.value = withTiming(targetWidth, WORKSPACE_PANE_TIMING); | ||
| 410 | }, [layout.listPaneWidth, panes.primarySidebarVisible, renderedSidebarWidth]); | ||
| 411 | const sidebarAnimatedStyle = useAnimatedStyle(() => ({ | ||
| 412 | opacity: Math.min(1, renderedSidebarWidth.value / 80), | ||
| 413 | width: renderedSidebarWidth.value, | ||
| 414 | })); | ||
| 415 | |||
| 416 | // Freeze the content pane at its SETTLED width while the side panes | ||
| 417 | // animate. The navigator (native header + markdown feed) lays out ONCE per | ||
| 418 | // pane toggle instead of re-measuring on every animation frame — the | ||
| 419 | // animating columns merely clip/reveal it over a matching background. | ||
| 420 | // Continuously re-wrapping the chat feed was the main source of dropped | ||
| 421 | // frames during sidebar/inspector transitions. | ||
| 422 | const inspectorColumnTargetWidth = | ||
| 423 | workspaceInspector !== null && workspaceInspector.active && panes.auxiliaryPaneVisible | ||
| 424 | ? (panes.auxiliaryPaneWidth ?? 0) | ||
| 425 | : 0; | ||
| 426 | const contentSettledWidth = layout.usesSplitView | ||
| 427 | ? Math.max(0, panes.contentPaneWidth - inspectorColumnTargetWidth) | ||
| 428 | : null; | ||
| 429 | |||
| 430 | const handleSelectThread = useCallback( | ||
| 431 | (thread: EnvironmentThreadShell) => { | ||
| 432 | const params = { | ||
| 433 | environmentId: String(thread.environmentId), | ||
| 434 | threadId: String(thread.id), | ||
| 435 | }; | ||
| 436 | const navigationAction = resolveThreadSelectionNavigationAction({ | ||
| 437 | usesSplitView: layout.usesSplitView, | ||
| 438 | pathname, | ||
| 439 | }); | ||
| 440 | if (navigationAction === "set-params") { | ||
| 441 | const nextThreadKey = scopedThreadKey(thread.environmentId, thread.id); | ||
| 442 | if (nextThreadKey === selectedThreadKey) { | ||
| 443 | return; | ||
| 444 | } | ||
| 445 | setFileInspectorPreferredVisible(false); | ||
| 446 | navigation.navigate("Thread", params); | ||
| 447 | return; | ||
| 448 | } | ||
| 449 | if (navigationAction === "replace") { | ||
| 450 | setFileInspectorPreferredVisible(false); | ||
| 451 | navigation.dispatch(StackActions.replace("Thread", params)); | ||
| 452 | return; | ||
| 453 | } | ||
| 454 | navigation.navigate("Thread", params); | ||
| 455 | }, | ||
| 456 | [layout.usesSplitView, pathname, navigation, selectedThreadKey], | ||
| 457 | ); | ||
| 458 | |||
| 459 | return ( | ||
| 460 | <HomeListOptionsProvider> | ||
| 461 | <AdaptiveWorkspaceContext.Provider value={contextValue}> | ||
| 462 | <View testID="adaptive-workspace-layout" style={{ flex: 1, flexDirection: "row" }}> | ||
| 463 | {shouldRenderPrimarySidebar && layout.listPaneWidth !== null ? ( | ||
| 464 | <Animated.View | ||
| 465 | accessibilityElementsHidden={!panes.primarySidebarVisible} | ||
| 466 | collapsable={false} | ||
| 467 | importantForAccessibility={ | ||
| 468 | panes.primarySidebarVisible ? "auto" : "no-hide-descendants" | ||
| 469 | } | ||
| 470 | pointerEvents={panes.primarySidebarVisible ? "auto" : "none"} | ||
| 471 | style={[{ alignSelf: "stretch", overflow: "hidden" }, sidebarAnimatedStyle]} | ||
| 472 | > | ||
| 473 | <ThreadNavigationSidebar | ||
| 474 | width={layout.listPaneWidth} | ||
| 475 | visible={panes.primarySidebarVisible} | ||
| 476 | onRequestVisibility={revealPrimarySidebar} | ||
| 477 | selectedThreadKey={selectedThreadKey} | ||
| 478 | onOpenSettings={handleOpenSettings} | ||
| 479 | onOpenEnvironmentSettings={handleOpenEnvironmentSettings} | ||
| 480 | onSelectThread={handleSelectThread} | ||
| 481 | onSearchQueryChange={setPrimarySidebarSearchQuery} | ||
| 482 | searchQuery={primarySidebarSearchQuery} | ||
| 483 | /> | ||
| 484 | </Animated.View> | ||
| 485 | ) : null} | ||
| 486 | <View className="bg-screen" collapsable={false} style={{ flex: 1, overflow: "hidden" }}> | ||
| 487 | <View | ||
| 488 | collapsable={false} | ||
| 489 | style={ | ||
| 490 | contentSettledWidth !== null ? { flex: 1, width: contentSettledWidth } : { flex: 1 } | ||
| 491 | } | ||
| 492 | > | ||
| 493 | {props.children} | ||
| 494 | </View> | ||
| 495 | </View> | ||
| 496 | <WorkspaceInspectorPane | ||
| 497 | active={workspaceInspector?.active ?? false} | ||
| 498 | panes={panes} | ||
| 499 | renderInspector={workspaceInspector?.render} | ||
| 500 | setAuxiliaryPaneWidth={setAuxiliaryPaneWidth} | ||
| 501 | onClosed={handleWorkspaceInspectorClosed} | ||
| 502 | /> | ||
| 503 | </View> | ||
| 504 | </AdaptiveWorkspaceContext.Provider> | ||
| 505 | </HomeListOptionsProvider> | ||
| 506 | ); | ||
| 507 | } | ||
apps/mobile/src/features/layout/WorkspaceEmptyDetail.tsx created+30| ... | @@ -0,0 +1,30 @@ | ||
| 1 | import { SymbolView } from "expo-symbols"; | ||
| 2 | import { Pressable, View } from "react-native"; | ||
| 3 | |||
| 4 | import { AppText as Text } from "../../components/AppText"; | ||
| 5 | import { useThemeColor } from "../../lib/useThemeColor"; | ||
| 6 | |||
| 7 | export function WorkspaceEmptyDetail(props: { readonly onStartNewTask?: () => void }) { | ||
| 8 | const iconColor = useThemeColor("--color-icon-subtle"); | ||
| 9 | |||
| 10 | return ( | ||
| 11 | <View className="flex-1 items-center justify-center bg-screen px-10"> | ||
| 12 | <View className="max-w-[360px] items-center gap-3"> | ||
| 13 | <SymbolView name="sidebar.left" size={34} tintColor={iconColor} type="hierarchical" /> | ||
| 14 | <Text className="text-center text-xl font-t3-bold">Select a thread</Text> | ||
| 15 | <Text className="text-center text-base text-foreground-muted"> | ||
| 16 | Choose a thread from the sidebar or start a new task. | ||
| 17 | </Text> | ||
| 18 | {props.onStartNewTask ? ( | ||
| 19 | <Pressable | ||
| 20 | accessibilityRole="button" | ||
| 21 | className="mt-2 flex-row items-center gap-2 rounded-full bg-primary px-5 py-3 active:opacity-70" | ||
| 22 | onPress={props.onStartNewTask} | ||
| 23 | > | ||
| 24 | <Text className="text-base font-t3-bold text-primary-foreground">New Task</Text> | ||
| 25 | </Pressable> | ||
| 26 | ) : null} | ||
| 27 | </View> | ||
| 28 | </View> | ||
| 29 | ); | ||
| 30 | } | ||
apps/mobile/src/features/layout/native-glass-header-items.ts created+33| ... | @@ -0,0 +1,33 @@ | ||
| 1 | type NativeGlassHeaderItem = { | ||
| 2 | readonly type: "button" | "menu"; | ||
| 3 | readonly glassEffect?: boolean; | ||
| 4 | readonly hidesSharedBackground?: boolean; | ||
| 5 | readonly sharesBackground?: boolean; | ||
| 6 | readonly variant?: "plain" | "done" | "prominent"; | ||
| 7 | readonly width?: number; | ||
| 8 | }; | ||
| 9 | |||
| 10 | /** | ||
| 11 | * iOS 26/27 Mail-style header controls need the native glass button | ||
| 12 | * shared background configuration when they are not part of a larger toolbar. | ||
| 13 | * Do not enable `glassEffect` for normal bar-button items: react-native-screens | ||
| 14 | * renders that as a custom UIButton, which creates a second skinny capsule. | ||
| 15 | */ | ||
| 16 | export function withNativeGlassHeaderItem<T extends NativeGlassHeaderItem>( | ||
| 17 | item: T, | ||
| 18 | options: { | ||
| 19 | readonly hidesSharedBackground?: boolean; | ||
| 20 | readonly sharesBackground?: boolean; | ||
| 21 | readonly width?: number; | ||
| 22 | } = {}, | ||
| 23 | ): T { | ||
| 24 | const sharesBackground = options.sharesBackground ?? item.sharesBackground ?? true; | ||
| 25 | return { | ||
| 26 | ...item, | ||
| 27 | glassEffect: item.glassEffect ?? false, | ||
| 28 | hidesSharedBackground: options.hidesSharedBackground ?? item.hidesSharedBackground ?? false, | ||
| 29 | sharesBackground, | ||
| 30 | variant: item.variant ?? "plain", | ||
| 31 | width: options.width ?? item.width, | ||
| 32 | } as T; | ||
| 33 | } | ||
apps/mobile/src/features/layout/native-mail-search-toolbar.ts created+25| ... | @@ -0,0 +1,25 @@ | ||
| 1 | import type { HeaderBarButtonMailSearchToolbarItem } from "react-native-screens"; | ||
| 2 | |||
| 3 | type NativeMailSearchToolbarInput = Omit< | ||
| 4 | HeaderBarButtonMailSearchToolbarItem, | ||
| 5 | "type" | "useFallbackSearchField" | ||
| 6 | >; | ||
| 7 | |||
| 8 | /** | ||
| 9 | * Builds the patched react-native-screens Mail-style bottom search toolbar. | ||
| 10 | * | ||
| 11 | * Keeping this behind an app-level helper makes the iOS-only RNS patch an | ||
| 12 | * explicit layout primitive instead of a per-screen object literal. Android can | ||
| 13 | * keep using platform-specific header/search primitives without depending on | ||
| 14 | * this helper. | ||
| 15 | */ | ||
| 16 | export function createNativeMailSearchToolbarItem( | ||
| 17 | input: NativeMailSearchToolbarInput, | ||
| 18 | ): HeaderBarButtonMailSearchToolbarItem { | ||
| 19 | return { | ||
| 20 | placeholder: "Search", | ||
| 21 | ...input, | ||
| 22 | type: "mailSearchToolbar", | ||
| 23 | useFallbackSearchField: true, | ||
| 24 | }; | ||
| 25 | } | ||
apps/mobile/src/features/layout/workspace-inspector-pane.tsx created+145| ... | @@ -0,0 +1,145 @@ | ||
| 1 | import { useCallback, useEffect, useRef, useState, type ReactNode } from "react"; | ||
| 2 | import Animated, { | ||
| 3 | runOnJS, | ||
| 4 | useAnimatedStyle, | ||
| 5 | useSharedValue, | ||
| 6 | withTiming, | ||
| 7 | } from "react-native-reanimated"; | ||
| 8 | |||
| 9 | import { constrainAuxiliaryPaneWidth, type WorkspacePaneLayout } from "../../lib/layout"; | ||
| 10 | import { WORKSPACE_PANE_TIMING } from "./workspace-pane-animation"; | ||
| 11 | import { WorkspacePaneDivider } from "./workspace-pane-divider"; | ||
| 12 | |||
| 13 | /** | ||
| 14 | * The trailing inspector column: resize divider + animated reveal. | ||
| 15 | * | ||
| 16 | * Rendered by AdaptiveWorkspaceLayout as a SIBLING of the navigator so the | ||
| 17 | * native stack header (and its trailing toolbar items) spans only the content | ||
| 18 | * pane — the inspector owns its own full-height column, mirroring how each | ||
| 19 | * column of a UISplitViewController has its own chrome. | ||
| 20 | * | ||
| 21 | * Receives the pane layout via props (not the workspace context hook) so this | ||
| 22 | * module stays import-cycle-free with AdaptiveWorkspaceLayout. | ||
| 23 | */ | ||
| 24 | export function WorkspaceInspectorPane(props: { | ||
| 25 | /** | ||
| 26 | * When false the pane animates closed but keeps its content mounted for the | ||
| 27 | * exit transition (a route that lost focus). `onClosed` fires once the | ||
| 28 | * close animation settles so the owner can drop the stale content. | ||
| 29 | */ | ||
| 30 | readonly active?: boolean; | ||
| 31 | readonly onClosed?: () => void; | ||
| 32 | readonly panes: WorkspacePaneLayout; | ||
| 33 | readonly renderInspector?: () => ReactNode; | ||
| 34 | readonly setAuxiliaryPaneWidth: (width: number) => void; | ||
| 35 | }) { | ||
| 36 | const { panes, setAuxiliaryPaneWidth } = props; | ||
| 37 | const inspectorWidth = panes.auxiliaryPaneWidth; | ||
| 38 | const inspectorSupported = props.renderInspector !== undefined && inspectorWidth !== null; | ||
| 39 | const inspectorVisible = | ||
| 40 | inspectorSupported && panes.auxiliaryPaneVisible && (props.active ?? true); | ||
| 41 | const resizeStartWidth = useRef(0); | ||
| 42 | const [resizing, setResizing] = useState(false); | ||
| 43 | |||
| 44 | // A file-to-file replace remounts the route. Initialize an already-visible | ||
| 45 | // inspector at its final position so route replacement never replays an | ||
| 46 | // entering transition. Only visibility and explicit resizing change it. | ||
| 47 | const inspectorProgress = useSharedValue(inspectorVisible ? 1 : 0); | ||
| 48 | const renderedInspectorWidth = useSharedValue(inspectorVisible ? (inspectorWidth ?? 0) : 0); | ||
| 49 | // The content keeps its own width so the reveal (outer width) clips a | ||
| 50 | // fully-laid-out pane instead of reflowing text every frame. When the OPEN | ||
| 51 | // pane's target width changes (e.g. the sidebar toggles and reserves | ||
| 52 | // space), animate the content width in lockstep rather than snapping. | ||
| 53 | const renderedContentWidth = useSharedValue(inspectorWidth ?? 0); | ||
| 54 | |||
| 55 | const onClosed = props.onClosed; | ||
| 56 | useEffect(() => { | ||
| 57 | inspectorProgress.value = withTiming( | ||
| 58 | inspectorVisible ? 1 : 0, | ||
| 59 | WORKSPACE_PANE_TIMING, | ||
| 60 | (finished) => { | ||
| 61 | if (finished === true && !inspectorVisible && onClosed !== undefined) { | ||
| 62 | runOnJS(onClosed)(); | ||
| 63 | } | ||
| 64 | }, | ||
| 65 | ); | ||
| 66 | const targetWidth = inspectorVisible ? (inspectorWidth ?? 0) : 0; | ||
| 67 | renderedInspectorWidth.value = resizing | ||
| 68 | ? targetWidth | ||
| 69 | : withTiming(targetWidth, WORKSPACE_PANE_TIMING); | ||
| 70 | }, [ | ||
| 71 | inspectorProgress, | ||
| 72 | inspectorVisible, | ||
| 73 | inspectorWidth, | ||
| 74 | onClosed, | ||
| 75 | renderedInspectorWidth, | ||
| 76 | resizing, | ||
| 77 | ]); | ||
| 78 | |||
| 79 | useEffect(() => { | ||
| 80 | const targetWidth = inspectorWidth ?? 0; | ||
| 81 | if (!inspectorVisible || resizing) { | ||
| 82 | // Hidden panes re-measure silently; during a divider drag the content | ||
| 83 | // tracks the finger directly. | ||
| 84 | renderedContentWidth.value = targetWidth; | ||
| 85 | return; | ||
| 86 | } | ||
| 87 | renderedContentWidth.value = withTiming(targetWidth, WORKSPACE_PANE_TIMING); | ||
| 88 | }, [inspectorVisible, inspectorWidth, renderedContentWidth, resizing]); | ||
| 89 | |||
| 90 | const inspectorStyle = useAnimatedStyle( | ||
| 91 | () => ({ | ||
| 92 | opacity: inspectorProgress.value, | ||
| 93 | transform: [{ translateX: (1 - inspectorProgress.value) * 24 }], | ||
| 94 | width: renderedInspectorWidth.value, | ||
| 95 | }), | ||
| 96 | [], | ||
| 97 | ); | ||
| 98 | const inspectorContentStyle = useAnimatedStyle(() => ({ width: renderedContentWidth.value }), []); | ||
| 99 | const beginResize = useCallback(() => { | ||
| 100 | resizeStartWidth.current = inspectorWidth ?? 0; | ||
| 101 | setResizing(true); | ||
| 102 | }, [inspectorWidth]); | ||
| 103 | const resizeBy = useCallback( | ||
| 104 | (delta: number) => { | ||
| 105 | setAuxiliaryPaneWidth( | ||
| 106 | constrainAuxiliaryPaneWidth({ | ||
| 107 | preferredWidth: resizeStartWidth.current + delta, | ||
| 108 | availableWidth: panes.contentPaneWidth, | ||
| 109 | }), | ||
| 110 | ); | ||
| 111 | }, | ||
| 112 | [panes.contentPaneWidth, setAuxiliaryPaneWidth], | ||
| 113 | ); | ||
| 114 | const endResize = useCallback(() => { | ||
| 115 | setResizing(false); | ||
| 116 | }, []); | ||
| 117 | |||
| 118 | return ( | ||
| 119 | <> | ||
| 120 | {inspectorVisible ? ( | ||
| 121 | <WorkspacePaneDivider | ||
| 122 | accessibilityLabel="Resize detail pane" | ||
| 123 | currentWidth={inspectorWidth ?? 0} | ||
| 124 | resizeDirection={-1} | ||
| 125 | onResizeStart={beginResize} | ||
| 126 | onResizeBy={resizeBy} | ||
| 127 | onResizeEnd={endResize} | ||
| 128 | /> | ||
| 129 | ) : null} | ||
| 130 | {inspectorSupported ? ( | ||
| 131 | <Animated.View | ||
| 132 | accessibilityElementsHidden={!inspectorVisible} | ||
| 133 | collapsable={false} | ||
| 134 | importantForAccessibility={inspectorVisible ? "auto" : "no-hide-descendants"} | ||
| 135 | pointerEvents={inspectorVisible ? "auto" : "none"} | ||
| 136 | style={[{ flexShrink: 0, overflow: "hidden" }, inspectorStyle]} | ||
| 137 | > | ||
| 138 | <Animated.View style={[{ flex: 1 }, inspectorContentStyle]}> | ||
| 139 | {props.renderInspector?.()} | ||
| 140 | </Animated.View> | ||
| 141 | </Animated.View> | ||
| 142 | ) : null} | ||
| 143 | </> | ||
| 144 | ); | ||
| 145 | } | ||
apps/mobile/src/features/layout/workspace-pane-animation.ts created+16| ... | @@ -0,0 +1,16 @@ | ||
| 1 | import { Easing, ReduceMotion } from "react-native-reanimated"; | ||
| 2 | |||
| 3 | /** | ||
| 4 | * One timing curve for every workspace pane (primary sidebar + inspector). | ||
| 5 | * | ||
| 6 | * Panes frequently animate together — opening the sidebar can auto-close the | ||
| 7 | * inspector when both no longer fit — so identical duration and easing on | ||
| 8 | * every pane keeps the center content pane from wobbling while both edges | ||
| 9 | * move. Asymmetric open/close timings (the previous 220ms out-cubic open vs | ||
| 10 | * 160ms in-cubic close) read as jank during those simultaneous swaps. | ||
| 11 | */ | ||
| 12 | export const WORKSPACE_PANE_TIMING = { | ||
| 13 | duration: 260, | ||
| 14 | easing: Easing.inOut(Easing.cubic), | ||
| 15 | reduceMotion: ReduceMotion.System, | ||
| 16 | } as const; | ||
apps/mobile/src/features/layout/workspace-pane-divider.tsx created+116| ... | @@ -0,0 +1,116 @@ | ||
| 1 | import { useCallback, useMemo, useRef, useState } from "react"; | ||
| 2 | import { | ||
| 3 | Platform, | ||
| 4 | PlatformColor, | ||
| 5 | Pressable, | ||
| 6 | StyleSheet, | ||
| 7 | View, | ||
| 8 | type AccessibilityActionEvent, | ||
| 9 | } from "react-native"; | ||
| 10 | import { Gesture, GestureDetector } from "react-native-gesture-handler"; | ||
| 11 | import { runOnJS } from "react-native-reanimated"; | ||
| 12 | |||
| 13 | const ACCESSIBILITY_RESIZE_STEP = 24; | ||
| 14 | |||
| 15 | interface WorkspacePaneDividerProps { | ||
| 16 | readonly accessibilityLabel: string; | ||
| 17 | readonly currentWidth: number; | ||
| 18 | /** 1 when dragging right grows the pane, -1 when dragging left grows it. */ | ||
| 19 | readonly resizeDirection: 1 | -1; | ||
| 20 | readonly onResizeStart?: () => void; | ||
| 21 | readonly onResizeBy: (delta: number) => void; | ||
| 22 | readonly onResizeEnd?: () => void; | ||
| 23 | } | ||
| 24 | |||
| 25 | /** A forgiving divider target for touch, pointer, and VoiceOver users. */ | ||
| 26 | export function WorkspacePaneDivider(props: WorkspacePaneDividerProps) { | ||
| 27 | const latestProps = useRef(props); | ||
| 28 | latestProps.current = props; | ||
| 29 | const [hovered, setHovered] = useState(false); | ||
| 30 | const [dragging, setDragging] = useState(false); | ||
| 31 | const handleResizeStart = useCallback(() => { | ||
| 32 | setDragging(true); | ||
| 33 | latestProps.current.onResizeStart?.(); | ||
| 34 | }, []); | ||
| 35 | const handleResize = useCallback((translationX: number) => { | ||
| 36 | latestProps.current.onResizeBy(translationX * latestProps.current.resizeDirection); | ||
| 37 | }, []); | ||
| 38 | const handleResizeEnd = useCallback(() => { | ||
| 39 | setDragging(false); | ||
| 40 | latestProps.current.onResizeEnd?.(); | ||
| 41 | }, []); | ||
| 42 | const resizeGesture = useMemo( | ||
| 43 | () => | ||
| 44 | Gesture.Pan() | ||
| 45 | .activeOffsetX([-4, 4]) | ||
| 46 | .failOffsetY([-24, 24]) | ||
| 47 | .onStart(() => { | ||
| 48 | runOnJS(handleResizeStart)(); | ||
| 49 | }) | ||
| 50 | .onUpdate((event) => { | ||
| 51 | runOnJS(handleResize)(event.translationX); | ||
| 52 | }) | ||
| 53 | .onFinalize(() => { | ||
| 54 | runOnJS(handleResizeEnd)(); | ||
| 55 | }), | ||
| 56 | [handleResize, handleResizeEnd, handleResizeStart], | ||
| 57 | ); | ||
| 58 | |||
| 59 | const handleAccessibilityAction = (event: AccessibilityActionEvent) => { | ||
| 60 | props.onResizeStart?.(); | ||
| 61 | if (event.nativeEvent.actionName === "increment") { | ||
| 62 | props.onResizeBy(ACCESSIBILITY_RESIZE_STEP); | ||
| 63 | } else if (event.nativeEvent.actionName === "decrement") { | ||
| 64 | props.onResizeBy(-ACCESSIBILITY_RESIZE_STEP); | ||
| 65 | } | ||
| 66 | props.onResizeEnd?.(); | ||
| 67 | }; | ||
| 68 | |||
| 69 | return ( | ||
| 70 | <GestureDetector gesture={resizeGesture}> | ||
| 71 | <Pressable | ||
| 72 | accessibilityActions={[ | ||
| 73 | { name: "increment", label: "Make pane wider" }, | ||
| 74 | { name: "decrement", label: "Make pane narrower" }, | ||
| 75 | ]} | ||
| 76 | accessibilityLabel={props.accessibilityLabel} | ||
| 77 | accessibilityRole="adjustable" | ||
| 78 | accessibilityValue={{ | ||
| 79 | now: Math.round(props.currentWidth), | ||
| 80 | text: `${Math.round(props.currentWidth)} points wide`, | ||
| 81 | }} | ||
| 82 | onAccessibilityAction={handleAccessibilityAction} | ||
| 83 | onHoverIn={() => setHovered(true)} | ||
| 84 | onHoverOut={() => setHovered(false)} | ||
| 85 | style={styles.hitTarget} | ||
| 86 | > | ||
| 87 | <View style={[styles.line, (hovered || dragging) && styles.activeLine]} /> | ||
| 88 | </Pressable> | ||
| 89 | </GestureDetector> | ||
| 90 | ); | ||
| 91 | } | ||
| 92 | |||
| 93 | const styles = StyleSheet.create({ | ||
| 94 | hitTarget: { | ||
| 95 | alignSelf: "stretch", | ||
| 96 | cursor: "pointer", | ||
| 97 | justifyContent: "center", | ||
| 98 | marginHorizontal: -22, | ||
| 99 | position: "relative", | ||
| 100 | width: 44, | ||
| 101 | zIndex: 100, | ||
| 102 | }, | ||
| 103 | line: { | ||
| 104 | alignSelf: "center", | ||
| 105 | backgroundColor: | ||
| 106 | Platform.OS === "ios" ? PlatformColor("separator") : "rgba(120, 120, 128, 0.28)", | ||
| 107 | height: "100%", | ||
| 108 | opacity: 0.7, | ||
| 109 | width: StyleSheet.hairlineWidth, | ||
| 110 | }, | ||
| 111 | activeLine: { | ||
| 112 | backgroundColor: Platform.OS === "ios" ? PlatformColor("systemBlueColor") : "#0a84ff", | ||
| 113 | opacity: 1, | ||
| 114 | width: 2, | ||
| 115 | }, | ||
| 116 | }); | ||
apps/mobile/src/features/layout/workspace-sidebar-toolbar.tsx created+31| ... | @@ -0,0 +1,31 @@ | ||
| 1 | import { NativeHeaderToolbar } from "../../native/StackHeader"; | ||
| 2 | import type { ReactNode } from "react"; | ||
| 3 | |||
| 4 | import { useAdaptiveWorkspaceLayout } from "./AdaptiveWorkspaceLayout"; | ||
| 5 | |||
| 6 | export function WorkspaceSidebarToolbar( | ||
| 7 | props: { | ||
| 8 | readonly children?: ReactNode; | ||
| 9 | readonly afterSidebarButton?: ReactNode; | ||
| 10 | } = {}, | ||
| 11 | ) { | ||
| 12 | const { layout, panes, togglePrimarySidebar } = useAdaptiveWorkspaceLayout(); | ||
| 13 | |||
| 14 | if (!layout.usesSplitView) { | ||
| 15 | return null; | ||
| 16 | } | ||
| 17 | |||
| 18 | return ( | ||
| 19 | <NativeHeaderToolbar placement="left"> | ||
| 20 | {props.children} | ||
| 21 | <NativeHeaderToolbar.Button | ||
| 22 | accessibilityLabel={ | ||
| 23 | panes.primarySidebarVisible ? "Maximize content" : "Show thread sidebar" | ||
| 24 | } | ||
| 25 | icon={panes.primarySidebarVisible ? "arrow.up.left.and.arrow.down.right" : "sidebar.left"} | ||
| 26 | onPress={togglePrimarySidebar} | ||
| 27 | /> | ||
| 28 | {props.afterSidebarButton} | ||
| 29 | </NativeHeaderToolbar> | ||
| 30 | ); | ||
| 31 | } | ||
apps/mobile/src/features/projects/AddProjectDestinationRoute.tsx created+15| ... | @@ -0,0 +1,15 @@ | ||
| 1 | import type { StaticScreenProps } from "@react-navigation/native"; | ||
| 2 | import { AddProjectDestinationScreen } from "./AddProjectScreen"; | ||
| 3 | |||
| 4 | type AddProjectDestinationRouteParams = { | ||
| 5 | readonly environmentId?: string | string[]; | ||
| 6 | readonly source?: string | string[]; | ||
| 7 | readonly remoteUrl?: string | string[]; | ||
| 8 | readonly repositoryTitle?: string | string[]; | ||
| 9 | }; | ||
| 10 | |||
| 11 | export function AddProjectDestinationRoute({ | ||
| 12 | route, | ||
| 13 | }: StaticScreenProps<AddProjectDestinationRouteParams | undefined>) { | ||
| 14 | return <AddProjectDestinationScreen {...(route.params ?? {})} />; | ||
| 15 | } | ||
apps/mobile/src/features/projects/AddProjectLocalRoute.tsx created+12| ... | @@ -0,0 +1,12 @@ | ||
| 1 | import type { StaticScreenProps } from "@react-navigation/native"; | ||
| 2 | import { AddProjectLocalFolderScreen } from "./AddProjectScreen"; | ||
| 3 | |||
| 4 | type AddProjectLocalRouteParams = { | ||
| 5 | readonly environmentId?: string | string[]; | ||
| 6 | }; | ||
| 7 | |||
| 8 | export function AddProjectLocalRoute({ | ||
| 9 | route, | ||
| 10 | }: StaticScreenProps<AddProjectLocalRouteParams | undefined>) { | ||
| 11 | return <AddProjectLocalFolderScreen {...(route.params ?? {})} />; | ||
| 12 | } | ||
apps/mobile/src/features/projects/AddProjectRepositoryRoute.tsx created+31| ... | @@ -0,0 +1,31 @@ | ||
| 1 | import type { StaticScreenProps } from "@react-navigation/native"; | ||
| 2 | import { NativeStackScreenOptions } from "../../native/StackHeader"; | ||
| 3 | import { addProjectRemoteSourceLabel } from "@t3tools/client-runtime/operations/projects"; | ||
| 4 | |||
| 5 | import { AddProjectRepositoryScreen } from "./AddProjectScreen"; | ||
| 6 | |||
| 7 | type AddProjectRepositoryRouteParams = { | ||
| 8 | readonly environmentId?: string | string[]; | ||
| 9 | readonly source?: string | string[]; | ||
| 10 | }; | ||
| 11 | |||
| 12 | export function AddProjectRepositoryRoute({ | ||
| 13 | route, | ||
| 14 | }: StaticScreenProps<AddProjectRepositoryRouteParams>) { | ||
| 15 | const params = route.params ?? {}; | ||
| 16 | const source = Array.isArray(params.source) ? params.source[0] : params.source; | ||
| 17 | const title = | ||
| 18 | source === "github" || | ||
| 19 | source === "gitlab" || | ||
| 20 | source === "bitbucket" || | ||
| 21 | source === "azure-devops" | ||
| 22 | ? addProjectRemoteSourceLabel(source) | ||
| 23 | : "Git URL"; | ||
| 24 | |||
| 25 | return ( | ||
| 26 | <> | ||
| 27 | <NativeStackScreenOptions options={{ title }} /> | ||
| 28 | <AddProjectRepositoryScreen {...params} /> | ||
| 29 | </> | ||
| 30 | ); | ||
| 31 | } | ||
apps/mobile/src/features/projects/AddProjectScreen.tsx+56-58| ... | @@ -22,7 +22,7 @@ import { | ... | @@ -22,7 +22,7 @@ import { |
| 22 | isFilesystemBrowseQuery, | 22 | isFilesystemBrowseQuery, |
| 23 | } from "@t3tools/client-runtime/state/projects"; | 23 | } from "@t3tools/client-runtime/state/projects"; |
| 24 | import { CommandId, type EnvironmentId, ProjectId } from "@t3tools/contracts"; | 24 | import { CommandId, type EnvironmentId, ProjectId } from "@t3tools/contracts"; |
| 25 | import { useLocalSearchParams, useRouter } from "expo-router"; | 25 | import { StackActions, useNavigation } from "@react-navigation/native"; |
| 26 | import { SymbolView } from "expo-symbols"; | 26 | import { SymbolView } from "expo-symbols"; |
| 27 | import { useCallback, useEffect, useMemo, useState, type ReactNode } from "react"; | 27 | import { useCallback, useEffect, useMemo, useState, type ReactNode } from "react"; |
| 28 | import { ActivityIndicator, Alert, Pressable, ScrollView, View } from "react-native"; | 28 | import { ActivityIndicator, Alert, Pressable, ScrollView, View } from "react-native"; |
| ... | @@ -168,9 +168,9 @@ function ListRow(props: { | ... | @@ -168,9 +168,9 @@ function ListRow(props: { |
| 168 | {props.icon} | 168 | {props.icon} |
| 169 | </View> | 169 | </View> |
| 170 | <View className="flex-1 gap-0.5"> | 170 | <View className="flex-1 gap-0.5"> |
| 171 | <Text className="text-base leading-[21px] font-t3-bold">{props.title}</Text> | 171 | <Text className="text-base leading-snug font-t3-bold">{props.title}</Text> |
| 172 | {props.subtitle ? ( | 172 | {props.subtitle ? ( |
| 173 | <Text className="text-sm leading-[17px] text-foreground-muted" numberOfLines={2}> | 173 | <Text className="text-sm leading-snug text-foreground-muted" numberOfLines={2}> |
| 174 | {props.subtitle} | 174 | {props.subtitle} |
| 175 | </Text> | 175 | </Text> |
| 176 | ) : null} | 176 | ) : null} |
| ... | @@ -215,7 +215,7 @@ function ProjectPathInput(props: { | ... | @@ -215,7 +215,7 @@ function ProjectPathInput(props: { |
| 215 | }) { | 215 | }) { |
| 216 | return ( | 216 | return ( |
| 217 | <TextInput | 217 | <TextInput |
| 218 | className="h-12 min-h-12 rounded-[24px] px-4 py-0 text-base leading-[20px]" | 218 | className="h-12 min-h-12 rounded-[24px] px-4 py-0 text-base leading-snug" |
| 219 | value={props.value} | 219 | value={props.value} |
| 220 | onChangeText={props.onChangeText} | 220 | onChangeText={props.onChangeText} |
| 221 | autoCapitalize="none" | 221 | autoCapitalize="none" |
| ... | @@ -250,35 +250,31 @@ function useSelectedEnvironment(): { | ... | @@ -250,35 +250,31 @@ function useSelectedEnvironment(): { |
| 250 | readonly selectedEnvironment: EnvironmentOption | null; | 250 | readonly selectedEnvironment: EnvironmentOption | null; |
| 251 | readonly setSelectedEnvironmentId: (environmentId: EnvironmentId) => void; | 251 | readonly setSelectedEnvironmentId: (environmentId: EnvironmentId) => void; |
| 252 | } { | 252 | } { |
| 253 | const router = useRouter(); | 253 | const [selectedEnvironmentId, setSelectedEnvironmentId] = useState<EnvironmentId | null>(null); |
| 254 | const params = useLocalSearchParams<{ environmentId?: string }>(); | ||
| 255 | const environmentOptions = useEnvironmentOptions(); | 254 | const environmentOptions = useEnvironmentOptions(); |
| 256 | const requestedEnvironmentId = stringParam(params.environmentId) as EnvironmentId | null; | ||
| 257 | const selectedEnvironment = | 255 | const selectedEnvironment = |
| 258 | environmentOptions.find( | 256 | environmentOptions.find((environment) => environment.environmentId === selectedEnvironmentId) ?? |
| 259 | (environment) => environment.environmentId === requestedEnvironmentId, | ||
| 260 | ) ?? | ||
| 261 | environmentOptions[0] ?? | 257 | environmentOptions[0] ?? |
| 262 | null; | 258 | null; |
| 263 | 259 | ||
| 264 | return { | 260 | return { |
| 265 | environmentOptions, | 261 | environmentOptions, |
| 266 | selectedEnvironment, | 262 | selectedEnvironment, |
| 267 | setSelectedEnvironmentId: (environmentId) => router.setParams({ environmentId }), | 263 | setSelectedEnvironmentId, |
| 268 | }; | 264 | }; |
| 269 | } | 265 | } |
| 270 | 266 | ||
| 271 | function EmptyEnvironmentState() { | 267 | function EmptyEnvironmentState() { |
| 272 | const router = useRouter(); | 268 | const navigation = useNavigation(); |
| 273 | 269 | ||
| 274 | return ( | 270 | return ( |
| 275 | <View className="items-center gap-3 rounded-2xl bg-card px-5 py-8"> | 271 | <View className="items-center gap-3 rounded-2xl bg-card px-5 py-8"> |
| 276 | <Text className="text-center text-lg font-t3-bold">No environments connected</Text> | 272 | <Text className="text-center text-lg font-t3-bold">No environments connected</Text> |
| 277 | <Text className="text-center text-sm leading-[20px] text-foreground-muted"> | 273 | <Text className="text-center text-sm leading-normal text-foreground-muted"> |
| 278 | Add an environment before adding a project. | 274 | Add an environment before adding a project. |
| 279 | </Text> | 275 | </Text> |
| 280 | <Pressable | 276 | <Pressable |
| 281 | onPress={() => router.replace("/connections/new")} | 277 | onPress={() => navigation.dispatch(StackActions.replace("ConnectionsNew"))} |
| 282 | className="mt-1 rounded-full bg-primary px-4 py-2.5 active:opacity-70" | 278 | className="mt-1 rounded-full bg-primary px-4 py-2.5 active:opacity-70" |
| 283 | > | 279 | > |
| 284 | <Text className="text-sm font-t3-bold text-primary-foreground">Add environment</Text> | 280 | <Text className="text-sm font-t3-bold text-primary-foreground">Add environment</Text> |
| ... | @@ -294,7 +290,7 @@ function SourceControlRow(props: { | ... | @@ -294,7 +290,7 @@ function SourceControlRow(props: { |
| 294 | readonly hint: string; | 290 | readonly hint: string; |
| 295 | readonly isFirst: boolean; | 291 | readonly isFirst: boolean; |
| 296 | }) { | 292 | }) { |
| 297 | const router = useRouter(); | 293 | const navigation = useNavigation(); |
| 298 | const iconColor = useThemeColor("--color-icon"); | 294 | const iconColor = useThemeColor("--color-icon"); |
| 299 | const title = | 295 | const title = |
| 300 | props.source === "url" ? "Git URL" : `${addProjectRemoteSourceLabel(props.source)} repository`; | 296 | props.source === "url" ? "Git URL" : `${addProjectRemoteSourceLabel(props.source)} repository`; |
| ... | @@ -322,8 +318,8 @@ function SourceControlRow(props: { | ... | @@ -322,8 +318,8 @@ function SourceControlRow(props: { |
| 322 | icon={icon} | 318 | icon={icon} |
| 323 | isFirst={props.isFirst} | 319 | isFirst={props.isFirst} |
| 324 | onPress={() => | 320 | onPress={() => |
| 325 | router.push({ | 321 | navigation.navigate("NewTaskSheet", { |
| 326 | pathname: "/new/add-project/repository", | 322 | screen: "AddProjectRepository", |
| 327 | params: { | 323 | params: { |
| 328 | environmentId: props.selectedEnvironmentId, | 324 | environmentId: props.selectedEnvironmentId, |
| 329 | source: props.source, | 325 | source: props.source, |
| ... | @@ -335,7 +331,7 @@ function SourceControlRow(props: { | ... | @@ -335,7 +331,7 @@ function SourceControlRow(props: { |
| 335 | } | 331 | } |
| 336 | 332 | ||
| 337 | export function AddProjectSourceScreen() { | 333 | export function AddProjectSourceScreen() { |
| 338 | const router = useRouter(); | 334 | const navigation = useNavigation(); |
| 339 | const accentColor = useThemeColor("--color-icon-muted"); | 335 | const accentColor = useThemeColor("--color-icon-muted"); |
| 340 | const iconColor = useThemeColor("--color-icon"); | 336 | const iconColor = useThemeColor("--color-icon"); |
| 341 | const { environmentOptions, selectedEnvironment, setSelectedEnvironmentId } = | 337 | const { environmentOptions, selectedEnvironment, setSelectedEnvironmentId } = |
| ... | @@ -409,9 +405,11 @@ export function AddProjectSourceScreen() { | ... | @@ -409,9 +405,11 @@ export function AddProjectSourceScreen() { |
| 409 | } | 405 | } |
| 410 | isFirst | 406 | isFirst |
| 411 | onPress={() => | 407 | onPress={() => |
| 412 | router.push({ | 408 | navigation.navigate("NewTaskSheet", { |
| 413 | pathname: "/new/add-project/local", | 409 | screen: "AddProjectLocal", |
| 414 | params: { environmentId: selectedEnvironment.environmentId }, | 410 | params: { |
| 411 | environmentId: selectedEnvironment.environmentId, | ||
| 412 | }, | ||
| 415 | }) | 413 | }) |
| 416 | } | 414 | } |
| 417 | /> | 415 | /> |
| ... | @@ -440,7 +438,7 @@ export function AddProjectSourceScreen() { | ... | @@ -440,7 +438,7 @@ export function AddProjectSourceScreen() { |
| 440 | } | 438 | } |
| 441 | 439 | ||
| 442 | function useCreateProject(environment: EnvironmentOption | null) { | 440 | function useCreateProject(environment: EnvironmentOption | null) { |
| 443 | const router = useRouter(); | 441 | const navigation = useNavigation(); |
| 444 | const createProject = useAtomCommand(projectEnvironment.create, { reportFailure: false }); | 442 | const createProject = useAtomCommand(projectEnvironment.create, { reportFailure: false }); |
| 445 | const projects = useProjects(); | 443 | const projects = useProjects(); |
| 446 | 444 | ||
| ... | @@ -455,14 +453,13 @@ function useCreateProject(environment: EnvironmentOption | null) { | ... | @@ -455,14 +453,13 @@ function useCreateProject(environment: EnvironmentOption | null) { |
| 455 | }); | 453 | }); |
| 456 | if (existing) { | 454 | if (existing) { |
| 457 | Alert.alert("Project already exists", existing.title); | 455 | Alert.alert("Project already exists", existing.title); |
| 458 | router.replace({ | 456 | navigation.dispatch( |
| 459 | pathname: "/new/draft", | 457 | StackActions.replace("NewTaskDraft", { |
| 460 | params: { | ||
| 461 | environmentId: existing.environmentId, | 458 | environmentId: existing.environmentId, |
| 462 | projectId: existing.id, | 459 | projectId: existing.id, |
| 463 | title: existing.title, | 460 | title: existing.title, |
| 464 | }, | 461 | }), |
| 465 | }); | 462 | ); |
| 466 | return; | 463 | return; |
| 467 | } | 464 | } |
| 468 | 465 | ||
| ... | @@ -480,24 +477,24 @@ function useCreateProject(environment: EnvironmentOption | null) { | ... | @@ -480,24 +477,24 @@ function useCreateProject(environment: EnvironmentOption | null) { |
| 480 | if (AsyncResult.isFailure(result)) { | 477 | if (AsyncResult.isFailure(result)) { |
| 481 | return result; | 478 | return result; |
| 482 | } | 479 | } |
| 483 | router.replace({ | 480 | navigation.dispatch( |
| 484 | pathname: "/new/draft", | 481 | StackActions.replace("NewTaskDraft", { |
| 485 | params: { | ||
| 486 | environmentId: environment.environmentId, | 482 | environmentId: environment.environmentId, |
| 487 | projectId, | 483 | projectId, |
| 488 | title: inferProjectTitleFromPath(workspaceRoot), | 484 | title: inferProjectTitleFromPath(workspaceRoot), |
| 489 | }, | 485 | }), |
| 490 | }); | 486 | ); |
| 491 | return result; | 487 | return result; |
| 492 | }, | 488 | }, |
| 493 | [createProject, environment, projects, router], | 489 | [createProject, environment, projects, navigation], |
| 494 | ); | 490 | ); |
| 495 | } | 491 | } |
| 496 | 492 | ||
| 497 | function useEnvironmentFromParam(): EnvironmentOption | null { | 493 | function useEnvironmentFromParam( |
| 498 | const params = useLocalSearchParams<{ environmentId?: string }>(); | 494 | environmentIdParam: string | string[] | undefined, |
| 495 | ): EnvironmentOption | null { | ||
| 499 | const environmentOptions = useEnvironmentOptions(); | 496 | const environmentOptions = useEnvironmentOptions(); |
| 500 | const environmentId = stringParam(params.environmentId) as EnvironmentId | null; | 497 | const environmentId = stringParam(environmentIdParam) as EnvironmentId | null; |
| 501 | return ( | 498 | return ( |
| 502 | environmentOptions.find((environment) => environment.environmentId === environmentId) ?? | 499 | environmentOptions.find((environment) => environment.environmentId === environmentId) ?? |
| 503 | environmentOptions[0] ?? | 500 | environmentOptions[0] ?? |
| ... | @@ -505,14 +502,16 @@ function useEnvironmentFromParam(): EnvironmentOption | null { | ... | @@ -505,14 +502,16 @@ function useEnvironmentFromParam(): EnvironmentOption | null { |
| 505 | ); | 502 | ); |
| 506 | } | 503 | } |
| 507 | 504 | ||
| 508 | export function AddProjectRepositoryScreen() { | 505 | export function AddProjectRepositoryScreen(props: { |
| 506 | readonly environmentId?: string | string[]; | ||
| 507 | readonly source?: string | string[]; | ||
| 508 | }) { | ||
| 509 | const lookupRepositoryQuery = useAtomQueryRunner(sourceControlEnvironment.repository, { | 509 | const lookupRepositoryQuery = useAtomQueryRunner(sourceControlEnvironment.repository, { |
| 510 | reportFailure: false, | 510 | reportFailure: false, |
| 511 | }); | 511 | }); |
| 512 | const router = useRouter(); | 512 | const navigation = useNavigation(); |
| 513 | const params = useLocalSearchParams<{ environmentId?: string; source?: string }>(); | 513 | const environment = useEnvironmentFromParam(props.environmentId); |
| 514 | const environment = useEnvironmentFromParam(); | 514 | const source = sourceFromParam(props.source); |
| 515 | const source = sourceFromParam(params.source); | ||
| 516 | const [repositoryInput, setRepositoryInput] = useState(""); | 515 | const [repositoryInput, setRepositoryInput] = useState(""); |
| 517 | const [isSubmitting, setIsSubmitting] = useState(false); | 516 | const [isSubmitting, setIsSubmitting] = useState(false); |
| 518 | const [error, setError] = useState<string | null>(null); | 517 | const [error, setError] = useState<string | null>(null); |
| ... | @@ -524,8 +523,8 @@ export function AddProjectRepositoryScreen() { | ... | @@ -524,8 +523,8 @@ export function AddProjectRepositoryScreen() { |
| 524 | const provider = addProjectRemoteSourceProvider(source); | 523 | const provider = addProjectRemoteSourceProvider(source); |
| 525 | if (!provider) { | 524 | if (!provider) { |
| 526 | const remoteUrl = repositoryInput.trim(); | 525 | const remoteUrl = repositoryInput.trim(); |
| 527 | router.push({ | 526 | navigation.navigate("NewTaskSheet", { |
| 528 | pathname: "/new/add-project/destination", | 527 | screen: "AddProjectDestination", |
| 529 | params: { | 528 | params: { |
| 530 | environmentId: environment.environmentId, | 529 | environmentId: environment.environmentId, |
| 531 | source, | 530 | source, |
| ... | @@ -548,8 +547,8 @@ export function AddProjectRepositoryScreen() { | ... | @@ -548,8 +547,8 @@ export function AddProjectRepositoryScreen() { |
| 548 | setError(errorMessage(Cause.squash(result.cause))); | 547 | setError(errorMessage(Cause.squash(result.cause))); |
| 549 | } else { | 548 | } else { |
| 550 | const repository = result.value; | 549 | const repository = result.value; |
| 551 | router.push({ | 550 | navigation.navigate("NewTaskSheet", { |
| 552 | pathname: "/new/add-project/destination", | 551 | screen: "AddProjectDestination", |
| 553 | params: { | 552 | params: { |
| 554 | environmentId: environment.environmentId, | 553 | environmentId: environment.environmentId, |
| 555 | source, | 554 | source, |
| ... | @@ -559,13 +558,13 @@ export function AddProjectRepositoryScreen() { | ... | @@ -559,13 +558,13 @@ export function AddProjectRepositoryScreen() { |
| 559 | }); | 558 | }); |
| 560 | } | 559 | } |
| 561 | setIsSubmitting(false); | 560 | setIsSubmitting(false); |
| 562 | }, [environment, isSubmitting, lookupRepositoryQuery, repositoryInput, router, source]); | 561 | }, [environment, isSubmitting, lookupRepositoryQuery, repositoryInput, navigation, source]); |
| 563 | 562 | ||
| 564 | return ( | 563 | return ( |
| 565 | <AddProjectShell> | 564 | <AddProjectShell> |
| 566 | {error ? <ErrorBanner message={error} /> : null} | 565 | {error ? <ErrorBanner message={error} /> : null} |
| 567 | <TextInput | 566 | <TextInput |
| 568 | className="h-12 min-h-12 rounded-[24px] px-4 py-0 text-base leading-[20px]" | 567 | className="h-12 min-h-12 rounded-[24px] px-4 py-0 text-base leading-snug" |
| 569 | value={repositoryInput} | 568 | value={repositoryInput} |
| 570 | onChangeText={setRepositoryInput} | 569 | onChangeText={setRepositoryInput} |
| 571 | autoCapitalize="none" | 570 | autoCapitalize="none" |
| ... | @@ -681,8 +680,8 @@ function FolderBrowser(props: { | ... | @@ -681,8 +680,8 @@ function FolderBrowser(props: { |
| 681 | ); | 680 | ); |
| 682 | } | 681 | } |
| 683 | 682 | ||
| 684 | export function AddProjectLocalFolderScreen() { | 683 | export function AddProjectLocalFolderScreen(props: { readonly environmentId?: string | string[] }) { |
| 685 | const environment = useEnvironmentFromParam(); | 684 | const environment = useEnvironmentFromParam(props.environmentId); |
| 686 | const createProject = useCreateProject(environment); | 685 | const createProject = useCreateProject(environment); |
| 687 | const [pathInput, setPathInput] = useState(() => | 686 | const [pathInput, setPathInput] = useState(() => |
| 688 | getAddProjectInitialQuery(environment?.baseDirectory), | 687 | getAddProjectInitialQuery(environment?.baseDirectory), |
| ... | @@ -745,19 +744,18 @@ export function AddProjectLocalFolderScreen() { | ... | @@ -745,19 +744,18 @@ export function AddProjectLocalFolderScreen() { |
| 745 | ); | 744 | ); |
| 746 | } | 745 | } |
| 747 | 746 | ||
| 748 | export function AddProjectDestinationScreen() { | 747 | export function AddProjectDestinationScreen(props: { |
| 748 | readonly environmentId?: string | string[]; | ||
| 749 | readonly remoteUrl?: string | string[]; | ||
| 750 | readonly repositoryTitle?: string | string[]; | ||
| 751 | }) { | ||
| 749 | const cloneRepository = useAtomCommand(sourceControlEnvironment.cloneRepository, { | 752 | const cloneRepository = useAtomCommand(sourceControlEnvironment.cloneRepository, { |
| 750 | reportFailure: false, | 753 | reportFailure: false, |
| 751 | }); | 754 | }); |
| 752 | const params = useLocalSearchParams<{ | 755 | const environment = useEnvironmentFromParam(props.environmentId); |
| 753 | environmentId?: string; | ||
| 754 | remoteUrl?: string; | ||
| 755 | repositoryTitle?: string; | ||
| 756 | }>(); | ||
| 757 | const environment = useEnvironmentFromParam(); | ||
| 758 | const createProject = useCreateProject(environment); | 756 | const createProject = useCreateProject(environment); |
| 759 | const remoteUrl = stringParam(params.remoteUrl); | 757 | const remoteUrl = stringParam(props.remoteUrl); |
| 760 | const repositoryTitle = stringParam(params.repositoryTitle); | 758 | const repositoryTitle = stringParam(props.repositoryTitle); |
| 761 | const [pathInput, setPathInput] = useState(() => | 759 | const [pathInput, setPathInput] = useState(() => |
| 762 | getAddProjectInitialQuery(environment?.baseDirectory), | 760 | getAddProjectInitialQuery(environment?.baseDirectory), |
| 763 | ); | 761 | ); |
apps/mobile/src/features/projects/AddProjectSourceRoute.tsx created+5| ... | @@ -0,0 +1,5 @@ | ||
| 1 | import { AddProjectSourceScreen } from "./AddProjectScreen"; | ||
| 2 | |||
| 3 | export function AddProjectSourceRoute() { | ||
| 4 | return <AddProjectSourceScreen />; | ||
| 5 | } | ||
apps/mobile/src/features/review/ReviewCommentComposerSheet.tsx+21-16| ... | @@ -1,4 +1,4 @@ | ... | @@ -1,4 +1,4 @@ |
| 1 | import { useLocalSearchParams, useRouter } from "expo-router"; | 1 | import { useNavigation, type StaticScreenProps } from "@react-navigation/native"; |
| 2 | import { SymbolView } from "expo-symbols"; | 2 | import { SymbolView } from "expo-symbols"; |
| 3 | import { TextInputWrapper } from "expo-paste-input"; | 3 | import { TextInputWrapper } from "expo-paste-input"; |
| 4 | import type { EnvironmentId, ThreadId } from "@t3tools/contracts"; | 4 | import type { EnvironmentId, ThreadId } from "@t3tools/contracts"; |
| ... | @@ -25,10 +25,10 @@ import { | ... | @@ -25,10 +25,10 @@ import { |
| 25 | getSelectedReviewCommentLines, | 25 | getSelectedReviewCommentLines, |
| 26 | useReviewCommentTarget, | 26 | useReviewCommentTarget, |
| 27 | } from "./reviewCommentSelection"; | 27 | } from "./reviewCommentSelection"; |
| 28 | import { useAppearanceCodeSurface } from "../settings/appearance/useAppearanceCodeSurface"; | ||
| 28 | import { | 29 | import { |
| 29 | changeTone, | 30 | changeTone, |
| 30 | DiffTokenText, | 31 | DiffTokenText, |
| 31 | REVIEW_DIFF_LINE_HEIGHT, | ||
| 32 | REVIEW_MONO_FONT_FAMILY, | 32 | REVIEW_MONO_FONT_FAMILY, |
| 33 | ReviewChangeBar, | 33 | ReviewChangeBar, |
| 34 | } from "./reviewDiffRendering"; | 34 | } from "./reviewDiffRendering"; |
| ... | @@ -40,17 +40,20 @@ import { | ... | @@ -40,17 +40,20 @@ import { |
| 40 | 40 | ||
| 41 | const REVIEW_COMMENT_PREVIEW_MAX_LINES = 5; | 41 | const REVIEW_COMMENT_PREVIEW_MAX_LINES = 5; |
| 42 | 42 | ||
| 43 | export function ReviewCommentComposerSheet() { | 43 | type ReviewCommentComposerSheetProps = StaticScreenProps<{ |
| 44 | const router = useRouter(); | 44 | readonly environmentId: EnvironmentId; |
| 45 | readonly threadId: ThreadId; | ||
| 46 | }>; | ||
| 47 | |||
| 48 | export function ReviewCommentComposerSheet(props: ReviewCommentComposerSheetProps) { | ||
| 49 | const navigation = useNavigation(); | ||
| 45 | const insets = useSafeAreaInsets(); | 50 | const insets = useSafeAreaInsets(); |
| 46 | const { width } = useWindowDimensions(); | 51 | const { width } = useWindowDimensions(); |
| 47 | const colorScheme = useColorScheme(); | 52 | const colorScheme = useColorScheme(); |
| 48 | const iconTint = String(useThemeColor("--color-icon")); | 53 | const iconTint = String(useThemeColor("--color-icon")); |
| 49 | const target = useReviewCommentTarget(); | 54 | const target = useReviewCommentTarget(); |
| 50 | const { environmentId, threadId } = useLocalSearchParams<{ | 55 | const { codeSurface } = useAppearanceCodeSurface(); |
| 51 | environmentId: EnvironmentId; | 56 | const { environmentId, threadId } = props.route.params; |
| 52 | threadId: ThreadId; | ||
| 53 | }>(); | ||
| 54 | const [commentText, setCommentText] = useState(""); | 57 | const [commentText, setCommentText] = useState(""); |
| 55 | const [highlightedLinesById, setHighlightedLinesById] = useState< | 58 | const [highlightedLinesById, setHighlightedLinesById] = useState< |
| 56 | Record<string, ReadonlyArray<ReviewHighlightedToken>> | 59 | Record<string, ReadonlyArray<ReviewHighlightedToken>> |
| ... | @@ -78,14 +81,14 @@ export function ReviewCommentComposerSheet() { | ... | @@ -78,14 +81,14 @@ export function ReviewCommentComposerSheet() { |
| 78 | ? `Lines ${firstNumber}-${lastNumber}` | 81 | ? `Lines ${firstNumber}-${lastNumber}` |
| 79 | : `${selectedLines.length} lines selected`; | 82 | : `${selectedLines.length} lines selected`; |
| 80 | const previewHeight = Math.max( | 83 | const previewHeight = Math.max( |
| 81 | Math.min(selectedLines.length, REVIEW_COMMENT_PREVIEW_MAX_LINES) * REVIEW_DIFF_LINE_HEIGHT, | 84 | Math.min(selectedLines.length, REVIEW_COMMENT_PREVIEW_MAX_LINES) * codeSurface.rowHeight, |
| 82 | REVIEW_DIFF_LINE_HEIGHT, | 85 | codeSurface.rowHeight, |
| 83 | ); | 86 | ); |
| 84 | const previewViewportWidth = Math.max(width - 40, 280); | 87 | const previewViewportWidth = Math.max(width - 40, 280); |
| 85 | const dismissComposer = useCallback(() => { | 88 | const dismissComposer = useCallback(() => { |
| 86 | clearReviewCommentTarget(); | 89 | clearReviewCommentTarget(); |
| 87 | router.dismiss(); | 90 | navigation.goBack(); |
| 88 | }, [router]); | 91 | }, [navigation]); |
| 89 | const handleNativePaste = useNativePaste((uris) => { | 92 | const handleNativePaste = useNativePaste((uris) => { |
| 90 | void (async () => { | 93 | void (async () => { |
| 91 | try { | 94 | try { |
| ... | @@ -167,7 +170,7 @@ export function ReviewCommentComposerSheet() { | ... | @@ -167,7 +170,7 @@ export function ReviewCommentComposerSheet() { |
| 167 | {!target ? ( | 170 | {!target ? ( |
| 168 | <View className="rounded-[22px] border border-border bg-card px-4 py-5"> | 171 | <View className="rounded-[22px] border border-border bg-card px-4 py-5"> |
| 169 | <Text className="text-base font-t3-bold text-foreground">No selection</Text> | 172 | <Text className="text-base font-t3-bold text-foreground">No selection</Text> |
| 170 | <Text className="mt-1 text-sm leading-[19px] text-foreground-muted"> | 173 | <Text className="mt-1 text-sm leading-normal text-foreground-muted"> |
| 171 | Select a diff line or range first. | 174 | Select a diff line or range first. |
| 172 | </Text> | 175 | </Text> |
| 173 | </View> | 176 | </View> |
| ... | @@ -178,7 +181,7 @@ export function ReviewCommentComposerSheet() { | ... | @@ -178,7 +181,7 @@ export function ReviewCommentComposerSheet() { |
| 178 | {selectionLabel} | 181 | {selectionLabel} |
| 179 | </Text> | 182 | </Text> |
| 180 | <Text | 183 | <Text |
| 181 | className="font-mono text-xs leading-[17px] text-foreground-muted" | 184 | className="font-mono text-xs leading-snug text-foreground-muted" |
| 182 | ellipsizeMode="middle" | 185 | ellipsizeMode="middle" |
| 183 | numberOfLines={2} | 186 | numberOfLines={2} |
| 184 | > | 187 | > |
| ... | @@ -211,9 +214,9 @@ export function ReviewCommentComposerSheet() { | ... | @@ -211,9 +214,9 @@ export function ReviewCommentComposerSheet() { |
| 211 | <View | 214 | <View |
| 212 | key={line.id} | 215 | key={line.id} |
| 213 | className={cn("flex-row items-start", changeTone(line.change))} | 216 | className={cn("flex-row items-start", changeTone(line.change))} |
| 214 | style={{ height: REVIEW_DIFF_LINE_HEIGHT }} | 217 | style={{ height: codeSurface.rowHeight }} |
| 215 | > | 218 | > |
| 216 | <ReviewChangeBar change={line.change} /> | 219 | <ReviewChangeBar change={line.change} height={codeSurface.rowHeight} /> |
| 217 | <Text | 220 | <Text |
| 218 | className="w-9 py-1 pr-1 text-right text-2xs font-t3-medium text-foreground-muted" | 221 | className="w-9 py-1 pr-1 text-right text-2xs font-t3-medium text-foreground-muted" |
| 219 | style={{ fontFamily: REVIEW_MONO_FONT_FAMILY }} | 222 | style={{ fontFamily: REVIEW_MONO_FONT_FAMILY }} |
| ... | @@ -225,6 +228,8 @@ export function ReviewCommentComposerSheet() { | ... | @@ -225,6 +228,8 @@ export function ReviewCommentComposerSheet() { |
| 225 | fallback={line.content} | 228 | fallback={line.content} |
| 226 | tokens={highlightedLinesById[line.id] ?? null} | 229 | tokens={highlightedLinesById[line.id] ?? null} |
| 227 | change={line.change} | 230 | change={line.change} |
| 231 | fontSize={codeSurface.fontSize} | ||
| 232 | lineHeight={codeSurface.rowHeight} | ||
| 228 | /> | 233 | /> |
| 229 | </View> | 234 | </View> |
| 230 | </View> | 235 | </View> |
apps/mobile/src/features/review/ReviewSheet.tsx+478-182| ... | @@ -1,14 +1,30 @@ | ... | @@ -1,14 +1,30 @@ |
| 1 | import type { EnvironmentId, ThreadId } from "@t3tools/contracts"; | 1 | import type { EnvironmentId, ThreadId } from "@t3tools/contracts"; |
| 2 | import { useLocalSearchParams } from "expo-router"; | 2 | import { useNavigation, type StaticScreenProps } from "@react-navigation/native"; |
| 3 | import Stack from "expo-router/stack"; | 3 | import { |
| 4 | NativeHeaderToolbar, | ||
| 5 | NativeStackScreenOptions, | ||
| 6 | nativeHeaderScrollEdgeEffects, | ||
| 7 | } from "../../native/StackHeader"; | ||
| 8 | import { Screen, ScreenStack, ScreenStackHeaderConfig } from "react-native-screens"; | ||
| 4 | import { SymbolView } from "expo-symbols"; | 9 | import { SymbolView } from "expo-symbols"; |
| 5 | import { memo, type ReactElement, useCallback, useMemo } from "react"; | 10 | import { |
| 11 | memo, | ||
| 12 | type Ref, | ||
| 13 | type ReactElement, | ||
| 14 | useCallback, | ||
| 15 | useEffect, | ||
| 16 | useImperativeHandle, | ||
| 17 | useMemo, | ||
| 18 | useRef, | ||
| 19 | useState, | ||
| 20 | } from "react"; | ||
| 6 | import { | 21 | import { |
| 7 | ActivityIndicator, | 22 | ActivityIndicator, |
| 23 | FlatList, | ||
| 24 | Platform, | ||
| 8 | Pressable, | 25 | Pressable, |
| 9 | ScrollView, | 26 | ScrollView, |
| 10 | type NativeSyntheticEvent, | 27 | type NativeSyntheticEvent, |
| 11 | Text as NativeText, | ||
| 12 | StyleSheet, | 28 | StyleSheet, |
| 13 | useColorScheme, | 29 | useColorScheme, |
| 14 | View, | 30 | View, |
| ... | @@ -20,23 +36,38 @@ import { environmentCatalog } from "../../connection/catalog"; | ... | @@ -20,23 +36,38 @@ import { environmentCatalog } from "../../connection/catalog"; |
| 20 | import { useEnvironmentPresentation } from "../../state/presentation"; | 36 | import { useEnvironmentPresentation } from "../../state/presentation"; |
| 21 | import { useAtomCommand } from "../../state/use-atom-command"; | 37 | import { useAtomCommand } from "../../state/use-atom-command"; |
| 22 | import { useThemeColor } from "../../lib/useThemeColor"; | 38 | import { useThemeColor } from "../../lib/useThemeColor"; |
| 23 | import { MOBILE_TYPOGRAPHY } from "../../lib/typography"; | ||
| 24 | import { useThreadDraftForThread } from "../../state/use-thread-composer-state"; | 39 | import { useThreadDraftForThread } from "../../state/use-thread-composer-state"; |
| 25 | import { EnvironmentConnectionNotice } from "../connection/EnvironmentConnectionNotice"; | 40 | import { EnvironmentConnectionNotice } from "../connection/EnvironmentConnectionNotice"; |
| 41 | import { | ||
| 42 | useAdaptiveWorkspaceLayout, | ||
| 43 | useAdaptiveWorkspacePaneRole, | ||
| 44 | useRegisterWorkspaceInspector, | ||
| 45 | } from "../layout/AdaptiveWorkspaceLayout"; | ||
| 46 | import { useEnvironmentQuery } from "../../state/query"; | ||
| 47 | import { useSelectedThreadGitActions } from "../../state/use-selected-thread-git-actions"; | ||
| 48 | import { useSelectedThreadGitState } from "../../state/use-selected-thread-git-state"; | ||
| 49 | import { useSelectedThreadWorktree } from "../../state/use-selected-thread-worktree"; | ||
| 50 | import { useThreadSelection } from "../../state/use-thread-selection"; | ||
| 51 | import { vcsEnvironment } from "../../state/vcs"; | ||
| 52 | import { WorkspaceSidebarToolbar } from "../layout/workspace-sidebar-toolbar"; | ||
| 53 | import { ThreadGitMenu } from "../threads/ThreadGitControls"; | ||
| 26 | import { useReviewCacheForThread } from "./reviewState"; | 54 | import { useReviewCacheForThread } from "./reviewState"; |
| 27 | import { resolveNativeReviewDiffView } from "../diffs/nativeReviewDiffSurface"; | ||
| 28 | import { | 55 | import { |
| 29 | NATIVE_REVIEW_DIFF_CONTENT_WIDTH, | 56 | type NativeReviewDiffViewHandle, |
| 30 | NATIVE_REVIEW_DIFF_ROW_HEIGHT, | 57 | resolveNativeReviewDiffView, |
| 31 | } from "./nativeReviewDiffAdapter"; | 58 | } from "../diffs/nativeReviewDiffSurface"; |
| 59 | import { NATIVE_REVIEW_DIFF_CONTENT_WIDTH } from "./nativeReviewDiffAdapter"; | ||
| 60 | import { useAppearanceCodeSurface } from "../settings/appearance/useAppearanceCodeSurface"; | ||
| 32 | import { useReviewDiffData } from "./useReviewDiffData"; | 61 | import { useReviewDiffData } from "./useReviewDiffData"; |
| 62 | import { useReviewDiffPrewarming } from "./useReviewDiffPrewarming"; | ||
| 33 | import { useReviewFileVisibility } from "./reviewFileVisibility"; | 63 | import { useReviewFileVisibility } from "./reviewFileVisibility"; |
| 34 | import { useReviewSections } from "./useReviewSections"; | 64 | import { useReviewSections } from "./useReviewSections"; |
| 35 | import { useNativeReviewDiffBridge } from "./useNativeReviewDiffBridge"; | 65 | import { useNativeReviewDiffBridge } from "./useNativeReviewDiffBridge"; |
| 36 | import { useReviewCommentSelectionController } from "./useReviewCommentSelectionController"; | 66 | import { useReviewCommentSelectionController } from "./useReviewCommentSelectionController"; |
| 37 | import { resolveReviewAvailability } from "./reviewAvailability"; | 67 | import { resolveReviewAvailability } from "./reviewAvailability"; |
| 68 | import { resolveSelectedReviewFileId } from "./reviewPaneSelection"; | ||
| 69 | import { buildReviewSectionMenu } from "./review-section-menu"; | ||
| 38 | 70 | ||
| 39 | const IOS_NAV_BAR_HEIGHT = 44; | ||
| 40 | const REVIEW_HEADER_SPACING = 0; | 71 | const REVIEW_HEADER_SPACING = 0; |
| 41 | 72 | ||
| 42 | const ReviewNotice = memo(function ReviewNotice(props: { readonly notice: string }) { | 73 | const ReviewNotice = memo(function ReviewNotice(props: { readonly notice: string }) { |
| ... | @@ -45,7 +76,7 @@ const ReviewNotice = memo(function ReviewNotice(props: { readonly notice: string | ... | @@ -45,7 +76,7 @@ const ReviewNotice = memo(function ReviewNotice(props: { readonly notice: string |
| 45 | <Text className="text-xs font-t3-bold uppercase text-amber-700 dark:text-amber-300"> | 76 | <Text className="text-xs font-t3-bold uppercase text-amber-700 dark:text-amber-300"> |
| 46 | Partial diff | 77 | Partial diff |
| 47 | </Text> | 78 | </Text> |
| 48 | <Text className="text-xs leading-[18px] text-amber-800 dark:text-amber-200"> | 79 | <Text className="text-xs leading-normal text-amber-800 dark:text-amber-200"> |
| 49 | {props.notice} | 80 | {props.notice} |
| 50 | </Text> | 81 | </Text> |
| 51 | </View> | 82 | </View> |
| ... | @@ -110,36 +141,244 @@ function ReviewSelectionActionBar(props: { | ... | @@ -110,36 +141,244 @@ function ReviewSelectionActionBar(props: { |
| 110 | ); | 141 | ); |
| 111 | } | 142 | } |
| 112 | 143 | ||
| 113 | export function ReviewSheet() { | 144 | interface ReviewNavigatorFile { |
| 145 | readonly id: string; | ||
| 146 | readonly path: string; | ||
| 147 | readonly additions: number; | ||
| 148 | readonly deletions: number; | ||
| 149 | } | ||
| 150 | |||
| 151 | const ReviewFileNavigatorRow = memo(function ReviewFileNavigatorRow(props: { | ||
| 152 | readonly file: ReviewNavigatorFile; | ||
| 153 | readonly selected: boolean; | ||
| 154 | readonly onSelectFile: (fileId: string | null) => void; | ||
| 155 | }) { | ||
| 156 | const { file, selected, onSelectFile } = props; | ||
| 157 | // Tapping the selected file again returns to the all-files diff. | ||
| 158 | const handlePress = useCallback(() => { | ||
| 159 | onSelectFile(selected ? null : file.id); | ||
| 160 | }, [file.id, onSelectFile, selected]); | ||
| 161 | |||
| 162 | return ( | ||
| 163 | <Pressable | ||
| 164 | accessibilityRole="button" | ||
| 165 | accessibilityState={{ selected }} | ||
| 166 | className={ | ||
| 167 | selected | ||
| 168 | ? "mt-1 min-h-12 justify-center rounded-xl bg-subtle-strong px-3 py-2" | ||
| 169 | : "mt-1 min-h-12 justify-center rounded-xl px-3 py-2 active:bg-subtle" | ||
| 170 | } | ||
| 171 | onPress={handlePress} | ||
| 172 | > | ||
| 173 | <Text | ||
| 174 | className={ | ||
| 175 | selected | ||
| 176 | ? "text-xs font-t3-bold text-foreground" | ||
| 177 | : "text-xs font-t3-medium text-foreground-secondary" | ||
| 178 | } | ||
| 179 | numberOfLines={2} | ||
| 180 | > | ||
| 181 | {file.path} | ||
| 182 | </Text> | ||
| 183 | <View className="mt-1 flex-row gap-2"> | ||
| 184 | <Text className="text-2xs font-t3-bold text-emerald-600">+{file.additions}</Text> | ||
| 185 | <Text className="text-2xs font-t3-bold text-rose-600">-{file.deletions}</Text> | ||
| 186 | </View> | ||
| 187 | </Pressable> | ||
| 188 | ); | ||
| 189 | }); | ||
| 190 | |||
| 191 | interface ReviewFileNavigatorHandle { | ||
| 192 | readonly setVisibleFile: (fileId: string | null) => void; | ||
| 193 | } | ||
| 194 | |||
| 195 | interface ReviewFileNavigatorProps { | ||
| 196 | readonly files: ReadonlyArray<ReviewNavigatorFile>; | ||
| 197 | readonly headerInset: number; | ||
| 198 | readonly sectionId: string | null; | ||
| 199 | readonly onSelectFile: (fileId: string | null) => void; | ||
| 200 | readonly ref?: Ref<ReviewFileNavigatorHandle>; | ||
| 201 | } | ||
| 202 | |||
| 203 | function ReviewFileNavigator({ | ||
| 204 | files, | ||
| 205 | headerInset, | ||
| 206 | sectionId, | ||
| 207 | onSelectFile, | ||
| 208 | ref, | ||
| 209 | }: ReviewFileNavigatorProps) { | ||
| 210 | const insets = useSafeAreaInsets(); | ||
| 211 | const sheetColor = String(useThemeColor("--color-sheet")); | ||
| 212 | const foregroundColor = String(useThemeColor("--color-foreground")); | ||
| 213 | const headerScrollEdgeEffects = nativeHeaderScrollEdgeEffects(Platform.OS, Platform.Version); | ||
| 214 | const [fileSelection, setFileSelection] = useState<{ | ||
| 215 | readonly sectionId: string | null; | ||
| 216 | readonly fileId: string | null; | ||
| 217 | }>({ sectionId: null, fileId: null }); | ||
| 218 | const availableFileIds = useMemo(() => files.map((file) => file.id), [files]); | ||
| 219 | const selectedFileId = resolveSelectedReviewFileId({ | ||
| 220 | selection: fileSelection, | ||
| 221 | sectionId, | ||
| 222 | availableFileIds, | ||
| 223 | }); | ||
| 224 | |||
| 225 | useImperativeHandle( | ||
| 226 | ref, | ||
| 227 | () => ({ | ||
| 228 | setVisibleFile: (fileId) => { | ||
| 229 | if (fileId !== null && !availableFileIds.includes(fileId)) { | ||
| 230 | return; | ||
| 231 | } | ||
| 232 | setFileSelection((current) => { | ||
| 233 | if (current.sectionId === sectionId && current.fileId === fileId) { | ||
| 234 | return current; | ||
| 235 | } | ||
| 236 | return { sectionId, fileId }; | ||
| 237 | }); | ||
| 238 | }, | ||
| 239 | }), | ||
| 240 | [availableFileIds, sectionId], | ||
| 241 | ); | ||
| 242 | |||
| 243 | const handleSelectFile = useCallback( | ||
| 244 | (fileId: string | null) => { | ||
| 245 | setFileSelection({ sectionId, fileId }); | ||
| 246 | onSelectFile(fileId); | ||
| 247 | }, | ||
| 248 | [onSelectFile, sectionId], | ||
| 249 | ); | ||
| 250 | |||
| 251 | const renderFile = useCallback( | ||
| 252 | ({ item }: { readonly item: ReviewNavigatorFile }) => ( | ||
| 253 | <ReviewFileNavigatorRow | ||
| 254 | file={item} | ||
| 255 | selected={selectedFileId === item.id} | ||
| 256 | onSelectFile={handleSelectFile} | ||
| 257 | /> | ||
| 258 | ), | ||
| 259 | [handleSelectFile, selectedFileId], | ||
| 260 | ); | ||
| 261 | |||
| 262 | const fileList = ( | ||
| 263 | <FlatList | ||
| 264 | data={files} | ||
| 265 | extraData={selectedFileId} | ||
| 266 | keyExtractor={(file) => file.id} | ||
| 267 | contentContainerStyle={{ | ||
| 268 | paddingHorizontal: 8, | ||
| 269 | paddingBottom: 8, | ||
| 270 | // The nested native header is translucent; start the list below it so | ||
| 271 | // the scroll-edge effect can sample the content (same treatment as | ||
| 272 | // FileTreeBrowser in the Files pane). | ||
| 273 | paddingTop: Platform.OS === "ios" ? insets.top + 44 + 8 : 8, | ||
| 274 | }} | ||
| 275 | scrollIndicatorInsets={Platform.OS === "ios" ? { top: insets.top + 44 } : undefined} | ||
| 276 | renderItem={renderFile} | ||
| 277 | /> | ||
| 278 | ); | ||
| 279 | |||
| 280 | if (Platform.OS === "ios") { | ||
| 281 | return ( | ||
| 282 | <View className="flex-1 border-l border-border bg-sheet"> | ||
| 283 | <ScreenStack style={{ flex: 1 }}> | ||
| 284 | <Screen | ||
| 285 | activityState={2} | ||
| 286 | enabled | ||
| 287 | isNativeStack | ||
| 288 | screenId="review-file-navigator-native" | ||
| 289 | scrollEdgeEffects={headerScrollEdgeEffects} | ||
| 290 | style={{ backgroundColor: sheetColor, flex: 1 }} | ||
| 291 | > | ||
| 292 | {fileList} | ||
| 293 | <ScreenStackHeaderConfig | ||
| 294 | backgroundColor="rgba(0,0,0,0)" | ||
| 295 | color={foregroundColor} | ||
| 296 | hideBackButton | ||
| 297 | hideShadow={false} | ||
| 298 | navigationItemStyle="editor" | ||
| 299 | subtitle={`${files.length} ${files.length === 1 ? "file" : "files"}`} | ||
| 300 | title="Changed files" | ||
| 301 | titleColor={foregroundColor} | ||
| 302 | titleFontSize={17} | ||
| 303 | titleFontWeight="700" | ||
| 304 | translucent | ||
| 305 | /> | ||
| 306 | </Screen> | ||
| 307 | </ScreenStack> | ||
| 308 | </View> | ||
| 309 | ); | ||
| 310 | } | ||
| 311 | |||
| 312 | return ( | ||
| 313 | <View className="flex-1 border-l border-border bg-sheet"> | ||
| 314 | <View className="border-b border-border" style={{ paddingTop: headerInset }}> | ||
| 315 | <View className="px-4 py-3"> | ||
| 316 | <Text className="text-sm font-t3-bold text-foreground">Changed files</Text> | ||
| 317 | <Text className="text-xs text-foreground-muted"> | ||
| 318 | {files.length} {files.length === 1 ? "file" : "files"} | ||
| 319 | </Text> | ||
| 320 | </View> | ||
| 321 | </View> | ||
| 322 | {fileList} | ||
| 323 | </View> | ||
| 324 | ); | ||
| 325 | } | ||
| 326 | |||
| 327 | type ReviewSheetProps = StaticScreenProps<{ | ||
| 328 | readonly environmentId: EnvironmentId; | ||
| 329 | readonly threadId: ThreadId; | ||
| 330 | }>; | ||
| 331 | |||
| 332 | export function ReviewSheet(props: ReviewSheetProps) { | ||
| 333 | const { nativeReviewDiffStyle } = useAppearanceCodeSurface(); | ||
| 334 | useAdaptiveWorkspacePaneRole("inspector"); | ||
| 335 | const { panes, showAuxiliaryPane, toggleAuxiliaryPane } = useAdaptiveWorkspaceLayout(); | ||
| 336 | const navigation = useNavigation(); | ||
| 114 | const insets = useSafeAreaInsets(); | 337 | const insets = useSafeAreaInsets(); |
| 115 | const colorScheme = useColorScheme(); | 338 | const colorScheme = useColorScheme(); |
| 116 | const headerForeground = String(useThemeColor("--color-foreground")); | ||
| 117 | const headerMuted = String(useThemeColor("--color-foreground-muted")); | ||
| 118 | const headerIcon = String(useThemeColor("--color-icon")); | 339 | const headerIcon = String(useThemeColor("--color-icon")); |
| 119 | const { environmentId, threadId } = useLocalSearchParams<{ | 340 | const { environmentId, threadId } = props.route.params; |
| 120 | environmentId: EnvironmentId; | ||
| 121 | threadId: ThreadId; | ||
| 122 | }>(); | ||
| 123 | const environment = useEnvironmentPresentation(environmentId); | 341 | const environment = useEnvironmentPresentation(environmentId); |
| 124 | const retryEnvironment = useAtomCommand(environmentCatalog.retryNow, "environment retry"); | 342 | const retryEnvironment = useAtomCommand(environmentCatalog.retryNow, "environment retry"); |
| 125 | const isEnvironmentReady = environment.presentation?.connection.phase === "connected"; | 343 | const isEnvironmentReady = environment.presentation?.connection.phase === "connected"; |
| 126 | const { draftMessage } = useThreadDraftForThread({ environmentId, threadId }); | 344 | const { draftMessage } = useThreadDraftForThread({ environmentId, threadId }); |
| 127 | const reviewCache = useReviewCacheForThread({ environmentId, threadId }); | 345 | const reviewCache = useReviewCacheForThread({ environmentId, threadId }); |
| 346 | /* ─── Git actions for the toolbar menu (commit/push without leaving review) ── */ | ||
| 347 | const { selectedThread } = useThreadSelection(); | ||
| 348 | const { selectedThreadCwd } = useSelectedThreadWorktree(); | ||
| 349 | const gitState = useSelectedThreadGitState(); | ||
| 350 | const gitActions = useSelectedThreadGitActions(); | ||
| 351 | const gitStatusQuery = useEnvironmentQuery( | ||
| 352 | selectedThread !== null && selectedThreadCwd !== null | ||
| 353 | ? vcsEnvironment.status({ | ||
| 354 | environmentId: selectedThread.environmentId, | ||
| 355 | input: { cwd: selectedThreadCwd }, | ||
| 356 | }) | ||
| 357 | : null, | ||
| 358 | ); | ||
| 359 | // The selection-based git hooks only apply when this review belongs to the | ||
| 360 | // selected thread (it always does when reached from the thread's toolbar). | ||
| 361 | const gitMenuAvailable = | ||
| 362 | selectedThread !== null && String(selectedThread.id) === String(threadId); | ||
| 128 | const selectedTheme = colorScheme === "dark" ? "dark" : "light"; | 363 | const selectedTheme = colorScheme === "dark" ? "dark" : "light"; |
| 129 | const topContentInset = insets.top + IOS_NAV_BAR_HEIGHT; | 364 | // With a solid (non-overlay) header the content lays out below the header |
| 130 | const { | 365 | // natively, so no manual top inset is needed. |
| 131 | error, | 366 | const topContentInset = 0; |
| 132 | loadingGitDiffs, | 367 | |
| 133 | loadingTurnIds, | 368 | useEffect(() => { |
| 134 | reviewSections, | 369 | showAuxiliaryPane("inspector"); |
| 135 | selectedSection, | 370 | }, [environmentId, showAuxiliaryPane, threadId]); |
| 136 | refreshSelectedSection, | 371 | const { error, reviewSections, selectedSection, refreshSelectedSection, selectSection } = |
| 137 | selectSection, | 372 | useReviewSections({ |
| 138 | } = useReviewSections({ | 373 | enabled: isEnvironmentReady, |
| 139 | enabled: isEnvironmentReady, | 374 | environmentId, |
| 140 | environmentId, | 375 | threadId, |
| 141 | threadId, | 376 | reviewCache, |
| 142 | reviewCache, | 377 | }); |
| 378 | useReviewDiffPrewarming({ | ||
| 379 | threadKey: reviewCache.threadKey, | ||
| 380 | sections: reviewSections, | ||
| 381 | selectedSectionId: selectedSection?.id ?? null, | ||
| 143 | }); | 382 | }); |
| 144 | const { headerDiffSummary, nativeReviewDiffData, parsedDiff, pendingReviewCommentCount } = | 383 | const { headerDiffSummary, nativeReviewDiffData, parsedDiff, pendingReviewCommentCount } = |
| 145 | useReviewDiffData({ | 384 | useReviewDiffData({ |
| ... | @@ -148,6 +387,18 @@ export function ReviewSheet() { | ... | @@ -148,6 +387,18 @@ export function ReviewSheet() { |
| 148 | draftMessage, | 387 | draftMessage, |
| 149 | }); | 388 | }); |
| 150 | const NativeReviewDiffView = resolveNativeReviewDiffView()!; | 389 | const NativeReviewDiffView = resolveNativeReviewDiffView()!; |
| 390 | const nativeReviewDiffViewRef = useRef<NativeReviewDiffViewHandle>(null); | ||
| 391 | // Native pull-to-refresh on the diff surface (replaces the old Refresh menu item). | ||
| 392 | const [isPullRefreshing, setIsPullRefreshing] = useState(false); | ||
| 393 | const handlePullToRefresh = useCallback(async () => { | ||
| 394 | setIsPullRefreshing(true); | ||
| 395 | try { | ||
| 396 | await refreshSelectedSection(); | ||
| 397 | } finally { | ||
| 398 | setIsPullRefreshing(false); | ||
| 399 | } | ||
| 400 | }, [refreshSelectedSection]); | ||
| 401 | const reviewFileNavigatorRef = useRef<ReviewFileNavigatorHandle>(null); | ||
| 151 | const reviewFiles = parsedDiff.kind === "files" ? parsedDiff.files : []; | 402 | const reviewFiles = parsedDiff.kind === "files" ? parsedDiff.files : []; |
| 152 | const fileVisibility = useReviewFileVisibility({ | 403 | const fileVisibility = useReviewFileVisibility({ |
| 153 | threadKey: reviewCache.threadKey, | 404 | threadKey: reviewCache.threadKey, |
| ... | @@ -179,6 +430,43 @@ export function ReviewSheet() { | ... | @@ -179,6 +430,43 @@ export function ReviewSheet() { |
| 179 | canHighlight: parsedDiff.kind === "files", | 430 | canHighlight: parsedDiff.kind === "files", |
| 180 | }); | 431 | }); |
| 181 | 432 | ||
| 433 | const handleSelectFile = useCallback( | ||
| 434 | (fileId: string | null) => { | ||
| 435 | commentSelection.clearSelection(); | ||
| 436 | if (fileId !== null && collapsedFileIds.includes(fileId)) { | ||
| 437 | toggleExpandedFile(fileId); | ||
| 438 | } | ||
| 439 | const navigation = | ||
| 440 | fileId === null | ||
| 441 | ? nativeReviewDiffViewRef.current?.scrollToTop(true) | ||
| 442 | : nativeReviewDiffViewRef.current?.scrollToFile(fileId, true); | ||
| 443 | void navigation?.catch((error: unknown) => { | ||
| 444 | console.error("[review] Failed to navigate to diff file", error); | ||
| 445 | }); | ||
| 446 | }, | ||
| 447 | [collapsedFileIds, commentSelection, toggleExpandedFile], | ||
| 448 | ); | ||
| 449 | const handleVisibleFileChange = useCallback( | ||
| 450 | (event: NativeSyntheticEvent<{ readonly fileId?: string | null }>) => { | ||
| 451 | reviewFileNavigatorRef.current?.setVisibleFile(event.nativeEvent.fileId ?? null); | ||
| 452 | }, | ||
| 453 | [], | ||
| 454 | ); | ||
| 455 | const renderInspector = useCallback( | ||
| 456 | () => ( | ||
| 457 | <ReviewFileNavigator | ||
| 458 | ref={reviewFileNavigatorRef} | ||
| 459 | files={nativeReviewDiffData.files} | ||
| 460 | // The workspace inspector column spans the full window height, so the | ||
| 461 | // pane clears the status bar itself. | ||
| 462 | headerInset={insets.top} | ||
| 463 | sectionId={selectedSection?.id ?? null} | ||
| 464 | onSelectFile={handleSelectFile} | ||
| 465 | /> | ||
| 466 | ), | ||
| 467 | [handleSelectFile, insets.top, nativeReviewDiffData.files, selectedSection?.id], | ||
| 468 | ); | ||
| 469 | |||
| 182 | const handleNativeToggleFile = useCallback( | 470 | const handleNativeToggleFile = useCallback( |
| 183 | (event: NativeSyntheticEvent<{ readonly fileId?: string }>) => { | 471 | (event: NativeSyntheticEvent<{ readonly fileId?: string }>) => { |
| 184 | const { fileId } = event.nativeEvent; | 472 | const { fileId } = event.nativeEvent; |
| ... | @@ -203,6 +491,7 @@ export function ReviewSheet() { | ... | @@ -203,6 +491,7 @@ export function ReviewSheet() { |
| 203 | parsedDiff.kind === "files" || parsedDiff.kind === "raw" ? parsedDiff.notice : null; | 491 | parsedDiff.kind === "files" || parsedDiff.kind === "raw" ? parsedDiff.notice : null; |
| 204 | const hasCachedSelectedDiff = selectedSection?.diff != null; | 492 | const hasCachedSelectedDiff = selectedSection?.diff != null; |
| 205 | const hasAnyCachedDiff = reviewSections.some((section) => section.diff != null); | 493 | const hasAnyCachedDiff = reviewSections.some((section) => section.diff != null); |
| 494 | const sectionMenu = useMemo(() => buildReviewSectionMenu(reviewSections), [reviewSections]); | ||
| 206 | const { showConnectionNotice, showSectionToolbar } = resolveReviewAvailability({ | 495 | const { showConnectionNotice, showSectionToolbar } = resolveReviewAvailability({ |
| 207 | hasEnvironmentPresentation: environment.isReady, | 496 | hasEnvironmentPresentation: environment.isReady, |
| 208 | isEnvironmentConnected: isEnvironmentReady, | 497 | isEnvironmentConnected: isEnvironmentReady, |
| ... | @@ -212,6 +501,22 @@ export function ReviewSheet() { | ... | @@ -212,6 +501,22 @@ export function ReviewSheet() { |
| 212 | const handleRetryEnvironment = useCallback(() => { | 501 | const handleRetryEnvironment = useCallback(() => { |
| 213 | void retryEnvironment(environmentId); | 502 | void retryEnvironment(environmentId); |
| 214 | }, [environmentId, retryEnvironment]); | 503 | }, [environmentId, retryEnvironment]); |
| 504 | const handleReturnToThread = useCallback(() => { | ||
| 505 | if (navigation.canGoBack()) { | ||
| 506 | navigation.goBack(); | ||
| 507 | return; | ||
| 508 | } | ||
| 509 | navigation.navigate("Thread", { | ||
| 510 | environmentId: String(environmentId), | ||
| 511 | threadId: String(threadId), | ||
| 512 | }); | ||
| 513 | }, [environmentId, navigation, threadId]); | ||
| 514 | |||
| 515 | // The changed-files navigator lives in the workspace inspector column — | ||
| 516 | // the single right-hand pane per route — instead of an in-screen panel. | ||
| 517 | const showChangedFilesPane = | ||
| 518 | !showConnectionNotice && selectedSection !== null && parsedDiff.kind === "files"; | ||
| 519 | useRegisterWorkspaceInspector(showChangedFilesPane ? renderInspector : undefined); | ||
| 215 | 520 | ||
| 216 | const listHeader = useMemo(() => { | 521 | const listHeader = useMemo(() => { |
| 217 | const children: ReactElement[] = []; | 522 | const children: ReactElement[] = []; |
| ... | @@ -220,7 +525,7 @@ export function ReviewSheet() { | ... | @@ -220,7 +525,7 @@ export function ReviewSheet() { |
| 220 | children.push( | 525 | children.push( |
| 221 | <View key="review-error" className="border-b border-border bg-card px-4 py-3"> | 526 | <View key="review-error" className="border-b border-border bg-card px-4 py-3"> |
| 222 | <Text className="text-sm font-t3-bold text-foreground">Review unavailable</Text> | 527 | <Text className="text-sm font-t3-bold text-foreground">Review unavailable</Text> |
| 223 | <Text className="text-xs leading-[18px] text-foreground-muted">{error}</Text> | 528 | <Text className="text-xs leading-normal text-foreground-muted">{error}</Text> |
| 224 | </View>, | 529 | </View>, |
| 225 | ); | 530 | ); |
| 226 | } | 531 | } |
| ... | @@ -235,134 +540,117 @@ export function ReviewSheet() { | ... | @@ -235,134 +540,117 @@ export function ReviewSheet() { |
| 235 | 540 | ||
| 236 | return <>{children}</>; | 541 | return <>{children}</>; |
| 237 | }, [error, parsedDiffNotice]); | 542 | }, [error, parsedDiffNotice]); |
| 543 | const headerSubtitle = [ | ||
| 544 | headerDiffSummary.additions, | ||
| 545 | headerDiffSummary.deletions, | ||
| 546 | pendingReviewCommentCount > 0 | ||
| 547 | ? `${pendingReviewCommentCount} comment${pendingReviewCommentCount === 1 ? "" : "s"}` | ||
| 548 | : null, | ||
| 549 | ] | ||
| 550 | .filter(Boolean) | ||
| 551 | .join(" · "); | ||
| 552 | const headerTitleText = selectedSection?.title ?? "Review changes"; | ||
| 238 | 553 | ||
| 239 | return ( | 554 | return ( |
| 240 | <> | 555 | <> |
| 241 | <Stack.Screen | 556 | <NativeStackScreenOptions |
| 242 | options={{ | 557 | options={{ |
| 243 | headerTransparent: true, | 558 | // Static header config lives in Stack.tsx (SOLID_HEADER_OPTIONS — the native |
| 244 | headerShadowVisible: false, | 559 | // diff scrolls internally, nothing for glass to sample). Only dynamic values |
| 560 | // here. | ||
| 245 | headerTintColor: headerIcon, | 561 | headerTintColor: headerIcon, |
| 246 | headerStyle: { | 562 | headerTitle: headerTitleText, |
| 247 | backgroundColor: "transparent", | 563 | title: headerTitleText, |
| 248 | }, | 564 | unstable_headerSubtitle: |
| 249 | headerTitle: () => ( | 565 | Platform.OS === "ios" && headerSubtitle.length > 0 ? headerSubtitle : undefined, |
| 250 | <View style={{ alignItems: "center" }}> | ||
| 251 | <NativeText | ||
| 252 | numberOfLines={1} | ||
| 253 | style={{ | ||
| 254 | fontFamily: "DMSans_700Bold", | ||
| 255 | fontSize: MOBILE_TYPOGRAPHY.headline.fontSize, | ||
| 256 | fontWeight: "900", | ||
| 257 | color: headerForeground, | ||
| 258 | letterSpacing: -0.4, | ||
| 259 | }} | ||
| 260 | > | ||
| 261 | Files Changed | ||
| 262 | </NativeText> | ||
| 263 | <View | ||
| 264 | style={{ | ||
| 265 | flexDirection: "row", | ||
| 266 | alignItems: "center", | ||
| 267 | justifyContent: "center", | ||
| 268 | gap: 6, | ||
| 269 | flexWrap: "wrap", | ||
| 270 | }} | ||
| 271 | > | ||
| 272 | {headerDiffSummary.additions && headerDiffSummary.deletions ? ( | ||
| 273 | <> | ||
| 274 | <NativeText | ||
| 275 | style={{ | ||
| 276 | fontFamily: "DMSans_700Bold", | ||
| 277 | fontSize: MOBILE_TYPOGRAPHY.label.fontSize, | ||
| 278 | fontWeight: "700", | ||
| 279 | color: "#16a34a", | ||
| 280 | }} | ||
| 281 | > | ||
| 282 | {headerDiffSummary.additions} | ||
| 283 | </NativeText> | ||
| 284 | <NativeText | ||
| 285 | style={{ | ||
| 286 | fontFamily: "DMSans_700Bold", | ||
| 287 | fontSize: MOBILE_TYPOGRAPHY.label.fontSize, | ||
| 288 | fontWeight: "700", | ||
| 289 | color: "#e11d48", | ||
| 290 | }} | ||
| 291 | > | ||
| 292 | {headerDiffSummary.deletions} | ||
| 293 | </NativeText> | ||
| 294 | {pendingReviewCommentCount > 0 ? ( | ||
| 295 | <NativeText | ||
| 296 | style={{ | ||
| 297 | fontFamily: "DMSans_700Bold", | ||
| 298 | fontSize: MOBILE_TYPOGRAPHY.label.fontSize, | ||
| 299 | fontWeight: "700", | ||
| 300 | color: "#b45309", | ||
| 301 | }} | ||
| 302 | > | ||
| 303 | {pendingReviewCommentCount} pending | ||
| 304 | </NativeText> | ||
| 305 | ) : null} | ||
| 306 | </> | ||
| 307 | ) : ( | ||
| 308 | <View style={{ flexDirection: "row", alignItems: "center", gap: 6 }}> | ||
| 309 | <NativeText | ||
| 310 | numberOfLines={1} | ||
| 311 | style={{ | ||
| 312 | fontFamily: "DMSans_700Bold", | ||
| 313 | fontSize: MOBILE_TYPOGRAPHY.label.fontSize, | ||
| 314 | fontWeight: "700", | ||
| 315 | color: headerMuted, | ||
| 316 | }} | ||
| 317 | > | ||
| 318 | {selectedSection?.title ?? "Review changes"} | ||
| 319 | </NativeText> | ||
| 320 | {pendingReviewCommentCount > 0 ? ( | ||
| 321 | <NativeText | ||
| 322 | style={{ | ||
| 323 | fontFamily: "DMSans_700Bold", | ||
| 324 | fontSize: MOBILE_TYPOGRAPHY.label.fontSize, | ||
| 325 | fontWeight: "700", | ||
| 326 | color: "#b45309", | ||
| 327 | }} | ||
| 328 | > | ||
| 329 | {pendingReviewCommentCount} pending | ||
| 330 | </NativeText> | ||
| 331 | ) : null} | ||
| 332 | </View> | ||
| 333 | )} | ||
| 334 | </View> | ||
| 335 | </View> | ||
| 336 | ), | ||
| 337 | }} | 566 | }} |
| 338 | /> | 567 | /> |
| 339 | 568 | ||
| 340 | {showSectionToolbar ? ( | 569 | <WorkspaceSidebarToolbar> |
| 341 | <Stack.Toolbar placement="right"> | 570 | <NativeHeaderToolbar.Button |
| 342 | <Stack.Toolbar.Menu icon="ellipsis.circle" title="Select diff" separateBackground> | 571 | accessibilityLabel="Back to chat" |
| 343 | {reviewSections.map((section) => ( | 572 | icon="chevron.left" |
| 344 | <Stack.Toolbar.MenuAction | 573 | onPress={handleReturnToThread} |
| 345 | key={section.id} | 574 | /> |
| 346 | icon={section.id === selectedSection?.id ? "checkmark" : "circle"} | 575 | </WorkspaceSidebarToolbar> |
| 347 | onPress={() => selectSection(section.id)} | 576 | |
| 348 | subtitle={section.subtitle ?? undefined} | 577 | {showSectionToolbar || panes.supportsAuxiliaryPane || gitMenuAvailable ? ( |
| 349 | > | 578 | <NativeHeaderToolbar placement="right"> |
| 350 | <Stack.Toolbar.Label>{section.title}</Stack.Toolbar.Label> | 579 | {panes.supportsAuxiliaryPane ? ( |
| 351 | </Stack.Toolbar.MenuAction> | 580 | <NativeHeaderToolbar.Button |
| 352 | ))} | 581 | accessibilityLabel={ |
| 353 | <Stack.Toolbar.MenuAction | 582 | panes.auxiliaryPaneVisible ? "Hide changed files" : "Show changed files" |
| 354 | icon="arrow.clockwise" | ||
| 355 | disabled={ | ||
| 356 | loadingGitDiffs || | ||
| 357 | (selectedSection?.kind === "turn" && loadingTurnIds[selectedSection.id] === true) | ||
| 358 | } | 583 | } |
| 359 | onPress={() => void refreshSelectedSection()} | 584 | icon="sidebar.right" |
| 360 | subtitle="Reload current diff" | 585 | onPress={toggleAuxiliaryPane} |
| 361 | > | 586 | separateBackground |
| 362 | <Stack.Toolbar.Label>Refresh</Stack.Toolbar.Label> | 587 | /> |
| 363 | </Stack.Toolbar.MenuAction> | 588 | ) : null} |
| 364 | </Stack.Toolbar.Menu> | 589 | {gitMenuAvailable && selectedThread !== null ? ( |
| 365 | </Stack.Toolbar> | 590 | <ThreadGitMenu |
| 591 | environmentId={environmentId} | ||
| 592 | threadId={threadId} | ||
| 593 | currentBranch={selectedThread.branch ?? null} | ||
| 594 | gitStatus={gitStatusQuery.data} | ||
| 595 | gitOperationLabel={gitState.gitOperationLabel} | ||
| 596 | onPull={gitActions.onPullSelectedThreadBranch} | ||
| 597 | onRunAction={gitActions.onRunSelectedThreadGitAction} | ||
| 598 | /> | ||
| 599 | ) : null} | ||
| 600 | {showSectionToolbar ? ( | ||
| 601 | <NativeHeaderToolbar.Menu icon="ellipsis" title="Select diff" separateBackground> | ||
| 602 | <NativeHeaderToolbar.Menu inline> | ||
| 603 | <NativeHeaderToolbar.MenuAction | ||
| 604 | disabled={sectionMenu.workingTree === null} | ||
| 605 | isOn={selectedSection?.id === sectionMenu.workingTree?.id} | ||
| 606 | onPress={() => { | ||
| 607 | if (sectionMenu.workingTree) { | ||
| 608 | selectSection(sectionMenu.workingTree.id); | ||
| 609 | } | ||
| 610 | }} | ||
| 611 | > | ||
| 612 | <NativeHeaderToolbar.Label>Working tree</NativeHeaderToolbar.Label> | ||
| 613 | </NativeHeaderToolbar.MenuAction> | ||
| 614 | <NativeHeaderToolbar.MenuAction | ||
| 615 | disabled={sectionMenu.branchChanges === null} | ||
| 616 | isOn={selectedSection?.id === sectionMenu.branchChanges?.id} | ||
| 617 | onPress={() => { | ||
| 618 | if (sectionMenu.branchChanges) { | ||
| 619 | selectSection(sectionMenu.branchChanges.id); | ||
| 620 | } | ||
| 621 | }} | ||
| 622 | > | ||
| 623 | <NativeHeaderToolbar.Label>Branch changes</NativeHeaderToolbar.Label> | ||
| 624 | </NativeHeaderToolbar.MenuAction> | ||
| 625 | <NativeHeaderToolbar.MenuAction | ||
| 626 | disabled={sectionMenu.latestTurn === null} | ||
| 627 | isOn={selectedSection?.id === sectionMenu.latestTurn?.id} | ||
| 628 | onPress={() => { | ||
| 629 | if (sectionMenu.latestTurn) { | ||
| 630 | selectSection(sectionMenu.latestTurn.id); | ||
| 631 | } | ||
| 632 | }} | ||
| 633 | > | ||
| 634 | <NativeHeaderToolbar.Label>Latest turn</NativeHeaderToolbar.Label> | ||
| 635 | </NativeHeaderToolbar.MenuAction> | ||
| 636 | {sectionMenu.turns.length > 0 ? ( | ||
| 637 | <NativeHeaderToolbar.Menu title="Turn"> | ||
| 638 | {sectionMenu.turns.map((section) => ( | ||
| 639 | <NativeHeaderToolbar.MenuAction | ||
| 640 | key={section.id} | ||
| 641 | isOn={section.id === selectedSection?.id} | ||
| 642 | onPress={() => selectSection(section.id)} | ||
| 643 | subtitle={section.subtitle ?? undefined} | ||
| 644 | > | ||
| 645 | <NativeHeaderToolbar.Label>{section.title}</NativeHeaderToolbar.Label> | ||
| 646 | </NativeHeaderToolbar.MenuAction> | ||
| 647 | ))} | ||
| 648 | </NativeHeaderToolbar.Menu> | ||
| 649 | ) : null} | ||
| 650 | </NativeHeaderToolbar.Menu> | ||
| 651 | </NativeHeaderToolbar.Menu> | ||
| 652 | ) : null} | ||
| 653 | </NativeHeaderToolbar> | ||
| 366 | ) : null} | 654 | ) : null} |
| 367 | 655 | ||
| 368 | <View className="flex-1 bg-sheet"> | 656 | <View className="flex-1 bg-sheet"> |
| ... | @@ -386,34 +674,42 @@ export function ReviewSheet() { | ... | @@ -386,34 +674,42 @@ export function ReviewSheet() { |
| 386 | className="flex-1" | 674 | className="flex-1" |
| 387 | style={{ | 675 | style={{ |
| 388 | backgroundColor: nativeBridge.theme.background, | 676 | backgroundColor: nativeBridge.theme.background, |
| 389 | paddingTop: topContentInset + REVIEW_HEADER_SPACING, | ||
| 390 | }} | 677 | }} |
| 391 | > | 678 | > |
| 392 | {listHeader} | 679 | <View |
| 393 | <View className="flex-1" collapsable={false}> | 680 | className="min-w-0 flex-1" |
| 394 | <NativeReviewDiffView | 681 | style={{ paddingTop: topContentInset + REVIEW_HEADER_SPACING }} |
| 395 | key={`${reviewCache.threadKey}:${selectedSection.id}`} | 682 | > |
| 396 | collapsable={false} | 683 | {listHeader} |
| 397 | testID="review-native-diff-view" | 684 | <View className="min-w-0 flex-1" collapsable={false}> |
| 398 | style={StyleSheet.absoluteFill} | 685 | <NativeReviewDiffView |
| 399 | appearanceScheme={selectedTheme} | 686 | collapsable={false} |
| 400 | collapsedFileIdsJson={nativeBridge.collapsedFileIdsJson} | 687 | testID="review-native-diff-view" |
| 401 | collapsedCommentIdsJson={nativeBridge.collapsedCommentIdsJson} | 688 | refreshing={isPullRefreshing} |
| 402 | contentWidth={NATIVE_REVIEW_DIFF_CONTENT_WIDTH} | 689 | onPullToRefresh={() => void handlePullToRefresh()} |
| 403 | rowHeight={NATIVE_REVIEW_DIFF_ROW_HEIGHT} | 690 | style={StyleSheet.absoluteFill} |
| 404 | rowsJson={nativeBridge.rowsJson} | 691 | appearanceScheme={selectedTheme} |
| 405 | selectedRowIdsJson={nativeBridge.selectedRowIdsJson} | 692 | collapsedFileIdsJson={nativeBridge.collapsedFileIdsJson} |
| 406 | styleJson={nativeBridge.styleJson} | 693 | collapsedCommentIdsJson={nativeBridge.collapsedCommentIdsJson} |
| 407 | themeJson={nativeBridge.themeJson} | 694 | contentResetKey={`${reviewCache.threadKey}:${selectedSection.id}`} |
| 408 | tokensPatchJson={nativeBridge.tokensPatchJson} | 695 | contentWidth={NATIVE_REVIEW_DIFF_CONTENT_WIDTH} |
| 409 | tokensResetKey={nativeBridge.tokensResetKey} | 696 | nativeViewRef={nativeReviewDiffViewRef} |
| 410 | viewedFileIdsJson={nativeBridge.viewedFileIdsJson} | 697 | rowHeight={nativeReviewDiffStyle.rowHeight} |
| 411 | onDebug={nativeBridge.onDebug} | 698 | rowsJson={nativeBridge.rowsJson} |
| 412 | onPressLine={commentSelection.onPressLine} | 699 | selectedRowIdsJson={nativeBridge.selectedRowIdsJson} |
| 413 | onToggleComment={nativeBridge.onToggleComment} | 700 | styleJson={nativeBridge.styleJson} |
| 414 | onToggleFile={handleNativeToggleFile} | 701 | themeJson={nativeBridge.themeJson} |
| 415 | onToggleViewedFile={handleNativeToggleViewedFile} | 702 | tokensPatchJson={nativeBridge.tokensPatchJson} |
| 416 | /> | 703 | tokensResetKey={nativeBridge.tokensResetKey} |
| 704 | viewedFileIdsJson={nativeBridge.viewedFileIdsJson} | ||
| 705 | onDebug={nativeBridge.onDebug} | ||
| 706 | onPressLine={commentSelection.onPressLine} | ||
| 707 | onVisibleFileChange={handleVisibleFileChange} | ||
| 708 | onToggleComment={nativeBridge.onToggleComment} | ||
| 709 | onToggleFile={handleNativeToggleFile} | ||
| 710 | onToggleViewedFile={handleNativeToggleViewedFile} | ||
| 711 | /> | ||
| 712 | </View> | ||
| 417 | </View> | 713 | </View> |
| 418 | </View> | 714 | </View> |
| 419 | ) : ( | 715 | ) : ( |
| ... | @@ -432,7 +728,7 @@ export function ReviewSheet() { | ... | @@ -432,7 +728,7 @@ export function ReviewSheet() { |
| 432 | {!selectedSection ? ( | 728 | {!selectedSection ? ( |
| 433 | <View className="border-b border-border bg-card px-4 py-5"> | 729 | <View className="border-b border-border bg-card px-4 py-5"> |
| 434 | <Text className="text-sm font-t3-bold text-foreground">No review diffs</Text> | 730 | <Text className="text-sm font-t3-bold text-foreground">No review diffs</Text> |
| 435 | <Text className="text-xs leading-[18px] text-foreground-muted"> | 731 | <Text className="text-xs leading-normal text-foreground-muted"> |
| 436 | This thread has no ready turn diffs and the worktree diff is empty. | 732 | This thread has no ready turn diffs and the worktree diff is empty. |
| 437 | </Text> | 733 | </Text> |
| 438 | </View> | 734 | </View> |
| ... | @@ -444,17 +740,17 @@ export function ReviewSheet() { | ... | @@ -444,17 +740,17 @@ export function ReviewSheet() { |
| 444 | ) : parsedDiff.kind === "empty" ? ( | 740 | ) : parsedDiff.kind === "empty" ? ( |
| 445 | <View className="border-b border-border bg-card px-4 py-5"> | 741 | <View className="border-b border-border bg-card px-4 py-5"> |
| 446 | <Text className="text-sm font-t3-bold text-foreground">No changes</Text> | 742 | <Text className="text-sm font-t3-bold text-foreground">No changes</Text> |
| 447 | <Text className="text-xs leading-[18px] text-foreground-muted"> | 743 | <Text className="text-xs leading-normal text-foreground-muted"> |
| 448 | {selectedSection.subtitle ?? "This diff is empty."} | 744 | {selectedSection.subtitle ?? "This diff is empty."} |
| 449 | </Text> | 745 | </Text> |
| 450 | </View> | 746 | </View> |
| 451 | ) : parsedDiff.kind === "raw" ? ( | 747 | ) : parsedDiff.kind === "raw" ? ( |
| 452 | <View className="gap-3 border-b border-border bg-card px-4 py-4"> | 748 | <View className="gap-3 border-b border-border bg-card px-4 py-4"> |
| 453 | <Text className="text-xs leading-[18px] text-foreground-muted"> | 749 | <Text className="text-xs leading-normal text-foreground-muted"> |
| 454 | {parsedDiff.reason} | 750 | {parsedDiff.reason} |
| 455 | </Text> | 751 | </Text> |
| 456 | <ScrollView horizontal showsHorizontalScrollIndicator={false} bounces={false}> | 752 | <ScrollView horizontal showsHorizontalScrollIndicator={false} bounces={false}> |
| 457 | <Text selectable className="font-mono text-xs leading-[19px] text-foreground"> | 753 | <Text selectable className="font-mono text-xs leading-relaxed text-foreground"> |
| 458 | {parsedDiff.text} | 754 | {parsedDiff.text} |
| 459 | </Text> | 755 | </Text> |
| 460 | </ScrollView> | 756 | </ScrollView> |
apps/mobile/src/features/review/nativeReviewDiffAdapter.test.ts created+56| ... | @@ -0,0 +1,56 @@ | ||
| 1 | import { describe, expect, it } from "vite-plus/test"; | ||
| 2 | |||
| 3 | import { | ||
| 4 | getCachedNativeReviewDiffData, | ||
| 5 | type BuildNativeReviewDiffDataInput, | ||
| 6 | } from "./nativeReviewDiffAdapter"; | ||
| 7 | import type { ReviewInlineComment } from "./reviewCommentSelection"; | ||
| 8 | import { buildReviewParsedDiff } from "./reviewModel"; | ||
| 9 | |||
| 10 | const parsedDiff = buildReviewParsedDiff( | ||
| 11 | [ | ||
| 12 | "diff --git a/example.ts b/example.ts", | ||
| 13 | "--- a/example.ts", | ||
| 14 | "+++ b/example.ts", | ||
| 15 | "@@ -1 +1 @@", | ||
| 16 | "-const before = 1;", | ||
| 17 | "+const after = 2;", | ||
| 18 | ].join("\n"), | ||
| 19 | "native-review-cache-test", | ||
| 20 | ); | ||
| 21 | |||
| 22 | function makeComment(text: string): ReviewInlineComment { | ||
| 23 | return { | ||
| 24 | id: "comment-1", | ||
| 25 | sectionId: "git:working-tree", | ||
| 26 | sectionTitle: "Dirty worktree", | ||
| 27 | filePath: "example.ts", | ||
| 28 | startIndex: 0, | ||
| 29 | endIndex: 0, | ||
| 30 | rangeLabel: "-1", | ||
| 31 | text, | ||
| 32 | diff: "@@ -1,1 +1,0 @@\n-const before = 1;", | ||
| 33 | }; | ||
| 34 | } | ||
| 35 | |||
| 36 | function buildInput(comments: BuildNativeReviewDiffDataInput["comments"]) { | ||
| 37 | return { parsedDiff, comments } satisfies BuildNativeReviewDiffDataInput; | ||
| 38 | } | ||
| 39 | |||
| 40 | describe("getCachedNativeReviewDiffData", () => { | ||
| 41 | it("reuses the row model for equivalent empty comment arrays", () => { | ||
| 42 | const first = getCachedNativeReviewDiffData(buildInput([])); | ||
| 43 | const second = getCachedNativeReviewDiffData(buildInput([])); | ||
| 44 | |||
| 45 | expect(second).toBe(first); | ||
| 46 | }); | ||
| 47 | |||
| 48 | it("reuses equivalent comment contents and invalidates changed comments", () => { | ||
| 49 | const first = getCachedNativeReviewDiffData(buildInput([makeComment("First")])); | ||
| 50 | const equivalent = getCachedNativeReviewDiffData(buildInput([makeComment("First")])); | ||
| 51 | const changed = getCachedNativeReviewDiffData(buildInput([makeComment("Changed")])); | ||
| 52 | |||
| 53 | expect(equivalent).toBe(first); | ||
| 54 | expect(changed).not.toBe(first); | ||
| 55 | }); | ||
| 56 | }); | ||
apps/mobile/src/features/review/nativeReviewDiffAdapter.ts+98-36| ... | @@ -5,6 +5,8 @@ import type { | ... | @@ -5,6 +5,8 @@ import type { |
| 5 | } from "../diffs/nativeReviewDiffTypes"; | 5 | } from "../diffs/nativeReviewDiffTypes"; |
| 6 | import * as Arr from "effect/Array"; | 6 | import * as Arr from "effect/Array"; |
| 7 | import { pipe } from "effect/Function"; | 7 | import { pipe } from "effect/Function"; |
| 8 | import type { ResolvedMobileCodeSurface } from "../../lib/appearancePreferences"; | ||
| 9 | import { resolveMobileCodeSurface } from "../../lib/appearancePreferences"; | ||
| 8 | import { MOBILE_CODE_SURFACE } from "../../lib/typography"; | 10 | import { MOBILE_CODE_SURFACE } from "../../lib/typography"; |
| 9 | import { getPierreTerminalTheme, type TerminalAppearanceScheme } from "../terminal/terminalTheme"; | 11 | import { getPierreTerminalTheme, type TerminalAppearanceScheme } from "../terminal/terminalTheme"; |
| 10 | import { computeWordAltDiffRanges } from "./reviewWordDiffs"; | 12 | import { computeWordAltDiffRanges } from "./reviewWordDiffs"; |
| ... | @@ -22,38 +24,44 @@ const NATIVE_REVIEW_MAX_WORD_DIFF_COVERAGE = 0.45; | ... | @@ -22,38 +24,44 @@ const NATIVE_REVIEW_MAX_WORD_DIFF_COVERAGE = 0.45; |
| 22 | export const NATIVE_REVIEW_DIFF_ROW_HEIGHT = MOBILE_CODE_SURFACE.rowHeight; | 24 | export const NATIVE_REVIEW_DIFF_ROW_HEIGHT = MOBILE_CODE_SURFACE.rowHeight; |
| 23 | export const NATIVE_REVIEW_DIFF_CONTENT_WIDTH = 2_800; | 25 | export const NATIVE_REVIEW_DIFF_CONTENT_WIDTH = 2_800; |
| 24 | 26 | ||
| 25 | export const NATIVE_REVIEW_DIFF_STYLE = { | 27 | export const NATIVE_REVIEW_DIFF_STYLE = createNativeReviewDiffStyle( |
| 26 | rowHeight: NATIVE_REVIEW_DIFF_ROW_HEIGHT, | 28 | resolveMobileCodeSurface(MOBILE_CODE_SURFACE.fontSize), |
| 27 | contentWidth: NATIVE_REVIEW_DIFF_CONTENT_WIDTH, | 29 | ); |
| 28 | changeBarWidth: 4, | 30 | |
| 29 | gutterWidth: MOBILE_CODE_SURFACE.gutterWidth, | 31 | export function createNativeReviewDiffStyle(codeSurface: ResolvedMobileCodeSurface) { |
| 30 | codePadding: MOBILE_CODE_SURFACE.codePadding, | 32 | return { |
| 31 | textVerticalInset: MOBILE_CODE_SURFACE.textVerticalInset, | 33 | rowHeight: codeSurface.rowHeight, |
| 32 | fileHeaderHeight: 56, | 34 | contentWidth: NATIVE_REVIEW_DIFF_CONTENT_WIDTH, |
| 33 | fileHeaderHorizontalMargin: 8, | 35 | changeBarWidth: 4, |
| 34 | fileHeaderVerticalMargin: 6, | 36 | gutterWidth: codeSurface.gutterWidth, |
| 35 | fileHeaderCornerRadius: 10, | 37 | codePadding: codeSurface.codePadding, |
| 36 | fileHeaderHorizontalPadding: 10, | 38 | textVerticalInset: codeSurface.textVerticalInset, |
| 37 | fileHeaderPathRightPadding: 118, | 39 | fileHeaderHeight: 56, |
| 38 | fileHeaderCountColumnWidth: 38, | 40 | fileHeaderHorizontalMargin: 8, |
| 39 | fileHeaderCountGap: 5, | 41 | fileHeaderVerticalMargin: 6, |
| 40 | codeFontSize: MOBILE_CODE_SURFACE.fontSize, | 42 | fileHeaderCornerRadius: 10, |
| 41 | codeFontWeight: "regular", | 43 | fileHeaderHorizontalPadding: 10, |
| 42 | lineNumberFontSize: MOBILE_CODE_SURFACE.lineNumberFontSize, | 44 | fileHeaderPathRightPadding: 118, |
| 43 | lineNumberFontWeight: "regular", | 45 | fileHeaderCountColumnWidth: 38, |
| 44 | hunkFontSize: 11, | 46 | fileHeaderCountGap: 5, |
| 45 | hunkFontWeight: "medium", | 47 | codeFontSize: codeSurface.fontSize, |
| 46 | fileHeaderFontSize: 11, | 48 | codeFontWeight: "regular", |
| 47 | fileHeaderFontWeight: "semibold", | 49 | lineNumberFontSize: codeSurface.lineNumberFontSize, |
| 48 | fileHeaderMetaFontSize: 10, | 50 | lineNumberFontWeight: "regular", |
| 49 | fileHeaderMetaFontWeight: "semibold", | 51 | hunkFontSize: 11, |
| 50 | fileHeaderSubtextFontSize: 11, | 52 | hunkFontWeight: "medium", |
| 51 | fileHeaderSubtextFontWeight: "medium", | 53 | fileHeaderFontSize: 11, |
| 52 | fileHeaderStatusFontSize: 9, | 54 | fileHeaderFontWeight: "semibold", |
| 53 | fileHeaderStatusFontWeight: "bold", | 55 | fileHeaderMetaFontSize: 10, |
| 54 | emptyStateFontSize: 12, | 56 | fileHeaderMetaFontWeight: "semibold", |
| 55 | emptyStateFontWeight: "medium", | 57 | fileHeaderSubtextFontSize: 11, |
| 56 | } as const; | 58 | fileHeaderSubtextFontWeight: "medium", |
| 59 | fileHeaderStatusFontSize: 9, | ||
| 60 | fileHeaderStatusFontWeight: "bold", | ||
| 61 | emptyStateFontSize: 12, | ||
| 62 | emptyStateFontWeight: "medium", | ||
| 63 | } as const; | ||
| 64 | } | ||
| 57 | 65 | ||
| 58 | export interface NativeReviewDiffData { | 66 | export interface NativeReviewDiffData { |
| 59 | readonly rows: ReadonlyArray<NativeReviewDiffRow>; | 67 | readonly rows: ReadonlyArray<NativeReviewDiffRow>; |
| ... | @@ -75,6 +83,33 @@ export interface BuildNativeReviewDiffDataInput { | ... | @@ -75,6 +83,33 @@ export interface BuildNativeReviewDiffDataInput { |
| 75 | readonly comments?: ReadonlyArray<ReviewInlineComment>; | 83 | readonly comments?: ReadonlyArray<ReviewInlineComment>; |
| 76 | } | 84 | } |
| 77 | 85 | ||
| 86 | interface CachedNativeReviewDiffData { | ||
| 87 | readonly commentsKey: string; | ||
| 88 | readonly data: NativeReviewDiffData; | ||
| 89 | } | ||
| 90 | |||
| 91 | const nativeReviewDiffDataCache = new WeakMap<ReviewParsedDiff, CachedNativeReviewDiffData>(); | ||
| 92 | |||
| 93 | function buildReviewCommentsCacheKey(comments: ReadonlyArray<ReviewInlineComment>): string { | ||
| 94 | if (comments.length === 0) { | ||
| 95 | return "none"; | ||
| 96 | } | ||
| 97 | |||
| 98 | return comments | ||
| 99 | .map((comment) => | ||
| 100 | [ | ||
| 101 | comment.id, | ||
| 102 | comment.sectionId, | ||
| 103 | comment.filePath, | ||
| 104 | comment.startIndex, | ||
| 105 | comment.endIndex, | ||
| 106 | comment.rangeLabel, | ||
| 107 | comment.text, | ||
| 108 | ].join("\u001f"), | ||
| 109 | ) | ||
| 110 | .join("\u001e"); | ||
| 111 | } | ||
| 112 | |||
| 78 | export function createNativeReviewDiffTheme( | 113 | export function createNativeReviewDiffTheme( |
| 79 | scheme: TerminalAppearanceScheme, | 114 | scheme: TerminalAppearanceScheme, |
| 80 | ): NativeReviewDiffTheme { | 115 | ): NativeReviewDiffTheme { |
| ... | @@ -83,10 +118,12 @@ export function createNativeReviewDiffTheme( | ... | @@ -83,10 +118,12 @@ export function createNativeReviewDiffTheme( |
| 83 | 118 | ||
| 84 | if (scheme === "dark") { | 119 | if (scheme === "dark") { |
| 85 | return { | 120 | return { |
| 86 | background: terminalTheme.background, | 121 | // Match the app surface (--color-sheet) so code views blend with the rest of |
| 122 | // the app instead of using a distinct code-editor background. | ||
| 123 | background: "#0e0e0e", | ||
| 87 | text: terminalTheme.foreground, | 124 | text: terminalTheme.foreground, |
| 88 | mutedText: terminalTheme.mutedForeground, | 125 | mutedText: terminalTheme.mutedForeground, |
| 89 | headerBackground: terminalTheme.background, | 126 | headerBackground: "#0e0e0e", |
| 90 | border: terminalTheme.border, | 127 | border: terminalTheme.border, |
| 91 | hunkBackground: "#071f28", | 128 | hunkBackground: "#071f28", |
| 92 | hunkText: terminalBlue ?? "#009fff", | 129 | hunkText: terminalBlue ?? "#009fff", |
| ... | @@ -100,10 +137,12 @@ export function createNativeReviewDiffTheme( | ... | @@ -100,10 +137,12 @@ export function createNativeReviewDiffTheme( |
| 100 | } | 137 | } |
| 101 | 138 | ||
| 102 | return { | 139 | return { |
| 103 | background: "#ffffff", | 140 | // Match the app surface (--color-sheet) so code views blend with the rest of the |
| 141 | // app instead of using a distinct code-editor background. | ||
| 142 | background: "#f2f2f7", | ||
| 104 | text: "#070707", | 143 | text: "#070707", |
| 105 | mutedText: terminalTheme.mutedForeground, | 144 | mutedText: terminalTheme.mutedForeground, |
| 106 | headerBackground: "#ffffff", | 145 | headerBackground: "#f2f2f7", |
| 107 | border: terminalTheme.border, | 146 | border: terminalTheme.border, |
| 108 | hunkBackground: "#e0f2ff", | 147 | hunkBackground: "#e0f2ff", |
| 109 | hunkText: terminalBlue ?? "#009fff", | 148 | hunkText: terminalBlue ?? "#009fff", |
| ... | @@ -430,3 +469,26 @@ export function buildNativeReviewDiffData( | ... | @@ -430,3 +469,26 @@ export function buildNativeReviewDiffData( |
| 430 | deletions: parsedDiff.deletions, | 469 | deletions: parsedDiff.deletions, |
| 431 | }; | 470 | }; |
| 432 | } | 471 | } |
| 472 | |||
| 473 | /** | ||
| 474 | * Reuses the expensive flattened native row model across React development | ||
| 475 | * render probes and unrelated draft updates. Only the latest comment version | ||
| 476 | * is retained for each parsed diff so editing a comment cannot grow the cache. | ||
| 477 | */ | ||
| 478 | export function getCachedNativeReviewDiffData( | ||
| 479 | input: BuildNativeReviewDiffDataInput, | ||
| 480 | ): NativeReviewDiffData { | ||
| 481 | const comments = input.comments ?? []; | ||
| 482 | const commentsKey = buildReviewCommentsCacheKey(comments); | ||
| 483 | const cached = nativeReviewDiffDataCache.get(input.parsedDiff); | ||
| 484 | if (cached?.commentsKey === commentsKey) { | ||
| 485 | return cached.data; | ||
| 486 | } | ||
| 487 | |||
| 488 | const data = buildNativeReviewDiffData({ | ||
| 489 | parsedDiff: input.parsedDiff, | ||
| 490 | comments, | ||
| 491 | }); | ||
| 492 | nativeReviewDiffDataCache.set(input.parsedDiff, { commentsKey, data }); | ||
| 493 | return data; | ||
| 494 | } |
apps/mobile/src/features/review/review-section-menu.test.ts created+40| ... | @@ -0,0 +1,40 @@ | ||
| 1 | import { describe, expect, it } from "vite-plus/test"; | ||
| 2 | |||
| 3 | import type { ReviewSectionItem, ReviewSectionKind } from "./reviewModel"; | ||
| 4 | import { buildReviewSectionMenu } from "./review-section-menu"; | ||
| 5 | |||
| 6 | function section(id: string, kind: ReviewSectionKind): ReviewSectionItem { | ||
| 7 | return { | ||
| 8 | id, | ||
| 9 | kind, | ||
| 10 | title: id, | ||
| 11 | subtitle: null, | ||
| 12 | diff: null, | ||
| 13 | isLoading: false, | ||
| 14 | }; | ||
| 15 | } | ||
| 16 | |||
| 17 | describe("buildReviewSectionMenu", () => { | ||
| 18 | it("exposes git scopes and the latest turn at the top level", () => { | ||
| 19 | const turn28 = section("turn:28", "turn"); | ||
| 20 | const turn27 = section("turn:27", "turn"); | ||
| 21 | const workingTree = section("git:working-tree", "working-tree"); | ||
| 22 | const branchChanges = section("git:branch-range", "branch-range"); | ||
| 23 | |||
| 24 | expect(buildReviewSectionMenu([turn28, turn27, workingTree, branchChanges])).toEqual({ | ||
| 25 | workingTree, | ||
| 26 | branchChanges, | ||
| 27 | latestTurn: turn28, | ||
| 28 | turns: [turn28, turn27], | ||
| 29 | }); | ||
| 30 | }); | ||
| 31 | |||
| 32 | it("keeps unavailable scopes empty while data loads", () => { | ||
| 33 | expect(buildReviewSectionMenu([])).toEqual({ | ||
| 34 | workingTree: null, | ||
| 35 | branchChanges: null, | ||
| 36 | latestTurn: null, | ||
| 37 | turns: [], | ||
| 38 | }); | ||
| 39 | }); | ||
| 40 | }); | ||
apps/mobile/src/features/review/review-section-menu.ts created+21| ... | @@ -0,0 +1,21 @@ | ||
| 1 | import type { ReviewSectionItem } from "./reviewModel"; | ||
| 2 | |||
| 3 | export interface ReviewSectionMenu { | ||
| 4 | readonly workingTree: ReviewSectionItem | null; | ||
| 5 | readonly branchChanges: ReviewSectionItem | null; | ||
| 6 | readonly latestTurn: ReviewSectionItem | null; | ||
| 7 | readonly turns: ReadonlyArray<ReviewSectionItem>; | ||
| 8 | } | ||
| 9 | |||
| 10 | export function buildReviewSectionMenu( | ||
| 11 | sections: ReadonlyArray<ReviewSectionItem>, | ||
| 12 | ): ReviewSectionMenu { | ||
| 13 | const turns = sections.filter((section) => section.kind === "turn"); | ||
| 14 | |||
| 15 | return { | ||
| 16 | workingTree: sections.find((section) => section.kind === "working-tree") ?? null, | ||
| 17 | branchChanges: sections.find((section) => section.kind === "branch-range") ?? null, | ||
| 18 | latestTurn: turns[0] ?? null, | ||
| 19 | turns, | ||
| 20 | }; | ||
| 21 | } | ||
apps/mobile/src/features/review/reviewDiffBridgeKeys.test.ts created+27| ... | @@ -0,0 +1,27 @@ | ||
| 1 | import { describe, expect, it } from "vite-plus/test"; | ||
| 2 | |||
| 3 | import { buildNativeReviewTokensResetKey, hashReviewDiffKey } from "./reviewDiffBridgeKeys"; | ||
| 4 | |||
| 5 | describe("native review diff bridge", () => { | ||
| 6 | it("builds stable reset keys from the rendered diff identity", () => { | ||
| 7 | const input = { | ||
| 8 | threadKey: "env:thread", | ||
| 9 | sectionId: "turn:2", | ||
| 10 | scheme: "dark" as const, | ||
| 11 | diff: "diff --git a/a.ts b/a.ts", | ||
| 12 | fileCount: 1, | ||
| 13 | rowCount: 4, | ||
| 14 | }; | ||
| 15 | |||
| 16 | expect(buildNativeReviewTokensResetKey(input)).toBe(buildNativeReviewTokensResetKey(input)); | ||
| 17 | expect(buildNativeReviewTokensResetKey({ ...input, rowCount: 5 })).not.toBe( | ||
| 18 | buildNativeReviewTokensResetKey(input), | ||
| 19 | ); | ||
| 20 | expect(buildNativeReviewTokensResetKey({ ...input, diff: null })).toContain(":empty:"); | ||
| 21 | }); | ||
| 22 | |||
| 23 | it("includes diff length in the hash key to reduce accidental collisions", () => { | ||
| 24 | expect(hashReviewDiffKey("abc")).toMatch(/^3:/); | ||
| 25 | expect(hashReviewDiffKey("abcd")).toMatch(/^4:/); | ||
| 26 | }); | ||
| 27 | }); | ||
apps/mobile/src/features/review/reviewDiffBridgeKeys.ts created+35| ... | @@ -0,0 +1,35 @@ | ||
| 1 | import type { NativeReviewDiffHighlightScheme } from "../diffs/nativeReviewDiffHighlighter"; | ||
| 2 | |||
| 3 | // Pure key-derivation helpers for the native review diff bridge. Kept free of | ||
| 4 | // react-native / hook imports so they stay unit-testable in node. | ||
| 5 | |||
| 6 | export function hashReviewDiffKey(diff: string | null | undefined): string { | ||
| 7 | if (!diff) { | ||
| 8 | return "empty"; | ||
| 9 | } | ||
| 10 | |||
| 11 | let hash = 5381; | ||
| 12 | for (let index = 0; index < diff.length; index += 1) { | ||
| 13 | hash = (hash * 33) ^ diff.charCodeAt(index); | ||
| 14 | } | ||
| 15 | |||
| 16 | return `${diff.length}:${(hash >>> 0).toString(36)}`; | ||
| 17 | } | ||
| 18 | |||
| 19 | export function buildNativeReviewTokensResetKey(input: { | ||
| 20 | readonly threadKey: string | null; | ||
| 21 | readonly sectionId: string | null; | ||
| 22 | readonly scheme: NativeReviewDiffHighlightScheme; | ||
| 23 | readonly diff: string | null | undefined; | ||
| 24 | readonly fileCount: number; | ||
| 25 | readonly rowCount: number; | ||
| 26 | }): string { | ||
| 27 | return [ | ||
| 28 | input.threadKey ?? "none", | ||
| 29 | input.sectionId ?? "none", | ||
| 30 | input.scheme, | ||
| 31 | hashReviewDiffKey(input.diff), | ||
| 32 | input.fileCount, | ||
| 33 | input.rowCount, | ||
| 34 | ].join(":"); | ||
| 35 | } | ||
apps/mobile/src/features/review/reviewDiffRendering.tsx+16-9| ... | @@ -13,7 +13,6 @@ export const REVIEW_MONO_FONT_FAMILY = Platform.select({ | ... | @@ -13,7 +13,6 @@ export const REVIEW_MONO_FONT_FAMILY = Platform.select({ |
| 13 | }); | 13 | }); |
| 14 | 14 | ||
| 15 | export const REVIEW_DIFF_LINE_HEIGHT = MOBILE_CODE_SURFACE.rowHeight; | 15 | export const REVIEW_DIFF_LINE_HEIGHT = MOBILE_CODE_SURFACE.rowHeight; |
| 16 | const REVIEW_DELETE_STRIPE_COUNT = REVIEW_DIFF_LINE_HEIGHT / 2; | ||
| 17 | 16 | ||
| 18 | export function renderVisibleWhitespace(value: string): string { | 17 | export function renderVisibleWhitespace(value: string): string { |
| 19 | const expandedTabs = value.replace(/\t/g, " "); | 18 | const expandedTabs = value.replace(/\t/g, " "); |
| ... | @@ -38,12 +37,16 @@ function diffHighlightColor(change: ReviewRenderableLineRow["change"]): string | | ... | @@ -38,12 +37,16 @@ function diffHighlightColor(change: ReviewRenderableLineRow["change"]): string | |
| 38 | return undefined; | 37 | return undefined; |
| 39 | } | 38 | } |
| 40 | 39 | ||
| 41 | export function ReviewChangeBar(props: { readonly change: ReviewRenderableLineRow["change"] }) { | 40 | export function ReviewChangeBar(props: { |
| 41 | readonly change: ReviewRenderableLineRow["change"]; | ||
| 42 | readonly height?: number; | ||
| 43 | }) { | ||
| 44 | const height = props.height ?? REVIEW_DIFF_LINE_HEIGHT; | ||
| 42 | if (props.change === "delete") { | 45 | if (props.change === "delete") { |
| 43 | return ( | 46 | return ( |
| 44 | <View className="w-[5px] overflow-hidden" style={{ height: REVIEW_DIFF_LINE_HEIGHT }}> | 47 | <View className="w-[5px] overflow-hidden" style={{ height }}> |
| 45 | <View> | 48 | <View> |
| 46 | {Array.from({ length: REVIEW_DELETE_STRIPE_COUNT }, (_, index) => ( | 49 | {Array.from({ length: Math.ceil(height / 2) }, (_, index) => ( |
| 47 | <View key={index}> | 50 | <View key={index}> |
| 48 | <View className="w-[5px] bg-rose-400" style={{ height: 1 }} /> | 51 | <View className="w-[5px] bg-rose-400" style={{ height: 1 }} /> |
| 49 | <View style={{ height: 1 }} /> | 52 | <View style={{ height: 1 }} /> |
| ... | @@ -55,7 +58,7 @@ export function ReviewChangeBar(props: { readonly change: ReviewRenderableLineRo | ... | @@ -55,7 +58,7 @@ export function ReviewChangeBar(props: { readonly change: ReviewRenderableLineRo |
| 55 | } | 58 | } |
| 56 | 59 | ||
| 57 | return ( | 60 | return ( |
| 58 | <View className="w-[5px] overflow-hidden" style={{ height: REVIEW_DIFF_LINE_HEIGHT }}> | 61 | <View className="w-[5px] overflow-hidden" style={{ height }}> |
| 59 | <View className={cn("h-full w-[5px] flex-1", changeBarTone(props.change))} /> | 62 | <View className={cn("h-full w-[5px] flex-1", changeBarTone(props.change))} /> |
| 60 | </View> | 63 | </View> |
| 61 | ); | 64 | ); |
| ... | @@ -66,7 +69,11 @@ export function DiffTokenText(props: { | ... | @@ -66,7 +69,11 @@ export function DiffTokenText(props: { |
| 66 | readonly fallback: string; | 69 | readonly fallback: string; |
| 67 | readonly change?: ReviewRenderableLineRow["change"]; | 70 | readonly change?: ReviewRenderableLineRow["change"]; |
| 68 | readonly className?: string; | 71 | readonly className?: string; |
| 72 | readonly fontSize?: number; | ||
| 73 | readonly lineHeight?: number; | ||
| 69 | }) { | 74 | }) { |
| 75 | const fontSize = props.fontSize ?? MOBILE_CODE_SURFACE.fontSize; | ||
| 76 | const lineHeight = props.lineHeight ?? MOBILE_CODE_SURFACE.rowHeight; | ||
| 70 | if (!props.tokens || props.tokens.length === 0) { | 77 | if (!props.tokens || props.tokens.length === 0) { |
| 71 | return ( | 78 | return ( |
| 72 | <NativeText | 79 | <NativeText |
| ... | @@ -75,8 +82,8 @@ export function DiffTokenText(props: { | ... | @@ -75,8 +82,8 @@ export function DiffTokenText(props: { |
| 75 | className={cn("font-normal text-foreground", props.className)} | 82 | className={cn("font-normal text-foreground", props.className)} |
| 76 | style={{ | 83 | style={{ |
| 77 | fontFamily: REVIEW_MONO_FONT_FAMILY, | 84 | fontFamily: REVIEW_MONO_FONT_FAMILY, |
| 78 | fontSize: MOBILE_CODE_SURFACE.fontSize, | 85 | fontSize, |
| 79 | lineHeight: MOBILE_CODE_SURFACE.rowHeight, | 86 | lineHeight, |
| 80 | }} | 87 | }} |
| 81 | > | 88 | > |
| 82 | {renderVisibleWhitespace(props.fallback || " ")} | 89 | {renderVisibleWhitespace(props.fallback || " ")} |
| ... | @@ -91,8 +98,8 @@ export function DiffTokenText(props: { | ... | @@ -91,8 +98,8 @@ export function DiffTokenText(props: { |
| 91 | className={cn("font-normal text-foreground", props.className)} | 98 | className={cn("font-normal text-foreground", props.className)} |
| 92 | style={{ | 99 | style={{ |
| 93 | fontFamily: REVIEW_MONO_FONT_FAMILY, | 100 | fontFamily: REVIEW_MONO_FONT_FAMILY, |
| 94 | fontSize: MOBILE_CODE_SURFACE.fontSize, | 101 | fontSize, |
| 95 | lineHeight: MOBILE_CODE_SURFACE.rowHeight, | 102 | lineHeight, |
| 96 | }} | 103 | }} |
| 97 | > | 104 | > |
| 98 | {(() => { | 105 | {(() => { |
apps/mobile/src/features/review/reviewPaneSelection.test.ts created+35| ... | @@ -0,0 +1,35 @@ | ||
| 1 | import { describe, expect, it } from "vite-plus/test"; | ||
| 2 | |||
| 3 | import { resolveSelectedReviewFileId } from "./reviewPaneSelection"; | ||
| 4 | |||
| 5 | describe("resolveSelectedReviewFileId", () => { | ||
| 6 | it("keeps a visible file selected within the active section", () => { | ||
| 7 | expect( | ||
| 8 | resolveSelectedReviewFileId({ | ||
| 9 | selection: { sectionId: "worktree", fileId: "second" }, | ||
| 10 | sectionId: "worktree", | ||
| 11 | availableFileIds: ["first", "second"], | ||
| 12 | }), | ||
| 13 | ).toBe("second"); | ||
| 14 | }); | ||
| 15 | |||
| 16 | it("clears selection when the review section changes", () => { | ||
| 17 | expect( | ||
| 18 | resolveSelectedReviewFileId({ | ||
| 19 | selection: { sectionId: "turn-1", fileId: "first" }, | ||
| 20 | sectionId: "turn-2", | ||
| 21 | availableFileIds: ["first"], | ||
| 22 | }), | ||
| 23 | ).toBeNull(); | ||
| 24 | }); | ||
| 25 | |||
| 26 | it("clears a file that no longer exists in the diff", () => { | ||
| 27 | expect( | ||
| 28 | resolveSelectedReviewFileId({ | ||
| 29 | selection: { sectionId: "worktree", fileId: "removed" }, | ||
| 30 | sectionId: "worktree", | ||
| 31 | availableFileIds: ["first", "second"], | ||
| 32 | }), | ||
| 33 | ).toBeNull(); | ||
| 34 | }); | ||
| 35 | }); | ||
apps/mobile/src/features/review/reviewPaneSelection.ts created+16| ... | @@ -0,0 +1,16 @@ | ||
| 1 | export interface ReviewPaneFileSelection { | ||
| 2 | readonly sectionId: string | null; | ||
| 3 | readonly fileId: string | null; | ||
| 4 | } | ||
| 5 | |||
| 6 | export function resolveSelectedReviewFileId(input: { | ||
| 7 | readonly selection: ReviewPaneFileSelection; | ||
| 8 | readonly sectionId: string | null; | ||
| 9 | readonly availableFileIds: ReadonlyArray<string>; | ||
| 10 | }): string | null { | ||
| 11 | if (input.selection.sectionId !== input.sectionId || input.selection.fileId === null) { | ||
| 12 | return null; | ||
| 13 | } | ||
| 14 | |||
| 15 | return input.availableFileIds.includes(input.selection.fileId) ? input.selection.fileId : null; | ||
| 16 | } | ||
apps/mobile/src/features/review/useNativeReviewDiffBridge.test.ts deleted-27| ... | @@ -1,27 +0,0 @@ | ||
| 1 | import { describe, expect, it } from "vite-plus/test"; | ||
| 2 | |||
| 3 | import { buildNativeReviewTokensResetKey, hashReviewDiffKey } from "./useNativeReviewDiffBridge"; | ||
| 4 | |||
| 5 | describe("native review diff bridge", () => { | ||
| 6 | it("builds stable reset keys from the rendered diff identity", () => { | ||
| 7 | const input = { | ||
| 8 | threadKey: "env:thread", | ||
| 9 | sectionId: "turn:2", | ||
| 10 | scheme: "dark" as const, | ||
| 11 | diff: "diff --git a/a.ts b/a.ts", | ||
| 12 | fileCount: 1, | ||
| 13 | rowCount: 4, | ||
| 14 | }; | ||
| 15 | |||
| 16 | expect(buildNativeReviewTokensResetKey(input)).toBe(buildNativeReviewTokensResetKey(input)); | ||
| 17 | expect(buildNativeReviewTokensResetKey({ ...input, rowCount: 5 })).not.toBe( | ||
| 18 | buildNativeReviewTokensResetKey(input), | ||
| 19 | ); | ||
| 20 | expect(buildNativeReviewTokensResetKey({ ...input, diff: null })).toContain(":empty:"); | ||
| 21 | }); | ||
| 22 | |||
| 23 | it("includes diff length in the hash key to reduce accidental collisions", () => { | ||
| 24 | expect(hashReviewDiffKey("abc")).toMatch(/^3:/); | ||
| 25 | expect(hashReviewDiffKey("abcd")).toMatch(/^4:/); | ||
| 26 | }); | ||
| 27 | }); | ||
apps/mobile/src/features/review/useNativeReviewDiffBridge.ts+6-36| ... | @@ -2,43 +2,12 @@ import { useCallback, useMemo, useState } from "react"; | ... | @@ -2,43 +2,12 @@ import { useCallback, useMemo, useState } from "react"; |
| 2 | import type { NativeSyntheticEvent } from "react-native"; | 2 | import type { NativeSyntheticEvent } from "react-native"; |
| 3 | 3 | ||
| 4 | import { type NativeReviewDiffHighlightScheme } from "../diffs/nativeReviewDiffHighlighter"; | 4 | import { type NativeReviewDiffHighlightScheme } from "../diffs/nativeReviewDiffHighlighter"; |
| 5 | import { | 5 | import { createNativeReviewDiffTheme, type NativeReviewDiffData } from "./nativeReviewDiffAdapter"; |
| 6 | createNativeReviewDiffTheme, | 6 | import { useAppearanceCodeSurface } from "../settings/appearance/useAppearanceCodeSurface"; |
| 7 | NATIVE_REVIEW_DIFF_STYLE, | ||
| 8 | type NativeReviewDiffData, | ||
| 9 | } from "./nativeReviewDiffAdapter"; | ||
| 10 | import { useNativeReviewDiffHighlighting } from "./useNativeReviewDiffHighlighting"; | 7 | import { useNativeReviewDiffHighlighting } from "./useNativeReviewDiffHighlighting"; |
| 8 | import { buildNativeReviewTokensResetKey } from "./reviewDiffBridgeKeys"; | ||
| 11 | 9 | ||
| 12 | export function hashReviewDiffKey(diff: string | null | undefined): string { | 10 | export { buildNativeReviewTokensResetKey, hashReviewDiffKey } from "./reviewDiffBridgeKeys"; |
| 13 | if (!diff) { | ||
| 14 | return "empty"; | ||
| 15 | } | ||
| 16 | |||
| 17 | let hash = 5381; | ||
| 18 | for (let index = 0; index < diff.length; index += 1) { | ||
| 19 | hash = (hash * 33) ^ diff.charCodeAt(index); | ||
| 20 | } | ||
| 21 | |||
| 22 | return `${diff.length}:${(hash >>> 0).toString(36)}`; | ||
| 23 | } | ||
| 24 | |||
| 25 | export function buildNativeReviewTokensResetKey(input: { | ||
| 26 | readonly threadKey: string | null; | ||
| 27 | readonly sectionId: string | null; | ||
| 28 | readonly scheme: NativeReviewDiffHighlightScheme; | ||
| 29 | readonly diff: string | null | undefined; | ||
| 30 | readonly fileCount: number; | ||
| 31 | readonly rowCount: number; | ||
| 32 | }): string { | ||
| 33 | return [ | ||
| 34 | input.threadKey ?? "none", | ||
| 35 | input.sectionId ?? "none", | ||
| 36 | input.scheme, | ||
| 37 | hashReviewDiffKey(input.diff), | ||
| 38 | input.fileCount, | ||
| 39 | input.rowCount, | ||
| 40 | ].join(":"); | ||
| 41 | } | ||
| 42 | 11 | ||
| 43 | export function useNativeReviewDiffBridge(input: { | 12 | export function useNativeReviewDiffBridge(input: { |
| 44 | readonly threadKey: string | null; | 13 | readonly threadKey: string | null; |
| ... | @@ -62,6 +31,7 @@ export function useNativeReviewDiffBridge(input: { | ... | @@ -62,6 +31,7 @@ export function useNativeReviewDiffBridge(input: { |
| 62 | threadKey, | 31 | threadKey, |
| 63 | viewedFileIds, | 32 | viewedFileIds, |
| 64 | } = input; | 33 | } = input; |
| 34 | const { nativeReviewDiffStyle } = useAppearanceCodeSurface(); | ||
| 65 | const [collapsedCommentIds, setCollapsedCommentIds] = useState<ReadonlySet<string>>( | 35 | const [collapsedCommentIds, setCollapsedCommentIds] = useState<ReadonlySet<string>>( |
| 66 | () => new Set(), | 36 | () => new Set(), |
| 67 | ); | 37 | ); |
| ... | @@ -76,7 +46,7 @@ export function useNativeReviewDiffBridge(input: { | ... | @@ -76,7 +46,7 @@ export function useNativeReviewDiffBridge(input: { |
| 76 | [collapsedCommentIds], | 46 | [collapsedCommentIds], |
| 77 | ); | 47 | ); |
| 78 | const themeJson = useMemo(() => JSON.stringify(theme), [theme]); | 48 | const themeJson = useMemo(() => JSON.stringify(theme), [theme]); |
| 79 | const styleJson = useMemo(() => JSON.stringify(NATIVE_REVIEW_DIFF_STYLE), []); | 49 | const styleJson = useMemo(() => JSON.stringify(nativeReviewDiffStyle), [nativeReviewDiffStyle]); |
| 80 | const tokensResetKey = useMemo( | 50 | const tokensResetKey = useMemo( |
| 81 | () => | 51 | () => |
| 82 | buildNativeReviewTokensResetKey({ | 52 | buildNativeReviewTokensResetKey({ |
apps/mobile/src/features/review/useReviewCommentSelectionController.ts+6-7| ... | @@ -1,12 +1,11 @@ | ... | @@ -1,12 +1,11 @@ |
| 1 | import { useCallback, useEffect, useMemo, useState } from "react"; | 1 | import { useCallback, useEffect, useMemo, useState } from "react"; |
| 2 | import type { NativeSyntheticEvent } from "react-native"; | 2 | import type { NativeSyntheticEvent } from "react-native"; |
| 3 | import { useRouter } from "expo-router"; | 3 | import { useNavigation } from "@react-navigation/native"; |
| 4 | import * as Arr from "effect/Array"; | 4 | import * as Arr from "effect/Array"; |
| 5 | import { pipe } from "effect/Function"; | 5 | import { pipe } from "effect/Function"; |
| 6 | import * as Result from "effect/Result"; | 6 | import * as Result from "effect/Result"; |
| 7 | 7 | ||
| 8 | import type { EnvironmentId, ThreadId } from "@t3tools/contracts"; | 8 | import type { EnvironmentId, ThreadId } from "@t3tools/contracts"; |
| 9 | |||
| 10 | import { | 9 | import { |
| 11 | buildReviewCommentTarget, | 10 | buildReviewCommentTarget, |
| 12 | clearReviewCommentTarget, | 11 | clearReviewCommentTarget, |
| ... | @@ -34,7 +33,7 @@ export function useReviewCommentSelectionController(input: { | ... | @@ -34,7 +33,7 @@ export function useReviewCommentSelectionController(input: { |
| 34 | readonly nativeReviewDiffData: NativeReviewDiffData; | 33 | readonly nativeReviewDiffData: NativeReviewDiffData; |
| 35 | }) { | 34 | }) { |
| 36 | const { environmentId, nativeReviewDiffData, selectedSection, threadId } = input; | 35 | const { environmentId, nativeReviewDiffData, selectedSection, threadId } = input; |
| 37 | const { push } = useRouter(); | 36 | const navigation = useNavigation(); |
| 38 | const activeCommentTarget = useReviewCommentTarget(); | 37 | const activeCommentTarget = useReviewCommentTarget(); |
| 39 | const [pendingNativeCommentSelection, setPendingNativeCommentSelection] = | 38 | const [pendingNativeCommentSelection, setPendingNativeCommentSelection] = |
| 40 | useState<PendingNativeCommentSelection | null>(null); | 39 | useState<PendingNativeCommentSelection | null>(null); |
| ... | @@ -44,11 +43,11 @@ export function useReviewCommentSelectionController(input: { | ... | @@ -44,11 +43,11 @@ export function useReviewCommentSelectionController(input: { |
| 44 | return; | 43 | return; |
| 45 | } | 44 | } |
| 46 | 45 | ||
| 47 | push({ | 46 | navigation.navigate("ThreadReviewComment", { |
| 48 | pathname: "/threads/[environmentId]/[threadId]/review-comment", | 47 | environmentId, |
| 49 | params: { environmentId, threadId }, | 48 | threadId, |
| 50 | }); | 49 | }); |
| 51 | }, [environmentId, push, threadId]); | 50 | }, [environmentId, navigation, threadId]); |
| 52 | 51 | ||
| 53 | const selectedRowIds = useMemo(() => { | 52 | const selectedRowIds = useMemo(() => { |
| 54 | if ( | 53 | if ( |
apps/mobile/src/features/review/useReviewDiffData.ts+11-9| ... | @@ -1,11 +1,13 @@ | ... | @@ -1,11 +1,13 @@ |
| 1 | import { useEffect, useMemo } from "react"; | 1 | import { useEffect, useMemo } from "react"; |
| 2 | 2 | ||
| 3 | import { countReviewCommentContexts, parseReviewInlineComments } from "./reviewCommentSelection"; | 3 | import { countReviewCommentContexts, parseReviewInlineComments } from "./reviewCommentSelection"; |
| 4 | import { buildNativeReviewDiffData } from "./nativeReviewDiffAdapter"; | 4 | import { getCachedNativeReviewDiffData } from "./nativeReviewDiffAdapter"; |
| 5 | import { markReviewEvent, measureReviewWork } from "./reviewPerf"; | 5 | import { markReviewEvent, measureReviewWork } from "./reviewPerf"; |
| 6 | import { getCachedReviewParsedDiff } from "./reviewState"; | 6 | import { getCachedReviewParsedDiff } from "./reviewState"; |
| 7 | import type { ReviewParsedDiff, ReviewSectionItem } from "./reviewModel"; | 7 | import type { ReviewParsedDiff, ReviewSectionItem } from "./reviewModel"; |
| 8 | 8 | ||
| 9 | const EMPTY_INLINE_REVIEW_COMMENTS = Object.freeze([]); | ||
| 10 | |||
| 9 | function isReviewDiffDebugLoggingEnabled(): boolean { | 11 | function isReviewDiffDebugLoggingEnabled(): boolean { |
| 10 | return typeof __DEV__ !== "undefined" ? __DEV__ : false; | 12 | return typeof __DEV__ !== "undefined" ? __DEV__ : false; |
| 11 | } | 13 | } |
| ... | @@ -43,6 +45,7 @@ export function useReviewDiffData(input: { | ... | @@ -43,6 +45,7 @@ export function useReviewDiffData(input: { |
| 43 | readonly draftMessage: string; | 45 | readonly draftMessage: string; |
| 44 | }) { | 46 | }) { |
| 45 | const { draftMessage, selectedSection, threadKey } = input; | 47 | const { draftMessage, selectedSection, threadKey } = input; |
| 48 | const selectedSectionId = selectedSection?.id ?? null; | ||
| 46 | const parsedDiff = useMemo( | 49 | const parsedDiff = useMemo( |
| 47 | () => | 50 | () => |
| 48 | measureReviewWork("parse-diff", () => | 51 | measureReviewWork("parse-diff", () => |
| ... | @@ -59,17 +62,16 @@ export function useReviewDiffData(input: { | ... | @@ -59,17 +62,16 @@ export function useReviewDiffData(input: { |
| 59 | () => parseReviewInlineComments(draftMessage), | 62 | () => parseReviewInlineComments(draftMessage), |
| 60 | [draftMessage], | 63 | [draftMessage], |
| 61 | ); | 64 | ); |
| 62 | const selectedSectionInlineComments = useMemo( | 65 | const selectedSectionInlineComments = useMemo(() => { |
| 63 | () => | 66 | if (!selectedSectionId || inlineReviewComments.length === 0) { |
| 64 | selectedSection | 67 | return EMPTY_INLINE_REVIEW_COMMENTS; |
| 65 | ? inlineReviewComments.filter((comment) => comment.sectionId === selectedSection.id) | 68 | } |
| 66 | : [], | 69 | return inlineReviewComments.filter((comment) => comment.sectionId === selectedSectionId); |
| 67 | [inlineReviewComments, selectedSection], | 70 | }, [inlineReviewComments, selectedSectionId]); |
| 68 | ); | ||
| 69 | const nativeReviewDiffData = useMemo( | 71 | const nativeReviewDiffData = useMemo( |
| 70 | () => | 72 | () => |
| 71 | measureReviewWork("build-native-diff-data", () => | 73 | measureReviewWork("build-native-diff-data", () => |
| 72 | buildNativeReviewDiffData({ | 74 | getCachedNativeReviewDiffData({ |
| 73 | parsedDiff, | 75 | parsedDiff, |
| 74 | comments: selectedSectionInlineComments, | 76 | comments: selectedSectionInlineComments, |
| 75 | }), | 77 | }), |
apps/mobile/src/features/review/useReviewDiffPrewarming.ts created+101| ... | @@ -0,0 +1,101 @@ | ||
| 1 | import { useEffect } from "react"; | ||
| 2 | |||
| 3 | import { getCachedNativeReviewDiffData } from "./nativeReviewDiffAdapter"; | ||
| 4 | import type { ReviewSectionItem } from "./reviewModel"; | ||
| 5 | import { getCachedReviewParsedDiff } from "./reviewState"; | ||
| 6 | |||
| 7 | interface IdleDeadlineLike { | ||
| 8 | readonly didTimeout: boolean; | ||
| 9 | timeRemaining(): number; | ||
| 10 | } | ||
| 11 | |||
| 12 | type IdleCallback = (deadline: IdleDeadlineLike) => void; | ||
| 13 | |||
| 14 | function scheduleIdle(callback: IdleCallback): number { | ||
| 15 | if (typeof globalThis.requestIdleCallback === "function") { | ||
| 16 | return globalThis.requestIdleCallback(callback, { timeout: 2_000 }); | ||
| 17 | } | ||
| 18 | |||
| 19 | return setTimeout( | ||
| 20 | () => callback({ didTimeout: true, timeRemaining: () => 0 }), | ||
| 21 | 100, | ||
| 22 | ) as unknown as number; | ||
| 23 | } | ||
| 24 | |||
| 25 | function cancelIdle(handle: number): void { | ||
| 26 | if (typeof globalThis.cancelIdleCallback === "function") { | ||
| 27 | globalThis.cancelIdleCallback(handle); | ||
| 28 | return; | ||
| 29 | } | ||
| 30 | clearTimeout(handle); | ||
| 31 | } | ||
| 32 | |||
| 33 | export function prewarmReviewDiffSection(input: { | ||
| 34 | readonly threadKey: string; | ||
| 35 | readonly section: ReviewSectionItem; | ||
| 36 | }): void { | ||
| 37 | const { section, threadKey } = input; | ||
| 38 | if (section.diff === null) { | ||
| 39 | return; | ||
| 40 | } | ||
| 41 | |||
| 42 | const parsedDiff = getCachedReviewParsedDiff({ | ||
| 43 | threadKey, | ||
| 44 | sectionId: section.id, | ||
| 45 | diff: section.diff, | ||
| 46 | }); | ||
| 47 | getCachedNativeReviewDiffData({ parsedDiff, comments: [] }); | ||
| 48 | } | ||
| 49 | |||
| 50 | /** Warms one cached section per idle period, after navigation animations finish. */ | ||
| 51 | export function useReviewDiffPrewarming(input: { | ||
| 52 | readonly threadKey: string | null; | ||
| 53 | readonly sections: ReadonlyArray<ReviewSectionItem>; | ||
| 54 | readonly selectedSectionId: string | null; | ||
| 55 | }): void { | ||
| 56 | const { sections, selectedSectionId, threadKey } = input; | ||
| 57 | |||
| 58 | useEffect(() => { | ||
| 59 | if (!threadKey) { | ||
| 60 | return; | ||
| 61 | } | ||
| 62 | |||
| 63 | const pendingSections = sections.filter( | ||
| 64 | (section) => section.id !== selectedSectionId && section.diff !== null, | ||
| 65 | ); | ||
| 66 | if (pendingSections.length === 0) { | ||
| 67 | return; | ||
| 68 | } | ||
| 69 | |||
| 70 | let cancelled = false; | ||
| 71 | let idleHandle: number | null = null; | ||
| 72 | let nextSectionIndex = 0; | ||
| 73 | |||
| 74 | const scheduleNext = () => { | ||
| 75 | idleHandle = scheduleIdle(() => { | ||
| 76 | if (cancelled) { | ||
| 77 | return; | ||
| 78 | } | ||
| 79 | |||
| 80 | const section = pendingSections[nextSectionIndex]; | ||
| 81 | if (!section) { | ||
| 82 | return; | ||
| 83 | } | ||
| 84 | nextSectionIndex += 1; | ||
| 85 | prewarmReviewDiffSection({ threadKey, section }); | ||
| 86 | |||
| 87 | if (nextSectionIndex < pendingSections.length) { | ||
| 88 | scheduleNext(); | ||
| 89 | } | ||
| 90 | }); | ||
| 91 | }; | ||
| 92 | |||
| 93 | scheduleNext(); | ||
| 94 | return () => { | ||
| 95 | cancelled = true; | ||
| 96 | if (idleHandle !== null) { | ||
| 97 | cancelIdle(idleHandle); | ||
| 98 | } | ||
| 99 | }; | ||
| 100 | }, [sections, selectedSectionId, threadKey]); | ||
| 101 | } | ||
apps/mobile/src/features/settings/SettingsAppearanceRouteScreen.tsx created+30| ... | @@ -0,0 +1,30 @@ | ||
| 1 | import { ScrollView, View } from "react-native"; | ||
| 2 | import { useSafeAreaInsets } from "react-native-safe-area-context"; | ||
| 3 | |||
| 4 | import { CodeAppearanceSection } from "./appearance/sections/CodeAppearanceSection"; | ||
| 5 | import { TerminalAppearanceSection } from "./appearance/sections/TerminalAppearanceSection"; | ||
| 6 | import { TextAppearanceSection } from "./appearance/sections/TextAppearanceSection"; | ||
| 7 | |||
| 8 | export function SettingsAppearanceRouteScreen() { | ||
| 9 | const insets = useSafeAreaInsets(); | ||
| 10 | |||
| 11 | return ( | ||
| 12 | <View collapsable={false} className="flex-1 bg-sheet"> | ||
| 13 | <ScrollView | ||
| 14 | contentInsetAdjustmentBehavior="automatic" | ||
| 15 | showsVerticalScrollIndicator={false} | ||
| 16 | style={{ flex: 1 }} | ||
| 17 | contentContainerStyle={{ | ||
| 18 | gap: 24, | ||
| 19 | paddingBottom: Math.max(insets.bottom, 18) + 18, | ||
| 20 | paddingHorizontal: 20, | ||
| 21 | paddingTop: 16, | ||
| 22 | }} | ||
| 23 | > | ||
| 24 | <TextAppearanceSection /> | ||
| 25 | <TerminalAppearanceSection /> | ||
| 26 | <CodeAppearanceSection /> | ||
| 27 | </ScrollView> | ||
| 28 | </View> | ||
| 29 | ); | ||
| 30 | } | ||
apps/mobile/src/features/settings/SettingsAuthRouteScreen.tsx created+39| ... | @@ -0,0 +1,39 @@ | ||
| 1 | import { useAuth } from "@clerk/expo"; | ||
| 2 | import { AuthView, UserProfileView } from "@clerk/expo/native"; | ||
| 3 | import { StackActions, useNavigation } from "@react-navigation/native"; | ||
| 4 | import { NativeStackScreenOptions } from "../../native/StackHeader"; | ||
| 5 | import { useEffect } from "react"; | ||
| 6 | import { View } from "react-native"; | ||
| 7 | |||
| 8 | import { hasCloudPublicConfig } from "../cloud/publicConfig"; | ||
| 9 | |||
| 10 | export function SettingsAuthRouteScreen() { | ||
| 11 | const navigation = useNavigation(); | ||
| 12 | |||
| 13 | useEffect(() => { | ||
| 14 | if (!hasCloudPublicConfig()) { | ||
| 15 | navigation.dispatch(StackActions.replace("Settings")); | ||
| 16 | } | ||
| 17 | }, [navigation]); | ||
| 18 | |||
| 19 | return hasCloudPublicConfig() ? <ConfiguredSettingsAuthRouteScreen /> : null; | ||
| 20 | } | ||
| 21 | |||
| 22 | function ConfiguredSettingsAuthRouteScreen() { | ||
| 23 | const { isLoaded, isSignedIn } = useAuth({ treatPendingAsSignedOut: false }); | ||
| 24 | |||
| 25 | return ( | ||
| 26 | <> | ||
| 27 | <NativeStackScreenOptions options={{ title: isSignedIn ? "Account" : "Sign in" }} /> | ||
| 28 | <View collapsable={false} className="flex-1 overflow-hidden bg-sheet"> | ||
| 29 | {isLoaded ? ( | ||
| 30 | isSignedIn ? ( | ||
| 31 | <UserProfileView isDismissible={false} /> | ||
| 32 | ) : ( | ||
| 33 | <AuthView isDismissible={false} /> | ||
| 34 | ) | ||
| 35 | ) : null} | ||
| 36 | </View> | ||
| 37 | </> | ||
| 38 | ); | ||
| 39 | } | ||
apps/mobile/src/features/settings/SettingsEnvironmentsRouteScreen.tsx created+448| ... | @@ -0,0 +1,448 @@ | ||
| 1 | import { useAuth } from "@clerk/expo"; | ||
| 2 | import { NativeHeaderToolbar } from "../../native/StackHeader"; | ||
| 3 | import { useNavigation } from "@react-navigation/native"; | ||
| 4 | import { SymbolView } from "expo-symbols"; | ||
| 5 | import { | ||
| 6 | connectionStatusText, | ||
| 7 | type EnvironmentConnectionPhase, | ||
| 8 | } from "@t3tools/client-runtime/connection"; | ||
| 9 | import type { EnvironmentId } from "@t3tools/contracts"; | ||
| 10 | import { useCallback, useState } from "react"; | ||
| 11 | import { | ||
| 12 | ActivityIndicator, | ||
| 13 | Pressable, | ||
| 14 | ScrollView, | ||
| 15 | Switch, | ||
| 16 | type NativeSyntheticEvent, | ||
| 17 | type TextLayoutEventData, | ||
| 18 | View, | ||
| 19 | } from "react-native"; | ||
| 20 | import { useSafeAreaInsets } from "react-native-safe-area-context"; | ||
| 21 | |||
| 22 | import { AppText as Text } from "../../components/AppText"; | ||
| 23 | import { | ||
| 24 | type RelayEnvironmentView, | ||
| 25 | useConnectionController, | ||
| 26 | } from "../connection/useConnectionController"; | ||
| 27 | import { hasCloudPublicConfig } from "../cloud/publicConfig"; | ||
| 28 | import { availableCloudEnvironmentPresentation } from "../cloud/cloudEnvironmentPresentation"; | ||
| 29 | import { ConnectionEnvironmentRow } from "../connection/ConnectionEnvironmentRow"; | ||
| 30 | import { ConnectionStatusDot } from "../connection/ConnectionStatusDot"; | ||
| 31 | import { splitEnvironmentSections } from "../connection/environmentSections"; | ||
| 32 | import { cn } from "../../lib/cn"; | ||
| 33 | import { copyTextWithHaptic } from "../../lib/copyTextWithHaptic"; | ||
| 34 | import { useThemeColor } from "../../lib/useThemeColor"; | ||
| 35 | import type { ConnectedEnvironmentSummary } from "../../state/remote-runtime-types"; | ||
| 36 | import { useRemoteConnections } from "../../state/use-remote-environment-registry"; | ||
| 37 | |||
| 38 | export function SettingsEnvironmentsRouteScreen() { | ||
| 39 | const { | ||
| 40 | connectedEnvironments, | ||
| 41 | onReconnectEnvironment, | ||
| 42 | onRemoveEnvironmentPress, | ||
| 43 | onUpdateEnvironment, | ||
| 44 | } = useRemoteConnections(); | ||
| 45 | const navigation = useNavigation(); | ||
| 46 | const insets = useSafeAreaInsets(); | ||
| 47 | const { localEnvironments, connectedCloudEnvironments } = splitEnvironmentSections({ | ||
| 48 | connectedEnvironments, | ||
| 49 | cloudEnvironments: null, | ||
| 50 | }); | ||
| 51 | const hasLocalEnvironments = localEnvironments.length > 0; | ||
| 52 | const [expandedId, setExpandedId] = useState<EnvironmentId | null>(null); | ||
| 53 | const accentColor = useThemeColor("--color-icon-muted"); | ||
| 54 | const headerIconColor = useThemeColor("--color-icon"); | ||
| 55 | |||
| 56 | const handleToggle = useCallback((environmentId: EnvironmentId) => { | ||
| 57 | setExpandedId((prev) => (prev === environmentId ? null : environmentId)); | ||
| 58 | }, []); | ||
| 59 | |||
| 60 | return ( | ||
| 61 | <View collapsable={false} className="flex-1 bg-sheet"> | ||
| 62 | <NativeHeaderToolbar placement="right"> | ||
| 63 | <NativeHeaderToolbar.Button | ||
| 64 | icon="plus" | ||
| 65 | onPress={() => navigation.navigate("SettingsSheet", { screen: "SettingsEnvironmentNew" })} | ||
| 66 | separateBackground | ||
| 67 | tintColor={headerIconColor} | ||
| 68 | /> | ||
| 69 | </NativeHeaderToolbar> | ||
| 70 | <ScrollView | ||
| 71 | contentInsetAdjustmentBehavior="automatic" | ||
| 72 | showsVerticalScrollIndicator={false} | ||
| 73 | style={{ flex: 1 }} | ||
| 74 | contentContainerStyle={{ | ||
| 75 | paddingBottom: Math.max(insets.bottom, 18) + 18, | ||
| 76 | paddingHorizontal: 20, | ||
| 77 | paddingTop: 16, | ||
| 78 | }} | ||
| 79 | > | ||
| 80 | {hasLocalEnvironments ? ( | ||
| 81 | <View collapsable={false} className="overflow-hidden rounded-[24px] bg-card"> | ||
| 82 | {localEnvironments.map((environment, index) => ( | ||
| 83 | <View | ||
| 84 | key={environment.environmentId} | ||
| 85 | collapsable={false} | ||
| 86 | style={{ | ||
| 87 | borderTopWidth: index === 0 ? 0 : 1, | ||
| 88 | }} | ||
| 89 | className={cn(index !== 0 && "border-border")} | ||
| 90 | > | ||
| 91 | <ConnectionEnvironmentRow | ||
| 92 | environment={environment} | ||
| 93 | expanded={expandedId === environment.environmentId} | ||
| 94 | onToggle={() => handleToggle(environment.environmentId)} | ||
| 95 | onReconnect={onReconnectEnvironment} | ||
| 96 | onRemove={onRemoveEnvironmentPress} | ||
| 97 | onUpdate={onUpdateEnvironment} | ||
| 98 | /> | ||
| 99 | </View> | ||
| 100 | ))} | ||
| 101 | </View> | ||
| 102 | ) : ( | ||
| 103 | <View collapsable={false} className="items-center gap-3 rounded-[24px] bg-card px-6 py-8"> | ||
| 104 | <View className="h-12 w-12 items-center justify-center rounded-[16px] bg-subtle"> | ||
| 105 | <SymbolView | ||
| 106 | name="point.3.connected.trianglepath.dotted" | ||
| 107 | size={20} | ||
| 108 | tintColor={accentColor} | ||
| 109 | type="monochrome" | ||
| 110 | /> | ||
| 111 | </View> | ||
| 112 | <Text className="text-center text-sm leading-normal text-foreground-muted"> | ||
| 113 | No environments connected yet.{"\n"}Tap{" "} | ||
| 114 | <Text className="font-t3-bold text-foreground">+</Text> to add one. | ||
| 115 | </Text> | ||
| 116 | </View> | ||
| 117 | )} | ||
| 118 | |||
| 119 | {hasCloudPublicConfig() ? ( | ||
| 120 | <ConfiguredCloudEnvironmentRows | ||
| 121 | connectedCloudEnvironments={connectedCloudEnvironments} | ||
| 122 | onReconnectEnvironment={onReconnectEnvironment} | ||
| 123 | /> | ||
| 124 | ) : null} | ||
| 125 | </ScrollView> | ||
| 126 | </View> | ||
| 127 | ); | ||
| 128 | } | ||
| 129 | |||
| 130 | function ConfiguredCloudEnvironmentRows(props: { | ||
| 131 | readonly connectedCloudEnvironments: ReadonlyArray<ConnectedEnvironmentSummary>; | ||
| 132 | readonly onReconnectEnvironment: (environmentId: EnvironmentId) => void; | ||
| 133 | }) { | ||
| 134 | const { isSignedIn } = useAuth({ treatPendingAsSignedOut: false }); | ||
| 135 | const controller = useConnectionController(); | ||
| 136 | const iconColor = useThemeColor("--color-icon"); | ||
| 137 | const availableCloudEnvironments = controller.availableRelayEnvironments; | ||
| 138 | const [expandedErrorId, setExpandedErrorId] = useState<string | null>(null); | ||
| 139 | const hasCloudRows = | ||
| 140 | props.connectedCloudEnvironments.length > 0 || availableCloudEnvironments.length > 0; | ||
| 141 | |||
| 142 | const handleConnectCloudEnvironment = useCallback( | ||
| 143 | (entry: RelayEnvironmentView) => controller.connectRelayEnvironment(entry.environment), | ||
| 144 | [controller], | ||
| 145 | ); | ||
| 146 | |||
| 147 | const handleDisconnectCloudEnvironment = useCallback( | ||
| 148 | (environmentId: EnvironmentId) => controller.removeEnvironment(environmentId), | ||
| 149 | [controller], | ||
| 150 | ); | ||
| 151 | |||
| 152 | const handleToggleCloudError = useCallback((environmentId: string) => { | ||
| 153 | setExpandedErrorId((current) => (current === environmentId ? null : environmentId)); | ||
| 154 | }, []); | ||
| 155 | |||
| 156 | if (!isSignedIn) return null; | ||
| 157 | |||
| 158 | return ( | ||
| 159 | <View collapsable={false} className="mt-5 gap-3"> | ||
| 160 | <View className="flex-row items-center justify-between px-1"> | ||
| 161 | <Text className="text-sm font-t3-bold uppercase text-foreground-muted">T3 Cloud</Text> | ||
| 162 | <Pressable | ||
| 163 | accessibilityRole="button" | ||
| 164 | disabled={controller.relayDiscovery.isRefreshing} | ||
| 165 | onPress={() => { | ||
| 166 | void controller.refreshRelayEnvironments(); | ||
| 167 | }} | ||
| 168 | className="h-9 w-9 items-center justify-center rounded-full bg-subtle active:opacity-70 disabled:opacity-50" | ||
| 169 | > | ||
| 170 | {controller.relayDiscovery.isRefreshing ? ( | ||
| 171 | <ActivityIndicator color={iconColor} size="small" /> | ||
| 172 | ) : ( | ||
| 173 | <SymbolView name="arrow.clockwise" size={14} tintColor={iconColor} type="monochrome" /> | ||
| 174 | )} | ||
| 175 | </Pressable> | ||
| 176 | </View> | ||
| 177 | |||
| 178 | {hasCloudRows ? ( | ||
| 179 | <View collapsable={false} className="overflow-hidden rounded-[24px] bg-card"> | ||
| 180 | {props.connectedCloudEnvironments.map((environment, index) => ( | ||
| 181 | <ConnectedCloudEnvironmentRow | ||
| 182 | key={environment.environmentId} | ||
| 183 | environment={environment} | ||
| 184 | borderTop={index !== 0} | ||
| 185 | onConnect={() => props.onReconnectEnvironment(environment.environmentId)} | ||
| 186 | onDisconnect={() => handleDisconnectCloudEnvironment(environment.environmentId)} | ||
| 187 | errorExpanded={expandedErrorId === environment.environmentId} | ||
| 188 | onToggleError={() => handleToggleCloudError(environment.environmentId)} | ||
| 189 | /> | ||
| 190 | ))} | ||
| 191 | {availableCloudEnvironments.map((environment, index) => ( | ||
| 192 | <CloudEnvironmentRow | ||
| 193 | key={environment.environment.environmentId} | ||
| 194 | environment={environment} | ||
| 195 | borderTop={props.connectedCloudEnvironments.length > 0 || index !== 0} | ||
| 196 | onConnect={() => handleConnectCloudEnvironment(environment)} | ||
| 197 | errorExpanded={expandedErrorId === environment.environment.environmentId} | ||
| 198 | onToggleError={() => handleToggleCloudError(environment.environment.environmentId)} | ||
| 199 | /> | ||
| 200 | ))} | ||
| 201 | </View> | ||
| 202 | ) : controller.relayDiscovery.isRefreshing ? ( | ||
| 203 | <View collapsable={false} className="items-center gap-3 rounded-[24px] bg-card p-6"> | ||
| 204 | <ActivityIndicator color={iconColor} /> | ||
| 205 | <Text className="text-center text-sm leading-normal text-foreground-muted"> | ||
| 206 | Loading linked cloud environments. | ||
| 207 | </Text> | ||
| 208 | </View> | ||
| 209 | ) : controller.relayDiscovery.error ? ( | ||
| 210 | <View collapsable={false} className="gap-3 rounded-[24px] bg-card p-5"> | ||
| 211 | <Text className="text-base font-t3-bold text-foreground"> | ||
| 212 | Could not load T3 Cloud environments | ||
| 213 | </Text> | ||
| 214 | <Text className="text-sm text-foreground-muted">{controller.relayDiscovery.error}</Text> | ||
| 215 | {controller.relayDiscovery.errorTraceId ? ( | ||
| 216 | <CopyTraceIdButton traceId={controller.relayDiscovery.errorTraceId} /> | ||
| 217 | ) : null} | ||
| 218 | </View> | ||
| 219 | ) : ( | ||
| 220 | <View collapsable={false} className="rounded-[24px] bg-card p-5"> | ||
| 221 | <Text className="text-sm leading-normal text-foreground-muted"> | ||
| 222 | No additional linked cloud environments. | ||
| 223 | </Text> | ||
| 224 | </View> | ||
| 225 | )} | ||
| 226 | </View> | ||
| 227 | ); | ||
| 228 | } | ||
| 229 | |||
| 230 | function ConnectedCloudEnvironmentRow(props: { | ||
| 231 | readonly environment: ConnectedEnvironmentSummary; | ||
| 232 | readonly borderTop: boolean; | ||
| 233 | readonly errorExpanded: boolean; | ||
| 234 | readonly onConnect: () => void; | ||
| 235 | readonly onDisconnect: () => void; | ||
| 236 | readonly onToggleError: () => void; | ||
| 237 | }) { | ||
| 238 | return ( | ||
| 239 | <CloudEnvironmentRowShell | ||
| 240 | borderTop={props.borderTop} | ||
| 241 | connectionError={props.environment.connectionError} | ||
| 242 | connectionErrorTraceId={props.environment.connectionErrorTraceId} | ||
| 243 | connectionState={props.environment.connectionState} | ||
| 244 | errorExpanded={props.errorExpanded} | ||
| 245 | label={props.environment.environmentLabel} | ||
| 246 | onValueChange={(enabled) => { | ||
| 247 | if (enabled) { | ||
| 248 | props.onConnect(); | ||
| 249 | return; | ||
| 250 | } | ||
| 251 | props.onDisconnect(); | ||
| 252 | }} | ||
| 253 | onToggleError={props.onToggleError} | ||
| 254 | value={props.environment.connectionState !== "available"} | ||
| 255 | /> | ||
| 256 | ); | ||
| 257 | } | ||
| 258 | |||
| 259 | function CloudEnvironmentRow(props: { | ||
| 260 | readonly environment: RelayEnvironmentView; | ||
| 261 | readonly borderTop: boolean; | ||
| 262 | readonly errorExpanded: boolean; | ||
| 263 | readonly onConnect: () => void; | ||
| 264 | readonly onToggleError: () => void; | ||
| 265 | }) { | ||
| 266 | const presentation = availableCloudEnvironmentPresentation({ | ||
| 267 | isStatusPending: props.environment.availability === "checking", | ||
| 268 | status: props.environment.status, | ||
| 269 | statusError: props.environment.error, | ||
| 270 | statusErrorTraceId: props.environment.traceId, | ||
| 271 | }); | ||
| 272 | |||
| 273 | return ( | ||
| 274 | <CloudEnvironmentRowShell | ||
| 275 | borderTop={props.borderTop} | ||
| 276 | connectionError={presentation.connectionError} | ||
| 277 | connectionErrorTraceId={presentation.connectionErrorTraceId} | ||
| 278 | connectionState={presentation.connectionState} | ||
| 279 | errorExpanded={props.errorExpanded} | ||
| 280 | label={props.environment.environment.label} | ||
| 281 | onValueChange={(enabled) => { | ||
| 282 | if (enabled) { | ||
| 283 | props.onConnect(); | ||
| 284 | } | ||
| 285 | }} | ||
| 286 | onToggleError={props.onToggleError} | ||
| 287 | statusText={presentation.statusText} | ||
| 288 | value={false} | ||
| 289 | /> | ||
| 290 | ); | ||
| 291 | } | ||
| 292 | |||
| 293 | function CloudEnvironmentRowShell(props: { | ||
| 294 | readonly borderTop: boolean; | ||
| 295 | readonly connectionError: string | null; | ||
| 296 | readonly connectionErrorTraceId: string | null; | ||
| 297 | readonly connectionState: EnvironmentConnectionPhase; | ||
| 298 | readonly disabled?: boolean; | ||
| 299 | readonly errorExpanded: boolean; | ||
| 300 | readonly label: string; | ||
| 301 | readonly onToggleError: () => void; | ||
| 302 | readonly onValueChange: (enabled: boolean) => void; | ||
| 303 | readonly statusText?: string; | ||
| 304 | readonly value: boolean; | ||
| 305 | }) { | ||
| 306 | const activeTrack = String(useThemeColor("--color-switch-active")); | ||
| 307 | const track = String(useThemeColor("--color-secondary-border")); | ||
| 308 | const chevron = useThemeColor("--color-chevron"); | ||
| 309 | const isRetrying = | ||
| 310 | props.connectionState === "connecting" || props.connectionState === "reconnecting"; | ||
| 311 | const shouldPulse = isRetrying; | ||
| 312 | const statusText = | ||
| 313 | props.statusText ?? | ||
| 314 | connectionStatusText({ | ||
| 315 | phase: props.connectionState, | ||
| 316 | error: props.connectionError, | ||
| 317 | traceId: props.connectionErrorTraceId, | ||
| 318 | }); | ||
| 319 | const statusClassName = props.connectionError | ||
| 320 | ? "text-rose-500 dark:text-rose-400" | ||
| 321 | : "text-foreground-muted"; | ||
| 322 | const [errorMeasurement, setErrorMeasurement] = useState<{ | ||
| 323 | readonly text: string; | ||
| 324 | readonly lineCount: number; | ||
| 325 | } | null>(null); | ||
| 326 | const errorTraceId = props.connectionErrorTraceId; | ||
| 327 | const measuredErrorText = errorTraceId ? `${statusText} Trace ID: ${errorTraceId}` : statusText; | ||
| 328 | const errorLineCount = | ||
| 329 | errorMeasurement?.text === measuredErrorText ? errorMeasurement.lineCount : 0; | ||
| 330 | const errorCanExpand = props.connectionError !== null && errorLineCount > 1; | ||
| 331 | const isErrorExpanded = errorCanExpand && props.errorExpanded; | ||
| 332 | const StatusContainer = errorCanExpand ? Pressable : View; | ||
| 333 | const onMeasuredErrorTextLayout = useCallback( | ||
| 334 | (event: NativeSyntheticEvent<TextLayoutEventData>) => { | ||
| 335 | if (!props.connectionError) { | ||
| 336 | return; | ||
| 337 | } | ||
| 338 | const nextLineCount = event.nativeEvent.lines.length; | ||
| 339 | setErrorMeasurement((currentMeasurement) => | ||
| 340 | currentMeasurement?.text === measuredErrorText && | ||
| 341 | currentMeasurement.lineCount === nextLineCount | ||
| 342 | ? currentMeasurement | ||
| 343 | : { text: measuredErrorText, lineCount: nextLineCount }, | ||
| 344 | ); | ||
| 345 | }, | ||
| 346 | [measuredErrorText, props.connectionError], | ||
| 347 | ); | ||
| 348 | return ( | ||
| 349 | <View | ||
| 350 | collapsable={false} | ||
| 351 | className={cn( | ||
| 352 | "flex-row items-center gap-3 bg-card px-4 py-3.5", | ||
| 353 | props.borderTop && "border-t border-border", | ||
| 354 | )} | ||
| 355 | > | ||
| 356 | <View className="min-w-0 flex-1 gap-0.5"> | ||
| 357 | <View className="min-w-0 flex-row items-center gap-2"> | ||
| 358 | <ConnectionStatusDot state={props.connectionState} pulse={shouldPulse} size={7} /> | ||
| 359 | <Text | ||
| 360 | className="min-w-0 flex-shrink text-base font-t3-bold leading-snug text-foreground" | ||
| 361 | numberOfLines={1} | ||
| 362 | > | ||
| 363 | {props.label} | ||
| 364 | </Text> | ||
| 365 | </View> | ||
| 366 | {props.connectionError ? ( | ||
| 367 | <Text | ||
| 368 | aria-hidden | ||
| 369 | className={cn("absolute left-0 right-0 text-xs", statusClassName)} | ||
| 370 | onTextLayout={onMeasuredErrorTextLayout} | ||
| 371 | style={{ opacity: 0, zIndex: -1 }} | ||
| 372 | > | ||
| 373 | {measuredErrorText} | ||
| 374 | </Text> | ||
| 375 | ) : null} | ||
| 376 | <StatusContainer | ||
| 377 | {...(errorCanExpand | ||
| 378 | ? { accessibilityRole: "button" as const, onPress: props.onToggleError } | ||
| 379 | : {})} | ||
| 380 | className="min-w-0 flex-row items-start gap-1" | ||
| 381 | > | ||
| 382 | <Text | ||
| 383 | className={cn("min-w-0 flex-1 text-xs", statusClassName)} | ||
| 384 | numberOfLines={isErrorExpanded ? undefined : 1} | ||
| 385 | > | ||
| 386 | {statusText} | ||
| 387 | {errorTraceId ? ( | ||
| 388 | <> | ||
| 389 | {" Trace ID: "} | ||
| 390 | <Text | ||
| 391 | accessibilityHint="Copies the trace ID" | ||
| 392 | accessibilityRole="button" | ||
| 393 | className={cn("text-xs underline", statusClassName)} | ||
| 394 | onLongPress={(event) => { | ||
| 395 | event.stopPropagation(); | ||
| 396 | copyTextWithHaptic(errorTraceId, { target: "connection-trace-id" }); | ||
| 397 | }} | ||
| 398 | onPress={(event) => { | ||
| 399 | event.stopPropagation(); | ||
| 400 | }} | ||
| 401 | style={{ textDecorationStyle: "dotted" }} | ||
| 402 | > | ||
| 403 | {errorTraceId} | ||
| 404 | </Text> | ||
| 405 | </> | ||
| 406 | ) : null} | ||
| 407 | </Text> | ||
| 408 | {errorCanExpand ? ( | ||
| 409 | <SymbolView | ||
| 410 | name="chevron.down" | ||
| 411 | size={10} | ||
| 412 | tintColor={chevron} | ||
| 413 | type="monochrome" | ||
| 414 | style={{ | ||
| 415 | marginTop: 3, | ||
| 416 | transform: [{ rotate: isErrorExpanded ? "180deg" : "0deg" }], | ||
| 417 | }} | ||
| 418 | /> | ||
| 419 | ) : null} | ||
| 420 | </StatusContainer> | ||
| 421 | </View> | ||
| 422 | <Switch | ||
| 423 | disabled={props.disabled} | ||
| 424 | ios_backgroundColor={track} | ||
| 425 | onValueChange={props.onValueChange} | ||
| 426 | trackColor={{ false: track, true: activeTrack }} | ||
| 427 | value={props.value} | ||
| 428 | /> | ||
| 429 | </View> | ||
| 430 | ); | ||
| 431 | } | ||
| 432 | |||
| 433 | function CopyTraceIdButton(props: { readonly traceId: string }) { | ||
| 434 | const iconColor = useThemeColor("--color-icon"); | ||
| 435 | |||
| 436 | return ( | ||
| 437 | <Pressable | ||
| 438 | accessibilityRole="button" | ||
| 439 | onPress={() => { | ||
| 440 | copyTextWithHaptic(props.traceId, { target: "connection-trace-id" }); | ||
| 441 | }} | ||
| 442 | className="self-start flex-row items-center gap-1.5 rounded-full bg-subtle px-3 py-2 active:opacity-70" | ||
| 443 | > | ||
| 444 | <SymbolView name="doc.on.doc" size={12} tintColor={iconColor} type="monochrome" /> | ||
| 445 | <Text className="text-xs font-t3-bold text-foreground">Copy trace ID</Text> | ||
| 446 | </Pressable> | ||
| 447 | ); | ||
| 448 | } | ||
apps/mobile/src/features/settings/SettingsRouteScreen.tsx created+448| ... | @@ -0,0 +1,448 @@ | ||
| 1 | import { useAuth, useUser } from "@clerk/expo"; | ||
| 2 | import * as Notifications from "expo-notifications"; | ||
| 3 | import { useNavigation } from "@react-navigation/native"; | ||
| 4 | import { NativeStackScreenOptions } from "../../native/StackHeader"; | ||
| 5 | import { SymbolView } from "expo-symbols"; | ||
| 6 | import * as Effect from "effect/Effect"; | ||
| 7 | import { useCallback, useEffect, useMemo, useState } from "react"; | ||
| 8 | import { Alert, Linking, Platform, ScrollView, View } from "react-native"; | ||
| 9 | import { useSafeAreaInsets } from "react-native-safe-area-context"; | ||
| 10 | |||
| 11 | import { | ||
| 12 | isAtomCommandInterrupted, | ||
| 13 | reportAtomCommandResult, | ||
| 14 | settleAsyncResult, | ||
| 15 | settlePromise, | ||
| 16 | squashAtomCommandFailure, | ||
| 17 | } from "@t3tools/client-runtime/state/runtime"; | ||
| 18 | import { AppText as Text } from "../../components/AppText"; | ||
| 19 | import { setLiveActivityUpdatesEnabled } from "../agent-awareness/liveActivityPreferences"; | ||
| 20 | import { requestAgentNotificationPermission } from "../agent-awareness/notificationPermissions"; | ||
| 21 | import { refreshAgentAwarenessRegistration } from "../agent-awareness/remoteRegistration"; | ||
| 22 | import { refreshManagedRelayEnvironments } from "../cloud/managedRelayState"; | ||
| 23 | import { useClerkSettingsSheetDetent } from "../cloud/ClerkSettingsSheetDetent"; | ||
| 24 | import { hasCloudPublicConfig, resolveRelayClerkTokenOptions } from "../cloud/publicConfig"; | ||
| 25 | import { withNativeGlassHeaderItem } from "../layout/native-glass-header-items"; | ||
| 26 | import { WorkspaceSidebarToolbar } from "../layout/workspace-sidebar-toolbar"; | ||
| 27 | import { runtime } from "../../lib/runtime"; | ||
| 28 | import { loadPreferences } from "../../lib/storage"; | ||
| 29 | import { useThemeColor } from "../../lib/useThemeColor"; | ||
| 30 | import { useSavedRemoteConnections } from "../../state/use-remote-environment-registry"; | ||
| 31 | import { SettingsRow } from "./components/SettingsRow"; | ||
| 32 | import { SettingsSection } from "./components/SettingsSection"; | ||
| 33 | import { SettingsSwitchRow } from "./components/SettingsSwitchRow"; | ||
| 34 | |||
| 35 | type NotificationStatus = "checking" | "enabled" | "disabled" | "unsupported"; | ||
| 36 | type LiveActivityStatus = "checking" | "enabled" | "disabled" | "signed-out" | "linking"; | ||
| 37 | |||
| 38 | export function SettingsRouteScreen() { | ||
| 39 | const navigation = useNavigation(); | ||
| 40 | |||
| 41 | return ( | ||
| 42 | <> | ||
| 43 | <WorkspaceSidebarToolbar /> | ||
| 44 | <NativeStackScreenOptions | ||
| 45 | options={{ | ||
| 46 | unstable_headerRightItems: | ||
| 47 | Platform.OS === "ios" | ||
| 48 | ? () => [ | ||
| 49 | withNativeGlassHeaderItem({ | ||
| 50 | accessibilityLabel: "Close settings", | ||
| 51 | icon: { name: "xmark", type: "sfSymbol" } as const, | ||
| 52 | identifier: "settings-close", | ||
| 53 | label: "", | ||
| 54 | onPress: () => navigation.goBack(), | ||
| 55 | type: "button", | ||
| 56 | }), | ||
| 57 | ] | ||
| 58 | : undefined, | ||
| 59 | }} | ||
| 60 | /> | ||
| 61 | {hasCloudPublicConfig() ? <ConfiguredSettingsRouteScreen /> : <LocalSettingsRouteScreen />} | ||
| 62 | </> | ||
| 63 | ); | ||
| 64 | } | ||
| 65 | |||
| 66 | function LocalSettingsRouteScreen() { | ||
| 67 | const insets = useSafeAreaInsets(); | ||
| 68 | const { savedConnectionsById } = useSavedRemoteConnections(); | ||
| 69 | const environmentCount = Object.keys(savedConnectionsById).length; | ||
| 70 | |||
| 71 | return ( | ||
| 72 | <View collapsable={false} className="flex-1 bg-sheet"> | ||
| 73 | <ScrollView | ||
| 74 | contentInsetAdjustmentBehavior="automatic" | ||
| 75 | showsVerticalScrollIndicator={false} | ||
| 76 | style={{ flex: 1 }} | ||
| 77 | contentContainerStyle={{ | ||
| 78 | gap: 24, | ||
| 79 | paddingBottom: Math.max(insets.bottom, 18) + 18, | ||
| 80 | paddingHorizontal: 20, | ||
| 81 | paddingTop: 16, | ||
| 82 | }} | ||
| 83 | > | ||
| 84 | <SettingsSection title="Configuration"> | ||
| 85 | <SettingsRow | ||
| 86 | icon="desktopcomputer" | ||
| 87 | label="Environments" | ||
| 88 | value={`${environmentCount}`} | ||
| 89 | target="SettingsEnvironments" | ||
| 90 | /> | ||
| 91 | </SettingsSection> | ||
| 92 | |||
| 93 | <SettingsSection title="Appearance"> | ||
| 94 | <SettingsRow icon="paintbrush" label="Appearance" target="SettingsAppearance" /> | ||
| 95 | </SettingsSection> | ||
| 96 | |||
| 97 | <ArchivedThreadsSettingsSection /> | ||
| 98 | |||
| 99 | <AppSettingsSection /> | ||
| 100 | </ScrollView> | ||
| 101 | </View> | ||
| 102 | ); | ||
| 103 | } | ||
| 104 | |||
| 105 | function ConfiguredSettingsRouteScreen() { | ||
| 106 | const insets = useSafeAreaInsets(); | ||
| 107 | const navigation = useNavigation(); | ||
| 108 | const { expand: expandClerkSheet } = useClerkSettingsSheetDetent(); | ||
| 109 | const { getToken, isLoaded, isSignedIn } = useAuth({ treatPendingAsSignedOut: false }); | ||
| 110 | const { user } = useUser(); | ||
| 111 | const { savedConnectionsById } = useSavedRemoteConnections(); | ||
| 112 | const [notificationStatus, setNotificationStatus] = useState<NotificationStatus>("checking"); | ||
| 113 | const [liveActivityStatus, setLiveActivityStatus] = useState<LiveActivityStatus>("checking"); | ||
| 114 | |||
| 115 | const connections = useMemo(() => Object.values(savedConnectionsById), [savedConnectionsById]); | ||
| 116 | const environmentCount = connections.length; | ||
| 117 | const accountLabel = useMemo(() => { | ||
| 118 | if (!isLoaded) return "Checking"; | ||
| 119 | if (!isSignedIn) return "Request access"; | ||
| 120 | return user?.primaryEmailAddress?.emailAddress ?? "Signed in"; | ||
| 121 | }, [isLoaded, isSignedIn, user?.primaryEmailAddress?.emailAddress]); | ||
| 122 | |||
| 123 | const refreshNotifications = useCallback(async () => { | ||
| 124 | if (process.env.EXPO_OS !== "ios") { | ||
| 125 | setNotificationStatus("unsupported"); | ||
| 126 | return; | ||
| 127 | } | ||
| 128 | const result = await settlePromise(() => Notifications.getPermissionsAsync()); | ||
| 129 | if (result._tag === "Failure") { | ||
| 130 | reportAtomCommandResult(result, { label: "notification permission refresh" }); | ||
| 131 | setNotificationStatus("disabled"); | ||
| 132 | return; | ||
| 133 | } | ||
| 134 | setNotificationStatus(result.value.granted ? "enabled" : "disabled"); | ||
| 135 | }, []); | ||
| 136 | |||
| 137 | useEffect(() => { | ||
| 138 | void refreshNotifications(); | ||
| 139 | }, [refreshNotifications]); | ||
| 140 | |||
| 141 | useEffect(() => { | ||
| 142 | if (!isLoaded) { | ||
| 143 | setLiveActivityStatus("checking"); | ||
| 144 | return; | ||
| 145 | } | ||
| 146 | if (!isSignedIn) { | ||
| 147 | setLiveActivityStatus("signed-out"); | ||
| 148 | return; | ||
| 149 | } | ||
| 150 | void (async () => { | ||
| 151 | const result = await settlePromise(() => loadPreferences()); | ||
| 152 | if (result._tag === "Failure") { | ||
| 153 | reportAtomCommandResult(result, { label: "live activity preference load" }); | ||
| 154 | setLiveActivityStatus("enabled"); | ||
| 155 | return; | ||
| 156 | } | ||
| 157 | setLiveActivityStatus(result.value.liveActivitiesEnabled === false ? "disabled" : "enabled"); | ||
| 158 | })(); | ||
| 159 | }, [isLoaded, isSignedIn]); | ||
| 160 | |||
| 161 | const requestNotifications = useCallback(async () => { | ||
| 162 | const result = await settleAsyncResult(() => | ||
| 163 | runtime.runPromiseExit( | ||
| 164 | requestAgentNotificationPermission.pipe( | ||
| 165 | Effect.tap((permission) => | ||
| 166 | permission.type === "granted" ? refreshAgentAwarenessRegistration() : Effect.void, | ||
| 167 | ), | ||
| 168 | ), | ||
| 169 | ), | ||
| 170 | ); | ||
| 171 | if (result._tag === "Failure") { | ||
| 172 | if (!isAtomCommandInterrupted(result)) { | ||
| 173 | const error = squashAtomCommandFailure(result); | ||
| 174 | Alert.alert( | ||
| 175 | "Notifications unavailable", | ||
| 176 | error instanceof Error ? error.message : "Could not request notification permission.", | ||
| 177 | ); | ||
| 178 | } | ||
| 179 | return; | ||
| 180 | } | ||
| 181 | if (result.value.type === "granted") { | ||
| 182 | setNotificationStatus("enabled"); | ||
| 183 | Alert.alert( | ||
| 184 | "Notifications enabled", | ||
| 185 | "Live Activity notifications are enabled for this device.", | ||
| 186 | ); | ||
| 187 | return; | ||
| 188 | } | ||
| 189 | if (result.value.type === "unsupported") { | ||
| 190 | setNotificationStatus("unsupported"); | ||
| 191 | Alert.alert( | ||
| 192 | "Notifications unavailable", | ||
| 193 | "Live Activity notifications are only available on iOS.", | ||
| 194 | ); | ||
| 195 | return; | ||
| 196 | } | ||
| 197 | setNotificationStatus("disabled"); | ||
| 198 | if (result.value.canAskAgain) { | ||
| 199 | Alert.alert("Notifications disabled", "Notifications were not enabled."); | ||
| 200 | return; | ||
| 201 | } | ||
| 202 | Alert.alert( | ||
| 203 | "Notifications disabled", | ||
| 204 | "Notifications were denied for this app. Open Settings to enable them.", | ||
| 205 | [ | ||
| 206 | { text: "Cancel", style: "cancel" }, | ||
| 207 | { text: "Open Settings", onPress: () => void Linking.openSettings() }, | ||
| 208 | ], | ||
| 209 | ); | ||
| 210 | }, []); | ||
| 211 | |||
| 212 | const promptSignIn = useCallback(() => { | ||
| 213 | Alert.alert( | ||
| 214 | "Request T3 Cloud access", | ||
| 215 | "Live Activity updates require approved T3 Cloud access so relay can deliver updates to this device.", | ||
| 216 | [ | ||
| 217 | { text: "Cancel", style: "cancel" }, | ||
| 218 | { | ||
| 219 | text: "Continue", | ||
| 220 | onPress: () => navigation.navigate("SettingsSheet", { screen: "SettingsWaitlist" }), | ||
| 221 | }, | ||
| 222 | ], | ||
| 223 | ); | ||
| 224 | }, [navigation]); | ||
| 225 | |||
| 226 | const linkEnvironments = useCallback(async () => { | ||
| 227 | if (!isSignedIn) { | ||
| 228 | promptSignIn(); | ||
| 229 | return; | ||
| 230 | } | ||
| 231 | |||
| 232 | setLiveActivityStatus("linking"); | ||
| 233 | const tokenResult = await settlePromise(() => getToken(resolveRelayClerkTokenOptions())); | ||
| 234 | if (tokenResult._tag === "Failure") { | ||
| 235 | setLiveActivityStatus("disabled"); | ||
| 236 | const error = squashAtomCommandFailure(tokenResult); | ||
| 237 | Alert.alert( | ||
| 238 | "Live Activities unavailable", | ||
| 239 | error instanceof Error ? error.message : "Could not enable Live Activity updates.", | ||
| 240 | ); | ||
| 241 | return; | ||
| 242 | } | ||
| 243 | if (!tokenResult.value) { | ||
| 244 | promptSignIn(); | ||
| 245 | setLiveActivityStatus("signed-out"); | ||
| 246 | return; | ||
| 247 | } | ||
| 248 | |||
| 249 | const updateResult = await settleAsyncResult(() => | ||
| 250 | runtime.runPromiseExit( | ||
| 251 | setLiveActivityUpdatesEnabled({ | ||
| 252 | enabled: true, | ||
| 253 | clerkToken: tokenResult.value, | ||
| 254 | connections, | ||
| 255 | }), | ||
| 256 | ), | ||
| 257 | ); | ||
| 258 | if (updateResult._tag === "Failure") { | ||
| 259 | setLiveActivityStatus("disabled"); | ||
| 260 | if (!isAtomCommandInterrupted(updateResult)) { | ||
| 261 | const error = squashAtomCommandFailure(updateResult); | ||
| 262 | Alert.alert( | ||
| 263 | "Live Activities unavailable", | ||
| 264 | error instanceof Error ? error.message : "Could not enable Live Activity updates.", | ||
| 265 | ); | ||
| 266 | } | ||
| 267 | return; | ||
| 268 | } | ||
| 269 | |||
| 270 | refreshManagedRelayEnvironments(); | ||
| 271 | setLiveActivityStatus("enabled"); | ||
| 272 | Alert.alert( | ||
| 273 | "Live Activities enabled", | ||
| 274 | environmentCount > 0 | ||
| 275 | ? `${environmentCount} environment${environmentCount === 1 ? "" : "s"} linked for Live Activity updates.` | ||
| 276 | : "Live Activity updates are enabled. Add an environment to start receiving updates.", | ||
| 277 | ); | ||
| 278 | }, [connections, environmentCount, getToken, isSignedIn, promptSignIn]); | ||
| 279 | |||
| 280 | const handleDeviceNotificationsChange = useCallback( | ||
| 281 | (enabled: boolean) => { | ||
| 282 | if (enabled) { | ||
| 283 | void requestNotifications(); | ||
| 284 | return; | ||
| 285 | } | ||
| 286 | |||
| 287 | Alert.alert( | ||
| 288 | "Disable notifications", | ||
| 289 | "Notification permission is controlled by iOS. Open Settings to disable notifications for T3 Code.", | ||
| 290 | [ | ||
| 291 | { text: "Cancel", style: "cancel" }, | ||
| 292 | { text: "Open Settings", onPress: () => void Linking.openSettings() }, | ||
| 293 | ], | ||
| 294 | ); | ||
| 295 | }, | ||
| 296 | [requestNotifications], | ||
| 297 | ); | ||
| 298 | |||
| 299 | const handleLiveActivitiesChange = useCallback( | ||
| 300 | (enabled: boolean) => { | ||
| 301 | if (!enabled) { | ||
| 302 | setLiveActivityStatus("disabled"); | ||
| 303 | void (async () => { | ||
| 304 | let token: string | null = null; | ||
| 305 | if (isSignedIn) { | ||
| 306 | const tokenResult = await settlePromise(() => | ||
| 307 | getToken(resolveRelayClerkTokenOptions()), | ||
| 308 | ); | ||
| 309 | if (tokenResult._tag === "Failure") { | ||
| 310 | reportAtomCommandResult(tokenResult, { | ||
| 311 | label: "live activity disable token lookup", | ||
| 312 | }); | ||
| 313 | return; | ||
| 314 | } | ||
| 315 | token = tokenResult.value; | ||
| 316 | } | ||
| 317 | |||
| 318 | const updateResult = await settleAsyncResult(() => | ||
| 319 | runtime.runPromiseExit( | ||
| 320 | setLiveActivityUpdatesEnabled({ | ||
| 321 | enabled: false, | ||
| 322 | clerkToken: token, | ||
| 323 | connections, | ||
| 324 | }), | ||
| 325 | ), | ||
| 326 | ); | ||
| 327 | if (updateResult._tag === "Failure") { | ||
| 328 | reportAtomCommandResult(updateResult, { | ||
| 329 | label: "live activity disable", | ||
| 330 | }); | ||
| 331 | return; | ||
| 332 | } | ||
| 333 | refreshManagedRelayEnvironments(); | ||
| 334 | })(); | ||
| 335 | return; | ||
| 336 | } | ||
| 337 | |||
| 338 | if (!isSignedIn) { | ||
| 339 | promptSignIn(); | ||
| 340 | return; | ||
| 341 | } | ||
| 342 | |||
| 343 | void linkEnvironments(); | ||
| 344 | }, | ||
| 345 | [connections, getToken, isSignedIn, linkEnvironments, promptSignIn], | ||
| 346 | ); | ||
| 347 | |||
| 348 | const openAccount = useCallback(() => { | ||
| 349 | if (!isLoaded) return; | ||
| 350 | if (!isSignedIn) { | ||
| 351 | navigation.navigate("SettingsSheet", { screen: "SettingsWaitlist" }); | ||
| 352 | return; | ||
| 353 | } | ||
| 354 | expandClerkSheet(); | ||
| 355 | navigation.navigate("SettingsSheet", { screen: "SettingsAuth" }); | ||
| 356 | }, [expandClerkSheet, isLoaded, isSignedIn, navigation]); | ||
| 357 | |||
| 358 | return ( | ||
| 359 | <View collapsable={false} className="flex-1 bg-sheet"> | ||
| 360 | <ScrollView | ||
| 361 | contentInsetAdjustmentBehavior="automatic" | ||
| 362 | showsVerticalScrollIndicator={false} | ||
| 363 | style={{ flex: 1 }} | ||
| 364 | contentContainerStyle={{ | ||
| 365 | gap: 24, | ||
| 366 | paddingBottom: Math.max(insets.bottom, 18) + 18, | ||
| 367 | paddingHorizontal: 20, | ||
| 368 | paddingTop: 16, | ||
| 369 | }} | ||
| 370 | > | ||
| 371 | <View className="gap-3"> | ||
| 372 | <SettingsSection title="Account"> | ||
| 373 | <SettingsRow | ||
| 374 | icon="person.crop.circle" | ||
| 375 | label="T3 Account" | ||
| 376 | value={accountLabel} | ||
| 377 | onPress={openAccount} | ||
| 378 | /> | ||
| 379 | </SettingsSection> | ||
| 380 | <Text className="px-2 text-sm text-foreground-muted"> | ||
| 381 | T3 Code works locally without signing in. Cloud features are optional. | ||
| 382 | </Text> | ||
| 383 | </View> | ||
| 384 | |||
| 385 | <SettingsSection title="Configuration"> | ||
| 386 | <SettingsRow | ||
| 387 | icon="desktopcomputer" | ||
| 388 | label="Environments" | ||
| 389 | value={`${environmentCount}`} | ||
| 390 | target="SettingsEnvironments" | ||
| 391 | /> | ||
| 392 | <SettingsSwitchRow | ||
| 393 | icon="bell.badge" | ||
| 394 | label="Device Notifications" | ||
| 395 | disabled={notificationStatus === "checking" || notificationStatus === "unsupported"} | ||
| 396 | value={notificationStatus === "enabled"} | ||
| 397 | onValueChange={handleDeviceNotificationsChange} | ||
| 398 | /> | ||
| 399 | <SettingsSwitchRow | ||
| 400 | disabled={ | ||
| 401 | !isLoaded || liveActivityStatus === "checking" || liveActivityStatus === "linking" | ||
| 402 | } | ||
| 403 | icon="bolt.circle" | ||
| 404 | label="Live Activity Updates" | ||
| 405 | value={liveActivityStatus === "enabled" || liveActivityStatus === "linking"} | ||
| 406 | onValueChange={handleLiveActivitiesChange} | ||
| 407 | /> | ||
| 408 | </SettingsSection> | ||
| 409 | |||
| 410 | <SettingsSection title="Appearance"> | ||
| 411 | <SettingsRow icon="paintbrush" label="Appearance" target="SettingsAppearance" /> | ||
| 412 | </SettingsSection> | ||
| 413 | |||
| 414 | <ArchivedThreadsSettingsSection /> | ||
| 415 | |||
| 416 | <AppSettingsSection /> | ||
| 417 | </ScrollView> | ||
| 418 | </View> | ||
| 419 | ); | ||
| 420 | } | ||
| 421 | |||
| 422 | function AppSettingsSection() { | ||
| 423 | const icon = useThemeColor("--color-icon"); | ||
| 424 | |||
| 425 | return ( | ||
| 426 | <SettingsSection title="App"> | ||
| 427 | <View className="flex-row items-center gap-4 p-4"> | ||
| 428 | <SymbolView | ||
| 429 | name="info.circle" | ||
| 430 | size={22} | ||
| 431 | tintColor={icon} | ||
| 432 | type="monochrome" | ||
| 433 | weight="regular" | ||
| 434 | /> | ||
| 435 | <Text className="flex-1 text-lg text-foreground">Version</Text> | ||
| 436 | <Text className="text-lg text-foreground-muted">Alpha</Text> | ||
| 437 | </View> | ||
| 438 | </SettingsSection> | ||
| 439 | ); | ||
| 440 | } | ||
| 441 | |||
| 442 | function ArchivedThreadsSettingsSection() { | ||
| 443 | return ( | ||
| 444 | <SettingsSection title="Threads"> | ||
| 445 | <SettingsRow icon="archivebox" label="Archived Threads" target="SettingsArchive" /> | ||
| 446 | </SettingsSection> | ||
| 447 | ); | ||
| 448 | } | ||
apps/mobile/src/features/settings/SettingsWaitlistRouteScreen.tsx created+60| ... | @@ -0,0 +1,60 @@ | ||
| 1 | import { useAuth } from "@clerk/expo"; | ||
| 2 | import { StackActions, useFocusEffect, useNavigation } from "@react-navigation/native"; | ||
| 3 | import { useCallback } from "react"; | ||
| 4 | import { ScrollView } from "react-native"; | ||
| 5 | |||
| 6 | import { CloudWaitlistEnrollment } from "../cloud/CloudWaitlistEnrollment"; | ||
| 7 | import { useClerkSettingsSheetDetent } from "../cloud/ClerkSettingsSheetDetent"; | ||
| 8 | import { hasCloudPublicConfig } from "../cloud/publicConfig"; | ||
| 9 | |||
| 10 | export function SettingsWaitlistRouteScreen() { | ||
| 11 | const navigation = useNavigation(); | ||
| 12 | |||
| 13 | useFocusEffect( | ||
| 14 | useCallback(() => { | ||
| 15 | if (!hasCloudPublicConfig()) { | ||
| 16 | navigation.dispatch(StackActions.replace("Settings")); | ||
| 17 | } | ||
| 18 | }, [navigation]), | ||
| 19 | ); | ||
| 20 | |||
| 21 | return hasCloudPublicConfig() ? <ConfiguredSettingsWaitlistRouteScreen /> : null; | ||
| 22 | } | ||
| 23 | |||
| 24 | function ConfiguredSettingsWaitlistRouteScreen() { | ||
| 25 | const { isLoaded, isSignedIn } = useAuth({ treatPendingAsSignedOut: false }); | ||
| 26 | const { expand } = useClerkSettingsSheetDetent(); | ||
| 27 | const navigation = useNavigation(); | ||
| 28 | |||
| 29 | useFocusEffect( | ||
| 30 | useCallback(() => { | ||
| 31 | if (isLoaded && isSignedIn) { | ||
| 32 | navigation.dispatch(StackActions.replace("Settings")); | ||
| 33 | } | ||
| 34 | }, [isLoaded, isSignedIn, navigation]), | ||
| 35 | ); | ||
| 36 | |||
| 37 | return ( | ||
| 38 | <> | ||
| 39 | <ScrollView | ||
| 40 | automaticallyAdjustKeyboardInsets | ||
| 41 | contentContainerStyle={{ | ||
| 42 | paddingBottom: 32, | ||
| 43 | paddingHorizontal: 20, | ||
| 44 | paddingTop: 12, | ||
| 45 | }} | ||
| 46 | contentInsetAdjustmentBehavior="automatic" | ||
| 47 | keyboardDismissMode="interactive" | ||
| 48 | keyboardShouldPersistTaps="handled" | ||
| 49 | showsVerticalScrollIndicator={false} | ||
| 50 | > | ||
| 51 | <CloudWaitlistEnrollment | ||
| 52 | onSignIn={() => { | ||
| 53 | expand(); | ||
| 54 | navigation.navigate("SettingsSheet", { screen: "SettingsAuth" }); | ||
| 55 | }} | ||
| 56 | /> | ||
| 57 | </ScrollView> | ||
| 58 | </> | ||
| 59 | ); | ||
| 60 | } | ||
apps/mobile/src/features/settings/appearance/AppearancePreferencesProvider.tsx created+158| ... | @@ -0,0 +1,158 @@ | ||
| 1 | import { | ||
| 2 | createContext, | ||
| 3 | useCallback, | ||
| 4 | useContext, | ||
| 5 | useEffect, | ||
| 6 | useMemo, | ||
| 7 | useState, | ||
| 8 | type ReactNode, | ||
| 9 | } from "react"; | ||
| 10 | |||
| 11 | import { Uniwind } from "uniwind"; | ||
| 12 | |||
| 13 | import { | ||
| 14 | resolveAppearance, | ||
| 15 | resolveAppearancePreferences, | ||
| 16 | resolveTextScaleVariables, | ||
| 17 | type AppearancePreferences, | ||
| 18 | type ResolvedAppearance, | ||
| 19 | } from "../../../lib/appearancePreferences"; | ||
| 20 | import { loadPreferences, savePreferencesPatch } from "../../../lib/storage"; | ||
| 21 | import { cacheTerminalFontSize } from "../../terminal/terminalUiState"; | ||
| 22 | |||
| 23 | interface AppearancePreferencesContextValue { | ||
| 24 | /** Effective values with base-size derivation applied. Use this for rendering. */ | ||
| 25 | readonly appearance: ResolvedAppearance; | ||
| 26 | readonly isReady: boolean; | ||
| 27 | readonly setBaseFontSize: (value: number) => void; | ||
| 28 | /** Pass null to clear the override and follow the base font size. */ | ||
| 29 | readonly setTerminalFontSize: (value: number | null) => void; | ||
| 30 | /** Pass null to clear the override and follow the base font size. */ | ||
| 31 | readonly setCodeFontSize: (value: number | null) => void; | ||
| 32 | readonly setCodeWordBreak: (value: boolean) => void; | ||
| 33 | } | ||
| 34 | |||
| 35 | const AppearancePreferencesContext = createContext<AppearancePreferencesContextValue | null>(null); | ||
| 36 | |||
| 37 | /** | ||
| 38 | * Injects the scaled `--text-*` variables into Uniwind so every | ||
| 39 | * className-based text size (`text-sm`, `text-base`, ...) re-resolves live. | ||
| 40 | * Updates the current theme last so the active stylesheet settles correctly. | ||
| 41 | */ | ||
| 42 | function applyTextScaleVariables(baseFontSize: number) { | ||
| 43 | const variables = resolveTextScaleVariables(baseFontSize); | ||
| 44 | const currentTheme = Uniwind.currentTheme; | ||
| 45 | |||
| 46 | for (const theme of ["light", "dark"] as const) { | ||
| 47 | if (theme !== currentTheme) { | ||
| 48 | Uniwind.updateCSSVariables(theme, variables); | ||
| 49 | } | ||
| 50 | } | ||
| 51 | Uniwind.updateCSSVariables(currentTheme, variables); | ||
| 52 | } | ||
| 53 | |||
| 54 | export function AppearancePreferencesProvider(props: { readonly children: ReactNode }) { | ||
| 55 | const [preferences, setPreferences] = useState<AppearancePreferences>(() => | ||
| 56 | resolveAppearancePreferences(null), | ||
| 57 | ); | ||
| 58 | const [isReady, setIsReady] = useState(false); | ||
| 59 | |||
| 60 | useEffect(() => { | ||
| 61 | let cancelled = false; | ||
| 62 | |||
| 63 | void loadPreferences() | ||
| 64 | .then((stored) => { | ||
| 65 | if (cancelled) { | ||
| 66 | return; | ||
| 67 | } | ||
| 68 | |||
| 69 | const resolved = resolveAppearancePreferences(stored); | ||
| 70 | setPreferences(resolved); | ||
| 71 | cacheTerminalFontSize(resolveAppearance(resolved).terminalFontSize); | ||
| 72 | setIsReady(true); | ||
| 73 | }) | ||
| 74 | .catch(() => { | ||
| 75 | if (cancelled) { | ||
| 76 | return; | ||
| 77 | } | ||
| 78 | |||
| 79 | setIsReady(true); | ||
| 80 | }); | ||
| 81 | |||
| 82 | return () => { | ||
| 83 | cancelled = true; | ||
| 84 | }; | ||
| 85 | }, []); | ||
| 86 | |||
| 87 | useEffect(() => { | ||
| 88 | applyTextScaleVariables(preferences.baseFontSize); | ||
| 89 | }, [preferences.baseFontSize]); | ||
| 90 | |||
| 91 | const updatePreferences = useCallback((patch: Partial<AppearancePreferences>) => { | ||
| 92 | setPreferences((current) => { | ||
| 93 | const next = resolveAppearancePreferences({ ...current, ...patch }); | ||
| 94 | cacheTerminalFontSize(resolveAppearance(next).terminalFontSize); | ||
| 95 | void savePreferencesPatch({ | ||
| 96 | baseFontSize: next.baseFontSize, | ||
| 97 | terminalFontSize: next.terminalFontSize, | ||
| 98 | codeFontSize: next.codeFontSize, | ||
| 99 | codeWordBreak: next.codeWordBreak, | ||
| 100 | }).catch(() => undefined); | ||
| 101 | return next; | ||
| 102 | }); | ||
| 103 | }, []); | ||
| 104 | |||
| 105 | const setBaseFontSize = useCallback( | ||
| 106 | (value: number) => { | ||
| 107 | updatePreferences({ baseFontSize: value }); | ||
| 108 | }, | ||
| 109 | [updatePreferences], | ||
| 110 | ); | ||
| 111 | |||
| 112 | const setTerminalFontSize = useCallback( | ||
| 113 | (value: number | null) => { | ||
| 114 | updatePreferences({ terminalFontSize: value }); | ||
| 115 | }, | ||
| 116 | [updatePreferences], | ||
| 117 | ); | ||
| 118 | |||
| 119 | const setCodeFontSize = useCallback( | ||
| 120 | (value: number | null) => { | ||
| 121 | updatePreferences({ codeFontSize: value }); | ||
| 122 | }, | ||
| 123 | [updatePreferences], | ||
| 124 | ); | ||
| 125 | |||
| 126 | const setCodeWordBreak = useCallback( | ||
| 127 | (value: boolean) => { | ||
| 128 | updatePreferences({ codeWordBreak: value }); | ||
| 129 | }, | ||
| 130 | [updatePreferences], | ||
| 131 | ); | ||
| 132 | |||
| 133 | const value = useMemo( | ||
| 134 | (): AppearancePreferencesContextValue => ({ | ||
| 135 | appearance: resolveAppearance(preferences), | ||
| 136 | isReady, | ||
| 137 | setBaseFontSize, | ||
| 138 | setTerminalFontSize, | ||
| 139 | setCodeFontSize, | ||
| 140 | setCodeWordBreak, | ||
| 141 | }), | ||
| 142 | [preferences, isReady, setBaseFontSize, setTerminalFontSize, setCodeFontSize, setCodeWordBreak], | ||
| 143 | ); | ||
| 144 | |||
| 145 | return ( | ||
| 146 | <AppearancePreferencesContext.Provider value={value}> | ||
| 147 | {props.children} | ||
| 148 | </AppearancePreferencesContext.Provider> | ||
| 149 | ); | ||
| 150 | } | ||
| 151 | |||
| 152 | export function useAppearancePreferences(): AppearancePreferencesContextValue { | ||
| 153 | const context = useContext(AppearancePreferencesContext); | ||
| 154 | if (!context) { | ||
| 155 | throw new Error("useAppearancePreferences must be used within AppearancePreferencesProvider"); | ||
| 156 | } | ||
| 157 | return context; | ||
| 158 | } | ||
apps/mobile/src/features/settings/appearance/components/AppearancePreviews.tsx created+177| ... | @@ -0,0 +1,177 @@ | ||
| 1 | import { Platform, ScrollView, View, useColorScheme } from "react-native"; | ||
| 2 | |||
| 3 | import { AppText as Text } from "../../../../components/AppText"; | ||
| 4 | import { | ||
| 5 | resolveMarkdownFontSizes, | ||
| 6 | resolveMobileCodeSurface, | ||
| 7 | } from "../../../../lib/appearancePreferences"; | ||
| 8 | import { useThemeColor } from "../../../../lib/useThemeColor"; | ||
| 9 | import { getPierreTerminalTheme } from "../../../terminal/terminalTheme"; | ||
| 10 | |||
| 11 | const CODE_FONT_FAMILY = Platform.select({ | ||
| 12 | ios: "ui-monospace", | ||
| 13 | android: "monospace", | ||
| 14 | default: "monospace", | ||
| 15 | }); | ||
| 16 | |||
| 17 | /** Hairline between a section's preview surface and its control rows. */ | ||
| 18 | export function AppearancePreviewSeparator() { | ||
| 19 | return <View className="h-px bg-separator" />; | ||
| 20 | } | ||
| 21 | |||
| 22 | /** Live sample of body text rendered at the chosen base font size. */ | ||
| 23 | export function TextAppearancePreview(props: { readonly fontSize: number }) { | ||
| 24 | const sizes = resolveMarkdownFontSizes(props.fontSize); | ||
| 25 | |||
| 26 | return ( | ||
| 27 | <View className="gap-1 p-4"> | ||
| 28 | <Text | ||
| 29 | className="text-foreground" | ||
| 30 | style={{ fontSize: sizes.m, lineHeight: sizes.bodyLineHeight }} | ||
| 31 | > | ||
| 32 | The quick brown fox jumps over the lazy dog. | ||
| 33 | </Text> | ||
| 34 | <Text | ||
| 35 | className="text-foreground-muted" | ||
| 36 | style={{ fontSize: sizes.s, lineHeight: Math.round(sizes.s * 1.4) }} | ||
| 37 | > | ||
| 38 | Messages, labels, and headings scale with this size. | ||
| 39 | </Text> | ||
| 40 | </View> | ||
| 41 | ); | ||
| 42 | } | ||
| 43 | |||
| 44 | /** | ||
| 45 | * Live terminal sample using the real terminal theme's text colors and font, | ||
| 46 | * on the shared card background so it reads like the other previews. | ||
| 47 | */ | ||
| 48 | export function TerminalAppearancePreview(props: { readonly fontSize: number }) { | ||
| 49 | const scheme = useColorScheme() === "light" ? "light" : "dark"; | ||
| 50 | const theme = getPierreTerminalTheme(scheme); | ||
| 51 | const lineHeight = Math.round(props.fontSize * 1.6); | ||
| 52 | const lineStyle = { | ||
| 53 | fontFamily: "Menlo", | ||
| 54 | fontSize: props.fontSize, | ||
| 55 | lineHeight, | ||
| 56 | } as const; | ||
| 57 | |||
| 58 | return ( | ||
| 59 | <View className="p-4"> | ||
| 60 | <Text style={[lineStyle, { color: theme.foreground }]}>$ npm run dev</Text> | ||
| 61 | <Text style={[lineStyle, { color: theme.palette[2] }]}>✓ Ready in 430ms</Text> | ||
| 62 | <Text style={[lineStyle, { color: theme.foreground }]}> | ||
| 63 | Local: http://localhost:3000{" "} | ||
| 64 | <Text style={[lineStyle, { color: theme.cursorForeground }]}>▏</Text> | ||
| 65 | </Text> | ||
| 66 | </View> | ||
| 67 | ); | ||
| 68 | } | ||
| 69 | |||
| 70 | interface CodePreviewToken { | ||
| 71 | readonly text: string; | ||
| 72 | readonly keyword?: boolean; | ||
| 73 | } | ||
| 74 | |||
| 75 | interface CodePreviewLine { | ||
| 76 | readonly id: string; | ||
| 77 | readonly tokens: ReadonlyArray<CodePreviewToken>; | ||
| 78 | } | ||
| 79 | |||
| 80 | const CODE_PREVIEW_LINES: ReadonlyArray<CodePreviewLine> = [ | ||
| 81 | { | ||
| 82 | id: "signature", | ||
| 83 | tokens: [{ text: "function", keyword: true }, { text: " formatUser(user) {" }], | ||
| 84 | }, | ||
| 85 | { | ||
| 86 | id: "body", | ||
| 87 | tokens: [ | ||
| 88 | { text: " " }, | ||
| 89 | { text: "return", keyword: true }, | ||
| 90 | { text: " `${user.name} <${user.email}>` // demonstrates how long lines behave" }, | ||
| 91 | ], | ||
| 92 | }, | ||
| 93 | { id: "close", tokens: [{ text: "}" }] }, | ||
| 94 | ]; | ||
| 95 | |||
| 96 | /** | ||
| 97 | * Live code sample matching the code & diff surface metrics. Long lines wrap | ||
| 98 | * when word break is on and scroll horizontally when it is off, mirroring the | ||
| 99 | * real code surface. | ||
| 100 | */ | ||
| 101 | export function CodeAppearancePreview(props: { | ||
| 102 | readonly fontSize: number; | ||
| 103 | readonly wordBreak: boolean; | ||
| 104 | }) { | ||
| 105 | const surface = resolveMobileCodeSurface(props.fontSize); | ||
| 106 | const lineNumberColor = useThemeColor("--color-icon-subtle"); | ||
| 107 | const keywordColor = useThemeColor("--color-md-link"); | ||
| 108 | |||
| 109 | const lineNumber = (line: CodePreviewLine, index: number) => ( | ||
| 110 | <Text | ||
| 111 | className="text-right" | ||
| 112 | key={line.id} | ||
| 113 | style={{ | ||
| 114 | color: lineNumberColor, | ||
| 115 | fontFamily: CODE_FONT_FAMILY, | ||
| 116 | fontSize: surface.lineNumberFontSize, | ||
| 117 | lineHeight: surface.rowHeight, | ||
| 118 | width: 22, | ||
| 119 | }} | ||
| 120 | > | ||
| 121 | {index + 1} | ||
| 122 | </Text> | ||
| 123 | ); | ||
| 124 | |||
| 125 | const codeLine = (line: CodePreviewLine, wrap: boolean) => ( | ||
| 126 | <Text | ||
| 127 | className="text-foreground" | ||
| 128 | key={line.id} | ||
| 129 | numberOfLines={wrap ? undefined : 1} | ||
| 130 | style={{ | ||
| 131 | fontFamily: CODE_FONT_FAMILY, | ||
| 132 | fontSize: surface.fontSize, | ||
| 133 | lineHeight: surface.rowHeight, | ||
| 134 | }} | ||
| 135 | > | ||
| 136 | {line.tokens.map((token) => ( | ||
| 137 | <Text | ||
| 138 | key={token.text} | ||
| 139 | style={{ | ||
| 140 | color: token.keyword ? keywordColor : undefined, | ||
| 141 | fontFamily: CODE_FONT_FAMILY, | ||
| 142 | fontSize: surface.fontSize, | ||
| 143 | lineHeight: surface.rowHeight, | ||
| 144 | }} | ||
| 145 | > | ||
| 146 | {token.text} | ||
| 147 | </Text> | ||
| 148 | ))} | ||
| 149 | </Text> | ||
| 150 | ); | ||
| 151 | |||
| 152 | if (props.wordBreak) { | ||
| 153 | return ( | ||
| 154 | <View className="p-4"> | ||
| 155 | {CODE_PREVIEW_LINES.map((line, index) => ( | ||
| 156 | <View className="flex-row" key={line.id}> | ||
| 157 | {lineNumber(line, index)} | ||
| 158 | <View className="flex-1 pl-3">{codeLine(line, true)}</View> | ||
| 159 | </View> | ||
| 160 | ))} | ||
| 161 | </View> | ||
| 162 | ); | ||
| 163 | } | ||
| 164 | |||
| 165 | return ( | ||
| 166 | <View className="flex-row p-4"> | ||
| 167 | <View>{CODE_PREVIEW_LINES.map((line, index) => lineNumber(line, index))}</View> | ||
| 168 | <ScrollView | ||
| 169 | horizontal | ||
| 170 | contentContainerStyle={{ paddingLeft: 12 }} | ||
| 171 | showsHorizontalScrollIndicator={false} | ||
| 172 | > | ||
| 173 | <View>{CODE_PREVIEW_LINES.map((line) => codeLine(line, false))}</View> | ||
| 174 | </ScrollView> | ||
| 175 | </View> | ||
| 176 | ); | ||
| 177 | } | ||
apps/mobile/src/features/settings/appearance/components/FontSizeSliderRow.tsx created+214| ... | @@ -0,0 +1,214 @@ | ||
| 1 | import * as Haptics from "expo-haptics"; | ||
| 2 | import { SymbolView } from "expo-symbols"; | ||
| 3 | import { useCallback, useEffect, useMemo, useRef } from "react"; | ||
| 4 | import { View, type AccessibilityActionEvent } from "react-native"; | ||
| 5 | import { Gesture, GestureDetector } from "react-native-gesture-handler"; | ||
| 6 | import Animated, { | ||
| 7 | runOnJS, | ||
| 8 | useAnimatedStyle, | ||
| 9 | useSharedValue, | ||
| 10 | withTiming, | ||
| 11 | } from "react-native-reanimated"; | ||
| 12 | import type { ComponentProps } from "react"; | ||
| 13 | |||
| 14 | import { AppText as Text } from "../../../../components/AppText"; | ||
| 15 | import { useThemeColor } from "../../../../lib/useThemeColor"; | ||
| 16 | |||
| 17 | type SymbolName = ComponentProps<typeof SymbolView>["name"]; | ||
| 18 | |||
| 19 | const THUMB_SIZE = 26; | ||
| 20 | const TRACK_HEIGHT = 4; | ||
| 21 | const SNAP_ANIMATION = { duration: 120 } as const; | ||
| 22 | |||
| 23 | function clampFraction(value: number): number { | ||
| 24 | "worklet"; | ||
| 25 | return Math.min(1, Math.max(0, value)); | ||
| 26 | } | ||
| 27 | |||
| 28 | export function FontSizeSliderRow(props: { | ||
| 29 | readonly disabled?: boolean; | ||
| 30 | readonly icon: SymbolName; | ||
| 31 | readonly label: string; | ||
| 32 | readonly valueLabel: string; | ||
| 33 | readonly min: number; | ||
| 34 | readonly max: number; | ||
| 35 | readonly step: number; | ||
| 36 | readonly value: number; | ||
| 37 | readonly onChange: (value: number) => void; | ||
| 38 | }) { | ||
| 39 | const icon = useThemeColor("--color-icon"); | ||
| 40 | const iconMuted = String(useThemeColor("--color-icon-muted")); | ||
| 41 | const trackColor = String(useThemeColor("--color-secondary-border")); | ||
| 42 | const fillColor = String(useThemeColor("--color-primary")); | ||
| 43 | |||
| 44 | const latest = useRef(props); | ||
| 45 | latest.current = props; | ||
| 46 | |||
| 47 | const { min, max, step, value, disabled } = props; | ||
| 48 | const fraction = (value - min) / (max - min); | ||
| 49 | |||
| 50 | const progress = useSharedValue(clampFraction(fraction)); | ||
| 51 | const trackWidth = useSharedValue(0); | ||
| 52 | const dragging = useSharedValue(false); | ||
| 53 | |||
| 54 | useEffect(() => { | ||
| 55 | if (!dragging.value) { | ||
| 56 | progress.value = withTiming(clampFraction(fraction), SNAP_ANIMATION); | ||
| 57 | } | ||
| 58 | }, [dragging, fraction, progress]); | ||
| 59 | |||
| 60 | const commit = useCallback((next: number) => { | ||
| 61 | if (next === latest.current.value) { | ||
| 62 | return; | ||
| 63 | } | ||
| 64 | Haptics.selectionAsync().catch(() => undefined); | ||
| 65 | latest.current.onChange(next); | ||
| 66 | }, []); | ||
| 67 | |||
| 68 | const gesture = useMemo(() => { | ||
| 69 | const snapValue = (raw: number): number => { | ||
| 70 | "worklet"; | ||
| 71 | const stepped = Math.round((raw - min) / step) * step + min; | ||
| 72 | return Math.min(max, Math.max(min, stepped)); | ||
| 73 | }; | ||
| 74 | const fractionAt = (x: number): number => { | ||
| 75 | "worklet"; | ||
| 76 | const usable = trackWidth.value - THUMB_SIZE; | ||
| 77 | if (usable <= 0) { | ||
| 78 | return 0; | ||
| 79 | } | ||
| 80 | return clampFraction((x - THUMB_SIZE / 2) / usable); | ||
| 81 | }; | ||
| 82 | const valueAtFraction = (f: number): number => { | ||
| 83 | "worklet"; | ||
| 84 | return snapValue(min + f * (max - min)); | ||
| 85 | }; | ||
| 86 | const fractionOfValue = (v: number): number => { | ||
| 87 | "worklet"; | ||
| 88 | return clampFraction((v - min) / (max - min)); | ||
| 89 | }; | ||
| 90 | |||
| 91 | const pan = Gesture.Pan() | ||
| 92 | .enabled(!disabled) | ||
| 93 | .activeOffsetX([-8, 8]) | ||
| 94 | .failOffsetY([-12, 12]) | ||
| 95 | .onUpdate((event) => { | ||
| 96 | dragging.value = true; | ||
| 97 | const f = fractionAt(event.x); | ||
| 98 | progress.value = f; | ||
| 99 | runOnJS(commit)(valueAtFraction(f)); | ||
| 100 | }) | ||
| 101 | .onFinalize(() => { | ||
| 102 | if (!dragging.value) { | ||
| 103 | return; | ||
| 104 | } | ||
| 105 | dragging.value = false; | ||
| 106 | progress.value = withTiming( | ||
| 107 | fractionOfValue(valueAtFraction(progress.value)), | ||
| 108 | SNAP_ANIMATION, | ||
| 109 | ); | ||
| 110 | }); | ||
| 111 | |||
| 112 | const tap = Gesture.Tap() | ||
| 113 | .enabled(!disabled) | ||
| 114 | .onEnd((event) => { | ||
| 115 | const next = valueAtFraction(fractionAt(event.x)); | ||
| 116 | progress.value = withTiming(fractionOfValue(next), SNAP_ANIMATION); | ||
| 117 | runOnJS(commit)(next); | ||
| 118 | }); | ||
| 119 | |||
| 120 | return Gesture.Race(pan, tap); | ||
| 121 | }, [commit, disabled, dragging, max, min, progress, step, trackWidth]); | ||
| 122 | |||
| 123 | const fillStyle = useAnimatedStyle(() => ({ | ||
| 124 | width: THUMB_SIZE / 2 + progress.value * Math.max(0, trackWidth.value - THUMB_SIZE), | ||
| 125 | })); | ||
| 126 | const thumbStyle = useAnimatedStyle(() => ({ | ||
| 127 | transform: [{ translateX: progress.value * Math.max(0, trackWidth.value - THUMB_SIZE) }], | ||
| 128 | })); | ||
| 129 | |||
| 130 | const handleAccessibilityAction = (event: AccessibilityActionEvent) => { | ||
| 131 | if (event.nativeEvent.actionName === "increment") { | ||
| 132 | commit(Math.min(max, value + step)); | ||
| 133 | } else if (event.nativeEvent.actionName === "decrement") { | ||
| 134 | commit(Math.max(min, value - step)); | ||
| 135 | } | ||
| 136 | }; | ||
| 137 | |||
| 138 | return ( | ||
| 139 | <View className="gap-1 p-4" style={{ opacity: disabled ? 0.45 : 1 }}> | ||
| 140 | <View className="flex-row items-center gap-4"> | ||
| 141 | <SymbolView | ||
| 142 | name={props.icon} | ||
| 143 | size={22} | ||
| 144 | tintColor={icon} | ||
| 145 | type="monochrome" | ||
| 146 | weight="regular" | ||
| 147 | /> | ||
| 148 | <Text className="flex-1 text-lg text-foreground">{props.label}</Text> | ||
| 149 | <Text className="text-base font-t3-medium text-foreground-muted">{props.valueLabel}</Text> | ||
| 150 | </View> | ||
| 151 | <View className="flex-row items-center gap-3"> | ||
| 152 | <SymbolView | ||
| 153 | name="textformat.size.smaller" | ||
| 154 | size={15} | ||
| 155 | tintColor={iconMuted} | ||
| 156 | type="monochrome" | ||
| 157 | weight="regular" | ||
| 158 | /> | ||
| 159 | <GestureDetector gesture={gesture}> | ||
| 160 | <View | ||
| 161 | accessible | ||
| 162 | accessibilityActions={[ | ||
| 163 | { name: "increment", label: `Increase ${props.label}` }, | ||
| 164 | { name: "decrement", label: `Decrease ${props.label}` }, | ||
| 165 | ]} | ||
| 166 | accessibilityLabel={props.label} | ||
| 167 | accessibilityRole="adjustable" | ||
| 168 | accessibilityValue={{ min, max, now: value, text: props.valueLabel }} | ||
| 169 | className="h-11 flex-1 justify-center" | ||
| 170 | onAccessibilityAction={handleAccessibilityAction} | ||
| 171 | onLayout={(event) => { | ||
| 172 | trackWidth.value = event.nativeEvent.layout.width; | ||
| 173 | }} | ||
| 174 | > | ||
| 175 | <View | ||
| 176 | className="w-full rounded-full" | ||
| 177 | style={{ backgroundColor: trackColor, height: TRACK_HEIGHT }} | ||
| 178 | > | ||
| 179 | <Animated.View | ||
| 180 | className="absolute inset-y-0 left-0 rounded-full" | ||
| 181 | style={[{ backgroundColor: fillColor }, fillStyle]} | ||
| 182 | /> | ||
| 183 | </View> | ||
| 184 | <Animated.View | ||
| 185 | className="absolute left-0 rounded-full bg-white" | ||
| 186 | style={[ | ||
| 187 | { | ||
| 188 | borderColor: "rgba(0, 0, 0, 0.06)", | ||
| 189 | borderWidth: 1, | ||
| 190 | height: THUMB_SIZE, | ||
| 191 | marginTop: -THUMB_SIZE / 2, | ||
| 192 | shadowColor: "#000000", | ||
| 193 | shadowOffset: { height: 2, width: 0 }, | ||
| 194 | shadowOpacity: 0.18, | ||
| 195 | shadowRadius: 3, | ||
| 196 | top: "50%", | ||
| 197 | width: THUMB_SIZE, | ||
| 198 | }, | ||
| 199 | thumbStyle, | ||
| 200 | ]} | ||
| 201 | /> | ||
| 202 | </View> | ||
| 203 | </GestureDetector> | ||
| 204 | <SymbolView | ||
| 205 | name="textformat.size.larger" | ||
| 206 | size={22} | ||
| 207 | tintColor={iconMuted} | ||
| 208 | type="monochrome" | ||
| 209 | weight="regular" | ||
| 210 | /> | ||
| 211 | </View> | ||
| 212 | </View> | ||
| 213 | ); | ||
| 214 | } | ||
apps/mobile/src/features/settings/appearance/sections/CodeAppearanceSection.tsx created+64| ... | @@ -0,0 +1,64 @@ | ||
| 1 | import { useCallback } from "react"; | ||
| 2 | |||
| 3 | import { | ||
| 4 | CODE_FONT_SIZE_STEP, | ||
| 5 | MAX_CODE_FONT_SIZE, | ||
| 6 | MIN_CODE_FONT_SIZE, | ||
| 7 | } from "../../../../lib/appearancePreferences"; | ||
| 8 | import { SettingsSection } from "../../components/SettingsSection"; | ||
| 9 | import { SettingsSwitchRow } from "../../components/SettingsSwitchRow"; | ||
| 10 | import { useAppearancePreferences } from "../AppearancePreferencesProvider"; | ||
| 11 | import { | ||
| 12 | AppearancePreviewSeparator, | ||
| 13 | CodeAppearancePreview, | ||
| 14 | } from "../components/AppearancePreviews"; | ||
| 15 | import { FontSizeSliderRow } from "../components/FontSizeSliderRow"; | ||
| 16 | |||
| 17 | export function CodeAppearanceSection() { | ||
| 18 | const { isReady, appearance, setCodeFontSize, setCodeWordBreak } = useAppearancePreferences(); | ||
| 19 | const custom = appearance.isCodeFontSizeCustom; | ||
| 20 | |||
| 21 | const handleToggleCustom = useCallback( | ||
| 22 | (enabled: boolean) => { | ||
| 23 | setCodeFontSize(enabled ? appearance.codeFontSize : null); | ||
| 24 | }, | ||
| 25 | [appearance.codeFontSize, setCodeFontSize], | ||
| 26 | ); | ||
| 27 | |||
| 28 | return ( | ||
| 29 | <SettingsSection title="Code & Diffs"> | ||
| 30 | <CodeAppearancePreview | ||
| 31 | fontSize={appearance.codeFontSize} | ||
| 32 | wordBreak={appearance.codeWordBreak} | ||
| 33 | /> | ||
| 34 | <AppearancePreviewSeparator /> | ||
| 35 | <SettingsSwitchRow | ||
| 36 | disabled={!isReady} | ||
| 37 | icon="chevron.left.forwardslash.chevron.right" | ||
| 38 | label="Custom font size" | ||
| 39 | onValueChange={handleToggleCustom} | ||
| 40 | value={custom} | ||
| 41 | /> | ||
| 42 | {custom ? ( | ||
| 43 | <FontSizeSliderRow | ||
| 44 | disabled={!isReady} | ||
| 45 | icon="textformat.size" | ||
| 46 | label="Font size" | ||
| 47 | max={MAX_CODE_FONT_SIZE} | ||
| 48 | min={MIN_CODE_FONT_SIZE} | ||
| 49 | onChange={setCodeFontSize} | ||
| 50 | step={CODE_FONT_SIZE_STEP} | ||
| 51 | value={appearance.codeFontSize} | ||
| 52 | valueLabel={`${appearance.codeFontSize} pt`} | ||
| 53 | /> | ||
| 54 | ) : null} | ||
| 55 | <SettingsSwitchRow | ||
| 56 | disabled={!isReady} | ||
| 57 | icon="text.word.spacing" | ||
| 58 | label="Word break" | ||
| 59 | onValueChange={setCodeWordBreak} | ||
| 60 | value={appearance.codeWordBreak} | ||
| 61 | /> | ||
| 62 | </SettingsSection> | ||
| 63 | ); | ||
| 64 | } | ||
apps/mobile/src/features/settings/appearance/sections/TerminalAppearanceSection.tsx created+54| ... | @@ -0,0 +1,54 @@ | ||
| 1 | import { useCallback } from "react"; | ||
| 2 | |||
| 3 | import { | ||
| 4 | MAX_TERMINAL_FONT_SIZE, | ||
| 5 | MIN_TERMINAL_FONT_SIZE, | ||
| 6 | TERMINAL_FONT_SIZE_STEP, | ||
| 7 | } from "../../../../lib/appearancePreferences"; | ||
| 8 | import { SettingsSection } from "../../components/SettingsSection"; | ||
| 9 | import { SettingsSwitchRow } from "../../components/SettingsSwitchRow"; | ||
| 10 | import { useAppearancePreferences } from "../AppearancePreferencesProvider"; | ||
| 11 | import { | ||
| 12 | AppearancePreviewSeparator, | ||
| 13 | TerminalAppearancePreview, | ||
| 14 | } from "../components/AppearancePreviews"; | ||
| 15 | import { FontSizeSliderRow } from "../components/FontSizeSliderRow"; | ||
| 16 | |||
| 17 | export function TerminalAppearanceSection() { | ||
| 18 | const { isReady, appearance, setTerminalFontSize } = useAppearancePreferences(); | ||
| 19 | const custom = appearance.isTerminalFontSizeCustom; | ||
| 20 | |||
| 21 | const handleToggleCustom = useCallback( | ||
| 22 | (enabled: boolean) => { | ||
| 23 | setTerminalFontSize(enabled ? appearance.terminalFontSize : null); | ||
| 24 | }, | ||
| 25 | [appearance.terminalFontSize, setTerminalFontSize], | ||
| 26 | ); | ||
| 27 | |||
| 28 | return ( | ||
| 29 | <SettingsSection title="Terminal"> | ||
| 30 | <TerminalAppearancePreview fontSize={appearance.terminalFontSize} /> | ||
| 31 | <AppearancePreviewSeparator /> | ||
| 32 | <SettingsSwitchRow | ||
| 33 | disabled={!isReady} | ||
| 34 | icon="terminal" | ||
| 35 | label="Custom font size" | ||
| 36 | onValueChange={handleToggleCustom} | ||
| 37 | value={custom} | ||
| 38 | /> | ||
| 39 | {custom ? ( | ||
| 40 | <FontSizeSliderRow | ||
| 41 | disabled={!isReady} | ||
| 42 | icon="textformat.size" | ||
| 43 | label="Font size" | ||
| 44 | max={MAX_TERMINAL_FONT_SIZE} | ||
| 45 | min={MIN_TERMINAL_FONT_SIZE} | ||
| 46 | onChange={setTerminalFontSize} | ||
| 47 | step={TERMINAL_FONT_SIZE_STEP} | ||
| 48 | value={appearance.terminalFontSize} | ||
| 49 | valueLabel={`${appearance.terminalFontSize.toFixed(1)} pt`} | ||
| 50 | /> | ||
| 51 | ) : null} | ||
| 52 | </SettingsSection> | ||
| 53 | ); | ||
| 54 | } | ||
apps/mobile/src/features/settings/appearance/sections/TextAppearanceSection.tsx created+34| ... | @@ -0,0 +1,34 @@ | ||
| 1 | import { | ||
| 2 | BASE_FONT_SIZE_STEP, | ||
| 3 | MAX_BASE_FONT_SIZE, | ||
| 4 | MIN_BASE_FONT_SIZE, | ||
| 5 | } from "../../../../lib/appearancePreferences"; | ||
| 6 | import { SettingsSection } from "../../components/SettingsSection"; | ||
| 7 | import { useAppearancePreferences } from "../AppearancePreferencesProvider"; | ||
| 8 | import { | ||
| 9 | AppearancePreviewSeparator, | ||
| 10 | TextAppearancePreview, | ||
| 11 | } from "../components/AppearancePreviews"; | ||
| 12 | import { FontSizeSliderRow } from "../components/FontSizeSliderRow"; | ||
| 13 | |||
| 14 | export function TextAppearanceSection() { | ||
| 15 | const { isReady, appearance, setBaseFontSize } = useAppearancePreferences(); | ||
| 16 | |||
| 17 | return ( | ||
| 18 | <SettingsSection title="Text"> | ||
| 19 | <TextAppearancePreview fontSize={appearance.baseFontSize} /> | ||
| 20 | <AppearancePreviewSeparator /> | ||
| 21 | <FontSizeSliderRow | ||
| 22 | disabled={!isReady} | ||
| 23 | icon="textformat.size" | ||
| 24 | label="Text size" | ||
| 25 | max={MAX_BASE_FONT_SIZE} | ||
| 26 | min={MIN_BASE_FONT_SIZE} | ||
| 27 | onChange={setBaseFontSize} | ||
| 28 | step={BASE_FONT_SIZE_STEP} | ||
| 29 | value={appearance.baseFontSize} | ||
| 30 | valueLabel={`${appearance.baseFontSize} pt`} | ||
| 31 | /> | ||
| 32 | </SettingsSection> | ||
| 33 | ); | ||
| 34 | } | ||
apps/mobile/src/features/settings/appearance/useAppearanceCodeSurface.ts created+34| ... | @@ -0,0 +1,34 @@ | ||
| 1 | import { useMemo } from "react"; | ||
| 2 | |||
| 3 | import { | ||
| 4 | resolveMobileCodeSurface, | ||
| 5 | type ResolvedMobileCodeSurface, | ||
| 6 | } from "../../../lib/appearancePreferences"; | ||
| 7 | import { createNativeReviewDiffStyle } from "../../review/nativeReviewDiffAdapter"; | ||
| 8 | import { createNativeSourceStyle } from "../../files/nativeSourceFileAdapter"; | ||
| 9 | import { useAppearancePreferences } from "./AppearancePreferencesProvider"; | ||
| 10 | |||
| 11 | export function useAppearanceCodeSurface(): { | ||
| 12 | readonly codeSurface: ResolvedMobileCodeSurface; | ||
| 13 | readonly codeWordBreak: boolean; | ||
| 14 | readonly nativeSourceStyle: ReturnType<typeof createNativeSourceStyle>; | ||
| 15 | readonly nativeReviewDiffStyle: ReturnType<typeof createNativeReviewDiffStyle>; | ||
| 16 | } { | ||
| 17 | const { appearance } = useAppearancePreferences(); | ||
| 18 | const codeSurface = useMemo( | ||
| 19 | () => resolveMobileCodeSurface(appearance.codeFontSize), | ||
| 20 | [appearance.codeFontSize], | ||
| 21 | ); | ||
| 22 | const nativeSourceStyle = useMemo(() => createNativeSourceStyle(codeSurface), [codeSurface]); | ||
| 23 | const nativeReviewDiffStyle = useMemo( | ||
| 24 | () => createNativeReviewDiffStyle(codeSurface), | ||
| 25 | [codeSurface], | ||
| 26 | ); | ||
| 27 | |||
| 28 | return { | ||
| 29 | codeSurface, | ||
| 30 | codeWordBreak: appearance.codeWordBreak, | ||
| 31 | nativeSourceStyle, | ||
| 32 | nativeReviewDiffStyle, | ||
| 33 | }; | ||
| 34 | } | ||
apps/mobile/src/features/settings/appearance/useScaledTextRole.ts created+36| ... | @@ -0,0 +1,36 @@ | ||
| 1 | import { useCSSVariable } from "uniwind"; | ||
| 2 | |||
| 3 | import { MOBILE_TYPOGRAPHY } from "../../../lib/typography"; | ||
| 4 | |||
| 5 | const TEXT_ROLE_VARIABLES = { | ||
| 6 | micro: "--text-3xs", | ||
| 7 | caption: "--text-2xs", | ||
| 8 | label: "--text-xs", | ||
| 9 | footnote: "--text-sm", | ||
| 10 | body: "--text-base", | ||
| 11 | headline: "--text-lg", | ||
| 12 | title: "--text-xl", | ||
| 13 | largeTitle: "--text-2xl", | ||
| 14 | display: "--text-3xl", | ||
| 15 | } as const satisfies Record<keyof typeof MOBILE_TYPOGRAPHY, string>; | ||
| 16 | |||
| 17 | export interface ScaledTextRole { | ||
| 18 | readonly fontSize: number; | ||
| 19 | readonly lineHeight: number; | ||
| 20 | } | ||
| 21 | |||
| 22 | /** | ||
| 23 | * Reads a typography role's current size from the Uniwind `--text-*` CSS | ||
| 24 | * variables (scaled at runtime with the base font size). Use for style-prop | ||
| 25 | * consumers that can't express their size as a `text-*` className. Reactive: | ||
| 26 | * re-renders when the appearance provider re-injects the variables. | ||
| 27 | */ | ||
| 28 | export function useScaledTextRole(role: keyof typeof MOBILE_TYPOGRAPHY): ScaledTextRole { | ||
| 29 | const variable = TEXT_ROLE_VARIABLES[role]; | ||
| 30 | const [fontSize, lineHeight] = useCSSVariable([variable, `${variable}--line-height`]); | ||
| 31 | |||
| 32 | return { | ||
| 33 | fontSize: typeof fontSize === "number" ? fontSize : MOBILE_TYPOGRAPHY[role].fontSize, | ||
| 34 | lineHeight: typeof lineHeight === "number" ? lineHeight : MOBILE_TYPOGRAPHY[role].lineHeight, | ||
| 35 | }; | ||
| 36 | } | ||
apps/mobile/src/features/settings/components/SettingsRow.tsx created+76| ... | @@ -0,0 +1,76 @@ | ||
| 1 | import { useNavigation } from "@react-navigation/native"; | ||
| 2 | import { SymbolView } from "expo-symbols"; | ||
| 3 | import type { ComponentProps } from "react"; | ||
| 4 | import { Pressable, View } from "react-native"; | ||
| 5 | |||
| 6 | import { AppText as Text } from "../../../components/AppText"; | ||
| 7 | import { useThemeColor } from "../../../lib/useThemeColor"; | ||
| 8 | import type { SettingsSheetTarget } from "./settings-sheet-targets"; | ||
| 9 | |||
| 10 | type SymbolName = ComponentProps<typeof SymbolView>["name"]; | ||
| 11 | |||
| 12 | export function SettingsRow(props: { | ||
| 13 | readonly disabled?: boolean; | ||
| 14 | readonly icon: SymbolName; | ||
| 15 | readonly label: string; | ||
| 16 | readonly value?: string; | ||
| 17 | readonly target?: SettingsSheetTarget; | ||
| 18 | readonly onPress?: () => void; | ||
| 19 | }) { | ||
| 20 | const navigation = useNavigation(); | ||
| 21 | const icon = useThemeColor("--color-icon"); | ||
| 22 | const chevron = useThemeColor("--color-chevron"); | ||
| 23 | const content = ( | ||
| 24 | <View | ||
| 25 | className="flex-row items-center gap-4 p-4" | ||
| 26 | style={{ opacity: props.disabled ? 0.45 : 1 }} | ||
| 27 | > | ||
| 28 | <SymbolView name={props.icon} size={22} tintColor={icon} type="monochrome" weight="regular" /> | ||
| 29 | <Text className="shrink-0 text-lg text-foreground" numberOfLines={1}> | ||
| 30 | {props.label} | ||
| 31 | </Text> | ||
| 32 | <View className="min-w-0 flex-1 items-end"> | ||
| 33 | {props.value ? ( | ||
| 34 | <Text | ||
| 35 | className="max-w-[180px] text-right text-base text-foreground-muted" | ||
| 36 | ellipsizeMode="middle" | ||
| 37 | numberOfLines={1} | ||
| 38 | > | ||
| 39 | {props.value} | ||
| 40 | </Text> | ||
| 41 | ) : null} | ||
| 42 | </View> | ||
| 43 | <SymbolView | ||
| 44 | name="chevron.right" | ||
| 45 | size={16} | ||
| 46 | tintColor={chevron} | ||
| 47 | type="monochrome" | ||
| 48 | weight="semibold" | ||
| 49 | /> | ||
| 50 | </View> | ||
| 51 | ); | ||
| 52 | |||
| 53 | const target = props.target; | ||
| 54 | if (target) { | ||
| 55 | return ( | ||
| 56 | <Pressable | ||
| 57 | accessibilityLabel={props.label} | ||
| 58 | accessibilityRole="button" | ||
| 59 | disabled={props.disabled} | ||
| 60 | onPress={() => | ||
| 61 | navigation.navigate("SettingsSheet", { | ||
| 62 | screen: target, | ||
| 63 | }) | ||
| 64 | } | ||
| 65 | > | ||
| 66 | {content} | ||
| 67 | </Pressable> | ||
| 68 | ); | ||
| 69 | } | ||
| 70 | |||
| 71 | return ( | ||
| 72 | <Pressable accessibilityRole="button" disabled={props.disabled} onPress={props.onPress}> | ||
| 73 | {content} | ||
| 74 | </Pressable> | ||
| 75 | ); | ||
| 76 | } | ||
apps/mobile/src/features/settings/components/SettingsSection.tsx created+18| ... | @@ -0,0 +1,18 @@ | ||
| 1 | import type { ReactNode } from "react"; | ||
| 2 | import { View } from "react-native"; | ||
| 3 | |||
| 4 | import { AppText as Text } from "../../../components/AppText"; | ||
| 5 | |||
| 6 | export function SettingsSection(props: { readonly title: string; readonly children: ReactNode }) { | ||
| 7 | return ( | ||
| 8 | <View className="gap-2"> | ||
| 9 | <Text className="px-2 text-sm font-t3-medium text-foreground-muted">{props.title}</Text> | ||
| 10 | <View | ||
| 11 | className="overflow-hidden rounded-[28px] bg-card" | ||
| 12 | style={{ borderCurve: "continuous" }} | ||
| 13 | > | ||
| 14 | {props.children} | ||
| 15 | </View> | ||
| 16 | </View> | ||
| 17 | ); | ||
| 18 | } | ||
apps/mobile/src/features/settings/components/SettingsSwitchRow.tsx created+37| ... | @@ -0,0 +1,37 @@ | ||
| 1 | import { SymbolView } from "expo-symbols"; | ||
| 2 | import type { ComponentProps } from "react"; | ||
| 3 | import { Switch, View } from "react-native"; | ||
| 4 | |||
| 5 | import { AppText as Text } from "../../../components/AppText"; | ||
| 6 | import { useThemeColor } from "../../../lib/useThemeColor"; | ||
| 7 | |||
| 8 | type SymbolName = ComponentProps<typeof SymbolView>["name"]; | ||
| 9 | |||
| 10 | export function SettingsSwitchRow(props: { | ||
| 11 | readonly disabled?: boolean; | ||
| 12 | readonly icon: SymbolName; | ||
| 13 | readonly label: string; | ||
| 14 | readonly value: boolean; | ||
| 15 | readonly onValueChange: (value: boolean) => void; | ||
| 16 | }) { | ||
| 17 | const icon = useThemeColor("--color-icon"); | ||
| 18 | const activeTrack = String(useThemeColor("--color-switch-active")); | ||
| 19 | const track = String(useThemeColor("--color-secondary-border")); | ||
| 20 | |||
| 21 | return ( | ||
| 22 | <View | ||
| 23 | className="flex-row items-center gap-4 p-4" | ||
| 24 | style={{ opacity: props.disabled ? 0.45 : 1 }} | ||
| 25 | > | ||
| 26 | <SymbolView name={props.icon} size={22} tintColor={icon} type="monochrome" weight="regular" /> | ||
| 27 | <Text className="flex-1 text-lg text-foreground">{props.label}</Text> | ||
| 28 | <Switch | ||
| 29 | disabled={props.disabled} | ||
| 30 | ios_backgroundColor={track} | ||
| 31 | onValueChange={props.onValueChange} | ||
| 32 | trackColor={{ false: track, true: activeTrack }} | ||
| 33 | value={props.value} | ||
| 34 | /> | ||
| 35 | </View> | ||
| 36 | ); | ||
| 37 | } | ||
apps/mobile/src/features/settings/components/settings-sheet-targets.ts created+1| ... | @@ -0,0 +1 @@ | ||
| 1 | export type SettingsSheetTarget = "SettingsEnvironments" | "SettingsArchive" | "SettingsAppearance"; | ||
apps/mobile/src/features/terminal/NativeTerminalSurface.tsx+17-44| ... | @@ -12,7 +12,10 @@ import { | ... | @@ -12,7 +12,10 @@ import { |
| 12 | 12 | ||
| 13 | import { AppText as Text } from "../../components/AppText"; | 13 | import { AppText as Text } from "../../components/AppText"; |
| 14 | import { MOBILE_TYPOGRAPHY } from "../../lib/typography"; | 14 | import { MOBILE_TYPOGRAPHY } from "../../lib/typography"; |
| 15 | import { resolveNativeTerminalSurfaceView } from "./nativeTerminalModule"; | 15 | import { |
| 16 | getNativeTerminalHardwareKeyRevision, | ||
| 17 | resolveNativeTerminalSurfaceView, | ||
| 18 | } from "./nativeTerminalModule"; | ||
| 16 | import { | 19 | import { |
| 17 | buildGhosttyThemeConfig, | 20 | buildGhosttyThemeConfig, |
| 18 | getPierreTerminalTheme, | 21 | getPierreTerminalTheme, |
| ... | @@ -92,9 +95,9 @@ const FallbackTerminalSurface = memo(function FallbackTerminalSurface(props: Ter | ... | @@ -92,9 +95,9 @@ const FallbackTerminalSurface = memo(function FallbackTerminalSurface(props: Ter |
| 92 | > | 95 | > |
| 93 | <View style={{ flex: 1, paddingHorizontal: 10, paddingVertical: 8 }}> | 96 | <View style={{ flex: 1, paddingHorizontal: 10, paddingVertical: 8 }}> |
| 94 | <Text | 97 | <Text |
| 98 | className="text-2xs" | ||
| 95 | style={{ | 99 | style={{ |
| 96 | color: theme.mutedForeground, | 100 | color: theme.mutedForeground, |
| 97 | fontSize: MOBILE_TYPOGRAPHY.caption.fontSize, | ||
| 98 | paddingBottom: 8, | 101 | paddingBottom: 8, |
| 99 | }} | 102 | }} |
| 100 | > | 103 | > |
| ... | @@ -137,11 +140,11 @@ const FallbackTerminalSurface = memo(function FallbackTerminalSurface(props: Ter | ... | @@ -137,11 +140,11 @@ const FallbackTerminalSurface = memo(function FallbackTerminalSurface(props: Ter |
| 137 | placeholder="type and press return" | 140 | placeholder="type and press return" |
| 138 | placeholderTextColor={theme.mutedForeground} | 141 | placeholderTextColor={theme.mutedForeground} |
| 139 | returnKeyType="send" | 142 | returnKeyType="send" |
| 143 | className="text-sm" | ||
| 140 | style={{ | 144 | style={{ |
| 141 | color: theme.foreground, | 145 | color: theme.foreground, |
| 142 | flex: 1, | 146 | flex: 1, |
| 143 | fontFamily: "Menlo", | 147 | fontFamily: "Menlo", |
| 144 | fontSize: MOBILE_TYPOGRAPHY.footnote.fontSize, | ||
| 145 | padding: 0, | 148 | padding: 0, |
| 146 | }} | 149 | }} |
| 147 | onSubmitEditing={(event) => { | 150 | onSubmitEditing={(event) => { |
| ... | @@ -163,10 +166,10 @@ const FallbackTerminalSurface = memo(function FallbackTerminalSurface(props: Ter | ... | @@ -163,10 +166,10 @@ const FallbackTerminalSurface = memo(function FallbackTerminalSurface(props: Ter |
| 163 | onPress={() => props.onInput("\u0003")} | 166 | onPress={() => props.onInput("\u0003")} |
| 164 | > | 167 | > |
| 165 | <Text | 168 | <Text |
| 169 | className="text-2xs" | ||
| 166 | style={{ | 170 | style={{ |
| 167 | color: theme.foreground, | 171 | color: theme.foreground, |
| 168 | fontFamily: "DMSans_700Bold", | 172 | fontFamily: "DMSans_700Bold", |
| 169 | fontSize: MOBILE_TYPOGRAPHY.caption.fontSize, | ||
| 170 | }} | 173 | }} |
| 171 | > | 174 | > |
| 172 | Ctrl-C | 175 | Ctrl-C |
| ... | @@ -179,7 +182,6 @@ const FallbackTerminalSurface = memo(function FallbackTerminalSurface(props: Ter | ... | @@ -179,7 +182,6 @@ const FallbackTerminalSurface = memo(function FallbackTerminalSurface(props: Ter |
| 179 | 182 | ||
| 180 | export const TerminalSurface = memo(function TerminalSurface(props: TerminalSurfaceProps) { | 183 | export const TerminalSurface = memo(function TerminalSurface(props: TerminalSurfaceProps) { |
| 181 | const fontSize = props.fontSize ?? MOBILE_TYPOGRAPHY.label.fontSize; | 184 | const fontSize = props.fontSize ?? MOBILE_TYPOGRAPHY.label.fontSize; |
| 182 | const keyboardInputRef = useRef<TextInput>(null); | ||
| 183 | const appearanceScheme = useColorScheme() === "light" ? "light" : "dark"; | 185 | const appearanceScheme = useColorScheme() === "light" ? "light" : "dark"; |
| 184 | const theme = props.theme ?? getPierreTerminalTheme(appearanceScheme); | 186 | const theme = props.theme ?? getPierreTerminalTheme(appearanceScheme); |
| 185 | const { onInput, onResize } = props; | 187 | const { onInput, onResize } = props; |
| ... | @@ -190,15 +192,23 @@ export const TerminalSurface = memo(function TerminalSurface(props: TerminalSurf | ... | @@ -190,15 +192,23 @@ export const TerminalSurface = memo(function TerminalSurface(props: TerminalSurf |
| 190 | terminalDebugLog("native:surface", { | 192 | terminalDebugLog("native:surface", { |
| 191 | terminalKey: props.terminalKey, | 193 | terminalKey: props.terminalKey, |
| 192 | native: hasNativeSurface, | 194 | native: hasNativeSurface, |
| 195 | // null = installed binary predates native hardware-key handling (rebuild needed). | ||
| 196 | hardwareKeyRevision: getNativeTerminalHardwareKeyRevision(), | ||
| 193 | bufferLen: props.buffer.length, | 197 | bufferLen: props.buffer.length, |
| 194 | isRunning: props.isRunning, | 198 | isRunning: props.isRunning, |
| 195 | }); | 199 | }); |
| 196 | }, [hasNativeSurface, props.buffer.length, props.isRunning, props.terminalKey]); | 200 | }, [hasNativeSurface, props.buffer.length, props.isRunning, props.terminalKey]); |
| 197 | const handleNativeInput = useCallback( | 201 | const handleNativeInput = useCallback( |
| 198 | (event: NativeSyntheticEvent<TerminalInputEvent>) => { | 202 | (event: NativeSyntheticEvent<TerminalInputEvent>) => { |
| 203 | if (!props.isRunning) { | ||
| 204 | return; | ||
| 205 | } | ||
| 206 | terminalDebugLog("native:onInput", { | ||
| 207 | codes: Array.from(event.nativeEvent.data, (char) => char.codePointAt(0)), | ||
| 208 | }); | ||
| 199 | onInput(event.nativeEvent.data); | 209 | onInput(event.nativeEvent.data); |
| 200 | }, | 210 | }, |
| 201 | [onInput], | 211 | [onInput, props.isRunning], |
| 202 | ); | 212 | ); |
| 203 | const handleNativeResize = useCallback( | 213 | const handleNativeResize = useCallback( |
| 204 | (event: NativeSyntheticEvent<TerminalResizeEvent>) => { | 214 | (event: NativeSyntheticEvent<TerminalResizeEvent>) => { |
| ... | @@ -210,33 +220,13 @@ export const TerminalSurface = memo(function TerminalSurface(props: TerminalSurf | ... | @@ -210,33 +220,13 @@ export const TerminalSurface = memo(function TerminalSurface(props: TerminalSurf |
| 210 | [onResize], | 220 | [onResize], |
| 211 | ); | 221 | ); |
| 212 | 222 | ||
| 213 | // Reopen focus through React and forward input normally; this avoids a native focus-command bridge. | ||
| 214 | useEffect(() => { | ||
| 215 | if (!NativeTerminalSurfaceView || (props.keyboardFocusRequest ?? 0) <= 0) { | ||
| 216 | return undefined; | ||
| 217 | } | ||
| 218 | |||
| 219 | keyboardInputRef.current?.blur(); | ||
| 220 | const focusFrame = requestAnimationFrame(() => keyboardInputRef.current?.focus()); | ||
| 221 | return () => cancelAnimationFrame(focusFrame); | ||
| 222 | }, [NativeTerminalSurfaceView, props.keyboardFocusRequest]); | ||
| 223 | |||
| 224 | const handleKeyboardInput = useCallback( | ||
| 225 | (data: string) => { | ||
| 226 | if (data.length > 0) { | ||
| 227 | onInput(data); | ||
| 228 | keyboardInputRef.current?.clear(); | ||
| 229 | } | ||
| 230 | }, | ||
| 231 | [onInput], | ||
| 232 | ); | ||
| 233 | |||
| 234 | if (NativeTerminalSurfaceView) { | 223 | if (NativeTerminalSurfaceView) { |
| 235 | return ( | 224 | return ( |
| 236 | <View style={props.style}> | 225 | <View style={props.style}> |
| 237 | <NativeTerminalSurfaceView | 226 | <NativeTerminalSurfaceView |
| 238 | appearanceScheme={appearanceScheme} | 227 | appearanceScheme={appearanceScheme} |
| 239 | backgroundColor={theme.background} | 228 | backgroundColor={theme.background} |
| 229 | focusRequest={props.isRunning ? (props.keyboardFocusRequest ?? 0) : 0} | ||
| 240 | foregroundColor={theme.foreground} | 230 | foregroundColor={theme.foreground} |
| 241 | mutedForegroundColor={theme.mutedForeground} | 231 | mutedForegroundColor={theme.mutedForeground} |
| 242 | terminalKey={props.terminalKey} | 232 | terminalKey={props.terminalKey} |
| ... | @@ -247,23 +237,6 @@ export const TerminalSurface = memo(function TerminalSurface(props: TerminalSurf | ... | @@ -247,23 +237,6 @@ export const TerminalSurface = memo(function TerminalSurface(props: TerminalSurf |
| 247 | onInput={handleNativeInput} | 237 | onInput={handleNativeInput} |
| 248 | onResize={handleNativeResize} | 238 | onResize={handleNativeResize} |
| 249 | /> | 239 | /> |
| 250 | <TextInput | ||
| 251 | ref={keyboardInputRef} | ||
| 252 | autoCapitalize="none" | ||
| 253 | autoCorrect={false} | ||
| 254 | blurOnSubmit={false} | ||
| 255 | caretHidden | ||
| 256 | editable={props.isRunning} | ||
| 257 | keyboardType="ascii-capable" | ||
| 258 | style={{ bottom: 0, height: 1, left: 0, opacity: 0.01, position: "absolute", width: 1 }} | ||
| 259 | onChangeText={handleKeyboardInput} | ||
| 260 | onKeyPress={(event) => { | ||
| 261 | if (event.nativeEvent.key === "Backspace") { | ||
| 262 | onInput("\u007f"); | ||
| 263 | } | ||
| 264 | }} | ||
| 265 | onSubmitEditing={() => onInput("\n")} | ||
| 266 | /> | ||
| 267 | </View> | 240 | </View> |
| 268 | ); | 241 | ); |
| 269 | } | 242 | } |
apps/mobile/src/features/terminal/ThreadTerminalRouteScreen.tsx+98-165| ... | @@ -1,9 +1,10 @@ | ... | @@ -1,9 +1,10 @@ |
| 1 | import { DEFAULT_TERMINAL_ID, EnvironmentId, ThreadId } from "@t3tools/contracts"; | 1 | import { DEFAULT_TERMINAL_ID, EnvironmentId, ThreadId } from "@t3tools/contracts"; |
| 2 | import { type KnownTerminalSession } from "@t3tools/client-runtime/state/terminal"; | 2 | import { type KnownTerminalSession } from "@t3tools/client-runtime/state/terminal"; |
| 3 | import { SymbolView } from "expo-symbols"; | 3 | import { SymbolView } from "expo-symbols"; |
| 4 | import { Stack, useLocalSearchParams, useRouter } from "expo-router"; | 4 | import { NativeHeaderToolbar, NativeStackScreenOptions } from "../../native/StackHeader"; |
| 5 | import { StackActions, useNavigation, type StaticScreenProps } from "@react-navigation/native"; | ||
| 5 | import { useCallback, useEffect, useMemo, useRef, useState } from "react"; | 6 | import { useCallback, useEffect, useMemo, useRef, useState } from "react"; |
| 6 | import { Pressable, Text as RNText, View, useColorScheme } from "react-native"; | 7 | import { Platform, Pressable, View, useColorScheme } from "react-native"; |
| 7 | import { | 8 | import { |
| 8 | KeyboardController, | 9 | KeyboardController, |
| 9 | KeyboardEvents, | 10 | KeyboardEvents, |
| ... | @@ -24,8 +25,13 @@ import { useEnvironmentPresentation } from "../../state/presentation"; | ... | @@ -24,8 +25,13 @@ import { useEnvironmentPresentation } from "../../state/presentation"; |
| 24 | import { terminalEnvironment } from "../../state/terminal"; | 25 | import { terminalEnvironment } from "../../state/terminal"; |
| 25 | import { useAtomCommand } from "../../state/use-atom-command"; | 26 | import { useAtomCommand } from "../../state/use-atom-command"; |
| 26 | import { useWorkspaceState } from "../../state/workspace"; | 27 | import { useWorkspaceState } from "../../state/workspace"; |
| 27 | import { buildThreadTerminalNavigation } from "../../lib/routes"; | 28 | import { |
| 28 | import { MOBILE_TYPOGRAPHY } from "../../lib/typography"; | 29 | MAX_TERMINAL_FONT_SIZE, |
| 30 | MIN_TERMINAL_FONT_SIZE, | ||
| 31 | TERMINAL_FONT_SIZE_STEP, | ||
| 32 | stepTerminalFontSize, | ||
| 33 | } from "../../lib/appearancePreferences"; | ||
| 34 | import { useAppearancePreferences } from "../settings/appearance/AppearancePreferencesProvider"; | ||
| 29 | import { | 35 | import { |
| 30 | useAttachedTerminalSession, | 36 | useAttachedTerminalSession, |
| 31 | useKnownTerminalSessions, | 37 | useKnownTerminalSessions, |
| ... | @@ -33,9 +39,9 @@ import { | ... | @@ -33,9 +39,9 @@ import { |
| 33 | import { useThreadSelection } from "../../state/use-thread-selection"; | 39 | import { useThreadSelection } from "../../state/use-thread-selection"; |
| 34 | import { useSelectedThreadDetail } from "../../state/use-thread-detail"; | 40 | import { useSelectedThreadDetail } from "../../state/use-thread-detail"; |
| 35 | import { EnvironmentConnectionNotice } from "../connection/EnvironmentConnectionNotice"; | 41 | import { EnvironmentConnectionNotice } from "../connection/EnvironmentConnectionNotice"; |
| 42 | import { useAdaptiveWorkspaceLayout } from "../layout/AdaptiveWorkspaceLayout"; | ||
| 36 | import { TerminalSurface } from "./NativeTerminalSurface"; | 43 | import { TerminalSurface } from "./NativeTerminalSurface"; |
| 37 | import { getPierreTerminalTheme } from "./terminalTheme"; | 44 | import { getPierreTerminalTheme } from "./terminalTheme"; |
| 38 | import { loadPreferences, savePreferencesPatch } from "../../lib/storage"; | ||
| 39 | import { terminalDebugLog } from "./terminalDebugLog"; | 45 | import { terminalDebugLog } from "./terminalDebugLog"; |
| 40 | import { | 46 | import { |
| 41 | getTerminalBufferReplayKey, | 47 | getTerminalBufferReplayKey, |
| ... | @@ -55,19 +61,7 @@ import { | ... | @@ -55,19 +61,7 @@ import { |
| 55 | resolveTerminalSessionLabel, | 61 | resolveTerminalSessionLabel, |
| 56 | type TerminalMenuSession, | 62 | type TerminalMenuSession, |
| 57 | } from "./terminalMenu"; | 63 | } from "./terminalMenu"; |
| 58 | import { | 64 | import { cacheTerminalGridSize, getCachedTerminalGridSize } from "./terminalUiState"; |
| 59 | DEFAULT_TERMINAL_FONT_SIZE, | ||
| 60 | MAX_TERMINAL_FONT_SIZE, | ||
| 61 | MIN_TERMINAL_FONT_SIZE, | ||
| 62 | TERMINAL_FONT_SIZE_STEP, | ||
| 63 | normalizeTerminalFontSize, | ||
| 64 | } from "./terminalPreferences"; | ||
| 65 | import { | ||
| 66 | cacheTerminalFontSize, | ||
| 67 | cacheTerminalGridSize, | ||
| 68 | getCachedTerminalFontSize, | ||
| 69 | getCachedTerminalGridSize, | ||
| 70 | } from "./terminalUiState"; | ||
| 71 | 65 | ||
| 72 | const DEFAULT_TERMINAL_COLS = 80; | 66 | const DEFAULT_TERMINAL_COLS = 80; |
| 73 | const DEFAULT_TERMINAL_ROWS = 24; | 67 | const DEFAULT_TERMINAL_ROWS = 24; |
| ... | @@ -153,19 +147,22 @@ function pickRunningTerminalSessionForBootstrap( | ... | @@ -153,19 +147,22 @@ function pickRunningTerminalSessionForBootstrap( |
| 153 | ); | 147 | ); |
| 154 | } | 148 | } |
| 155 | 149 | ||
| 156 | export function ThreadTerminalRouteScreen() { | 150 | type ThreadTerminalRouteScreenProps = StaticScreenProps<{ |
| 157 | const router = useRouter(); | 151 | readonly environmentId: string; |
| 152 | readonly threadId: string; | ||
| 153 | readonly terminalId?: string; | ||
| 154 | }>; | ||
| 155 | |||
| 156 | export function ThreadTerminalRouteScreen(props: ThreadTerminalRouteScreenProps) { | ||
| 157 | const navigation = useNavigation(); | ||
| 158 | const writeTerminal = useAtomCommand(terminalEnvironment.write, "terminal write"); | 158 | const writeTerminal = useAtomCommand(terminalEnvironment.write, "terminal write"); |
| 159 | const resizeTerminal = useAtomCommand(terminalEnvironment.resize, "terminal resize"); | 159 | const resizeTerminal = useAtomCommand(terminalEnvironment.resize, "terminal resize"); |
| 160 | const clearTerminal = useAtomCommand(terminalEnvironment.clear, "terminal clear"); | 160 | const clearTerminal = useAtomCommand(terminalEnvironment.clear, "terminal clear"); |
| 161 | const retryEnvironment = useAtomCommand(environmentCatalog.retryNow, "environment retry"); | 161 | const retryEnvironment = useAtomCommand(environmentCatalog.retryNow, "environment retry"); |
| 162 | const appearanceScheme = useColorScheme() === "light" ? "light" : "dark"; | 162 | const appearanceScheme = useColorScheme() === "light" ? "light" : "dark"; |
| 163 | const { state: workspaceState } = useWorkspaceState(); | 163 | const { state: workspaceState } = useWorkspaceState(); |
| 164 | const params = useLocalSearchParams<{ | 164 | const { layout, panes, togglePrimarySidebar } = useAdaptiveWorkspaceLayout(); |
| 165 | environmentId?: string | string[]; | 165 | const params = props.route.params; |
| 166 | threadId?: string | string[]; | ||
| 167 | terminalId?: string | string[]; | ||
| 168 | }>(); | ||
| 169 | const { selectedThread, selectedThreadProject, selectedEnvironmentConnection } = | 166 | const { selectedThread, selectedThreadProject, selectedEnvironmentConnection } = |
| 170 | useThreadSelection(); | 167 | useThreadSelection(); |
| 171 | const selectedThreadDetail = useSelectedThreadDetail(); | 168 | const selectedThreadDetail = useSelectedThreadDetail(); |
| ... | @@ -179,7 +176,12 @@ export function ThreadTerminalRouteScreen() { | ... | @@ -179,7 +176,12 @@ export function ThreadTerminalRouteScreen() { |
| 179 | const isEnvironmentReady = environment.presentation?.connection.phase === "connected"; | 176 | const isEnvironmentReady = environment.presentation?.connection.phase === "connected"; |
| 180 | const requestedTerminalId = firstRouteParam(params.terminalId); | 177 | const requestedTerminalId = firstRouteParam(params.terminalId); |
| 181 | const terminalId = requestedTerminalId ?? DEFAULT_TERMINAL_ID; | 178 | const terminalId = requestedTerminalId ?? DEFAULT_TERMINAL_ID; |
| 182 | const cachedFontSize = getCachedTerminalFontSize(); | 179 | const { |
| 180 | isReady: hasResolvedFontPreference, | ||
| 181 | appearance, | ||
| 182 | setTerminalFontSize, | ||
| 183 | } = useAppearancePreferences(); | ||
| 184 | const fontSize = appearance.terminalFontSize; | ||
| 183 | const cachedRouteGridSize = | 185 | const cachedRouteGridSize = |
| 184 | routeEnvironmentId && routeThreadId | 186 | routeEnvironmentId && routeThreadId |
| 185 | ? getCachedTerminalGridSize({ | 187 | ? getCachedTerminalGridSize({ |
| ... | @@ -239,7 +241,6 @@ export function ThreadTerminalRouteScreen() { | ... | @@ -239,7 +241,6 @@ export function ThreadTerminalRouteScreen() { |
| 239 | rows: DEFAULT_TERMINAL_ROWS, | 241 | rows: DEFAULT_TERMINAL_ROWS, |
| 240 | }, | 242 | }, |
| 241 | ); | 243 | ); |
| 242 | const [fontSize, setFontSize] = useState(cachedFontSize ?? DEFAULT_TERMINAL_FONT_SIZE); | ||
| 243 | const [keyboardFocusRequest, setKeyboardFocusRequest] = useState(0); | 244 | const [keyboardFocusRequest, setKeyboardFocusRequest] = useState(0); |
| 244 | const [isAccessoryDismissed, setIsAccessoryDismissed] = useState(false); | 245 | const [isAccessoryDismissed, setIsAccessoryDismissed] = useState(false); |
| 245 | const bufferReplayTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null); | 246 | const bufferReplayTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null); |
| ... | @@ -247,9 +248,6 @@ export function ThreadTerminalRouteScreen() { | ... | @@ -247,9 +248,6 @@ export function ThreadTerminalRouteScreen() { |
| 247 | const lastBufferReplayKeyRef = useRef<string | null>(null); | 248 | const lastBufferReplayKeyRef = useRef<string | null>(null); |
| 248 | const sentInitialInputKeyRef = useRef<string | null>(null); | 249 | const sentInitialInputKeyRef = useRef<string | null>(null); |
| 249 | const [readyBufferReplayKey, setReadyBufferReplayKey] = useState<string | null>(null); | 250 | const [readyBufferReplayKey, setReadyBufferReplayKey] = useState<string | null>(null); |
| 250 | const [hasResolvedFontPreference, setHasResolvedFontPreference] = useState( | ||
| 251 | cachedFontSize !== null, | ||
| 252 | ); | ||
| 253 | /** Default grid is always valid for attach; onResize refines cols/rows. Requiring a cached size blocked bootstrap for new terminal routes. */ | 251 | /** Default grid is always valid for attach; onResize refines cols/rows. Requiring a cached size blocked bootstrap for new terminal routes. */ |
| 254 | const [hasMeasuredSurface, setHasMeasuredSurface] = useState(true); | 252 | const [hasMeasuredSurface, setHasMeasuredSurface] = useState(true); |
| 255 | const [pendingModifierState, setPendingModifierState] = useState<{ | 253 | const [pendingModifierState, setPendingModifierState] = useState<{ |
| ... | @@ -404,24 +402,10 @@ export function ThreadTerminalRouteScreen() { | ... | @@ -404,24 +402,10 @@ export function ThreadTerminalRouteScreen() { |
| 404 | ); | 402 | ); |
| 405 | 403 | ||
| 406 | const terminalTheme = getPierreTerminalTheme(appearanceScheme); | 404 | const terminalTheme = getPierreTerminalTheme(appearanceScheme); |
| 405 | const usesNativeHeaderGlass = Platform.OS === "ios"; | ||
| 407 | const pendingModifier = | 406 | const pendingModifier = |
| 408 | pendingModifierState.terminalId === terminalId ? pendingModifierState.value : null; | 407 | pendingModifierState.terminalId === terminalId ? pendingModifierState.value : null; |
| 409 | const headerTitle = useMemo(() => { | 408 | const headerSubtitle = selectedThreadProject?.title ?? ""; |
| 410 | const topLineParts = [ | ||
| 411 | selectedEnvironmentConnection?.environmentLabel ?? null, | ||
| 412 | selectedThreadProject?.title ?? null, | ||
| 413 | ].filter((value): value is string => Boolean(value)); | ||
| 414 | |||
| 415 | return { | ||
| 416 | topLine: topLineParts.join(" \u00b7 "), | ||
| 417 | bottomLine: cwd ?? selectedThreadProject?.workspaceRoot ?? "", | ||
| 418 | }; | ||
| 419 | }, [ | ||
| 420 | cwd, | ||
| 421 | selectedEnvironmentConnection?.environmentLabel, | ||
| 422 | selectedThreadProject?.title, | ||
| 423 | selectedThreadProject?.workspaceRoot, | ||
| 424 | ]); | ||
| 425 | const terminalToolbarActions = useMemo<ReadonlyArray<TerminalToolbarAction>>(() => { | 409 | const terminalToolbarActions = useMemo<ReadonlyArray<TerminalToolbarAction>>(() => { |
| 426 | const modifierActions: ReadonlyArray<TerminalToolbarAction> = | 410 | const modifierActions: ReadonlyArray<TerminalToolbarAction> = |
| 427 | hostPlatform === "mac" | 411 | hostPlatform === "mac" |
| ... | @@ -546,8 +530,14 @@ export function ThreadTerminalRouteScreen() { | ... | @@ -546,8 +530,14 @@ export function ThreadTerminalRouteScreen() { |
| 546 | if (!shouldRedirectToRunningTerminal || !selectedThread || !runningSession) { | 530 | if (!shouldRedirectToRunningTerminal || !selectedThread || !runningSession) { |
| 547 | return; | 531 | return; |
| 548 | } | 532 | } |
| 549 | router.replace(buildThreadTerminalNavigation(selectedThread, runningSession.target.terminalId)); | 533 | navigation.dispatch( |
| 550 | }, [router, runningSession, selectedThread, shouldRedirectToRunningTerminal]); | 534 | StackActions.replace("ThreadTerminal", { |
| 535 | environmentId: String(selectedThread.environmentId), | ||
| 536 | threadId: String(selectedThread.id), | ||
| 537 | terminalId: runningSession.target.terminalId, | ||
| 538 | }), | ||
| 539 | ); | ||
| 540 | }, [navigation, runningSession, selectedThread, shouldRedirectToRunningTerminal]); | ||
| 551 | 541 | ||
| 552 | useEffect(() => { | 542 | useEffect(() => { |
| 553 | const initialInput = pendingLaunch?.initialInput; | 543 | const initialInput = pendingLaunch?.initialInput; |
| ... | @@ -637,42 +627,6 @@ export function ThreadTerminalRouteScreen() { | ... | @@ -637,42 +627,6 @@ export function ThreadTerminalRouteScreen() { |
| 637 | setHasMeasuredSurface(true); | 627 | setHasMeasuredSurface(true); |
| 638 | }, [routeEnvironmentId, routeThreadId, terminalId]); | 628 | }, [routeEnvironmentId, routeThreadId, terminalId]); |
| 639 | 629 | ||
| 640 | useEffect(() => { | ||
| 641 | let cancelled = false; | ||
| 642 | |||
| 643 | void loadPreferences() | ||
| 644 | .then((preferences) => { | ||
| 645 | if (cancelled) { | ||
| 646 | return; | ||
| 647 | } | ||
| 648 | |||
| 649 | setFontSize(cacheTerminalFontSize(preferences.terminalFontSize)); | ||
| 650 | setHasResolvedFontPreference(true); | ||
| 651 | }) | ||
| 652 | .catch(() => { | ||
| 653 | if (cancelled) { | ||
| 654 | return; | ||
| 655 | } | ||
| 656 | |||
| 657 | setHasResolvedFontPreference(true); | ||
| 658 | }); | ||
| 659 | |||
| 660 | return () => { | ||
| 661 | cancelled = true; | ||
| 662 | }; | ||
| 663 | }, []); | ||
| 664 | |||
| 665 | useEffect(() => { | ||
| 666 | if (!hasResolvedFontPreference) { | ||
| 667 | return; | ||
| 668 | } | ||
| 669 | |||
| 670 | cacheTerminalFontSize(fontSize); | ||
| 671 | void savePreferencesPatch({ | ||
| 672 | terminalFontSize: normalizeTerminalFontSize(fontSize), | ||
| 673 | }); | ||
| 674 | }, [fontSize, hasResolvedFontPreference]); | ||
| 675 | |||
| 676 | const writeInput = useCallback( | 630 | const writeInput = useCallback( |
| 677 | (data: string) => { | 631 | (data: string) => { |
| 678 | if (!selectedThread || !isRunning) { | 632 | if (!selectedThread || !isRunning) { |
| ... | @@ -772,9 +726,15 @@ export function ThreadTerminalRouteScreen() { | ... | @@ -772,9 +726,15 @@ export function ThreadTerminalRouteScreen() { |
| 772 | return; | 726 | return; |
| 773 | } | 727 | } |
| 774 | 728 | ||
| 775 | router.replace(buildThreadTerminalNavigation(selectedThread, nextTerminalId)); | 729 | navigation.dispatch( |
| 730 | StackActions.replace("ThreadTerminal", { | ||
| 731 | environmentId: String(selectedThread.environmentId), | ||
| 732 | threadId: String(selectedThread.id), | ||
| 733 | terminalId: nextTerminalId, | ||
| 734 | }), | ||
| 735 | ); | ||
| 776 | }, | 736 | }, |
| 777 | [router, selectedThread, terminalId], | 737 | [navigation, selectedThread, terminalId], |
| 778 | ); | 738 | ); |
| 779 | 739 | ||
| 780 | const handleOpenNewTerminal = useCallback(() => { | 740 | const handleOpenNewTerminal = useCallback(() => { |
| ... | @@ -782,30 +742,25 @@ export function ThreadTerminalRouteScreen() { | ... | @@ -782,30 +742,25 @@ export function ThreadTerminalRouteScreen() { |
| 782 | return; | 742 | return; |
| 783 | } | 743 | } |
| 784 | 744 | ||
| 785 | router.replace( | 745 | navigation.dispatch( |
| 786 | buildThreadTerminalNavigation( | 746 | StackActions.replace("ThreadTerminal", { |
| 787 | selectedThread, | 747 | environmentId: String(selectedThread.environmentId), |
| 788 | nextOpenTerminalId({ | 748 | threadId: String(selectedThread.id), |
| 749 | terminalId: nextOpenTerminalId({ | ||
| 789 | listedTerminalIds: terminalMenuSessions.map((session) => session.terminalId), | 750 | listedTerminalIds: terminalMenuSessions.map((session) => session.terminalId), |
| 790 | activeRouteTerminalId: terminalId, | 751 | activeRouteTerminalId: terminalId, |
| 791 | }), | 752 | }), |
| 792 | ), | 753 | }), |
| 793 | ); | 754 | ); |
| 794 | }, [router, selectedThread, terminalId, terminalMenuSessions]); | 755 | }, [navigation, selectedThread, terminalId, terminalMenuSessions]); |
| 795 | |||
| 796 | const adjustFontSize = useCallback((delta: number) => { | ||
| 797 | setTimeout(() => { | ||
| 798 | setFontSize((current) => cacheTerminalFontSize(current + delta)); | ||
| 799 | }, 0); | ||
| 800 | }, []); | ||
| 801 | 756 | ||
| 802 | const handleDecreaseFontSize = useCallback(() => { | 757 | const handleDecreaseFontSize = useCallback(() => { |
| 803 | adjustFontSize(-TERMINAL_FONT_SIZE_STEP); | 758 | setTerminalFontSize(stepTerminalFontSize(fontSize, -1)); |
| 804 | }, [adjustFontSize]); | 759 | }, [fontSize, setTerminalFontSize]); |
| 805 | 760 | ||
| 806 | const handleIncreaseFontSize = useCallback(() => { | 761 | const handleIncreaseFontSize = useCallback(() => { |
| 807 | adjustFontSize(TERMINAL_FONT_SIZE_STEP); | 762 | setTerminalFontSize(stepTerminalFontSize(fontSize, 1)); |
| 808 | }, [adjustFontSize]); | 763 | }, [fontSize, setTerminalFontSize]); |
| 809 | 764 | ||
| 810 | const handleClearTerminal = useCallback(() => { | 765 | const handleClearTerminal = useCallback(() => { |
| 811 | if (!selectedThread) { | 766 | if (!selectedThread) { |
| ... | @@ -898,80 +853,58 @@ export function ThreadTerminalRouteScreen() { | ... | @@ -898,80 +853,58 @@ export function ThreadTerminalRouteScreen() { |
| 898 | 853 | ||
| 899 | return ( | 854 | return ( |
| 900 | <> | 855 | <> |
| 901 | <Stack.Screen | 856 | <NativeStackScreenOptions |
| 902 | options={{ | 857 | options={{ |
| 903 | headerShown: true, | 858 | // Static header config lives in Stack.tsx (SOLID_HEADER_OPTIONS — the pty |
| 904 | headerBackButtonDisplayMode: "minimal", | 859 | // scrolls internally, nothing for glass to sample). Default title/subtitle |
| 905 | headerBackTitle: "", | 860 | // styling, like every other page. |
| 906 | headerShadowVisible: false, | 861 | title: "Terminal", |
| 907 | headerStyle: { backgroundColor: terminalTheme.background }, | 862 | unstable_headerSubtitle: |
| 908 | headerTintColor: terminalTheme.foreground, | 863 | usesNativeHeaderGlass && headerSubtitle.length > 0 ? headerSubtitle : undefined, |
| 909 | headerTitleAlign: "center", | ||
| 910 | title: "", | ||
| 911 | headerTitle: () => ( | ||
| 912 | <View | ||
| 913 | style={{ | ||
| 914 | alignItems: "center", | ||
| 915 | gap: 1, | ||
| 916 | maxWidth: 240, | ||
| 917 | }} | ||
| 918 | > | ||
| 919 | <RNText | ||
| 920 | numberOfLines={1} | ||
| 921 | style={{ | ||
| 922 | color: terminalTheme.foreground, | ||
| 923 | fontFamily: "DMSans_700Bold", | ||
| 924 | fontSize: MOBILE_TYPOGRAPHY.footnote.fontSize, | ||
| 925 | lineHeight: 16, | ||
| 926 | }} | ||
| 927 | > | ||
| 928 | {headerTitle.topLine} | ||
| 929 | </RNText> | ||
| 930 | <RNText | ||
| 931 | ellipsizeMode="middle" | ||
| 932 | numberOfLines={1} | ||
| 933 | style={{ | ||
| 934 | color: terminalTheme.mutedForeground, | ||
| 935 | fontFamily: "Menlo", | ||
| 936 | fontSize: MOBILE_TYPOGRAPHY.caption.fontSize, | ||
| 937 | lineHeight: 14, | ||
| 938 | }} | ||
| 939 | > | ||
| 940 | {headerTitle.bottomLine} | ||
| 941 | </RNText> | ||
| 942 | </View> | ||
| 943 | ), | ||
| 944 | }} | 864 | }} |
| 945 | /> | 865 | /> |
| 946 | 866 | ||
| 867 | {layout.usesSplitView ? ( | ||
| 868 | <NativeHeaderToolbar placement="left"> | ||
| 869 | <NativeHeaderToolbar.Button | ||
| 870 | accessibilityLabel={panes.primarySidebarVisible ? "Maximize terminal" : "Show threads"} | ||
| 871 | icon={ | ||
| 872 | panes.primarySidebarVisible ? "arrow.up.left.and.arrow.down.right" : "sidebar.left" | ||
| 873 | } | ||
| 874 | onPress={togglePrimarySidebar} | ||
| 875 | separateBackground | ||
| 876 | /> | ||
| 877 | </NativeHeaderToolbar> | ||
| 878 | ) : null} | ||
| 879 | |||
| 947 | {isEnvironmentReady ? ( | 880 | {isEnvironmentReady ? ( |
| 948 | <Stack.Toolbar placement="right"> | 881 | <NativeHeaderToolbar placement="right"> |
| 949 | <Stack.Toolbar.Menu icon="terminal" title="Terminal options" separateBackground> | 882 | <NativeHeaderToolbar.Menu icon="terminal" title="Terminal options" separateBackground> |
| 950 | <Stack.Toolbar.Label> | 883 | <NativeHeaderToolbar.Label> |
| 951 | {getTerminalStatusLabel({ | 884 | {getTerminalStatusLabel({ |
| 952 | status: terminal.status, | 885 | status: terminal.status, |
| 953 | hasRunningSubprocess: terminal.hasRunningSubprocess, | 886 | hasRunningSubprocess: terminal.hasRunningSubprocess, |
| 954 | })} | 887 | })} |
| 955 | </Stack.Toolbar.Label> | 888 | </NativeHeaderToolbar.Label> |
| 956 | <Stack.Toolbar.Menu icon="textformat.size" inline title="Text size"> | 889 | <NativeHeaderToolbar.Menu icon="textformat.size" inline title="Text size"> |
| 957 | <Stack.Toolbar.Label>Text size</Stack.Toolbar.Label> | 890 | <NativeHeaderToolbar.Label>Text size</NativeHeaderToolbar.Label> |
| 958 | <Stack.Toolbar.MenuAction | 891 | <NativeHeaderToolbar.MenuAction |
| 959 | disabled={fontSize <= MIN_TERMINAL_FONT_SIZE} | 892 | disabled={fontSize <= MIN_TERMINAL_FONT_SIZE} |
| 960 | discoverabilityLabel="Decrease terminal text size" | 893 | discoverabilityLabel="Decrease terminal text size" |
| 961 | onPress={handleDecreaseFontSize} | 894 | onPress={handleDecreaseFontSize} |
| 962 | > | 895 | > |
| 963 | <Stack.Toolbar.Label>{`A- ${Math.max(MIN_TERMINAL_FONT_SIZE, fontSize - TERMINAL_FONT_SIZE_STEP).toFixed(1)} pt`}</Stack.Toolbar.Label> | 896 | <NativeHeaderToolbar.Label>{`A- ${Math.max(MIN_TERMINAL_FONT_SIZE, fontSize - TERMINAL_FONT_SIZE_STEP).toFixed(1)} pt`}</NativeHeaderToolbar.Label> |
| 964 | </Stack.Toolbar.MenuAction> | 897 | </NativeHeaderToolbar.MenuAction> |
| 965 | <Stack.Toolbar.MenuAction | 898 | <NativeHeaderToolbar.MenuAction |
| 966 | disabled={fontSize >= MAX_TERMINAL_FONT_SIZE} | 899 | disabled={fontSize >= MAX_TERMINAL_FONT_SIZE} |
| 967 | discoverabilityLabel="Increase terminal text size" | 900 | discoverabilityLabel="Increase terminal text size" |
| 968 | onPress={handleIncreaseFontSize} | 901 | onPress={handleIncreaseFontSize} |
| 969 | > | 902 | > |
| 970 | <Stack.Toolbar.Label>{`A+ ${Math.min(MAX_TERMINAL_FONT_SIZE, fontSize + TERMINAL_FONT_SIZE_STEP).toFixed(1)} pt`}</Stack.Toolbar.Label> | 903 | <NativeHeaderToolbar.Label>{`A+ ${Math.min(MAX_TERMINAL_FONT_SIZE, fontSize + TERMINAL_FONT_SIZE_STEP).toFixed(1)} pt`}</NativeHeaderToolbar.Label> |
| 971 | </Stack.Toolbar.MenuAction> | 904 | </NativeHeaderToolbar.MenuAction> |
| 972 | </Stack.Toolbar.Menu> | 905 | </NativeHeaderToolbar.Menu> |
| 973 | {terminalMenuSessions.map((session) => ( | 906 | {terminalMenuSessions.map((session) => ( |
| 974 | <Stack.Toolbar.MenuAction | 907 | <NativeHeaderToolbar.MenuAction |
| 975 | key={session.terminalId} | 908 | key={session.terminalId} |
| 976 | icon={session.terminalId === terminalId ? "checkmark" : "terminal"} | 909 | icon={session.terminalId === terminalId ? "checkmark" : "terminal"} |
| 977 | onPress={() => handleSelectTerminal(session.terminalId)} | 910 | onPress={() => handleSelectTerminal(session.terminalId)} |
| ... | @@ -982,18 +915,18 @@ export function ThreadTerminalRouteScreen() { | ... | @@ -982,18 +915,18 @@ export function ThreadTerminalRouteScreen() { |
| 982 | .filter(Boolean) | 915 | .filter(Boolean) |
| 983 | .join(" · ")} | 916 | .join(" · ")} |
| 984 | > | 917 | > |
| 985 | <Stack.Toolbar.Label>{session.displayLabel}</Stack.Toolbar.Label> | 918 | <NativeHeaderToolbar.Label>{session.displayLabel}</NativeHeaderToolbar.Label> |
| 986 | </Stack.Toolbar.MenuAction> | 919 | </NativeHeaderToolbar.MenuAction> |
| 987 | ))} | 920 | ))} |
| 988 | <Stack.Toolbar.MenuAction | 921 | <NativeHeaderToolbar.MenuAction |
| 989 | icon="plus" | 922 | icon="plus" |
| 990 | onPress={handleOpenNewTerminal} | 923 | onPress={handleOpenNewTerminal} |
| 991 | subtitle={`Start another shell in ${basename(selectedThreadProject.workspaceRoot) ?? "this workspace"}`} | 924 | subtitle={`Start another shell in ${basename(selectedThreadProject.workspaceRoot) ?? "this workspace"}`} |
| 992 | > | 925 | > |
| 993 | <Stack.Toolbar.Label>Open new terminal</Stack.Toolbar.Label> | 926 | <NativeHeaderToolbar.Label>Open new terminal</NativeHeaderToolbar.Label> |
| 994 | </Stack.Toolbar.MenuAction> | 927 | </NativeHeaderToolbar.MenuAction> |
| 995 | </Stack.Toolbar.Menu> | 928 | </NativeHeaderToolbar.Menu> |
| 996 | </Stack.Toolbar> | 929 | </NativeHeaderToolbar> |
| 997 | ) : null} | 930 | ) : null} |
| 998 | 931 | ||
| 999 | <View style={{ flex: 1, backgroundColor: terminalTheme.background }}> | 932 | <View style={{ flex: 1, backgroundColor: terminalTheme.background }}> |
apps/mobile/src/features/terminal/nativeTerminalModule.ts+21-1| ... | @@ -1,6 +1,6 @@ | ... | @@ -1,6 +1,6 @@ |
| 1 | import type { ComponentType } from "react"; | 1 | import type { ComponentType } from "react"; |
| 2 | import type { NativeSyntheticEvent, ViewProps } from "react-native"; | 2 | import type { NativeSyntheticEvent, ViewProps } from "react-native"; |
| 3 | import { requireNativeView } from "expo"; | 3 | import { requireNativeView, requireOptionalNativeModule } from "expo"; |
| 4 | 4 | ||
| 5 | import { NativeViewResolutionError } from "../../native/nativeViewResolutionError"; | 5 | import { NativeViewResolutionError } from "../../native/nativeViewResolutionError"; |
| 6 | 6 | ||
| ... | @@ -23,6 +23,7 @@ interface TerminalResizeEvent { | ... | @@ -23,6 +23,7 @@ interface TerminalResizeEvent { |
| 23 | 23 | ||
| 24 | export interface NativeTerminalSurfaceProps extends ViewProps { | 24 | export interface NativeTerminalSurfaceProps extends ViewProps { |
| 25 | readonly appearanceScheme?: "light" | "dark"; | 25 | readonly appearanceScheme?: "light" | "dark"; |
| 26 | readonly focusRequest?: number; | ||
| 26 | readonly themeConfig?: string; | 27 | readonly themeConfig?: string; |
| 27 | readonly backgroundColor?: string; | 28 | readonly backgroundColor?: string; |
| 28 | readonly foregroundColor?: string; | 29 | readonly foregroundColor?: string; |
| ... | @@ -74,6 +75,25 @@ export function resolveNativeTerminalSurfaceView(): ComponentType<NativeTerminal | ... | @@ -74,6 +75,25 @@ export function resolveNativeTerminalSurfaceView(): ComponentType<NativeTerminal |
| 74 | return cachedNativeTerminalSurfaceView ?? null; | 75 | return cachedNativeTerminalSurfaceView ?? null; |
| 75 | } | 76 | } |
| 76 | 77 | ||
| 78 | /** | ||
| 79 | * Revision of the native hardware-keyboard handling compiled into the installed binary, | ||
| 80 | * or `null` when the binary predates the revision constant (or the module is missing). | ||
| 81 | * Used in terminal debug logs to detect stale native builds. | ||
| 82 | */ | ||
| 83 | export function getNativeTerminalHardwareKeyRevision(): number | null { | ||
| 84 | try { | ||
| 85 | if (typeof requireOptionalNativeModule !== "function") { | ||
| 86 | return null; | ||
| 87 | } | ||
| 88 | const module = requireOptionalNativeModule<{ readonly hardwareKeyRevision?: number }>( | ||
| 89 | NATIVE_TERMINAL_MODULE_NAME, | ||
| 90 | ); | ||
| 91 | return module?.hardwareKeyRevision ?? null; | ||
| 92 | } catch { | ||
| 93 | return null; | ||
| 94 | } | ||
| 95 | } | ||
| 96 | |||
| 77 | export function hasNativeTerminalSurface() { | 97 | export function hasNativeTerminalSurface() { |
| 78 | return resolveNativeTerminalSurfaceView() !== null; | 98 | return resolveNativeTerminalSurfaceView() !== null; |
| 79 | } | 99 | } |
apps/mobile/src/features/terminal/terminalPreferences.ts+1-1| ... | @@ -1,4 +1,4 @@ | ... | @@ -1,4 +1,4 @@ |
| 1 | export const DEFAULT_TERMINAL_FONT_SIZE = 10; | 1 | export const DEFAULT_TERMINAL_FONT_SIZE = 10.5; |
| 2 | export const TERMINAL_FONT_SIZE_STEP = 0.5; | 2 | export const TERMINAL_FONT_SIZE_STEP = 0.5; |
| 3 | export const MIN_TERMINAL_FONT_SIZE = 6; | 3 | export const MIN_TERMINAL_FONT_SIZE = 6; |
| 4 | export const MAX_TERMINAL_FONT_SIZE = 14; | 4 | export const MAX_TERMINAL_FONT_SIZE = 14; |
apps/mobile/src/features/threads/ComposerCommandPopover.tsx+1-3| ... | @@ -7,8 +7,6 @@ import { Pressable, ScrollView, useColorScheme, View, type ViewStyle } from "rea | ... | @@ -7,8 +7,6 @@ import { Pressable, ScrollView, useColorScheme, View, type ViewStyle } from "rea |
| 7 | 7 | ||
| 8 | import { AppText as Text } from "../../components/AppText"; | 8 | import { AppText as Text } from "../../components/AppText"; |
| 9 | import { PierreEntryIcon } from "../../components/PierreEntryIcon"; | 9 | import { PierreEntryIcon } from "../../components/PierreEntryIcon"; |
| 10 | import { MOBILE_TYPOGRAPHY } from "../../lib/typography"; | ||
| 11 | |||
| 12 | export type ComposerCommandItem = | 10 | export type ComposerCommandItem = |
| 13 | | { | 11 | | { |
| 14 | readonly id: string; | 12 | readonly id: string; |
| ... | @@ -165,11 +163,11 @@ const CommandRow = memo(function CommandRow(props: { | ... | @@ -165,11 +163,11 @@ const CommandRow = memo(function CommandRow(props: { |
| 165 | </Text> | 163 | </Text> |
| 166 | {props.item.description ? ( | 164 | {props.item.description ? ( |
| 167 | <Text | 165 | <Text |
| 166 | className="text-xs" | ||
| 168 | numberOfLines={1} | 167 | numberOfLines={1} |
| 169 | style={{ | 168 | style={{ |
| 170 | flex: 1, | 169 | flex: 1, |
| 171 | minWidth: 0, | 170 | minWidth: 0, |
| 172 | fontSize: MOBILE_TYPOGRAPHY.label.fontSize, | ||
| 173 | color: "#a1a1aa", | 171 | color: "#a1a1aa", |
| 174 | }} | 172 | }} |
| 175 | > | 173 | > |
apps/mobile/src/features/threads/NewTaskDraftRouteScreen.tsx created+32| ... | @@ -0,0 +1,32 @@ | ||
| 1 | import type { StaticScreenProps } from "@react-navigation/native"; | ||
| 2 | import { NativeStackScreenOptions } from "../../native/StackHeader"; | ||
| 3 | |||
| 4 | import { NewTaskDraftScreen } from "./NewTaskDraftScreen"; | ||
| 5 | |||
| 6 | type NewTaskDraftRouteParams = { | ||
| 7 | readonly environmentId?: string | string[]; | ||
| 8 | readonly projectId?: string | string[]; | ||
| 9 | readonly title?: string | string[]; | ||
| 10 | }; | ||
| 11 | |||
| 12 | export function NewTaskDraftRouteScreen({ route }: StaticScreenProps<NewTaskDraftRouteParams>) { | ||
| 13 | const params = route.params ?? {}; | ||
| 14 | |||
| 15 | return ( | ||
| 16 | <> | ||
| 17 | <NativeStackScreenOptions | ||
| 18 | options={{ | ||
| 19 | title: Array.isArray(params.title) ? params.title[0] : (params.title ?? "New task"), | ||
| 20 | }} | ||
| 21 | /> | ||
| 22 | <NewTaskDraftScreen | ||
| 23 | initialProjectRef={{ | ||
| 24 | environmentId: Array.isArray(params.environmentId) | ||
| 25 | ? params.environmentId[0] | ||
| 26 | : params.environmentId, | ||
| 27 | projectId: Array.isArray(params.projectId) ? params.projectId[0] : params.projectId, | ||
| 28 | }} | ||
| 29 | /> | ||
| 30 | </> | ||
| 31 | ); | ||
| 32 | } | ||
apps/mobile/src/features/threads/NewTaskDraftScreen.tsx+16-10| ... | @@ -1,4 +1,5 @@ | ... | @@ -1,4 +1,5 @@ |
| 1 | import { Stack, useRouter } from "expo-router"; | 1 | import { NativeStackScreenOptions } from "../../native/StackHeader"; |
| 2 | import { StackActions, useNavigation } from "@react-navigation/native"; | ||
| 2 | import { useCallback, useEffect, useMemo, useRef } from "react"; | 3 | import { useCallback, useEffect, useMemo, useRef } from "react"; |
| 3 | import { Alert, InteractionManager, View, useColorScheme } from "react-native"; | 4 | import { Alert, InteractionManager, View, useColorScheme } from "react-native"; |
| 4 | import { KeyboardAvoidingView, useKeyboardState } from "react-native-keyboard-controller"; | 5 | import { KeyboardAvoidingView, useKeyboardState } from "react-native-keyboard-controller"; |
| ... | @@ -29,9 +30,8 @@ import { | ... | @@ -29,9 +30,8 @@ import { |
| 29 | providerOptionsConfigurationLabel, | 30 | providerOptionsConfigurationLabel, |
| 30 | resolveProviderOptionDescriptors, | 31 | resolveProviderOptionDescriptors, |
| 31 | } from "../../lib/providerOptions"; | 32 | } from "../../lib/providerOptions"; |
| 32 | import { buildThreadRoutePath } from "../../lib/routes"; | ||
| 33 | import { scopedProjectKey } from "../../lib/scopedEntities"; | 33 | import { scopedProjectKey } from "../../lib/scopedEntities"; |
| 34 | import { MOBILE_TYPOGRAPHY } from "../../lib/typography"; | 34 | import { useScaledTextRole } from "../settings/appearance/useScaledTextRole"; |
| 35 | import { getComposerDraftSnapshot } from "../../state/use-composer-drafts"; | 35 | import { getComposerDraftSnapshot } from "../../state/use-composer-drafts"; |
| 36 | import { useProjects } from "../../state/entities"; | 36 | import { useProjects } from "../../state/entities"; |
| 37 | import { branchBadgeLabel, useNewTaskFlow } from "./new-task-flow-provider"; | 37 | import { branchBadgeLabel, useNewTaskFlow } from "./new-task-flow-provider"; |
| ... | @@ -58,7 +58,7 @@ export function NewTaskDraftScreen(props: { | ... | @@ -58,7 +58,7 @@ export function NewTaskDraftScreen(props: { |
| 58 | const projects = useProjects(); | 58 | const projects = useProjects(); |
| 59 | const createProjectThread = useCreateProjectThread(); | 59 | const createProjectThread = useCreateProjectThread(); |
| 60 | const flow = useNewTaskFlow(); | 60 | const flow = useNewTaskFlow(); |
| 61 | const router = useRouter(); | 61 | const navigation = useNavigation(); |
| 62 | const insets = useSafeAreaInsets(); | 62 | const insets = useSafeAreaInsets(); |
| 63 | const colorScheme = useColorScheme(); | 63 | const colorScheme = useColorScheme(); |
| 64 | const isKeyboardVisible = useKeyboardState((state) => state.isVisible); | 64 | const isKeyboardVisible = useKeyboardState((state) => state.isVisible); |
| ... | @@ -68,6 +68,7 @@ export function NewTaskDraftScreen(props: { | ... | @@ -68,6 +68,7 @@ export function NewTaskDraftScreen(props: { |
| 68 | const loadedBranchesProjectKeyRef = useRef<string | null>(null); | 68 | const loadedBranchesProjectKeyRef = useRef<string | null>(null); |
| 69 | 69 | ||
| 70 | const borderColor = useThemeColor("--color-border"); | 70 | const borderColor = useThemeColor("--color-border"); |
| 71 | const bodyText = useScaledTextRole("body"); | ||
| 71 | const sheetFadeOpaque = colorScheme === "dark" ? "rgba(14,14,14,0.98)" : "rgba(242,242,247,0.98)"; | 72 | const sheetFadeOpaque = colorScheme === "dark" ? "rgba(14,14,14,0.98)" : "rgba(242,242,247,0.98)"; |
| 72 | const sheetFadeTransparent = colorScheme === "dark" ? "rgba(14,14,14,0)" : "rgba(242,242,247,0)"; | 73 | const sheetFadeTransparent = colorScheme === "dark" ? "rgba(14,14,14,0)" : "rgba(242,242,247,0)"; |
| 73 | 74 | ||
| ... | @@ -101,13 +102,13 @@ export function NewTaskDraftScreen(props: { | ... | @@ -101,13 +102,13 @@ export function NewTaskDraftScreen(props: { |
| 101 | return; | 102 | return; |
| 102 | } | 103 | } |
| 103 | 104 | ||
| 104 | router.replace("/new"); | 105 | navigation.dispatch(StackActions.replace("NewTask")); |
| 105 | }, [ | 106 | }, [ |
| 106 | logicalProjects, | 107 | logicalProjects, |
| 107 | projects, | 108 | projects, |
| 108 | props.initialProjectRef?.environmentId, | 109 | props.initialProjectRef?.environmentId, |
| 109 | props.initialProjectRef?.projectId, | 110 | props.initialProjectRef?.projectId, |
| 110 | router, | 111 | navigation, |
| 111 | selectedProject, | 112 | selectedProject, |
| 112 | setProject, | 113 | setProject, |
| 113 | ]); | 114 | ]); |
| ... | @@ -429,20 +430,25 @@ export function NewTaskDraftScreen(props: { | ... | @@ -429,20 +430,25 @@ export function NewTaskDraftScreen(props: { |
| 429 | 430 | ||
| 430 | flow.setPrompt(""); | 431 | flow.setPrompt(""); |
| 431 | flow.clearAttachments(); | 432 | flow.clearAttachments(); |
| 432 | router.replace(buildThreadRoutePath(result.value)); | 433 | navigation.dispatch( |
| 434 | StackActions.replace("Thread", { | ||
| 435 | environmentId: String(result.value.environmentId), | ||
| 436 | threadId: String(result.value.threadId), | ||
| 437 | }), | ||
| 438 | ); | ||
| 433 | } | 439 | } |
| 434 | 440 | ||
| 435 | if (!selectedProject) { | 441 | if (!selectedProject) { |
| 436 | return ( | 442 | return ( |
| 437 | <View className="flex-1 bg-sheet"> | 443 | <View className="flex-1 bg-sheet"> |
| 438 | <Stack.Screen options={{ title: "Loading task" }} /> | 444 | <NativeStackScreenOptions options={{ title: "Loading task" }} /> |
| 439 | </View> | 445 | </View> |
| 440 | ); | 446 | ); |
| 441 | } | 447 | } |
| 442 | 448 | ||
| 443 | return ( | 449 | return ( |
| 444 | <View className="flex-1 bg-sheet"> | 450 | <View className="flex-1 bg-sheet"> |
| 445 | <Stack.Screen options={{ title: selectedProject.title }} /> | 451 | <NativeStackScreenOptions options={{ title: selectedProject.title }} /> |
| 446 | 452 | ||
| 447 | <KeyboardAvoidingView automaticOffset behavior="padding" style={{ flex: 1 }}> | 453 | <KeyboardAvoidingView automaticOffset behavior="padding" style={{ flex: 1 }}> |
| 448 | <View style={{ flex: 1, minHeight: 0, paddingHorizontal: 20, paddingTop: 8 }}> | 454 | <View style={{ flex: 1, minHeight: 0, paddingHorizontal: 20, paddingTop: 8 }}> |
| ... | @@ -457,7 +463,7 @@ export function NewTaskDraftScreen(props: { | ... | @@ -457,7 +463,7 @@ export function NewTaskDraftScreen(props: { |
| 457 | onPasteImages={(uris) => void handleNativePasteImages(uris)} | 463 | onPasteImages={(uris) => void handleNativePasteImages(uris)} |
| 458 | placeholder={`Describe a coding task in ${selectedProject.title}`} | 464 | placeholder={`Describe a coding task in ${selectedProject.title}`} |
| 459 | style={{ flex: 1, minHeight: 0 }} | 465 | style={{ flex: 1, minHeight: 0 }} |
| 460 | textStyle={MOBILE_TYPOGRAPHY.composer} | 466 | textStyle={bodyText} |
| 461 | /> | 467 | /> |
| 462 | </View> | 468 | </View> |
| 463 | 469 |
apps/mobile/src/features/threads/NewTaskRouteScreen.tsx created+227| ... | @@ -0,0 +1,227 @@ | ||
| 1 | import { NativeHeaderToolbar } from "../../native/StackHeader"; | ||
| 2 | import { useNavigation } from "@react-navigation/native"; | ||
| 3 | import { SymbolView } from "expo-symbols"; | ||
| 4 | import type { EnvironmentId, ProjectId } from "@t3tools/contracts"; | ||
| 5 | import { useMemo } from "react"; | ||
| 6 | import { ActivityIndicator, Pressable, ScrollView, View } from "react-native"; | ||
| 7 | import { useSafeAreaInsets } from "react-native-safe-area-context"; | ||
| 8 | import { useThemeColor } from "../../lib/useThemeColor"; | ||
| 9 | |||
| 10 | import { AppText as Text } from "../../components/AppText"; | ||
| 11 | import { ProjectFavicon } from "../../components/ProjectFavicon"; | ||
| 12 | import { useProjects, useThreadShells } from "../../state/entities"; | ||
| 13 | import type { WorkspaceState } from "../../state/workspaceModel"; | ||
| 14 | import { useWorkspaceState } from "../../state/workspace"; | ||
| 15 | import { groupProjectsByRepository } from "../../lib/repositoryGroups"; | ||
| 16 | import { useAdaptiveWorkspaceLayout } from "../layout/AdaptiveWorkspaceLayout"; | ||
| 17 | |||
| 18 | function deriveProjectEmptyState(catalogState: WorkspaceState): { | ||
| 19 | readonly title: string; | ||
| 20 | readonly detail: string; | ||
| 21 | readonly loading: boolean; | ||
| 22 | } { | ||
| 23 | if (catalogState.isLoadingConnections) { | ||
| 24 | return { | ||
| 25 | title: "Loading environments", | ||
| 26 | detail: "Checking saved environments on this device.", | ||
| 27 | loading: true, | ||
| 28 | }; | ||
| 29 | } | ||
| 30 | |||
| 31 | if (!catalogState.hasConnections) { | ||
| 32 | return { | ||
| 33 | title: "No environments connected", | ||
| 34 | detail: "Add an environment before creating a task.", | ||
| 35 | loading: false, | ||
| 36 | }; | ||
| 37 | } | ||
| 38 | |||
| 39 | if ( | ||
| 40 | (catalogState.connectionState === "available" || | ||
| 41 | catalogState.connectionState === "offline" || | ||
| 42 | catalogState.connectionState === "error") && | ||
| 43 | !catalogState.hasLoadedShellSnapshot | ||
| 44 | ) { | ||
| 45 | return { | ||
| 46 | title: "Environment unavailable", | ||
| 47 | detail: | ||
| 48 | catalogState.connectionError ?? | ||
| 49 | "The saved environment is offline. Check the URL or start the environment, then retry.", | ||
| 50 | loading: false, | ||
| 51 | }; | ||
| 52 | } | ||
| 53 | |||
| 54 | if ( | ||
| 55 | catalogState.hasConnectingEnvironment && | ||
| 56 | !catalogState.hasLoadedShellSnapshot && | ||
| 57 | catalogState.connectionError === null | ||
| 58 | ) { | ||
| 59 | return { | ||
| 60 | title: "Connecting to environment", | ||
| 61 | detail: "Loading projects from the saved environment.", | ||
| 62 | loading: true, | ||
| 63 | }; | ||
| 64 | } | ||
| 65 | |||
| 66 | return { | ||
| 67 | title: "No projects found", | ||
| 68 | detail: "The connected environment did not report any projects.", | ||
| 69 | loading: false, | ||
| 70 | }; | ||
| 71 | } | ||
| 72 | |||
| 73 | export function NewTaskRouteScreen() { | ||
| 74 | const projects = useProjects(); | ||
| 75 | const threads = useThreadShells(); | ||
| 76 | const { state: catalogState } = useWorkspaceState(); | ||
| 77 | const navigation = useNavigation(); | ||
| 78 | const { layout } = useAdaptiveWorkspaceLayout(); | ||
| 79 | const insets = useSafeAreaInsets(); | ||
| 80 | const chevronColor = useThemeColor("--color-chevron"); | ||
| 81 | const accentColor = useThemeColor("--color-icon-muted"); | ||
| 82 | const borderSubtleColor = useThemeColor("--color-border-subtle"); | ||
| 83 | const repositoryGroups = useMemo( | ||
| 84 | () => groupProjectsByRepository({ projects, threads }), | ||
| 85 | [projects, threads], | ||
| 86 | ); | ||
| 87 | const items = useMemo(() => { | ||
| 88 | const nextItems: Array<{ | ||
| 89 | readonly environmentId: EnvironmentId; | ||
| 90 | readonly id: ProjectId; | ||
| 91 | readonly key: string; | ||
| 92 | readonly title: string; | ||
| 93 | readonly workspaceRoot: string; | ||
| 94 | }> = []; | ||
| 95 | for (const group of repositoryGroups) { | ||
| 96 | const project = group.projects[0]?.project; | ||
| 97 | if (!project) { | ||
| 98 | continue; | ||
| 99 | } | ||
| 100 | nextItems.push({ | ||
| 101 | environmentId: project.environmentId, | ||
| 102 | id: project.id, | ||
| 103 | key: group.key, | ||
| 104 | title: project.title, | ||
| 105 | workspaceRoot: project.workspaceRoot, | ||
| 106 | }); | ||
| 107 | } | ||
| 108 | return nextItems; | ||
| 109 | }, [repositoryGroups]); | ||
| 110 | const projectEmptyState = deriveProjectEmptyState(catalogState); | ||
| 111 | |||
| 112 | return ( | ||
| 113 | <View collapsable={false} className="flex-1 bg-sheet"> | ||
| 114 | <NativeHeaderToolbar placement="right"> | ||
| 115 | {layout.usesSplitView ? ( | ||
| 116 | <NativeHeaderToolbar.Button | ||
| 117 | accessibilityLabel="Close new task" | ||
| 118 | icon="xmark" | ||
| 119 | onPress={() => navigation.goBack()} | ||
| 120 | separateBackground | ||
| 121 | /> | ||
| 122 | ) : null} | ||
| 123 | <NativeHeaderToolbar.Button | ||
| 124 | icon="plus" | ||
| 125 | onPress={() => navigation.navigate("NewTaskSheet", { screen: "AddProject" })} | ||
| 126 | separateBackground | ||
| 127 | /> | ||
| 128 | </NativeHeaderToolbar> | ||
| 129 | |||
| 130 | <ScrollView | ||
| 131 | contentInsetAdjustmentBehavior="automatic" | ||
| 132 | showsVerticalScrollIndicator={false} | ||
| 133 | style={{ flex: 1 }} | ||
| 134 | contentInset={{ bottom: Math.max(insets.bottom, 18) + 18 }} | ||
| 135 | contentContainerStyle={{ | ||
| 136 | paddingHorizontal: 20, | ||
| 137 | paddingTop: 8, | ||
| 138 | }} | ||
| 139 | > | ||
| 140 | {items.length === 0 ? ( | ||
| 141 | <View collapsable={false} className="items-center gap-3 rounded-[24px] bg-card px-6 py-8"> | ||
| 142 | {projectEmptyState.loading ? <ActivityIndicator color={accentColor} /> : null} | ||
| 143 | <Text className="text-center text-lg font-t3-bold text-foreground"> | ||
| 144 | {projectEmptyState.title} | ||
| 145 | </Text> | ||
| 146 | <Text className="text-center text-sm leading-normal text-foreground-muted"> | ||
| 147 | {projectEmptyState.detail} | ||
| 148 | </Text> | ||
| 149 | {!catalogState.hasReadyEnvironment ? ( | ||
| 150 | <Pressable | ||
| 151 | className="mt-1 rounded-full bg-primary px-4 py-2.5 active:opacity-70" | ||
| 152 | onPress={() => navigation.navigate("ConnectionsNew")} | ||
| 153 | > | ||
| 154 | <Text className="text-sm font-t3-bold text-primary-foreground"> | ||
| 155 | Add environment | ||
| 156 | </Text> | ||
| 157 | </Pressable> | ||
| 158 | ) : ( | ||
| 159 | <Pressable | ||
| 160 | className="mt-1 rounded-full bg-primary px-4 py-2.5 active:opacity-70" | ||
| 161 | onPress={() => navigation.navigate("NewTaskSheet", { screen: "AddProject" })} | ||
| 162 | > | ||
| 163 | <Text className="text-sm font-t3-bold text-primary-foreground"> | ||
| 164 | Add new project | ||
| 165 | </Text> | ||
| 166 | </Pressable> | ||
| 167 | )} | ||
| 168 | </View> | ||
| 169 | ) : ( | ||
| 170 | <View collapsable={false} className="overflow-hidden rounded-[24px] bg-card"> | ||
| 171 | {items.map((item, index) => { | ||
| 172 | const isFirst = index === 0; | ||
| 173 | const isLast = index === items.length - 1; | ||
| 174 | |||
| 175 | return ( | ||
| 176 | <Pressable | ||
| 177 | key={item.key} | ||
| 178 | className="bg-card" | ||
| 179 | onPress={() => | ||
| 180 | navigation.navigate("NewTaskSheet", { | ||
| 181 | screen: "NewTaskDraft", | ||
| 182 | params: { | ||
| 183 | environmentId: item.environmentId, | ||
| 184 | projectId: item.id, | ||
| 185 | title: item.title, | ||
| 186 | }, | ||
| 187 | }) | ||
| 188 | } | ||
| 189 | style={{ | ||
| 190 | paddingHorizontal: 16, | ||
| 191 | paddingVertical: 14, | ||
| 192 | borderTopWidth: isFirst ? 0 : 1, | ||
| 193 | borderTopColor: borderSubtleColor, | ||
| 194 | borderTopLeftRadius: isFirst ? 24 : 0, | ||
| 195 | borderTopRightRadius: isFirst ? 24 : 0, | ||
| 196 | borderBottomLeftRadius: isLast ? 24 : 0, | ||
| 197 | borderBottomRightRadius: isLast ? 24 : 0, | ||
| 198 | }} | ||
| 199 | > | ||
| 200 | <View className="flex-row items-center justify-between gap-3"> | ||
| 201 | <View className="h-7 w-7 items-center justify-center"> | ||
| 202 | <ProjectFavicon | ||
| 203 | environmentId={item.environmentId} | ||
| 204 | size={20} | ||
| 205 | projectTitle={item.title} | ||
| 206 | workspaceRoot={item.workspaceRoot} | ||
| 207 | /> | ||
| 208 | </View> | ||
| 209 | <View className="flex-1"> | ||
| 210 | <Text className="text-base leading-snug font-t3-bold">{item.title}</Text> | ||
| 211 | </View> | ||
| 212 | <SymbolView | ||
| 213 | name="chevron.right" | ||
| 214 | size={14} | ||
| 215 | tintColor={chevronColor} | ||
| 216 | type="monochrome" | ||
| 217 | /> | ||
| 218 | </View> | ||
| 219 | </Pressable> | ||
| 220 | ); | ||
| 221 | })} | ||
| 222 | </View> | ||
| 223 | )} | ||
| 224 | </ScrollView> | ||
| 225 | </View> | ||
| 226 | ); | ||
| 227 | } | ||
apps/mobile/src/features/threads/PendingApprovalCard.tsx+1-1| ... | @@ -23,7 +23,7 @@ export function PendingApprovalCard(props: PendingApprovalCardProps) { | ... | @@ -23,7 +23,7 @@ export function PendingApprovalCard(props: PendingApprovalCardProps) { |
| 23 | {props.approval.requestKind} | 23 | {props.approval.requestKind} |
| 24 | </Text> | 24 | </Text> |
| 25 | {props.approval.detail ? ( | 25 | {props.approval.detail ? ( |
| 26 | <Text className="font-sans text-sm leading-5 text-neutral-600 dark:text-neutral-400"> | 26 | <Text className="font-sans text-sm leading-normal text-neutral-600 dark:text-neutral-400"> |
| 27 | {props.approval.detail} | 27 | {props.approval.detail} |
| 28 | </Text> | 28 | </Text> |
| 29 | ) : null} | 29 | ) : null} |
apps/mobile/src/features/threads/PendingUserInputCard.tsx+1-1| ... | @@ -39,7 +39,7 @@ export function PendingUserInputCard(props: PendingUserInputCardProps) { | ... | @@ -39,7 +39,7 @@ export function PendingUserInputCard(props: PendingUserInputCardProps) { |
| 39 | <Text className="font-t3-bold text-xs uppercase tracking-[1px] text-neutral-500 dark:text-neutral-500"> | 39 | <Text className="font-t3-bold text-xs uppercase tracking-[1px] text-neutral-500 dark:text-neutral-500"> |
| 40 | {question.header} | 40 | {question.header} |
| 41 | </Text> | 41 | </Text> |
| 42 | <Text className="font-sans text-base leading-[21px] text-neutral-950 dark:text-neutral-50"> | 42 | <Text className="font-sans text-base leading-snug text-neutral-950 dark:text-neutral-50"> |
| 43 | {question.question} | 43 | {question.question} |
| 44 | </Text> | 44 | </Text> |
| 45 | <View className="flex-row flex-wrap gap-2.5"> | 45 | <View className="flex-row flex-wrap gap-2.5"> |
apps/mobile/src/features/threads/ThreadComposer.tsx+51-17| ... | @@ -26,6 +26,7 @@ import { | ... | @@ -26,6 +26,7 @@ import { |
| 26 | } from "react-native"; | 26 | } from "react-native"; |
| 27 | import ImageViewing from "react-native-image-viewing"; | 27 | import ImageViewing from "react-native-image-viewing"; |
| 28 | import { useThemeColor } from "../../lib/useThemeColor"; | 28 | import { useThemeColor } from "../../lib/useThemeColor"; |
| 29 | import { scopedThreadKey } from "../../lib/scopedEntities"; | ||
| 29 | 30 | ||
| 30 | import { AppText as Text } from "../../components/AppText"; | 31 | import { AppText as Text } from "../../components/AppText"; |
| 31 | import { ComposerAttachmentStrip } from "../../components/ComposerAttachmentStrip"; | 32 | import { ComposerAttachmentStrip } from "../../components/ComposerAttachmentStrip"; |
| ... | @@ -44,7 +45,7 @@ import { ControlPill, ControlPillMenu } from "../../components/ControlPill"; | ... | @@ -44,7 +45,7 @@ import { ControlPill, ControlPillMenu } from "../../components/ControlPill"; |
| 44 | import { ProviderIcon } from "../../components/ProviderIcon"; | 45 | import { ProviderIcon } from "../../components/ProviderIcon"; |
| 45 | import type { DraftComposerImageAttachment } from "../../lib/composerImages"; | 46 | import type { DraftComposerImageAttachment } from "../../lib/composerImages"; |
| 46 | import { buildModelOptions, groupByProvider } from "../../lib/modelOptions"; | 47 | import { buildModelOptions, groupByProvider } from "../../lib/modelOptions"; |
| 47 | import { MOBILE_TYPOGRAPHY } from "../../lib/typography"; | 48 | import { useScaledTextRole } from "../settings/appearance/useScaledTextRole"; |
| 48 | import type { RemoteClientConnectionState } from "../../lib/connection"; | 49 | import type { RemoteClientConnectionState } from "../../lib/connection"; |
| 49 | import { | 50 | import { |
| 50 | insertRankedSearchResult, | 51 | insertRankedSearchResult, |
| ... | @@ -76,10 +77,17 @@ export interface ThreadComposerProps { | ... | @@ -76,10 +77,17 @@ export interface ThreadComposerProps { |
| 76 | readonly draftMessage: string; | 77 | readonly draftMessage: string; |
| 77 | readonly draftAttachments: ReadonlyArray<DraftComposerImageAttachment>; | 78 | readonly draftAttachments: ReadonlyArray<DraftComposerImageAttachment>; |
| 78 | readonly placeholder: string; | 79 | readonly placeholder: string; |
| 80 | readonly contentMaxWidth?: number; | ||
| 79 | readonly bottomInset?: number; | 81 | readonly bottomInset?: number; |
| 80 | readonly connectionState: RemoteClientConnectionState; | 82 | readonly connectionState: RemoteClientConnectionState; |
| 81 | readonly connectionError: string | null; | 83 | readonly connectionError: string | null; |
| 82 | readonly environmentLabel: string | null; | 84 | readonly environmentLabel: string | null; |
| 85 | /** | ||
| 86 | * Message sync phase for the selected thread (drives the status pill): | ||
| 87 | * "loading" = first fetch, nothing to show yet; "syncing" = cached messages | ||
| 88 | * are on screen while they reconcile with the server. | ||
| 89 | */ | ||
| 90 | readonly threadSyncPhase?: "loading" | "syncing" | null; | ||
| 83 | readonly selectedThread: OrchestrationThreadShell; | 91 | readonly selectedThread: OrchestrationThreadShell; |
| 84 | readonly serverConfig: T3ServerConfig | null; | 92 | readonly serverConfig: T3ServerConfig | null; |
| 85 | readonly queueCount: number; | 93 | readonly queueCount: number; |
| ... | @@ -139,11 +147,17 @@ function ComposerSurface(props: { | ... | @@ -139,11 +147,17 @@ function ComposerSurface(props: { |
| 139 | ); | 147 | ); |
| 140 | } | 148 | } |
| 141 | 149 | ||
| 150 | type ComposerStatusPillState = { | ||
| 151 | readonly kind: "unavailable" | "reconnecting" | "syncing"; | ||
| 152 | readonly label: string; | ||
| 153 | }; | ||
| 154 | |||
| 142 | function composerConnectionStatus(input: { | 155 | function composerConnectionStatus(input: { |
| 143 | readonly connectionError: string | null; | 156 | readonly connectionError: string | null; |
| 144 | readonly connectionState: RemoteClientConnectionState; | 157 | readonly connectionState: RemoteClientConnectionState; |
| 145 | readonly environmentLabel: string | null; | 158 | readonly environmentLabel: string | null; |
| 146 | }): { readonly kind: "unavailable" | "reconnecting"; readonly label: string } | null { | 159 | readonly threadSyncPhase?: "loading" | "syncing" | null; |
| 160 | }): ComposerStatusPillState | null { | ||
| 147 | const environmentLabel = input.environmentLabel ?? "Environment"; | 161 | const environmentLabel = input.environmentLabel ?? "Environment"; |
| 148 | 162 | ||
| 149 | switch (input.connectionState) { | 163 | switch (input.connectionState) { |
| ... | @@ -168,15 +182,27 @@ function composerConnectionStatus(input: { | ... | @@ -168,15 +182,27 @@ function composerConnectionStatus(input: { |
| 168 | case "available": | 182 | case "available": |
| 169 | return { kind: "unavailable", label: `${environmentLabel} is not connected` }; | 183 | return { kind: "unavailable", label: `${environmentLabel} is not connected` }; |
| 170 | case "connected": | 184 | case "connected": |
| 185 | break; | ||
| 186 | } | ||
| 187 | |||
| 188 | // Connected: the pill is the single loading/sync indicator. One stable | ||
| 189 | // label per open — "Loading" when starting from scratch, "Syncing" when | ||
| 190 | // cached messages are already visible. | ||
| 191 | switch (input.threadSyncPhase) { | ||
| 192 | case "loading": | ||
| 193 | return { kind: "syncing", label: "Loading messages..." }; | ||
| 194 | case "syncing": | ||
| 195 | return { kind: "syncing", label: "Syncing messages..." }; | ||
| 196 | default: | ||
| 171 | return null; | 197 | return null; |
| 172 | } | 198 | } |
| 173 | } | 199 | } |
| 174 | 200 | ||
| 175 | const ComposerConnectionStatusPill = memo(function ComposerConnectionStatusPill(props: { | 201 | const ComposerConnectionStatusPill = memo(function ComposerConnectionStatusPill(props: { |
| 176 | readonly onPress: () => void; | 202 | readonly onPress: () => void; |
| 177 | readonly status: { readonly kind: "unavailable" | "reconnecting"; readonly label: string }; | 203 | readonly status: ComposerStatusPillState; |
| 178 | }) { | 204 | }) { |
| 179 | const isReconnecting = props.status.kind === "reconnecting"; | 205 | const isReconnecting = props.status.kind !== "unavailable"; |
| 180 | 206 | ||
| 181 | return ( | 207 | return ( |
| 182 | <View className="items-center pb-2"> | 208 | <View className="items-center pb-2"> |
| ... | @@ -191,7 +217,7 @@ const ComposerConnectionStatusPill = memo(function ComposerConnectionStatusPill( | ... | @@ -191,7 +217,7 @@ const ComposerConnectionStatusPill = memo(function ComposerConnectionStatusPill( |
| 191 | <View className="h-2 w-2 rounded-full bg-red-500" /> | 217 | <View className="h-2 w-2 rounded-full bg-red-500" /> |
| 192 | )} | 218 | )} |
| 193 | <Text | 219 | <Text |
| 194 | className="max-w-[260px] text-sm font-t3-bold leading-[17px] text-foreground" | 220 | className="max-w-[260px] text-sm font-t3-bold leading-snug text-foreground" |
| 195 | numberOfLines={1} | 221 | numberOfLines={1} |
| 196 | > | 222 | > |
| 197 | {props.status.label} | 223 | {props.status.label} |
| ... | @@ -204,10 +230,12 @@ const ComposerConnectionStatusPill = memo(function ComposerConnectionStatusPill( | ... | @@ -204,10 +230,12 @@ const ComposerConnectionStatusPill = memo(function ComposerConnectionStatusPill( |
| 204 | export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposerProps) { | 230 | export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposerProps) { |
| 205 | const isDarkMode = useColorScheme() === "dark"; | 231 | const isDarkMode = useColorScheme() === "dark"; |
| 206 | const foregroundColor = useThemeColor("--color-foreground"); | 232 | const foregroundColor = useThemeColor("--color-foreground"); |
| 233 | const bodyText = useScaledTextRole("body"); | ||
| 207 | const fallbackInputRef = useRef<ComposerEditorHandle>(null); | 234 | const fallbackInputRef = useRef<ComposerEditorHandle>(null); |
| 208 | const inputRef = props.editorRef ?? fallbackInputRef; | 235 | const inputRef = props.editorRef ?? fallbackInputRef; |
| 209 | const [isFocused, setIsFocused] = useState(false); | 236 | const [isFocused, setIsFocused] = useState(false); |
| 210 | const wasExpandedBeforePreviewRef = useRef(false); | 237 | const wasExpandedBeforePreviewRef = useRef(false); |
| 238 | const inFlightThreadIdsRef = useRef(new Set<string>()); | ||
| 211 | const { onExpandedChange } = props; | 239 | const { onExpandedChange } = props; |
| 212 | 240 | ||
| 213 | const [previewImageUri, setPreviewImageUri] = useState<string | null>(null); | 241 | const [previewImageUri, setPreviewImageUri] = useState<string | null>(null); |
| ... | @@ -254,6 +282,7 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer | ... | @@ -254,6 +282,7 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer |
| 254 | connectionError: props.connectionError, | 282 | connectionError: props.connectionError, |
| 255 | connectionState: props.connectionState, | 283 | connectionState: props.connectionState, |
| 256 | environmentLabel: props.environmentLabel, | 284 | environmentLabel: props.environmentLabel, |
| 285 | threadSyncPhase: props.threadSyncPhase, | ||
| 257 | }); | 286 | }); |
| 258 | const toolbarFadeOpaque = isDarkMode ? "rgba(0,0,0,0.95)" : "rgba(255,255,255,0.95)"; | 287 | const toolbarFadeOpaque = isDarkMode ? "rgba(0,0,0,0.95)" : "rgba(255,255,255,0.95)"; |
| 259 | const toolbarFadeTransparent = isDarkMode ? "rgba(0,0,0,0)" : "rgba(255,255,255,0)"; | 288 | const toolbarFadeTransparent = isDarkMode ? "rgba(0,0,0,0)" : "rgba(255,255,255,0)"; |
| ... | @@ -447,9 +476,16 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer | ... | @@ -447,9 +476,16 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer |
| 447 | // ── Handle command selection ────────────────────────────── | 476 | // ── Handle command selection ────────────────────────────── |
| 448 | const { onChangeDraftMessage, onUpdateInteractionMode, draftMessage, onSendMessage } = props; | 477 | const { onChangeDraftMessage, onUpdateInteractionMode, draftMessage, onSendMessage } = props; |
| 449 | 478 | ||
| 450 | const handleSend = useCallback(() => { | 479 | const handleSend = useCallback(async () => { |
| 451 | void onSendMessage(); | 480 | const threadKey = scopedThreadKey(props.environmentId, props.selectedThread.id); |
| 452 | }, [onSendMessage]); | 481 | if (inFlightThreadIdsRef.current.has(threadKey)) return; |
| 482 | inFlightThreadIdsRef.current.add(threadKey); | ||
| 483 | try { | ||
| 484 | await onSendMessage(); | ||
| 485 | } finally { | ||
| 486 | inFlightThreadIdsRef.current.delete(threadKey); | ||
| 487 | } | ||
| 488 | }, [onSendMessage, props.environmentId, props.selectedThread.id]); | ||
| 453 | const handleCommandSelect = useCallback( | 489 | const handleCommandSelect = useCallback( |
| 454 | (item: ComposerCommandItem) => { | 490 | (item: ComposerCommandItem) => { |
| 455 | if (!composerTrigger) return; | 491 | if (!composerTrigger) return; |
| ... | @@ -629,7 +665,10 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer | ... | @@ -629,7 +665,10 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer |
| 629 | : "linear-gradient(to bottom, rgba(255,255,255,0) 0%, rgba(255,255,255,0.85) 40%, rgba(255,255,255,0.95) 100%)", | 665 | : "linear-gradient(to bottom, rgba(255,255,255,0) 0%, rgba(255,255,255,0.85) 40%, rgba(255,255,255,0.95) 100%)", |
| 630 | }} | 666 | }} |
| 631 | > | 667 | > |
| 632 | <View className="w-full" style={{ position: "relative" }}> | 668 | <View |
| 669 | className="w-full" | ||
| 670 | style={{ alignSelf: "center", maxWidth: props.contentMaxWidth, position: "relative" }} | ||
| 671 | > | ||
| 633 | {composerTrigger && composerMenuItems.length > 0 ? ( | 672 | {composerTrigger && composerMenuItems.length > 0 ? ( |
| 634 | <View | 673 | <View |
| 635 | style={{ | 674 | style={{ |
| ... | @@ -702,6 +741,7 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer | ... | @@ -702,6 +741,7 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer |
| 702 | placeholder={props.placeholder} | 741 | placeholder={props.placeholder} |
| 703 | onFocus={handleFocus} | 742 | onFocus={handleFocus} |
| 704 | onBlur={handleBlur} | 743 | onBlur={handleBlur} |
| 744 | onSubmit={handleSend} | ||
| 705 | scrollEnabled={isExpanded} | 745 | scrollEnabled={isExpanded} |
| 706 | contentInsetVertical={isExpanded ? 0 : 6} | 746 | contentInsetVertical={isExpanded ? 0 : 6} |
| 707 | style={ | 747 | style={ |
| ... | @@ -717,7 +757,7 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer | ... | @@ -717,7 +757,7 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer |
| 717 | } | 757 | } |
| 718 | } | 758 | } |
| 719 | textStyle={{ | 759 | textStyle={{ |
| 720 | ...MOBILE_TYPOGRAPHY.composer, | 760 | ...bodyText, |
| 721 | color: foregroundColor, | 761 | color: foregroundColor, |
| 722 | fontFamily: "DMSans_400Regular", | 762 | fontFamily: "DMSans_400Regular", |
| 723 | }} | 763 | }} |
| ... | @@ -827,13 +867,7 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer | ... | @@ -827,13 +867,7 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer |
| 827 | 867 | ||
| 828 | {/* Queue count */} | 868 | {/* Queue count */} |
| 829 | {props.queueCount > 0 ? ( | 869 | {props.queueCount > 0 ? ( |
| 830 | <Text | 870 | <Text className="text-xs text-foreground-muted" style={{ paddingTop: 8 }}> |
| 831 | className="text-foreground-muted" | ||
| 832 | style={{ | ||
| 833 | ...MOBILE_TYPOGRAPHY.label, | ||
| 834 | paddingTop: 8, | ||
| 835 | }} | ||
| 836 | > | ||
| 837 | {props.queueCount} queued message{props.queueCount === 1 ? "" : "s"} will send | 871 | {props.queueCount} queued message{props.queueCount === 1 ? "" : "s"} will send |
| 838 | automatically. | 872 | automatically. |
| 839 | </Text> | 873 | </Text> |
apps/mobile/src/features/threads/ThreadDetailScreen.tsx+62-31| ... | @@ -1,4 +1,5 @@ | ... | @@ -1,4 +1,5 @@ |
| 1 | import { type EnvironmentConnectionPhase } from "@t3tools/client-runtime/connection"; | 1 | import { type EnvironmentConnectionPhase } from "@t3tools/client-runtime/connection"; |
| 2 | import type { EnvironmentThreadStatus } from "@t3tools/client-runtime/state/threads"; | ||
| 2 | import { useKeyboardChatComposerInset, useKeyboardScrollToEnd } from "@legendapp/list/keyboard"; | 3 | import { useKeyboardChatComposerInset, useKeyboardScrollToEnd } from "@legendapp/list/keyboard"; |
| 3 | import type { LegendListRef } from "@legendapp/list/react-native"; | 4 | import type { LegendListRef } from "@legendapp/list/react-native"; |
| 4 | import type { | 5 | import type { |
| ... | @@ -15,7 +16,6 @@ import type { | ... | @@ -15,7 +16,6 @@ import type { |
| 15 | } from "@t3tools/contracts"; | 16 | } from "@t3tools/contracts"; |
| 16 | import { formatElapsed } from "@t3tools/shared/orchestrationTiming"; | 17 | import { formatElapsed } from "@t3tools/shared/orchestrationTiming"; |
| 17 | import * as Haptics from "expo-haptics"; | 18 | import * as Haptics from "expo-haptics"; |
| 18 | import { useHeaderHeight } from "expo-router/build/react-navigation/elements"; | ||
| 19 | import { memo, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react"; | 19 | import { memo, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react"; |
| 20 | import { View, type GestureResponderEvent } from "react-native"; | 20 | import { View, type GestureResponderEvent } from "react-native"; |
| 21 | import { Gesture, GestureDetector } from "react-native-gesture-handler"; | 21 | import { Gesture, GestureDetector } from "react-native-gesture-handler"; |
| ... | @@ -27,7 +27,7 @@ import { AppText as Text } from "../../components/AppText"; | ... | @@ -27,7 +27,7 @@ import { AppText as Text } from "../../components/AppText"; |
| 27 | import type { ComposerEditorHandle } from "../../components/ComposerEditor"; | 27 | import type { ComposerEditorHandle } from "../../components/ComposerEditor"; |
| 28 | import type { StatusTone } from "../../components/StatusPill"; | 28 | import type { StatusTone } from "../../components/StatusPill"; |
| 29 | import type { DraftComposerImageAttachment } from "../../lib/composerImages"; | 29 | import type { DraftComposerImageAttachment } from "../../lib/composerImages"; |
| 30 | import type { LayoutVariant } from "../../lib/layout"; | 30 | import { CHAT_CONTENT_MAX_WIDTH, type LayoutVariant } from "../../lib/layout"; |
| 31 | import { scopedThreadKey } from "../../lib/scopedEntities"; | 31 | import { scopedThreadKey } from "../../lib/scopedEntities"; |
| 32 | import type { | 32 | import type { |
| 33 | PendingApproval, | 33 | PendingApproval, |
| ... | @@ -62,6 +62,8 @@ export interface ThreadDetailScreenProps { | ... | @@ -62,6 +62,8 @@ export interface ThreadDetailScreenProps { |
| 62 | readonly draftMessage: string; | 62 | readonly draftMessage: string; |
| 63 | readonly draftAttachments: ReadonlyArray<DraftComposerImageAttachment>; | 63 | readonly draftAttachments: ReadonlyArray<DraftComposerImageAttachment>; |
| 64 | readonly connectionStateLabel: EnvironmentConnectionPhase; | 64 | readonly connectionStateLabel: EnvironmentConnectionPhase; |
| 65 | /** Message sync status for the selected thread (drives the composer status pill). */ | ||
| 66 | readonly threadSyncStatus?: EnvironmentThreadStatus; | ||
| 65 | readonly activeThreadBusy: boolean; | 67 | readonly activeThreadBusy: boolean; |
| 66 | readonly environmentId: EnvironmentId; | 68 | readonly environmentId: EnvironmentId; |
| 67 | readonly projectWorkspaceRoot: string | null; | 69 | readonly projectWorkspaceRoot: string | null; |
| ... | @@ -69,6 +71,8 @@ export interface ThreadDetailScreenProps { | ... | @@ -69,6 +71,8 @@ export interface ThreadDetailScreenProps { |
| 69 | readonly selectedThreadQueueCount: number; | 71 | readonly selectedThreadQueueCount: number; |
| 70 | readonly serverConfig: T3ServerConfig | null; | 72 | readonly serverConfig: T3ServerConfig | null; |
| 71 | readonly layoutVariant?: LayoutVariant; | 73 | readonly layoutVariant?: LayoutVariant; |
| 74 | readonly usesAutomaticContentInsets?: boolean; | ||
| 75 | readonly onHeaderMaterialVisibilityChange?: (visible: boolean) => void; | ||
| 72 | readonly onOpenDrawer: () => void; | 76 | readonly onOpenDrawer: () => void; |
| 73 | readonly onOpenConnectionEditor: () => void; | 77 | readonly onOpenConnectionEditor: () => void; |
| 74 | readonly onChangeDraftMessage: (value: string) => void; | 78 | readonly onChangeDraftMessage: (value: string) => void; |
| ... | @@ -207,7 +211,6 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread | ... | @@ -207,7 +211,6 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread |
| 207 | const { onOpenDrawer } = props; | 211 | const { onOpenDrawer } = props; |
| 208 | 212 | ||
| 209 | const insets = useSafeAreaInsets(); | 213 | const insets = useSafeAreaInsets(); |
| 210 | const headerHeight = useHeaderHeight(); | ||
| 211 | const agentLabel = `${props.selectedThread.modelSelection.instanceId} agent`; | 214 | const agentLabel = `${props.selectedThread.modelSelection.instanceId} agent`; |
| 212 | const selectedThreadKey = scopedThreadKey(props.environmentId, props.selectedThread.id); | 215 | const selectedThreadKey = scopedThreadKey(props.environmentId, props.selectedThread.id); |
| 213 | const composerEditorRef = useRef<ComposerEditorHandle>(null); | 216 | const composerEditorRef = useRef<ComposerEditorHandle>(null); |
| ... | @@ -220,6 +223,22 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread | ... | @@ -220,6 +223,22 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread |
| 220 | const [anchorMessageId, setAnchorMessageId] = useState<MessageId | null>(null); | 223 | const [anchorMessageId, setAnchorMessageId] = useState<MessageId | null>(null); |
| 221 | const composerBottomInset = composerExpanded ? 0 : Math.max(insets.bottom, 12); | 224 | const composerBottomInset = composerExpanded ? 0 : Math.max(insets.bottom, 12); |
| 222 | const contentPresentationKind = props.contentPresentation.kind; | 225 | const contentPresentationKind = props.contentPresentation.kind; |
| 226 | // The raw sync status enters "synchronizing" on every full fetch, cached or | ||
| 227 | // not. Whether messages are already on screen decides the pill label: no | ||
| 228 | // data yet → "Loading messages", cached data reconciling → "Syncing". | ||
| 229 | const threadSyncPhase = (() => { | ||
| 230 | switch (props.threadSyncStatus) { | ||
| 231 | case "empty": | ||
| 232 | case "cached": | ||
| 233 | case "synchronizing": | ||
| 234 | if (contentPresentationKind === "ready") { | ||
| 235 | return "syncing" as const; | ||
| 236 | } | ||
| 237 | return contentPresentationKind === "loading" ? ("loading" as const) : null; | ||
| 238 | default: | ||
| 239 | return null; | ||
| 240 | } | ||
| 241 | })(); | ||
| 223 | const selectedThreadFeed = props.selectedThreadFeed; | 242 | const selectedThreadFeed = props.selectedThreadFeed; |
| 224 | const composerChrome = composerExpanded ? COMPOSER_EXPANDED_CHROME : COMPOSER_COLLAPSED_CHROME; | 243 | const composerChrome = composerExpanded ? COMPOSER_EXPANDED_CHROME : COMPOSER_COLLAPSED_CHROME; |
| 225 | const composerOverlapHeight = composerChrome + composerBottomInset; | 244 | const composerOverlapHeight = composerChrome + composerBottomInset; |
| ... | @@ -234,6 +253,7 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread | ... | @@ -234,6 +253,7 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread |
| 234 | const showContent = props.showContent ?? true; | 253 | const showContent = props.showContent ?? true; |
| 235 | const layoutVariant = props.layoutVariant ?? "compact"; | 254 | const layoutVariant = props.layoutVariant ?? "compact"; |
| 236 | const isSplitLayout = layoutVariant === "split"; | 255 | const isSplitLayout = layoutVariant === "split"; |
| 256 | const contentMaxWidth = isSplitLayout ? CHAT_CONTENT_MAX_WIDTH : undefined; | ||
| 237 | const selectedInstanceId = props.selectedThread.modelSelection.instanceId; | 257 | const selectedInstanceId = props.selectedThread.modelSelection.instanceId; |
| 238 | useStreamingHaptics(props.selectedThread.id, props.selectedThreadFeed); | 258 | useStreamingHaptics(props.selectedThread.id, props.selectedThreadFeed); |
| 239 | const selectedProviderSkills = useMemo( | 259 | const selectedProviderSkills = useMemo( |
| ... | @@ -382,9 +402,12 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread | ... | @@ -382,9 +402,12 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread |
| 382 | freeze={freeze} | 402 | freeze={freeze} |
| 383 | anchorMessageId={anchorMessageId} | 403 | anchorMessageId={anchorMessageId} |
| 384 | contentInsetEndAdjustment={contentInsetEndAdjustment} | 404 | contentInsetEndAdjustment={contentInsetEndAdjustment} |
| 385 | contentTopInset={headerHeight} | 405 | contentTopInset={0} |
| 386 | contentBottomInset={estimatedOverlayHeight} | 406 | contentBottomInset={estimatedOverlayHeight} |
| 407 | contentMaxWidth={contentMaxWidth} | ||
| 387 | layoutVariant={layoutVariant} | 408 | layoutVariant={layoutVariant} |
| 409 | usesAutomaticContentInsets={props.usesAutomaticContentInsets} | ||
| 410 | onHeaderMaterialVisibilityChange={props.onHeaderMaterialVisibilityChange} | ||
| 388 | skills={selectedProviderSkills} | 411 | skills={selectedProviderSkills} |
| 389 | /> | 412 | /> |
| 390 | </View> | 413 | </View> |
| ... | @@ -398,42 +421,50 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread | ... | @@ -398,42 +421,50 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread |
| 398 | style={{ position: "absolute", bottom: 0, left: 0, right: 0 }} | 421 | style={{ position: "absolute", bottom: 0, left: 0, right: 0 }} |
| 399 | offset={{ closed: 0, opened: 0 }} | 422 | offset={{ closed: 0, opened: 0 }} |
| 400 | > | 423 | > |
| 401 | <View ref={composerOverlayRef} onLayout={onComposerLayout} style={{ paddingTop: 8 }}> | 424 | <View |
| 402 | {props.activeWorkStartedAt ? ( | 425 | ref={composerOverlayRef} |
| 403 | <WorkingDurationPill startedAt={props.activeWorkStartedAt} /> | 426 | onLayout={onComposerLayout} |
| 404 | ) : null} | 427 | style={{ width: "100%", paddingTop: 8 }} |
| 405 | 428 | > | |
| 406 | {props.activePendingApproval || props.activePendingUserInput ? ( | 429 | <View style={{ alignSelf: "center", maxWidth: contentMaxWidth, width: "100%" }}> |
| 407 | <View className="gap-3 px-4 pb-3" style={{ flexShrink: 0 }}> | 430 | {props.activeWorkStartedAt ? ( |
| 408 | {props.activePendingApproval ? ( | 431 | <WorkingDurationPill startedAt={props.activeWorkStartedAt} /> |
| 409 | <PendingApprovalCard | 432 | ) : null} |
| 410 | approval={props.activePendingApproval} | 433 | |
| 411 | respondingApprovalId={props.respondingApprovalId} | 434 | {props.activePendingApproval || props.activePendingUserInput ? ( |
| 412 | onRespond={props.onRespondToApproval} | 435 | <View className="gap-3 px-4 pb-3" style={{ flexShrink: 0 }}> |
| 413 | /> | 436 | {props.activePendingApproval ? ( |
| 414 | ) : null} | 437 | <PendingApprovalCard |
| 415 | {props.activePendingUserInput ? ( | 438 | approval={props.activePendingApproval} |
| 416 | <PendingUserInputCard | 439 | respondingApprovalId={props.respondingApprovalId} |
| 417 | pendingUserInput={props.activePendingUserInput} | 440 | onRespond={props.onRespondToApproval} |
| 418 | drafts={props.activePendingUserInputDrafts} | 441 | /> |
| 419 | answers={props.activePendingUserInputAnswers} | 442 | ) : null} |
| 420 | respondingUserInputId={props.respondingUserInputId} | 443 | {props.activePendingUserInput ? ( |
| 421 | onSelectOption={props.onSelectUserInputOption} | 444 | <PendingUserInputCard |
| 422 | onChangeCustomAnswer={props.onChangeUserInputCustomAnswer} | 445 | pendingUserInput={props.activePendingUserInput} |
| 423 | onSubmit={props.onSubmitUserInput} | 446 | drafts={props.activePendingUserInputDrafts} |
| 424 | /> | 447 | answers={props.activePendingUserInputAnswers} |
| 425 | ) : null} | 448 | respondingUserInputId={props.respondingUserInputId} |
| 426 | </View> | 449 | onSelectOption={props.onSelectUserInputOption} |
| 427 | ) : null} | 450 | onChangeCustomAnswer={props.onChangeUserInputCustomAnswer} |
| 451 | onSubmit={props.onSubmitUserInput} | ||
| 452 | /> | ||
| 453 | ) : null} | ||
| 454 | </View> | ||
| 455 | ) : null} | ||
| 456 | </View> | ||
| 428 | 457 | ||
| 429 | <ThreadComposer | 458 | <ThreadComposer |
| 430 | editorRef={composerEditorRef} | 459 | editorRef={composerEditorRef} |
| 431 | draftMessage={props.draftMessage} | 460 | draftMessage={props.draftMessage} |
| 432 | draftAttachments={props.draftAttachments} | 461 | draftAttachments={props.draftAttachments} |
| 433 | placeholder="Ask the repo agent, or run a command…" | 462 | placeholder="Ask the repo agent, or run a command…" |
| 463 | contentMaxWidth={contentMaxWidth} | ||
| 434 | connectionState={props.connectionStateLabel} | 464 | connectionState={props.connectionStateLabel} |
| 435 | connectionError={props.connectionError} | 465 | connectionError={props.connectionError} |
| 436 | environmentLabel={props.environmentLabel} | 466 | environmentLabel={props.environmentLabel} |
| 467 | threadSyncPhase={threadSyncPhase} | ||
| 437 | selectedThread={props.selectedThread} | 468 | selectedThread={props.selectedThread} |
| 438 | serverConfig={props.serverConfig} | 469 | serverConfig={props.serverConfig} |
| 439 | queueCount={props.selectedThreadQueueCount} | 470 | queueCount={props.selectedThreadQueueCount} |
apps/mobile/src/features/threads/ThreadFeed.tsx+176-107| ... | @@ -4,7 +4,7 @@ import { type LegendListRef } from "@legendapp/list/react-native"; | ... | @@ -4,7 +4,7 @@ import { type LegendListRef } from "@legendapp/list/react-native"; |
| 4 | import type { EnvironmentId, MessageId, ThreadId, TurnId } from "@t3tools/contracts"; | 4 | import type { EnvironmentId, MessageId, ThreadId, TurnId } from "@t3tools/contracts"; |
| 5 | import { CHAT_LIST_ANCHOR_OFFSET, resolveChatListAnchoredEndSpace } from "@t3tools/shared/chatList"; | 5 | import { CHAT_LIST_ANCHOR_OFFSET, resolveChatListAnchoredEndSpace } from "@t3tools/shared/chatList"; |
| 6 | import { SymbolView } from "expo-symbols"; | 6 | import { SymbolView } from "expo-symbols"; |
| 7 | import { useRouter } from "expo-router"; | 7 | import { useNavigation } from "@react-navigation/native"; |
| 8 | import { | 8 | import { |
| 9 | memo, | 9 | memo, |
| 10 | useCallback, | 10 | useCallback, |
| ... | @@ -25,6 +25,10 @@ import { | ... | @@ -25,6 +25,10 @@ import { |
| 25 | ActivityIndicator, | 25 | ActivityIndicator, |
| 26 | Image, | 26 | Image, |
| 27 | Linking, | 27 | Linking, |
| 28 | Platform, | ||
| 29 | type LayoutChangeEvent, | ||
| 30 | type NativeScrollEvent, | ||
| 31 | type NativeSyntheticEvent, | ||
| 28 | Pressable, | 32 | Pressable, |
| 29 | ScrollView, | 33 | ScrollView, |
| 30 | StyleSheet, | 34 | StyleSheet, |
| ... | @@ -37,7 +41,7 @@ import { | ... | @@ -37,7 +41,7 @@ import { |
| 37 | import { TouchableOpacity } from "react-native-gesture-handler"; | 41 | import { TouchableOpacity } from "react-native-gesture-handler"; |
| 38 | import ImageViewing from "react-native-image-viewing"; | 42 | import ImageViewing from "react-native-image-viewing"; |
| 39 | import { useSafeAreaInsets } from "react-native-safe-area-context"; | 43 | import { useSafeAreaInsets } from "react-native-safe-area-context"; |
| 40 | import type { SharedValue } from "react-native-reanimated"; | 44 | import { type SharedValue } from "react-native-reanimated"; |
| 41 | import { useThemeColor } from "../../lib/useThemeColor"; | 45 | import { useThemeColor } from "../../lib/useThemeColor"; |
| 42 | import { copyTextWithHaptic } from "../../lib/copyTextWithHaptic"; | 46 | import { copyTextWithHaptic } from "../../lib/copyTextWithHaptic"; |
| 43 | import { | 47 | import { |
| ... | @@ -58,14 +62,16 @@ import { | ... | @@ -58,14 +62,16 @@ import { |
| 58 | buildNativeReviewDiffData, | 62 | buildNativeReviewDiffData, |
| 59 | createNativeReviewDiffTheme, | 63 | createNativeReviewDiffTheme, |
| 60 | NATIVE_REVIEW_DIFF_CONTENT_WIDTH, | 64 | NATIVE_REVIEW_DIFF_CONTENT_WIDTH, |
| 61 | NATIVE_REVIEW_DIFF_ROW_HEIGHT, | ||
| 62 | NATIVE_REVIEW_DIFF_STYLE, | ||
| 63 | } from "../review/nativeReviewDiffAdapter"; | 65 | } from "../review/nativeReviewDiffAdapter"; |
| 64 | import { buildReviewParsedDiff } from "../review/reviewModel"; | 66 | import { buildReviewParsedDiff } from "../review/reviewModel"; |
| 65 | import { cn } from "../../lib/cn"; | 67 | import { cn } from "../../lib/cn"; |
| 66 | import type { LayoutVariant } from "../../lib/layout"; | 68 | import { deriveCenteredContentHorizontalPadding, type LayoutVariant } from "../../lib/layout"; |
| 67 | import { buildThreadFilesNavigation } from "../../lib/routes"; | 69 | import { |
| 68 | import { MOBILE_CODE_SURFACE, MOBILE_TYPOGRAPHY } from "../../lib/typography"; | 70 | resolveMarkdownFontSizes, |
| 71 | resolveNativeMarkdownTypography, | ||
| 72 | } from "../../lib/appearancePreferences"; | ||
| 73 | import { useAppearancePreferences } from "../settings/appearance/AppearancePreferencesProvider"; | ||
| 74 | import { useAppearanceCodeSurface } from "../settings/appearance/useAppearanceCodeSurface"; | ||
| 69 | import { markdownFileIconSource } from "@t3tools/mobile-markdown-text/file-icons"; | 75 | import { markdownFileIconSource } from "@t3tools/mobile-markdown-text/file-icons"; |
| 70 | import { resolveMarkdownLinkPresentation } from "@t3tools/mobile-markdown-text/links"; | 76 | import { resolveMarkdownLinkPresentation } from "@t3tools/mobile-markdown-text/links"; |
| 71 | import { | 77 | import { |
| ... | @@ -82,7 +88,6 @@ const MESSAGE_TIME_FORMATTER = new Intl.DateTimeFormat(undefined, { | ... | @@ -82,7 +88,6 @@ const MESSAGE_TIME_FORMATTER = new Intl.DateTimeFormat(undefined, { |
| 82 | hour: "numeric", | 88 | hour: "numeric", |
| 83 | minute: "2-digit", | 89 | minute: "2-digit", |
| 84 | }); | 90 | }); |
| 85 | |||
| 86 | function formatMessageTime(input: string): string { | 91 | function formatMessageTime(input: string): string { |
| 87 | const timestamp = Date.parse(input); | 92 | const timestamp = Date.parse(input); |
| 88 | if (Number.isNaN(timestamp)) { | 93 | if (Number.isNaN(timestamp)) { |
| ... | @@ -105,7 +110,10 @@ export interface ThreadFeedProps { | ... | @@ -105,7 +110,10 @@ export interface ThreadFeedProps { |
| 105 | readonly contentInsetEndAdjustment: SharedValue<number>; | 110 | readonly contentInsetEndAdjustment: SharedValue<number>; |
| 106 | readonly contentTopInset?: number; | 111 | readonly contentTopInset?: number; |
| 107 | readonly contentBottomInset?: number; | 112 | readonly contentBottomInset?: number; |
| 113 | readonly contentMaxWidth?: number; | ||
| 108 | readonly layoutVariant?: LayoutVariant; | 114 | readonly layoutVariant?: LayoutVariant; |
| 115 | readonly usesAutomaticContentInsets?: boolean; | ||
| 116 | readonly onHeaderMaterialVisibilityChange?: (visible: boolean) => void; | ||
| 109 | readonly skills?: ReadonlyArray<SelectableMarkdownSkill>; | 117 | readonly skills?: ReadonlyArray<SelectableMarkdownSkill>; |
| 110 | } | 118 | } |
| 111 | 119 | ||
| ... | @@ -273,6 +281,15 @@ function useReviewCommentColors(): ReviewCommentColors { | ... | @@ -273,6 +281,15 @@ function useReviewCommentColors(): ReviewCommentColors { |
| 273 | 281 | ||
| 274 | function useMarkdownStyles(onLinkPress: (href: string) => void): MarkdownStyleSets { | 282 | function useMarkdownStyles(onLinkPress: (href: string) => void): MarkdownStyleSets { |
| 275 | const colorScheme = useColorScheme(); | 283 | const colorScheme = useColorScheme(); |
| 284 | const { appearance } = useAppearancePreferences(); | ||
| 285 | const markdownFontSizes = useMemo( | ||
| 286 | () => resolveMarkdownFontSizes(appearance.baseFontSize), | ||
| 287 | [appearance.baseFontSize], | ||
| 288 | ); | ||
| 289 | const nativeMarkdownTypography = useMemo( | ||
| 290 | () => resolveNativeMarkdownTypography(appearance.baseFontSize), | ||
| 291 | [appearance.baseFontSize], | ||
| 292 | ); | ||
| 276 | const colors = MARKDOWN_COLORS[colorScheme === "dark" ? "dark" : "light"]; | 293 | const colors = MARKDOWN_COLORS[colorScheme === "dark" ? "dark" : "light"]; |
| 277 | const inlineSkillForeground = String(useThemeColor("--color-inline-skill-foreground")); | 294 | const inlineSkillForeground = String(useThemeColor("--color-inline-skill-foreground")); |
| 278 | 295 | ||
| ... | @@ -316,14 +333,14 @@ function useMarkdownStyles(onLinkPress: (href: string) => void): MarkdownStyleSe | ... | @@ -316,14 +333,14 @@ function useMarkdownStyles(onLinkPress: (href: string) => void): MarkdownStyleSe |
| 316 | xl: 16, | 333 | xl: 16, |
| 317 | }, | 334 | }, |
| 318 | fontSizes: { | 335 | fontSizes: { |
| 319 | s: 13, | 336 | s: markdownFontSizes.s, |
| 320 | m: 15, | 337 | m: markdownFontSizes.m, |
| 321 | h1: 20, | 338 | h1: markdownFontSizes.h1, |
| 322 | h2: 18, | 339 | h2: markdownFontSizes.h2, |
| 323 | h3: 16, | 340 | h3: markdownFontSizes.h3, |
| 324 | h4: 14, | 341 | h4: markdownFontSizes.h4, |
| 325 | h5: 14, | 342 | h5: markdownFontSizes.h5, |
| 326 | h6: 14, | 343 | h6: markdownFontSizes.h6, |
| 327 | }, | 344 | }, |
| 328 | fontFamilies: { | 345 | fontFamilies: { |
| 329 | regular: "DMSans_400Regular", | 346 | regular: "DMSans_400Regular", |
| ... | @@ -345,7 +362,7 @@ function useMarkdownStyles(onLinkPress: (href: string) => void): MarkdownStyleSe | ... | @@ -345,7 +362,7 @@ function useMarkdownStyles(onLinkPress: (href: string) => void): MarkdownStyleSe |
| 345 | list: { marginTop: 4, marginBottom: 8 }, | 362 | list: { marginTop: 4, marginBottom: 8 }, |
| 346 | list_item: { marginTop: 0, marginBottom: 4 }, | 363 | list_item: { marginTop: 0, marginBottom: 4 }, |
| 347 | task_list_item: { marginTop: 0, marginBottom: 4 }, | 364 | task_list_item: { marginTop: 0, marginBottom: 4 }, |
| 348 | text: { lineHeight: 22 }, | 365 | text: { lineHeight: markdownFontSizes.bodyLineHeight }, |
| 349 | bold: { | 366 | bold: { |
| 350 | fontWeight: "700", | 367 | fontWeight: "700", |
| 351 | color: markdownStrongColor, | 368 | color: markdownStrongColor, |
| ... | @@ -454,7 +471,8 @@ function useMarkdownStyles(onLinkPress: (href: string) => void): MarkdownStyleSe | ... | @@ -454,7 +471,8 @@ function useMarkdownStyles(onLinkPress: (href: string) => void): MarkdownStyleSe |
| 454 | marginRight: 5, | 471 | marginRight: 5, |
| 455 | color: inlineTextColor, | 472 | color: inlineTextColor, |
| 456 | fontFamily: "DMSans_400Regular", | 473 | fontFamily: "DMSans_400Regular", |
| 457 | ...MOBILE_TYPOGRAPHY.body, | 474 | fontSize: markdownFontSizes.m, |
| 475 | lineHeight: markdownFontSizes.bodyLineHeight, | ||
| 458 | textAlign: ordered ? "right" : "center", | 476 | textAlign: ordered ? "right" : "center", |
| 459 | }} | 477 | }} |
| 460 | > | 478 | > |
| ... | @@ -475,8 +493,8 @@ function useMarkdownStyles(onLinkPress: (href: string) => void): MarkdownStyleSe | ... | @@ -475,8 +493,8 @@ function useMarkdownStyles(onLinkPress: (href: string) => void): MarkdownStyleSe |
| 475 | style={{ | 493 | style={{ |
| 476 | color: inlineCodeTextColor, | 494 | color: inlineCodeTextColor, |
| 477 | fontFamily: "ui-monospace", | 495 | fontFamily: "ui-monospace", |
| 478 | fontSize: MOBILE_TYPOGRAPHY.label.fontSize, | 496 | fontSize: markdownFontSizes.codeBlockFontSize, |
| 479 | lineHeight: 22, | 497 | lineHeight: markdownFontSizes.bodyLineHeight, |
| 480 | }} | 498 | }} |
| 481 | > | 499 | > |
| 482 | {value} | 500 | {value} |
| ... | @@ -512,7 +530,7 @@ function useMarkdownStyles(onLinkPress: (href: string) => void): MarkdownStyleSe | ... | @@ -512,7 +530,7 @@ function useMarkdownStyles(onLinkPress: (href: string) => void): MarkdownStyleSe |
| 512 | style={{ | 530 | style={{ |
| 513 | color: markdownBodyColor, | 531 | color: markdownBodyColor, |
| 514 | fontFamily: "ui-monospace", | 532 | fontFamily: "ui-monospace", |
| 515 | fontSize: MOBILE_TYPOGRAPHY.label.fontSize, | 533 | fontSize: markdownFontSizes.codeBlockFontSize, |
| 516 | opacity: 0.7, | 534 | opacity: 0.7, |
| 517 | textTransform: "uppercase", | 535 | textTransform: "uppercase", |
| 518 | }} | 536 | }} |
| ... | @@ -532,8 +550,8 @@ function useMarkdownStyles(onLinkPress: (href: string) => void): MarkdownStyleSe | ... | @@ -532,8 +550,8 @@ function useMarkdownStyles(onLinkPress: (href: string) => void): MarkdownStyleSe |
| 532 | style={{ | 550 | style={{ |
| 533 | color: blockTextColor, | 551 | color: blockTextColor, |
| 534 | fontFamily: "ui-monospace", | 552 | fontFamily: "ui-monospace", |
| 535 | fontSize: MOBILE_TYPOGRAPHY.label.fontSize, | 553 | fontSize: markdownFontSizes.codeBlockFontSize, |
| 536 | lineHeight: 18, | 554 | lineHeight: markdownFontSizes.codeBlockLineHeight, |
| 537 | }} | 555 | }} |
| 538 | > | 556 | > |
| 539 | {content} | 557 | {content} |
| ... | @@ -612,7 +630,9 @@ function useMarkdownStyles(onLinkPress: (href: string) => void): MarkdownStyleSe | ... | @@ -612,7 +630,9 @@ function useMarkdownStyles(onLinkPress: (href: string) => void): MarkdownStyleSe |
| 612 | skillTextColor: "#f0abfc", | 630 | skillTextColor: "#f0abfc", |
| 613 | quoteMarkerColor: markdownUserBodyColor, | 631 | quoteMarkerColor: markdownUserBodyColor, |
| 614 | dividerColor: markdownUserBodyColor, | 632 | dividerColor: markdownUserBodyColor, |
| 615 | ...MOBILE_TYPOGRAPHY.body, | 633 | fontSize: nativeMarkdownTypography.fontSize, |
| 634 | lineHeight: nativeMarkdownTypography.lineHeight, | ||
| 635 | headingFontSizes: nativeMarkdownTypography.headingFontSizes, | ||
| 616 | fontFamily: "DMSans_400Regular", | 636 | fontFamily: "DMSans_400Regular", |
| 617 | headingFontFamily: "DMSans_700Bold", | 637 | headingFontFamily: "DMSans_700Bold", |
| 618 | boldFontFamily: "DMSans_700Bold", | 638 | boldFontFamily: "DMSans_700Bold", |
| ... | @@ -641,14 +661,16 @@ function useMarkdownStyles(onLinkPress: (href: string) => void): MarkdownStyleSe | ... | @@ -641,14 +661,16 @@ function useMarkdownStyles(onLinkPress: (href: string) => void): MarkdownStyleSe |
| 641 | skillTextColor: inlineSkillForeground, | 661 | skillTextColor: inlineSkillForeground, |
| 642 | quoteMarkerColor: markdownBlockquoteBorder, | 662 | quoteMarkerColor: markdownBlockquoteBorder, |
| 643 | dividerColor: markdownHrColor, | 663 | dividerColor: markdownHrColor, |
| 644 | ...MOBILE_TYPOGRAPHY.body, | 664 | fontSize: nativeMarkdownTypography.fontSize, |
| 665 | lineHeight: nativeMarkdownTypography.lineHeight, | ||
| 666 | headingFontSizes: nativeMarkdownTypography.headingFontSizes, | ||
| 645 | fontFamily: "DMSans_400Regular", | 667 | fontFamily: "DMSans_400Regular", |
| 646 | headingFontFamily: "DMSans_700Bold", | 668 | headingFontFamily: "DMSans_700Bold", |
| 647 | boldFontFamily: "DMSans_700Bold", | 669 | boldFontFamily: "DMSans_700Bold", |
| 648 | }, | 670 | }, |
| 649 | }, | 671 | }, |
| 650 | }; | 672 | }; |
| 651 | }, [colors, inlineSkillForeground, onLinkPress]); | 673 | }, [colors, inlineSkillForeground, markdownFontSizes, nativeMarkdownTypography, onLinkPress]); |
| 652 | } | 674 | } |
| 653 | 675 | ||
| 654 | function renderFeedEntry( | 676 | function renderFeedEntry( |
| ... | @@ -924,6 +946,7 @@ const ReviewCommentCard = memo(function ReviewCommentCard(props: { | ... | @@ -924,6 +946,7 @@ const ReviewCommentCard = memo(function ReviewCommentCard(props: { |
| 924 | readonly comment: ReviewInlineComment; | 946 | readonly comment: ReviewInlineComment; |
| 925 | readonly colors: ReviewCommentColors; | 947 | readonly colors: ReviewCommentColors; |
| 926 | }) { | 948 | }) { |
| 949 | const { codeSurface, nativeReviewDiffStyle } = useAppearanceCodeSurface(); | ||
| 927 | const colorScheme = useColorScheme(); | 950 | const colorScheme = useColorScheme(); |
| 928 | const appearanceScheme = colorScheme === "light" ? "light" : "dark"; | 951 | const appearanceScheme = colorScheme === "light" ? "light" : "dark"; |
| 929 | const NativeReviewDiffView = resolveNativeReviewDiffView(); | 952 | const NativeReviewDiffView = resolveNativeReviewDiffView(); |
| ... | @@ -946,18 +969,21 @@ const ReviewCommentCard = memo(function ReviewCommentCard(props: { | ... | @@ -946,18 +969,21 @@ const ReviewCommentCard = memo(function ReviewCommentCard(props: { |
| 946 | () => JSON.stringify(nativeReviewDiffTheme), | 969 | () => JSON.stringify(nativeReviewDiffTheme), |
| 947 | [nativeReviewDiffTheme], | 970 | [nativeReviewDiffTheme], |
| 948 | ); | 971 | ); |
| 949 | const nativeStyleJson = useMemo(() => JSON.stringify(NATIVE_REVIEW_DIFF_STYLE), []); | 972 | const nativeStyleJson = useMemo( |
| 973 | () => JSON.stringify(nativeReviewDiffStyle), | ||
| 974 | [nativeReviewDiffStyle], | ||
| 975 | ); | ||
| 950 | const nativeDiffHeight = useMemo( | 976 | const nativeDiffHeight = useMemo( |
| 951 | () => | 977 | () => |
| 952 | Math.min( | 978 | Math.min( |
| 953 | 360, | 979 | 360, |
| 954 | Math.max( | 980 | Math.max( |
| 955 | 112, | 981 | 112, |
| 956 | compactNativeRows.length * NATIVE_REVIEW_DIFF_ROW_HEIGHT + | 982 | compactNativeRows.length * nativeReviewDiffStyle.rowHeight + |
| 957 | NATIVE_REVIEW_DIFF_STYLE.fileHeaderVerticalMargin, | 983 | nativeReviewDiffStyle.fileHeaderVerticalMargin, |
| 958 | ), | 984 | ), |
| 959 | ), | 985 | ), |
| 960 | [compactNativeRows.length], | 986 | [compactNativeRows.length, nativeReviewDiffStyle], |
| 961 | ); | 987 | ); |
| 962 | const shouldRenderNativeDiff = NativeReviewDiffView != null && compactNativeRows.length > 0; | 988 | const shouldRenderNativeDiff = NativeReviewDiffView != null && compactNativeRows.length > 0; |
| 963 | 989 | ||
| ... | @@ -987,7 +1013,7 @@ const ReviewCommentCard = memo(function ReviewCommentCard(props: { | ... | @@ -987,7 +1013,7 @@ const ReviewCommentCard = memo(function ReviewCommentCard(props: { |
| 987 | </View> | 1013 | </View> |
| 988 | <View className="min-w-0 flex-1"> | 1014 | <View className="min-w-0 flex-1"> |
| 989 | <Text | 1015 | <Text |
| 990 | className="font-mono text-xs leading-[16px]" | 1016 | className="font-mono text-xs" |
| 991 | numberOfLines={1} | 1017 | numberOfLines={1} |
| 992 | style={{ color: props.colors.text }} | 1018 | style={{ color: props.colors.text }} |
| 993 | > | 1019 | > |
| ... | @@ -1010,7 +1036,7 @@ const ReviewCommentCard = memo(function ReviewCommentCard(props: { | ... | @@ -1010,7 +1036,7 @@ const ReviewCommentCard = memo(function ReviewCommentCard(props: { |
| 1010 | style={StyleSheet.absoluteFill} | 1036 | style={StyleSheet.absoluteFill} |
| 1011 | appearanceScheme={appearanceScheme} | 1037 | appearanceScheme={appearanceScheme} |
| 1012 | contentWidth={NATIVE_REVIEW_DIFF_CONTENT_WIDTH} | 1038 | contentWidth={NATIVE_REVIEW_DIFF_CONTENT_WIDTH} |
| 1013 | rowHeight={NATIVE_REVIEW_DIFF_ROW_HEIGHT} | 1039 | rowHeight={nativeReviewDiffStyle.rowHeight} |
| 1014 | rowsJson={nativeRowsJson} | 1040 | rowsJson={nativeRowsJson} |
| 1015 | styleJson={nativeStyleJson} | 1041 | styleJson={nativeStyleJson} |
| 1016 | themeJson={nativeThemeJson} | 1042 | themeJson={nativeThemeJson} |
| ... | @@ -1032,8 +1058,8 @@ const ReviewCommentCard = memo(function ReviewCommentCard(props: { | ... | @@ -1032,8 +1058,8 @@ const ReviewCommentCard = memo(function ReviewCommentCard(props: { |
| 1032 | style={{ | 1058 | style={{ |
| 1033 | color: props.colors.text, | 1059 | color: props.colors.text, |
| 1034 | fontFamily: "ui-monospace", | 1060 | fontFamily: "ui-monospace", |
| 1035 | fontSize: MOBILE_CODE_SURFACE.fontSize, | 1061 | fontSize: codeSurface.fontSize, |
| 1036 | lineHeight: MOBILE_CODE_SURFACE.rowHeight, | 1062 | lineHeight: codeSurface.rowHeight, |
| 1037 | }} | 1063 | }} |
| 1038 | > | 1064 | > |
| 1039 | {props.comment.diff.trim()} | 1065 | {props.comment.diff.trim()} |
| ... | @@ -1042,11 +1068,7 @@ const ReviewCommentCard = memo(function ReviewCommentCard(props: { | ... | @@ -1042,11 +1068,7 @@ const ReviewCommentCard = memo(function ReviewCommentCard(props: { |
| 1042 | ) : null} | 1068 | ) : null} |
| 1043 | {props.comment.text.length > 0 ? ( | 1069 | {props.comment.text.length > 0 ? ( |
| 1044 | <View className="border-t px-3 py-3" style={{ borderColor: props.colors.border }}> | 1070 | <View className="border-t px-3 py-3" style={{ borderColor: props.colors.border }}> |
| 1045 | <Text | 1071 | <Text selectable className="text-base leading-snug" style={{ color: props.colors.text }}> |
| 1046 | selectable | ||
| 1047 | className="text-base leading-[21px]" | ||
| 1048 | style={{ color: props.colors.text }} | ||
| 1049 | > | ||
| 1050 | {props.comment.text} | 1072 | {props.comment.text} |
| 1051 | </Text> | 1073 | </Text> |
| 1052 | </View> | 1074 | </View> |
| ... | @@ -1087,7 +1109,6 @@ function ThreadFeedPlaceholder(props: { | ... | @@ -1087,7 +1109,6 @@ function ThreadFeedPlaceholder(props: { |
| 1087 | readonly bottomInset: number; | 1109 | readonly bottomInset: number; |
| 1088 | readonly detail: string; | 1110 | readonly detail: string; |
| 1089 | readonly horizontalPadding: number; | 1111 | readonly horizontalPadding: number; |
| 1090 | readonly loading?: boolean; | ||
| 1091 | readonly title: string; | 1112 | readonly title: string; |
| 1092 | readonly topInset: number; | 1113 | readonly topInset: number; |
| 1093 | }) { | 1114 | }) { |
| ... | @@ -1104,9 +1125,8 @@ function ThreadFeedPlaceholder(props: { | ... | @@ -1104,9 +1125,8 @@ function ThreadFeedPlaceholder(props: { |
| 1104 | }} | 1125 | }} |
| 1105 | > | 1126 | > |
| 1106 | <View className="max-w-[320px] items-center gap-2"> | 1127 | <View className="max-w-[320px] items-center gap-2"> |
| 1107 | {props.loading ? <ActivityIndicator style={{ marginBottom: 6 }} /> : null} | ||
| 1108 | <Text className="text-center font-t3-bold text-lg text-foreground">{props.title}</Text> | 1128 | <Text className="text-center font-t3-bold text-lg text-foreground">{props.title}</Text> |
| 1109 | <Text className="text-center text-sm leading-5 text-foreground-secondary"> | 1129 | <Text className="text-center text-sm leading-normal text-foreground-secondary"> |
| 1110 | {props.detail} | 1130 | {props.detail} |
| 1111 | </Text> | 1131 | </Text> |
| 1112 | </View> | 1132 | </View> |
| ... | @@ -1115,13 +1135,17 @@ function ThreadFeedPlaceholder(props: { | ... | @@ -1115,13 +1135,17 @@ function ThreadFeedPlaceholder(props: { |
| 1115 | } | 1135 | } |
| 1116 | 1136 | ||
| 1117 | export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { | 1137 | export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { |
| 1118 | const router = useRouter(); | 1138 | const navigation = useNavigation(); |
| 1119 | const copyFeedbackTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null); | 1139 | const copyFeedbackTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null); |
| 1120 | const foldSettleFrameRef = useRef<number | null>(null); | 1140 | const foldSettleFrameRef = useRef<number | null>(null); |
| 1121 | const foldSettleSecondFrameRef = useRef<number | null>(null); | 1141 | const foldSettleSecondFrameRef = useRef<number | null>(null); |
| 1122 | const disclosureAnchorKeyRef = useRef<string | null>(null); | 1142 | const disclosureAnchorKeyRef = useRef<string | null>(null); |
| 1143 | const headerMaterialVisibleRef = useRef(false); | ||
| 1123 | const previousLatestTurnRef = useRef(props.latestTurn); | 1144 | const previousLatestTurnRef = useRef(props.latestTurn); |
| 1124 | const { width: viewportWidth } = useWindowDimensions(); | 1145 | const { width: windowWidth } = useWindowDimensions(); |
| 1146 | const [viewportWidth, setViewportWidth] = useState(() => | ||
| 1147 | props.layoutVariant === "split" ? 0 : windowWidth, | ||
| 1148 | ); | ||
| 1125 | const [disclosureToggleSettling, setDisclosureToggleSettling] = useState(false); | 1149 | const [disclosureToggleSettling, setDisclosureToggleSettling] = useState(false); |
| 1126 | const [interactionState, setInteractionState] = useState<{ | 1150 | const [interactionState, setInteractionState] = useState<{ |
| 1127 | readonly copiedRowId: string | null; | 1151 | readonly copiedRowId: string | null; |
| ... | @@ -1140,12 +1164,19 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { | ... | @@ -1140,12 +1164,19 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { |
| 1140 | headers?: Record<string, string>; | 1164 | headers?: Record<string, string>; |
| 1141 | } | null>(null); | 1165 | } | null>(null); |
| 1142 | const horizontalPadding = props.layoutVariant === "split" ? 20 : 16; | 1166 | const horizontalPadding = props.layoutVariant === "split" ? 20 : 16; |
| 1143 | const contentWidth = Math.max(0, viewportWidth - horizontalPadding * 2); | 1167 | const contentHorizontalPadding = deriveCenteredContentHorizontalPadding({ |
| 1168 | viewportWidth, | ||
| 1169 | maxContentWidth: props.contentMaxWidth ?? null, | ||
| 1170 | minimumPadding: horizontalPadding, | ||
| 1171 | }); | ||
| 1172 | const contentWidth = Math.max(0, viewportWidth - contentHorizontalPadding * 2); | ||
| 1144 | const userBubbleMaxWidth = contentWidth * 0.85; | 1173 | const userBubbleMaxWidth = contentWidth * 0.85; |
| 1145 | const reviewCommentBubbleWidth = Math.min(Math.max(280, contentWidth * 0.85), contentWidth); | 1174 | const reviewCommentBubbleWidth = Math.min(Math.max(280, contentWidth * 0.85), contentWidth); |
| 1146 | const insets = useSafeAreaInsets(); | 1175 | const insets = useSafeAreaInsets(); |
| 1147 | const topContentInset = props.contentTopInset ?? insets.top + 44; | 1176 | const topContentInset = props.contentTopInset ?? insets.top + 44; |
| 1148 | const bottomContentInset = props.contentBottomInset ?? 18; | 1177 | const bottomContentInset = props.contentBottomInset ?? 18; |
| 1178 | const usesNativeAutomaticInsets = | ||
| 1179 | props.usesAutomaticContentInsets === true && Platform.OS === "ios"; | ||
| 1149 | 1180 | ||
| 1150 | const iconSubtleColor = useThemeColor("--color-icon-subtle"); | 1181 | const iconSubtleColor = useThemeColor("--color-icon-subtle"); |
| 1151 | const userBubbleColor = useThemeColor("--color-user-bubble"); | 1182 | const userBubbleColor = useThemeColor("--color-user-bubble"); |
| ... | @@ -1159,13 +1190,12 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { | ... | @@ -1159,13 +1190,12 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { |
| 1159 | ); | 1190 | ); |
| 1160 | if (relativePath) { | 1191 | if (relativePath) { |
| 1161 | void Haptics.selectionAsync(); | 1192 | void Haptics.selectionAsync(); |
| 1162 | router.push( | 1193 | navigation.navigate("ThreadFile", { |
| 1163 | buildThreadFilesNavigation( | 1194 | environmentId: String(props.environmentId), |
| 1164 | { environmentId: props.environmentId, threadId: props.threadId }, | 1195 | threadId: String(props.threadId), |
| 1165 | relativePath, | 1196 | path: relativePath.split("/").filter((segment) => segment.length > 0), |
| 1166 | presentation.line, | 1197 | ...(presentation.line ? { line: String(presentation.line) } : {}), |
| 1167 | ), | 1198 | }); |
| 1168 | ); | ||
| 1169 | } | 1199 | } |
| 1170 | return; | 1200 | return; |
| 1171 | } | 1201 | } |
| ... | @@ -1174,7 +1204,7 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { | ... | @@ -1174,7 +1204,7 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { |
| 1174 | void Linking.openURL(presentation.href); | 1204 | void Linking.openURL(presentation.href); |
| 1175 | } | 1205 | } |
| 1176 | }, | 1206 | }, |
| 1177 | [props.environmentId, props.threadId, props.workspaceRoot, router], | 1207 | [props.environmentId, props.threadId, props.workspaceRoot, navigation], |
| 1178 | ); | 1208 | ); |
| 1179 | const markdownStyles = useMarkdownStyles(onMarkdownLinkPress); | 1209 | const markdownStyles = useMarkdownStyles(onMarkdownLinkPress); |
| 1180 | const reviewCommentColors = useReviewCommentColors(); | 1210 | const reviewCommentColors = useReviewCommentColors(); |
| ... | @@ -1188,6 +1218,7 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { | ... | @@ -1188,6 +1218,7 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { |
| 1188 | markdownStyles, | 1218 | markdownStyles, |
| 1189 | reviewCommentColors, | 1219 | reviewCommentColors, |
| 1190 | userBubbleColor, | 1220 | userBubbleColor, |
| 1221 | viewportWidth, | ||
| 1191 | }), | 1222 | }), |
| 1192 | [ | 1223 | [ |
| 1193 | copiedRowId, | 1224 | copiedRowId, |
| ... | @@ -1196,8 +1227,34 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { | ... | @@ -1196,8 +1227,34 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { |
| 1196 | markdownStyles, | 1227 | markdownStyles, |
| 1197 | reviewCommentColors, | 1228 | reviewCommentColors, |
| 1198 | userBubbleColor, | 1229 | userBubbleColor, |
| 1230 | viewportWidth, | ||
| 1199 | ], | 1231 | ], |
| 1200 | ); | 1232 | ); |
| 1233 | const reportHeaderMaterialVisibility = useCallback( | ||
| 1234 | (visible: boolean) => { | ||
| 1235 | if (headerMaterialVisibleRef.current === visible) { | ||
| 1236 | return; | ||
| 1237 | } | ||
| 1238 | headerMaterialVisibleRef.current = visible; | ||
| 1239 | props.onHeaderMaterialVisibilityChange?.(visible); | ||
| 1240 | }, | ||
| 1241 | [props.onHeaderMaterialVisibilityChange], | ||
| 1242 | ); | ||
| 1243 | const handleScroll = useCallback( | ||
| 1244 | (event: NativeSyntheticEvent<NativeScrollEvent>) => { | ||
| 1245 | reportHeaderMaterialVisibility(event.nativeEvent.contentOffset.y + topContentInset > 6); | ||
| 1246 | }, | ||
| 1247 | [reportHeaderMaterialVisibility, topContentInset], | ||
| 1248 | ); | ||
| 1249 | const handleViewportLayout = useCallback((event: LayoutChangeEvent) => { | ||
| 1250 | const nextWidth = Math.round(event.nativeEvent.layout.width); | ||
| 1251 | setViewportWidth((current) => (Math.abs(current - nextWidth) > 1 ? nextWidth : current)); | ||
| 1252 | }, []); | ||
| 1253 | |||
| 1254 | useEffect(() => { | ||
| 1255 | reportHeaderMaterialVisibility(false); | ||
| 1256 | }, [props.threadId, reportHeaderMaterialVisibility]); | ||
| 1257 | |||
| 1201 | const expandedWorkGroupIds = useMemo(() => { | 1258 | const expandedWorkGroupIds = useMemo(() => { |
| 1202 | const ids = new Set<string>(); | 1259 | const ids = new Set<string>(); |
| 1203 | for (const [groupId, expanded] of Object.entries(expandedWorkGroups)) { | 1260 | for (const [groupId, expanded] of Object.entries(expandedWorkGroups)) { |
| ... | @@ -1217,6 +1274,7 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { | ... | @@ -1217,6 +1274,7 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { |
| 1217 | ), | 1274 | ), |
| 1218 | [expandedTurnIds, expandedWorkGroupIds, props.feed, props.latestTurn], | 1275 | [expandedTurnIds, expandedWorkGroupIds, props.feed, props.latestTurn], |
| 1219 | ); | 1276 | ); |
| 1277 | |||
| 1220 | const anchoredEndSpace = useMemo( | 1278 | const anchoredEndSpace = useMemo( |
| 1221 | () => | 1279 | () => |
| 1222 | resolveChatListAnchoredEndSpace( | 1280 | resolveChatListAnchoredEndSpace( |
| ... | @@ -1424,19 +1482,6 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { | ... | @@ -1424,19 +1482,6 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { |
| 1424 | ], | 1482 | ], |
| 1425 | ); | 1483 | ); |
| 1426 | 1484 | ||
| 1427 | if (props.contentPresentation.kind === "loading") { | ||
| 1428 | return ( | ||
| 1429 | <ThreadFeedPlaceholder | ||
| 1430 | title="Loading conversation" | ||
| 1431 | detail="Fetching the latest messages from this environment." | ||
| 1432 | loading | ||
| 1433 | topInset={topContentInset} | ||
| 1434 | bottomInset={bottomContentInset} | ||
| 1435 | horizontalPadding={horizontalPadding} | ||
| 1436 | /> | ||
| 1437 | ); | ||
| 1438 | } | ||
| 1439 | |||
| 1440 | if (props.contentPresentation.kind === "unavailable") { | 1485 | if (props.contentPresentation.kind === "unavailable") { |
| 1441 | return ( | 1486 | return ( |
| 1442 | <ThreadFeedPlaceholder | 1487 | <ThreadFeedPlaceholder |
| ... | @@ -1451,48 +1496,72 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { | ... | @@ -1451,48 +1496,72 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { |
| 1451 | 1496 | ||
| 1452 | return ( | 1497 | return ( |
| 1453 | <> | 1498 | <> |
| 1454 | <View style={{ flex: 1 }}> | 1499 | <View style={{ flex: 1 }} onLayout={handleViewportLayout}> |
| 1455 | <KeyboardAwareLegendList | 1500 | <View style={{ flex: 1 }}> |
| 1456 | ref={props.listRef} | 1501 | <KeyboardAwareLegendList |
| 1457 | key={props.threadId} | 1502 | ref={props.listRef} |
| 1458 | style={{ flex: 1 }} | 1503 | // The empty↔filled key remounts the list when messages first |
| 1459 | automaticallyAdjustsScrollIndicatorInsets={false} | 1504 | // arrive. LegendList's maintainScrollAtEnd calls scrollToEnd(), |
| 1460 | scrollIndicatorInsets={{ top: topContentInset, bottom: 0 }} | 1505 | // which is blind to UIKit's adjustedContentInset — inserting into |
| 1461 | {...(anchoredEndSpace ? { anchoredEndSpace } : {})} | 1506 | // an already-attached list under a transparent header can pin |
| 1462 | contentInsetEndAdjustment={props.contentInsetEndAdjustment} | 1507 | // short content at offset 0 (one header-height too high). A fresh |
| 1463 | freeze={props.freeze} | 1508 | // mount positions during attach, where UIKit applies the inset. |
| 1464 | maintainScrollAtEnd={ | 1509 | key={`${props.threadId}:${props.feed.length === 0 ? "empty" : "filled"}`} |
| 1465 | disclosureToggleSettling | 1510 | style={{ flex: 1 }} |
| 1466 | ? false | 1511 | contentInsetAdjustmentBehavior={usesNativeAutomaticInsets ? "automatic" : "never"} |
| 1467 | : { | 1512 | automaticallyAdjustsScrollIndicatorInsets={usesNativeAutomaticInsets} |
| 1468 | animated: false, | 1513 | {...(usesNativeAutomaticInsets |
| 1469 | on: { | 1514 | ? { |
| 1470 | dataChange: true, | 1515 | // Do NOT pass a manual `contentInset` here. Like the Home |
| 1471 | itemLayout: true, | 1516 | // ScrollView, we rely purely on `contentInsetAdjustmentBehavior: |
| 1472 | layout: true, | 1517 | // "automatic"` so UIKit derives the top inset from the transparent |
| 1473 | }, | 1518 | // header. A manual contentInset (which LegendList consumes into its |
| 1519 | // own layout math) collapses the scroll view's adjustedContentInset | ||
| 1520 | // top to 0, leaving the iOS 26/27 scroll-edge effect no region to | ||
| 1521 | // render into — which is why the header blur was missing on threads. | ||
| 1522 | scrollIndicatorInsets: { top: 0, left: 0, right: 0, bottom: 0 }, | ||
| 1474 | } | 1523 | } |
| 1475 | } | 1524 | : { scrollIndicatorInsets: { top: topContentInset, bottom: 0 } })} |
| 1476 | maintainVisibleContentPosition={maintainVisibleContentPosition} | 1525 | {...(anchoredEndSpace ? { anchoredEndSpace } : {})} |
| 1477 | data={presentedFeed} | 1526 | contentInsetEndAdjustment={props.contentInsetEndAdjustment} |
| 1478 | extraData={listAppearanceData} | 1527 | freeze={props.freeze} |
| 1479 | renderItem={renderItem} | 1528 | maintainScrollAtEnd={ |
| 1480 | keyExtractor={(entry) => entry.id} | 1529 | disclosureToggleSettling |
| 1481 | getItemType={(entry) => | 1530 | ? false |
| 1482 | entry.type === "message" ? `message:${entry.message.role}` : entry.type | 1531 | : { |
| 1483 | } | 1532 | animated: false, |
| 1484 | keyboardShouldPersistTaps="always" | 1533 | on: { |
| 1485 | keyboardDismissMode="none" | 1534 | dataChange: true, |
| 1486 | keyboardLiftBehavior="whenAtEnd" | 1535 | itemLayout: true, |
| 1487 | estimatedItemSize={180} | 1536 | layout: true, |
| 1488 | initialScrollAtEnd | 1537 | }, |
| 1489 | ListHeaderComponent={<View style={{ height: topContentInset }} />} | 1538 | } |
| 1490 | contentContainerStyle={{ | 1539 | } |
| 1491 | paddingTop: 12, | 1540 | maintainVisibleContentPosition={maintainVisibleContentPosition} |
| 1492 | paddingHorizontal: horizontalPadding, | 1541 | data={presentedFeed} |
| 1493 | }} | 1542 | extraData={listAppearanceData} |
| 1494 | /> | 1543 | renderItem={renderItem} |
| 1495 | {props.feed.length === 0 ? ( | 1544 | keyExtractor={(entry) => entry.id} |
| 1545 | getItemType={(entry) => | ||
| 1546 | entry.type === "message" ? `message:${entry.message.role}` : entry.type | ||
| 1547 | } | ||
| 1548 | keyboardShouldPersistTaps="always" | ||
| 1549 | keyboardDismissMode="none" | ||
| 1550 | keyboardLiftBehavior="whenAtEnd" | ||
| 1551 | estimatedItemSize={180} | ||
| 1552 | initialScrollAtEnd | ||
| 1553 | onScroll={handleScroll} | ||
| 1554 | scrollEventThrottle={16} | ||
| 1555 | ListHeaderComponent={ | ||
| 1556 | usesNativeAutomaticInsets ? null : <View style={{ height: topContentInset }} /> | ||
| 1557 | } | ||
| 1558 | contentContainerStyle={{ | ||
| 1559 | paddingTop: 12, | ||
| 1560 | paddingHorizontal: contentHorizontalPadding, | ||
| 1561 | }} | ||
| 1562 | /> | ||
| 1563 | </View> | ||
| 1564 | {props.feed.length === 0 && props.contentPresentation.kind === "ready" ? ( | ||
| 1496 | <View pointerEvents="none" style={StyleSheet.absoluteFill}> | 1565 | <View pointerEvents="none" style={StyleSheet.absoluteFill}> |
| 1497 | <ThreadFeedPlaceholder | 1566 | <ThreadFeedPlaceholder |
| 1498 | title="No conversation yet" | 1567 | title="No conversation yet" |
apps/mobile/src/features/threads/ThreadGitControls.tsx+382-122| ... | @@ -1,20 +1,19 @@ | ... | @@ -1,20 +1,19 @@ |
| 1 | import type { | 1 | import { |
| 2 | EnvironmentId, | 2 | EnvironmentId, |
| 3 | GitRunStackedActionResult, | 3 | type GitRunStackedActionResult, |
| 4 | ProjectScript, | 4 | type ProjectScript, |
| 5 | ThreadId, | 5 | ThreadId, |
| 6 | VcsStatusResult, | 6 | type VcsStatusResult, |
| 7 | } from "@t3tools/contracts"; | 7 | } from "@t3tools/contracts"; |
| 8 | import { | 8 | import { |
| 9 | type GitActionRequestInput, | 9 | type GitActionRequestInput, |
| 10 | requiresDefaultBranchConfirmation, | 10 | requiresDefaultBranchConfirmation, |
| 11 | resolveQuickAction, | 11 | resolveQuickAction, |
| 12 | } from "@t3tools/client-runtime/state/vcs"; | 12 | } from "@t3tools/client-runtime/state/vcs"; |
| 13 | import { useLocalSearchParams, useRouter } from "expo-router"; | 13 | import { useNavigation } from "@react-navigation/native"; |
| 14 | import Stack from "expo-router/stack"; | 14 | import { NativeHeaderToolbar } from "../../native/StackHeader"; |
| 15 | import { useCallback, useMemo } from "react"; | 15 | import { useCallback, useMemo } from "react"; |
| 16 | import { Alert } from "react-native"; | 16 | import { Alert } from "react-native"; |
| 17 | import { buildThreadFilesNavigation, buildThreadReviewRoutePath } from "../../lib/routes"; | ||
| 18 | import { tryOpenExternalUrl } from "../../lib/openExternalUrl"; | 17 | import { tryOpenExternalUrl } from "../../lib/openExternalUrl"; |
| 19 | import { | 18 | import { |
| 20 | basename, | 19 | basename, |
| ... | @@ -65,25 +64,52 @@ function compactMenuStatus(gitStatus: VcsStatusResult | null): string { | ... | @@ -65,25 +64,52 @@ function compactMenuStatus(gitStatus: VcsStatusResult | null): string { |
| 65 | return parts.join(" · "); | 64 | return parts.join(" · "); |
| 66 | } | 65 | } |
| 67 | 66 | ||
| 68 | export function ThreadGitControls(props: { | 67 | type HeaderItem = Record<string, unknown>; |
| 68 | type HeaderItems = HeaderItem[]; | ||
| 69 | type ThreadGitHeaderActionItems = { | ||
| 70 | readonly terminal: HeaderItem; | ||
| 71 | readonly files: HeaderItem; | ||
| 72 | readonly git: HeaderItem; | ||
| 73 | }; | ||
| 74 | type QuickActionIcon = | ||
| 75 | | "arrow.down.circle" | ||
| 76 | | "arrow.up.right.circle" | ||
| 77 | | "checkmark.circle" | ||
| 78 | | "arrow.up.circle"; | ||
| 79 | |||
| 80 | /** The subset of git-control wiring the standalone git menu needs. */ | ||
| 81 | export type ThreadGitMenuProps = { | ||
| 82 | readonly environmentId: EnvironmentId | string; | ||
| 83 | readonly threadId: ThreadId | string; | ||
| 69 | readonly currentBranch: string | null; | 84 | readonly currentBranch: string | null; |
| 70 | readonly gitStatus: VcsStatusResult | null; | 85 | readonly gitStatus: VcsStatusResult | null; |
| 71 | readonly gitOperationLabel: string | null; | 86 | readonly gitOperationLabel: string | null; |
| 87 | readonly onOpenFilesInspector?: () => void; | ||
| 88 | readonly onOpenGitInspector?: () => void; | ||
| 89 | readonly onPull: () => Promise<void>; | ||
| 90 | readonly onRunAction: (input: GitActionRequestInput) => Promise<GitRunStackedActionResult | null>; | ||
| 91 | }; | ||
| 92 | |||
| 93 | type ThreadGitControlsProps = ThreadGitMenuProps & { | ||
| 94 | readonly auxiliaryPaneControl?: { | ||
| 95 | readonly accessibilityLabel: string; | ||
| 96 | readonly onPress: () => void; | ||
| 97 | }; | ||
| 72 | readonly canOpenTerminal: boolean; | 98 | readonly canOpenTerminal: boolean; |
| 73 | readonly canOpenFiles: boolean; | 99 | readonly canOpenFiles: boolean; |
| 74 | readonly projectScripts: ReadonlyArray<ProjectScript>; | 100 | readonly projectScripts: ReadonlyArray<ProjectScript>; |
| 75 | readonly terminalSessions: ReadonlyArray<TerminalMenuSession>; | 101 | readonly terminalSessions: ReadonlyArray<TerminalMenuSession>; |
| 102 | readonly showActionControls?: boolean; | ||
| 103 | readonly showDirectFileControl?: boolean; | ||
| 76 | readonly onOpenTerminal: (terminalId?: string | null) => void; | 104 | readonly onOpenTerminal: (terminalId?: string | null) => void; |
| 77 | readonly onOpenNewTerminal: () => void; | 105 | readonly onOpenNewTerminal: () => void; |
| 78 | readonly onRunProjectScript: (script: ProjectScript) => Promise<void>; | 106 | readonly onRunProjectScript: (script: ProjectScript) => Promise<void>; |
| 79 | readonly onPull: () => Promise<void>; | 107 | }; |
| 80 | readonly onRunAction: (input: GitActionRequestInput) => Promise<GitRunStackedActionResult | null>; | 108 | |
| 81 | }) { | 109 | function useThreadGitControlModel(props: ThreadGitMenuProps) { |
| 82 | const router = useRouter(); | 110 | const navigation = useNavigation(); |
| 83 | const { environmentId, threadId } = useLocalSearchParams<{ | 111 | const environmentId = props.environmentId; |
| 84 | environmentId: EnvironmentId; | 112 | const threadId = props.threadId; |
| 85 | threadId: ThreadId; | ||
| 86 | }>(); | ||
| 87 | const { gitStatus, gitOperationLabel, onPull, onRunAction } = props; | 113 | const { gitStatus, gitOperationLabel, onPull, onRunAction } = props; |
| 88 | 114 | ||
| 89 | const currentBranchLabel = gitStatus?.refName ?? props.currentBranch ?? "Detached HEAD"; | 115 | const currentBranchLabel = gitStatus?.refName ?? props.currentBranch ?? "Detached HEAD"; |
| ... | @@ -109,7 +135,7 @@ export function ThreadGitControls(props: { | ... | @@ -109,7 +135,7 @@ export function ThreadGitControls(props: { |
| 109 | ? (quickAction.hint ?? "This action is unavailable.") | 135 | ? (quickAction.hint ?? "This action is unavailable.") |
| 110 | : null; | 136 | : null; |
| 111 | 137 | ||
| 112 | const quickActionIcon = (() => { | 138 | const quickActionIcon: QuickActionIcon = (() => { |
| 113 | if (quickAction.kind === "run_pull") return "arrow.down.circle"; | 139 | if (quickAction.kind === "run_pull") return "arrow.down.circle"; |
| 114 | if (quickAction.kind === "open_pr") return "arrow.up.right.circle"; | 140 | if (quickAction.kind === "open_pr") return "arrow.up.right.circle"; |
| 115 | if (quickAction.kind === "run_action") { | 141 | if (quickAction.kind === "run_action") { |
| ... | @@ -147,24 +173,21 @@ export function ThreadGitControls(props: { | ... | @@ -147,24 +173,21 @@ export function ThreadGitControls(props: { |
| 147 | !input.featureBranch && | 173 | !input.featureBranch && |
| 148 | requiresDefaultBranchConfirmation(input.action, isDefaultRef) | 174 | requiresDefaultBranchConfirmation(input.action, isDefaultRef) |
| 149 | ) { | 175 | ) { |
| 150 | router.push({ | 176 | navigation.navigate("GitConfirm", { |
| 151 | pathname: "/threads/[environmentId]/[threadId]/git-confirm", | 177 | environmentId: String(environmentId), |
| 152 | params: { | 178 | threadId: String(threadId), |
| 153 | environmentId, | 179 | confirmAction: confirmableAction, |
| 154 | threadId, | 180 | branchName, |
| 155 | confirmAction: confirmableAction, | 181 | includesCommit: String( |
| 156 | branchName, | 182 | input.action === "commit_push" || input.action === "commit_push_pr", |
| 157 | includesCommit: String( | 183 | ), |
| 158 | input.action === "commit_push" || input.action === "commit_push_pr", | ||
| 159 | ), | ||
| 160 | }, | ||
| 161 | }); | 184 | }); |
| 162 | return; | 185 | return; |
| 163 | } | 186 | } |
| 164 | 187 | ||
| 165 | await onRunAction(input); | 188 | await onRunAction(input); |
| 166 | }, | 189 | }, |
| 167 | [environmentId, gitStatus, isDefaultRef, onRunAction, router, threadId], | 190 | [environmentId, gitStatus, isDefaultRef, onRunAction, navigation, threadId], |
| 168 | ); | 191 | ); |
| 169 | 192 | ||
| 170 | const runQuickAction = useCallback(async () => { | 193 | const runQuickAction = useCallback(async () => { |
| ... | @@ -181,102 +204,339 @@ export function ThreadGitControls(props: { | ... | @@ -181,102 +204,339 @@ export function ThreadGitControls(props: { |
| 181 | } | 204 | } |
| 182 | }, [onPull, openExistingPr, quickAction, runActionWithPrompt]); | 205 | }, [onPull, openExistingPr, quickAction, runActionWithPrompt]); |
| 183 | 206 | ||
| 207 | const openFiles = useCallback(() => { | ||
| 208 | if (props.onOpenFilesInspector) { | ||
| 209 | props.onOpenFilesInspector(); | ||
| 210 | return; | ||
| 211 | } | ||
| 212 | navigation.navigate("ThreadFiles", { | ||
| 213 | environmentId: String(environmentId), | ||
| 214 | threadId: String(threadId), | ||
| 215 | }); | ||
| 216 | }, [environmentId, props.onOpenFilesInspector, navigation, threadId]); | ||
| 217 | |||
| 218 | const openReview = useCallback(() => { | ||
| 219 | navigation.navigate("ThreadReview", { | ||
| 220 | environmentId: EnvironmentId.make(String(environmentId)), | ||
| 221 | threadId: ThreadId.make(String(threadId)), | ||
| 222 | }); | ||
| 223 | }, [environmentId, navigation, threadId]); | ||
| 224 | |||
| 225 | const openGitInspector = useCallback(() => { | ||
| 226 | if (props.onOpenGitInspector) { | ||
| 227 | props.onOpenGitInspector(); | ||
| 228 | return; | ||
| 229 | } | ||
| 230 | navigation.navigate("GitOverview", { | ||
| 231 | environmentId: String(environmentId), | ||
| 232 | threadId: String(threadId), | ||
| 233 | }); | ||
| 234 | }, [environmentId, props.onOpenGitInspector, navigation, threadId]); | ||
| 235 | |||
| 236 | return { | ||
| 237 | currentBranchLabel, | ||
| 238 | isRepo, | ||
| 239 | openFiles, | ||
| 240 | openGitInspector, | ||
| 241 | openReview, | ||
| 242 | quickAction, | ||
| 243 | quickActionHint, | ||
| 244 | quickActionIcon, | ||
| 245 | runQuickAction, | ||
| 246 | }; | ||
| 247 | } | ||
| 248 | |||
| 249 | function useThreadGitHeaderActionItems(props: ThreadGitControlsProps): ThreadGitHeaderActionItems { | ||
| 250 | const model = useThreadGitControlModel(props); | ||
| 251 | |||
| 252 | return useMemo( | ||
| 253 | () => ({ | ||
| 254 | terminal: { | ||
| 255 | accessibilityLabel: "Open terminal", | ||
| 256 | disabled: !props.canOpenTerminal, | ||
| 257 | icon: { name: "terminal", type: "sfSymbol" }, | ||
| 258 | identifier: "thread-right-terminal", | ||
| 259 | label: "Terminal", | ||
| 260 | menu: { | ||
| 261 | items: [ | ||
| 262 | ...props.projectScripts.map((script) => ({ | ||
| 263 | description: script.command, | ||
| 264 | icon: { name: projectScriptMenuIcon(script.icon), type: "sfSymbol" as const }, | ||
| 265 | label: projectScriptMenuLabel(script), | ||
| 266 | onPress: () => void props.onRunProjectScript(script), | ||
| 267 | type: "action" as const, | ||
| 268 | })), | ||
| 269 | ...(props.projectScripts.length === 0 | ||
| 270 | ? [ | ||
| 271 | { | ||
| 272 | description: "This project has no saved scripts yet", | ||
| 273 | disabled: true, | ||
| 274 | icon: { name: "play", type: "sfSymbol" as const }, | ||
| 275 | label: "No project scripts", | ||
| 276 | onPress: () => {}, | ||
| 277 | type: "action" as const, | ||
| 278 | }, | ||
| 279 | ] | ||
| 280 | : []), | ||
| 281 | ...props.terminalSessions.map((session) => ({ | ||
| 282 | description: [ | ||
| 283 | getTerminalStatusLabel({ | ||
| 284 | status: session.status, | ||
| 285 | hasRunningSubprocess: session.hasRunningSubprocess, | ||
| 286 | }), | ||
| 287 | basename(session.cwd), | ||
| 288 | ] | ||
| 289 | .filter(Boolean) | ||
| 290 | .join(" · "), | ||
| 291 | icon: { name: "terminal", type: "sfSymbol" as const }, | ||
| 292 | label: session.displayLabel, | ||
| 293 | onPress: () => props.onOpenTerminal(session.terminalId), | ||
| 294 | type: "action" as const, | ||
| 295 | })), | ||
| 296 | { | ||
| 297 | description: "Start another shell for this thread", | ||
| 298 | icon: { name: "plus", type: "sfSymbol" }, | ||
| 299 | label: "Open new terminal", | ||
| 300 | onPress: props.onOpenNewTerminal, | ||
| 301 | type: "action", | ||
| 302 | }, | ||
| 303 | ], | ||
| 304 | title: "Terminal", | ||
| 305 | }, | ||
| 306 | sharesBackground: true, | ||
| 307 | type: "menu", | ||
| 308 | variant: "plain", | ||
| 309 | }, | ||
| 310 | files: { | ||
| 311 | accessibilityLabel: "Open files", | ||
| 312 | disabled: !props.canOpenFiles, | ||
| 313 | icon: { name: "folder", type: "sfSymbol" }, | ||
| 314 | identifier: "thread-right-files", | ||
| 315 | label: "Files", | ||
| 316 | onPress: model.openFiles, | ||
| 317 | sharesBackground: true, | ||
| 318 | type: "button", | ||
| 319 | variant: "plain", | ||
| 320 | }, | ||
| 321 | git: { | ||
| 322 | accessibilityLabel: "Git actions", | ||
| 323 | icon: { name: "point.topleft.down.curvedto.point.bottomright.up", type: "sfSymbol" }, | ||
| 324 | identifier: "thread-right-git", | ||
| 325 | label: "Git", | ||
| 326 | menu: { | ||
| 327 | items: [ | ||
| 328 | { | ||
| 329 | description: compactMenuStatus(props.gitStatus), | ||
| 330 | disabled: true, | ||
| 331 | icon: { | ||
| 332 | name: "point.topleft.down.curvedto.point.bottomright.up", | ||
| 333 | type: "sfSymbol", | ||
| 334 | }, | ||
| 335 | label: compactMenuBranchLabel(model.currentBranchLabel), | ||
| 336 | onPress: (): void => {}, | ||
| 337 | type: "action", | ||
| 338 | }, | ||
| 339 | { | ||
| 340 | description: model.quickActionHint ?? undefined, | ||
| 341 | disabled: model.quickAction.disabled, | ||
| 342 | icon: { name: model.quickActionIcon, type: "sfSymbol" }, | ||
| 343 | label: model.quickAction.label, | ||
| 344 | onPress: (): void => void model.runQuickAction(), | ||
| 345 | type: "action", | ||
| 346 | }, | ||
| 347 | { | ||
| 348 | description: "Turn diffs and worktree changes", | ||
| 349 | disabled: !model.isRepo, | ||
| 350 | icon: { name: "text.bubble", type: "sfSymbol" }, | ||
| 351 | label: "Review changes", | ||
| 352 | onPress: model.openReview, | ||
| 353 | type: "action", | ||
| 354 | }, | ||
| 355 | { | ||
| 356 | description: "Commit, files, branches", | ||
| 357 | icon: { name: "ellipsis", type: "sfSymbol" }, | ||
| 358 | label: "More", | ||
| 359 | onPress: model.openGitInspector, | ||
| 360 | type: "action", | ||
| 361 | }, | ||
| 362 | ], | ||
| 363 | title: "Git", | ||
| 364 | }, | ||
| 365 | sharesBackground: true, | ||
| 366 | type: "menu", | ||
| 367 | variant: "plain", | ||
| 368 | }, | ||
| 369 | }), | ||
| 370 | [ | ||
| 371 | model.currentBranchLabel, | ||
| 372 | model.isRepo, | ||
| 373 | model.openFiles, | ||
| 374 | model.openGitInspector, | ||
| 375 | model.openReview, | ||
| 376 | model.quickAction.disabled, | ||
| 377 | model.quickAction.label, | ||
| 378 | model.quickActionHint, | ||
| 379 | model.quickActionIcon, | ||
| 380 | model.runQuickAction, | ||
| 381 | props.canOpenFiles, | ||
| 382 | props.canOpenTerminal, | ||
| 383 | props.gitStatus, | ||
| 384 | props.onOpenNewTerminal, | ||
| 385 | props.onOpenTerminal, | ||
| 386 | props.onRunProjectScript, | ||
| 387 | props.projectScripts, | ||
| 388 | props.terminalSessions, | ||
| 389 | ], | ||
| 390 | ); | ||
| 391 | } | ||
| 392 | |||
| 393 | export function useThreadGitRightHeaderItems(props: ThreadGitControlsProps): HeaderItems { | ||
| 394 | const actionItems = useThreadGitHeaderActionItems(props); | ||
| 395 | return useMemo( | ||
| 396 | () => [actionItems.git, actionItems.files, actionItems.terminal] as HeaderItems, | ||
| 397 | [actionItems], | ||
| 398 | ); | ||
| 399 | } | ||
| 400 | |||
| 401 | export function useThreadGitCenterHeaderItems(props: ThreadGitControlsProps): HeaderItems { | ||
| 402 | const actionItems = useThreadGitHeaderActionItems(props); | ||
| 403 | return useMemo( | ||
| 404 | () => [actionItems.files, actionItems.git, actionItems.terminal] as HeaderItems, | ||
| 405 | [actionItems], | ||
| 406 | ); | ||
| 407 | } | ||
| 408 | |||
| 409 | export function ThreadGitControls(props: ThreadGitControlsProps) { | ||
| 410 | const model = useThreadGitControlModel(props); | ||
| 411 | const showActionControls = props.showActionControls ?? true; | ||
| 412 | |||
| 413 | if (!showActionControls) { | ||
| 414 | return null; | ||
| 415 | } | ||
| 416 | |||
| 184 | return ( | 417 | return ( |
| 185 | <Stack.Toolbar placement="right"> | 418 | <NativeHeaderToolbar placement="right"> |
| 186 | <Stack.Toolbar.Menu icon="terminal" disabled={!props.canOpenTerminal} separateBackground> | 419 | {showActionControls && props.auxiliaryPaneControl ? ( |
| 187 | {props.projectScripts.length > 0 ? ( | 420 | <NativeHeaderToolbar.Button |
| 188 | props.projectScripts.map((script) => ( | 421 | accessibilityLabel={props.auxiliaryPaneControl.accessibilityLabel} |
| 189 | <Stack.Toolbar.MenuAction | 422 | icon="sidebar.right" |
| 190 | key={script.id} | 423 | onPress={props.auxiliaryPaneControl.onPress} |
| 191 | icon={projectScriptMenuIcon(script.icon)} | 424 | separateBackground |
| 192 | onPress={() => void props.onRunProjectScript(script)} | 425 | /> |
| 193 | subtitle={script.command} | 426 | ) : null} |
| 427 | {showActionControls ? ( | ||
| 428 | <NativeHeaderToolbar.Menu | ||
| 429 | icon="terminal" | ||
| 430 | disabled={!props.canOpenTerminal} | ||
| 431 | separateBackground | ||
| 432 | > | ||
| 433 | {props.projectScripts.length > 0 ? ( | ||
| 434 | props.projectScripts.map((script) => ( | ||
| 435 | <NativeHeaderToolbar.MenuAction | ||
| 436 | key={script.id} | ||
| 437 | icon={projectScriptMenuIcon(script.icon)} | ||
| 438 | onPress={() => void props.onRunProjectScript(script)} | ||
| 439 | subtitle={script.command} | ||
| 440 | > | ||
| 441 | <NativeHeaderToolbar.Label> | ||
| 442 | {projectScriptMenuLabel(script)} | ||
| 443 | </NativeHeaderToolbar.Label> | ||
| 444 | </NativeHeaderToolbar.MenuAction> | ||
| 445 | )) | ||
| 446 | ) : ( | ||
| 447 | <NativeHeaderToolbar.MenuAction | ||
| 448 | icon="play" | ||
| 449 | disabled | ||
| 450 | onPress={() => {}} | ||
| 451 | subtitle="This project has no saved scripts yet" | ||
| 194 | > | 452 | > |
| 195 | <Stack.Toolbar.Label>{projectScriptMenuLabel(script)}</Stack.Toolbar.Label> | 453 | <NativeHeaderToolbar.Label>No project scripts</NativeHeaderToolbar.Label> |
| 196 | </Stack.Toolbar.MenuAction> | 454 | </NativeHeaderToolbar.MenuAction> |
| 197 | )) | 455 | )} |
| 198 | ) : ( | 456 | {props.terminalSessions.map((session) => ( |
| 199 | <Stack.Toolbar.MenuAction | 457 | <NativeHeaderToolbar.MenuAction |
| 200 | icon="play" | 458 | key={session.terminalId} |
| 201 | disabled | 459 | icon="terminal" |
| 202 | onPress={() => {}} | 460 | onPress={() => props.onOpenTerminal(session.terminalId)} |
| 203 | subtitle="This project has no saved scripts yet" | 461 | subtitle={[ |
| 204 | > | 462 | getTerminalStatusLabel({ |
| 205 | <Stack.Toolbar.Label>No project scripts</Stack.Toolbar.Label> | 463 | status: session.status, |
| 206 | </Stack.Toolbar.MenuAction> | 464 | hasRunningSubprocess: session.hasRunningSubprocess, |
| 207 | )} | 465 | }), |
| 208 | {props.terminalSessions.map((session) => ( | 466 | basename(session.cwd), |
| 209 | <Stack.Toolbar.MenuAction | 467 | ] |
| 210 | key={session.terminalId} | 468 | .filter(Boolean) |
| 211 | icon="terminal" | 469 | .join(" · ")} |
| 212 | onPress={() => props.onOpenTerminal(session.terminalId)} | 470 | > |
| 213 | subtitle={[ | 471 | <NativeHeaderToolbar.Label>{session.displayLabel}</NativeHeaderToolbar.Label> |
| 214 | getTerminalStatusLabel({ | 472 | </NativeHeaderToolbar.MenuAction> |
| 215 | status: session.status, | 473 | ))} |
| 216 | hasRunningSubprocess: session.hasRunningSubprocess, | 474 | <NativeHeaderToolbar.MenuAction |
| 217 | }), | 475 | icon="plus" |
| 218 | basename(session.cwd), | 476 | onPress={props.onOpenNewTerminal} |
| 219 | ] | 477 | subtitle="Start another shell for this thread" |
| 220 | .filter(Boolean) | ||
| 221 | .join(" · ")} | ||
| 222 | > | 478 | > |
| 223 | <Stack.Toolbar.Label>{session.displayLabel}</Stack.Toolbar.Label> | 479 | <NativeHeaderToolbar.Label>Open new terminal</NativeHeaderToolbar.Label> |
| 224 | </Stack.Toolbar.MenuAction> | 480 | </NativeHeaderToolbar.MenuAction> |
| 225 | ))} | 481 | </NativeHeaderToolbar.Menu> |
| 226 | <Stack.Toolbar.MenuAction | 482 | ) : null} |
| 227 | icon="plus" | 483 | {showActionControls && props.showDirectFileControl ? ( |
| 228 | onPress={props.onOpenNewTerminal} | 484 | <NativeHeaderToolbar.Button |
| 229 | subtitle="Start another shell for this thread" | 485 | accessibilityLabel="Open files" |
| 230 | > | ||
| 231 | <Stack.Toolbar.Label>Open new terminal</Stack.Toolbar.Label> | ||
| 232 | </Stack.Toolbar.MenuAction> | ||
| 233 | </Stack.Toolbar.Menu> | ||
| 234 | <Stack.Toolbar.Menu icon="point.topleft.down.curvedto.point.bottomright.up"> | ||
| 235 | <Stack.Toolbar.MenuAction | ||
| 236 | icon="point.topleft.down.curvedto.point.bottomright.up" | ||
| 237 | disabled | ||
| 238 | onPress={() => {}} | ||
| 239 | subtitle={compactMenuStatus(gitStatus)} | ||
| 240 | > | ||
| 241 | <Stack.Toolbar.Label>{compactMenuBranchLabel(currentBranchLabel)}</Stack.Toolbar.Label> | ||
| 242 | </Stack.Toolbar.MenuAction> | ||
| 243 | <Stack.Toolbar.MenuAction | ||
| 244 | icon={quickActionIcon} | ||
| 245 | disabled={quickAction.disabled} | ||
| 246 | onPress={() => void runQuickAction()} | ||
| 247 | subtitle={quickActionHint ?? undefined} | ||
| 248 | > | ||
| 249 | <Stack.Toolbar.Label>{quickAction.label}</Stack.Toolbar.Label> | ||
| 250 | </Stack.Toolbar.MenuAction> | ||
| 251 | <Stack.Toolbar.MenuAction | ||
| 252 | icon="text.bubble" | ||
| 253 | disabled={!isRepo} | ||
| 254 | onPress={() => router.push(buildThreadReviewRoutePath({ environmentId, threadId }))} | ||
| 255 | subtitle="Turn diffs and worktree changes" | ||
| 256 | > | ||
| 257 | <Stack.Toolbar.Label>Review changes</Stack.Toolbar.Label> | ||
| 258 | </Stack.Toolbar.MenuAction> | ||
| 259 | <Stack.Toolbar.MenuAction | ||
| 260 | icon="folder" | ||
| 261 | disabled={!props.canOpenFiles} | 486 | disabled={!props.canOpenFiles} |
| 262 | onPress={() => router.push(buildThreadFilesNavigation({ environmentId, threadId }))} | 487 | icon="folder" |
| 263 | subtitle="Browse this workspace" | 488 | onPress={model.openFiles} |
| 264 | > | 489 | separateBackground |
| 265 | <Stack.Toolbar.Label>Files</Stack.Toolbar.Label> | 490 | /> |
| 266 | </Stack.Toolbar.MenuAction> | 491 | ) : null} |
| 267 | <Stack.Toolbar.MenuAction | 492 | {showActionControls ? <ThreadGitMenu {...props} /> : null} |
| 268 | icon="ellipsis.circle" | 493 | </NativeHeaderToolbar> |
| 269 | onPress={() => | 494 | ); |
| 270 | router.push({ | 495 | } |
| 271 | pathname: "/threads/[environmentId]/[threadId]/git", | 496 | |
| 272 | params: { environmentId, threadId }, | 497 | /** |
| 273 | }) | 498 | * The standalone git actions menu (branch status, quick commit/push action, |
| 274 | } | 499 | * review, more). Rendered inside a NativeHeaderToolbar by both the thread |
| 275 | subtitle="Commit, files, branches" | 500 | * chat header and the review screen's toolbar. |
| 276 | > | 501 | */ |
| 277 | <Stack.Toolbar.Label>More</Stack.Toolbar.Label> | 502 | export function ThreadGitMenu(props: ThreadGitMenuProps) { |
| 278 | </Stack.Toolbar.MenuAction> | 503 | const model = useThreadGitControlModel(props); |
| 279 | </Stack.Toolbar.Menu> | 504 | |
| 280 | </Stack.Toolbar> | 505 | return ( |
| 506 | <NativeHeaderToolbar.Menu icon="point.topleft.down.curvedto.point.bottomright.up"> | ||
| 507 | <NativeHeaderToolbar.MenuAction | ||
| 508 | icon="point.topleft.down.curvedto.point.bottomright.up" | ||
| 509 | disabled | ||
| 510 | onPress={() => {}} | ||
| 511 | subtitle={compactMenuStatus(props.gitStatus)} | ||
| 512 | > | ||
| 513 | <NativeHeaderToolbar.Label> | ||
| 514 | {compactMenuBranchLabel(model.currentBranchLabel)} | ||
| 515 | </NativeHeaderToolbar.Label> | ||
| 516 | </NativeHeaderToolbar.MenuAction> | ||
| 517 | <NativeHeaderToolbar.MenuAction | ||
| 518 | icon={model.quickActionIcon} | ||
| 519 | disabled={model.quickAction.disabled} | ||
| 520 | onPress={() => void model.runQuickAction()} | ||
| 521 | subtitle={model.quickActionHint ?? undefined} | ||
| 522 | > | ||
| 523 | <NativeHeaderToolbar.Label>{model.quickAction.label}</NativeHeaderToolbar.Label> | ||
| 524 | </NativeHeaderToolbar.MenuAction> | ||
| 525 | <NativeHeaderToolbar.MenuAction | ||
| 526 | icon="text.bubble" | ||
| 527 | disabled={!model.isRepo} | ||
| 528 | onPress={model.openReview} | ||
| 529 | subtitle="Turn diffs and worktree changes" | ||
| 530 | > | ||
| 531 | <NativeHeaderToolbar.Label>Review changes</NativeHeaderToolbar.Label> | ||
| 532 | </NativeHeaderToolbar.MenuAction> | ||
| 533 | <NativeHeaderToolbar.MenuAction | ||
| 534 | icon="ellipsis" | ||
| 535 | onPress={model.openGitInspector} | ||
| 536 | subtitle="Commit, files, branches" | ||
| 537 | > | ||
| 538 | <NativeHeaderToolbar.Label>More</NativeHeaderToolbar.Label> | ||
| 539 | </NativeHeaderToolbar.MenuAction> | ||
| 540 | </NativeHeaderToolbar.Menu> | ||
| 281 | ); | 541 | ); |
| 282 | } | 542 | } |
apps/mobile/src/features/threads/ThreadNavigationDrawer.tsx+7-34| ... | @@ -8,8 +8,6 @@ import { | ... | @@ -8,8 +8,6 @@ import { |
| 8 | useWindowDimensions, | 8 | useWindowDimensions, |
| 9 | View, | 9 | View, |
| 10 | } from "react-native"; | 10 | } from "react-native"; |
| 11 | import * as Arr from "effect/Array"; | ||
| 12 | import * as Order from "effect/Order"; | ||
| 13 | import { Gesture, GestureDetector } from "react-native-gesture-handler"; | 11 | import { Gesture, GestureDetector } from "react-native-gesture-handler"; |
| 14 | import { useSafeAreaInsets } from "react-native-safe-area-context"; | 12 | import { useSafeAreaInsets } from "react-native-safe-area-context"; |
| 15 | import Animated, { | 13 | import Animated, { |
| ... | @@ -23,22 +21,11 @@ import { useThemeColor } from "../../lib/useThemeColor"; | ... | @@ -23,22 +21,11 @@ import { useThemeColor } from "../../lib/useThemeColor"; |
| 23 | import { AppText as Text } from "../../components/AppText"; | 21 | import { AppText as Text } from "../../components/AppText"; |
| 24 | import { StatusPill } from "../../components/StatusPill"; | 22 | import { StatusPill } from "../../components/StatusPill"; |
| 25 | import { useProjects, useThreadShells } from "../../state/entities"; | 23 | import { useProjects, useThreadShells } from "../../state/entities"; |
| 26 | import { groupProjectsByRepository } from "../../lib/repositoryGroups"; | ||
| 27 | import { scopedThreadKey } from "../../lib/scopedEntities"; | 24 | import { scopedThreadKey } from "../../lib/scopedEntities"; |
| 28 | import { relativeTime } from "../../lib/time"; | 25 | import { relativeTime } from "../../lib/time"; |
| 29 | import { threadStatusTone } from "./threadPresentation"; | 26 | import { resolveThreadStatus } from "./threadPresentation"; |
| 30 | import { EnvironmentThreadShell } from "@t3tools/client-runtime/state/shell"; | 27 | import type { EnvironmentThreadShell } from "@t3tools/client-runtime/state/shell"; |
| 31 | 28 | import { buildThreadNavigationGroups } from "./thread-navigation-groups"; | |
| 32 | const threadActivityOrder = Order.mapInput( | ||
| 33 | Order.Struct({ | ||
| 34 | activityAt: Order.flip(Order.Number), | ||
| 35 | title: Order.String, | ||
| 36 | }), | ||
| 37 | (thread: EnvironmentThreadShell) => ({ | ||
| 38 | activityAt: new Date(thread.updatedAt ?? thread.createdAt).getTime(), | ||
| 39 | title: thread.title, | ||
| 40 | }), | ||
| 41 | ); | ||
| 42 | 29 | ||
| 43 | export function ThreadNavigationDrawer(props: { | 30 | export function ThreadNavigationDrawer(props: { |
| 44 | readonly visible: boolean; | 31 | readonly visible: boolean; |
| ... | @@ -192,24 +179,9 @@ function ThreadNavigationDrawerContent(props: { | ... | @@ -192,24 +179,9 @@ function ThreadNavigationDrawerContent(props: { |
| 192 | }) { | 179 | }) { |
| 193 | const projects = useProjects(); | 180 | const projects = useProjects(); |
| 194 | const threads = useThreadShells(); | 181 | const threads = useThreadShells(); |
| 195 | const repositoryGroups = useMemo( | ||
| 196 | () => groupProjectsByRepository({ projects, threads }), | ||
| 197 | [projects, threads], | ||
| 198 | ); | ||
| 199 | const groupedThreads = useMemo( | 182 | const groupedThreads = useMemo( |
| 200 | () => | 183 | () => buildThreadNavigationGroups({ projects, threads }), |
| 201 | repositoryGroups.map((group) => { | 184 | [projects, threads], |
| 202 | const threads: EnvironmentThreadShell[] = []; | ||
| 203 | for (const projectGroup of group.projects) { | ||
| 204 | threads.push(...projectGroup.threads); | ||
| 205 | } | ||
| 206 | return { | ||
| 207 | key: group.key, | ||
| 208 | title: group.projects[0]?.project.title ?? group.title, | ||
| 209 | threads: Arr.sort(threads, threadActivityOrder), | ||
| 210 | }; | ||
| 211 | }), | ||
| 212 | [repositoryGroups], | ||
| 213 | ); | 185 | ); |
| 214 | 186 | ||
| 215 | return ( | 187 | return ( |
| ... | @@ -239,6 +211,7 @@ function ThreadNavigationDrawerContent(props: { | ... | @@ -239,6 +211,7 @@ function ThreadNavigationDrawerContent(props: { |
| 239 | group.threads.map((thread, index) => { | 211 | group.threads.map((thread, index) => { |
| 240 | const threadKey = scopedThreadKey(thread.environmentId, thread.id); | 212 | const threadKey = scopedThreadKey(thread.environmentId, thread.id); |
| 241 | const selected = props.selectedThreadKey === threadKey; | 213 | const selected = props.selectedThreadKey === threadKey; |
| 214 | const status = resolveThreadStatus(thread); | ||
| 242 | 215 | ||
| 243 | return ( | 216 | return ( |
| 244 | <Pressable | 217 | <Pressable |
| ... | @@ -268,7 +241,7 @@ function ThreadNavigationDrawerContent(props: { | ... | @@ -268,7 +241,7 @@ function ThreadNavigationDrawerContent(props: { |
| 268 | {relativeTime(thread.updatedAt ?? thread.createdAt)} | 241 | {relativeTime(thread.updatedAt ?? thread.createdAt)} |
| 269 | </Text> | 242 | </Text> |
| 270 | </View> | 243 | </View> |
| 271 | <StatusPill {...threadStatusTone(thread)} /> | 244 | {status ? <StatusPill {...status} /> : null} |
| 272 | </View> | 245 | </View> |
| 273 | </Pressable> | 246 | </Pressable> |
| 274 | ); | 247 | ); |
apps/mobile/src/features/threads/ThreadNavigationSidebar.tsx created+794| ... | @@ -0,0 +1,794 @@ | ||
| 1 | import { isLiquidGlassSupported, LiquidGlassView } from "@callstack/liquid-glass"; | ||
| 2 | import type { EnvironmentThreadShell } from "@t3tools/client-runtime/state/shell"; | ||
| 3 | import { LegendList } from "@legendapp/list/react-native"; | ||
| 4 | import type { MenuAction } from "@react-native-menu/menu"; | ||
| 5 | import { SymbolView } from "expo-symbols"; | ||
| 6 | import { useCallback, useMemo, useRef, useState, type ReactNode } from "react"; | ||
| 7 | import type { LayoutChangeEvent, NativeScrollEvent, NativeSyntheticEvent } from "react-native"; | ||
| 8 | import { Platform, StyleSheet, TextInput, View, useColorScheme } from "react-native"; | ||
| 9 | import { Gesture, GestureDetector } from "react-native-gesture-handler"; | ||
| 10 | import type { SwipeableMethods } from "react-native-gesture-handler/ReanimatedSwipeable"; | ||
| 11 | import { useSafeAreaInsets } from "react-native-safe-area-context"; | ||
| 12 | import type { SearchBarCommands } from "react-native-screens"; | ||
| 13 | import Svg, { Defs, LinearGradient, Rect, Stop } from "react-native-svg"; | ||
| 14 | |||
| 15 | import { AppText as Text } from "../../components/AppText"; | ||
| 16 | import { ControlPillMenu } from "../../components/ControlPill"; | ||
| 17 | import { NativeStackScreenOptions } from "../../native/StackHeader"; | ||
| 18 | import { scopedProjectKey, scopedThreadKey } from "../../lib/scopedEntities"; | ||
| 19 | import { useThemeColor } from "../../lib/useThemeColor"; | ||
| 20 | import { useProjects, useThreadShells } from "../../state/entities"; | ||
| 21 | import { useWorkspaceState } from "../../state/workspace"; | ||
| 22 | import { useSavedRemoteConnections } from "../../state/use-remote-environment-registry"; | ||
| 23 | import { useHardwareKeyboardCommand } from "../keyboard/hardwareKeyboardCommands"; | ||
| 24 | import { | ||
| 25 | hasCustomHomeListOptions, | ||
| 26 | PROJECT_GROUPING_OPTIONS, | ||
| 27 | PROJECT_SORT_OPTIONS, | ||
| 28 | THREAD_SORT_OPTIONS, | ||
| 29 | useHomeListOptions, | ||
| 30 | } from "../home/home-list-options"; | ||
| 31 | import { buildHomeListFilterMenu } from "../home/home-list-filter-menu"; | ||
| 32 | import { | ||
| 33 | buildHomeListLayout, | ||
| 34 | DEFAULT_GROUP_DISPLAY_STATE, | ||
| 35 | homeListItemsAreEqual, | ||
| 36 | nextGroupDisplayState, | ||
| 37 | type HomeGroupDisplayAction, | ||
| 38 | type HomeGroupDisplayState, | ||
| 39 | type HomeListItem, | ||
| 40 | } from "../home/homeListItems"; | ||
| 41 | import { buildHomeThreadGroups } from "../home/homeThreadList"; | ||
| 42 | import { SwipeableScrollGateProvider, useSwipeableScrollGate } from "../home/thread-swipe-actions"; | ||
| 43 | import { useThreadListActions } from "../home/useThreadListActions"; | ||
| 44 | import { WorkspaceConnectionStatus } from "../home/WorkspaceConnectionStatus"; | ||
| 45 | import { shouldShowWorkspaceConnectionStatus } from "../home/workspace-connection-status"; | ||
| 46 | import { SidebarHeaderActions } from "./sidebar-header-actions"; | ||
| 47 | import { SidebarFilterButton } from "./sidebar-filter-button"; | ||
| 48 | import { createSidebarHeaderItems } from "./sidebar-native-header-items"; | ||
| 49 | import { SidebarNavigationShell } from "./sidebar-navigation-shell"; | ||
| 50 | import { ThreadListGroupHeader, ThreadListRow, ThreadListShowMoreRow } from "./thread-list-items"; | ||
| 51 | |||
| 52 | /** | ||
| 53 | * Shared capsule behind the sidebar header buttons — a native liquid-glass | ||
| 54 | * surface on iOS 26+, a tinted pill everywhere else. | ||
| 55 | */ | ||
| 56 | function SidebarHeaderButtonGroup(props: { | ||
| 57 | readonly children: ReactNode; | ||
| 58 | readonly colorScheme: "light" | "dark"; | ||
| 59 | }) { | ||
| 60 | if (isLiquidGlassSupported) { | ||
| 61 | return ( | ||
| 62 | <LiquidGlassView | ||
| 63 | colorScheme={props.colorScheme} | ||
| 64 | effect="regular" | ||
| 65 | interactive | ||
| 66 | style={styles.headerButtonGroup} | ||
| 67 | > | ||
| 68 | {props.children} | ||
| 69 | </LiquidGlassView> | ||
| 70 | ); | ||
| 71 | } | ||
| 72 | |||
| 73 | return ( | ||
| 74 | <View | ||
| 75 | style={[ | ||
| 76 | styles.headerButtonGroup, | ||
| 77 | props.colorScheme === "dark" | ||
| 78 | ? { backgroundColor: "rgba(118,118,128,0.24)", borderColor: "rgba(255,255,255,0.08)" } | ||
| 79 | : { backgroundColor: "rgba(255,255,255,0.72)", borderColor: "rgba(0,0,0,0.08)" }, | ||
| 80 | { borderWidth: StyleSheet.hairlineWidth }, | ||
| 81 | ]} | ||
| 82 | > | ||
| 83 | {props.children} | ||
| 84 | </View> | ||
| 85 | ); | ||
| 86 | } | ||
| 87 | |||
| 88 | const SIDEBAR_STICKY_HEADER_HEIGHT = 106; | ||
| 89 | const SIDEBAR_STICKY_HEADER_FADE_HEIGHT = 44; | ||
| 90 | const IOS_SEARCH_FILL_DARK = "rgba(118, 118, 128, 0.24)"; | ||
| 91 | const IOS_SEARCH_FILL_LIGHT = "rgba(118, 118, 128, 0.12)"; | ||
| 92 | const SIDEBAR_HEADER_WASH_OPACITY = { | ||
| 93 | dark: [0.22, 0.14, 0.04], | ||
| 94 | light: [0.46, 0.3, 0.08], | ||
| 95 | } as const; | ||
| 96 | |||
| 97 | interface ThreadNavigationSidebarProps { | ||
| 98 | readonly width: number; | ||
| 99 | readonly visible: boolean; | ||
| 100 | readonly selectedThreadKey: string | null; | ||
| 101 | readonly onOpenSettings: () => void; | ||
| 102 | readonly onOpenEnvironmentSettings: () => void; | ||
| 103 | readonly onSearchQueryChange: (query: string) => void; | ||
| 104 | readonly onSelectThread: (thread: EnvironmentThreadShell) => void; | ||
| 105 | readonly onRequestVisibility: () => void; | ||
| 106 | readonly searchQuery: string; | ||
| 107 | } | ||
| 108 | |||
| 109 | /** | ||
| 110 | * iPad/large-width sidebar column. | ||
| 111 | * | ||
| 112 | * On iOS the pane is hosted inside its own navigation-inert single-screen | ||
| 113 | * native stack (SidebarNavigationShell) so the header is a real | ||
| 114 | * UINavigationBar: large title, native bar-button items, and a | ||
| 115 | * UISearchController search field — the same chrome a UISplitViewController | ||
| 116 | * column gets. Other platforms keep the custom header chrome. | ||
| 117 | */ | ||
| 118 | export function ThreadNavigationSidebar(props: ThreadNavigationSidebarProps) { | ||
| 119 | if (Platform.OS !== "ios") { | ||
| 120 | return <ThreadNavigationSidebarPane {...props} nativeChrome={false} />; | ||
| 121 | } | ||
| 122 | return <NativeSidebarContainer {...props} />; | ||
| 123 | } | ||
| 124 | |||
| 125 | function NativeSidebarContainer(props: ThreadNavigationSidebarProps) { | ||
| 126 | const backgroundColor = useThemeColor("--color-drawer"); | ||
| 127 | const borderColor = useThemeColor("--color-border"); | ||
| 128 | |||
| 129 | return ( | ||
| 130 | <View | ||
| 131 | testID="thread-navigation-sidebar" | ||
| 132 | style={[ | ||
| 133 | styles.container, | ||
| 134 | { | ||
| 135 | width: props.width, | ||
| 136 | backgroundColor, | ||
| 137 | borderRightColor: borderColor, | ||
| 138 | borderRightWidth: StyleSheet.hairlineWidth, | ||
| 139 | }, | ||
| 140 | ]} | ||
| 141 | > | ||
| 142 | <SidebarNavigationShell> | ||
| 143 | <ThreadNavigationSidebarPane {...props} nativeChrome /> | ||
| 144 | </SidebarNavigationShell> | ||
| 145 | </View> | ||
| 146 | ); | ||
| 147 | } | ||
| 148 | |||
| 149 | function ThreadNavigationSidebarPane( | ||
| 150 | props: ThreadNavigationSidebarProps & { readonly nativeChrome: boolean }, | ||
| 151 | ) { | ||
| 152 | const insets = useSafeAreaInsets(); | ||
| 153 | const colorScheme = useColorScheme() === "dark" ? "dark" : "light"; | ||
| 154 | const projects = useProjects(); | ||
| 155 | const threads = useThreadShells(); | ||
| 156 | const { state: catalogState } = useWorkspaceState(); | ||
| 157 | const { savedConnectionsById } = useSavedRemoteConnections(); | ||
| 158 | const [headerIsOverContent, setHeaderIsOverContent] = useState(false); | ||
| 159 | const searchInputRef = useRef<TextInput>(null); | ||
| 160 | const searchBarRef = useRef<SearchBarCommands>(null); | ||
| 161 | const openSwipeableRef = useRef<SwipeableMethods | null>(null); | ||
| 162 | const headerIsOverContentRef = useRef(false); | ||
| 163 | const sidebarScrollGesture = useMemo(() => Gesture.Native(), []); | ||
| 164 | const { archiveThread, confirmDeleteThread } = useThreadListActions(); | ||
| 165 | const environments = useMemo( | ||
| 166 | () => | ||
| 167 | Object.values(savedConnectionsById) | ||
| 168 | .map((connection) => ({ | ||
| 169 | environmentId: connection.environmentId, | ||
| 170 | label: connection.environmentLabel, | ||
| 171 | })) | ||
| 172 | .sort((left, right) => left.label.localeCompare(right.label)), | ||
| 173 | [savedConnectionsById], | ||
| 174 | ); | ||
| 175 | const availableEnvironmentIds = useMemo( | ||
| 176 | () => new Set(environments.map((environment) => environment.environmentId)), | ||
| 177 | [environments], | ||
| 178 | ); | ||
| 179 | const { | ||
| 180 | options, | ||
| 181 | setSelectedEnvironmentId, | ||
| 182 | setProjectGroupingMode, | ||
| 183 | setProjectSortOrder, | ||
| 184 | setThreadSortOrder, | ||
| 185 | } = useHomeListOptions(availableEnvironmentIds); | ||
| 186 | const groups = useMemo( | ||
| 187 | () => | ||
| 188 | buildHomeThreadGroups({ | ||
| 189 | projects, | ||
| 190 | threads, | ||
| 191 | environmentId: options.selectedEnvironmentId, | ||
| 192 | searchQuery: props.searchQuery, | ||
| 193 | projectSortOrder: options.projectSortOrder, | ||
| 194 | threadSortOrder: options.threadSortOrder, | ||
| 195 | projectGroupingMode: options.projectGroupingMode, | ||
| 196 | }), | ||
| 197 | [options, projects, props.searchQuery, threads], | ||
| 198 | ); | ||
| 199 | const [groupDisplayStates, setGroupDisplayStates] = useState< | ||
| 200 | ReadonlyMap<string, HomeGroupDisplayState> | ||
| 201 | >(() => new Map()); | ||
| 202 | const updateGroupDisplay = useCallback((key: string, action: HomeGroupDisplayAction) => { | ||
| 203 | setGroupDisplayStates((previous) => { | ||
| 204 | const next = new Map(previous); | ||
| 205 | next.set( | ||
| 206 | key, | ||
| 207 | nextGroupDisplayState(previous.get(key) ?? DEFAULT_GROUP_DISPLAY_STATE, action), | ||
| 208 | ); | ||
| 209 | return next; | ||
| 210 | }); | ||
| 211 | }, []); | ||
| 212 | const hasSearchQuery = props.searchQuery.trim().length > 0; | ||
| 213 | const listLayout = useMemo( | ||
| 214 | () => | ||
| 215 | buildHomeListLayout({ | ||
| 216 | groups, | ||
| 217 | displayStates: groupDisplayStates, | ||
| 218 | showAllThreads: hasSearchQuery, | ||
| 219 | }), | ||
| 220 | [groups, groupDisplayStates, hasSearchQuery], | ||
| 221 | ); | ||
| 222 | const projectCwdByKey = useMemo(() => { | ||
| 223 | const map = new Map<string, string>(); | ||
| 224 | for (const project of projects) { | ||
| 225 | map.set(scopedProjectKey(project.environmentId, project.id), project.workspaceRoot); | ||
| 226 | } | ||
| 227 | return map; | ||
| 228 | }, [projects]); | ||
| 229 | const showsConnectionStatus = shouldShowWorkspaceConnectionStatus(catalogState); | ||
| 230 | const listMenuActions = useMemo<MenuAction[]>( | ||
| 231 | () => [ | ||
| 232 | { | ||
| 233 | id: "environment", | ||
| 234 | title: "Environment", | ||
| 235 | subactions: [ | ||
| 236 | { | ||
| 237 | id: "environment:all", | ||
| 238 | title: "All environments", | ||
| 239 | subtitle: "Show threads from every environment", | ||
| 240 | state: options.selectedEnvironmentId === null ? "on" : "off", | ||
| 241 | }, | ||
| 242 | ...environments.map((environment) => ({ | ||
| 243 | id: `environment:${environment.environmentId}`, | ||
| 244 | title: environment.label, | ||
| 245 | state: | ||
| 246 | options.selectedEnvironmentId === environment.environmentId | ||
| 247 | ? ("on" as const) | ||
| 248 | : ("off" as const), | ||
| 249 | })), | ||
| 250 | ], | ||
| 251 | }, | ||
| 252 | { | ||
| 253 | id: "project-sort", | ||
| 254 | title: "Sort projects", | ||
| 255 | subactions: PROJECT_SORT_OPTIONS.map((option) => ({ | ||
| 256 | id: `project-sort:${option.value}`, | ||
| 257 | title: option.label, | ||
| 258 | state: options.projectSortOrder === option.value ? "on" : "off", | ||
| 259 | })), | ||
| 260 | }, | ||
| 261 | { | ||
| 262 | id: "thread-sort", | ||
| 263 | title: "Sort threads", | ||
| 264 | subactions: THREAD_SORT_OPTIONS.map((option) => ({ | ||
| 265 | id: `thread-sort:${option.value}`, | ||
| 266 | title: option.label, | ||
| 267 | state: options.threadSortOrder === option.value ? "on" : "off", | ||
| 268 | })), | ||
| 269 | }, | ||
| 270 | { | ||
| 271 | id: "project-grouping", | ||
| 272 | title: "Group projects", | ||
| 273 | subactions: PROJECT_GROUPING_OPTIONS.map((option) => ({ | ||
| 274 | id: `project-grouping:${option.value}`, | ||
| 275 | title: option.label, | ||
| 276 | subtitle: option.subtitle, | ||
| 277 | state: options.projectGroupingMode === option.value ? "on" : "off", | ||
| 278 | })), | ||
| 279 | }, | ||
| 280 | ], | ||
| 281 | [environments, options], | ||
| 282 | ); | ||
| 283 | const handleListMenuAction = useCallback( | ||
| 284 | ({ nativeEvent }: { readonly nativeEvent: { readonly event: string } }) => { | ||
| 285 | const event = nativeEvent.event; | ||
| 286 | if (event === "environment:all") { | ||
| 287 | setSelectedEnvironmentId(null); | ||
| 288 | return; | ||
| 289 | } | ||
| 290 | if (event.startsWith("environment:")) { | ||
| 291 | const environment = environments.find( | ||
| 292 | (candidate) => String(candidate.environmentId) === event.slice("environment:".length), | ||
| 293 | ); | ||
| 294 | if (environment) setSelectedEnvironmentId(environment.environmentId); | ||
| 295 | return; | ||
| 296 | } | ||
| 297 | const projectSort = PROJECT_SORT_OPTIONS.find( | ||
| 298 | (option) => `project-sort:${option.value}` === event, | ||
| 299 | ); | ||
| 300 | if (projectSort) { | ||
| 301 | setProjectSortOrder(projectSort.value); | ||
| 302 | return; | ||
| 303 | } | ||
| 304 | const threadSort = THREAD_SORT_OPTIONS.find( | ||
| 305 | (option) => `thread-sort:${option.value}` === event, | ||
| 306 | ); | ||
| 307 | if (threadSort) { | ||
| 308 | setThreadSortOrder(threadSort.value); | ||
| 309 | return; | ||
| 310 | } | ||
| 311 | const grouping = PROJECT_GROUPING_OPTIONS.find( | ||
| 312 | (option) => `project-grouping:${option.value}` === event, | ||
| 313 | ); | ||
| 314 | if (grouping) setProjectGroupingMode(grouping.value); | ||
| 315 | }, | ||
| 316 | [ | ||
| 317 | environments, | ||
| 318 | setProjectGroupingMode, | ||
| 319 | setProjectSortOrder, | ||
| 320 | setSelectedEnvironmentId, | ||
| 321 | setThreadSortOrder, | ||
| 322 | ], | ||
| 323 | ); | ||
| 324 | |||
| 325 | const backgroundColor = useThemeColor("--color-drawer"); | ||
| 326 | const borderColor = useThemeColor("--color-border"); | ||
| 327 | const foregroundColor = useThemeColor("--color-foreground"); | ||
| 328 | const mutedColor = useThemeColor("--color-foreground-muted"); | ||
| 329 | const placeholderColor = useThemeColor("--color-placeholder"); | ||
| 330 | const searchBackgroundColor = | ||
| 331 | colorScheme === "dark" ? IOS_SEARCH_FILL_DARK : IOS_SEARCH_FILL_LIGHT; | ||
| 332 | const headerFadeColor = String(backgroundColor); | ||
| 333 | const headerWashOpacity = SIDEBAR_HEADER_WASH_OPACITY[colorScheme]; | ||
| 334 | const [measuredHeaderHeight, setMeasuredHeaderHeight] = useState<number | null>(null); | ||
| 335 | // The sticky header (title row, search field, optional connection status) | ||
| 336 | // is measured so the list inset always matches its real height — no | ||
| 337 | // hardcoded per-variant constants. | ||
| 338 | const stickyHeaderHeight = measuredHeaderHeight ?? insets.top + SIDEBAR_STICKY_HEADER_HEIGHT; | ||
| 339 | const topListInset = stickyHeaderHeight + 6; | ||
| 340 | const handleStickyHeaderLayout = useCallback((event: LayoutChangeEvent) => { | ||
| 341 | const nextHeight = Math.round(event.nativeEvent.layout.height); | ||
| 342 | setMeasuredHeaderHeight((current) => (current === nextHeight ? current : nextHeight)); | ||
| 343 | }, []); | ||
| 344 | const handleSwipeableWillOpen = useCallback((methods: SwipeableMethods) => { | ||
| 345 | if (openSwipeableRef.current !== methods) { | ||
| 346 | openSwipeableRef.current?.close(); | ||
| 347 | openSwipeableRef.current = methods; | ||
| 348 | } | ||
| 349 | }, []); | ||
| 350 | const handleSwipeableClose = useCallback((methods: SwipeableMethods) => { | ||
| 351 | if (openSwipeableRef.current === methods) { | ||
| 352 | openSwipeableRef.current = null; | ||
| 353 | } | ||
| 354 | }, []); | ||
| 355 | const handleSelectThread = useCallback( | ||
| 356 | (thread: EnvironmentThreadShell) => { | ||
| 357 | props.onSelectThread(thread); | ||
| 358 | openSwipeableRef.current?.close(); | ||
| 359 | }, | ||
| 360 | [props.onSelectThread], | ||
| 361 | ); | ||
| 362 | const handleScroll = useCallback((event: NativeSyntheticEvent<NativeScrollEvent>) => { | ||
| 363 | const next = event.nativeEvent.contentOffset.y > 6; | ||
| 364 | if (headerIsOverContentRef.current === next) { | ||
| 365 | return; | ||
| 366 | } | ||
| 367 | headerIsOverContentRef.current = next; | ||
| 368 | setHeaderIsOverContent(next); | ||
| 369 | }, []); | ||
| 370 | const handleScrollBeginDrag = useCallback(() => { | ||
| 371 | openSwipeableRef.current?.close(); | ||
| 372 | }, []); | ||
| 373 | const { swipeEnabled, scrollGateHandlers } = useSwipeableScrollGate({ | ||
| 374 | onScroll: handleScroll, | ||
| 375 | onScrollBeginDrag: handleScrollBeginDrag, | ||
| 376 | }); | ||
| 377 | const listExtraData = props.selectedThreadKey ?? ""; | ||
| 378 | const focusSearch = useCallback(() => { | ||
| 379 | const focus = () => { | ||
| 380 | if (props.nativeChrome) { | ||
| 381 | searchBarRef.current?.focus(); | ||
| 382 | return; | ||
| 383 | } | ||
| 384 | searchInputRef.current?.focus(); | ||
| 385 | }; | ||
| 386 | if (!props.visible) { | ||
| 387 | props.onRequestVisibility(); | ||
| 388 | setTimeout(focus, 240); | ||
| 389 | } else { | ||
| 390 | focus(); | ||
| 391 | } | ||
| 392 | return true; | ||
| 393 | }, [props.nativeChrome, props.onRequestVisibility, props.visible]); | ||
| 394 | useHardwareKeyboardCommand("focusSearch", focusSearch); | ||
| 395 | const renderListItem = useCallback( | ||
| 396 | ({ item }: { readonly item: HomeListItem }) => { | ||
| 397 | switch (item.type) { | ||
| 398 | case "header": | ||
| 399 | return ( | ||
| 400 | <ThreadListGroupHeader | ||
| 401 | variant="sidebar" | ||
| 402 | collapsed={item.collapsed} | ||
| 403 | isFirst={item.isFirst} | ||
| 404 | groupKey={item.group.key} | ||
| 405 | onGroupAction={updateGroupDisplay} | ||
| 406 | project={item.group.representative} | ||
| 407 | threadCount={item.group.threads.length} | ||
| 408 | title={item.group.title} | ||
| 409 | /> | ||
| 410 | ); | ||
| 411 | case "thread": { | ||
| 412 | const thread = item.thread; | ||
| 413 | return ( | ||
| 414 | <ThreadListRow | ||
| 415 | variant="sidebar" | ||
| 416 | thread={thread} | ||
| 417 | environmentLabel={ | ||
| 418 | savedConnectionsById[thread.environmentId]?.environmentLabel ?? null | ||
| 419 | } | ||
| 420 | projectCwd={ | ||
| 421 | projectCwdByKey.get(scopedProjectKey(thread.environmentId, thread.projectId)) ?? | ||
| 422 | null | ||
| 423 | } | ||
| 424 | isLast={item.isLast} | ||
| 425 | selected={ | ||
| 426 | scopedThreadKey(thread.environmentId, thread.id) === props.selectedThreadKey | ||
| 427 | } | ||
| 428 | fullSwipeWidth={props.width - 20} | ||
| 429 | onArchiveThread={archiveThread} | ||
| 430 | onDeleteThread={confirmDeleteThread} | ||
| 431 | onSelectThread={handleSelectThread} | ||
| 432 | onSwipeableClose={handleSwipeableClose} | ||
| 433 | onSwipeableWillOpen={handleSwipeableWillOpen} | ||
| 434 | simultaneousSwipeGesture={sidebarScrollGesture} | ||
| 435 | /> | ||
| 436 | ); | ||
| 437 | } | ||
| 438 | case "show-more": | ||
| 439 | return ( | ||
| 440 | <ThreadListShowMoreRow | ||
| 441 | variant="sidebar" | ||
| 442 | hiddenCount={item.hiddenCount} | ||
| 443 | canShowLess={item.canShowLess} | ||
| 444 | groupKey={item.groupKey} | ||
| 445 | onGroupAction={updateGroupDisplay} | ||
| 446 | /> | ||
| 447 | ); | ||
| 448 | } | ||
| 449 | }, | ||
| 450 | [ | ||
| 451 | archiveThread, | ||
| 452 | confirmDeleteThread, | ||
| 453 | handleSelectThread, | ||
| 454 | handleSwipeableClose, | ||
| 455 | handleSwipeableWillOpen, | ||
| 456 | projectCwdByKey, | ||
| 457 | props.selectedThreadKey, | ||
| 458 | props.width, | ||
| 459 | savedConnectionsById, | ||
| 460 | sidebarScrollGesture, | ||
| 461 | updateGroupDisplay, | ||
| 462 | ], | ||
| 463 | ); | ||
| 464 | const filterIcon = hasCustomHomeListOptions(options) | ||
| 465 | ? "line.3.horizontal.decrease.circle.fill" | ||
| 466 | : "line.3.horizontal.decrease.circle"; | ||
| 467 | const filterMenu = useMemo( | ||
| 468 | () => | ||
| 469 | buildHomeListFilterMenu({ | ||
| 470 | environments, | ||
| 471 | selectedEnvironmentId: options.selectedEnvironmentId, | ||
| 472 | projectSortOrder: options.projectSortOrder, | ||
| 473 | threadSortOrder: options.threadSortOrder, | ||
| 474 | projectGroupingMode: options.projectGroupingMode, | ||
| 475 | onEnvironmentChange: setSelectedEnvironmentId, | ||
| 476 | onProjectSortOrderChange: setProjectSortOrder, | ||
| 477 | onThreadSortOrderChange: setThreadSortOrder, | ||
| 478 | onProjectGroupingModeChange: setProjectGroupingMode, | ||
| 479 | }), | ||
| 480 | [ | ||
| 481 | environments, | ||
| 482 | options, | ||
| 483 | setProjectGroupingMode, | ||
| 484 | setProjectSortOrder, | ||
| 485 | setSelectedEnvironmentId, | ||
| 486 | setThreadSortOrder, | ||
| 487 | ], | ||
| 488 | ); | ||
| 489 | const nativeHeaderItems = useMemo( | ||
| 490 | () => | ||
| 491 | createSidebarHeaderItems({ | ||
| 492 | filterIcon, | ||
| 493 | filterMenu, | ||
| 494 | onOpenSettings: props.onOpenSettings, | ||
| 495 | }), | ||
| 496 | [filterIcon, filterMenu, props.onOpenSettings], | ||
| 497 | ); | ||
| 498 | const listEmpty = ( | ||
| 499 | <Text className="px-2 py-4 text-sm" style={{ color: mutedColor }}> | ||
| 500 | {catalogState.isLoadingConnections | ||
| 501 | ? "Loading threads…" | ||
| 502 | : props.searchQuery.trim().length > 0 | ||
| 503 | ? "No matching threads" | ||
| 504 | : "No threads yet"} | ||
| 505 | </Text> | ||
| 506 | ); | ||
| 507 | |||
| 508 | if (props.nativeChrome) { | ||
| 509 | return ( | ||
| 510 | <> | ||
| 511 | <NativeStackScreenOptions | ||
| 512 | options={{ | ||
| 513 | headerSearchBarOptions: { | ||
| 514 | ref: searchBarRef, | ||
| 515 | autoCapitalize: "none", | ||
| 516 | hideNavigationBar: false, | ||
| 517 | // Keep the search bar pinned under the title — UIKit's default | ||
| 518 | // hidesSearchBarWhenScrolling collapses it on scroll. | ||
| 519 | hideWhenScrolling: false, | ||
| 520 | obscureBackground: false, | ||
| 521 | placeholder: "Search", | ||
| 522 | placement: "stacked", | ||
| 523 | onCancelButtonPress: () => { | ||
| 524 | props.onSearchQueryChange(""); | ||
| 525 | }, | ||
| 526 | onChangeText: (event) => { | ||
| 527 | props.onSearchQueryChange(event.nativeEvent.text); | ||
| 528 | }, | ||
| 529 | }, | ||
| 530 | unstable_headerRightItems: () => nativeHeaderItems, | ||
| 531 | }} | ||
| 532 | /> | ||
| 533 | <View style={styles.container}> | ||
| 534 | <SwipeableScrollGateProvider enabled={swipeEnabled}> | ||
| 535 | <GestureDetector gesture={sidebarScrollGesture}> | ||
| 536 | <LegendList | ||
| 537 | data={listLayout.items} | ||
| 538 | drawDistance={500} | ||
| 539 | estimatedItemSize={64} | ||
| 540 | extraData={listExtraData} | ||
| 541 | getItemType={(item) => item.type} | ||
| 542 | itemsAreEqual={homeListItemsAreEqual} | ||
| 543 | keyExtractor={(item) => item.key} | ||
| 544 | renderItem={renderListItem} | ||
| 545 | automaticallyAdjustsScrollIndicatorInsets | ||
| 546 | contentInsetAdjustmentBehavior="automatic" | ||
| 547 | contentContainerStyle={[ | ||
| 548 | styles.threadListContent, | ||
| 549 | { | ||
| 550 | paddingBottom: Math.max(insets.bottom, 16) + 16, | ||
| 551 | paddingTop: 6, | ||
| 552 | }, | ||
| 553 | ]} | ||
| 554 | keyboardDismissMode="on-drag" | ||
| 555 | keyboardShouldPersistTaps="handled" | ||
| 556 | {...scrollGateHandlers} | ||
| 557 | recycleItems | ||
| 558 | scrollEventThrottle={16} | ||
| 559 | showsVerticalScrollIndicator={false} | ||
| 560 | style={styles.threadList} | ||
| 561 | ListHeaderComponent={ | ||
| 562 | showsConnectionStatus ? ( | ||
| 563 | <View style={styles.connectionStatusNative}> | ||
| 564 | <WorkspaceConnectionStatus | ||
| 565 | onPress={props.onOpenEnvironmentSettings} | ||
| 566 | state={catalogState} | ||
| 567 | variant="sidebar" | ||
| 568 | /> | ||
| 569 | </View> | ||
| 570 | ) : null | ||
| 571 | } | ||
| 572 | ListEmptyComponent={listEmpty} | ||
| 573 | /> | ||
| 574 | </GestureDetector> | ||
| 575 | </SwipeableScrollGateProvider> | ||
| 576 | </View> | ||
| 577 | </> | ||
| 578 | ); | ||
| 579 | } | ||
| 580 | |||
| 581 | return ( | ||
| 582 | <View | ||
| 583 | testID="thread-navigation-sidebar" | ||
| 584 | style={[ | ||
| 585 | styles.container, | ||
| 586 | { | ||
| 587 | width: props.width, | ||
| 588 | backgroundColor, | ||
| 589 | borderRightColor: borderColor, | ||
| 590 | borderRightWidth: StyleSheet.hairlineWidth, | ||
| 591 | }, | ||
| 592 | ]} | ||
| 593 | > | ||
| 594 | <View style={{ flex: 1, paddingBottom: insets.bottom }}> | ||
| 595 | <SwipeableScrollGateProvider enabled={swipeEnabled}> | ||
| 596 | <GestureDetector gesture={sidebarScrollGesture}> | ||
| 597 | <LegendList | ||
| 598 | data={listLayout.items} | ||
| 599 | drawDistance={500} | ||
| 600 | estimatedItemSize={64} | ||
| 601 | extraData={listExtraData} | ||
| 602 | getItemType={(item) => item.type} | ||
| 603 | itemsAreEqual={homeListItemsAreEqual} | ||
| 604 | keyExtractor={(item) => item.key} | ||
| 605 | renderItem={renderListItem} | ||
| 606 | contentContainerStyle={[ | ||
| 607 | styles.threadListContent, | ||
| 608 | { | ||
| 609 | paddingBottom: 16 + insets.bottom, | ||
| 610 | paddingTop: topListInset, | ||
| 611 | }, | ||
| 612 | ]} | ||
| 613 | keyboardDismissMode="on-drag" | ||
| 614 | keyboardShouldPersistTaps="handled" | ||
| 615 | {...scrollGateHandlers} | ||
| 616 | recycleItems | ||
| 617 | scrollEventThrottle={16} | ||
| 618 | showsVerticalScrollIndicator={false} | ||
| 619 | style={styles.threadList} | ||
| 620 | ListEmptyComponent={listEmpty} | ||
| 621 | /> | ||
| 622 | </GestureDetector> | ||
| 623 | </SwipeableScrollGateProvider> | ||
| 624 | </View> | ||
| 625 | |||
| 626 | <View | ||
| 627 | onLayout={handleStickyHeaderLayout} | ||
| 628 | pointerEvents="box-none" | ||
| 629 | style={[ | ||
| 630 | styles.stickyHeader, | ||
| 631 | { | ||
| 632 | paddingTop: insets.top, | ||
| 633 | }, | ||
| 634 | ]} | ||
| 635 | > | ||
| 636 | <View | ||
| 637 | pointerEvents="none" | ||
| 638 | accessibilityElementsHidden | ||
| 639 | importantForAccessibility="no-hide-descendants" | ||
| 640 | style={[ | ||
| 641 | styles.stickyHeaderWash, | ||
| 642 | { | ||
| 643 | height: stickyHeaderHeight + SIDEBAR_STICKY_HEADER_FADE_HEIGHT, | ||
| 644 | }, | ||
| 645 | ]} | ||
| 646 | > | ||
| 647 | <Svg width="100%" height="100%"> | ||
| 648 | <Defs> | ||
| 649 | <LinearGradient id="sidebar-header-wash" x1="0%" x2="0%" y1="0%" y2="100%"> | ||
| 650 | <Stop | ||
| 651 | offset="0%" | ||
| 652 | stopColor={headerFadeColor} | ||
| 653 | stopOpacity={headerIsOverContent ? headerWashOpacity[0] : 0} | ||
| 654 | /> | ||
| 655 | <Stop | ||
| 656 | offset="58%" | ||
| 657 | stopColor={headerFadeColor} | ||
| 658 | stopOpacity={headerIsOverContent ? headerWashOpacity[1] : 0} | ||
| 659 | /> | ||
| 660 | <Stop | ||
| 661 | offset="88%" | ||
| 662 | stopColor={headerFadeColor} | ||
| 663 | stopOpacity={headerIsOverContent ? headerWashOpacity[2] : 0} | ||
| 664 | /> | ||
| 665 | <Stop offset="100%" stopColor={headerFadeColor} stopOpacity={0} /> | ||
| 666 | </LinearGradient> | ||
| 667 | </Defs> | ||
| 668 | <Rect width="100%" height="100%" fill="url(#sidebar-header-wash)" /> | ||
| 669 | </Svg> | ||
| 670 | </View> | ||
| 671 | <View style={styles.header}> | ||
| 672 | <Text | ||
| 673 | className="flex-1 text-[34px] font-t3-bold" | ||
| 674 | numberOfLines={1} | ||
| 675 | style={{ color: foregroundColor }} | ||
| 676 | > | ||
| 677 | Threads | ||
| 678 | </Text> | ||
| 679 | <SidebarHeaderButtonGroup colorScheme={colorScheme}> | ||
| 680 | <ControlPillMenu actions={listMenuActions} onPressAction={handleListMenuAction}> | ||
| 681 | <SidebarFilterButton | ||
| 682 | grouped | ||
| 683 | accessibilityLabel="Filter and sort threads" | ||
| 684 | icon={filterIcon} | ||
| 685 | /> | ||
| 686 | </ControlPillMenu> | ||
| 687 | <SidebarHeaderActions grouped onOpenSettings={props.onOpenSettings} /> | ||
| 688 | </SidebarHeaderButtonGroup> | ||
| 689 | </View> | ||
| 690 | |||
| 691 | <View | ||
| 692 | style={[ | ||
| 693 | styles.searchField, | ||
| 694 | { | ||
| 695 | backgroundColor: searchBackgroundColor, | ||
| 696 | }, | ||
| 697 | ]} | ||
| 698 | > | ||
| 699 | <SymbolView name="magnifyingglass" size={15} tintColor={mutedColor} type="monochrome" /> | ||
| 700 | <TextInput | ||
| 701 | ref={searchInputRef} | ||
| 702 | accessibilityLabel="Search threads" | ||
| 703 | autoCapitalize="none" | ||
| 704 | autoCorrect={false} | ||
| 705 | clearButtonMode="while-editing" | ||
| 706 | onChangeText={props.onSearchQueryChange} | ||
| 707 | placeholder="Search" | ||
| 708 | placeholderTextColor={placeholderColor} | ||
| 709 | returnKeyType="search" | ||
| 710 | className="text-base" | ||
| 711 | style={[styles.searchInput, { color: foregroundColor }]} | ||
| 712 | value={props.searchQuery} | ||
| 713 | /> | ||
| 714 | </View> | ||
| 715 | |||
| 716 | {showsConnectionStatus ? ( | ||
| 717 | <View style={styles.connectionStatus}> | ||
| 718 | <WorkspaceConnectionStatus | ||
| 719 | onPress={props.onOpenEnvironmentSettings} | ||
| 720 | state={catalogState} | ||
| 721 | variant="sidebar" | ||
| 722 | /> | ||
| 723 | </View> | ||
| 724 | ) : null} | ||
| 725 | </View> | ||
| 726 | </View> | ||
| 727 | ); | ||
| 728 | } | ||
| 729 | |||
| 730 | const styles = StyleSheet.create({ | ||
| 731 | container: { | ||
| 732 | flex: 1, | ||
| 733 | }, | ||
| 734 | stickyHeader: { | ||
| 735 | left: 0, | ||
| 736 | position: "absolute", | ||
| 737 | right: 0, | ||
| 738 | top: 0, | ||
| 739 | zIndex: 4, | ||
| 740 | }, | ||
| 741 | stickyHeaderWash: { | ||
| 742 | left: 0, | ||
| 743 | position: "absolute", | ||
| 744 | right: 0, | ||
| 745 | top: 0, | ||
| 746 | }, | ||
| 747 | header: { | ||
| 748 | height: 50, | ||
| 749 | paddingLeft: 20, | ||
| 750 | paddingRight: 8, | ||
| 751 | flexDirection: "row", | ||
| 752 | alignItems: "flex-end", | ||
| 753 | gap: 2, | ||
| 754 | }, | ||
| 755 | headerButtonGroup: { | ||
| 756 | alignItems: "center", | ||
| 757 | borderRadius: 22, | ||
| 758 | flexDirection: "row", | ||
| 759 | overflow: "hidden", | ||
| 760 | }, | ||
| 761 | connectionStatus: { | ||
| 762 | paddingTop: 10, | ||
| 763 | paddingHorizontal: 14, | ||
| 764 | }, | ||
| 765 | connectionStatusNative: { | ||
| 766 | paddingBottom: 8, | ||
| 767 | paddingHorizontal: 6, | ||
| 768 | paddingTop: 2, | ||
| 769 | }, | ||
| 770 | searchField: { | ||
| 771 | height: 38, | ||
| 772 | marginTop: 9, | ||
| 773 | marginHorizontal: 16, | ||
| 774 | paddingLeft: 11, | ||
| 775 | paddingRight: 10, | ||
| 776 | borderRadius: 12, | ||
| 777 | flexDirection: "row", | ||
| 778 | alignItems: "center", | ||
| 779 | gap: 6, | ||
| 780 | }, | ||
| 781 | searchInput: { | ||
| 782 | flex: 1, | ||
| 783 | height: 34, | ||
| 784 | paddingVertical: 0, | ||
| 785 | paddingHorizontal: 0, | ||
| 786 | fontFamily: "DMSans_400Regular", | ||
| 787 | }, | ||
| 788 | threadList: { | ||
| 789 | flex: 1, | ||
| 790 | }, | ||
| 791 | threadListContent: { | ||
| 792 | paddingHorizontal: 8, | ||
| 793 | }, | ||
| 794 | }); | ||
apps/mobile/src/features/threads/ThreadRouteScreen.tsx+467-127| ... | @@ -1,9 +1,16 @@ | ... | @@ -1,9 +1,16 @@ |
| 1 | import { Stack, useLocalSearchParams, useRouter } from "expo-router"; | 1 | import { NativeStackScreenOptions } from "../../native/StackHeader"; |
| 2 | import { useCallback, useMemo, useState } from "react"; | 2 | import { |
| 3 | StackActions, | ||
| 4 | useFocusEffect, | ||
| 5 | useNavigation, | ||
| 6 | type StaticScreenProps, | ||
| 7 | } from "@react-navigation/native"; | ||
| 8 | import { useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from "react"; | ||
| 3 | import * as Option from "effect/Option"; | 9 | import * as Option from "effect/Option"; |
| 4 | import { EnvironmentId, type ProjectScript } from "@t3tools/contracts"; | 10 | import { EnvironmentId, ThreadId, type ProjectScript } from "@t3tools/contracts"; |
| 5 | import { projectScriptCwd, projectScriptRuntimeEnv } from "@t3tools/shared/projectScripts"; | 11 | import { projectScriptCwd, projectScriptRuntimeEnv } from "@t3tools/shared/projectScripts"; |
| 6 | import { Pressable, ScrollView, Text as RNText, View } from "react-native"; | 12 | import { Platform, Pressable, ScrollView, Text as RNText, View } from "react-native"; |
| 13 | import { useSafeAreaInsets } from "react-native-safe-area-context"; | ||
| 7 | import { useWorkspaceState } from "../../state/workspace"; | 14 | import { useWorkspaceState } from "../../state/workspace"; |
| 8 | import { useThemeColor } from "../../lib/useThemeColor"; | 15 | import { useThemeColor } from "../../lib/useThemeColor"; |
| 9 | import { useEnvironmentQuery } from "../../state/query"; | 16 | import { useEnvironmentQuery } from "../../state/query"; |
| ... | @@ -12,7 +19,6 @@ import { vcsEnvironment } from "../../state/vcs"; | ... | @@ -12,7 +19,6 @@ import { vcsEnvironment } from "../../state/vcs"; |
| 12 | 19 | ||
| 13 | import { EmptyState } from "../../components/EmptyState"; | 20 | import { EmptyState } from "../../components/EmptyState"; |
| 14 | import { LoadingScreen } from "../../components/LoadingScreen"; | 21 | import { LoadingScreen } from "../../components/LoadingScreen"; |
| 15 | import { buildThreadRoutePath, buildThreadTerminalNavigation } from "../../lib/routes"; | ||
| 16 | import { scopedThreadKey } from "../../lib/scopedEntities"; | 22 | import { scopedThreadKey } from "../../lib/scopedEntities"; |
| 17 | import { MOBILE_TYPOGRAPHY } from "../../lib/typography"; | 23 | import { MOBILE_TYPOGRAPHY } from "../../lib/typography"; |
| 18 | import { connectionTone } from "../connection/connectionTone"; | 24 | import { connectionTone } from "../connection/connectionTone"; |
| ... | @@ -37,7 +43,12 @@ import { | ... | @@ -37,7 +43,12 @@ import { |
| 37 | } from "../terminal/terminalLaunchContext"; | 43 | } from "../terminal/terminalLaunchContext"; |
| 38 | import { terminalDebugLog } from "../terminal/terminalDebugLog"; | 44 | import { terminalDebugLog } from "../terminal/terminalDebugLog"; |
| 39 | import { ThreadDetailScreen } from "./ThreadDetailScreen"; | 45 | import { ThreadDetailScreen } from "./ThreadDetailScreen"; |
| 40 | import { ThreadGitControls } from "./ThreadGitControls"; | 46 | import { |
| 47 | ThreadGitControls, | ||
| 48 | useThreadGitCenterHeaderItems, | ||
| 49 | useThreadGitRightHeaderItems, | ||
| 50 | } from "./ThreadGitControls"; | ||
| 51 | import { GitOverviewSheet } from "./git/GitOverviewSheet"; | ||
| 41 | import { ThreadNavigationDrawer } from "./ThreadNavigationDrawer"; | 52 | import { ThreadNavigationDrawer } from "./ThreadNavigationDrawer"; |
| 42 | import { useAtomCommand } from "../../state/use-atom-command"; | 53 | import { useAtomCommand } from "../../state/use-atom-command"; |
| 43 | import { useSelectedThreadGitActions } from "../../state/use-selected-thread-git-actions"; | 54 | import { useSelectedThreadGitActions } from "../../state/use-selected-thread-git-actions"; |
| ... | @@ -47,6 +58,30 @@ import { useSelectedThreadWorktree } from "../../state/use-selected-thread-workt | ... | @@ -47,6 +58,30 @@ import { useSelectedThreadWorktree } from "../../state/use-selected-thread-workt |
| 47 | import { useThreadComposerState } from "../../state/use-thread-composer-state"; | 58 | import { useThreadComposerState } from "../../state/use-thread-composer-state"; |
| 48 | import { threadEnvironment } from "../../state/threads"; | 59 | import { threadEnvironment } from "../../state/threads"; |
| 49 | import { projectThreadContentPresentation } from "./threadContentPresentation"; | 60 | import { projectThreadContentPresentation } from "./threadContentPresentation"; |
| 61 | import { | ||
| 62 | useAdaptiveWorkspaceLayout, | ||
| 63 | useAdaptiveWorkspacePaneRole, | ||
| 64 | useRegisterWorkspaceInspector, | ||
| 65 | } from "../layout/AdaptiveWorkspaceLayout"; | ||
| 66 | import { withNativeGlassHeaderItem } from "../layout/native-glass-header-items"; | ||
| 67 | import { ThreadFileNavigatorPane } from "../files/thread-file-navigator-pane"; | ||
| 68 | import { | ||
| 69 | ThreadInspectorContentStack, | ||
| 70 | type ThreadInspectorMode, | ||
| 71 | } from "./thread-inspector-content-stack"; | ||
| 72 | import { useHardwareKeyboardCommand } from "../keyboard/hardwareKeyboardCommands"; | ||
| 73 | |||
| 74 | interface ThreadInspectorSelection { | ||
| 75 | readonly routeThreadIdentity: string | null; | ||
| 76 | readonly mode: ThreadInspectorMode; | ||
| 77 | } | ||
| 78 | |||
| 79 | type NativeHeaderItems = ReadonlyArray<Record<string, unknown>>; | ||
| 80 | |||
| 81 | function InspectorPaneRoleActivation() { | ||
| 82 | useAdaptiveWorkspacePaneRole("inspector"); | ||
| 83 | return null; | ||
| 84 | } | ||
| 50 | 85 | ||
| 51 | function firstRouteParam(value: string | string[] | undefined): string | null { | 86 | function firstRouteParam(value: string | string[] | undefined): string | null { |
| 52 | if (Array.isArray(value)) { | 87 | if (Array.isArray(value)) { |
| ... | @@ -60,13 +95,99 @@ function OpeningThreadLoadingScreen() { | ... | @@ -60,13 +95,99 @@ function OpeningThreadLoadingScreen() { |
| 60 | return <LoadingScreen message="Opening thread…" messagePlacement="above-spinner" />; | 95 | return <LoadingScreen message="Opening thread…" messagePlacement="above-spinner" />; |
| 61 | } | 96 | } |
| 62 | 97 | ||
| 63 | export function ThreadRouteScreen() { | 98 | type ThreadRouteScreenRouteProps = StaticScreenProps<{ |
| 99 | readonly environmentId: string; | ||
| 100 | readonly threadId: string; | ||
| 101 | }>; | ||
| 102 | |||
| 103 | interface ThreadRouteScreenProps extends ThreadRouteScreenRouteProps { | ||
| 104 | readonly onReturnToThread?: () => void; | ||
| 105 | readonly renderInspector?: (headerInset: number) => ReactNode; | ||
| 106 | } | ||
| 107 | |||
| 108 | function ThreadUnavailableScreen() { | ||
| 109 | return ( | ||
| 110 | <ScrollView | ||
| 111 | contentInsetAdjustmentBehavior="automatic" | ||
| 112 | contentContainerStyle={{ | ||
| 113 | flexGrow: 1, | ||
| 114 | justifyContent: "center", | ||
| 115 | paddingHorizontal: 24, | ||
| 116 | paddingVertical: 32, | ||
| 117 | }} | ||
| 118 | className="bg-screen flex-1" | ||
| 119 | > | ||
| 120 | <EmptyState | ||
| 121 | title="Thread unavailable" | ||
| 122 | detail="This thread is not available in the current mobile snapshot." | ||
| 123 | /> | ||
| 124 | </ScrollView> | ||
| 125 | ); | ||
| 126 | } | ||
| 127 | |||
| 128 | export function ThreadRouteScreen(props: ThreadRouteScreenProps) { | ||
| 64 | const { state: workspaceState } = useWorkspaceState(); | 129 | const { state: workspaceState } = useWorkspaceState(); |
| 65 | const { connectionState } = useRemoteConnectionStatus(); | 130 | const { connectionState } = useRemoteConnectionStatus(); |
| 131 | const { selectedThread } = useThreadSelection(); | ||
| 132 | const params = props.route.params; | ||
| 133 | const environmentIdRaw = firstRouteParam(params.environmentId); | ||
| 134 | const threadIdRaw = firstRouteParam(params.threadId); | ||
| 135 | const environmentId = environmentIdRaw ? EnvironmentId.make(environmentIdRaw) : null; | ||
| 136 | const routeEnvironmentRuntime = useRemoteEnvironmentRuntime(environmentId); | ||
| 137 | const routeConnectionState = | ||
| 138 | routeEnvironmentRuntime?.connectionState ?? (environmentId ? "available" : connectionState); | ||
| 139 | const routeThreadKey = | ||
| 140 | environmentId !== null && threadIdRaw !== null | ||
| 141 | ? scopedThreadKey(environmentId, ThreadId.make(threadIdRaw)) | ||
| 142 | : null; | ||
| 143 | const selectedThreadKey = | ||
| 144 | selectedThread === null | ||
| 145 | ? null | ||
| 146 | : scopedThreadKey(selectedThread.environmentId, selectedThread.id); | ||
| 147 | const selectedThreadDetailState = useSelectedThreadDetailState(); | ||
| 148 | |||
| 149 | if (environmentId === null || threadIdRaw === null) { | ||
| 150 | return <OpeningThreadLoadingScreen />; | ||
| 151 | } | ||
| 152 | |||
| 153 | // Render the full thread chrome (header, feed, composer) as soon as the | ||
| 154 | // thread SHELL is known — no blocking on message detail. The feed shows a | ||
| 155 | // loading placeholder while messages fetch, and the composer's connection | ||
| 156 | // pill reports connecting/reconnecting/syncing status. | ||
| 157 | if (selectedThread !== null && selectedThreadKey === routeThreadKey) { | ||
| 158 | return <ThreadRouteContent {...props} selectedThreadDetailState={selectedThreadDetailState} />; | ||
| 159 | } | ||
| 160 | |||
| 161 | const stillHydrating = | ||
| 162 | workspaceState.isLoadingConnections || | ||
| 163 | routeConnectionState === "connecting" || | ||
| 164 | routeConnectionState === "reconnecting"; | ||
| 165 | |||
| 166 | if (stillHydrating) { | ||
| 167 | return <OpeningThreadLoadingScreen />; | ||
| 168 | } | ||
| 169 | |||
| 170 | return <ThreadUnavailableScreen />; | ||
| 171 | } | ||
| 172 | |||
| 173 | function ThreadRouteContent( | ||
| 174 | props: ThreadRouteScreenProps & { | ||
| 175 | readonly selectedThreadDetailState: ReturnType<typeof useSelectedThreadDetailState>; | ||
| 176 | }, | ||
| 177 | ) { | ||
| 178 | const { | ||
| 179 | fileInspector, | ||
| 180 | layout, | ||
| 181 | panes, | ||
| 182 | showAuxiliaryPane, | ||
| 183 | toggleAuxiliaryPane, | ||
| 184 | togglePrimarySidebar, | ||
| 185 | } = useAdaptiveWorkspaceLayout(); | ||
| 186 | const { connectionState } = useRemoteConnectionStatus(); | ||
| 66 | const { onReconnectEnvironment } = useRemoteConnections(); | 187 | const { onReconnectEnvironment } = useRemoteConnections(); |
| 67 | const { selectedThread, selectedThreadProject, selectedEnvironmentConnection } = | 188 | const { selectedThread, selectedThreadProject, selectedEnvironmentConnection } = |
| 68 | useThreadSelection(); | 189 | useThreadSelection(); |
| 69 | const selectedThreadDetailState = useSelectedThreadDetailState(); | 190 | const selectedThreadDetailState = props.selectedThreadDetailState; |
| 70 | const selectedThreadDetail = Option.getOrNull(selectedThreadDetailState.data); | 191 | const selectedThreadDetail = Option.getOrNull(selectedThreadDetailState.data); |
| 71 | const { selectedThreadCwd } = useSelectedThreadWorktree(); | 192 | const { selectedThreadCwd } = useSelectedThreadWorktree(); |
| 72 | const composer = useThreadComposerState(); | 193 | const composer = useThreadComposerState(); |
| ... | @@ -74,15 +195,72 @@ export function ThreadRouteScreen() { | ... | @@ -74,15 +195,72 @@ export function ThreadRouteScreen() { |
| 74 | const gitActions = useSelectedThreadGitActions(); | 195 | const gitActions = useSelectedThreadGitActions(); |
| 75 | const requests = useSelectedThreadRequests(); | 196 | const requests = useSelectedThreadRequests(); |
| 76 | const interruptThreadTurn = useAtomCommand(threadEnvironment.interruptTurn, "thread interrupt"); | 197 | const interruptThreadTurn = useAtomCommand(threadEnvironment.interruptTurn, "thread interrupt"); |
| 77 | const router = useRouter(); | 198 | const navigation = useNavigation(); |
| 78 | const params = useLocalSearchParams<{ | 199 | const params = props.route.params; |
| 79 | environmentId?: string | string[]; | ||
| 80 | threadId?: string | string[]; | ||
| 81 | }>(); | ||
| 82 | const [drawerVisible, setDrawerVisible] = useState(false); | 200 | const [drawerVisible, setDrawerVisible] = useState(false); |
| 83 | const environmentIdRaw = firstRouteParam(params.environmentId); | 201 | const environmentIdRaw = firstRouteParam(params.environmentId); |
| 84 | const environmentId = environmentIdRaw ? EnvironmentId.make(environmentIdRaw) : null; | 202 | const environmentId = environmentIdRaw ? EnvironmentId.make(environmentIdRaw) : null; |
| 85 | const threadId = firstRouteParam(params.threadId); | 203 | const threadId = firstRouteParam(params.threadId); |
| 204 | const routeThreadIdentity = | ||
| 205 | environmentIdRaw !== null && threadId !== null ? `${environmentIdRaw}:${threadId}` : null; | ||
| 206 | const [inspectorSelection, setInspectorSelection] = useState<ThreadInspectorSelection | null>( | ||
| 207 | () => (props.renderInspector ? { routeThreadIdentity, mode: "route" } : null), | ||
| 208 | ); | ||
| 209 | const inspectorMode = (() => { | ||
| 210 | if (inspectorSelection?.routeThreadIdentity === routeThreadIdentity) { | ||
| 211 | if (inspectorSelection.mode === "files" && selectedThreadCwd === null) { | ||
| 212 | return null; | ||
| 213 | } | ||
| 214 | return inspectorSelection.mode; | ||
| 215 | } | ||
| 216 | return null; | ||
| 217 | })(); | ||
| 218 | useEffect(() => { | ||
| 219 | if ( | ||
| 220 | fileInspector.supported && | ||
| 221 | selectedThreadCwd === null && | ||
| 222 | inspectorMode === null && | ||
| 223 | panes.auxiliaryPaneVisible | ||
| 224 | ) { | ||
| 225 | toggleAuxiliaryPane(); | ||
| 226 | } | ||
| 227 | }, [ | ||
| 228 | fileInspector.supported, | ||
| 229 | inspectorMode, | ||
| 230 | panes.auxiliaryPaneVisible, | ||
| 231 | selectedThreadCwd, | ||
| 232 | toggleAuxiliaryPane, | ||
| 233 | ]); | ||
| 234 | |||
| 235 | useEffect(() => { | ||
| 236 | setInspectorSelection((current) => { | ||
| 237 | if (props.renderInspector === undefined) { | ||
| 238 | if (current === null || current.mode === "route") { | ||
| 239 | return null; | ||
| 240 | } | ||
| 241 | return { ...current, routeThreadIdentity }; | ||
| 242 | } | ||
| 243 | |||
| 244 | if (current === null || current.mode === "route") { | ||
| 245 | return { routeThreadIdentity, mode: "route" }; | ||
| 246 | } | ||
| 247 | |||
| 248 | return { ...current, routeThreadIdentity }; | ||
| 249 | }); | ||
| 250 | }, [props.renderInspector, routeThreadIdentity]); | ||
| 251 | |||
| 252 | useFocusEffect( | ||
| 253 | useCallback(() => { | ||
| 254 | return () => { | ||
| 255 | if (props.renderInspector === undefined) { | ||
| 256 | // Inspectors are contextual to this chat destination. Clear the | ||
| 257 | // hidden chat copy after a native push so returning from Files, | ||
| 258 | // Review, or Terminal cannot reserve an empty trailing pane. | ||
| 259 | setInspectorSelection(null); | ||
| 260 | } | ||
| 261 | }; | ||
| 262 | }, [props.renderInspector]), | ||
| 263 | ); | ||
| 86 | const routeEnvironmentRuntime = useRemoteEnvironmentRuntime(environmentId); | 264 | const routeEnvironmentRuntime = useRemoteEnvironmentRuntime(environmentId); |
| 87 | const routeConnectionState = | 265 | const routeConnectionState = |
| 88 | routeEnvironmentRuntime?.connectionState ?? (environmentId ? "available" : connectionState); | 266 | routeEnvironmentRuntime?.connectionState ?? (environmentId ? "available" : connectionState); |
| ... | @@ -101,10 +279,14 @@ export function ThreadRouteScreen() { | ... | @@ -101,10 +279,14 @@ export function ThreadRouteScreen() { |
| 101 | ); | 279 | ); |
| 102 | 280 | ||
| 103 | /* ─── Native header theming ──────────────────────────────────────── */ | 281 | /* ─── Native header theming ──────────────────────────────────────── */ |
| 104 | const iconColor = String(useThemeColor("--color-icon")); | ||
| 105 | const foregroundColor = String(useThemeColor("--color-foreground")); | 282 | const foregroundColor = String(useThemeColor("--color-foreground")); |
| 106 | const secondaryFg = String(useThemeColor("--color-foreground-secondary")); | 283 | const usesNativeHeaderGlass = Platform.OS === "ios"; |
| 107 | 284 | const headerSubtitle = [ | |
| 285 | selectedThreadProject?.title ?? null, | ||
| 286 | selectedEnvironmentConnection?.environmentLabel ?? null, | ||
| 287 | ] | ||
| 288 | .filter(Boolean) | ||
| 289 | .join(" · "); | ||
| 108 | /* ─── Git status for native header trigger ───────────────────────── */ | 290 | /* ─── Git status for native header trigger ───────────────────────── */ |
| 109 | const gitStatus = useEnvironmentQuery( | 291 | const gitStatus = useEnvironmentQuery( |
| 110 | selectedThread !== null && selectedThreadCwd !== null | 292 | selectedThread !== null && selectedThreadCwd !== null |
| ... | @@ -145,12 +327,134 @@ export function ThreadRouteScreen() { | ... | @@ -145,12 +327,134 @@ export function ThreadRouteScreen() { |
| 145 | const gitActionProgress = useGitActionProgress(gitActionProgressTarget); | 327 | const gitActionProgress = useGitActionProgress(gitActionProgressTarget); |
| 146 | 328 | ||
| 147 | const handleOpenDrawer = useCallback(() => { | 329 | const handleOpenDrawer = useCallback(() => { |
| 148 | setDrawerVisible(true); | 330 | if (!layout.usesSplitView) { |
| 331 | setDrawerVisible(true); | ||
| 332 | } | ||
| 333 | }, [layout.usesSplitView]); | ||
| 334 | |||
| 335 | useEffect(() => { | ||
| 336 | if (layout.usesSplitView) { | ||
| 337 | setDrawerVisible(false); | ||
| 338 | } | ||
| 339 | }, [layout.usesSplitView]); | ||
| 340 | |||
| 341 | const handleOpenGitInspector = useCallback(() => { | ||
| 342 | setInspectorSelection({ routeThreadIdentity, mode: "git" }); | ||
| 343 | showAuxiliaryPane("inspector"); | ||
| 344 | }, [routeThreadIdentity, showAuxiliaryPane]); | ||
| 345 | const handleOpenFilesInspector = useCallback(() => { | ||
| 346 | if (!fileInspector.supported || selectedThread === null || selectedThreadCwd === null) { | ||
| 347 | return; | ||
| 348 | } | ||
| 349 | setInspectorSelection({ | ||
| 350 | routeThreadIdentity, | ||
| 351 | mode: props.renderInspector === undefined ? "files" : "route", | ||
| 352 | }); | ||
| 353 | showAuxiliaryPane("inspector"); | ||
| 354 | }, [ | ||
| 355 | fileInspector.supported, | ||
| 356 | props.renderInspector, | ||
| 357 | routeThreadIdentity, | ||
| 358 | selectedThread, | ||
| 359 | selectedThreadCwd, | ||
| 360 | showAuxiliaryPane, | ||
| 361 | ]); | ||
| 362 | const inspectorToggleActionRef = useRef({ | ||
| 363 | inspectorMode, | ||
| 364 | openFilesInspector: handleOpenFilesInspector, | ||
| 365 | toggleAuxiliaryPane, | ||
| 366 | }); | ||
| 367 | inspectorToggleActionRef.current = { | ||
| 368 | inspectorMode, | ||
| 369 | openFilesInspector: handleOpenFilesInspector, | ||
| 370 | toggleAuxiliaryPane, | ||
| 371 | }; | ||
| 372 | const handleToggleInspector = useCallback(() => { | ||
| 373 | const action = inspectorToggleActionRef.current; | ||
| 374 | if (action.inspectorMode === null) { | ||
| 375 | action.openFilesInspector(); | ||
| 376 | return; | ||
| 377 | } | ||
| 378 | action.toggleAuxiliaryPane(); | ||
| 149 | }, []); | 379 | }, []); |
| 380 | const handleSelectInspectorFile = useCallback( | ||
| 381 | (path: string) => { | ||
| 382 | if (selectedThread === null) { | ||
| 383 | return; | ||
| 384 | } | ||
| 385 | const params = { | ||
| 386 | environmentId: String(selectedThread.environmentId), | ||
| 387 | threadId: String(selectedThread.id), | ||
| 388 | path: path.split("/").filter((segment) => segment.length > 0), | ||
| 389 | }; | ||
| 390 | if (fileInspector.supported) { | ||
| 391 | navigation.navigate("ThreadFile", params); | ||
| 392 | return; | ||
| 393 | } | ||
| 394 | navigation.navigate("ThreadFile", params); | ||
| 395 | }, | ||
| 396 | [fileInspector.supported, navigation, selectedThread], | ||
| 397 | ); | ||
| 398 | // The workspace inspector column spans the full window height. On iOS the | ||
| 399 | // panes bring their own nested native headers (which underlap the status | ||
| 400 | // bar); elsewhere the pane content pads itself below the top inset. | ||
| 401 | const safeAreaInsets = useSafeAreaInsets(); | ||
| 402 | const inspectorHeaderInset = Platform.OS === "ios" ? 0 : safeAreaInsets.top; | ||
| 403 | const GitInspector = useCallback( | ||
| 404 | () => ( | ||
| 405 | <GitOverviewSheet | ||
| 406 | headerInset={inspectorHeaderInset} | ||
| 407 | presentation="inspector" | ||
| 408 | route={{ params: props.route.params }} | ||
| 409 | /> | ||
| 410 | ), | ||
| 411 | [inspectorHeaderInset, props.route.params], | ||
| 412 | ); | ||
| 413 | const FilesInspector = useCallback( | ||
| 414 | () => | ||
| 415 | selectedThread !== null && selectedThreadCwd !== null ? ( | ||
| 416 | <ThreadFileNavigatorPane | ||
| 417 | cwd={selectedThreadCwd} | ||
| 418 | environmentId={selectedThread.environmentId} | ||
| 419 | headerInset={inspectorHeaderInset} | ||
| 420 | projectName={selectedThreadProject?.title ?? "Files"} | ||
| 421 | selectedPath={null} | ||
| 422 | onSelectFile={handleSelectInspectorFile} | ||
| 423 | /> | ||
| 424 | ) : null, | ||
| 425 | [ | ||
| 426 | handleSelectInspectorFile, | ||
| 427 | inspectorHeaderInset, | ||
| 428 | selectedThread, | ||
| 429 | selectedThreadCwd, | ||
| 430 | selectedThreadProject?.title, | ||
| 431 | ], | ||
| 432 | ); | ||
| 433 | const RouteInspector = useCallback( | ||
| 434 | () => props.renderInspector?.(inspectorHeaderInset), | ||
| 435 | [inspectorHeaderInset, props.renderInspector], | ||
| 436 | ); | ||
| 437 | const renderInspectorStack = useCallback( | ||
| 438 | () => | ||
| 439 | inspectorMode === null ? null : ( | ||
| 440 | <ThreadInspectorContentStack | ||
| 441 | Files={FilesInspector} | ||
| 442 | Git={GitInspector} | ||
| 443 | mode={inspectorMode} | ||
| 444 | Route={props.renderInspector ? RouteInspector : undefined} | ||
| 445 | /> | ||
| 446 | ), | ||
| 447 | [FilesInspector, GitInspector, RouteInspector, inspectorMode, props.renderInspector], | ||
| 448 | ); | ||
| 449 | const activeInspectorRenderer = inspectorMode === null ? undefined : renderInspectorStack; | ||
| 450 | // Hand the inspector to the workspace so it renders beside the navigator, | ||
| 451 | // outside this screen's native header — the terminal/git/files toolbar | ||
| 452 | // stays anchored to the chat pane instead of floating above the inspector. | ||
| 453 | useRegisterWorkspaceInspector(activeInspectorRenderer); | ||
| 150 | 454 | ||
| 151 | const handleOpenConnectionEditor = useCallback(() => { | 455 | const handleOpenConnectionEditor = useCallback(() => { |
| 152 | void router.push("/connections"); | 456 | void navigation.navigate("Connections"); |
| 153 | }, [router]); | 457 | }, [navigation]); |
| 154 | const handleStopThread = useCallback(() => { | 458 | const handleStopThread = useCallback(() => { |
| 155 | if ( | 459 | if ( |
| 156 | !selectedThread || | 460 | !selectedThread || |
| ... | @@ -182,9 +486,13 @@ export function ThreadRouteScreen() { | ... | @@ -182,9 +486,13 @@ export function ThreadRouteScreen() { |
| 182 | return; | 486 | return; |
| 183 | } | 487 | } |
| 184 | 488 | ||
| 185 | void router.push(buildThreadTerminalNavigation(selectedThread, nextTerminalId)); | 489 | void navigation.navigate("ThreadTerminal", { |
| 490 | environmentId: String(selectedThread.environmentId), | ||
| 491 | threadId: String(selectedThread.id), | ||
| 492 | ...(nextTerminalId ? { terminalId: nextTerminalId } : {}), | ||
| 493 | }); | ||
| 186 | }, | 494 | }, |
| 187 | [router, selectedThread, selectedThreadProject?.workspaceRoot], | 495 | [navigation, selectedThread, selectedThreadProject?.workspaceRoot], |
| 188 | ); | 496 | ); |
| 189 | 497 | ||
| 190 | const handleOpenNewTerminal = useCallback(() => { | 498 | const handleOpenNewTerminal = useCallback(() => { |
| ... | @@ -201,8 +509,12 @@ export function ThreadRouteScreen() { | ... | @@ -201,8 +509,12 @@ export function ThreadRouteScreen() { |
| 201 | const nextId = nextOpenTerminalId({ | 509 | const nextId = nextOpenTerminalId({ |
| 202 | listedTerminalIds: terminalMenuSessions.map((session) => session.terminalId), | 510 | listedTerminalIds: terminalMenuSessions.map((session) => session.terminalId), |
| 203 | }); | 511 | }); |
| 204 | void router.push(buildThreadTerminalNavigation(selectedThread, nextId)); | 512 | void navigation.navigate("ThreadTerminal", { |
| 205 | }, [router, selectedThread, selectedThreadProject?.workspaceRoot, terminalMenuSessions]); | 513 | environmentId: String(selectedThread.environmentId), |
| 514 | threadId: String(selectedThread.id), | ||
| 515 | terminalId: nextId, | ||
| 516 | }); | ||
| 517 | }, [navigation, selectedThread, selectedThreadProject?.workspaceRoot, terminalMenuSessions]); | ||
| 206 | 518 | ||
| 207 | const handleRunProjectScript = useCallback( | 519 | const handleRunProjectScript = useCallback( |
| 208 | async (script: ProjectScript) => { | 520 | async (script: ProjectScript) => { |
| ... | @@ -259,48 +571,97 @@ export function ThreadRouteScreen() { | ... | @@ -259,48 +571,97 @@ export function ThreadRouteScreen() { |
| 259 | worktreePath: preferredWorktreePath, | 571 | worktreePath: preferredWorktreePath, |
| 260 | }); | 572 | }); |
| 261 | 573 | ||
| 262 | void router.push(buildThreadTerminalNavigation(selectedThread, targetTerminalId)); | 574 | void navigation.navigate("ThreadTerminal", { |
| 575 | environmentId: String(selectedThread.environmentId), | ||
| 576 | threadId: String(selectedThread.id), | ||
| 577 | terminalId: targetTerminalId, | ||
| 578 | }); | ||
| 263 | }, | 579 | }, |
| 264 | [ | 580 | [ |
| 265 | router, | 581 | navigation, |
| 266 | selectedThread, | 582 | selectedThread, |
| 267 | selectedThreadDetailWorktreePath, | 583 | selectedThreadDetailWorktreePath, |
| 268 | selectedThreadProject, | 584 | selectedThreadProject, |
| 269 | terminalMenuSessions, | 585 | terminalMenuSessions, |
| 270 | ], | 586 | ], |
| 271 | ); | 587 | ); |
| 588 | const threadGitControlProps = { | ||
| 589 | environmentId: environmentIdRaw ?? "", | ||
| 590 | threadId: threadId ?? "", | ||
| 591 | auxiliaryPaneControl: | ||
| 592 | !layout.usesSplitView && fileInspector.supported && selectedThreadCwd !== null | ||
| 593 | ? { | ||
| 594 | accessibilityLabel: "Toggle inspector", | ||
| 595 | onPress: handleToggleInspector, | ||
| 596 | } | ||
| 597 | : undefined, | ||
| 598 | onOpenFilesInspector: | ||
| 599 | fileInspector.supported && selectedThreadCwd !== null ? handleOpenFilesInspector : undefined, | ||
| 600 | onOpenGitInspector: fileInspector.supported ? handleOpenGitInspector : undefined, | ||
| 601 | currentBranch: selectedThread?.branch ?? null, | ||
| 602 | gitStatus: gitStatus.data, | ||
| 603 | gitOperationLabel: gitState.gitOperationLabel, | ||
| 604 | canOpenTerminal: Boolean(selectedThreadProject?.workspaceRoot), | ||
| 605 | canOpenFiles: Boolean(selectedThreadProject?.workspaceRoot), | ||
| 606 | projectScripts: selectedThreadProject?.scripts ?? [], | ||
| 607 | terminalSessions: terminalMenuSessions, | ||
| 608 | showDirectFileControl: layout.usesSplitView, | ||
| 609 | onOpenTerminal: handleOpenTerminal, | ||
| 610 | onOpenNewTerminal: handleOpenNewTerminal, | ||
| 611 | onRunProjectScript: handleRunProjectScript, | ||
| 612 | onPull: gitActions.onPullSelectedThreadBranch, | ||
| 613 | onRunAction: gitActions.onRunSelectedThreadGitAction, | ||
| 614 | }; | ||
| 615 | const threadCenterHeaderItems = useThreadGitCenterHeaderItems(threadGitControlProps); | ||
| 616 | const compactRightHeaderItems = useThreadGitRightHeaderItems(threadGitControlProps); | ||
| 617 | const splitLeftHeaderItems = useMemo<NativeHeaderItems>( | ||
| 618 | () => [ | ||
| 619 | { | ||
| 620 | // Match Mail's split-view detail toolbar: the first detail action sits | ||
| 621 | // inside the content pane, not flush against the sidebar divider. | ||
| 622 | spacing: 18, | ||
| 623 | type: "spacing" as const, | ||
| 624 | }, | ||
| 625 | ...(props.onReturnToThread | ||
| 626 | ? [ | ||
| 627 | withNativeGlassHeaderItem({ | ||
| 628 | accessibilityLabel: "Return to chat", | ||
| 629 | icon: { name: "chevron.left", type: "sfSymbol" as const }, | ||
| 630 | identifier: "thread-left-return", | ||
| 631 | onPress: props.onReturnToThread, | ||
| 632 | type: "button" as const, | ||
| 633 | }), | ||
| 634 | ] | ||
| 635 | : []), | ||
| 636 | withNativeGlassHeaderItem({ | ||
| 637 | accessibilityLabel: panes.primarySidebarVisible | ||
| 638 | ? "Maximize content" | ||
| 639 | : "Show thread sidebar", | ||
| 640 | icon: { | ||
| 641 | name: panes.primarySidebarVisible ? "arrow.up.left.and.arrow.down.right" : "sidebar.left", | ||
| 642 | type: "sfSymbol" as const, | ||
| 643 | }, | ||
| 644 | identifier: "thread-left-sidebar", | ||
| 645 | onPress: togglePrimarySidebar, | ||
| 646 | type: "button" as const, | ||
| 647 | }), | ||
| 648 | withNativeGlassHeaderItem({ | ||
| 649 | accessibilityLabel: "New task", | ||
| 650 | icon: { name: "square.and.pencil", type: "sfSymbol" as const }, | ||
| 651 | identifier: "thread-left-new-task", | ||
| 652 | onPress: () => navigation.navigate("NewTaskSheet", { screen: "NewTask" }), | ||
| 653 | type: "button" as const, | ||
| 654 | }), | ||
| 655 | ], | ||
| 656 | [panes.primarySidebarVisible, props.onReturnToThread, navigation, togglePrimarySidebar], | ||
| 657 | ); | ||
| 272 | 658 | ||
| 273 | if (!environmentId || !threadId) { | 659 | if (!environmentId || !threadId) { |
| 274 | return <OpeningThreadLoadingScreen />; | 660 | return <OpeningThreadLoadingScreen />; |
| 275 | } | 661 | } |
| 276 | 662 | ||
| 277 | if (!selectedThread) { | 663 | if (!selectedThread) { |
| 278 | const stillHydrating = | 664 | return <OpeningThreadLoadingScreen />; |
| 279 | workspaceState.isLoadingConnections || | ||
| 280 | routeConnectionState === "connecting" || | ||
| 281 | routeConnectionState === "reconnecting"; | ||
| 282 | |||
| 283 | if (stillHydrating) { | ||
| 284 | return <OpeningThreadLoadingScreen />; | ||
| 285 | } | ||
| 286 | |||
| 287 | return ( | ||
| 288 | <ScrollView | ||
| 289 | contentInsetAdjustmentBehavior="automatic" | ||
| 290 | contentContainerStyle={{ | ||
| 291 | flexGrow: 1, | ||
| 292 | justifyContent: "center", | ||
| 293 | paddingHorizontal: 24, | ||
| 294 | paddingVertical: 32, | ||
| 295 | }} | ||
| 296 | className="bg-screen flex-1" | ||
| 297 | > | ||
| 298 | <EmptyState | ||
| 299 | title="Thread unavailable" | ||
| 300 | detail="This thread is not available in the current mobile snapshot." | ||
| 301 | /> | ||
| 302 | </ScrollView> | ||
| 303 | ); | ||
| 304 | } | 665 | } |
| 305 | 666 | ||
| 306 | const selectedThreadKey = scopedThreadKey(selectedThread.environmentId, selectedThread.id); | 667 | const selectedThreadKey = scopedThreadKey(selectedThread.environmentId, selectedThread.id); |
| ... | @@ -311,74 +672,9 @@ export function ThreadRouteScreen() { | ... | @@ -311,74 +672,9 @@ export function ThreadRouteScreen() { |
| 311 | connectionState: routeConnectionState, | 672 | connectionState: routeConnectionState, |
| 312 | }); | 673 | }); |
| 313 | const serverConfig = routeEnvironmentRuntime?.serverConfig ?? null; | 674 | const serverConfig = routeEnvironmentRuntime?.serverConfig ?? null; |
| 314 | 675 | const renderThreadRouteBody = (showActionControls: boolean) => ( | |
| 315 | const headerSubtitle = [ | ||
| 316 | selectedThreadProject?.title ?? null, | ||
| 317 | selectedEnvironmentConnection?.environmentLabel ?? null, | ||
| 318 | ] | ||
| 319 | .filter(Boolean) | ||
| 320 | .join(" · "); | ||
| 321 | |||
| 322 | return ( | ||
| 323 | <> | 676 | <> |
| 324 | <Stack.Screen | 677 | <ThreadGitControls {...threadGitControlProps} showActionControls={showActionControls} /> |
| 325 | options={{ | ||
| 326 | headerShown: true, | ||
| 327 | headerTransparent: true, | ||
| 328 | headerStyle: { backgroundColor: "transparent" }, | ||
| 329 | headerShadowVisible: false, | ||
| 330 | headerTintColor: iconColor, | ||
| 331 | headerBackTitle: "", | ||
| 332 | headerTitle: () => ( | ||
| 333 | <Pressable | ||
| 334 | style={{ alignItems: "center", maxWidth: 200 }} | ||
| 335 | onLongPress={() => { | ||
| 336 | // TODO: trigger rename modal | ||
| 337 | }} | ||
| 338 | > | ||
| 339 | <RNText | ||
| 340 | numberOfLines={1} | ||
| 341 | style={{ | ||
| 342 | fontFamily: "DMSans_700Bold", | ||
| 343 | fontSize: MOBILE_TYPOGRAPHY.headline.fontSize, | ||
| 344 | fontWeight: "900", | ||
| 345 | color: foregroundColor, | ||
| 346 | letterSpacing: -0.4, | ||
| 347 | }} | ||
| 348 | > | ||
| 349 | {selectedThread.title} | ||
| 350 | </RNText> | ||
| 351 | <RNText | ||
| 352 | numberOfLines={1} | ||
| 353 | style={{ | ||
| 354 | fontFamily: "DMSans_700Bold", | ||
| 355 | fontSize: MOBILE_TYPOGRAPHY.label.fontSize, | ||
| 356 | fontWeight: "700", | ||
| 357 | color: secondaryFg, | ||
| 358 | letterSpacing: 0.3, | ||
| 359 | }} | ||
| 360 | > | ||
| 361 | {headerSubtitle} | ||
| 362 | </RNText> | ||
| 363 | </Pressable> | ||
| 364 | ), | ||
| 365 | }} | ||
| 366 | /> | ||
| 367 | |||
| 368 | <ThreadGitControls | ||
| 369 | currentBranch={selectedThread.branch} | ||
| 370 | gitStatus={gitStatus.data} | ||
| 371 | gitOperationLabel={gitState.gitOperationLabel} | ||
| 372 | canOpenTerminal={Boolean(selectedThreadProject?.workspaceRoot)} | ||
| 373 | canOpenFiles={Boolean(selectedThreadProject?.workspaceRoot)} | ||
| 374 | projectScripts={selectedThreadProject?.scripts ?? []} | ||
| 375 | terminalSessions={terminalMenuSessions} | ||
| 376 | onOpenTerminal={handleOpenTerminal} | ||
| 377 | onOpenNewTerminal={handleOpenNewTerminal} | ||
| 378 | onRunProjectScript={handleRunProjectScript} | ||
| 379 | onPull={gitActions.onPullSelectedThreadBranch} | ||
| 380 | onRunAction={gitActions.onRunSelectedThreadGitAction} | ||
| 381 | /> | ||
| 382 | 678 | ||
| 383 | <GitActionProgressOverlay progress={gitActionProgress} onDismiss={dismissGitActionResult} /> | 679 | <GitActionProgressOverlay progress={gitActionProgress} onDismiss={dismissGitActionResult} /> |
| 384 | 680 | ||
| ... | @@ -400,11 +696,14 @@ export function ThreadRouteScreen() { | ... | @@ -400,11 +696,14 @@ export function ThreadRouteScreen() { |
| 400 | draftMessage={composer.draftMessage} | 696 | draftMessage={composer.draftMessage} |
| 401 | draftAttachments={composer.draftAttachments} | 697 | draftAttachments={composer.draftAttachments} |
| 402 | connectionStateLabel={routeConnectionState} | 698 | connectionStateLabel={routeConnectionState} |
| 699 | threadSyncStatus={selectedThreadDetailState.status} | ||
| 403 | activeThreadBusy={composer.activeThreadBusy} | 700 | activeThreadBusy={composer.activeThreadBusy} |
| 404 | environmentId={selectedThread.environmentId} | 701 | environmentId={selectedThread.environmentId} |
| 405 | projectWorkspaceRoot={selectedThreadProject?.workspaceRoot ?? null} | 702 | projectWorkspaceRoot={selectedThreadProject?.workspaceRoot ?? null} |
| 406 | threadCwd={selectedThreadCwd} | 703 | threadCwd={selectedThreadCwd} |
| 407 | selectedThreadQueueCount={composer.selectedThreadQueueCount} | 704 | selectedThreadQueueCount={composer.selectedThreadQueueCount} |
| 705 | layoutVariant={layout.variant} | ||
| 706 | usesAutomaticContentInsets={usesNativeHeaderGlass} | ||
| 408 | onOpenDrawer={handleOpenDrawer} | 707 | onOpenDrawer={handleOpenDrawer} |
| 409 | onOpenConnectionEditor={handleOpenConnectionEditor} | 708 | onOpenConnectionEditor={handleOpenConnectionEditor} |
| 410 | onChangeDraftMessage={composer.onChangeDraftMessage} | 709 | onChangeDraftMessage={composer.onChangeDraftMessage} |
| ... | @@ -424,16 +723,57 @@ export function ThreadRouteScreen() { | ... | @@ -424,16 +723,57 @@ export function ThreadRouteScreen() { |
| 424 | onSubmitUserInput={requests.onSubmitUserInput} | 723 | onSubmitUserInput={requests.onSubmitUserInput} |
| 425 | /> | 724 | /> |
| 426 | 725 | ||
| 427 | <ThreadNavigationDrawer | 726 | {layout.usesSplitView ? null : ( |
| 428 | visible={drawerVisible} | 727 | <ThreadNavigationDrawer |
| 429 | selectedThreadKey={selectedThreadKey} | 728 | visible={drawerVisible} |
| 430 | onClose={() => setDrawerVisible(false)} | 729 | selectedThreadKey={selectedThreadKey} |
| 431 | onSelectThread={(thread) => { | 730 | onClose={() => setDrawerVisible(false)} |
| 432 | router.replace(buildThreadRoutePath(thread)); | 731 | onSelectThread={(thread) => { |
| 433 | }} | 732 | navigation.dispatch( |
| 434 | onStartNewTask={() => router.push("/new")} | 733 | StackActions.replace("Thread", { |
| 435 | /> | 734 | environmentId: String(thread.environmentId), |
| 735 | threadId: String(thread.id), | ||
| 736 | }), | ||
| 737 | ); | ||
| 738 | }} | ||
| 739 | onStartNewTask={() => navigation.navigate("NewTaskSheet", { screen: "NewTask" })} | ||
| 740 | /> | ||
| 741 | )} | ||
| 436 | </View> | 742 | </View> |
| 437 | </> | 743 | </> |
| 438 | ); | 744 | ); |
| 745 | |||
| 746 | return ( | ||
| 747 | <> | ||
| 748 | {activeInspectorRenderer ? <InspectorPaneRoleActivation /> : null} | ||
| 749 | <NativeStackScreenOptions | ||
| 750 | options={{ | ||
| 751 | headerTitle: selectedThread.title, | ||
| 752 | headerTitleStyle: usesNativeHeaderGlass | ||
| 753 | ? { | ||
| 754 | fontSize: 17, | ||
| 755 | fontWeight: "800", | ||
| 756 | } | ||
| 757 | : undefined, | ||
| 758 | title: selectedThread.title, | ||
| 759 | headerBackVisible: !layout.usesSplitView, | ||
| 760 | // Compact uses the NATIVE back button (Thread lives flat in the root | ||
| 761 | // stack now, so a real previous route exists); only split view needs | ||
| 762 | // custom left items. | ||
| 763 | unstable_headerLeftItems: | ||
| 764 | Platform.OS === "ios" && layout.usesSplitView ? () => splitLeftHeaderItems : undefined, | ||
| 765 | // Search lives in the persistent sidebar, so the split header keeps | ||
| 766 | // the git controls on the RIGHT (no center items — center space is | ||
| 767 | // reserved for future breadcrumbs/status). | ||
| 768 | unstable_headerRightItems: | ||
| 769 | Platform.OS === "ios" | ||
| 770 | ? () => (layout.usesSplitView ? threadCenterHeaderItems : compactRightHeaderItems) | ||
| 771 | : undefined, | ||
| 772 | unstable_headerSubtitle: usesNativeHeaderGlass ? headerSubtitle : undefined, | ||
| 773 | }} | ||
| 774 | /> | ||
| 775 | |||
| 776 | {renderThreadRouteBody(!layout.usesSplitView && !usesNativeHeaderGlass)} | ||
| 777 | </> | ||
| 778 | ); | ||
| 439 | } | 779 | } |
apps/mobile/src/features/threads/git/GitBranchesSheet.tsx+11-6| ... | @@ -1,5 +1,5 @@ | ... | @@ -1,5 +1,5 @@ |
| 1 | import { sanitizeFeatureBranchName } from "@t3tools/shared/git"; | 1 | import { sanitizeFeatureBranchName } from "@t3tools/shared/git"; |
| 2 | import { useRouter } from "expo-router"; | 2 | import { useNavigation, type StaticScreenProps } from "@react-navigation/native"; |
| 3 | import { useState } from "react"; | 3 | import { useState } from "react"; |
| 4 | import { Pressable, ScrollView, View } from "react-native"; | 4 | import { Pressable, ScrollView, View } from "react-native"; |
| 5 | import { useSafeAreaInsets } from "react-native-safe-area-context"; | 5 | import { useSafeAreaInsets } from "react-native-safe-area-context"; |
| ... | @@ -14,8 +14,13 @@ import { useSelectedThreadWorktree } from "../../../state/use-selected-thread-wo | ... | @@ -14,8 +14,13 @@ import { useSelectedThreadWorktree } from "../../../state/use-selected-thread-wo |
| 14 | import { vcsEnvironment } from "../../../state/vcs"; | 14 | import { vcsEnvironment } from "../../../state/vcs"; |
| 15 | import { SheetActionButton } from "./gitSheetComponents"; | 15 | import { SheetActionButton } from "./gitSheetComponents"; |
| 16 | 16 | ||
| 17 | export function GitBranchesSheet() { | 17 | type GitBranchesSheetProps = StaticScreenProps<{ |
| 18 | const router = useRouter(); | 18 | readonly environmentId: string; |
| 19 | readonly threadId: string; | ||
| 20 | }>; | ||
| 21 | |||
| 22 | export function GitBranchesSheet(_props: GitBranchesSheetProps) { | ||
| 23 | const navigation = useNavigation(); | ||
| 19 | const insets = useSafeAreaInsets(); | 24 | const insets = useSafeAreaInsets(); |
| 20 | const { selectedThread } = useThreadSelection(); | 25 | const { selectedThread } = useThreadSelection(); |
| 21 | const { selectedThreadCwd, selectedThreadWorktreePath } = useSelectedThreadWorktree(); | 26 | const { selectedThreadCwd, selectedThreadWorktreePath } = useSelectedThreadWorktree(); |
| ... | @@ -96,7 +101,7 @@ export function GitBranchesSheet() { | ... | @@ -96,7 +101,7 @@ export function GitBranchesSheet() { |
| 96 | if (branch.length === 0) return; | 101 | if (branch.length === 0) return; |
| 97 | void gitActions.onCreateSelectedThreadBranch(branch).then(() => { | 102 | void gitActions.onCreateSelectedThreadBranch(branch).then(() => { |
| 98 | setNewBranchName(""); | 103 | setNewBranchName(""); |
| 99 | router.dismiss(); | 104 | navigation.goBack(); |
| 100 | }); | 105 | }); |
| 101 | }} | 106 | }} |
| 102 | /> | 107 | /> |
| ... | @@ -146,7 +151,7 @@ export function GitBranchesSheet() { | ... | @@ -146,7 +151,7 @@ export function GitBranchesSheet() { |
| 146 | if (baseBranch.length === 0 || newBranch.length === 0) return; | 151 | if (baseBranch.length === 0 || newBranch.length === 0) return; |
| 147 | void gitActions.onCreateSelectedThreadWorktree({ baseBranch, newBranch }).then(() => { | 152 | void gitActions.onCreateSelectedThreadWorktree({ baseBranch, newBranch }).then(() => { |
| 148 | setWorktreeBranchName(""); | 153 | setWorktreeBranchName(""); |
| 149 | router.dismiss(); | 154 | navigation.goBack(); |
| 150 | }); | 155 | }); |
| 151 | }} | 156 | }} |
| 152 | /> | 157 | /> |
| ... | @@ -188,7 +193,7 @@ export function GitBranchesSheet() { | ... | @@ -188,7 +193,7 @@ export function GitBranchesSheet() { |
| 188 | }} | 193 | }} |
| 189 | onPress={() => { | 194 | onPress={() => { |
| 190 | void gitActions.onCheckoutSelectedThreadBranch(branch.name).then(() => { | 195 | void gitActions.onCheckoutSelectedThreadBranch(branch.name).then(() => { |
| 191 | router.dismiss(); | 196 | navigation.goBack(); |
| 192 | }); | 197 | }); |
| 193 | }} | 198 | }} |
| 194 | > | 199 | > |
apps/mobile/src/features/threads/git/GitCommitSheet.tsx+15-10| ... | @@ -1,4 +1,4 @@ | ... | @@ -1,4 +1,4 @@ |
| 1 | import { useRouter } from "expo-router"; | 1 | import { useNavigation, type StaticScreenProps } from "@react-navigation/native"; |
| 2 | import { useCallback, useState } from "react"; | 2 | import { useCallback, useState } from "react"; |
| 3 | import { Pressable, ScrollView, View, useColorScheme } from "react-native"; | 3 | import { Pressable, ScrollView, View, useColorScheme } from "react-native"; |
| 4 | import { useSafeAreaInsets } from "react-native-safe-area-context"; | 4 | import { useSafeAreaInsets } from "react-native-safe-area-context"; |
| ... | @@ -13,8 +13,13 @@ import { useSelectedThreadWorktree } from "../../../state/use-selected-thread-wo | ... | @@ -13,8 +13,13 @@ import { useSelectedThreadWorktree } from "../../../state/use-selected-thread-wo |
| 13 | import { vcsEnvironment } from "../../../state/vcs"; | 13 | import { vcsEnvironment } from "../../../state/vcs"; |
| 14 | import { SheetActionButton } from "./gitSheetComponents"; | 14 | import { SheetActionButton } from "./gitSheetComponents"; |
| 15 | 15 | ||
| 16 | export function GitCommitSheet() { | 16 | type GitCommitSheetProps = StaticScreenProps<{ |
| 17 | const router = useRouter(); | 17 | readonly environmentId: string; |
| 18 | readonly threadId: string; | ||
| 19 | }>; | ||
| 20 | |||
| 21 | export function GitCommitSheet(_props: GitCommitSheetProps) { | ||
| 22 | const navigation = useNavigation(); | ||
| 18 | const insets = useSafeAreaInsets(); | 23 | const insets = useSafeAreaInsets(); |
| 19 | const isDarkMode = useColorScheme() === "dark"; | 24 | const isDarkMode = useColorScheme() === "dark"; |
| 20 | const { selectedThread } = useThreadSelection(); | 25 | const { selectedThread } = useThreadSelection(); |
| ... | @@ -55,7 +60,7 @@ export function GitCommitSheet() { | ... | @@ -55,7 +60,7 @@ export function GitCommitSheet() { |
| 55 | const runCommitAction = useCallback( | 60 | const runCommitAction = useCallback( |
| 56 | async (featureBranch: boolean) => { | 61 | async (featureBranch: boolean) => { |
| 57 | const commitMessage = dialogCommitMessage.trim(); | 62 | const commitMessage = dialogCommitMessage.trim(); |
| 58 | router.dismiss(); | 63 | navigation.goBack(); |
| 59 | await gitActions.onRunSelectedThreadGitAction({ | 64 | await gitActions.onRunSelectedThreadGitAction({ |
| 60 | action: "commit", | 65 | action: "commit", |
| 61 | featureBranch, | 66 | featureBranch, |
| ... | @@ -63,7 +68,7 @@ export function GitCommitSheet() { | ... | @@ -63,7 +68,7 @@ export function GitCommitSheet() { |
| 63 | ...(!allSelected ? { filePaths: selectedFiles.map((file) => file.path) } : {}), | 68 | ...(!allSelected ? { filePaths: selectedFiles.map((file) => file.path) } : {}), |
| 64 | }); | 69 | }); |
| 65 | }, | 70 | }, |
| 66 | [allSelected, dialogCommitMessage, gitActions, router, selectedFiles], | 71 | [allSelected, dialogCommitMessage, gitActions, navigation, selectedFiles], |
| 67 | ); | 72 | ); |
| 68 | 73 | ||
| 69 | return ( | 74 | return ( |
| ... | @@ -86,7 +91,7 @@ export function GitCommitSheet() { | ... | @@ -86,7 +91,7 @@ export function GitCommitSheet() { |
| 86 | </View> | 91 | </View> |
| 87 | {isDefaultRef ? ( | 92 | {isDefaultRef ? ( |
| 88 | <Text | 93 | <Text |
| 89 | className="text-xs leading-[18px]" | 94 | className="text-xs leading-normal" |
| 90 | style={{ color: isDarkMode ? "#fbbf24" : "#b45309" }} | 95 | style={{ color: isDarkMode ? "#fbbf24" : "#b45309" }} |
| 91 | > | 96 | > |
| 92 | Warning: this is the default branch. | 97 | Warning: this is the default branch. |
| ... | @@ -98,7 +103,7 @@ export function GitCommitSheet() { | ... | @@ -98,7 +103,7 @@ export function GitCommitSheet() { |
| 98 | <View className="flex-row items-center justify-between gap-3"> | 103 | <View className="flex-row items-center justify-between gap-3"> |
| 99 | <View className="gap-1"> | 104 | <View className="gap-1"> |
| 100 | <Text className="text-foreground text-base font-t3-bold">Files</Text> | 105 | <Text className="text-foreground text-base font-t3-bold">Files</Text> |
| 101 | <Text className="text-foreground-muted text-xs leading-[18px]"> | 106 | <Text className="text-foreground-muted text-xs leading-normal"> |
| 102 | {selectedFiles.length} selected · +{selectedInsertions} / -{selectedDeletions} | 107 | {selectedFiles.length} selected · +{selectedInsertions} / -{selectedDeletions} |
| 103 | </Text> | 108 | </Text> |
| 104 | </View> | 109 | </View> |
| ... | @@ -123,7 +128,7 @@ export function GitCommitSheet() { | ... | @@ -123,7 +128,7 @@ export function GitCommitSheet() { |
| 123 | </View> | 128 | </View> |
| 124 | 129 | ||
| 125 | {allFiles.length === 0 ? ( | 130 | {allFiles.length === 0 ? ( |
| 126 | <Text className="text-foreground-secondary text-sm leading-[19px]"> | 131 | <Text className="text-foreground-secondary text-sm leading-normal"> |
| 127 | No changed files are available to commit. | 132 | No changed files are available to commit. |
| 128 | </Text> | 133 | </Text> |
| 129 | ) : !isEditingFiles ? ( | 134 | ) : !isEditingFiles ? ( |
| ... | @@ -142,7 +147,7 @@ export function GitCommitSheet() { | ... | @@ -142,7 +147,7 @@ export function GitCommitSheet() { |
| 142 | </View> | 147 | </View> |
| 143 | ))} | 148 | ))} |
| 144 | {selectedFiles.length > selectedFilePreview.length ? ( | 149 | {selectedFiles.length > selectedFilePreview.length ? ( |
| 145 | <Text className="text-foreground-muted text-xs leading-[17px]"> | 150 | <Text className="text-foreground-muted text-xs leading-snug"> |
| 146 | +{selectedFiles.length - selectedFilePreview.length} more files | 151 | +{selectedFiles.length - selectedFilePreview.length} more files |
| 147 | </Text> | 152 | </Text> |
| 148 | ) : null} | 153 | ) : null} |
| ... | @@ -182,7 +187,7 @@ export function GitCommitSheet() { | ... | @@ -182,7 +187,7 @@ export function GitCommitSheet() { |
| 182 | {file.path} | 187 | {file.path} |
| 183 | </Text> | 188 | </Text> |
| 184 | {!included ? ( | 189 | {!included ? ( |
| 185 | <Text className="text-foreground-muted text-2xs leading-[16px]"> | 190 | <Text className="text-foreground-muted text-2xs leading-normal"> |
| 186 | Excluded from this commit | 191 | Excluded from this commit |
| 187 | </Text> | 192 | </Text> |
| 188 | ) : null} | 193 | ) : null} |
apps/mobile/src/features/threads/git/GitConfirmSheet.tsx+30-15| ... | @@ -2,7 +2,7 @@ import { resolveDefaultBranchActionDialogCopy } from "@t3tools/client-runtime/st | ... | @@ -2,7 +2,7 @@ import { resolveDefaultBranchActionDialogCopy } from "@t3tools/client-runtime/st |
| 2 | import { resolveAutoFeatureBranchName } from "@t3tools/shared/git"; | 2 | import { resolveAutoFeatureBranchName } from "@t3tools/shared/git"; |
| 3 | import * as Arr from "effect/Array"; | 3 | import * as Arr from "effect/Array"; |
| 4 | import * as Result from "effect/Result"; | 4 | import * as Result from "effect/Result"; |
| 5 | import { useLocalSearchParams, useRouter } from "expo-router"; | 5 | import { StackActions, useNavigation, type StaticScreenProps } from "@react-navigation/native"; |
| 6 | import { useCallback, useMemo } from "react"; | 6 | import { useCallback, useMemo } from "react"; |
| 7 | import { View } from "react-native"; | 7 | import { View } from "react-native"; |
| 8 | import { useSafeAreaInsets } from "react-native-safe-area-context"; | 8 | import { useSafeAreaInsets } from "react-native-safe-area-context"; |
| ... | @@ -12,19 +12,23 @@ import { useSelectedThreadGitActions } from "../../../state/use-selected-thread- | ... | @@ -12,19 +12,23 @@ import { useSelectedThreadGitActions } from "../../../state/use-selected-thread- |
| 12 | import { useSelectedThreadGitState } from "../../../state/use-selected-thread-git-state"; | 12 | import { useSelectedThreadGitState } from "../../../state/use-selected-thread-git-state"; |
| 13 | import { SheetActionButton } from "./gitSheetComponents"; | 13 | import { SheetActionButton } from "./gitSheetComponents"; |
| 14 | 14 | ||
| 15 | export function GitConfirmSheet() { | 15 | type GitConfirmSheetProps = StaticScreenProps<{ |
| 16 | const router = useRouter(); | 16 | readonly environmentId: string; |
| 17 | readonly threadId: string; | ||
| 18 | readonly confirmAction?: string; | ||
| 19 | readonly branchName?: string; | ||
| 20 | readonly includesCommit?: string; | ||
| 21 | readonly commitMessage?: string; | ||
| 22 | readonly filePaths?: string; | ||
| 23 | }>; | ||
| 24 | |||
| 25 | export function GitConfirmSheet(props: GitConfirmSheetProps) { | ||
| 26 | const navigation = useNavigation(); | ||
| 17 | const insets = useSafeAreaInsets(); | 27 | const insets = useSafeAreaInsets(); |
| 18 | const gitState = useSelectedThreadGitState(); | 28 | const gitState = useSelectedThreadGitState(); |
| 19 | const gitActions = useSelectedThreadGitActions(); | 29 | const gitActions = useSelectedThreadGitActions(); |
| 20 | 30 | ||
| 21 | const params = useLocalSearchParams<{ | 31 | const params = props.route.params; |
| 22 | confirmAction?: string; | ||
| 23 | branchName?: string; | ||
| 24 | includesCommit?: string; | ||
| 25 | commitMessage?: string; | ||
| 26 | filePaths?: string; | ||
| 27 | }>(); | ||
| 28 | 32 | ||
| 29 | const confirmAction = params.confirmAction as | 33 | const confirmAction = params.confirmAction as |
| 30 | | "push" | 34 | | "push" |
| ... | @@ -34,6 +38,8 @@ export function GitConfirmSheet() { | ... | @@ -34,6 +38,8 @@ export function GitConfirmSheet() { |
| 34 | | undefined; | 38 | | undefined; |
| 35 | const branchName = params.branchName ?? ""; | 39 | const branchName = params.branchName ?? ""; |
| 36 | const includesCommit = params.includesCommit === "true"; | 40 | const includesCommit = params.includesCommit === "true"; |
| 41 | const environmentId = params.environmentId ?? ""; | ||
| 42 | const threadId = params.threadId ?? ""; | ||
| 37 | 43 | ||
| 38 | const copy = useMemo( | 44 | const copy = useMemo( |
| 39 | () => | 45 | () => |
| ... | @@ -49,17 +55,17 @@ export function GitConfirmSheet() { | ... | @@ -49,17 +55,17 @@ export function GitConfirmSheet() { |
| 49 | 55 | ||
| 50 | const continuePendingAction = useCallback(async () => { | 56 | const continuePendingAction = useCallback(async () => { |
| 51 | if (!confirmAction) return; | 57 | if (!confirmAction) return; |
| 52 | router.dismissAll(); | 58 | navigation.dispatch(StackActions.replace("Thread", { environmentId, threadId })); |
| 53 | await gitActions.onRunSelectedThreadGitAction({ | 59 | await gitActions.onRunSelectedThreadGitAction({ |
| 54 | action: confirmAction, | 60 | action: confirmAction, |
| 55 | ...(params.commitMessage ? { commitMessage: params.commitMessage } : {}), | 61 | ...(params.commitMessage ? { commitMessage: params.commitMessage } : {}), |
| 56 | ...(params.filePaths ? { filePaths: params.filePaths.split(",") } : {}), | 62 | ...(params.filePaths ? { filePaths: params.filePaths.split(",") } : {}), |
| 57 | }); | 63 | }); |
| 58 | }, [confirmAction, gitActions, params, router]); | 64 | }, [confirmAction, environmentId, gitActions, params, navigation, threadId]); |
| 59 | 65 | ||
| 60 | const movePendingActionToFeatureBranch = useCallback(async () => { | 66 | const movePendingActionToFeatureBranch = useCallback(async () => { |
| 61 | if (!confirmAction) return; | 67 | if (!confirmAction) return; |
| 62 | router.dismissAll(); | 68 | navigation.dispatch(StackActions.replace("Thread", { environmentId, threadId })); |
| 63 | 69 | ||
| 64 | if (includesCommit) { | 70 | if (includesCommit) { |
| 65 | await gitActions.onRunSelectedThreadGitAction({ | 71 | await gitActions.onRunSelectedThreadGitAction({ |
| ... | @@ -82,7 +88,16 @@ export function GitConfirmSheet() { | ... | @@ -82,7 +88,16 @@ export function GitConfirmSheet() { |
| 82 | ); | 88 | ); |
| 83 | await gitActions.onCreateSelectedThreadBranch(newBranchName); | 89 | await gitActions.onCreateSelectedThreadBranch(newBranchName); |
| 84 | await gitActions.onRunSelectedThreadGitAction({ action: confirmAction }); | 90 | await gitActions.onRunSelectedThreadGitAction({ action: confirmAction }); |
| 85 | }, [confirmAction, gitActions, gitState.selectedThreadBranches, includesCommit, params, router]); | 91 | }, [ |
| 92 | confirmAction, | ||
| 93 | gitActions, | ||
| 94 | gitState.selectedThreadBranches, | ||
| 95 | includesCommit, | ||
| 96 | params, | ||
| 97 | navigation, | ||
| 98 | environmentId, | ||
| 99 | threadId, | ||
| 100 | ]); | ||
| 86 | 101 | ||
| 87 | return ( | 102 | return ( |
| 88 | <View collapsable={false} className="flex-1 bg-sheet"> | 103 | <View collapsable={false} className="flex-1 bg-sheet"> |
| ... | @@ -98,7 +113,7 @@ export function GitConfirmSheet() { | ... | @@ -98,7 +113,7 @@ export function GitConfirmSheet() { |
| 98 | <Text className="text-center text-3xl font-t3-bold"> | 113 | <Text className="text-center text-3xl font-t3-bold"> |
| 99 | {copy?.title ?? "Run action on default branch?"} | 114 | {copy?.title ?? "Run action on default branch?"} |
| 100 | </Text> | 115 | </Text> |
| 101 | <Text className="text-center text-foreground-secondary text-sm font-medium leading-[19px]"> | 116 | <Text className="text-center text-foreground-secondary text-sm font-medium leading-normal"> |
| 102 | {copy?.description ?? "Choose how to continue."} | 117 | {copy?.description ?? "Choose how to continue."} |
| 103 | </Text> | 118 | </Text> |
| 104 | </View> | 119 | </View> |
apps/mobile/src/features/threads/git/GitOverviewSheet.tsx+246-97| ... | @@ -4,17 +4,18 @@ import { | ... | @@ -4,17 +4,18 @@ import { |
| 4 | getGitActionDisabledReason, | 4 | getGitActionDisabledReason, |
| 5 | requiresDefaultBranchConfirmation, | 5 | requiresDefaultBranchConfirmation, |
| 6 | } from "@t3tools/client-runtime/state/vcs"; | 6 | } from "@t3tools/client-runtime/state/vcs"; |
| 7 | import type { EnvironmentId, ThreadId } from "@t3tools/contracts"; | 7 | import { EnvironmentId, ThreadId } from "@t3tools/contracts"; |
| 8 | import { useLocalSearchParams, useRouter } from "expo-router"; | 8 | import { useNavigation, type StaticScreenProps } from "@react-navigation/native"; |
| 9 | import { SymbolView } from "expo-symbols"; | 9 | import { SymbolView } from "expo-symbols"; |
| 10 | import { useCallback, useEffect, useMemo } from "react"; | 10 | import { useCallback, useEffect, useMemo, useState } from "react"; |
| 11 | import { Alert, Pressable, ScrollView, View } from "react-native"; | 11 | import { Alert, Platform, Pressable, RefreshControl, ScrollView, View } from "react-native"; |
| 12 | import { Screen, ScreenStack, ScreenStackHeaderConfig } from "react-native-screens"; | ||
| 12 | import { useSafeAreaInsets } from "react-native-safe-area-context"; | 13 | import { useSafeAreaInsets } from "react-native-safe-area-context"; |
| 13 | import { useThemeColor } from "../../../lib/useThemeColor"; | 14 | import { useThemeColor } from "../../../lib/useThemeColor"; |
| 14 | 15 | ||
| 15 | import { AppText as Text } from "../../../components/AppText"; | 16 | import { AppText as Text } from "../../../components/AppText"; |
| 17 | import { nativeHeaderScrollEdgeEffects } from "../../../native/StackHeader"; | ||
| 16 | import { tryOpenExternalUrl } from "../../../lib/openExternalUrl"; | 18 | import { tryOpenExternalUrl } from "../../../lib/openExternalUrl"; |
| 17 | import { buildThreadReviewRoutePath } from "../../../lib/routes"; | ||
| 18 | import { useEnvironmentQuery } from "../../../state/query"; | 19 | import { useEnvironmentQuery } from "../../../state/query"; |
| 19 | import { useThreadSelection } from "../../../state/use-thread-selection"; | 20 | import { useThreadSelection } from "../../../state/use-thread-selection"; |
| 20 | import { useSelectedThreadGitActions } from "../../../state/use-selected-thread-git-actions"; | 21 | import { useSelectedThreadGitActions } from "../../../state/use-selected-thread-git-actions"; |
| ... | @@ -23,13 +24,23 @@ import { useSelectedThreadWorktree } from "../../../state/use-selected-thread-wo | ... | @@ -23,13 +24,23 @@ import { useSelectedThreadWorktree } from "../../../state/use-selected-thread-wo |
| 23 | import { vcsEnvironment } from "../../../state/vcs"; | 24 | import { vcsEnvironment } from "../../../state/vcs"; |
| 24 | import { MetaCard, SheetListRow, menuItemIconName, statusSummary } from "./gitSheetComponents"; | 25 | import { MetaCard, SheetListRow, menuItemIconName, statusSummary } from "./gitSheetComponents"; |
| 25 | 26 | ||
| 26 | export function GitOverviewSheet() { | 27 | const HEADER_SCROLL_EDGE_EFFECTS = nativeHeaderScrollEdgeEffects(Platform.OS, Platform.Version); |
| 27 | const router = useRouter(); | 28 | |
| 29 | type GitOverviewSheetProps = StaticScreenProps<{ | ||
| 30 | readonly environmentId: string; | ||
| 31 | readonly threadId: string; | ||
| 32 | }> & { | ||
| 33 | readonly headerInset?: number; | ||
| 34 | readonly presentation?: "sheet" | "inspector"; | ||
| 35 | }; | ||
| 36 | |||
| 37 | export function GitOverviewSheet(props: GitOverviewSheetProps) { | ||
| 38 | const navigation = useNavigation(); | ||
| 28 | const insets = useSafeAreaInsets(); | 39 | const insets = useSafeAreaInsets(); |
| 29 | const { environmentId, threadId } = useLocalSearchParams<{ | 40 | const presentation = props.presentation ?? "sheet"; |
| 30 | environmentId: EnvironmentId; | 41 | const isInspector = presentation === "inspector"; |
| 31 | threadId: ThreadId; | 42 | const environmentId = EnvironmentId.make(props.route.params.environmentId); |
| 32 | }>(); | 43 | const threadId = ThreadId.make(props.route.params.threadId); |
| 33 | const { selectedThread } = useThreadSelection(); | 44 | const { selectedThread } = useThreadSelection(); |
| 34 | const { selectedThreadCwd, selectedThreadWorktreePath } = useSelectedThreadWorktree(); | 45 | const { selectedThreadCwd, selectedThreadWorktreePath } = useSelectedThreadWorktree(); |
| 35 | const gitState = useSelectedThreadGitState(); | 46 | const gitState = useSelectedThreadGitState(); |
| ... | @@ -37,6 +48,8 @@ export function GitOverviewSheet() { | ... | @@ -37,6 +48,8 @@ export function GitOverviewSheet() { |
| 37 | 48 | ||
| 38 | const iconColor = useThemeColor("--color-icon"); | 49 | const iconColor = useThemeColor("--color-icon"); |
| 39 | const borderColor = useThemeColor("--color-border"); | 50 | const borderColor = useThemeColor("--color-border"); |
| 51 | const foregroundColor = String(useThemeColor("--color-foreground")); | ||
| 52 | const sheetColor = String(useThemeColor("--color-sheet")); | ||
| 40 | 53 | ||
| 41 | const gitStatus = useEnvironmentQuery( | 54 | const gitStatus = useEnvironmentQuery( |
| 42 | selectedThread !== null && selectedThreadCwd !== null | 55 | selectedThread !== null && selectedThreadCwd !== null |
| ... | @@ -48,6 +61,7 @@ export function GitOverviewSheet() { | ... | @@ -48,6 +61,7 @@ export function GitOverviewSheet() { |
| 48 | ); | 61 | ); |
| 49 | 62 | ||
| 50 | const currentBranchLabel = gitStatus.data?.refName ?? selectedThread?.branch ?? "Detached HEAD"; | 63 | const currentBranchLabel = gitStatus.data?.refName ?? selectedThread?.branch ?? "Detached HEAD"; |
| 64 | const currentStatusSummary = statusSummary(gitStatus.data); | ||
| 51 | const currentWorktreePath = selectedThreadWorktreePath; | 65 | const currentWorktreePath = selectedThreadWorktreePath; |
| 52 | const gitOperationLabel = gitState.gitOperationLabel; | 66 | const gitOperationLabel = gitState.gitOperationLabel; |
| 53 | const busy = gitOperationLabel !== null; | 67 | const busy = gitOperationLabel !== null; |
| ... | @@ -105,25 +119,24 @@ export function GitOverviewSheet() { | ... | @@ -105,25 +119,24 @@ export function GitOverviewSheet() { |
| 105 | !input.featureBranch && | 119 | !input.featureBranch && |
| 106 | requiresDefaultBranchConfirmation(input.action, isDefaultRef) | 120 | requiresDefaultBranchConfirmation(input.action, isDefaultRef) |
| 107 | ) { | 121 | ) { |
| 108 | router.push({ | 122 | navigation.navigate("GitConfirm", { |
| 109 | pathname: "/threads/[environmentId]/[threadId]/git-confirm", | 123 | environmentId: String(environmentId), |
| 110 | params: { | 124 | threadId: String(threadId), |
| 111 | environmentId, | 125 | confirmAction: confirmableAction, |
| 112 | threadId, | 126 | branchName, |
| 113 | confirmAction: confirmableAction, | 127 | includesCommit: String( |
| 114 | branchName, | 128 | input.action === "commit_push" || input.action === "commit_push_pr", |
| 115 | includesCommit: String( | 129 | ), |
| 116 | input.action === "commit_push" || input.action === "commit_push_pr", | ||
| 117 | ), | ||
| 118 | }, | ||
| 119 | }); | 130 | }); |
| 120 | return; | 131 | return; |
| 121 | } | 132 | } |
| 122 | 133 | ||
| 123 | router.dismiss(); | 134 | if (!isInspector) { |
| 135 | navigation.goBack(); | ||
| 136 | } | ||
| 124 | await gitActions.onRunSelectedThreadGitAction(input); | 137 | await gitActions.onRunSelectedThreadGitAction(input); |
| 125 | }, | 138 | }, |
| 126 | [environmentId, gitActions, gitStatus.data, isDefaultRef, router, threadId], | 139 | [environmentId, gitActions, gitStatus.data, isDefaultRef, isInspector, navigation, threadId], |
| 127 | ); | 140 | ); |
| 128 | 141 | ||
| 129 | const onPressMenuItem = useCallback( | 142 | const onPressMenuItem = useCallback( |
| ... | @@ -134,9 +147,9 @@ export function GitOverviewSheet() { | ... | @@ -134,9 +147,9 @@ export function GitOverviewSheet() { |
| 134 | return; | 147 | return; |
| 135 | } | 148 | } |
| 136 | if (item.dialogAction === "commit") { | 149 | if (item.dialogAction === "commit") { |
| 137 | router.push({ | 150 | navigation.navigate("GitCommit", { |
| 138 | pathname: "/threads/[environmentId]/[threadId]/git/commit", | 151 | environmentId: String(environmentId), |
| 139 | params: { environmentId, threadId }, | 152 | threadId: String(threadId), |
| 140 | }); | 153 | }); |
| 141 | return; | 154 | return; |
| 142 | } | 155 | } |
| ... | @@ -148,14 +161,209 @@ export function GitOverviewSheet() { | ... | @@ -148,14 +161,209 @@ export function GitOverviewSheet() { |
| 148 | await runActionWithPrompt({ action: "create_pr" }); | 161 | await runActionWithPrompt({ action: "create_pr" }); |
| 149 | } | 162 | } |
| 150 | }, | 163 | }, |
| 151 | [environmentId, openExistingPr, router, runActionWithPrompt, threadId], | 164 | [environmentId, openExistingPr, navigation, runActionWithPrompt, threadId], |
| 165 | ); | ||
| 166 | |||
| 167 | // Status facts live on the relevant rows instead of crowding the header | ||
| 168 | // subtitle: files changed → Commit, ahead → Push, PR → View PR, behind → Pull. | ||
| 169 | const rowStatusDetail = useCallback( | ||
| 170 | (item: (typeof menuItems)[number]): string | undefined => { | ||
| 171 | const status = gitStatus.data; | ||
| 172 | if (status == null) { | ||
| 173 | return undefined; | ||
| 174 | } | ||
| 175 | if (item.dialogAction === "commit" && status.hasWorkingTreeChanges) { | ||
| 176 | const fileCount = status.workingTree?.files.length ?? 0; | ||
| 177 | return `${fileCount} file${fileCount === 1 ? "" : "s"} changed`; | ||
| 178 | } | ||
| 179 | if (item.dialogAction === "push" && (status.aheadCount ?? 0) > 0) { | ||
| 180 | const ahead = status.aheadCount ?? 0; | ||
| 181 | return `${ahead} commit${ahead === 1 ? "" : "s"} ahead`; | ||
| 182 | } | ||
| 183 | if (item.kind === "open_pr" && status.pr?.number != null) { | ||
| 184 | return `PR #${status.pr.number} ${status.pr.state ?? "open"}`; | ||
| 185 | } | ||
| 186 | return undefined; | ||
| 187 | }, | ||
| 188 | [gitStatus.data, menuItems], | ||
| 189 | ); | ||
| 190 | |||
| 191 | const behindCount = gitStatus.data?.behindCount ?? 0; | ||
| 192 | |||
| 193 | // Deterministic pull-to-refresh state. Tying RefreshControl to the query's | ||
| 194 | // isPending flag left the spinner stuck (the status query reports pending | ||
| 195 | // during quiet background refreshes too). | ||
| 196 | const [isPullRefreshing, setIsPullRefreshing] = useState(false); | ||
| 197 | const handlePullRefresh = useCallback(async () => { | ||
| 198 | setIsPullRefreshing(true); | ||
| 199 | try { | ||
| 200 | await gitActions.refreshSelectedThreadGitStatus(); | ||
| 201 | } finally { | ||
| 202 | setIsPullRefreshing(false); | ||
| 203 | } | ||
| 204 | }, [gitActions]); | ||
| 205 | |||
| 206 | const content = ( | ||
| 207 | <ScrollView | ||
| 208 | contentInsetAdjustmentBehavior={Platform.OS === "ios" ? "automatic" : "never"} | ||
| 209 | showsVerticalScrollIndicator={false} | ||
| 210 | style={{ flex: 1, backgroundColor: sheetColor }} | ||
| 211 | contentInset={{ bottom: Math.max(insets.bottom, 18) + 18 }} | ||
| 212 | contentContainerStyle={{ | ||
| 213 | paddingHorizontal: isInspector ? 12 : 20, | ||
| 214 | paddingTop: 8, | ||
| 215 | gap: 14, | ||
| 216 | }} | ||
| 217 | refreshControl={ | ||
| 218 | <RefreshControl refreshing={isPullRefreshing} onRefresh={() => void handlePullRefresh()} /> | ||
| 219 | } | ||
| 220 | > | ||
| 221 | <View | ||
| 222 | className={ | ||
| 223 | isInspector | ||
| 224 | ? "overflow-hidden rounded-2xl border border-border bg-card px-3 py-1" | ||
| 225 | : "overflow-hidden rounded-[22px] border border-border bg-card px-4 py-1" | ||
| 226 | } | ||
| 227 | > | ||
| 228 | {sheetMenuItems.map(({ item, disabledReason }, index) => ( | ||
| 229 | <View key={`${item.id}-${item.label}`}> | ||
| 230 | {index > 0 ? ( | ||
| 231 | <View className="ml-12 h-px" style={{ backgroundColor: borderColor }} /> | ||
| 232 | ) : null} | ||
| 233 | <SheetListRow | ||
| 234 | icon={menuItemIconName(item.icon)} | ||
| 235 | title={item.label} | ||
| 236 | subtitle={disabledReason ?? rowStatusDetail(item)} | ||
| 237 | disabled={item.disabled} | ||
| 238 | onPress={() => void onPressMenuItem(item)} | ||
| 239 | /> | ||
| 240 | </View> | ||
| 241 | ))} | ||
| 242 | {behindCount > 0 ? ( | ||
| 243 | <> | ||
| 244 | <View className="ml-12 h-px" style={{ backgroundColor: borderColor }} /> | ||
| 245 | <SheetListRow | ||
| 246 | icon="arrow.down.circle" | ||
| 247 | title="Pull latest" | ||
| 248 | subtitle={`${behindCount} commit${behindCount === 1 ? "" : "s"} behind upstream`} | ||
| 249 | disabled={busy || !isRepo} | ||
| 250 | onPress={() => void gitActions.onPullSelectedThreadBranch()} | ||
| 251 | /> | ||
| 252 | </> | ||
| 253 | ) : null} | ||
| 254 | <View className="ml-12 h-px" style={{ backgroundColor: borderColor }} /> | ||
| 255 | <SheetListRow | ||
| 256 | icon="text.bubble" | ||
| 257 | title="Review changes" | ||
| 258 | subtitle="Inspect turn diffs, worktree changes, and base branch diff" | ||
| 259 | disabled={busy || !isRepo} | ||
| 260 | onPress={() => navigation.navigate("ThreadReview", { environmentId, threadId })} | ||
| 261 | /> | ||
| 262 | <View className="ml-12 h-px" style={{ backgroundColor: borderColor }} /> | ||
| 263 | <SheetListRow | ||
| 264 | icon="point.topleft.down.curvedto.point.bottomright.up" | ||
| 265 | title="Branches & worktrees" | ||
| 266 | subtitle="Switch branch, create branch, or move to a worktree" | ||
| 267 | disabled={busy || !isRepo} | ||
| 268 | onPress={() => | ||
| 269 | navigation.navigate("GitBranches", { | ||
| 270 | environmentId: String(environmentId), | ||
| 271 | threadId: String(threadId), | ||
| 272 | }) | ||
| 273 | } | ||
| 274 | /> | ||
| 275 | </View> | ||
| 276 | |||
| 277 | {currentWorktreePath ? <MetaCard label="Worktree" value={currentWorktreePath} /> : null} | ||
| 278 | </ScrollView> | ||
| 152 | ); | 279 | ); |
| 153 | 280 | ||
| 281 | if (isInspector && Platform.OS === "ios") { | ||
| 282 | return ( | ||
| 283 | <View collapsable={false} className="flex-1 border-l border-border bg-sheet"> | ||
| 284 | <ScreenStack style={{ flex: 1 }}> | ||
| 285 | <Screen | ||
| 286 | activityState={2} | ||
| 287 | enabled | ||
| 288 | isNativeStack | ||
| 289 | screenId="thread-git-inspector-native" | ||
| 290 | scrollEdgeEffects={HEADER_SCROLL_EDGE_EFFECTS} | ||
| 291 | style={{ backgroundColor: sheetColor, flex: 1 }} | ||
| 292 | > | ||
| 293 | {content} | ||
| 294 | <ScreenStackHeaderConfig | ||
| 295 | backgroundColor="rgba(0,0,0,0)" | ||
| 296 | color={foregroundColor} | ||
| 297 | hideBackButton | ||
| 298 | hideShadow={false} | ||
| 299 | navigationItemStyle="editor" | ||
| 300 | title={currentBranchLabel} | ||
| 301 | titleColor={foregroundColor} | ||
| 302 | titleFontSize={17} | ||
| 303 | titleFontWeight="700" | ||
| 304 | translucent | ||
| 305 | /> | ||
| 306 | </Screen> | ||
| 307 | </ScreenStack> | ||
| 308 | </View> | ||
| 309 | ); | ||
| 310 | } | ||
| 311 | |||
| 312 | if (Platform.OS === "ios") { | ||
| 313 | // Compact form sheet: a plain screen presented as formSheet never renders a | ||
| 314 | // stack header, so — like the Settings sheet — the header must come from a | ||
| 315 | // nested native stack INSIDE the sheet. This reuses the exact structure of the | ||
| 316 | // inspector branch below: branch as the title, status summary as the native | ||
| 317 | // subtitle, refresh as a header button. | ||
| 318 | return ( | ||
| 319 | <View collapsable={false} className="flex-1 bg-sheet"> | ||
| 320 | <ScreenStack style={{ flex: 1 }}> | ||
| 321 | <Screen | ||
| 322 | activityState={2} | ||
| 323 | enabled | ||
| 324 | isNativeStack | ||
| 325 | screenId="thread-git-sheet-native" | ||
| 326 | scrollEdgeEffects={HEADER_SCROLL_EDGE_EFFECTS} | ||
| 327 | style={{ backgroundColor: sheetColor, flex: 1 }} | ||
| 328 | > | ||
| 329 | {content} | ||
| 330 | <ScreenStackHeaderConfig | ||
| 331 | backgroundColor="rgba(0,0,0,0)" | ||
| 332 | color={foregroundColor} | ||
| 333 | hideBackButton | ||
| 334 | hideShadow={false} | ||
| 335 | navigationItemStyle="editor" | ||
| 336 | title={currentBranchLabel} | ||
| 337 | titleColor={foregroundColor} | ||
| 338 | titleFontSize={18} | ||
| 339 | titleFontWeight="800" | ||
| 340 | translucent | ||
| 341 | /> | ||
| 342 | </Screen> | ||
| 343 | </ScreenStack> | ||
| 344 | </View> | ||
| 345 | ); | ||
| 346 | } | ||
| 347 | |||
| 154 | return ( | 348 | return ( |
| 155 | <View collapsable={false} className="flex-1 bg-sheet"> | 349 | <View |
| 156 | <View style={{ minHeight: 16, paddingTop: 8 }} /> | 350 | collapsable={false} |
| 351 | className={isInspector ? "flex-1 border-l border-border bg-sheet" : "flex-1 bg-sheet"} | ||
| 352 | > | ||
| 353 | <View | ||
| 354 | style={{ | ||
| 355 | minHeight: isInspector ? (props.headerInset ?? 0) : 16, | ||
| 356 | paddingTop: isInspector ? (props.headerInset ?? 0) : 8, | ||
| 357 | }} | ||
| 358 | /> | ||
| 157 | 359 | ||
| 158 | <View className="items-center gap-1 px-5 pb-3 pt-4"> | 360 | <View |
| 361 | className={ | ||
| 362 | isInspector | ||
| 363 | ? "gap-1 border-b border-border px-4 pb-4 pt-3" | ||
| 364 | : "items-center gap-1 px-5 pb-3 pt-4" | ||
| 365 | } | ||
| 366 | > | ||
| 159 | <Pressable | 367 | <Pressable |
| 160 | className="absolute right-3 top-4 h-9 w-9 items-center justify-center rounded-full bg-subtle" | 368 | className="absolute right-3 top-4 h-9 w-9 items-center justify-center rounded-full bg-subtle" |
| 161 | style={{ zIndex: 1, opacity: busy ? 0.45 : 1 }} | 369 | style={{ zIndex: 1, opacity: busy ? 0.45 : 1 }} |
| ... | @@ -174,76 +382,17 @@ export function GitOverviewSheet() { | ... | @@ -174,76 +382,17 @@ export function GitOverviewSheet() { |
| 174 | className="text-xs font-t3-bold uppercase text-foreground-muted" | 382 | className="text-xs font-t3-bold uppercase text-foreground-muted" |
| 175 | style={{ letterSpacing: 1 }} | 383 | style={{ letterSpacing: 1 }} |
| 176 | > | 384 | > |
| 177 | Branch | 385 | {isInspector ? "Repository" : "Branch"} |
| 386 | </Text> | ||
| 387 | <Text className={isInspector ? "pr-10 text-xl font-t3-bold" : "text-3xl font-t3-bold"}> | ||
| 388 | {currentBranchLabel} | ||
| 178 | </Text> | 389 | </Text> |
| 179 | <Text className="text-3xl font-t3-bold">{currentBranchLabel}</Text> | 390 | <Text className="text-foreground-secondary text-sm font-medium leading-normal"> |
| 180 | <Text className="text-foreground-secondary text-sm font-medium leading-[19px]"> | 391 | {currentStatusSummary} |
| 181 | {statusSummary(gitStatus.data)} | ||
| 182 | </Text> | 392 | </Text> |
| 183 | </View> | 393 | </View> |
| 184 | 394 | ||
| 185 | <ScrollView | 395 | {content} |
| 186 | showsVerticalScrollIndicator={false} | ||
| 187 | style={{ flex: 1 }} | ||
| 188 | contentInset={{ bottom: Math.max(insets.bottom, 18) + 18 }} | ||
| 189 | contentContainerStyle={{ | ||
| 190 | paddingHorizontal: 20, | ||
| 191 | paddingTop: 8, | ||
| 192 | gap: 14, | ||
| 193 | }} | ||
| 194 | > | ||
| 195 | <View className="overflow-hidden rounded-[22px] border border-border bg-card px-4 py-1"> | ||
| 196 | {sheetMenuItems.map(({ item, disabledReason }, index) => ( | ||
| 197 | <View key={`${item.id}-${item.label}`}> | ||
| 198 | {index > 0 ? ( | ||
| 199 | <View className="ml-12 h-px" style={{ backgroundColor: borderColor }} /> | ||
| 200 | ) : null} | ||
| 201 | <SheetListRow | ||
| 202 | icon={menuItemIconName(item.icon)} | ||
| 203 | title={item.label} | ||
| 204 | subtitle={disabledReason} | ||
| 205 | disabled={item.disabled} | ||
| 206 | onPress={() => void onPressMenuItem(item)} | ||
| 207 | /> | ||
| 208 | </View> | ||
| 209 | ))} | ||
| 210 | {(gitStatus.data?.behindCount ?? 0) > 0 ? ( | ||
| 211 | <> | ||
| 212 | <View className="ml-12 h-px" style={{ backgroundColor: borderColor }} /> | ||
| 213 | <SheetListRow | ||
| 214 | icon="arrow.down.circle" | ||
| 215 | title="Pull latest" | ||
| 216 | subtitle="Sync this branch with upstream" | ||
| 217 | disabled={busy || !isRepo} | ||
| 218 | onPress={() => void gitActions.onPullSelectedThreadBranch()} | ||
| 219 | /> | ||
| 220 | </> | ||
| 221 | ) : null} | ||
| 222 | <View className="ml-12 h-px" style={{ backgroundColor: borderColor }} /> | ||
| 223 | <SheetListRow | ||
| 224 | icon="text.bubble" | ||
| 225 | title="Review changes" | ||
| 226 | subtitle="Inspect turn diffs, worktree changes, and base branch diff" | ||
| 227 | disabled={busy || !isRepo} | ||
| 228 | onPress={() => router.push(buildThreadReviewRoutePath({ environmentId, threadId }))} | ||
| 229 | /> | ||
| 230 | <View className="ml-12 h-px" style={{ backgroundColor: borderColor }} /> | ||
| 231 | <SheetListRow | ||
| 232 | icon="point.topleft.down.curvedto.point.bottomright.up" | ||
| 233 | title="Branches & worktrees" | ||
| 234 | subtitle="Switch branch, create branch, or move to a worktree" | ||
| 235 | disabled={busy || !isRepo} | ||
| 236 | onPress={() => | ||
| 237 | router.push({ | ||
| 238 | pathname: "/threads/[environmentId]/[threadId]/git/branches", | ||
| 239 | params: { environmentId, threadId }, | ||
| 240 | }) | ||
| 241 | } | ||
| 242 | /> | ||
| 243 | </View> | ||
| 244 | |||
| 245 | {currentWorktreePath ? <MetaCard label="Worktree" value={currentWorktreePath} /> : null} | ||
| 246 | </ScrollView> | ||
| 247 | </View> | 396 | </View> |
| 248 | ); | 397 | ); |
| 249 | } | 398 | } |
apps/mobile/src/features/threads/git/gitSheetComponents.tsx+1-1| ... | @@ -104,7 +104,7 @@ export function SheetListRow(props: { | ... | @@ -104,7 +104,7 @@ export function SheetListRow(props: { |
| 104 | <View className="flex-1 gap-0.5"> | 104 | <View className="flex-1 gap-0.5"> |
| 105 | <Text className="text-foreground text-base font-t3-bold">{props.title}</Text> | 105 | <Text className="text-foreground text-base font-t3-bold">{props.title}</Text> |
| 106 | {props.subtitle ? ( | 106 | {props.subtitle ? ( |
| 107 | <Text className="text-foreground-muted text-xs leading-[17px]">{props.subtitle}</Text> | 107 | <Text className="text-foreground-muted text-xs leading-snug">{props.subtitle}</Text> |
| 108 | ) : null} | 108 | ) : null} |
| 109 | </View> | 109 | </View> |
| 110 | <SymbolView name="chevron.right" size={13} tintColor={iconSubtleColor} type="monochrome" /> | 110 | <SymbolView name="chevron.right" size={13} tintColor={iconSubtleColor} type="monochrome" /> |
apps/mobile/src/features/threads/sidebar-filter-button.tsx created+55| ... | @@ -0,0 +1,55 @@ | ||
| 1 | import { SymbolView } from "expo-symbols"; | ||
| 2 | import { Pressable, StyleSheet, useColorScheme } from "react-native"; | ||
| 3 | |||
| 4 | import { useThemeColor } from "../../lib/useThemeColor"; | ||
| 5 | |||
| 6 | export type SidebarFilterButtonIcon = | ||
| 7 | | "line.3.horizontal.decrease.circle" | ||
| 8 | | "line.3.horizontal.decrease.circle.fill"; | ||
| 9 | |||
| 10 | export function SidebarFilterButton(props: { | ||
| 11 | readonly accessibilityLabel: string; | ||
| 12 | readonly icon: SidebarFilterButtonIcon; | ||
| 13 | /** Rendered inside a shared capsule group — no own background/border. */ | ||
| 14 | readonly grouped?: boolean; | ||
| 15 | }) { | ||
| 16 | const iconColor = useThemeColor("--color-foreground"); | ||
| 17 | const pressedBackgroundColor = useThemeColor("--color-subtle"); | ||
| 18 | const colorScheme = useColorScheme() === "dark" ? "dark" : "light"; | ||
| 19 | const idleBackgroundColor = | ||
| 20 | colorScheme === "dark" ? "rgba(118,118,128,0.24)" : "rgba(255,255,255,0.72)"; | ||
| 21 | const borderColor = colorScheme === "dark" ? "rgba(255,255,255,0.08)" : "rgba(0,0,0,0.08)"; | ||
| 22 | |||
| 23 | return ( | ||
| 24 | <Pressable | ||
| 25 | accessibilityLabel={props.accessibilityLabel} | ||
| 26 | accessibilityRole="button" | ||
| 27 | hitSlop={4} | ||
| 28 | style={({ pressed }) => [ | ||
| 29 | styles.button, | ||
| 30 | props.grouped | ||
| 31 | ? { backgroundColor: pressed ? pressedBackgroundColor : "transparent", borderWidth: 0 } | ||
| 32 | : { | ||
| 33 | backgroundColor: pressed ? pressedBackgroundColor : idleBackgroundColor, | ||
| 34 | borderColor, | ||
| 35 | }, | ||
| 36 | ]} | ||
| 37 | > | ||
| 38 | <SymbolView name={props.icon} size={20} tintColor={iconColor} type="monochrome" /> | ||
| 39 | </Pressable> | ||
| 40 | ); | ||
| 41 | } | ||
| 42 | |||
| 43 | const styles = StyleSheet.create({ | ||
| 44 | button: { | ||
| 45 | // Match the native glass UIBarButtonItem group metrics (~50pt slots, | ||
| 46 | // 44pt bar height, label-colored ~20pt glyphs). | ||
| 47 | width: 50, | ||
| 48 | height: 44, | ||
| 49 | borderRadius: 22, | ||
| 50 | borderWidth: StyleSheet.hairlineWidth, | ||
| 51 | alignItems: "center", | ||
| 52 | justifyContent: "center", | ||
| 53 | cursor: "pointer", | ||
| 54 | }, | ||
| 55 | }); | ||
apps/mobile/src/features/threads/sidebar-header-actions.tsx created+74| ... | @@ -0,0 +1,74 @@ | ||
| 1 | import { SymbolView } from "expo-symbols"; | ||
| 2 | import { Pressable, StyleSheet, View, useColorScheme } from "react-native"; | ||
| 3 | |||
| 4 | import { useThemeColor } from "../../lib/useThemeColor"; | ||
| 5 | |||
| 6 | export interface SidebarHeaderActionsProps { | ||
| 7 | readonly onOpenSettings: () => void; | ||
| 8 | /** Rendered inside a shared capsule group — buttons drop their own chrome. */ | ||
| 9 | readonly grouped?: boolean; | ||
| 10 | } | ||
| 11 | |||
| 12 | function FallbackHeaderButton(props: { | ||
| 13 | readonly accessibilityLabel: string; | ||
| 14 | readonly icon: "gearshape" | "square.and.pencil"; | ||
| 15 | readonly grouped?: boolean; | ||
| 16 | readonly onPress: () => void; | ||
| 17 | }) { | ||
| 18 | const iconColor = useThemeColor("--color-foreground"); | ||
| 19 | const pressedBackgroundColor = useThemeColor("--color-subtle"); | ||
| 20 | const colorScheme = useColorScheme() === "dark" ? "dark" : "light"; | ||
| 21 | const idleBackgroundColor = | ||
| 22 | colorScheme === "dark" ? "rgba(118,118,128,0.24)" : "rgba(255,255,255,0.72)"; | ||
| 23 | const borderColor = colorScheme === "dark" ? "rgba(255,255,255,0.08)" : "rgba(0,0,0,0.08)"; | ||
| 24 | |||
| 25 | return ( | ||
| 26 | <Pressable | ||
| 27 | accessibilityLabel={props.accessibilityLabel} | ||
| 28 | accessibilityRole="button" | ||
| 29 | hitSlop={4} | ||
| 30 | onPress={props.onPress} | ||
| 31 | style={({ pressed }) => [ | ||
| 32 | styles.button, | ||
| 33 | props.grouped | ||
| 34 | ? { backgroundColor: pressed ? pressedBackgroundColor : "transparent", borderWidth: 0 } | ||
| 35 | : { | ||
| 36 | backgroundColor: pressed ? pressedBackgroundColor : idleBackgroundColor, | ||
| 37 | borderColor, | ||
| 38 | }, | ||
| 39 | ]} | ||
| 40 | > | ||
| 41 | <SymbolView name={props.icon} size={20} tintColor={iconColor} type="monochrome" /> | ||
| 42 | </Pressable> | ||
| 43 | ); | ||
| 44 | } | ||
| 45 | |||
| 46 | export function SidebarHeaderActions(props: SidebarHeaderActionsProps) { | ||
| 47 | return ( | ||
| 48 | <View style={styles.actions}> | ||
| 49 | <FallbackHeaderButton | ||
| 50 | accessibilityLabel="Open settings" | ||
| 51 | grouped={props.grouped} | ||
| 52 | icon="gearshape" | ||
| 53 | onPress={props.onOpenSettings} | ||
| 54 | /> | ||
| 55 | </View> | ||
| 56 | ); | ||
| 57 | } | ||
| 58 | |||
| 59 | const styles = StyleSheet.create({ | ||
| 60 | actions: { | ||
| 61 | flexDirection: "row", | ||
| 62 | alignItems: "center", | ||
| 63 | gap: 2, | ||
| 64 | }, | ||
| 65 | button: { | ||
| 66 | // Match the native glass UIBarButtonItem group metrics. | ||
| 67 | width: 50, | ||
| 68 | height: 44, | ||
| 69 | borderRadius: 22, | ||
| 70 | borderWidth: StyleSheet.hairlineWidth, | ||
| 71 | alignItems: "center", | ||
| 72 | justifyContent: "center", | ||
| 73 | }, | ||
| 74 | }); | ||
apps/mobile/src/features/threads/sidebar-native-header-items.ts created+63| ... | @@ -0,0 +1,63 @@ | ||
| 1 | import type { | ||
| 2 | NativeStackHeaderItem, | ||
| 3 | NativeStackHeaderItemMenu, | ||
| 4 | } from "@react-navigation/native-stack"; | ||
| 5 | |||
| 6 | import type { HomeListFilterMenu } from "../home/home-list-filter-menu"; | ||
| 7 | import { withNativeGlassHeaderItem } from "../layout/native-glass-header-items"; | ||
| 8 | |||
| 9 | type NativeHeaderMenuItems = NativeStackHeaderItemMenu["menu"]["items"]; | ||
| 10 | type NativeHeaderIcon = NonNullable<Extract<NativeStackHeaderItem, { type: "button" }>["icon"]>; | ||
| 11 | |||
| 12 | function sfSymbolIcon(name: string): NativeHeaderIcon { | ||
| 13 | return { type: "sfSymbol", name: name as never }; | ||
| 14 | } | ||
| 15 | |||
| 16 | function toNativeHeaderMenuItems(items: HomeListFilterMenu["items"]): NativeHeaderMenuItems { | ||
| 17 | return items.map((item) => | ||
| 18 | item.type === "action" | ||
| 19 | ? { | ||
| 20 | type: "action" as const, | ||
| 21 | label: item.title, | ||
| 22 | description: item.subtitle, | ||
| 23 | onPress: item.onPress, | ||
| 24 | state: item.state === "on" ? ("on" as const) : undefined, | ||
| 25 | } | ||
| 26 | : { | ||
| 27 | type: "submenu" as const, | ||
| 28 | label: item.title, | ||
| 29 | items: toNativeHeaderMenuItems(item.items), | ||
| 30 | }, | ||
| 31 | ); | ||
| 32 | } | ||
| 33 | |||
| 34 | /** | ||
| 35 | * Right-side UINavigationBar items for the sidebar column: the thread list | ||
| 36 | * filter/sort menu plus the settings button, sharing one glass capsule — | ||
| 37 | * the Messages-style grouped header buttons. | ||
| 38 | */ | ||
| 39 | export function createSidebarHeaderItems(input: { | ||
| 40 | readonly filterIcon: string; | ||
| 41 | readonly filterMenu: HomeListFilterMenu; | ||
| 42 | readonly onOpenSettings: () => void; | ||
| 43 | }): NativeStackHeaderItem[] { | ||
| 44 | return [ | ||
| 45 | withNativeGlassHeaderItem({ | ||
| 46 | type: "menu", | ||
| 47 | label: "", | ||
| 48 | accessibilityLabel: "Filter and sort threads", | ||
| 49 | icon: sfSymbolIcon(input.filterIcon), | ||
| 50 | menu: { | ||
| 51 | title: input.filterMenu.title, | ||
| 52 | items: toNativeHeaderMenuItems(input.filterMenu.items), | ||
| 53 | }, | ||
| 54 | }), | ||
| 55 | withNativeGlassHeaderItem({ | ||
| 56 | type: "button", | ||
| 57 | label: "", | ||
| 58 | accessibilityLabel: "Open settings", | ||
| 59 | icon: sfSymbolIcon("gearshape"), | ||
| 60 | onPress: input.onOpenSettings, | ||
| 61 | }), | ||
| 62 | ]; | ||
| 63 | } | ||
apps/mobile/src/features/threads/sidebar-navigation-shell.tsx created+72| ... | @@ -0,0 +1,72 @@ | ||
| 1 | import { | ||
| 2 | DarkTheme, | ||
| 3 | DefaultTheme, | ||
| 4 | NavigationContainer, | ||
| 5 | NavigationIndependentTree, | ||
| 6 | } from "@react-navigation/native"; | ||
| 7 | import { | ||
| 8 | createNativeStackNavigator, | ||
| 9 | type NativeStackNavigationOptions, | ||
| 10 | } from "@react-navigation/native-stack"; | ||
| 11 | import type { ReactNode } from "react"; | ||
| 12 | import { Platform, useColorScheme } from "react-native"; | ||
| 13 | |||
| 14 | import { nativeHeaderScrollEdgeEffects } from "../../native/StackHeader"; | ||
| 15 | |||
| 16 | const SCROLL_EDGE_EFFECTS = nativeHeaderScrollEdgeEffects(Platform.OS, Platform.Version); | ||
| 17 | |||
| 18 | type SidebarScreenOptions = NativeStackNavigationOptions & { | ||
| 19 | // Same patched RNS option the GLASS/SOLID presets in Stack.tsx use — the | ||
| 20 | // iOS 26 "editor" navigation-item style leading-aligns the inline title. | ||
| 21 | readonly unstable_navigationItemStyle?: "editor"; | ||
| 22 | }; | ||
| 23 | |||
| 24 | /** | ||
| 25 | * Static chrome for the sidebar column: a real UINavigationBar with a fixed | ||
| 26 | * inline title (no large title — saves vertical space, left-aligned via the | ||
| 27 | * editor item style) and the search bar pinned below it, scroll-edge blur | ||
| 28 | * sampling the list. Only genuinely dynamic values (search callbacks, header | ||
| 29 | * items) are set by the screen content via NativeStackScreenOptions. | ||
| 30 | */ | ||
| 31 | const SIDEBAR_SCREEN_OPTIONS: SidebarScreenOptions = { | ||
| 32 | contentStyle: { backgroundColor: "transparent" }, | ||
| 33 | headerLargeTitle: false, | ||
| 34 | headerShadowVisible: false, | ||
| 35 | headerShown: true, | ||
| 36 | headerStyle: { backgroundColor: "transparent" }, | ||
| 37 | headerTitleStyle: { fontSize: 18, fontWeight: "800" }, | ||
| 38 | headerTransparent: true, | ||
| 39 | scrollEdgeEffects: SCROLL_EDGE_EFFECTS, | ||
| 40 | title: "Threads", | ||
| 41 | unstable_navigationItemStyle: "editor", | ||
| 42 | }; | ||
| 43 | |||
| 44 | const SidebarStack = createNativeStackNavigator(); | ||
| 45 | |||
| 46 | /** | ||
| 47 | * Hosts the iPad sidebar pane inside its own single-screen native stack. | ||
| 48 | * | ||
| 49 | * The stack is navigation-inert — nothing is ever pushed onto it. It exists so | ||
| 50 | * the sidebar column owns a real UINavigationBar (large title, native bar | ||
| 51 | * button items, UISearchController), mirroring how each column of a | ||
| 52 | * UISplitViewController has its own UINavigationController. All real | ||
| 53 | * navigation still flows through the root stack via callbacks minted in | ||
| 54 | * AdaptiveWorkspaceLayout; NavigationIndependentTree only isolates the | ||
| 55 | * navigation hooks used for header configuration inside the pane. | ||
| 56 | */ | ||
| 57 | export function SidebarNavigationShell(props: { readonly children: ReactNode }) { | ||
| 58 | const colorScheme = useColorScheme(); | ||
| 59 | |||
| 60 | return ( | ||
| 61 | <NavigationIndependentTree> | ||
| 62 | <NavigationContainer theme={colorScheme === "dark" ? DarkTheme : DefaultTheme}> | ||
| 63 | <SidebarStack.Navigator | ||
| 64 | screenOptions={SIDEBAR_SCREEN_OPTIONS} | ||
| 65 | initialRouteName="SidebarThreads" | ||
| 66 | > | ||
| 67 | <SidebarStack.Screen name="SidebarThreads">{() => props.children}</SidebarStack.Screen> | ||
| 68 | </SidebarStack.Navigator> | ||
| 69 | </NavigationContainer> | ||
| 70 | </NavigationIndependentTree> | ||
| 71 | ); | ||
| 72 | } | ||
apps/mobile/src/features/threads/thread-inspector-content-stack.tsx created+101| ... | @@ -0,0 +1,101 @@ | ||
| 1 | import { useEffect, useState, type ComponentType, type ReactNode } from "react"; | ||
| 2 | import { View } from "react-native"; | ||
| 3 | |||
| 4 | export type ThreadInspectorMode = "route" | "git" | "files"; | ||
| 5 | |||
| 6 | const INSPECTOR_PREWARM_DELAY_MS = 350; | ||
| 7 | |||
| 8 | function InspectorContentPane(props: { | ||
| 9 | readonly children: ReactNode; | ||
| 10 | readonly mounted: boolean; | ||
| 11 | readonly visible: boolean; | ||
| 12 | }) { | ||
| 13 | if (!props.mounted) { | ||
| 14 | return null; | ||
| 15 | } | ||
| 16 | |||
| 17 | return ( | ||
| 18 | <View | ||
| 19 | accessibilityElementsHidden={!props.visible} | ||
| 20 | focusable={props.visible} | ||
| 21 | importantForAccessibility={props.visible ? "auto" : "no-hide-descendants"} | ||
| 22 | pointerEvents={props.visible ? "auto" : "none"} | ||
| 23 | style={{ | ||
| 24 | position: "absolute", | ||
| 25 | inset: 0, | ||
| 26 | opacity: props.visible ? 1 : 0, | ||
| 27 | zIndex: props.visible ? 1 : 0, | ||
| 28 | }} | ||
| 29 | > | ||
| 30 | {props.children} | ||
| 31 | </View> | ||
| 32 | ); | ||
| 33 | } | ||
| 34 | |||
| 35 | export function ThreadInspectorContentStack(props: { | ||
| 36 | readonly Files: ComponentType; | ||
| 37 | readonly Git: ComponentType; | ||
| 38 | readonly mode: ThreadInspectorMode; | ||
| 39 | readonly Route?: ComponentType; | ||
| 40 | }) { | ||
| 41 | const [mountedModes, setMountedModes] = useState<ReadonlySet<ThreadInspectorMode>>( | ||
| 42 | () => new Set([props.mode]), | ||
| 43 | ); | ||
| 44 | |||
| 45 | useEffect(() => { | ||
| 46 | setMountedModes((current) => { | ||
| 47 | if (current.has(props.mode)) { | ||
| 48 | return current; | ||
| 49 | } | ||
| 50 | return new Set([...current, props.mode]); | ||
| 51 | }); | ||
| 52 | |||
| 53 | if (props.mode === "route") { | ||
| 54 | return; | ||
| 55 | } | ||
| 56 | |||
| 57 | // The file tree is expensive to detach because UIKit rebuilds its focus | ||
| 58 | // graph. Keep both chat inspectors alive after the opening animation so a | ||
| 59 | // later Files/Git switch only changes visibility. | ||
| 60 | const alternateMode = props.mode === "files" ? "git" : "files"; | ||
| 61 | const timeout = setTimeout(() => { | ||
| 62 | setMountedModes((current) => { | ||
| 63 | if (current.has(alternateMode)) { | ||
| 64 | return current; | ||
| 65 | } | ||
| 66 | return new Set([...current, alternateMode]); | ||
| 67 | }); | ||
| 68 | }, INSPECTOR_PREWARM_DELAY_MS); | ||
| 69 | |||
| 70 | return () => clearTimeout(timeout); | ||
| 71 | }, [props.mode]); | ||
| 72 | |||
| 73 | const Files = props.Files; | ||
| 74 | const Git = props.Git; | ||
| 75 | const Route = props.Route; | ||
| 76 | |||
| 77 | return ( | ||
| 78 | <View style={{ flex: 1 }}> | ||
| 79 | <InspectorContentPane | ||
| 80 | mounted={mountedModes.has("files") || props.mode === "files"} | ||
| 81 | visible={props.mode === "files"} | ||
| 82 | > | ||
| 83 | <Files /> | ||
| 84 | </InspectorContentPane> | ||
| 85 | <InspectorContentPane | ||
| 86 | mounted={mountedModes.has("git") || props.mode === "git"} | ||
| 87 | visible={props.mode === "git"} | ||
| 88 | > | ||
| 89 | <Git /> | ||
| 90 | </InspectorContentPane> | ||
| 91 | {Route ? ( | ||
| 92 | <InspectorContentPane | ||
| 93 | mounted={mountedModes.has("route") || props.mode === "route"} | ||
| 94 | visible={props.mode === "route"} | ||
| 95 | > | ||
| 96 | <Route /> | ||
| 97 | </InspectorContentPane> | ||
| 98 | ) : null} | ||
| 99 | </View> | ||
| 100 | ); | ||
| 101 | } | ||
apps/mobile/src/features/threads/thread-list-items.tsx created+444| ... | @@ -0,0 +1,444 @@ | ||
| 1 | import { useRecyclingState } from "@legendapp/list/react-native"; | ||
| 2 | import type { | ||
| 3 | EnvironmentProject, | ||
| 4 | EnvironmentThreadShell, | ||
| 5 | } from "@t3tools/client-runtime/state/shell"; | ||
| 6 | import type { MenuAction } from "@react-native-menu/menu"; | ||
| 7 | import { SymbolView } from "expo-symbols"; | ||
| 8 | import { memo, useCallback, useMemo, type ComponentProps } from "react"; | ||
| 9 | import { Pressable, useWindowDimensions, View } from "react-native"; | ||
| 10 | import type { SwipeableMethods } from "react-native-gesture-handler/ReanimatedSwipeable"; | ||
| 11 | |||
| 12 | import { AppText as Text } from "../../components/AppText"; | ||
| 13 | import { ControlPillMenu } from "../../components/ControlPill"; | ||
| 14 | import { ProjectFavicon } from "../../components/ProjectFavicon"; | ||
| 15 | import { relativeTime } from "../../lib/time"; | ||
| 16 | import { useThemeColor } from "../../lib/useThemeColor"; | ||
| 17 | import { useThreadPr } from "../../state/use-thread-pr"; | ||
| 18 | import type { HomeGroupDisplayAction } from "../home/homeListItems"; | ||
| 19 | import { ThreadSwipeable } from "../home/thread-swipe-actions"; | ||
| 20 | import { resolveThreadStatus } from "./threadPresentation"; | ||
| 21 | |||
| 22 | /** | ||
| 23 | * Shared presentation for the thread lists: the compact (phone) Home list and | ||
| 24 | * the iPad sidebar render the SAME items — group headers with collapse, | ||
| 25 | * thread rows with status/PR/subtitle, and show-more rows — differing only in | ||
| 26 | * metrics and chrome via `variant`. | ||
| 27 | */ | ||
| 28 | export type ThreadListVariant = "compact" | "sidebar"; | ||
| 29 | |||
| 30 | /** Left inset that aligns compact secondary rows with the title column. */ | ||
| 31 | export const THREAD_LIST_COMPACT_INSET = 20; | ||
| 32 | const SIDEBAR_ROW_RADIUS = 12; | ||
| 33 | |||
| 34 | /* ─── Project group header ───────────────────────────────────────────── */ | ||
| 35 | |||
| 36 | export const ThreadListGroupHeader = memo(function ThreadListGroupHeader(props: { | ||
| 37 | readonly variant: ThreadListVariant; | ||
| 38 | readonly project: EnvironmentProject; | ||
| 39 | readonly title: string; | ||
| 40 | readonly threadCount: number; | ||
| 41 | readonly collapsed: boolean; | ||
| 42 | readonly isFirst: boolean; | ||
| 43 | readonly groupKey: string; | ||
| 44 | readonly onGroupAction: (key: string, action: HomeGroupDisplayAction) => void; | ||
| 45 | }) { | ||
| 46 | const iconSubtleColor = useThemeColor("--color-icon-subtle"); | ||
| 47 | const { groupKey, onGroupAction } = props; | ||
| 48 | const compact = props.variant === "compact"; | ||
| 49 | const handleToggle = useCallback( | ||
| 50 | () => onGroupAction(groupKey, "toggle-collapsed"), | ||
| 51 | [groupKey, onGroupAction], | ||
| 52 | ); | ||
| 53 | |||
| 54 | return ( | ||
| 55 | <Pressable | ||
| 56 | accessibilityRole="button" | ||
| 57 | accessibilityState={{ expanded: !props.collapsed }} | ||
| 58 | accessibilityLabel={`${props.title}, ${props.threadCount} threads`} | ||
| 59 | accessibilityHint={props.collapsed ? "Expands the project" : "Collapses the project"} | ||
| 60 | className={compact ? "bg-screen" : undefined} | ||
| 61 | onPress={handleToggle} | ||
| 62 | > | ||
| 63 | <View | ||
| 64 | className={ | ||
| 65 | compact | ||
| 66 | ? `flex-row items-center gap-2.5 px-5 pb-3 ${props.isFirst ? "pt-2" : "pt-6"}` | ||
| 67 | : `flex-row items-center gap-2 px-3 pb-2 ${props.isFirst ? "pt-1" : "pt-5"}` | ||
| 68 | } | ||
| 69 | style={{ minHeight: compact ? 44 : 36 }} | ||
| 70 | > | ||
| 71 | <ProjectFavicon | ||
| 72 | environmentId={props.project.environmentId} | ||
| 73 | size={compact ? 22 : 18} | ||
| 74 | projectTitle={props.project.title} | ||
| 75 | workspaceRoot={props.project.workspaceRoot} | ||
| 76 | /> | ||
| 77 | <Text | ||
| 78 | className={ | ||
| 79 | compact | ||
| 80 | ? "flex-shrink text-base font-t3-bold text-foreground-muted" | ||
| 81 | : "flex-shrink text-sm font-t3-bold text-foreground-muted" | ||
| 82 | } | ||
| 83 | style={{ letterSpacing: 0.2 }} | ||
| 84 | numberOfLines={1} | ||
| 85 | > | ||
| 86 | {props.title} | ||
| 87 | </Text> | ||
| 88 | <Text | ||
| 89 | className={ | ||
| 90 | compact | ||
| 91 | ? "flex-1 text-sm font-t3-medium text-foreground-tertiary" | ||
| 92 | : "flex-1 text-xs font-t3-medium text-foreground-tertiary" | ||
| 93 | } | ||
| 94 | > | ||
| 95 | {props.threadCount} | ||
| 96 | </Text> | ||
| 97 | <SymbolView | ||
| 98 | name={props.collapsed ? "chevron.right" : "chevron.down"} | ||
| 99 | size={compact ? 13 : 11} | ||
| 100 | tintColor={iconSubtleColor} | ||
| 101 | type="monochrome" | ||
| 102 | weight="semibold" | ||
| 103 | /> | ||
| 104 | </View> | ||
| 105 | </Pressable> | ||
| 106 | ); | ||
| 107 | }); | ||
| 108 | |||
| 109 | /* ─── Show more / show less row ──────────────────────────────────────── */ | ||
| 110 | |||
| 111 | export const ThreadListShowMoreRow = memo(function ThreadListShowMoreRow(props: { | ||
| 112 | readonly variant: ThreadListVariant; | ||
| 113 | readonly hiddenCount: number; | ||
| 114 | readonly canShowLess: boolean; | ||
| 115 | readonly groupKey: string; | ||
| 116 | readonly onGroupAction: (key: string, action: HomeGroupDisplayAction) => void; | ||
| 117 | }) { | ||
| 118 | const iconSubtleColor = useThemeColor("--color-icon-subtle"); | ||
| 119 | const showsMore = props.hiddenCount > 0; | ||
| 120 | const compact = props.variant === "compact"; | ||
| 121 | const { groupKey, onGroupAction } = props; | ||
| 122 | const handleShowMore = useCallback( | ||
| 123 | () => onGroupAction(groupKey, "show-more"), | ||
| 124 | [groupKey, onGroupAction], | ||
| 125 | ); | ||
| 126 | const handleShowLess = useCallback( | ||
| 127 | () => onGroupAction(groupKey, "show-less"), | ||
| 128 | [groupKey, onGroupAction], | ||
| 129 | ); | ||
| 130 | |||
| 131 | const button = (label: string, icon: "chevron.down" | "chevron.up", onPress: () => void) => ( | ||
| 132 | <Pressable | ||
| 133 | accessibilityRole="button" | ||
| 134 | accessibilityLabel={label === "Show more" ? "Show more threads" : "Show fewer threads"} | ||
| 135 | className="rounded-full bg-subtle" | ||
| 136 | hitSlop={6} | ||
| 137 | onPress={onPress} | ||
| 138 | style={({ pressed }) => ({ | ||
| 139 | opacity: pressed ? 0.6 : 1, | ||
| 140 | paddingHorizontal: compact ? 14 : 12, | ||
| 141 | paddingVertical: compact ? 7 : 6, | ||
| 142 | borderCurve: "continuous", | ||
| 143 | })} | ||
| 144 | > | ||
| 145 | <View className="flex-row items-center gap-1.5"> | ||
| 146 | <SymbolView | ||
| 147 | name={icon} | ||
| 148 | size={10} | ||
| 149 | tintColor={iconSubtleColor} | ||
| 150 | type="monochrome" | ||
| 151 | weight="semibold" | ||
| 152 | /> | ||
| 153 | <Text | ||
| 154 | className={ | ||
| 155 | compact | ||
| 156 | ? "text-sm font-t3-medium text-foreground-muted" | ||
| 157 | : "text-xs font-t3-medium text-foreground-muted" | ||
| 158 | } | ||
| 159 | > | ||
| 160 | {label} | ||
| 161 | </Text> | ||
| 162 | </View> | ||
| 163 | </Pressable> | ||
| 164 | ); | ||
| 165 | |||
| 166 | return ( | ||
| 167 | <View | ||
| 168 | className={ | ||
| 169 | compact ? "flex-row items-center gap-2.5 bg-screen" : "flex-row items-center gap-2" | ||
| 170 | } | ||
| 171 | style={{ | ||
| 172 | paddingLeft: compact ? THREAD_LIST_COMPACT_INSET : 12, | ||
| 173 | paddingRight: compact ? 18 : 12, | ||
| 174 | paddingVertical: compact ? 12 : 8, | ||
| 175 | }} | ||
| 176 | > | ||
| 177 | {showsMore ? button("Show more", "chevron.down", handleShowMore) : null} | ||
| 178 | {props.canShowLess ? button("Show less", "chevron.up", handleShowLess) : null} | ||
| 179 | </View> | ||
| 180 | ); | ||
| 181 | }); | ||
| 182 | |||
| 183 | /* ─── Thread row ─────────────────────────────────────────────────────── */ | ||
| 184 | |||
| 185 | const THREAD_ROW_MENU_ACTIONS: MenuAction[] = [ | ||
| 186 | { id: "archive", title: "Archive", image: "archivebox" }, | ||
| 187 | { id: "delete", title: "Delete", image: "trash", attributes: { destructive: true } }, | ||
| 188 | ]; | ||
| 189 | |||
| 190 | export const ThreadListRow = memo(function ThreadListRow(props: { | ||
| 191 | readonly variant: ThreadListVariant; | ||
| 192 | readonly thread: EnvironmentThreadShell; | ||
| 193 | readonly environmentLabel: string | null; | ||
| 194 | readonly projectCwd: string | null; | ||
| 195 | readonly isLast: boolean; | ||
| 196 | /** Sidebar only: the thread currently open in the detail pane. */ | ||
| 197 | readonly selected?: boolean; | ||
| 198 | /** Defaults to window width minus compact margins. */ | ||
| 199 | readonly fullSwipeWidth?: number; | ||
| 200 | readonly onSelectThread: (thread: EnvironmentThreadShell) => void; | ||
| 201 | readonly onArchiveThread: (thread: EnvironmentThreadShell) => void; | ||
| 202 | readonly onDeleteThread: (thread: EnvironmentThreadShell) => void; | ||
| 203 | readonly onSwipeableWillOpen: (methods: SwipeableMethods) => void; | ||
| 204 | readonly onSwipeableClose: (methods: SwipeableMethods) => void; | ||
| 205 | readonly simultaneousSwipeGesture?: ComponentProps< | ||
| 206 | typeof ThreadSwipeable | ||
| 207 | >["simultaneousWithExternalGesture"]; | ||
| 208 | }) { | ||
| 209 | const { width: windowWidth } = useWindowDimensions(); | ||
| 210 | const compact = props.variant === "compact"; | ||
| 211 | const selected = props.selected === true; | ||
| 212 | // Recycling-safe: resets when the list container is reused for another | ||
| 213 | // thread, so a hover highlight can't leak across rows. | ||
| 214 | const [hovered, setHovered] = useRecyclingState(false); | ||
| 215 | |||
| 216 | const separatorColor = useThemeColor("--color-separator"); | ||
| 217 | const iconSubtleColor = useThemeColor("--color-icon-subtle"); | ||
| 218 | const screenColor = useThemeColor("--color-screen"); | ||
| 219 | const drawerColor = useThemeColor("--color-drawer"); | ||
| 220 | const foregroundColor = useThemeColor("--color-foreground"); | ||
| 221 | const mutedColor = useThemeColor("--color-foreground-muted"); | ||
| 222 | const pressedBackgroundColor = useThemeColor("--color-subtle"); | ||
| 223 | const selectedBackgroundColor = useThemeColor("--color-user-bubble"); | ||
| 224 | const selectedForegroundColor = useThemeColor("--color-user-bubble-foreground"); | ||
| 225 | const selectedMutedColor = useThemeColor("--color-user-bubble-foreground-muted"); | ||
| 226 | |||
| 227 | const { thread, onSelectThread, onArchiveThread, onDeleteThread } = props; | ||
| 228 | const status = resolveThreadStatus(thread); | ||
| 229 | const pr = useThreadPr(thread, props.projectCwd); | ||
| 230 | const timestamp = relativeTime( | ||
| 231 | thread.latestUserMessageAt ?? thread.updatedAt ?? thread.createdAt, | ||
| 232 | ); | ||
| 233 | const subtitleParts = [props.environmentLabel, thread.branch].filter((part): part is string => | ||
| 234 | Boolean(part), | ||
| 235 | ); | ||
| 236 | |||
| 237 | const backgroundColor = compact ? screenColor : drawerColor; | ||
| 238 | const effectiveForeground = selected ? selectedForegroundColor : foregroundColor; | ||
| 239 | const effectiveMuted = selected ? selectedMutedColor : mutedColor; | ||
| 240 | const effectivePressedBackground = selected ? "rgba(255,255,255,0.16)" : pressedBackgroundColor; | ||
| 241 | const effectiveStatus = | ||
| 242 | selected && status | ||
| 243 | ? { ...status, pillClassName: "bg-white/20", textClassName: "text-white" } | ||
| 244 | : status; | ||
| 245 | |||
| 246 | const handleDelete = useCallback(() => onDeleteThread(thread), [onDeleteThread, thread]); | ||
| 247 | const handleArchive = useCallback(() => onArchiveThread(thread), [onArchiveThread, thread]); | ||
| 248 | const primaryAction = useMemo( | ||
| 249 | () => ({ | ||
| 250 | accessibilityLabel: `Archive ${thread.title}`, | ||
| 251 | icon: "archivebox" as const, | ||
| 252 | label: "Archive", | ||
| 253 | onPress: handleArchive, | ||
| 254 | }), | ||
| 255 | [handleArchive, thread.title], | ||
| 256 | ); | ||
| 257 | const handleMenuAction = useCallback( | ||
| 258 | ({ nativeEvent }: { readonly nativeEvent: { readonly event: string } }) => { | ||
| 259 | if (nativeEvent.event === "archive") handleArchive(); | ||
| 260 | if (nativeEvent.event === "delete") handleDelete(); | ||
| 261 | }, | ||
| 262 | [handleArchive, handleDelete], | ||
| 263 | ); | ||
| 264 | |||
| 265 | const statusPill = effectiveStatus ? ( | ||
| 266 | <View | ||
| 267 | className={effectiveStatus.pillClassName} | ||
| 268 | style={{ borderRadius: 99, paddingHorizontal: 6, paddingVertical: 2 }} | ||
| 269 | > | ||
| 270 | <Text className={`text-3xs font-t3-bold ${effectiveStatus.textClassName}`}> | ||
| 271 | {effectiveStatus.label} | ||
| 272 | </Text> | ||
| 273 | </View> | ||
| 274 | ) : null; | ||
| 275 | |||
| 276 | const subtitleRow = | ||
| 277 | subtitleParts.length > 0 || pr !== null ? ( | ||
| 278 | <View className="flex-row items-center gap-1.5" style={{ marginTop: 1 }}> | ||
| 279 | {subtitleParts.length > 0 ? ( | ||
| 280 | <> | ||
| 281 | <SymbolView | ||
| 282 | name="arrow.triangle.branch" | ||
| 283 | size={10} | ||
| 284 | tintColor={compact ? iconSubtleColor : effectiveMuted} | ||
| 285 | type="monochrome" | ||
| 286 | /> | ||
| 287 | <Text | ||
| 288 | className={compact ? "text-sm text-foreground-muted" : "text-xs"} | ||
| 289 | numberOfLines={1} | ||
| 290 | style={compact ? { flexShrink: 1 } : { flexShrink: 1, color: effectiveMuted }} | ||
| 291 | > | ||
| 292 | {subtitleParts.join(" · ")} | ||
| 293 | </Text> | ||
| 294 | </> | ||
| 295 | ) : null} | ||
| 296 | {pr !== null ? ( | ||
| 297 | <Text | ||
| 298 | className={`${compact ? "text-sm" : "text-xs"} font-t3-medium ${ | ||
| 299 | selected ? "text-white" : pr.textClassName | ||
| 300 | }`} | ||
| 301 | > | ||
| 302 | {pr.label} | ||
| 303 | </Text> | ||
| 304 | ) : null} | ||
| 305 | </View> | ||
| 306 | ) : null; | ||
| 307 | |||
| 308 | const rowContent = (close: () => void) => | ||
| 309 | compact ? ( | ||
| 310 | <Pressable | ||
| 311 | accessibilityHint="Swipe left for archive and delete actions" | ||
| 312 | accessibilityLabel={thread.title} | ||
| 313 | accessibilityRole="button" | ||
| 314 | className="bg-screen" | ||
| 315 | onPress={() => { | ||
| 316 | close(); | ||
| 317 | onSelectThread(thread); | ||
| 318 | }} | ||
| 319 | style={({ pressed }) => ({ opacity: pressed ? 0.7 : 1 })} | ||
| 320 | > | ||
| 321 | <View | ||
| 322 | style={{ | ||
| 323 | paddingLeft: THREAD_LIST_COMPACT_INSET, | ||
| 324 | paddingRight: 18, | ||
| 325 | paddingTop: 10, | ||
| 326 | }} | ||
| 327 | > | ||
| 328 | <View | ||
| 329 | style={{ | ||
| 330 | gap: 3, | ||
| 331 | borderBottomWidth: props.isLast ? 0 : 1, | ||
| 332 | borderBottomColor: separatorColor, | ||
| 333 | paddingBottom: 10, | ||
| 334 | }} | ||
| 335 | > | ||
| 336 | <View className="flex-row items-center justify-between gap-2"> | ||
| 337 | <Text className="flex-1 text-lg font-t3-bold text-foreground" numberOfLines={1}> | ||
| 338 | {thread.title} | ||
| 339 | </Text> | ||
| 340 | <View className="flex-row items-center gap-2"> | ||
| 341 | {statusPill} | ||
| 342 | <Text | ||
| 343 | className="text-base text-foreground-tertiary" | ||
| 344 | style={{ fontVariant: ["tabular-nums"] }} | ||
| 345 | > | ||
| 346 | {timestamp} | ||
| 347 | </Text> | ||
| 348 | <SymbolView | ||
| 349 | name="chevron.right" | ||
| 350 | size={13} | ||
| 351 | tintColor={iconSubtleColor} | ||
| 352 | type="monochrome" | ||
| 353 | /> | ||
| 354 | </View> | ||
| 355 | </View> | ||
| 356 | {subtitleRow} | ||
| 357 | </View> | ||
| 358 | </View> | ||
| 359 | </Pressable> | ||
| 360 | ) : ( | ||
| 361 | <Pressable | ||
| 362 | accessibilityHint="Opens the thread" | ||
| 363 | accessibilityLabel={thread.title} | ||
| 364 | accessibilityRole="button" | ||
| 365 | accessibilityState={{ selected }} | ||
| 366 | onHoverIn={() => setHovered(true)} | ||
| 367 | onHoverOut={() => setHovered(false)} | ||
| 368 | onPress={() => { | ||
| 369 | close(); | ||
| 370 | onSelectThread(thread); | ||
| 371 | }} | ||
| 372 | style={({ pressed }) => ({ | ||
| 373 | backgroundColor: selected | ||
| 374 | ? selectedBackgroundColor | ||
| 375 | : pressed || hovered | ||
| 376 | ? effectivePressedBackground | ||
| 377 | : backgroundColor, | ||
| 378 | borderRadius: SIDEBAR_ROW_RADIUS, | ||
| 379 | cursor: "pointer", | ||
| 380 | minHeight: 64, | ||
| 381 | justifyContent: "center", | ||
| 382 | paddingHorizontal: 12, | ||
| 383 | paddingVertical: 10, | ||
| 384 | })} | ||
| 385 | > | ||
| 386 | <View style={{ gap: 3 }}> | ||
| 387 | <View className="flex-row items-center justify-between gap-2"> | ||
| 388 | <Text | ||
| 389 | className="flex-1 text-base font-t3-medium" | ||
| 390 | numberOfLines={1} | ||
| 391 | style={{ color: effectiveForeground }} | ||
| 392 | > | ||
| 393 | {thread.title} | ||
| 394 | </Text> | ||
| 395 | <View className="flex-row items-center gap-2"> | ||
| 396 | {statusPill} | ||
| 397 | <Text | ||
| 398 | className="text-xs" | ||
| 399 | numberOfLines={1} | ||
| 400 | style={{ color: effectiveMuted, fontVariant: ["tabular-nums"] }} | ||
| 401 | > | ||
| 402 | {timestamp} | ||
| 403 | </Text> | ||
| 404 | </View> | ||
| 405 | </View> | ||
| 406 | {subtitleRow} | ||
| 407 | </View> | ||
| 408 | </Pressable> | ||
| 409 | ); | ||
| 410 | |||
| 411 | return ( | ||
| 412 | <ThreadSwipeable | ||
| 413 | backgroundColor={backgroundColor} | ||
| 414 | containerStyle={ | ||
| 415 | compact ? undefined : { borderRadius: SIDEBAR_ROW_RADIUS, overflow: "hidden" } | ||
| 416 | } | ||
| 417 | enableTrackpadSwipe | ||
| 418 | fullSwipeWidth={props.fullSwipeWidth ?? windowWidth - 32} | ||
| 419 | onDelete={handleDelete} | ||
| 420 | onSwipeableClose={props.onSwipeableClose} | ||
| 421 | onSwipeableWillOpen={props.onSwipeableWillOpen} | ||
| 422 | primaryAction={primaryAction} | ||
| 423 | resetKey={`${thread.environmentId}:${thread.id}`} | ||
| 424 | simultaneousWithExternalGesture={props.simultaneousSwipeGesture} | ||
| 425 | threadTitle={thread.title} | ||
| 426 | > | ||
| 427 | {(close) => ( | ||
| 428 | // Messages-style row actions: a real UIContextMenuInteraction on | ||
| 429 | // long-press / pointer right-click, with the row as the zoom preview. | ||
| 430 | // Requires the patched @react-native-menu (see | ||
| 431 | // patches/@react-native-menu__menu@2.0.0.patch): in long-press mode | ||
| 432 | // the interaction is hosted by the component view and the underlying | ||
| 433 | // UIButton passes touches through, so row taps keep working. | ||
| 434 | <ControlPillMenu | ||
| 435 | actions={THREAD_ROW_MENU_ACTIONS} | ||
| 436 | onPressAction={handleMenuAction} | ||
| 437 | shouldOpenOnLongPress | ||
| 438 | > | ||
| 439 | {rowContent(close)} | ||
| 440 | </ControlPillMenu> | ||
| 441 | )} | ||
| 442 | </ThreadSwipeable> | ||
| 443 | ); | ||
| 444 | }); | ||
apps/mobile/src/features/threads/thread-navigation-groups.test.ts created+111| ... | @@ -0,0 +1,111 @@ | ||
| 1 | import type { | ||
| 2 | EnvironmentProject, | ||
| 3 | EnvironmentThreadShell, | ||
| 4 | } from "@t3tools/client-runtime/state/shell"; | ||
| 5 | import { EnvironmentId, ProjectId, ProviderInstanceId, ThreadId } from "@t3tools/contracts"; | ||
| 6 | import { describe, expect, it } from "vite-plus/test"; | ||
| 7 | |||
| 8 | import { buildThreadNavigationGroups } from "./thread-navigation-groups"; | ||
| 9 | |||
| 10 | const environmentId = EnvironmentId.make("environment-1"); | ||
| 11 | |||
| 12 | function makeProject(input: Pick<EnvironmentProject, "id" | "title">): EnvironmentProject { | ||
| 13 | return { | ||
| 14 | environmentId, | ||
| 15 | workspaceRoot: `/workspaces/${input.id}`, | ||
| 16 | repositoryIdentity: null, | ||
| 17 | defaultModelSelection: null, | ||
| 18 | scripts: [], | ||
| 19 | createdAt: "2026-06-01T00:00:00.000Z", | ||
| 20 | updatedAt: "2026-06-01T00:00:00.000Z", | ||
| 21 | ...input, | ||
| 22 | }; | ||
| 23 | } | ||
| 24 | |||
| 25 | function makeThread( | ||
| 26 | input: Pick<EnvironmentThreadShell, "id" | "projectId" | "title"> & | ||
| 27 | Partial<EnvironmentThreadShell>, | ||
| 28 | ): EnvironmentThreadShell { | ||
| 29 | return { | ||
| 30 | environmentId, | ||
| 31 | modelSelection: { instanceId: ProviderInstanceId.make("codex"), model: "gpt-5.4" }, | ||
| 32 | runtimeMode: "full-access", | ||
| 33 | interactionMode: "default", | ||
| 34 | branch: null, | ||
| 35 | worktreePath: null, | ||
| 36 | latestTurn: null, | ||
| 37 | createdAt: "2026-06-01T00:00:00.000Z", | ||
| 38 | updatedAt: "2026-06-01T00:00:00.000Z", | ||
| 39 | archivedAt: null, | ||
| 40 | session: null, | ||
| 41 | latestUserMessageAt: null, | ||
| 42 | hasPendingApprovals: false, | ||
| 43 | hasPendingUserInput: false, | ||
| 44 | hasActionableProposedPlan: false, | ||
| 45 | ...input, | ||
| 46 | }; | ||
| 47 | } | ||
| 48 | |||
| 49 | describe("buildThreadNavigationGroups", () => { | ||
| 50 | const project = makeProject({ id: ProjectId.make("project-1"), title: "T3 Code" }); | ||
| 51 | const threads = [ | ||
| 52 | makeThread({ | ||
| 53 | id: ThreadId.make("older"), | ||
| 54 | projectId: project.id, | ||
| 55 | title: "Fix reconnect flow", | ||
| 56 | updatedAt: "2026-06-02T00:00:00.000Z", | ||
| 57 | }), | ||
| 58 | makeThread({ | ||
| 59 | id: ThreadId.make("newer"), | ||
| 60 | projectId: project.id, | ||
| 61 | title: "Build adaptive sidebar", | ||
| 62 | updatedAt: "2026-06-03T00:00:00.000Z", | ||
| 63 | }), | ||
| 64 | ]; | ||
| 65 | |||
| 66 | it("sorts each group by recent activity", () => { | ||
| 67 | expect( | ||
| 68 | buildThreadNavigationGroups({ projects: [project], threads })[0]?.threads.map( | ||
| 69 | (thread) => thread.id, | ||
| 70 | ), | ||
| 71 | ).toEqual(["newer", "older"]); | ||
| 72 | }); | ||
| 73 | |||
| 74 | it("matches thread titles without dropping their group", () => { | ||
| 75 | const groups = buildThreadNavigationGroups({ | ||
| 76 | projects: [project], | ||
| 77 | threads, | ||
| 78 | searchQuery: "reconnect", | ||
| 79 | }); | ||
| 80 | |||
| 81 | expect(groups).toHaveLength(1); | ||
| 82 | expect(groups[0]?.threads.map((thread) => thread.id)).toEqual(["older"]); | ||
| 83 | }); | ||
| 84 | |||
| 85 | it("keeps every thread when the project title matches", () => { | ||
| 86 | expect( | ||
| 87 | buildThreadNavigationGroups({ | ||
| 88 | projects: [project], | ||
| 89 | threads, | ||
| 90 | searchQuery: "t3 code", | ||
| 91 | })[0]?.threads.map((thread) => thread.id), | ||
| 92 | ).toEqual(["newer", "older"]); | ||
| 93 | }); | ||
| 94 | |||
| 95 | it("excludes archived threads from the navigation sidebar", () => { | ||
| 96 | const archived = makeThread({ | ||
| 97 | id: ThreadId.make("archived"), | ||
| 98 | projectId: project.id, | ||
| 99 | title: "Archived work", | ||
| 100 | archivedAt: "2026-06-04T00:00:00.000Z", | ||
| 101 | updatedAt: "2026-06-04T00:00:00.000Z", | ||
| 102 | }); | ||
| 103 | |||
| 104 | expect( | ||
| 105 | buildThreadNavigationGroups({ | ||
| 106 | projects: [project], | ||
| 107 | threads: [...threads, archived], | ||
| 108 | })[0]?.threads.map((thread) => thread.id), | ||
| 109 | ).toEqual(["newer", "older"]); | ||
| 110 | }); | ||
| 111 | }); | ||
apps/mobile/src/features/threads/thread-navigation-groups.ts created+64| ... | @@ -0,0 +1,64 @@ | ||
| 1 | import type { | ||
| 2 | EnvironmentProject, | ||
| 3 | EnvironmentThreadShell, | ||
| 4 | } from "@t3tools/client-runtime/state/shell"; | ||
| 5 | import * as Arr from "effect/Array"; | ||
| 6 | import * as Order from "effect/Order"; | ||
| 7 | |||
| 8 | import { groupProjectsByRepository } from "../../lib/repositoryGroups"; | ||
| 9 | |||
| 10 | export interface ThreadNavigationGroup { | ||
| 11 | readonly key: string; | ||
| 12 | readonly title: string; | ||
| 13 | readonly threads: ReadonlyArray<EnvironmentThreadShell>; | ||
| 14 | } | ||
| 15 | |||
| 16 | const threadActivityOrder = Order.mapInput( | ||
| 17 | Order.Struct({ | ||
| 18 | activityAt: Order.flip(Order.Number), | ||
| 19 | title: Order.String, | ||
| 20 | }), | ||
| 21 | (thread: EnvironmentThreadShell) => ({ | ||
| 22 | activityAt: new Date(thread.updatedAt ?? thread.createdAt).getTime(), | ||
| 23 | title: thread.title, | ||
| 24 | }), | ||
| 25 | ); | ||
| 26 | |||
| 27 | export function buildThreadNavigationGroups(input: { | ||
| 28 | readonly projects: ReadonlyArray<EnvironmentProject>; | ||
| 29 | readonly threads: ReadonlyArray<EnvironmentThreadShell>; | ||
| 30 | readonly searchQuery?: string; | ||
| 31 | }): ReadonlyArray<ThreadNavigationGroup> { | ||
| 32 | const query = input.searchQuery?.trim().toLocaleLowerCase() ?? ""; | ||
| 33 | const activeThreads = input.threads.filter((thread) => thread.archivedAt === null); | ||
| 34 | |||
| 35 | return groupProjectsByRepository({ ...input, threads: activeThreads }).flatMap((group) => { | ||
| 36 | const threads = Arr.sort( | ||
| 37 | group.projects.flatMap((projectGroup) => projectGroup.threads), | ||
| 38 | threadActivityOrder, | ||
| 39 | ); | ||
| 40 | const title = group.projects[0]?.project.title ?? group.title; | ||
| 41 | const groupMatches = | ||
| 42 | query.length === 0 || | ||
| 43 | title.toLocaleLowerCase().includes(query) || | ||
| 44 | group.title.toLocaleLowerCase().includes(query) || | ||
| 45 | group.projects.some((projectGroup) => | ||
| 46 | projectGroup.project.title.toLocaleLowerCase().includes(query), | ||
| 47 | ); | ||
| 48 | const matchingThreads = groupMatches | ||
| 49 | ? threads | ||
| 50 | : threads.filter((thread) => thread.title.toLocaleLowerCase().includes(query)); | ||
| 51 | |||
| 52 | if (query.length > 0 && matchingThreads.length === 0) { | ||
| 53 | return []; | ||
| 54 | } | ||
| 55 | |||
| 56 | return [ | ||
| 57 | { | ||
| 58 | key: group.key, | ||
| 59 | title, | ||
| 60 | threads: matchingThreads, | ||
| 61 | }, | ||
| 62 | ]; | ||
| 63 | }); | ||
| 64 | } | ||
apps/mobile/src/features/threads/thread-work-log.tsx+2-5| ... | @@ -138,10 +138,7 @@ export function ThreadWorkLog(props: { | ... | @@ -138,10 +138,7 @@ export function ThreadWorkLog(props: { |
| 138 | /> | 138 | /> |
| 139 | </View> | 139 | </View> |
| 140 | 140 | ||
| 141 | <Text | 141 | <Text className="min-w-0 flex-1 text-xs text-foreground" numberOfLines={1}> |
| 142 | className="min-w-0 flex-1 text-xs leading-4 text-foreground" | ||
| 143 | numberOfLines={1} | ||
| 144 | > | ||
| 145 | <Text | 142 | <Text |
| 146 | className={cn( | 143 | className={cn( |
| 147 | "font-t3-medium text-foreground", | 144 | "font-t3-medium text-foreground", |
| ... | @@ -202,7 +199,7 @@ export function ThreadWorkLog(props: { | ... | @@ -202,7 +199,7 @@ export function ThreadWorkLog(props: { |
| 202 | > | 199 | > |
| 203 | <Text | 200 | <Text |
| 204 | selectable | 201 | selectable |
| 205 | className="text-2xs leading-[17px] text-foreground-muted" | 202 | className="text-2xs leading-normal text-foreground-muted" |
| 206 | style={{ fontFamily: "ui-monospace" }} | 203 | style={{ fontFamily: "ui-monospace" }} |
| 207 | > | 204 | > |
| 208 | {row.fullDetail} | 205 | {row.fullDetail} |
apps/mobile/src/features/threads/threadContentPresentation.ts+7-1| ... | @@ -32,7 +32,13 @@ export function projectThreadContentPresentation(input: { | ... | @@ -32,7 +32,13 @@ export function projectThreadContentPresentation(input: { |
| 32 | detail: input.detailError, | 32 | detail: input.detailError, |
| 33 | }; | 33 | }; |
| 34 | } | 34 | } |
| 35 | if (input.connectionState === "connected") { | 35 | if ( |
| 36 | input.connectionState === "connected" || | ||
| 37 | input.connectionState === "connecting" || | ||
| 38 | input.connectionState === "reconnecting" | ||
| 39 | ) { | ||
| 40 | // Messages will arrive once the (re)connection completes — present as | ||
| 41 | // loading; the composer's connection pill reports the connection phase. | ||
| 36 | return { kind: "loading" }; | 42 | return { kind: "loading" }; |
| 37 | } | 43 | } |
| 38 | return { | 44 | return { |
apps/mobile/src/features/threads/threadPresentation.ts+103-18| ... | @@ -1,4 +1,5 @@ | ... | @@ -1,4 +1,5 @@ |
| 1 | import type { StatusTone } from "../../components/StatusPill"; | 1 | import type { StatusTone } from "../../components/StatusPill"; |
| 2 | import type { OrchestrationLatestTurn, OrchestrationSession } from "@t3tools/contracts"; | ||
| 2 | import { EnvironmentThreadShell } from "@t3tools/client-runtime/state/shell"; | 3 | import { EnvironmentThreadShell } from "@t3tools/client-runtime/state/shell"; |
| 3 | 4 | ||
| 4 | export function threadSortValue(thread: EnvironmentThreadShell): number { | 5 | export function threadSortValue(thread: EnvironmentThreadShell): number { |
| ... | @@ -6,39 +7,123 @@ export function threadSortValue(thread: EnvironmentThreadShell): number { | ... | @@ -6,39 +7,123 @@ export function threadSortValue(thread: EnvironmentThreadShell): number { |
| 6 | return Number.isNaN(candidate) ? 0 : candidate; | 7 | return Number.isNaN(candidate) ? 0 : candidate; |
| 7 | } | 8 | } |
| 8 | 9 | ||
| 9 | export function threadStatusTone(thread: EnvironmentThreadShell): StatusTone { | 10 | export type ThreadStatusKind = |
| 10 | const status = thread.session?.status; | 11 | | "pending-approval" |
| 11 | if (status === "running") { | 12 | | "awaiting-input" |
| 13 | | "working" | ||
| 14 | | "connecting" | ||
| 15 | | "error" | ||
| 16 | | "plan-ready"; | ||
| 17 | |||
| 18 | export interface ThreadStatusPresentation extends StatusTone { | ||
| 19 | readonly kind: ThreadStatusKind; | ||
| 20 | /** Foreground color for the leading status icon. */ | ||
| 21 | readonly iconColor: string; | ||
| 22 | /** Background color for the leading status icon circle. */ | ||
| 23 | readonly iconBackground: string; | ||
| 24 | /** Whether the indicator represents in-flight activity. */ | ||
| 25 | readonly pulse: boolean; | ||
| 26 | } | ||
| 27 | |||
| 28 | /** Neutral icon colors for threads with no actionable status. */ | ||
| 29 | export const THREAD_STATUS_NEUTRAL_ICON = { | ||
| 30 | iconColor: "#8e8e93", | ||
| 31 | iconBackground: "rgba(142,142,147,0.22)", | ||
| 32 | } as const; | ||
| 33 | |||
| 34 | function isLatestTurnSettled( | ||
| 35 | latestTurn: OrchestrationLatestTurn | null, | ||
| 36 | session: OrchestrationSession | null, | ||
| 37 | ): boolean { | ||
| 38 | if (!latestTurn?.startedAt) return false; | ||
| 39 | if (!latestTurn.completedAt) return false; | ||
| 40 | if (!session) return true; | ||
| 41 | return session.status !== "running"; | ||
| 42 | } | ||
| 43 | |||
| 44 | /** | ||
| 45 | * Resolves the user-facing status of a thread, in priority order. Returns | ||
| 46 | * `null` for quiescent threads so rows stay free of "Idle"-style noise. | ||
| 47 | * Mirrors `resolveThreadStatusPill` in apps/web/src/components/Sidebar.logic.ts. | ||
| 48 | */ | ||
| 49 | export function resolveThreadStatus( | ||
| 50 | thread: EnvironmentThreadShell, | ||
| 51 | ): ThreadStatusPresentation | null { | ||
| 52 | if (thread.hasPendingApprovals) { | ||
| 53 | return { | ||
| 54 | kind: "pending-approval", | ||
| 55 | label: "Needs Approval", | ||
| 56 | pillClassName: "bg-amber-500/12 dark:bg-amber-500/16", | ||
| 57 | textClassName: "text-amber-700 dark:text-amber-300", | ||
| 58 | iconColor: "#ff9f0a", | ||
| 59 | iconBackground: "rgba(255,159,10,0.22)", | ||
| 60 | pulse: false, | ||
| 61 | }; | ||
| 62 | } | ||
| 63 | |||
| 64 | if (thread.hasPendingUserInput) { | ||
| 12 | return { | 65 | return { |
| 13 | label: "Running", | 66 | kind: "awaiting-input", |
| 14 | pillClassName: "bg-orange-500/12 dark:bg-orange-500/16", | 67 | label: "Awaiting Input", |
| 15 | textClassName: "text-orange-700 dark:text-orange-300", | 68 | pillClassName: "bg-indigo-500/12 dark:bg-indigo-500/16", |
| 69 | textClassName: "text-indigo-700 dark:text-indigo-300", | ||
| 70 | iconColor: "#5e5ce6", | ||
| 71 | iconBackground: "rgba(94,92,230,0.22)", | ||
| 72 | pulse: false, | ||
| 16 | }; | 73 | }; |
| 17 | } | 74 | } |
| 18 | if (status === "ready") { | 75 | |
| 76 | if (thread.session?.status === "running") { | ||
| 19 | return { | 77 | return { |
| 20 | label: "Ready", | 78 | kind: "working", |
| 21 | pillClassName: "bg-emerald-500/12 dark:bg-emerald-500/16", | 79 | label: "Working", |
| 22 | textClassName: "text-emerald-700 dark:text-emerald-300", | 80 | pillClassName: "bg-sky-500/12 dark:bg-sky-500/16", |
| 81 | textClassName: "text-sky-700 dark:text-sky-300", | ||
| 82 | iconColor: "#0a84ff", | ||
| 83 | iconBackground: "rgba(10,132,255,0.22)", | ||
| 84 | pulse: true, | ||
| 23 | }; | 85 | }; |
| 24 | } | 86 | } |
| 25 | if (status === "starting") { | 87 | |
| 88 | if (thread.session?.status === "starting") { | ||
| 26 | return { | 89 | return { |
| 27 | label: "Starting", | 90 | kind: "connecting", |
| 91 | label: "Connecting", | ||
| 28 | pillClassName: "bg-sky-500/12 dark:bg-sky-500/16", | 92 | pillClassName: "bg-sky-500/12 dark:bg-sky-500/16", |
| 29 | textClassName: "text-sky-700 dark:text-sky-300", | 93 | textClassName: "text-sky-700 dark:text-sky-300", |
| 94 | iconColor: "#0a84ff", | ||
| 95 | iconBackground: "rgba(10,132,255,0.22)", | ||
| 96 | pulse: true, | ||
| 30 | }; | 97 | }; |
| 31 | } | 98 | } |
| 32 | if (status === "error") { | 99 | |
| 100 | if (thread.session?.status === "error" || thread.latestTurn?.state === "error") { | ||
| 33 | return { | 101 | return { |
| 102 | kind: "error", | ||
| 34 | label: "Error", | 103 | label: "Error", |
| 35 | pillClassName: "bg-rose-500/12 dark:bg-rose-500/16", | 104 | pillClassName: "bg-rose-500/12 dark:bg-rose-500/16", |
| 36 | textClassName: "text-rose-700 dark:text-rose-300", | 105 | textClassName: "text-rose-700 dark:text-rose-300", |
| 106 | iconColor: "#ff453a", | ||
| 107 | iconBackground: "rgba(255,69,58,0.22)", | ||
| 108 | pulse: false, | ||
| 37 | }; | 109 | }; |
| 38 | } | 110 | } |
| 39 | return { | 111 | |
| 40 | label: "Idle", | 112 | const hasPlanReadyPrompt = |
| 41 | pillClassName: "bg-neutral-500/10 dark:bg-neutral-500/16", | 113 | thread.interactionMode === "plan" && |
| 42 | textClassName: "text-neutral-600 dark:text-neutral-300", | 114 | isLatestTurnSettled(thread.latestTurn, thread.session) && |
| 43 | }; | 115 | thread.hasActionableProposedPlan; |
| 116 | if (hasPlanReadyPrompt) { | ||
| 117 | return { | ||
| 118 | kind: "plan-ready", | ||
| 119 | label: "Plan Ready", | ||
| 120 | pillClassName: "bg-violet-500/12 dark:bg-violet-500/16", | ||
| 121 | textClassName: "text-violet-700 dark:text-violet-300", | ||
| 122 | iconColor: "#bf5af2", | ||
| 123 | iconBackground: "rgba(191,90,242,0.22)", | ||
| 124 | pulse: false, | ||
| 125 | }; | ||
| 126 | } | ||
| 127 | |||
| 128 | return null; | ||
| 44 | } | 129 | } |
apps/mobile/src/lib/adaptive-navigation.test.ts created+68| ... | @@ -0,0 +1,68 @@ | ||
| 1 | import { describe, expect, it } from "vite-plus/test"; | ||
| 2 | |||
| 3 | import { | ||
| 4 | isBaseThreadRoute, | ||
| 5 | resolveFileSelectionNavigationAction, | ||
| 6 | resolveThreadSelectionNavigationAction, | ||
| 7 | } from "./adaptive-navigation"; | ||
| 8 | |||
| 9 | describe("isBaseThreadRoute", () => { | ||
| 10 | it("recognizes only the thread detail route", () => { | ||
| 11 | expect(isBaseThreadRoute("/threads/environment/thread")).toBe(true); | ||
| 12 | expect(isBaseThreadRoute("/threads/environment/thread/")).toBe(true); | ||
| 13 | expect(isBaseThreadRoute("/threads/environment/thread/files")).toBe(false); | ||
| 14 | expect(isBaseThreadRoute("/threads/environment/thread/review")).toBe(false); | ||
| 15 | }); | ||
| 16 | }); | ||
| 17 | |||
| 18 | describe("resolveThreadSelectionNavigationAction", () => { | ||
| 19 | it("updates params when a persistent sidebar selects a peer thread", () => { | ||
| 20 | expect( | ||
| 21 | resolveThreadSelectionNavigationAction({ | ||
| 22 | usesSplitView: true, | ||
| 23 | pathname: "/threads/environment/thread", | ||
| 24 | }), | ||
| 25 | ).toBe("set-params"); | ||
| 26 | }); | ||
| 27 | |||
| 28 | it("replaces nested thread content when a persistent sidebar selects a peer", () => { | ||
| 29 | expect( | ||
| 30 | resolveThreadSelectionNavigationAction({ | ||
| 31 | usesSplitView: true, | ||
| 32 | pathname: "/threads/environment/thread/files/path", | ||
| 33 | }), | ||
| 34 | ).toBe("replace"); | ||
| 35 | }); | ||
| 36 | |||
| 37 | it("pushes from Home so the back stack survives collapsing to compact", () => { | ||
| 38 | expect( | ||
| 39 | resolveThreadSelectionNavigationAction({ | ||
| 40 | usesSplitView: true, | ||
| 41 | pathname: "/", | ||
| 42 | }), | ||
| 43 | ).toBe("push"); | ||
| 44 | }); | ||
| 45 | |||
| 46 | it("pushes compact list selections onto the native stack", () => { | ||
| 47 | expect( | ||
| 48 | resolveThreadSelectionNavigationAction({ | ||
| 49 | usesSplitView: false, | ||
| 50 | pathname: "/threads/environment/thread", | ||
| 51 | }), | ||
| 52 | ).toBe("push"); | ||
| 53 | }); | ||
| 54 | }); | ||
| 55 | |||
| 56 | describe("resolveFileSelectionNavigationAction", () => { | ||
| 57 | it("replaces the wide file browser with the selected preview", () => { | ||
| 58 | expect(resolveFileSelectionNavigationAction({ hasPersistentFileInspector: true })).toBe( | ||
| 59 | "replace", | ||
| 60 | ); | ||
| 61 | }); | ||
| 62 | |||
| 63 | it("pushes a preview above the compact file browser", () => { | ||
| 64 | expect(resolveFileSelectionNavigationAction({ hasPersistentFileInspector: false })).toBe( | ||
| 65 | "push", | ||
| 66 | ); | ||
| 67 | }); | ||
| 68 | }); | ||
apps/mobile/src/lib/adaptive-navigation.ts created+35| ... | @@ -0,0 +1,35 @@ | ||
| 1 | export type AdaptiveNavigationAction = "push" | "replace" | "set-params"; | ||
| 2 | |||
| 3 | const BASE_THREAD_ROUTE_PATTERN = /^\/threads\/[^/]+\/[^/]+\/?$/; | ||
| 4 | |||
| 5 | export function isBaseThreadRoute(pathname: string): boolean { | ||
| 6 | return BASE_THREAD_ROUTE_PATTERN.test(pathname); | ||
| 7 | } | ||
| 8 | |||
| 9 | /** | ||
| 10 | * A persistent sidebar selects a peer destination in place. A compact list | ||
| 11 | * drills into a new destination so the native back stack remains available. | ||
| 12 | * From Home the selection pushes (never replaces) so Home stays beneath the | ||
| 13 | * thread — collapsing back to a compact width keeps a sane back stack. | ||
| 14 | */ | ||
| 15 | export function resolveThreadSelectionNavigationAction(input: { | ||
| 16 | readonly usesSplitView: boolean; | ||
| 17 | readonly pathname: string; | ||
| 18 | }): AdaptiveNavigationAction { | ||
| 19 | if (!input.usesSplitView || input.pathname === "/") { | ||
| 20 | return "push"; | ||
| 21 | } | ||
| 22 | |||
| 23 | return isBaseThreadRoute(input.pathname) ? "set-params" : "replace"; | ||
| 24 | } | ||
| 25 | |||
| 26 | /** | ||
| 27 | * On regular-width layouts, the file browser and preview occupy one workspace | ||
| 28 | * destination. Replacing the browser route keeps a single back step to chat. | ||
| 29 | * Compact layouts retain the browser as the previous stack screen. | ||
| 30 | */ | ||
| 31 | export function resolveFileSelectionNavigationAction(input: { | ||
| 32 | readonly hasPersistentFileInspector: boolean; | ||
| 33 | }): AdaptiveNavigationAction { | ||
| 34 | return input.hasPersistentFileInspector ? "replace" : "push"; | ||
| 35 | } | ||
apps/mobile/src/lib/appearancePreferences.test.ts created+142| ... | @@ -0,0 +1,142 @@ | ||
| 1 | import { describe, expect, it } from "vite-plus/test"; | ||
| 2 | |||
| 3 | import { | ||
| 4 | DEFAULT_BASE_FONT_SIZE, | ||
| 5 | deriveCodeFontSize, | ||
| 6 | deriveTerminalFontSize, | ||
| 7 | normalizeBaseFontSize, | ||
| 8 | normalizeCodeFontSize, | ||
| 9 | normalizeCodeWordBreak, | ||
| 10 | resolveAppearance, | ||
| 11 | resolveAppearancePreferences, | ||
| 12 | resolveMarkdownFontSizes, | ||
| 13 | resolveMobileCodeSurface, | ||
| 14 | resolveNativeMarkdownTypography, | ||
| 15 | resolveTextScaleVariables, | ||
| 16 | stepBaseFontSize, | ||
| 17 | stepCodeFontSize, | ||
| 18 | stepTerminalFontSize, | ||
| 19 | } from "./appearancePreferences"; | ||
| 20 | |||
| 21 | describe("appearancePreferences", () => { | ||
| 22 | it("resolves defaults for empty stored preferences", () => { | ||
| 23 | expect(resolveAppearancePreferences({})).toEqual({ | ||
| 24 | baseFontSize: DEFAULT_BASE_FONT_SIZE, | ||
| 25 | terminalFontSize: null, | ||
| 26 | codeFontSize: null, | ||
| 27 | codeWordBreak: false, | ||
| 28 | }); | ||
| 29 | }); | ||
| 30 | |||
| 31 | it("migrates the legacy markdownFontSize key to baseFontSize", () => { | ||
| 32 | expect(resolveAppearancePreferences({ markdownFontSize: 18 }).baseFontSize).toBe(18); | ||
| 33 | expect( | ||
| 34 | resolveAppearancePreferences({ baseFontSize: 16, markdownFontSize: 18 }).baseFontSize, | ||
| 35 | ).toBe(16); | ||
| 36 | }); | ||
| 37 | |||
| 38 | it("keeps explicit overrides and treats missing values as automatic", () => { | ||
| 39 | const preferences = resolveAppearancePreferences({ terminalFontSize: 12, codeFontSize: 14 }); | ||
| 40 | expect(preferences.terminalFontSize).toBe(12); | ||
| 41 | expect(preferences.codeFontSize).toBe(14); | ||
| 42 | expect(resolveAppearancePreferences({ terminalFontSize: null }).terminalFontSize).toBe(null); | ||
| 43 | }); | ||
| 44 | |||
| 45 | it("derives terminal and code sizes from the base size when not overridden", () => { | ||
| 46 | const appearance = resolveAppearance(resolveAppearancePreferences({ baseFontSize: 15 })); | ||
| 47 | expect(appearance.terminalFontSize).toBe(10); | ||
| 48 | expect(appearance.codeFontSize).toBe(11); | ||
| 49 | expect(appearance.isTerminalFontSizeCustom).toBe(false); | ||
| 50 | expect(appearance.isCodeFontSizeCustom).toBe(false); | ||
| 51 | |||
| 52 | const scaled = resolveAppearance(resolveAppearancePreferences({ baseFontSize: 22 })); | ||
| 53 | expect(scaled.terminalFontSize).toBe(deriveTerminalFontSize(22)); | ||
| 54 | expect(scaled.codeFontSize).toBe(deriveCodeFontSize(22)); | ||
| 55 | expect(scaled.terminalFontSize).toBeGreaterThan(10); | ||
| 56 | expect(scaled.codeFontSize).toBeGreaterThan(11); | ||
| 57 | }); | ||
| 58 | |||
| 59 | it("applies explicit overrides over derived values", () => { | ||
| 60 | const appearance = resolveAppearance( | ||
| 61 | resolveAppearancePreferences({ baseFontSize: 22, terminalFontSize: 8, codeFontSize: 9 }), | ||
| 62 | ); | ||
| 63 | expect(appearance.terminalFontSize).toBe(8); | ||
| 64 | expect(appearance.codeFontSize).toBe(9); | ||
| 65 | expect(appearance.isTerminalFontSizeCustom).toBe(true); | ||
| 66 | expect(appearance.isCodeFontSizeCustom).toBe(true); | ||
| 67 | }); | ||
| 68 | |||
| 69 | it("clamps base and code font sizes", () => { | ||
| 70 | expect(normalizeBaseFontSize(4)).toBe(11); | ||
| 71 | expect(normalizeBaseFontSize(30)).toBe(22); | ||
| 72 | expect(normalizeCodeFontSize(4)).toBe(8); | ||
| 73 | expect(normalizeCodeFontSize(30)).toBe(18); | ||
| 74 | }); | ||
| 75 | |||
| 76 | it("steps font sizes within bounds", () => { | ||
| 77 | expect(stepTerminalFontSize(6, -1)).toBe(6); | ||
| 78 | expect(stepBaseFontSize(11, -1)).toBe(11); | ||
| 79 | expect(stepCodeFontSize(8, -1)).toBe(8); | ||
| 80 | expect(stepBaseFontSize(15, 1)).toBe(16); | ||
| 81 | }); | ||
| 82 | |||
| 83 | it("scales markdown typography from the base size", () => { | ||
| 84 | expect(resolveMarkdownFontSizes(15)).toMatchObject({ | ||
| 85 | m: 15, | ||
| 86 | h1: 20, | ||
| 87 | bodyLineHeight: 22, | ||
| 88 | codeBlockFontSize: 12, | ||
| 89 | codeBlockLineHeight: 18, | ||
| 90 | }); | ||
| 91 | }); | ||
| 92 | |||
| 93 | it("scales code surface geometry from the code font size", () => { | ||
| 94 | expect(resolveMobileCodeSurface(11)).toMatchObject({ | ||
| 95 | fontSize: 11, | ||
| 96 | rowHeight: 20, | ||
| 97 | }); | ||
| 98 | }); | ||
| 99 | |||
| 100 | it("defaults code word break to false", () => { | ||
| 101 | expect(normalizeCodeWordBreak(undefined)).toBe(false); | ||
| 102 | expect(normalizeCodeWordBreak(true)).toBe(true); | ||
| 103 | }); | ||
| 104 | |||
| 105 | it("returns the authored text scale at the 16pt default", () => { | ||
| 106 | expect(DEFAULT_BASE_FONT_SIZE).toBe(16); | ||
| 107 | |||
| 108 | const variables = resolveTextScaleVariables(DEFAULT_BASE_FONT_SIZE); | ||
| 109 | expect(variables["--text-base"]).toBe(16); | ||
| 110 | expect(variables["--text-base--line-height"]).toBe(23); | ||
| 111 | expect(variables["--text-sm"]).toBe(14); | ||
| 112 | expect(variables["--text-sm--line-height"]).toBe(19); | ||
| 113 | expect(variables["--text-lg"]).toBe(18); | ||
| 114 | expect(variables["--text-3xl"]).toBe(30); | ||
| 115 | }); | ||
| 116 | |||
| 117 | it("scales every text variable proportionally with the base size", () => { | ||
| 118 | const smallerVariables = resolveTextScaleVariables(15); | ||
| 119 | expect(smallerVariables["--text-base"]).toBe(15); | ||
| 120 | expect(smallerVariables["--text-sm"]).toBe(13); | ||
| 121 | |||
| 122 | const variables = resolveTextScaleVariables(20); | ||
| 123 | expect(variables["--text-base"]).toBe(20); | ||
| 124 | expect(variables["--text-base--line-height"]).toBe(29); | ||
| 125 | expect(variables["--text-sm"]).toBe(18); | ||
| 126 | expect(variables["--text-xs"]).toBe(16); | ||
| 127 | expect(variables["--text-lg"]).toBe(23); | ||
| 128 | |||
| 129 | const smaller = resolveTextScaleVariables(11); | ||
| 130 | expect(smaller["--text-base"]).toBe(11); | ||
| 131 | expect(smaller["--text-3xs"]).toBeGreaterThanOrEqual(8); | ||
| 132 | expect(smaller["--text-3xs--line-height"]).toBeGreaterThanOrEqual(10); | ||
| 133 | }); | ||
| 134 | |||
| 135 | it("derives native markdown typography from the base size", () => { | ||
| 136 | expect(resolveNativeMarkdownTypography(22)).toEqual({ | ||
| 137 | fontSize: 22, | ||
| 138 | lineHeight: 32, | ||
| 139 | headingFontSizes: [29, 26, 23, 21, 21, 21], | ||
| 140 | }); | ||
| 141 | }); | ||
| 142 | }); | ||
apps/mobile/src/lib/appearancePreferences.ts created+245| ... | @@ -0,0 +1,245 @@ | ||
| 1 | import { MOBILE_CODE_SURFACE, MOBILE_TYPOGRAPHY } from "./typography"; | ||
| 2 | import { | ||
| 3 | DEFAULT_TERMINAL_FONT_SIZE, | ||
| 4 | MAX_TERMINAL_FONT_SIZE, | ||
| 5 | MIN_TERMINAL_FONT_SIZE, | ||
| 6 | TERMINAL_FONT_SIZE_STEP, | ||
| 7 | normalizeTerminalFontSize, | ||
| 8 | } from "../features/terminal/terminalPreferences"; | ||
| 9 | |||
| 10 | export const DEFAULT_BASE_FONT_SIZE = MOBILE_TYPOGRAPHY.body.fontSize; | ||
| 11 | export const MIN_BASE_FONT_SIZE = 11; | ||
| 12 | export const MAX_BASE_FONT_SIZE = 22; | ||
| 13 | export const BASE_FONT_SIZE_STEP = 1; | ||
| 14 | |||
| 15 | export const DEFAULT_CODE_FONT_SIZE = MOBILE_CODE_SURFACE.fontSize; | ||
| 16 | export const MIN_CODE_FONT_SIZE = 8; | ||
| 17 | export const MAX_CODE_FONT_SIZE = 18; | ||
| 18 | export const CODE_FONT_SIZE_STEP = 1; | ||
| 19 | |||
| 20 | /** | ||
| 21 | * User-configurable appearance preferences as stored. `null` overrides mean | ||
| 22 | * "automatic": the value is derived from the base font size. | ||
| 23 | */ | ||
| 24 | export interface AppearancePreferences { | ||
| 25 | readonly baseFontSize: number; | ||
| 26 | readonly terminalFontSize: number | null; | ||
| 27 | readonly codeFontSize: number | null; | ||
| 28 | readonly codeWordBreak: boolean; | ||
| 29 | } | ||
| 30 | |||
| 31 | /** Effective appearance values after applying base-size derivation. */ | ||
| 32 | export interface ResolvedAppearance { | ||
| 33 | readonly baseFontSize: number; | ||
| 34 | readonly terminalFontSize: number; | ||
| 35 | readonly codeFontSize: number; | ||
| 36 | readonly codeWordBreak: boolean; | ||
| 37 | readonly isTerminalFontSizeCustom: boolean; | ||
| 38 | readonly isCodeFontSizeCustom: boolean; | ||
| 39 | } | ||
| 40 | |||
| 41 | export interface ResolvedMobileCodeSurface { | ||
| 42 | readonly fontSize: number; | ||
| 43 | readonly lineNumberFontSize: number; | ||
| 44 | readonly rowHeight: number; | ||
| 45 | readonly gutterWidth: number; | ||
| 46 | readonly codePadding: number; | ||
| 47 | readonly textVerticalInset: number; | ||
| 48 | } | ||
| 49 | |||
| 50 | export interface ResolvedMarkdownFontSizes { | ||
| 51 | readonly s: number; | ||
| 52 | readonly m: number; | ||
| 53 | readonly h1: number; | ||
| 54 | readonly h2: number; | ||
| 55 | readonly h3: number; | ||
| 56 | readonly h4: number; | ||
| 57 | readonly h5: number; | ||
| 58 | readonly h6: number; | ||
| 59 | readonly bodyLineHeight: number; | ||
| 60 | readonly codeBlockFontSize: number; | ||
| 61 | readonly codeBlockLineHeight: number; | ||
| 62 | } | ||
| 63 | |||
| 64 | export interface NativeMarkdownTypography { | ||
| 65 | readonly fontSize: number; | ||
| 66 | readonly lineHeight: number; | ||
| 67 | readonly headingFontSizes: readonly [number, number, number, number, number, number]; | ||
| 68 | } | ||
| 69 | |||
| 70 | export function normalizeBaseFontSize(value: number | null | undefined): number { | ||
| 71 | if (typeof value !== "number" || !Number.isFinite(value)) { | ||
| 72 | return DEFAULT_BASE_FONT_SIZE; | ||
| 73 | } | ||
| 74 | |||
| 75 | return Math.min(MAX_BASE_FONT_SIZE, Math.max(MIN_BASE_FONT_SIZE, Math.round(value))); | ||
| 76 | } | ||
| 77 | |||
| 78 | export function normalizeCodeFontSize(value: number | null | undefined): number { | ||
| 79 | if (typeof value !== "number" || !Number.isFinite(value)) { | ||
| 80 | return DEFAULT_CODE_FONT_SIZE; | ||
| 81 | } | ||
| 82 | |||
| 83 | return Math.min(MAX_CODE_FONT_SIZE, Math.max(MIN_CODE_FONT_SIZE, Math.round(value))); | ||
| 84 | } | ||
| 85 | |||
| 86 | export function normalizeCodeWordBreak(value: boolean | null | undefined): boolean { | ||
| 87 | return value === true; | ||
| 88 | } | ||
| 89 | |||
| 90 | /** Terminal size derived from base: 10.5pt at base 16, snapped to 0.5pt steps. */ | ||
| 91 | export function deriveTerminalFontSize(baseFontSize: number): number { | ||
| 92 | const scale = normalizeBaseFontSize(baseFontSize) / DEFAULT_BASE_FONT_SIZE; | ||
| 93 | return normalizeTerminalFontSize(Math.round(DEFAULT_TERMINAL_FONT_SIZE * scale * 2) / 2); | ||
| 94 | } | ||
| 95 | |||
| 96 | /** Code/diff size derived from base: 12pt at base 16. */ | ||
| 97 | export function deriveCodeFontSize(baseFontSize: number): number { | ||
| 98 | const scale = normalizeBaseFontSize(baseFontSize) / DEFAULT_BASE_FONT_SIZE; | ||
| 99 | return normalizeCodeFontSize(Math.round(DEFAULT_CODE_FONT_SIZE * scale)); | ||
| 100 | } | ||
| 101 | |||
| 102 | interface StoredAppearancePreferences { | ||
| 103 | readonly baseFontSize?: number | null | undefined; | ||
| 104 | /** Legacy key from before base font size existed; migrated to baseFontSize. */ | ||
| 105 | readonly markdownFontSize?: number | null | undefined; | ||
| 106 | readonly terminalFontSize?: number | null | undefined; | ||
| 107 | readonly codeFontSize?: number | null | undefined; | ||
| 108 | readonly codeWordBreak?: boolean | null | undefined; | ||
| 109 | } | ||
| 110 | |||
| 111 | export function resolveAppearancePreferences( | ||
| 112 | stored: StoredAppearancePreferences | null | undefined, | ||
| 113 | ): AppearancePreferences { | ||
| 114 | return { | ||
| 115 | baseFontSize: normalizeBaseFontSize(stored?.baseFontSize ?? stored?.markdownFontSize), | ||
| 116 | terminalFontSize: | ||
| 117 | typeof stored?.terminalFontSize === "number" && Number.isFinite(stored.terminalFontSize) | ||
| 118 | ? normalizeTerminalFontSize(stored.terminalFontSize) | ||
| 119 | : null, | ||
| 120 | codeFontSize: | ||
| 121 | typeof stored?.codeFontSize === "number" && Number.isFinite(stored.codeFontSize) | ||
| 122 | ? normalizeCodeFontSize(stored.codeFontSize) | ||
| 123 | : null, | ||
| 124 | codeWordBreak: normalizeCodeWordBreak(stored?.codeWordBreak), | ||
| 125 | }; | ||
| 126 | } | ||
| 127 | |||
| 128 | export function resolveAppearance(preferences: AppearancePreferences): ResolvedAppearance { | ||
| 129 | return { | ||
| 130 | baseFontSize: preferences.baseFontSize, | ||
| 131 | terminalFontSize: | ||
| 132 | preferences.terminalFontSize ?? deriveTerminalFontSize(preferences.baseFontSize), | ||
| 133 | codeFontSize: preferences.codeFontSize ?? deriveCodeFontSize(preferences.baseFontSize), | ||
| 134 | codeWordBreak: preferences.codeWordBreak, | ||
| 135 | isTerminalFontSizeCustom: preferences.terminalFontSize !== null, | ||
| 136 | isCodeFontSizeCustom: preferences.codeFontSize !== null, | ||
| 137 | }; | ||
| 138 | } | ||
| 139 | |||
| 140 | export function resolveMobileCodeSurface(codeFontSize: number): ResolvedMobileCodeSurface { | ||
| 141 | const fontSize = normalizeCodeFontSize(codeFontSize); | ||
| 142 | const scale = fontSize / DEFAULT_CODE_FONT_SIZE; | ||
| 143 | |||
| 144 | return { | ||
| 145 | fontSize, | ||
| 146 | lineNumberFontSize: Math.max(8, Math.round(MOBILE_CODE_SURFACE.lineNumberFontSize * scale)), | ||
| 147 | rowHeight: Math.max(14, Math.round(MOBILE_CODE_SURFACE.rowHeight * scale)), | ||
| 148 | gutterWidth: MOBILE_CODE_SURFACE.gutterWidth, | ||
| 149 | codePadding: MOBILE_CODE_SURFACE.codePadding, | ||
| 150 | textVerticalInset: MOBILE_CODE_SURFACE.textVerticalInset, | ||
| 151 | }; | ||
| 152 | } | ||
| 153 | |||
| 154 | export function resolveMarkdownFontSizes(baseFontSize: number): ResolvedMarkdownFontSizes { | ||
| 155 | const m = normalizeBaseFontSize(baseFontSize); | ||
| 156 | const scale = m / DEFAULT_BASE_FONT_SIZE; | ||
| 157 | const codeBlockFontSize = Math.max(10, Math.round(13 * scale)); | ||
| 158 | |||
| 159 | return { | ||
| 160 | s: Math.max(10, Math.round(14 * scale)), | ||
| 161 | m, | ||
| 162 | h1: Math.max(16, Math.round(21 * scale)), | ||
| 163 | h2: Math.max(14, Math.round(19 * scale)), | ||
| 164 | h3: Math.max(13, Math.round(17 * scale)), | ||
| 165 | h4: Math.max(12, Math.round(15 * scale)), | ||
| 166 | h5: Math.max(12, Math.round(15 * scale)), | ||
| 167 | h6: Math.max(12, Math.round(15 * scale)), | ||
| 168 | bodyLineHeight: Math.max(18, Math.round(MOBILE_TYPOGRAPHY.body.lineHeight * scale)), | ||
| 169 | codeBlockFontSize, | ||
| 170 | codeBlockLineHeight: codeBlockFontSize + 6, | ||
| 171 | }; | ||
| 172 | } | ||
| 173 | |||
| 174 | /** | ||
| 175 | * Maps the Uniwind `--text-*` theme variables (see global.css) to the | ||
| 176 | * MOBILE_TYPOGRAPHY roles they were authored from. Keep in sync with both. | ||
| 177 | */ | ||
| 178 | const TEXT_SCALE_VARIABLE_ROLES = { | ||
| 179 | "--text-3xs": MOBILE_TYPOGRAPHY.micro, | ||
| 180 | "--text-2xs": MOBILE_TYPOGRAPHY.caption, | ||
| 181 | "--text-xs": MOBILE_TYPOGRAPHY.label, | ||
| 182 | "--text-sm": MOBILE_TYPOGRAPHY.footnote, | ||
| 183 | "--text-base": MOBILE_TYPOGRAPHY.body, | ||
| 184 | "--text-lg": MOBILE_TYPOGRAPHY.headline, | ||
| 185 | "--text-xl": MOBILE_TYPOGRAPHY.title, | ||
| 186 | "--text-2xl": MOBILE_TYPOGRAPHY.largeTitle, | ||
| 187 | "--text-3xl": MOBILE_TYPOGRAPHY.display, | ||
| 188 | } as const; | ||
| 189 | |||
| 190 | /** | ||
| 191 | * Scaled values for every `--text-*` size and line-height variable, ready to | ||
| 192 | * pass to `Uniwind.updateCSSVariables`. All className-based text (`text-sm`, | ||
| 193 | * `text-base`, ...) re-resolves live when these are injected. | ||
| 194 | */ | ||
| 195 | export function resolveTextScaleVariables(baseFontSize: number): Record<string, number> { | ||
| 196 | const scale = normalizeBaseFontSize(baseFontSize) / DEFAULT_BASE_FONT_SIZE; | ||
| 197 | const variables: Record<string, number> = {}; | ||
| 198 | |||
| 199 | for (const [name, role] of Object.entries(TEXT_SCALE_VARIABLE_ROLES)) { | ||
| 200 | variables[name] = Math.max(8, Math.round(role.fontSize * scale)); | ||
| 201 | variables[`${name}--line-height`] = Math.max(10, Math.round(role.lineHeight * scale)); | ||
| 202 | } | ||
| 203 | |||
| 204 | return variables; | ||
| 205 | } | ||
| 206 | |||
| 207 | export function resolveNativeMarkdownTypography(baseFontSize: number): NativeMarkdownTypography { | ||
| 208 | const fontSizes = resolveMarkdownFontSizes(baseFontSize); | ||
| 209 | return { | ||
| 210 | fontSize: fontSizes.m, | ||
| 211 | lineHeight: fontSizes.bodyLineHeight, | ||
| 212 | headingFontSizes: [ | ||
| 213 | fontSizes.h1, | ||
| 214 | fontSizes.h2, | ||
| 215 | fontSizes.h3, | ||
| 216 | fontSizes.h4, | ||
| 217 | fontSizes.h5, | ||
| 218 | fontSizes.h6, | ||
| 219 | ], | ||
| 220 | }; | ||
| 221 | } | ||
| 222 | |||
| 223 | export function stepBaseFontSize(current: number, direction: -1 | 1): number { | ||
| 224 | const next = direction === -1 ? current - BASE_FONT_SIZE_STEP : current + BASE_FONT_SIZE_STEP; | ||
| 225 | return normalizeBaseFontSize(next); | ||
| 226 | } | ||
| 227 | |||
| 228 | export function stepTerminalFontSize(current: number, direction: -1 | 1): number { | ||
| 229 | const next = | ||
| 230 | direction === -1 ? current - TERMINAL_FONT_SIZE_STEP : current + TERMINAL_FONT_SIZE_STEP; | ||
| 231 | return normalizeTerminalFontSize(next); | ||
| 232 | } | ||
| 233 | |||
| 234 | export function stepCodeFontSize(current: number, direction: -1 | 1): number { | ||
| 235 | const next = direction === -1 ? current - CODE_FONT_SIZE_STEP : current + CODE_FONT_SIZE_STEP; | ||
| 236 | return normalizeCodeFontSize(next); | ||
| 237 | } | ||
| 238 | |||
| 239 | export { | ||
| 240 | DEFAULT_TERMINAL_FONT_SIZE, | ||
| 241 | MAX_TERMINAL_FONT_SIZE, | ||
| 242 | MIN_TERMINAL_FONT_SIZE, | ||
| 243 | TERMINAL_FONT_SIZE_STEP, | ||
| 244 | normalizeTerminalFontSize, | ||
| 245 | }; | ||
apps/mobile/src/lib/layout.test.ts created+343| ... | @@ -0,0 +1,343 @@ | ||
| 1 | import { describe, expect, it } from "vite-plus/test"; | ||
| 2 | |||
| 3 | import { | ||
| 4 | constrainAuxiliaryPaneWidth, | ||
| 5 | constrainPrimarySidebarWidth, | ||
| 6 | deriveCenteredContentHorizontalPadding, | ||
| 7 | deriveFileInspectorPaneLayout, | ||
| 8 | deriveLayout, | ||
| 9 | deriveStableFormSheetDetent, | ||
| 10 | deriveWorkspacePaneLayout, | ||
| 11 | SPLIT_LAYOUT_MIN_HEIGHT, | ||
| 12 | SPLIT_LAYOUT_MIN_WIDTH, | ||
| 13 | } from "./layout"; | ||
| 14 | |||
| 15 | describe("resizable pane constraints", () => { | ||
| 16 | it("keeps a preferred sidebar width across large windows and clamps it in a narrow split view", () => { | ||
| 17 | expect(constrainPrimarySidebarWidth(430, 1_366)).toBe(430); | ||
| 18 | expect(constrainPrimarySidebarWidth(430, 744)).toBe(384); | ||
| 19 | expect(constrainPrimarySidebarWidth(100, 1_366)).toBe(280); | ||
| 20 | }); | ||
| 21 | |||
| 22 | it("preserves a useful main pane while constraining a trailing pane", () => { | ||
| 23 | expect(constrainAuxiliaryPaneWidth({ preferredWidth: 440, availableWidth: 1_100 })).toBe(440); | ||
| 24 | expect(constrainAuxiliaryPaneWidth({ preferredWidth: 440, availableWidth: 900 })).toBe(340); | ||
| 25 | expect(constrainAuxiliaryPaneWidth({ preferredWidth: 100, availableWidth: 1_100 })).toBe(260); | ||
| 26 | }); | ||
| 27 | }); | ||
| 28 | |||
| 29 | describe("deriveCenteredContentHorizontalPadding", () => { | ||
| 30 | it("keeps the minimum padding while the viewport fits the reading width", () => { | ||
| 31 | expect( | ||
| 32 | deriveCenteredContentHorizontalPadding({ | ||
| 33 | viewportWidth: 744, | ||
| 34 | maxContentWidth: 960, | ||
| 35 | minimumPadding: 20, | ||
| 36 | }), | ||
| 37 | ).toBe(20); | ||
| 38 | }); | ||
| 39 | |||
| 40 | it("centers only the content inside a wider full-width scroll host", () => { | ||
| 41 | expect( | ||
| 42 | deriveCenteredContentHorizontalPadding({ | ||
| 43 | viewportWidth: 1_032, | ||
| 44 | maxContentWidth: 960, | ||
| 45 | minimumPadding: 20, | ||
| 46 | }), | ||
| 47 | ).toBe(56); | ||
| 48 | }); | ||
| 49 | |||
| 50 | it("supports unconstrained compact content", () => { | ||
| 51 | expect( | ||
| 52 | deriveCenteredContentHorizontalPadding({ | ||
| 53 | viewportWidth: 430, | ||
| 54 | maxContentWidth: null, | ||
| 55 | minimumPadding: 16, | ||
| 56 | }), | ||
| 57 | ).toBe(16); | ||
| 58 | }); | ||
| 59 | }); | ||
| 60 | |||
| 61 | describe("deriveLayout", () => { | ||
| 62 | it.each([ | ||
| 63 | { name: "small iPhone portrait", width: 375, height: 667 }, | ||
| 64 | { name: "large iPhone portrait", width: 430, height: 932 }, | ||
| 65 | { name: "small iPhone landscape", width: 667, height: 375 }, | ||
| 66 | { name: "large iPhone landscape", width: 932, height: 430 }, | ||
| 67 | { name: "short wide window", width: 1_024, height: 599 }, | ||
| 68 | { name: "narrow tall window", width: 719, height: 1_024 }, | ||
| 69 | ])("keeps a $name in the compact shell", ({ width, height }) => { | ||
| 70 | expect(deriveLayout({ width, height })).toEqual({ | ||
| 71 | variant: "compact", | ||
| 72 | usesSplitView: false, | ||
| 73 | listPaneWidth: null, | ||
| 74 | shellPadding: 0, | ||
| 75 | }); | ||
| 76 | }); | ||
| 77 | |||
| 78 | it.each([ | ||
| 79 | { name: "small tablet portrait", width: 744, height: 1_133 }, | ||
| 80 | { name: "tablet landscape", width: 1_024, height: 768 }, | ||
| 81 | { name: "large resizable window", width: 1_366, height: 1_024 }, | ||
| 82 | { name: "foldable-sized window", width: 800, height: 700 }, | ||
| 83 | ])("uses the split shell for a $name", ({ width, height }) => { | ||
| 84 | expect(deriveLayout({ width, height })).toMatchObject({ | ||
| 85 | variant: "split", | ||
| 86 | usesSplitView: true, | ||
| 87 | }); | ||
| 88 | }); | ||
| 89 | |||
| 90 | it("switches only after both space requirements are met", () => { | ||
| 91 | expect( | ||
| 92 | deriveLayout({ width: SPLIT_LAYOUT_MIN_WIDTH, height: SPLIT_LAYOUT_MIN_HEIGHT }).variant, | ||
| 93 | ).toBe("split"); | ||
| 94 | expect( | ||
| 95 | deriveLayout({ width: SPLIT_LAYOUT_MIN_WIDTH - 1, height: SPLIT_LAYOUT_MIN_HEIGHT }).variant, | ||
| 96 | ).toBe("compact"); | ||
| 97 | expect( | ||
| 98 | deriveLayout({ width: SPLIT_LAYOUT_MIN_WIDTH, height: SPLIT_LAYOUT_MIN_HEIGHT - 1 }).variant, | ||
| 99 | ).toBe("compact"); | ||
| 100 | }); | ||
| 101 | |||
| 102 | it("keeps the sidebar within usable native-column bounds", () => { | ||
| 103 | expect(deriveLayout({ width: 720, height: 1_000 }).listPaneWidth).toBe(280); | ||
| 104 | expect(deriveLayout({ width: 1_024, height: 768 }).listPaneWidth).toBe(328); | ||
| 105 | expect(deriveLayout({ width: 1_600, height: 1_000 }).listPaneWidth).toBe(380); | ||
| 106 | }); | ||
| 107 | }); | ||
| 108 | |||
| 109 | describe("deriveWorkspacePaneLayout", () => { | ||
| 110 | it("keeps the auxiliary pane out of a standard iPad detail column", () => { | ||
| 111 | const layout = deriveLayout({ width: 1_194, height: 834 }); | ||
| 112 | |||
| 113 | expect( | ||
| 114 | deriveWorkspacePaneLayout({ | ||
| 115 | layout, | ||
| 116 | viewportWidth: 1_194, | ||
| 117 | primarySidebarPreferredVisible: true, | ||
| 118 | auxiliaryPanePreferredVisible: true, | ||
| 119 | }), | ||
| 120 | ).toEqual({ | ||
| 121 | primarySidebarVisible: true, | ||
| 122 | primarySidebarSuppressedByAuxiliary: false, | ||
| 123 | contentPaneWidth: 814, | ||
| 124 | supportsAuxiliaryPane: false, | ||
| 125 | auxiliaryPaneVisible: false, | ||
| 126 | auxiliaryPaneWidth: null, | ||
| 127 | }); | ||
| 128 | }); | ||
| 129 | |||
| 130 | it("offers an auxiliary pane when maximizing a standard iPad landscape window", () => { | ||
| 131 | const layout = deriveLayout({ width: 1_194, height: 834 }); | ||
| 132 | |||
| 133 | expect( | ||
| 134 | deriveWorkspacePaneLayout({ | ||
| 135 | layout, | ||
| 136 | viewportWidth: 1_194, | ||
| 137 | primarySidebarPreferredVisible: false, | ||
| 138 | auxiliaryPanePreferredVisible: true, | ||
| 139 | }), | ||
| 140 | ).toEqual({ | ||
| 141 | primarySidebarVisible: false, | ||
| 142 | primarySidebarSuppressedByAuxiliary: false, | ||
| 143 | contentPaneWidth: 1_194, | ||
| 144 | supportsAuxiliaryPane: true, | ||
| 145 | auxiliaryPaneVisible: true, | ||
| 146 | auxiliaryPaneWidth: 320, | ||
| 147 | }); | ||
| 148 | }); | ||
| 149 | |||
| 150 | it("prioritizes a trailing file inspector over the thread sidebar at medium widths", () => { | ||
| 151 | const layout = deriveLayout({ width: 1_024, height: 1_366 }); | ||
| 152 | |||
| 153 | expect( | ||
| 154 | deriveWorkspacePaneLayout({ | ||
| 155 | layout, | ||
| 156 | viewportWidth: 1_024, | ||
| 157 | primarySidebarPreferredVisible: true, | ||
| 158 | auxiliaryPanePreferredVisible: true, | ||
| 159 | auxiliaryPaneRole: "inspector", | ||
| 160 | }), | ||
| 161 | ).toEqual({ | ||
| 162 | primarySidebarVisible: false, | ||
| 163 | primarySidebarSuppressedByAuxiliary: true, | ||
| 164 | contentPaneWidth: 1_024, | ||
| 165 | supportsAuxiliaryPane: true, | ||
| 166 | auxiliaryPaneVisible: true, | ||
| 167 | auxiliaryPaneWidth: 260, | ||
| 168 | }); | ||
| 169 | }); | ||
| 170 | |||
| 171 | it("keeps threads, content, and the file inspector visible in a large landscape window", () => { | ||
| 172 | const layout = deriveLayout({ width: 1_366, height: 1_024 }); | ||
| 173 | |||
| 174 | expect( | ||
| 175 | deriveWorkspacePaneLayout({ | ||
| 176 | layout, | ||
| 177 | viewportWidth: 1_366, | ||
| 178 | primarySidebarPreferredVisible: true, | ||
| 179 | auxiliaryPanePreferredVisible: true, | ||
| 180 | auxiliaryPaneRole: "inspector", | ||
| 181 | }), | ||
| 182 | ).toEqual({ | ||
| 183 | primarySidebarVisible: true, | ||
| 184 | primarySidebarSuppressedByAuxiliary: false, | ||
| 185 | contentPaneWidth: 986, | ||
| 186 | supportsAuxiliaryPane: true, | ||
| 187 | auxiliaryPaneVisible: true, | ||
| 188 | auxiliaryPaneWidth: 276, | ||
| 189 | }); | ||
| 190 | }); | ||
| 191 | |||
| 192 | it("keeps an explicitly hidden thread sidebar hidden when the file inspector is visible", () => { | ||
| 193 | const layout = deriveLayout({ width: 1_024, height: 1_366 }); | ||
| 194 | |||
| 195 | expect( | ||
| 196 | deriveWorkspacePaneLayout({ | ||
| 197 | layout, | ||
| 198 | viewportWidth: 1_024, | ||
| 199 | primarySidebarPreferredVisible: false, | ||
| 200 | auxiliaryPanePreferredVisible: true, | ||
| 201 | auxiliaryPaneRole: "inspector", | ||
| 202 | }), | ||
| 203 | ).toMatchObject({ | ||
| 204 | primarySidebarVisible: false, | ||
| 205 | primarySidebarSuppressedByAuxiliary: false, | ||
| 206 | auxiliaryPaneVisible: true, | ||
| 207 | }); | ||
| 208 | }); | ||
| 209 | |||
| 210 | it("restores the thread sidebar when the file inspector is hidden", () => { | ||
| 211 | const layout = deriveLayout({ width: 1_024, height: 1_366 }); | ||
| 212 | |||
| 213 | expect( | ||
| 214 | deriveWorkspacePaneLayout({ | ||
| 215 | layout, | ||
| 216 | viewportWidth: 1_024, | ||
| 217 | primarySidebarPreferredVisible: true, | ||
| 218 | auxiliaryPanePreferredVisible: false, | ||
| 219 | auxiliaryPaneRole: "inspector", | ||
| 220 | }), | ||
| 221 | ).toMatchObject({ | ||
| 222 | primarySidebarVisible: true, | ||
| 223 | primarySidebarSuppressedByAuxiliary: false, | ||
| 224 | auxiliaryPaneVisible: false, | ||
| 225 | }); | ||
| 226 | }); | ||
| 227 | |||
| 228 | it("keeps file navigation on the native stack below the inspector breakpoint", () => { | ||
| 229 | const layout = deriveLayout({ width: 744, height: 1_133 }); | ||
| 230 | |||
| 231 | expect(deriveFileInspectorPaneLayout({ layout, viewportWidth: 744 })).toEqual({ | ||
| 232 | supported: false, | ||
| 233 | width: null, | ||
| 234 | }); | ||
| 235 | expect( | ||
| 236 | deriveWorkspacePaneLayout({ | ||
| 237 | layout, | ||
| 238 | viewportWidth: 744, | ||
| 239 | primarySidebarPreferredVisible: true, | ||
| 240 | auxiliaryPanePreferredVisible: true, | ||
| 241 | auxiliaryPaneRole: "inspector", | ||
| 242 | }), | ||
| 243 | ).toMatchObject({ | ||
| 244 | primarySidebarVisible: true, | ||
| 245 | supportsAuxiliaryPane: false, | ||
| 246 | auxiliaryPaneVisible: false, | ||
| 247 | }); | ||
| 248 | }); | ||
| 249 | |||
| 250 | it("supports three visible columns in a sufficiently large window", () => { | ||
| 251 | const layout = deriveLayout({ width: 1_366, height: 1_024 }); | ||
| 252 | |||
| 253 | expect( | ||
| 254 | deriveWorkspacePaneLayout({ | ||
| 255 | layout, | ||
| 256 | viewportWidth: 1_366, | ||
| 257 | primarySidebarPreferredVisible: true, | ||
| 258 | auxiliaryPanePreferredVisible: true, | ||
| 259 | }), | ||
| 260 | ).toMatchObject({ | ||
| 261 | primarySidebarVisible: true, | ||
| 262 | contentPaneWidth: 986, | ||
| 263 | supportsAuxiliaryPane: true, | ||
| 264 | auxiliaryPaneVisible: true, | ||
| 265 | auxiliaryPaneWidth: 276, | ||
| 266 | }); | ||
| 267 | }); | ||
| 268 | |||
| 269 | it("uses a preferred inspector width when all three panes still fit", () => { | ||
| 270 | const layout = deriveLayout({ width: 1_366, height: 1_024 }); | ||
| 271 | |||
| 272 | expect( | ||
| 273 | deriveWorkspacePaneLayout({ | ||
| 274 | layout, | ||
| 275 | viewportWidth: 1_366, | ||
| 276 | primarySidebarPreferredVisible: true, | ||
| 277 | auxiliaryPanePreferredVisible: true, | ||
| 278 | auxiliaryPaneRole: "inspector", | ||
| 279 | auxiliaryPanePreferredWidth: 420, | ||
| 280 | }), | ||
| 281 | ).toMatchObject({ | ||
| 282 | primarySidebarVisible: true, | ||
| 283 | auxiliaryPaneVisible: true, | ||
| 284 | auxiliaryPaneWidth: 420, | ||
| 285 | }); | ||
| 286 | }); | ||
| 287 | |||
| 288 | it("clamps a preferred supplementary width before squeezing the main pane", () => { | ||
| 289 | const layout = deriveLayout({ width: 1_366, height: 1_024 }); | ||
| 290 | |||
| 291 | expect( | ||
| 292 | deriveWorkspacePaneLayout({ | ||
| 293 | layout, | ||
| 294 | viewportWidth: 1_366, | ||
| 295 | primarySidebarPreferredVisible: true, | ||
| 296 | auxiliaryPanePreferredVisible: true, | ||
| 297 | auxiliaryPanePreferredWidth: 460, | ||
| 298 | }).auxiliaryPaneWidth, | ||
| 299 | ).toBe(426); | ||
| 300 | }); | ||
| 301 | |||
| 302 | it("respects a hidden auxiliary-pane preference", () => { | ||
| 303 | const layout = deriveLayout({ width: 1_366, height: 1_024 }); | ||
| 304 | |||
| 305 | expect( | ||
| 306 | deriveWorkspacePaneLayout({ | ||
| 307 | layout, | ||
| 308 | viewportWidth: 1_366, | ||
| 309 | primarySidebarPreferredVisible: true, | ||
| 310 | auxiliaryPanePreferredVisible: false, | ||
| 311 | }).auxiliaryPaneVisible, | ||
| 312 | ).toBe(false); | ||
| 313 | }); | ||
| 314 | |||
| 315 | it("never exposes workspace panes in compact layouts", () => { | ||
| 316 | const layout = deriveLayout({ width: 430, height: 932 }); | ||
| 317 | |||
| 318 | expect( | ||
| 319 | deriveWorkspacePaneLayout({ | ||
| 320 | layout, | ||
| 321 | viewportWidth: 430, | ||
| 322 | primarySidebarPreferredVisible: true, | ||
| 323 | auxiliaryPanePreferredVisible: true, | ||
| 324 | }), | ||
| 325 | ).toMatchObject({ | ||
| 326 | primarySidebarVisible: false, | ||
| 327 | supportsAuxiliaryPane: false, | ||
| 328 | auxiliaryPaneVisible: false, | ||
| 329 | auxiliaryPaneWidth: null, | ||
| 330 | }); | ||
| 331 | }); | ||
| 332 | }); | ||
| 333 | |||
| 334 | describe("deriveStableFormSheetDetent", () => { | ||
| 335 | it.each([ | ||
| 336 | { height: 1_194, expected: 0.62 }, | ||
| 337 | { height: 834, expected: 0.863 }, | ||
| 338 | { height: 600, expected: 0.893 }, | ||
| 339 | { height: 0, expected: 0.92 }, | ||
| 340 | ])("derives a stable sheet detent for height $height", ({ height, expected }) => { | ||
| 341 | expect(deriveStableFormSheetDetent(height)).toBe(expected); | ||
| 342 | }); | ||
| 343 | }); | ||
apps/mobile/src/lib/layout.ts+226-4| ... | @@ -2,6 +2,33 @@ function clamp(value: number, min: number, max: number): number { | ... | @@ -2,6 +2,33 @@ function clamp(value: number, min: number, max: number): number { |
| 2 | return Math.min(Math.max(value, min), max); | 2 | return Math.min(Math.max(value, min), max); |
| 3 | } | 3 | } |
| 4 | 4 | ||
| 5 | /** | ||
| 6 | * Use available space, not device or orientation labels, to choose the shell. | ||
| 7 | * | ||
| 8 | * The height floor deliberately keeps every current iPhone in the compact shell | ||
| 9 | * when it rotates to landscape, while still allowing iPad and foldable-sized | ||
| 10 | * windows to adopt the persistent sidebar as they resize. | ||
| 11 | */ | ||
| 12 | export const SPLIT_LAYOUT_MIN_WIDTH = 720; | ||
| 13 | export const SPLIT_LAYOUT_MIN_HEIGHT = 600; | ||
| 14 | |||
| 15 | export const SPLIT_SIDEBAR_MIN_WIDTH = 280; | ||
| 16 | export const SPLIT_SIDEBAR_MAX_WIDTH = 460; | ||
| 17 | const SPLIT_SIDEBAR_DEFAULT_MAX_WIDTH = 380; | ||
| 18 | |||
| 19 | export const AUXILIARY_PANE_MIN_CONTENT_WIDTH = 960; | ||
| 20 | export const CHAT_CONTENT_MAX_WIDTH = 960; | ||
| 21 | |||
| 22 | export const AUXILIARY_PANE_MIN_WIDTH = 260; | ||
| 23 | export const AUXILIARY_PANE_MAX_WIDTH = 480; | ||
| 24 | const AUXILIARY_PANE_DEFAULT_MAX_WIDTH = 320; | ||
| 25 | const FILE_INSPECTOR_MIN_VIEWPORT_WIDTH = 820; | ||
| 26 | const FILE_INSPECTOR_MIN_MAIN_WIDTH = 560; | ||
| 27 | const STABLE_FORM_SHEET_MAX_HEIGHT = 720; | ||
| 28 | const STABLE_FORM_SHEET_VERTICAL_MARGIN = 64; | ||
| 29 | const STABLE_FORM_SHEET_MIN_DETENT = 0.62; | ||
| 30 | const STABLE_FORM_SHEET_MAX_DETENT = 0.92; | ||
| 31 | |||
| 5 | export type LayoutVariant = "compact" | "split"; | 32 | export type LayoutVariant = "compact" | "split"; |
| 6 | 33 | ||
| 7 | export interface Layout { | 34 | export interface Layout { |
| ... | @@ -11,10 +38,25 @@ export interface Layout { | ... | @@ -11,10 +38,25 @@ export interface Layout { |
| 11 | readonly shellPadding: number; | 38 | readonly shellPadding: number; |
| 12 | } | 39 | } |
| 13 | 40 | ||
| 41 | export interface WorkspacePaneLayout { | ||
| 42 | readonly primarySidebarVisible: boolean; | ||
| 43 | readonly primarySidebarSuppressedByAuxiliary: boolean; | ||
| 44 | readonly contentPaneWidth: number; | ||
| 45 | readonly supportsAuxiliaryPane: boolean; | ||
| 46 | readonly auxiliaryPaneVisible: boolean; | ||
| 47 | readonly auxiliaryPaneWidth: number | null; | ||
| 48 | } | ||
| 49 | |||
| 50 | export interface FileInspectorPaneLayout { | ||
| 51 | readonly supported: boolean; | ||
| 52 | readonly width: number | null; | ||
| 53 | } | ||
| 54 | |||
| 55 | export type WorkspaceAuxiliaryPaneRole = "supplementary" | "inspector"; | ||
| 56 | |||
| 14 | export function deriveLayout(input: { readonly width: number; readonly height: number }): Layout { | 57 | export function deriveLayout(input: { readonly width: number; readonly height: number }): Layout { |
| 15 | const { width, height } = input; | 58 | const { width, height } = input; |
| 16 | const shortestEdge = Math.min(width, height); | 59 | const wideEnoughForSplit = width >= SPLIT_LAYOUT_MIN_WIDTH && height >= SPLIT_LAYOUT_MIN_HEIGHT; |
| 17 | const wideEnoughForSplit = width >= 900 || (width >= 700 && shortestEdge >= 700); | ||
| 18 | 60 | ||
| 19 | if (!wideEnoughForSplit) { | 61 | if (!wideEnoughForSplit) { |
| 20 | return { | 62 | return { |
| ... | @@ -28,7 +70,187 @@ export function deriveLayout(input: { readonly width: number; readonly height: n | ... | @@ -28,7 +70,187 @@ export function deriveLayout(input: { readonly width: number; readonly height: n |
| 28 | return { | 70 | return { |
| 29 | variant: "split", | 71 | variant: "split", |
| 30 | usesSplitView: true, | 72 | usesSplitView: true, |
| 31 | listPaneWidth: clamp(Math.round(width * 0.34), 320, 420), | 73 | listPaneWidth: clamp( |
| 32 | shellPadding: width >= 1180 ? 20 : 14, | 74 | Math.round(width * 0.32), |
| 75 | SPLIT_SIDEBAR_MIN_WIDTH, | ||
| 76 | SPLIT_SIDEBAR_DEFAULT_MAX_WIDTH, | ||
| 77 | ), | ||
| 78 | shellPadding: 0, | ||
| 79 | }; | ||
| 80 | } | ||
| 81 | |||
| 82 | export function deriveWorkspacePaneLayout(input: { | ||
| 83 | readonly layout: Layout; | ||
| 84 | readonly viewportWidth: number; | ||
| 85 | readonly primarySidebarPreferredVisible: boolean; | ||
| 86 | readonly auxiliaryPanePreferredVisible: boolean; | ||
| 87 | readonly auxiliaryPaneRole?: WorkspaceAuxiliaryPaneRole; | ||
| 88 | readonly auxiliaryPanePreferredWidth?: number; | ||
| 89 | }): WorkspacePaneLayout { | ||
| 90 | const viewportWidth = Math.max(0, input.viewportWidth); | ||
| 91 | const auxiliaryPaneRole = input.auxiliaryPaneRole ?? "supplementary"; | ||
| 92 | const preferredPrimarySidebarVisible = | ||
| 93 | input.layout.usesSplitView && input.primarySidebarPreferredVisible; | ||
| 94 | const preferredPrimarySidebarWidth = preferredPrimarySidebarVisible | ||
| 95 | ? (input.layout.listPaneWidth ?? 0) | ||
| 96 | : 0; | ||
| 97 | |||
| 98 | if (auxiliaryPaneRole === "inspector") { | ||
| 99 | const fileInspector = deriveFileInspectorPaneLayout({ | ||
| 100 | layout: input.layout, | ||
| 101 | viewportWidth, | ||
| 102 | preferredWidth: input.auxiliaryPanePreferredWidth, | ||
| 103 | reservedLeadingWidth: preferredPrimarySidebarWidth, | ||
| 104 | }); | ||
| 105 | const auxiliaryPaneVisible = fileInspector.supported && input.auxiliaryPanePreferredVisible; | ||
| 106 | const primarySidebarSuppressedByAuxiliary = | ||
| 107 | preferredPrimarySidebarVisible && | ||
| 108 | auxiliaryPaneVisible && | ||
| 109 | fileInspector.width !== null && | ||
| 110 | input.layout.listPaneWidth !== null && | ||
| 111 | viewportWidth - input.layout.listPaneWidth - fileInspector.width < | ||
| 112 | FILE_INSPECTOR_MIN_MAIN_WIDTH; | ||
| 113 | const primarySidebarVisible = | ||
| 114 | preferredPrimarySidebarVisible && !primarySidebarSuppressedByAuxiliary; | ||
| 115 | const primarySidebarWidth = primarySidebarVisible ? (input.layout.listPaneWidth ?? 0) : 0; | ||
| 116 | |||
| 117 | return { | ||
| 118 | primarySidebarVisible, | ||
| 119 | primarySidebarSuppressedByAuxiliary, | ||
| 120 | contentPaneWidth: Math.max(0, viewportWidth - primarySidebarWidth), | ||
| 121 | supportsAuxiliaryPane: fileInspector.supported, | ||
| 122 | auxiliaryPaneVisible, | ||
| 123 | auxiliaryPaneWidth: fileInspector.width, | ||
| 124 | }; | ||
| 125 | } | ||
| 126 | |||
| 127 | const contentPaneWidth = Math.max(0, viewportWidth - preferredPrimarySidebarWidth); | ||
| 128 | const supportsAuxiliaryPane = | ||
| 129 | input.layout.usesSplitView && contentPaneWidth >= AUXILIARY_PANE_MIN_CONTENT_WIDTH; | ||
| 130 | const auxiliaryPaneVisible = supportsAuxiliaryPane && input.auxiliaryPanePreferredVisible; | ||
| 131 | const defaultAuxiliaryPaneWidth = clamp( | ||
| 132 | Math.round(contentPaneWidth * 0.28), | ||
| 133 | AUXILIARY_PANE_MIN_WIDTH, | ||
| 134 | AUXILIARY_PANE_DEFAULT_MAX_WIDTH, | ||
| 135 | ); | ||
| 136 | |||
| 137 | return { | ||
| 138 | primarySidebarVisible: preferredPrimarySidebarVisible, | ||
| 139 | primarySidebarSuppressedByAuxiliary: false, | ||
| 140 | contentPaneWidth, | ||
| 141 | supportsAuxiliaryPane, | ||
| 142 | auxiliaryPaneVisible, | ||
| 143 | auxiliaryPaneWidth: supportsAuxiliaryPane | ||
| 144 | ? constrainAuxiliaryPaneWidth({ | ||
| 145 | preferredWidth: input.auxiliaryPanePreferredWidth ?? defaultAuxiliaryPaneWidth, | ||
| 146 | availableWidth: contentPaneWidth, | ||
| 147 | }) | ||
| 148 | : null, | ||
| 149 | }; | ||
| 150 | } | ||
| 151 | |||
| 152 | export function deriveFileInspectorPaneLayout(input: { | ||
| 153 | readonly layout: Layout; | ||
| 154 | readonly viewportWidth: number; | ||
| 155 | readonly preferredWidth?: number; | ||
| 156 | readonly reservedLeadingWidth?: number; | ||
| 157 | }): FileInspectorPaneLayout { | ||
| 158 | const viewportWidth = Math.max(0, input.viewportWidth); | ||
| 159 | const reservedLeadingWidth = Number.isFinite(input.reservedLeadingWidth) | ||
| 160 | ? Math.max(0, input.reservedLeadingWidth ?? 0) | ||
| 161 | : 0; | ||
| 162 | const availableContentWidth = Math.max(0, viewportWidth - reservedLeadingWidth); | ||
| 163 | const supported = | ||
| 164 | input.layout.usesSplitView && viewportWidth >= FILE_INSPECTOR_MIN_VIEWPORT_WIDTH; | ||
| 165 | |||
| 166 | return { | ||
| 167 | supported, | ||
| 168 | width: supported | ||
| 169 | ? constrainAuxiliaryPaneWidth({ | ||
| 170 | preferredWidth: | ||
| 171 | input.preferredWidth ?? | ||
| 172 | clamp( | ||
| 173 | Math.round(availableContentWidth * 0.28), | ||
| 174 | AUXILIARY_PANE_MIN_WIDTH, | ||
| 175 | AUXILIARY_PANE_DEFAULT_MAX_WIDTH, | ||
| 176 | ), | ||
| 177 | availableWidth: availableContentWidth, | ||
| 178 | }) | ||
| 179 | : null, | ||
| 33 | }; | 180 | }; |
| 34 | } | 181 | } |
| 182 | |||
| 183 | /** Keep a user-selected sidebar width useful as a window is resized. */ | ||
| 184 | export function constrainPrimarySidebarWidth( | ||
| 185 | preferredWidth: number, | ||
| 186 | viewportWidth = Number.POSITIVE_INFINITY, | ||
| 187 | ): number { | ||
| 188 | const safeWidth = Number.isFinite(preferredWidth) ? preferredWidth : SPLIT_SIDEBAR_MIN_WIDTH; | ||
| 189 | const viewportMax = Number.isFinite(viewportWidth) | ||
| 190 | ? Math.max(SPLIT_SIDEBAR_MIN_WIDTH, viewportWidth - 360) | ||
| 191 | : SPLIT_SIDEBAR_MAX_WIDTH; | ||
| 192 | return clamp( | ||
| 193 | Math.round(safeWidth), | ||
| 194 | SPLIT_SIDEBAR_MIN_WIDTH, | ||
| 195 | Math.min(SPLIT_SIDEBAR_MAX_WIDTH, viewportMax), | ||
| 196 | ); | ||
| 197 | } | ||
| 198 | |||
| 199 | /** | ||
| 200 | * Keep an auxiliary pane within native-feeling bounds without squeezing its | ||
| 201 | * neighboring content below a usable reading/editor width. | ||
| 202 | */ | ||
| 203 | export function constrainAuxiliaryPaneWidth(input: { | ||
| 204 | readonly preferredWidth: number; | ||
| 205 | readonly availableWidth: number; | ||
| 206 | }): number { | ||
| 207 | const safePreferredWidth = Number.isFinite(input.preferredWidth) | ||
| 208 | ? input.preferredWidth | ||
| 209 | : AUXILIARY_PANE_MIN_WIDTH; | ||
| 210 | const availableWidth = Number.isFinite(input.availableWidth) | ||
| 211 | ? Math.max(0, input.availableWidth) | ||
| 212 | : 0; | ||
| 213 | const maxWidth = Math.max( | ||
| 214 | AUXILIARY_PANE_MIN_WIDTH, | ||
| 215 | Math.min(AUXILIARY_PANE_MAX_WIDTH, availableWidth - FILE_INSPECTOR_MIN_MAIN_WIDTH), | ||
| 216 | ); | ||
| 217 | return clamp(Math.round(safePreferredWidth), AUXILIARY_PANE_MIN_WIDTH, maxWidth); | ||
| 218 | } | ||
| 219 | |||
| 220 | export function deriveCenteredContentHorizontalPadding(input: { | ||
| 221 | readonly viewportWidth: number; | ||
| 222 | readonly maxContentWidth: number | null; | ||
| 223 | readonly minimumPadding: number; | ||
| 224 | }): number { | ||
| 225 | const viewportWidth = Number.isFinite(input.viewportWidth) ? Math.max(0, input.viewportWidth) : 0; | ||
| 226 | const minimumPadding = Number.isFinite(input.minimumPadding) | ||
| 227 | ? Math.max(0, input.minimumPadding) | ||
| 228 | : 0; | ||
| 229 | |||
| 230 | if ( | ||
| 231 | input.maxContentWidth === null || | ||
| 232 | !Number.isFinite(input.maxContentWidth) || | ||
| 233 | input.maxContentWidth <= 0 | ||
| 234 | ) { | ||
| 235 | return minimumPadding; | ||
| 236 | } | ||
| 237 | |||
| 238 | return minimumPadding + Math.max(0, (viewportWidth - input.maxContentWidth) / 2); | ||
| 239 | } | ||
| 240 | |||
| 241 | export function deriveStableFormSheetDetent(containerHeight: number): number { | ||
| 242 | if (!Number.isFinite(containerHeight) || containerHeight <= 0) { | ||
| 243 | return STABLE_FORM_SHEET_MAX_DETENT; | ||
| 244 | } | ||
| 245 | |||
| 246 | const targetHeight = Math.min( | ||
| 247 | STABLE_FORM_SHEET_MAX_HEIGHT, | ||
| 248 | Math.max(0, containerHeight - STABLE_FORM_SHEET_VERTICAL_MARGIN), | ||
| 249 | ); | ||
| 250 | const detent = clamp( | ||
| 251 | targetHeight / containerHeight, | ||
| 252 | STABLE_FORM_SHEET_MIN_DETENT, | ||
| 253 | STABLE_FORM_SHEET_MAX_DETENT, | ||
| 254 | ); | ||
| 255 | return Math.round(detent * 1_000) / 1_000; | ||
| 256 | } |
apps/mobile/src/lib/routes.test.ts deleted-45| ... | @@ -1,45 +0,0 @@ | ||
| 1 | import { describe, expect, it } from "vite-plus/test"; | ||
| 2 | import { EnvironmentId, ThreadId } from "@t3tools/contracts"; | ||
| 3 | |||
| 4 | import { buildThreadFilesNavigation, buildThreadFilesRoutePath } from "./routes"; | ||
| 5 | |||
| 6 | const thread = { | ||
| 7 | environmentId: EnvironmentId.make("environment-1"), | ||
| 8 | threadId: ThreadId.make("thread-1"), | ||
| 9 | }; | ||
| 10 | |||
| 11 | describe("thread file routes", () => { | ||
| 12 | it("includes an optional source line in string routes", () => { | ||
| 13 | expect(buildThreadFilesRoutePath(thread, "src/main.ts", 42)).toBe( | ||
| 14 | "/threads/environment-1/thread-1/files/src/main.ts?line=42", | ||
| 15 | ); | ||
| 16 | }); | ||
| 17 | |||
| 18 | it("encodes each file path segment without encoding separators", () => { | ||
| 19 | expect(buildThreadFilesRoutePath(thread, "docs/My File#1.md")).toBe( | ||
| 20 | "/threads/environment-1/thread-1/files/docs/My%20File%231.md", | ||
| 21 | ); | ||
| 22 | }); | ||
| 23 | |||
| 24 | it("builds typed navigation params for a file and source line", () => { | ||
| 25 | expect(buildThreadFilesNavigation(thread, "src/main.ts", 42)).toEqual({ | ||
| 26 | pathname: "/threads/[environmentId]/[threadId]/files/[...path]", | ||
| 27 | params: { | ||
| 28 | environmentId: "environment-1", | ||
| 29 | threadId: "thread-1", | ||
| 30 | path: ["src", "main.ts"], | ||
| 31 | line: "42", | ||
| 32 | }, | ||
| 33 | }); | ||
| 34 | }); | ||
| 35 | |||
| 36 | it("targets the files index when no file path is provided", () => { | ||
| 37 | expect(buildThreadFilesNavigation(thread)).toEqual({ | ||
| 38 | pathname: "/threads/[environmentId]/[threadId]/files", | ||
| 39 | params: { | ||
| 40 | environmentId: "environment-1", | ||
| 41 | threadId: "thread-1", | ||
| 42 | }, | ||
| 43 | }); | ||
| 44 | }); | ||
| 45 | }); | ||
apps/mobile/src/lib/routes.ts deleted-134| ... | @@ -1,134 +0,0 @@ | ||
| 1 | import type { Href, useRouter } from "expo-router"; | ||
| 2 | import { type EnvironmentThreadShell } from "@t3tools/client-runtime/state/shell"; | ||
| 3 | import type { EnvironmentId, ThreadId } from "@t3tools/contracts"; | ||
| 4 | |||
| 5 | import type { SelectedThreadRef } from "../state/remote-runtime-types"; | ||
| 6 | |||
| 7 | type Router = ReturnType<typeof useRouter>; | ||
| 8 | |||
| 9 | type ThreadRouteInput = | ||
| 10 | | Pick<SelectedThreadRef, "environmentId" | "threadId"> | ||
| 11 | | Pick<EnvironmentThreadShell, "environmentId" | "id">; | ||
| 12 | type PlainThreadRouteInput = | ||
| 13 | | { | ||
| 14 | environmentId: EnvironmentId; | ||
| 15 | threadId: ThreadId; | ||
| 16 | } | ||
| 17 | | { | ||
| 18 | environmentId: EnvironmentId; | ||
| 19 | id: ThreadId; | ||
| 20 | }; | ||
| 21 | |||
| 22 | export function buildThreadRoutePath(input: ThreadRouteInput | PlainThreadRouteInput): string { | ||
| 23 | const environmentId = input.environmentId; | ||
| 24 | const threadId = "threadId" in input ? input.threadId : input.id; | ||
| 25 | |||
| 26 | return `/threads/${encodeURIComponent(environmentId)}/${encodeURIComponent(threadId)}`; | ||
| 27 | } | ||
| 28 | |||
| 29 | export function buildThreadReviewRoutePath( | ||
| 30 | input: ThreadRouteInput | PlainThreadRouteInput, | ||
| 31 | ): string { | ||
| 32 | return `${buildThreadRoutePath(input)}/review`; | ||
| 33 | } | ||
| 34 | |||
| 35 | export function buildThreadFilesRoutePath( | ||
| 36 | input: ThreadRouteInput | PlainThreadRouteInput, | ||
| 37 | relativePath?: string | null, | ||
| 38 | line?: number | null, | ||
| 39 | ): string { | ||
| 40 | const basePath = `${buildThreadRoutePath(input)}/files`; | ||
| 41 | if (!relativePath) { | ||
| 42 | return basePath; | ||
| 43 | } | ||
| 44 | |||
| 45 | const pathSegments = relativePath.split("/").filter((segment) => segment.length > 0); | ||
| 46 | if (pathSegments.length === 0) { | ||
| 47 | return basePath; | ||
| 48 | } | ||
| 49 | |||
| 50 | const encodedPath = pathSegments.map(encodeURIComponent).join("/"); | ||
| 51 | const lineParam = | ||
| 52 | Number.isFinite(line) && Number(line) > 0 ? `?line=${Math.floor(Number(line))}` : ""; | ||
| 53 | return `${basePath}/${encodedPath}${lineParam}`; | ||
| 54 | } | ||
| 55 | |||
| 56 | export function buildThreadTerminalRoutePath( | ||
| 57 | input: ThreadRouteInput | PlainThreadRouteInput, | ||
| 58 | terminalId?: string | null, | ||
| 59 | ): string { | ||
| 60 | const basePath = `${buildThreadRoutePath(input)}/terminal`; | ||
| 61 | if (!terminalId) { | ||
| 62 | return basePath; | ||
| 63 | } | ||
| 64 | |||
| 65 | return `${basePath}?terminalId=${encodeURIComponent(terminalId)}`; | ||
| 66 | } | ||
| 67 | |||
| 68 | /** | ||
| 69 | * Prefer this over {@link buildThreadTerminalRoutePath} with `router.push(string)` — Expo Router | ||
| 70 | * often does not merge query strings into `useLocalSearchParams`, which breaks terminal bootstrap | ||
| 71 | * (`requestedTerminalId` stays null while the UI assumes `default`). | ||
| 72 | */ | ||
| 73 | export function buildThreadTerminalNavigation( | ||
| 74 | input: ThreadRouteInput | PlainThreadRouteInput, | ||
| 75 | terminalId?: string | null, | ||
| 76 | ): Href { | ||
| 77 | const environmentId = String(input.environmentId); | ||
| 78 | const threadId = String("threadId" in input ? input.threadId : input.id); | ||
| 79 | |||
| 80 | const params: { environmentId: string; threadId: string; terminalId?: string } = { | ||
| 81 | environmentId, | ||
| 82 | threadId, | ||
| 83 | }; | ||
| 84 | |||
| 85 | if (terminalId != null && terminalId !== "") { | ||
| 86 | params.terminalId = terminalId; | ||
| 87 | } | ||
| 88 | |||
| 89 | return { | ||
| 90 | pathname: "/threads/[environmentId]/[threadId]/terminal", | ||
| 91 | params, | ||
| 92 | }; | ||
| 93 | } | ||
| 94 | |||
| 95 | export function buildThreadFilesNavigation( | ||
| 96 | input: ThreadRouteInput | PlainThreadRouteInput, | ||
| 97 | relativePath?: string | null, | ||
| 98 | line?: number | null, | ||
| 99 | ): Href { | ||
| 100 | const environmentId = String(input.environmentId); | ||
| 101 | const threadId = String("threadId" in input ? input.threadId : input.id); | ||
| 102 | const path = relativePath?.split("/").filter((segment) => segment.length > 0) ?? []; | ||
| 103 | |||
| 104 | if (path.length === 0) { | ||
| 105 | return { | ||
| 106 | pathname: "/threads/[environmentId]/[threadId]/files", | ||
| 107 | params: { environmentId, threadId }, | ||
| 108 | }; | ||
| 109 | } | ||
| 110 | |||
| 111 | const params: { | ||
| 112 | environmentId: string; | ||
| 113 | threadId: string; | ||
| 114 | path: string[]; | ||
| 115 | line?: string; | ||
| 116 | } = { environmentId, threadId, path }; | ||
| 117 | if (Number.isFinite(line) && Number(line) > 0) { | ||
| 118 | params.line = String(Math.floor(Number(line))); | ||
| 119 | } | ||
| 120 | |||
| 121 | return { | ||
| 122 | pathname: "/threads/[environmentId]/[threadId]/files/[...path]", | ||
| 123 | params, | ||
| 124 | }; | ||
| 125 | } | ||
| 126 | |||
| 127 | export function dismissRoute(router: Router) { | ||
| 128 | if (router.canGoBack()) { | ||
| 129 | router.back(); | ||
| 130 | return; | ||
| 131 | } | ||
| 132 | |||
| 133 | router.replace("/"); | ||
| 134 | } | ||
apps/mobile/src/lib/storage.ts+26-3| ... | @@ -59,7 +59,14 @@ export class MobileStorageEncodeError extends Schema.TaggedErrorClass<MobileStor | ... | @@ -59,7 +59,14 @@ export class MobileStorageEncodeError extends Schema.TaggedErrorClass<MobileStor |
| 59 | 59 | ||
| 60 | export interface Preferences { | 60 | export interface Preferences { |
| 61 | readonly liveActivitiesEnabled?: boolean; | 61 | readonly liveActivitiesEnabled?: boolean; |
| 62 | readonly terminalFontSize?: number; | 62 | readonly baseFontSize?: number; |
| 63 | /** Terminal font size override; null/absent means derived from baseFontSize. */ | ||
| 64 | readonly terminalFontSize?: number | null; | ||
| 65 | /** Legacy key predating baseFontSize; read once for migration. */ | ||
| 66 | readonly markdownFontSize?: number; | ||
| 67 | /** Code/diff font size override; null/absent means derived from baseFontSize. */ | ||
| 68 | readonly codeFontSize?: number | null; | ||
| 69 | readonly codeWordBreak?: boolean; | ||
| 63 | } | 70 | } |
| 64 | 71 | ||
| 65 | async function readStorageItem(key: MobileStorageKeyValue): Promise<string | null> { | 72 | async function readStorageItem(key: MobileStorageKeyValue): Promise<string | null> { |
| ... | @@ -153,15 +160,31 @@ export async function loadPreferences(): Promise<Preferences> { | ... | @@ -153,15 +160,31 @@ export async function loadPreferences(): Promise<Preferences> { |
| 153 | 160 | ||
| 154 | const preferences: { | 161 | const preferences: { |
| 155 | liveActivitiesEnabled?: boolean; | 162 | liveActivitiesEnabled?: boolean; |
| 156 | terminalFontSize?: number; | 163 | baseFontSize?: number; |
| 164 | terminalFontSize?: number | null; | ||
| 165 | markdownFontSize?: number; | ||
| 166 | codeFontSize?: number | null; | ||
| 167 | codeWordBreak?: boolean; | ||
| 157 | } = {}; | 168 | } = {}; |
| 158 | 169 | ||
| 159 | if (typeof parsed.liveActivitiesEnabled === "boolean") { | 170 | if (typeof parsed.liveActivitiesEnabled === "boolean") { |
| 160 | preferences.liveActivitiesEnabled = parsed.liveActivitiesEnabled; | 171 | preferences.liveActivitiesEnabled = parsed.liveActivitiesEnabled; |
| 161 | } | 172 | } |
| 162 | if (typeof parsed.terminalFontSize === "number") { | 173 | if (typeof parsed.baseFontSize === "number") { |
| 174 | preferences.baseFontSize = parsed.baseFontSize; | ||
| 175 | } | ||
| 176 | if (typeof parsed.terminalFontSize === "number" || parsed.terminalFontSize === null) { | ||
| 163 | preferences.terminalFontSize = parsed.terminalFontSize; | 177 | preferences.terminalFontSize = parsed.terminalFontSize; |
| 164 | } | 178 | } |
| 179 | if (typeof parsed.markdownFontSize === "number") { | ||
| 180 | preferences.markdownFontSize = parsed.markdownFontSize; | ||
| 181 | } | ||
| 182 | if (typeof parsed.codeFontSize === "number" || parsed.codeFontSize === null) { | ||
| 183 | preferences.codeFontSize = parsed.codeFontSize; | ||
| 184 | } | ||
| 185 | if (typeof parsed.codeWordBreak === "boolean") { | ||
| 186 | preferences.codeWordBreak = parsed.codeWordBreak; | ||
| 187 | } | ||
| 165 | 188 | ||
| 166 | return preferences; | 189 | return preferences; |
| 167 | } | 190 | } |
apps/mobile/src/lib/typography.test.ts+4-7| ... | @@ -3,21 +3,18 @@ import { describe, expect, it } from "vite-plus/test"; | ... | @@ -3,21 +3,18 @@ import { describe, expect, it } from "vite-plus/test"; |
| 3 | import { MOBILE_CODE_SURFACE, MOBILE_TYPOGRAPHY } from "./typography"; | 3 | import { MOBILE_CODE_SURFACE, MOBILE_TYPOGRAPHY } from "./typography"; |
| 4 | 4 | ||
| 5 | describe("mobile typography", () => { | 5 | describe("mobile typography", () => { |
| 6 | it("uses the intentional compact mobile font scale", () => { | 6 | it("uses the intentional mobile font scale anchored at a 16pt body", () => { |
| 7 | expect(Object.values(MOBILE_TYPOGRAPHY).map(({ fontSize }) => fontSize)).toEqual([ | 7 | expect(Object.values(MOBILE_TYPOGRAPHY).map(({ fontSize }) => fontSize)).toEqual([ |
| 8 | 10, 11, 12, 13, 14, 15, 17, 20, 24, 28, | 8 | 11, 12, 13, 14, 16, 18, 21, 26, 30, |
| 9 | ]); | 9 | ]); |
| 10 | }); | 10 | expect(MOBILE_TYPOGRAPHY.body).toEqual({ fontSize: 16, lineHeight: 23 }); |
| 11 | |||
| 12 | it("uses a compact shared style for editable composer text", () => { | ||
| 13 | expect(MOBILE_TYPOGRAPHY.composer).toEqual({ fontSize: 14, lineHeight: 20 }); | ||
| 14 | }); | 11 | }); |
| 15 | 12 | ||
| 16 | it("uses caption-sized code with a compact readable row height", () => { | 13 | it("uses caption-sized code with a compact readable row height", () => { |
| 17 | expect(MOBILE_CODE_SURFACE).toMatchObject({ | 14 | expect(MOBILE_CODE_SURFACE).toMatchObject({ |
| 18 | fontSize: MOBILE_TYPOGRAPHY.caption.fontSize, | 15 | fontSize: MOBILE_TYPOGRAPHY.caption.fontSize, |
| 19 | lineNumberFontSize: MOBILE_TYPOGRAPHY.micro.fontSize, | 16 | lineNumberFontSize: MOBILE_TYPOGRAPHY.micro.fontSize, |
| 20 | rowHeight: 20, | 17 | rowHeight: 22, |
| 21 | }); | 18 | }); |
| 22 | }); | 19 | }); |
| 23 | }); | 20 | }); |
apps/mobile/src/lib/typography.ts+10-11| ... | @@ -1,19 +1,18 @@ | ... | @@ -1,19 +1,18 @@ |
| 1 | export const MOBILE_TYPOGRAPHY = { | 1 | export const MOBILE_TYPOGRAPHY = { |
| 2 | micro: { fontSize: 10, lineHeight: 13 }, | 2 | micro: { fontSize: 11, lineHeight: 14 }, |
| 3 | caption: { fontSize: 11, lineHeight: 15 }, | 3 | caption: { fontSize: 12, lineHeight: 16 }, |
| 4 | label: { fontSize: 12, lineHeight: 16 }, | 4 | label: { fontSize: 13, lineHeight: 17 }, |
| 5 | footnote: { fontSize: 13, lineHeight: 18 }, | 5 | footnote: { fontSize: 14, lineHeight: 19 }, |
| 6 | composer: { fontSize: 14, lineHeight: 20 }, | 6 | body: { fontSize: 16, lineHeight: 23 }, |
| 7 | body: { fontSize: 15, lineHeight: 22 }, | 7 | headline: { fontSize: 18, lineHeight: 23 }, |
| 8 | headline: { fontSize: 17, lineHeight: 22 }, | 8 | title: { fontSize: 21, lineHeight: 28 }, |
| 9 | title: { fontSize: 20, lineHeight: 26 }, | 9 | largeTitle: { fontSize: 26, lineHeight: 32 }, |
| 10 | largeTitle: { fontSize: 24, lineHeight: 30 }, | 10 | display: { fontSize: 30, lineHeight: 36 }, |
| 11 | display: { fontSize: 28, lineHeight: 34 }, | ||
| 12 | } as const; | 11 | } as const; |
| 13 | 12 | ||
| 14 | /** Shared geometry for dense, horizontally scrolling code surfaces. */ | 13 | /** Shared geometry for dense, horizontally scrolling code surfaces. */ |
| 15 | export const MOBILE_CODE_SURFACE = { | 14 | export const MOBILE_CODE_SURFACE = { |
| 16 | rowHeight: 20, | 15 | rowHeight: 22, |
| 17 | gutterWidth: 46, | 16 | gutterWidth: 46, |
| 18 | codePadding: 7, | 17 | codePadding: 7, |
| 19 | textVerticalInset: 2, | 18 | textVerticalInset: 2, |
apps/mobile/src/native/StackHeader.tsx created+359| ... | @@ -0,0 +1,359 @@ | ||
| 1 | import { useNavigation, type ParamListBase } from "@react-navigation/native"; | ||
| 2 | import type { | ||
| 3 | NativeStackHeaderItem, | ||
| 4 | NativeStackHeaderItemMenu, | ||
| 5 | NativeStackNavigationOptions, | ||
| 6 | NativeStackNavigationProp, | ||
| 7 | } from "@react-navigation/native-stack"; | ||
| 8 | import { | ||
| 9 | Children, | ||
| 10 | isValidElement, | ||
| 11 | useEffect, | ||
| 12 | useLayoutEffect, | ||
| 13 | useMemo, | ||
| 14 | type ReactElement, | ||
| 15 | type ReactNode, | ||
| 16 | } from "react"; | ||
| 17 | import type { ColorValue } from "react-native"; | ||
| 18 | |||
| 19 | export { | ||
| 20 | nativeHeaderScrollEdgeEffects, | ||
| 21 | nativeTopScrollEdgeEffect, | ||
| 22 | type NativeHeaderScrollEdgeEffects, | ||
| 23 | type NativeTopScrollEdgeEffect, | ||
| 24 | } from "./scrollEdgeEffects"; | ||
| 25 | |||
| 26 | export type AppNativeStackNavigationOptions = Omit< | ||
| 27 | NativeStackNavigationOptions, | ||
| 28 | "headerTintColor" | "unstable_headerLeftItems" | "unstable_headerRightItems" | ||
| 29 | > & { | ||
| 30 | readonly headerTintColor?: string | ColorValue; | ||
| 31 | readonly unstable_headerCenterItems?: unknown; | ||
| 32 | readonly unstable_headerLeftItems?: unknown; | ||
| 33 | readonly unstable_headerRightItems?: unknown; | ||
| 34 | readonly unstable_headerSubtitle?: unknown; | ||
| 35 | readonly unstable_headerToolbarItems?: unknown; | ||
| 36 | readonly unstable_navigationItemStyle?: unknown; | ||
| 37 | }; | ||
| 38 | |||
| 39 | function useNativeStackNavigation(): NativeStackNavigationProp<ParamListBase> | null { | ||
| 40 | return useNavigation<NativeStackNavigationProp<ParamListBase>>(); | ||
| 41 | } | ||
| 42 | |||
| 43 | function normalizeScreenOptions( | ||
| 44 | options: AppNativeStackNavigationOptions | undefined, | ||
| 45 | ): NativeStackNavigationOptions | undefined { | ||
| 46 | if (!options) { | ||
| 47 | return options; | ||
| 48 | } | ||
| 49 | |||
| 50 | const normalized = { ...options } as NativeStackNavigationOptions & { | ||
| 51 | unstable_navigationItemStyle?: unknown; | ||
| 52 | unstable_headerCenterItems?: unknown; | ||
| 53 | unstable_headerSubtitle?: unknown; | ||
| 54 | unstable_headerToolbarItems?: unknown; | ||
| 55 | }; | ||
| 56 | |||
| 57 | if (normalized.headerTintColor !== undefined) { | ||
| 58 | normalized.headerTintColor = String(normalized.headerTintColor); | ||
| 59 | } | ||
| 60 | |||
| 61 | return normalized as NativeStackNavigationOptions; | ||
| 62 | } | ||
| 63 | |||
| 64 | export function NativeStackScreenOptions(props: { | ||
| 65 | readonly options?: AppNativeStackNavigationOptions; | ||
| 66 | readonly listeners?: Record<string, (event: never) => void>; | ||
| 67 | readonly name?: string; | ||
| 68 | }) { | ||
| 69 | const navigation = useNativeStackNavigation(); | ||
| 70 | const normalizedOptions = useMemo(() => normalizeScreenOptions(props.options), [props.options]); | ||
| 71 | |||
| 72 | useLayoutEffect(() => { | ||
| 73 | if (!navigation || !normalizedOptions) { | ||
| 74 | return; | ||
| 75 | } | ||
| 76 | navigation.setOptions(normalizedOptions); | ||
| 77 | }, [navigation, normalizedOptions]); | ||
| 78 | |||
| 79 | useEffect(() => { | ||
| 80 | if (!navigation || !props.listeners) { | ||
| 81 | return; | ||
| 82 | } | ||
| 83 | const subscriptions = Object.entries(props.listeners).map(([eventName, listener]) => | ||
| 84 | navigation.addListener(eventName as never, listener as never), | ||
| 85 | ); | ||
| 86 | return () => { | ||
| 87 | for (const unsubscribe of subscriptions) { | ||
| 88 | unsubscribe(); | ||
| 89 | } | ||
| 90 | }; | ||
| 91 | }, [navigation, props.listeners]); | ||
| 92 | |||
| 93 | return null; | ||
| 94 | } | ||
| 95 | |||
| 96 | function labelFromChildren(children: ReactNode): string { | ||
| 97 | const parts: string[] = []; | ||
| 98 | Children.forEach(children, (child) => { | ||
| 99 | if (typeof child === "string" || typeof child === "number") { | ||
| 100 | parts.push(String(child)); | ||
| 101 | } else if (isValidElement<{ children?: ReactNode }>(child)) { | ||
| 102 | parts.push(labelFromChildren(child.props.children)); | ||
| 103 | } | ||
| 104 | }); | ||
| 105 | return parts.join(""); | ||
| 106 | } | ||
| 107 | |||
| 108 | type NativeStackHeaderIcon = NonNullable< | ||
| 109 | Extract<NativeStackHeaderItem, { type: "button" }>["icon"] | ||
| 110 | >; | ||
| 111 | type NativeStackOptionsWithToolbar = NativeStackNavigationOptions & { | ||
| 112 | unstable_headerToolbarItems?: () => NativeStackHeaderItem[]; | ||
| 113 | }; | ||
| 114 | |||
| 115 | function iconFromProp(icon: unknown): NativeStackHeaderIcon | undefined { | ||
| 116 | if (typeof icon !== "string") { | ||
| 117 | return undefined; | ||
| 118 | } | ||
| 119 | return { type: "sfSymbol", name: icon as never }; | ||
| 120 | } | ||
| 121 | |||
| 122 | type ToolbarElementProps = Record<string, unknown> & { readonly children?: ReactNode }; | ||
| 123 | |||
| 124 | function elementTypeName(element: ReactElement): string | undefined { | ||
| 125 | const type = element.type; | ||
| 126 | if (typeof type === "function") { | ||
| 127 | return (type as { displayName?: string; name?: string }).displayName ?? type.name; | ||
| 128 | } | ||
| 129 | return undefined; | ||
| 130 | } | ||
| 131 | |||
| 132 | function convertMenuAction( | ||
| 133 | element: ReactElement<ToolbarElementProps>, | ||
| 134 | ): NativeStackHeaderItemMenu["menu"]["items"][number] | null { | ||
| 135 | const typeName = elementTypeName(element); | ||
| 136 | if (typeName === "NativeHeaderToolbarMenuAction") { | ||
| 137 | const label = labelFromChildren(element.props.children); | ||
| 138 | return { | ||
| 139 | type: "action", | ||
| 140 | label, | ||
| 141 | description: typeof element.props.subtitle === "string" ? element.props.subtitle : undefined, | ||
| 142 | disabled: Boolean(element.props.disabled), | ||
| 143 | icon: iconFromProp(element.props.icon), | ||
| 144 | onPress: | ||
| 145 | typeof element.props.onPress === "function" | ||
| 146 | ? (element.props.onPress as () => void) | ||
| 147 | : () => undefined, | ||
| 148 | state: element.props.isOn === true ? "on" : undefined, | ||
| 149 | destructive: Boolean(element.props.destructive), | ||
| 150 | discoverabilityLabel: | ||
| 151 | typeof element.props.discoverabilityLabel === "string" | ||
| 152 | ? element.props.discoverabilityLabel | ||
| 153 | : undefined, | ||
| 154 | }; | ||
| 155 | } | ||
| 156 | |||
| 157 | if (typeName === "NativeHeaderToolbarMenu") { | ||
| 158 | return { | ||
| 159 | type: "submenu", | ||
| 160 | label: | ||
| 161 | typeof element.props.title === "string" | ||
| 162 | ? element.props.title | ||
| 163 | : labelFromChildren(element.props.children), | ||
| 164 | icon: iconFromProp(element.props.icon), | ||
| 165 | inline: Boolean(element.props.inline), | ||
| 166 | items: collectMenuItems(element.props.children), | ||
| 167 | }; | ||
| 168 | } | ||
| 169 | |||
| 170 | return null; | ||
| 171 | } | ||
| 172 | |||
| 173 | function collectMenuItems(children: ReactNode): NativeStackHeaderItemMenu["menu"]["items"] { | ||
| 174 | const items: NativeStackHeaderItemMenu["menu"]["items"] = []; | ||
| 175 | Children.forEach(children, (child) => { | ||
| 176 | if (!isValidElement<ToolbarElementProps>(child)) { | ||
| 177 | return; | ||
| 178 | } | ||
| 179 | const item = convertMenuAction(child); | ||
| 180 | if (item) { | ||
| 181 | items.push(item); | ||
| 182 | return; | ||
| 183 | } | ||
| 184 | items.push(...collectMenuItems(child.props.children)); | ||
| 185 | }); | ||
| 186 | return items; | ||
| 187 | } | ||
| 188 | |||
| 189 | function convertToolbarChild(child: ReactNode): NativeStackHeaderItem | null { | ||
| 190 | if (!isValidElement<ToolbarElementProps>(child)) { | ||
| 191 | return null; | ||
| 192 | } | ||
| 193 | |||
| 194 | const typeName = elementTypeName(child); | ||
| 195 | if (typeName === "NativeHeaderToolbarButton") { | ||
| 196 | return { | ||
| 197 | type: "button", | ||
| 198 | label: "", | ||
| 199 | accessibilityLabel: | ||
| 200 | typeof child.props.accessibilityLabel === "string" | ||
| 201 | ? child.props.accessibilityLabel | ||
| 202 | : undefined, | ||
| 203 | disabled: Boolean(child.props.disabled), | ||
| 204 | icon: iconFromProp(child.props.icon), | ||
| 205 | onPress: | ||
| 206 | typeof child.props.onPress === "function" | ||
| 207 | ? (child.props.onPress as () => void) | ||
| 208 | : () => undefined, | ||
| 209 | sharesBackground: !child.props.separateBackground, | ||
| 210 | tintColor: child.props.tintColor as ColorValue | undefined, | ||
| 211 | variant: "plain", | ||
| 212 | }; | ||
| 213 | } | ||
| 214 | |||
| 215 | if (typeName === "NativeHeaderToolbarMenu") { | ||
| 216 | return { | ||
| 217 | type: "menu", | ||
| 218 | label: typeof child.props.title === "string" ? child.props.title : "", | ||
| 219 | accessibilityLabel: | ||
| 220 | typeof child.props.accessibilityLabel === "string" | ||
| 221 | ? child.props.accessibilityLabel | ||
| 222 | : undefined, | ||
| 223 | disabled: Boolean(child.props.disabled), | ||
| 224 | icon: iconFromProp(child.props.icon), | ||
| 225 | menu: { | ||
| 226 | title: typeof child.props.title === "string" ? child.props.title : undefined, | ||
| 227 | items: collectMenuItems(child.props.children), | ||
| 228 | }, | ||
| 229 | sharesBackground: !child.props.separateBackground, | ||
| 230 | tintColor: child.props.tintColor as ColorValue | undefined, | ||
| 231 | variant: "plain", | ||
| 232 | }; | ||
| 233 | } | ||
| 234 | |||
| 235 | if (typeName === "NativeHeaderToolbarSpacer") { | ||
| 236 | return { | ||
| 237 | type: "spacing", | ||
| 238 | spacing: typeof child.props.width === "number" ? child.props.width : 8, | ||
| 239 | }; | ||
| 240 | } | ||
| 241 | |||
| 242 | return null; | ||
| 243 | } | ||
| 244 | |||
| 245 | function collectToolbarItems(children: ReactNode): NativeStackHeaderItem[] { | ||
| 246 | const items: NativeStackHeaderItem[] = []; | ||
| 247 | Children.forEach(children, (child) => { | ||
| 248 | const item = convertToolbarChild(child); | ||
| 249 | if (item) { | ||
| 250 | items.push(item); | ||
| 251 | } | ||
| 252 | }); | ||
| 253 | return items; | ||
| 254 | } | ||
| 255 | |||
| 256 | function NativeHeaderToolbarRoot(props: { | ||
| 257 | readonly placement?: "left" | "right" | "bottom"; | ||
| 258 | readonly children?: ReactNode; | ||
| 259 | }) { | ||
| 260 | const navigation = useNativeStackNavigation(); | ||
| 261 | const items = useMemo(() => collectToolbarItems(props.children), [props.children]); | ||
| 262 | |||
| 263 | useEffect(() => { | ||
| 264 | if (!navigation) { | ||
| 265 | return; | ||
| 266 | } | ||
| 267 | if (props.placement === "bottom") { | ||
| 268 | navigation.setOptions({ | ||
| 269 | unstable_headerToolbarItems: () => items, | ||
| 270 | } as NativeStackOptionsWithToolbar); | ||
| 271 | return () => { | ||
| 272 | navigation.setOptions({ | ||
| 273 | unstable_headerToolbarItems: () => [], | ||
| 274 | } as NativeStackOptionsWithToolbar); | ||
| 275 | }; | ||
| 276 | } | ||
| 277 | if (props.placement === "left") { | ||
| 278 | navigation.setOptions({ unstable_headerLeftItems: () => items }); | ||
| 279 | return () => { | ||
| 280 | navigation.setOptions({ unstable_headerLeftItems: () => [] }); | ||
| 281 | }; | ||
| 282 | } | ||
| 283 | navigation.setOptions({ unstable_headerRightItems: () => items }); | ||
| 284 | return () => { | ||
| 285 | navigation.setOptions({ unstable_headerRightItems: () => [] }); | ||
| 286 | }; | ||
| 287 | }, [items, navigation, props.placement]); | ||
| 288 | |||
| 289 | return null; | ||
| 290 | } | ||
| 291 | |||
| 292 | function NativeHeaderToolbarButton(_props: { | ||
| 293 | readonly accessibilityLabel?: string; | ||
| 294 | readonly disabled?: boolean; | ||
| 295 | readonly icon?: string; | ||
| 296 | readonly onPress?: () => void; | ||
| 297 | readonly separateBackground?: boolean; | ||
| 298 | readonly tintColor?: ColorValue; | ||
| 299 | }) { | ||
| 300 | return null; | ||
| 301 | } | ||
| 302 | NativeHeaderToolbarButton.displayName = "NativeHeaderToolbarButton"; | ||
| 303 | |||
| 304 | function NativeHeaderToolbarMenu(_props: { | ||
| 305 | readonly accessibilityLabel?: string; | ||
| 306 | readonly children?: ReactNode; | ||
| 307 | readonly disabled?: boolean; | ||
| 308 | readonly icon?: string; | ||
| 309 | readonly inline?: boolean; | ||
| 310 | readonly separateBackground?: boolean; | ||
| 311 | readonly tintColor?: ColorValue; | ||
| 312 | readonly title?: string; | ||
| 313 | }) { | ||
| 314 | return null; | ||
| 315 | } | ||
| 316 | NativeHeaderToolbarMenu.displayName = "NativeHeaderToolbarMenu"; | ||
| 317 | |||
| 318 | function NativeHeaderToolbarMenuAction(_props: { | ||
| 319 | readonly children?: ReactNode; | ||
| 320 | readonly destructive?: boolean; | ||
| 321 | readonly disabled?: boolean; | ||
| 322 | readonly discoverabilityLabel?: string; | ||
| 323 | readonly icon?: string; | ||
| 324 | readonly isOn?: boolean; | ||
| 325 | readonly onPress?: () => void; | ||
| 326 | readonly subtitle?: string; | ||
| 327 | }) { | ||
| 328 | return null; | ||
| 329 | } | ||
| 330 | NativeHeaderToolbarMenuAction.displayName = "NativeHeaderToolbarMenuAction"; | ||
| 331 | |||
| 332 | function NativeHeaderToolbarLabel(_props: { readonly children?: ReactNode }) { | ||
| 333 | return null; | ||
| 334 | } | ||
| 335 | NativeHeaderToolbarLabel.displayName = "NativeHeaderToolbarLabel"; | ||
| 336 | |||
| 337 | function NativeHeaderToolbarSpacer(_props: { | ||
| 338 | readonly sharesBackground?: boolean; | ||
| 339 | readonly width?: number; | ||
| 340 | }) { | ||
| 341 | return null; | ||
| 342 | } | ||
| 343 | NativeHeaderToolbarSpacer.displayName = "NativeHeaderToolbarSpacer"; | ||
| 344 | |||
| 345 | function NativeHeaderToolbarSearchBarSlot() { | ||
| 346 | return null; | ||
| 347 | } | ||
| 348 | NativeHeaderToolbarSearchBarSlot.displayName = "NativeHeaderToolbarSearchBarSlot"; | ||
| 349 | |||
| 350 | export const NativeHeaderToolbar = Object.assign(NativeHeaderToolbarRoot, { | ||
| 351 | Button: NativeHeaderToolbarButton, | ||
| 352 | Label: NativeHeaderToolbarLabel, | ||
| 353 | Menu: Object.assign(NativeHeaderToolbarMenu, { | ||
| 354 | Action: NativeHeaderToolbarMenuAction, | ||
| 355 | }), | ||
| 356 | MenuAction: NativeHeaderToolbarMenuAction, | ||
| 357 | SearchBarSlot: NativeHeaderToolbarSearchBarSlot, | ||
| 358 | Spacer: NativeHeaderToolbarSpacer, | ||
| 359 | }); | ||
apps/mobile/src/native/T3ComposerEditor.ios.tsx+7-3| ... | @@ -14,8 +14,8 @@ import { Image, StyleSheet } from "react-native"; | ... | @@ -14,8 +14,8 @@ import { Image, StyleSheet } from "react-native"; |
| 14 | 14 | ||
| 15 | import { markdownFileIconSource } from "@t3tools/mobile-markdown-text/file-icons"; | 15 | import { markdownFileIconSource } from "@t3tools/mobile-markdown-text/file-icons"; |
| 16 | import { resolveMarkdownFileIcon } from "@t3tools/mobile-markdown-text/links"; | 16 | import { resolveMarkdownFileIcon } from "@t3tools/mobile-markdown-text/links"; |
| 17 | import { MOBILE_TYPOGRAPHY } from "../lib/typography"; | ||
| 18 | import { useThemeColor } from "../lib/useThemeColor"; | 17 | import { useThemeColor } from "../lib/useThemeColor"; |
| 18 | import { useScaledTextRole } from "../features/settings/appearance/useScaledTextRole"; | ||
| 19 | import { | 19 | import { |
| 20 | acknowledgeComposerNativeEvent, | 20 | acknowledgeComposerNativeEvent, |
| 21 | isComposerNativeEcho, | 21 | isComposerNativeEcho, |
| ... | @@ -69,6 +69,7 @@ interface NativeComposerEditorProps extends ViewProps { | ... | @@ -69,6 +69,7 @@ interface NativeComposerEditorProps extends ViewProps { |
| 69 | readonly onComposerPasteImages?: (event: NativePasteImagesEvent) => void; | 69 | readonly onComposerPasteImages?: (event: NativePasteImagesEvent) => void; |
| 70 | readonly onComposerFocus?: () => void; | 70 | readonly onComposerFocus?: () => void; |
| 71 | readonly onComposerBlur?: () => void; | 71 | readonly onComposerBlur?: () => void; |
| 72 | readonly onComposerSubmit?: () => void; | ||
| 72 | } | 73 | } |
| 73 | 74 | ||
| 74 | const NativeView = requireNativeView<NativeComposerEditorProps>(NATIVE_MODULE_NAME); | 75 | const NativeView = requireNativeView<NativeComposerEditorProps>(NATIVE_MODULE_NAME); |
| ... | @@ -93,6 +94,7 @@ export function ComposerEditor({ | ... | @@ -93,6 +94,7 @@ export function ComposerEditor({ |
| 93 | onPasteImages, | 94 | onPasteImages, |
| 94 | onFocus, | 95 | onFocus, |
| 95 | onBlur, | 96 | onBlur, |
| 97 | onSubmit, | ||
| 96 | contentInsetVertical = 0, | 98 | contentInsetVertical = 0, |
| 97 | ...props | 99 | ...props |
| 98 | }: ComposerEditorProps) { | 100 | }: ComposerEditorProps) { |
| ... | @@ -105,6 +107,7 @@ export function ComposerEditor({ | ... | @@ -105,6 +107,7 @@ export function ComposerEditor({ |
| 105 | { eventCount: 0, value: props.value, selection: selection ?? null }, | 107 | { eventCount: 0, value: props.value, selection: selection ?? null }, |
| 106 | ]); | 108 | ]); |
| 107 | const confirmedTokensRef = useRef(collectComposerInlineTokens(props.value)); | 109 | const confirmedTokensRef = useRef(collectComposerInlineTokens(props.value)); |
| 110 | const bodyText = useScaledTextRole("body"); | ||
| 108 | const textColor = useThemeColor("--color-foreground"); | 111 | const textColor = useThemeColor("--color-foreground"); |
| 109 | const placeholderColor = useThemeColor("--color-placeholder"); | 112 | const placeholderColor = useThemeColor("--color-placeholder"); |
| 110 | const chipBackground = useThemeColor("--color-subtle"); | 113 | const chipBackground = useThemeColor("--color-subtle"); |
| ... | @@ -230,12 +233,12 @@ export function ComposerEditor({ | ... | @@ -230,12 +233,12 @@ export function ComposerEditor({ |
| 230 | fontSize={ | 233 | fontSize={ |
| 231 | typeof resolvedTextStyle.fontSize === "number" | 234 | typeof resolvedTextStyle.fontSize === "number" |
| 232 | ? resolvedTextStyle.fontSize | 235 | ? resolvedTextStyle.fontSize |
| 233 | : MOBILE_TYPOGRAPHY.composer.fontSize | 236 | : bodyText.fontSize |
| 234 | } | 237 | } |
| 235 | lineHeight={ | 238 | lineHeight={ |
| 236 | typeof resolvedTextStyle.lineHeight === "number" | 239 | typeof resolvedTextStyle.lineHeight === "number" |
| 237 | ? resolvedTextStyle.lineHeight | 240 | ? resolvedTextStyle.lineHeight |
| 238 | : MOBILE_TYPOGRAPHY.composer.lineHeight | 241 | : bodyText.lineHeight |
| 239 | } | 242 | } |
| 240 | contentInsetVertical={contentInsetVertical} | 243 | contentInsetVertical={contentInsetVertical} |
| 241 | editable={props.editable ?? true} | 244 | editable={props.editable ?? true} |
| ... | @@ -270,6 +273,7 @@ export function ComposerEditor({ | ... | @@ -270,6 +273,7 @@ export function ComposerEditor({ |
| 270 | onComposerPasteImages={(event) => onPasteImages?.(event.nativeEvent.uris)} | 273 | onComposerPasteImages={(event) => onPasteImages?.(event.nativeEvent.uris)} |
| 271 | onComposerFocus={onFocus} | 274 | onComposerFocus={onFocus} |
| 272 | onComposerBlur={onBlur} | 275 | onComposerBlur={onBlur} |
| 276 | onComposerSubmit={onSubmit} | ||
| 273 | /> | 277 | /> |
| 274 | ); | 278 | ); |
| 275 | } | 279 | } |
apps/mobile/src/native/T3ComposerEditor.tsx+3-2| ... | @@ -2,8 +2,8 @@ import { TextInputWrapper } from "expo-paste-input"; | ... | @@ -2,8 +2,8 @@ import { TextInputWrapper } from "expo-paste-input"; |
| 2 | import { useImperativeHandle, useRef } from "react"; | 2 | import { useImperativeHandle, useRef } from "react"; |
| 3 | import { TextInput, type TextInput as RNTextInput } from "react-native"; | 3 | import { TextInput, type TextInput as RNTextInput } from "react-native"; |
| 4 | 4 | ||
| 5 | import { MOBILE_TYPOGRAPHY } from "../lib/typography"; | ||
| 6 | import { useThemeColor } from "../lib/useThemeColor"; | 5 | import { useThemeColor } from "../lib/useThemeColor"; |
| 6 | import { useScaledTextRole } from "../features/settings/appearance/useScaledTextRole"; | ||
| 7 | import { useNativePaste } from "../lib/useNativePaste"; | 7 | import { useNativePaste } from "../lib/useNativePaste"; |
| 8 | import type { ComposerEditorProps } from "./T3ComposerEditor.types"; | 8 | import type { ComposerEditorProps } from "./T3ComposerEditor.types"; |
| 9 | 9 | ||
| ... | @@ -18,6 +18,7 @@ export function ComposerEditor({ | ... | @@ -18,6 +18,7 @@ export function ComposerEditor({ |
| 18 | ...props | 18 | ...props |
| 19 | }: ComposerEditorProps) { | 19 | }: ComposerEditorProps) { |
| 20 | const inputRef = useRef<RNTextInput>(null); | 20 | const inputRef = useRef<RNTextInput>(null); |
| 21 | const bodyText = useScaledTextRole("body"); | ||
| 21 | const foregroundColor = useThemeColor("--color-foreground"); | 22 | const foregroundColor = useThemeColor("--color-foreground"); |
| 22 | const placeholderColor = useThemeColor("--color-placeholder"); | 23 | const placeholderColor = useThemeColor("--color-placeholder"); |
| 23 | const handlePaste = useNativePaste((uris) => onPasteImages?.(uris)); | 24 | const handlePaste = useNativePaste((uris) => onPasteImages?.(uris)); |
| ... | @@ -48,7 +49,7 @@ export function ComposerEditor({ | ... | @@ -48,7 +49,7 @@ export function ComposerEditor({ |
| 48 | minHeight: 0, | 49 | minHeight: 0, |
| 49 | color: foregroundColor, | 50 | color: foregroundColor, |
| 50 | fontFamily: "DMSans_400Regular", | 51 | fontFamily: "DMSans_400Regular", |
| 51 | ...MOBILE_TYPOGRAPHY.composer, | 52 | ...bodyText, |
| 52 | paddingVertical: contentInsetVertical, | 53 | paddingVertical: contentInsetVertical, |
| 53 | }, | 54 | }, |
| 54 | textStyle, | 55 | textStyle, |
apps/mobile/src/native/T3ComposerEditor.types.ts+2| ... | @@ -35,4 +35,6 @@ export interface ComposerEditorProps { | ... | @@ -35,4 +35,6 @@ export interface ComposerEditorProps { |
| 35 | readonly onPasteImages?: (uris: ReadonlyArray<string>) => void; | 35 | readonly onPasteImages?: (uris: ReadonlyArray<string>) => void; |
| 36 | readonly onFocus?: () => void; | 36 | readonly onFocus?: () => void; |
| 37 | readonly onBlur?: () => void; | 37 | readonly onBlur?: () => void; |
| 38 | /** Invoked by the native editor when Command-Return is pressed on a hardware keyboard. */ | ||
| 39 | readonly onSubmit?: () => void; | ||
| 38 | } | 40 | } |
apps/mobile/src/native/T3KeyboardCommands.ios.tsx created+31| ... | @@ -0,0 +1,31 @@ | ||
| 1 | import { requireNativeView } from "expo"; | ||
| 2 | import type { PropsWithChildren } from "react"; | ||
| 3 | import type { NativeSyntheticEvent, ViewProps } from "react-native"; | ||
| 4 | |||
| 5 | import type { HardwareKeyboardCommand } from "../features/keyboard/hardwareKeyboardCommands"; | ||
| 6 | |||
| 7 | interface NativeKeyboardCommandsProps extends ViewProps, PropsWithChildren { | ||
| 8 | readonly enabledCommands: ReadonlyArray<HardwareKeyboardCommand>; | ||
| 9 | readonly onCommand: ( | ||
| 10 | event: NativeSyntheticEvent<{ readonly command: HardwareKeyboardCommand }>, | ||
| 11 | ) => void; | ||
| 12 | } | ||
| 13 | |||
| 14 | const NativeKeyboardCommands = requireNativeView<NativeKeyboardCommandsProps>("T3KeyboardCommands"); | ||
| 15 | |||
| 16 | export function T3KeyboardCommands( | ||
| 17 | props: PropsWithChildren<{ | ||
| 18 | readonly enabledCommands: ReadonlyArray<HardwareKeyboardCommand>; | ||
| 19 | readonly onCommand: (command: HardwareKeyboardCommand) => void; | ||
| 20 | }>, | ||
| 21 | ) { | ||
| 22 | return ( | ||
| 23 | <NativeKeyboardCommands | ||
| 24 | onCommand={(event) => props.onCommand(event.nativeEvent.command)} | ||
| 25 | enabledCommands={props.enabledCommands} | ||
| 26 | style={{ flex: 1 }} | ||
| 27 | > | ||
| 28 | {props.children} | ||
| 29 | </NativeKeyboardCommands> | ||
| 30 | ); | ||
| 31 | } | ||
apps/mobile/src/native/T3KeyboardCommands.tsx created+13| ... | @@ -0,0 +1,13 @@ | ||
| 1 | import type { PropsWithChildren } from "react"; | ||
| 2 | import { View } from "react-native"; | ||
| 3 | |||
| 4 | import type { HardwareKeyboardCommand } from "../features/keyboard/hardwareKeyboardCommands"; | ||
| 5 | |||
| 6 | export function T3KeyboardCommands( | ||
| 7 | props: PropsWithChildren<{ | ||
| 8 | readonly enabledCommands: ReadonlyArray<HardwareKeyboardCommand>; | ||
| 9 | readonly onCommand: (command: HardwareKeyboardCommand) => void; | ||
| 10 | }>, | ||
| 11 | ) { | ||
| 12 | return <View style={{ flex: 1 }}>{props.children}</View>; | ||
| 13 | } | ||
apps/mobile/src/native/scrollEdgeEffects.test.ts created+29| ... | @@ -0,0 +1,29 @@ | ||
| 1 | import { describe, expect, it } from "vite-plus/test"; | ||
| 2 | |||
| 3 | import { nativeHeaderScrollEdgeEffects, nativeTopScrollEdgeEffect } from "./scrollEdgeEffects"; | ||
| 4 | |||
| 5 | describe("nativeTopScrollEdgeEffect", () => { | ||
| 6 | it("keeps the automatic native treatment on iOS 26", () => { | ||
| 7 | expect(nativeTopScrollEdgeEffect("ios", "26.5")).toBe("automatic"); | ||
| 8 | }); | ||
| 9 | |||
| 10 | it("keeps UIKit automatic scroll-edge sampling on iOS 27 and later", () => { | ||
| 11 | expect(nativeTopScrollEdgeEffect("ios", "27.0")).toBe("automatic"); | ||
| 12 | expect(nativeTopScrollEdgeEffect("ios", 28)).toBe("automatic"); | ||
| 13 | }); | ||
| 14 | |||
| 15 | it("does not apply the iOS workaround to other platforms", () => { | ||
| 16 | expect(nativeTopScrollEdgeEffect("android", 27)).toBe("automatic"); | ||
| 17 | }); | ||
| 18 | }); | ||
| 19 | |||
| 20 | describe("nativeHeaderScrollEdgeEffects", () => { | ||
| 21 | it("keeps non-top header edges hidden while applying the platform top effect", () => { | ||
| 22 | expect(nativeHeaderScrollEdgeEffects("ios", "27.0")).toEqual({ | ||
| 23 | top: "automatic", | ||
| 24 | bottom: "hidden", | ||
| 25 | left: "hidden", | ||
| 26 | right: "hidden", | ||
| 27 | }); | ||
| 28 | }); | ||
| 29 | }); | ||
apps/mobile/src/native/scrollEdgeEffects.ts created+37| ... | @@ -0,0 +1,37 @@ | ||
| 1 | // Pure helpers for native header scroll-edge effects. Kept free of | ||
| 2 | // react-native / react-navigation imports so they stay unit-testable in node | ||
| 3 | // (those packages ship untranspiled Flow syntax). | ||
| 4 | |||
| 5 | export type NativeTopScrollEdgeEffect = "automatic" | "soft"; | ||
| 6 | export type NativeHeaderScrollEdgeEffects = { | ||
| 7 | readonly top: NativeTopScrollEdgeEffect; | ||
| 8 | readonly bottom: "hidden"; | ||
| 9 | readonly left: "hidden"; | ||
| 10 | readonly right: "hidden"; | ||
| 11 | }; | ||
| 12 | |||
| 13 | export function nativeTopScrollEdgeEffect( | ||
| 14 | os: string, | ||
| 15 | _version: number | string, | ||
| 16 | ): NativeTopScrollEdgeEffect { | ||
| 17 | if (os !== "ios") { | ||
| 18 | return "automatic"; | ||
| 19 | } | ||
| 20 | |||
| 21 | // The standalone RNS/Mail spike that matched Messages/GitHub used UIKit's | ||
| 22 | // automatic scroll-edge behavior. Forcing `soft` on iOS 27 makes production | ||
| 23 | // look like a local overlay instead of sampling the app content edge-to-edge. | ||
| 24 | return "automatic"; | ||
| 25 | } | ||
| 26 | |||
| 27 | export function nativeHeaderScrollEdgeEffects( | ||
| 28 | os: string, | ||
| 29 | version: number | string, | ||
| 30 | ): NativeHeaderScrollEdgeEffects { | ||
| 31 | return { | ||
| 32 | top: nativeTopScrollEdgeEffect(os, version), | ||
| 33 | bottom: "hidden", | ||
| 34 | left: "hidden", | ||
| 35 | right: "hidden", | ||
| 36 | }; | ||
| 37 | } | ||
apps/mobile/src/state/use-thread-pr.ts created+67| ... | @@ -0,0 +1,67 @@ | ||
| 1 | import type { EnvironmentThreadShell } from "@t3tools/client-runtime/state/shell"; | ||
| 2 | import type { VcsStatusResult } from "@t3tools/contracts"; | ||
| 3 | import { resolveChangeRequestPresentation } from "@t3tools/shared/sourceControl"; | ||
| 4 | |||
| 5 | import { useEnvironmentQuery } from "./query"; | ||
| 6 | import { vcsEnvironment } from "./vcs"; | ||
| 7 | |||
| 8 | export type ThreadPr = NonNullable<VcsStatusResult["pr"]>; | ||
| 9 | |||
| 10 | export interface ThreadPrPresentation { | ||
| 11 | readonly number: number; | ||
| 12 | readonly state: ThreadPr["state"]; | ||
| 13 | readonly url: string; | ||
| 14 | /** Compact chip label, e.g. "PR open" / "MR merged". */ | ||
| 15 | readonly label: string; | ||
| 16 | readonly textClassName: string; | ||
| 17 | } | ||
| 18 | |||
| 19 | const PR_STATE_TEXT_CLASS: Record<ThreadPr["state"], string> = { | ||
| 20 | open: "text-emerald-600 dark:text-emerald-400", | ||
| 21 | merged: "text-violet-600 dark:text-violet-400", | ||
| 22 | closed: "text-zinc-500 dark:text-zinc-400", | ||
| 23 | }; | ||
| 24 | |||
| 25 | export function presentThreadPr( | ||
| 26 | pr: ThreadPr, | ||
| 27 | provider: VcsStatusResult["sourceControlProvider"] | null | undefined, | ||
| 28 | ): ThreadPrPresentation { | ||
| 29 | const shortName = resolveChangeRequestPresentation(provider).shortName; | ||
| 30 | return { | ||
| 31 | number: pr.number, | ||
| 32 | state: pr.state, | ||
| 33 | url: pr.url, | ||
| 34 | label: `${shortName} ${pr.state}`, | ||
| 35 | textClassName: PR_STATE_TEXT_CLASS[pr.state], | ||
| 36 | }; | ||
| 37 | } | ||
| 38 | |||
| 39 | /** | ||
| 40 | * Live PR status for a thread's branch. Subscriptions are deduplicated per | ||
| 41 | * (environmentId, cwd) by the atom family, so many rows on the same worktree | ||
| 42 | * or project root share one stream — and virtualization means only visible | ||
| 43 | * rows subscribe at all. | ||
| 44 | */ | ||
| 45 | export function useThreadPr( | ||
| 46 | thread: EnvironmentThreadShell, | ||
| 47 | projectCwd: string | null, | ||
| 48 | ): ThreadPrPresentation | null { | ||
| 49 | const cwd = thread.worktreePath ?? projectCwd; | ||
| 50 | const gitStatus = useEnvironmentQuery( | ||
| 51 | thread.branch !== null && cwd !== null | ||
| 52 | ? vcsEnvironment.status({ | ||
| 53 | environmentId: thread.environmentId, | ||
| 54 | input: { cwd }, | ||
| 55 | }) | ||
| 56 | : null, | ||
| 57 | ); | ||
| 58 | |||
| 59 | const status = gitStatus.data; | ||
| 60 | if (status === null || thread.branch === null || status.refName !== thread.branch) { | ||
| 61 | return null; | ||
| 62 | } | ||
| 63 | if (!status.pr) { | ||
| 64 | return null; | ||
| 65 | } | ||
| 66 | return presentThreadPr(status.pr, status.sourceControlProvider); | ||
| 67 | } | ||
apps/mobile/src/state/use-thread-selection.ts+101-20| ... | @@ -1,12 +1,25 @@ | ... | @@ -1,12 +1,25 @@ |
| 1 | import { useLocalSearchParams } from "expo-router"; | 1 | import { useRoute, type RouteProp } from "@react-navigation/native"; |
| 2 | import { useMemo } from "react"; | 2 | import { useMemo, useRef } from "react"; |
| 3 | import { EnvironmentId, ThreadId, type ScopedProjectRef } from "@t3tools/contracts"; | 3 | import { |
| 4 | EnvironmentId, | ||
| 5 | type OrchestrationThread, | ||
| 6 | ThreadId, | ||
| 7 | type ScopedProjectRef, | ||
| 8 | type ScopedThreadRef, | ||
| 9 | } from "@t3tools/contracts"; | ||
| 10 | import type { EnvironmentThreadShell } from "@t3tools/client-runtime/state/shell"; | ||
| 11 | import * as Option from "effect/Option"; | ||
| 4 | 12 | ||
| 5 | import { useProject, useThreadShell } from "../state/entities"; | 13 | import { useProject, useThreadShell } from "../state/entities"; |
| 14 | import { useEnvironmentThread } from "../state/threads"; | ||
| 6 | import { | 15 | import { |
| 7 | useRemoteEnvironmentRuntime, | 16 | useRemoteEnvironmentRuntime, |
| 8 | useSavedRemoteConnection, | 17 | useSavedRemoteConnection, |
| 9 | } from "./use-remote-environment-registry"; | 18 | } from "./use-remote-environment-registry"; |
| 19 | type ThreadSelectionRouteParams = { | ||
| 20 | readonly environmentId?: string | string[]; | ||
| 21 | readonly threadId?: string | string[]; | ||
| 22 | }; | ||
| 10 | 23 | ||
| 11 | function firstRouteParam(value: string | string[] | undefined): string | null { | 24 | function firstRouteParam(value: string | string[] | undefined): string | null { |
| 12 | if (Array.isArray(value)) { | 25 | if (Array.isArray(value)) { |
| ... | @@ -16,14 +29,48 @@ function firstRouteParam(value: string | string[] | undefined): string | null { | ... | @@ -16,14 +29,48 @@ function firstRouteParam(value: string | string[] | undefined): string | null { |
| 16 | return value ?? null; | 29 | return value ?? null; |
| 17 | } | 30 | } |
| 18 | 31 | ||
| 19 | export function useThreadSelection() { | 32 | function latestUserMessageAt(thread: OrchestrationThread): OrchestrationThread["updatedAt"] | null { |
| 20 | const params = useLocalSearchParams<{ | 33 | for (let index = thread.messages.length - 1; index >= 0; index -= 1) { |
| 21 | environmentId?: string | string[]; | 34 | const message = thread.messages[index]; |
| 22 | threadId?: string | string[]; | 35 | if (message?.role === "user") { |
| 23 | }>(); | 36 | return message.createdAt; |
| 24 | const selectedThreadRef = useMemo(() => { | 37 | } |
| 25 | const environmentId = firstRouteParam(params.environmentId); | 38 | } |
| 26 | const threadId = firstRouteParam(params.threadId); | 39 | |
| 40 | return null; | ||
| 41 | } | ||
| 42 | |||
| 43 | function threadDetailToShell( | ||
| 44 | environmentId: EnvironmentId, | ||
| 45 | thread: OrchestrationThread, | ||
| 46 | ): EnvironmentThreadShell { | ||
| 47 | return { | ||
| 48 | environmentId, | ||
| 49 | id: thread.id, | ||
| 50 | projectId: thread.projectId, | ||
| 51 | title: thread.title, | ||
| 52 | modelSelection: thread.modelSelection, | ||
| 53 | runtimeMode: thread.runtimeMode, | ||
| 54 | interactionMode: thread.interactionMode, | ||
| 55 | branch: thread.branch, | ||
| 56 | worktreePath: thread.worktreePath, | ||
| 57 | latestTurn: thread.latestTurn, | ||
| 58 | createdAt: thread.createdAt, | ||
| 59 | updatedAt: thread.updatedAt, | ||
| 60 | archivedAt: thread.archivedAt, | ||
| 61 | session: thread.session, | ||
| 62 | latestUserMessageAt: latestUserMessageAt(thread), | ||
| 63 | hasPendingApprovals: false, | ||
| 64 | hasPendingUserInput: false, | ||
| 65 | hasActionableProposedPlan: false, | ||
| 66 | }; | ||
| 67 | } | ||
| 68 | |||
| 69 | function useResolvedThreadSelection(params: ThreadSelectionRouteParams | undefined) { | ||
| 70 | const routeParams = params ?? {}; | ||
| 71 | const routeThreadRef = useMemo<ScopedThreadRef | null>(() => { | ||
| 72 | const environmentId = firstRouteParam(routeParams.environmentId); | ||
| 73 | const threadId = firstRouteParam(routeParams.threadId); | ||
| 27 | if (!environmentId || !threadId) { | 74 | if (!environmentId || !threadId) { |
| 28 | return null; | 75 | return null; |
| 29 | } | 76 | } |
| ... | @@ -32,8 +79,26 @@ export function useThreadSelection() { | ... | @@ -32,8 +79,26 @@ export function useThreadSelection() { |
| 32 | environmentId: EnvironmentId.make(environmentId), | 79 | environmentId: EnvironmentId.make(environmentId), |
| 33 | threadId: ThreadId.make(threadId), | 80 | threadId: ThreadId.make(threadId), |
| 34 | }; | 81 | }; |
| 35 | }, [params.environmentId, params.threadId]); | 82 | }, [routeParams.environmentId, routeParams.threadId]); |
| 36 | const selectedThread = useThreadShell(selectedThreadRef); | 83 | const lastRouteThreadRef = useRef<ScopedThreadRef | null>(null); |
| 84 | if (routeThreadRef !== null) { | ||
| 85 | lastRouteThreadRef.current = routeThreadRef; | ||
| 86 | } | ||
| 87 | const selectedThreadRef = routeThreadRef ?? lastRouteThreadRef.current; | ||
| 88 | const selectedThreadShell = useThreadShell(selectedThreadRef); | ||
| 89 | const selectedThreadDetailState = useEnvironmentThread( | ||
| 90 | selectedThreadRef?.environmentId ?? null, | ||
| 91 | selectedThreadRef?.threadId ?? null, | ||
| 92 | ); | ||
| 93 | const selectedThreadDetail = Option.getOrNull(selectedThreadDetailState.data); | ||
| 94 | const selectedThread = useMemo( | ||
| 95 | () => | ||
| 96 | selectedThreadShell ?? | ||
| 97 | (selectedThreadRef !== null && selectedThreadDetail !== null | ||
| 98 | ? threadDetailToShell(selectedThreadRef.environmentId, selectedThreadDetail) | ||
| 99 | : null), | ||
| 100 | [selectedThreadDetail, selectedThreadRef, selectedThreadShell], | ||
| 101 | ); | ||
| 37 | const selectedProjectRef = useMemo<ScopedProjectRef | null>( | 102 | const selectedProjectRef = useMemo<ScopedProjectRef | null>( |
| 38 | () => | 103 | () => |
| 39 | selectedThread === null | 104 | selectedThread === null |
| ... | @@ -49,11 +114,27 @@ export function useThreadSelection() { | ... | @@ -49,11 +114,27 @@ export function useThreadSelection() { |
| 49 | const selectedEnvironmentConnection = useSavedRemoteConnection(selectedEnvironmentId); | 114 | const selectedEnvironmentConnection = useSavedRemoteConnection(selectedEnvironmentId); |
| 50 | const selectedEnvironmentRuntime = useRemoteEnvironmentRuntime(selectedEnvironmentId); | 115 | const selectedEnvironmentRuntime = useRemoteEnvironmentRuntime(selectedEnvironmentId); |
| 51 | 116 | ||
| 52 | return { | 117 | return useMemo( |
| 53 | selectedThreadRef, | 118 | () => ({ |
| 54 | selectedThread, | 119 | selectedThreadRef, |
| 55 | selectedThreadProject, | 120 | selectedThread, |
| 56 | selectedEnvironmentConnection, | 121 | selectedThreadProject, |
| 57 | selectedEnvironmentRuntime, | 122 | selectedEnvironmentConnection, |
| 58 | }; | 123 | selectedEnvironmentRuntime, |
| 124 | }), | ||
| 125 | [ | ||
| 126 | selectedEnvironmentConnection, | ||
| 127 | selectedEnvironmentRuntime, | ||
| 128 | selectedThread, | ||
| 129 | selectedThreadProject, | ||
| 130 | selectedThreadRef, | ||
| 131 | ], | ||
| 132 | ); | ||
| 133 | } | ||
| 134 | |||
| 135 | type ThreadSelectionState = ReturnType<typeof useResolvedThreadSelection>; | ||
| 136 | |||
| 137 | export function useThreadSelection(): ThreadSelectionState { | ||
| 138 | const route = useRoute<RouteProp<Record<string, ThreadSelectionRouteParams | undefined>>>(); | ||
| 139 | return useResolvedThreadSelection(route.params); | ||
| 59 | } | 140 | } |
experiments/messages-glass-lab/MessagesGlassLab.xcodeproj/project.pbxproj created+285| ... | @@ -0,0 +1,285 @@ | ||
| 1 | // !$*UTF8*$! | ||
| 2 | { | ||
| 3 | 	archiveVersion = 1; | ||
| 4 | 	classes = { | ||
| 5 | 	}; | ||
| 6 | 	objectVersion = 77; | ||
| 7 | 	objects = { | ||
| 8 | |||
| 9 | /* Begin PBXBuildFile section */ | ||
| 10 | 		8A01A0012D10000100A00001 /* MessagesGlassLabApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8A01A0002D10000100A00001 /* MessagesGlassLabApp.swift */; }; | ||
| 11 | 		8A01A0032D10000100A00001 /* ContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8A01A0022D10000100A00001 /* ContentView.swift */; }; | ||
| 12 | /* End PBXBuildFile section */ | ||
| 13 | |||
| 14 | /* Begin PBXFileReference section */ | ||
| 15 | 		8A01A0002D10000100A00001 /* MessagesGlassLabApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MessagesGlassLabApp.swift; sourceTree = "<group>"; }; | ||
| 16 | 		8A01A0022D10000100A00001 /* ContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContentView.swift; sourceTree = "<group>"; }; | ||
| 17 | 		8A01A0102D10000100A00001 /* MessagesGlassLab.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = MessagesGlassLab.app; sourceTree = BUILT_PRODUCTS_DIR; }; | ||
| 18 | /* End PBXFileReference section */ | ||
| 19 | |||
| 20 | 		8A01A0202D10000100A00001 /* MessagesGlassLab */ = { | ||
| 21 | 			isa = PBXGroup; | ||
| 22 | 			children = ( | ||
| 23 | 				8A01A0002D10000100A00001 /* MessagesGlassLabApp.swift */, | ||
| 24 | 				8A01A0022D10000100A00001 /* ContentView.swift */, | ||
| 25 | 			); | ||
| 26 | 			path = MessagesGlassLab; | ||
| 27 | 			sourceTree = "<group>"; | ||
| 28 | 		}; | ||
| 29 | |||
| 30 | /* Begin PBXFrameworksBuildPhase section */ | ||
| 31 | 		8A01A0112D10000100A00001 /* Frameworks */ = { | ||
| 32 | 			isa = PBXFrameworksBuildPhase; | ||
| 33 | 			buildActionMask = 2147483647; | ||
| 34 | 			files = ( | ||
| 35 | 			); | ||
| 36 | 			runOnlyForDeploymentPostprocessing = 0; | ||
| 37 | 		}; | ||
| 38 | /* End PBXFrameworksBuildPhase section */ | ||
| 39 | |||
| 40 | /* Begin PBXGroup section */ | ||
| 41 | 		8A01A0302D10000100A00001 = { | ||
| 42 | 			isa = PBXGroup; | ||
| 43 | 			children = ( | ||
| 44 | 				8A01A0202D10000100A00001 /* MessagesGlassLab */, | ||
| 45 | 				8A01A0312D10000100A00001 /* Products */, | ||
| 46 | 			); | ||
| 47 | 			sourceTree = "<group>"; | ||
| 48 | 		}; | ||
| 49 | 		8A01A0312D10000100A00001 /* Products */ = { | ||
| 50 | 			isa = PBXGroup; | ||
| 51 | 			children = ( | ||
| 52 | 				8A01A0102D10000100A00001 /* MessagesGlassLab.app */, | ||
| 53 | 			); | ||
| 54 | 			name = Products; | ||
| 55 | 			sourceTree = "<group>"; | ||
| 56 | 		}; | ||
| 57 | /* End PBXGroup section */ | ||
| 58 | |||
| 59 | /* Begin PBXNativeTarget section */ | ||
| 60 | 		8A01A0402D10000100A00001 /* MessagesGlassLab */ = { | ||
| 61 | 			isa = PBXNativeTarget; | ||
| 62 | 			buildConfigurationList = 8A01A0502D10000100A00001 /* Build configuration list for PBXNativeTarget "MessagesGlassLab" */; | ||
| 63 | 			buildPhases = ( | ||
| 64 | 				8A01A0412D10000100A00001 /* Sources */, | ||
| 65 | 				8A01A0112D10000100A00001 /* Frameworks */, | ||
| 66 | 				8A01A0422D10000100A00001 /* Resources */, | ||
| 67 | 			); | ||
| 68 | 			buildRules = ( | ||
| 69 | 			); | ||
| 70 | 			dependencies = ( | ||
| 71 | 			); | ||
| 72 | 			name = MessagesGlassLab; | ||
| 73 | 			packageProductDependencies = ( | ||
| 74 | 			); | ||
| 75 | 			productName = MessagesGlassLab; | ||
| 76 | 			productReference = 8A01A0102D10000100A00001 /* MessagesGlassLab.app */; | ||
| 77 | 			productType = "com.apple.product-type.application"; | ||
| 78 | 		}; | ||
| 79 | /* End PBXNativeTarget section */ | ||
| 80 | |||
| 81 | /* Begin PBXProject section */ | ||
| 82 | 		8A01A0602D10000100A00001 /* Project object */ = { | ||
| 83 | 			isa = PBXProject; | ||
| 84 | 			attributes = { | ||
| 85 | 				BuildIndependentTargetsInParallel = 1; | ||
| 86 | 				LastSwiftUpdateCheck = 2700; | ||
| 87 | 				LastUpgradeCheck = 2700; | ||
| 88 | 				TargetAttributes = { | ||
| 89 | 					8A01A0402D10000100A00001 = { | ||
| 90 | 						CreatedOnToolsVersion = 27.0; | ||
| 91 | 					}; | ||
| 92 | 				}; | ||
| 93 | 			}; | ||
| 94 | 			buildConfigurationList = 8A01A0612D10000100A00001 /* Build configuration list for PBXProject "MessagesGlassLab" */; | ||
| 95 | 			compatibilityVersion = "Xcode 16.0"; | ||
| 96 | 			developmentRegion = en; | ||
| 97 | 			hasScannedForEncodings = 0; | ||
| 98 | 			knownRegions = ( | ||
| 99 | 				en, | ||
| 100 | 				Base, | ||
| 101 | 			); | ||
| 102 | 			mainGroup = 8A01A0302D10000100A00001; | ||
| 103 | 			minimizedProjectReferenceProxies = 1; | ||
| 104 | 			preferredProjectObjectVersion = 77; | ||
| 105 | 			productRefGroup = 8A01A0312D10000100A00001 /* Products */; | ||
| 106 | 			projectDirPath = ""; | ||
| 107 | 			projectRoot = ""; | ||
| 108 | 			targets = ( | ||
| 109 | 				8A01A0402D10000100A00001 /* MessagesGlassLab */, | ||
| 110 | 			); | ||
| 111 | 		}; | ||
| 112 | /* End PBXProject section */ | ||
| 113 | |||
| 114 | /* Begin PBXResourcesBuildPhase section */ | ||
| 115 | 		8A01A0422D10000100A00001 /* Resources */ = { | ||
| 116 | 			isa = PBXResourcesBuildPhase; | ||
| 117 | 			buildActionMask = 2147483647; | ||
| 118 | 			files = ( | ||
| 119 | 			); | ||
| 120 | 			runOnlyForDeploymentPostprocessing = 0; | ||
| 121 | 		}; | ||
| 122 | /* End PBXResourcesBuildPhase section */ | ||
| 123 | |||
| 124 | /* Begin PBXSourcesBuildPhase section */ | ||
| 125 | 		8A01A0412D10000100A00001 /* Sources */ = { | ||
| 126 | 			isa = PBXSourcesBuildPhase; | ||
| 127 | 			buildActionMask = 2147483647; | ||
| 128 | 			files = ( | ||
| 129 | 				8A01A0012D10000100A00001 /* MessagesGlassLabApp.swift in Sources */, | ||
| 130 | 				8A01A0032D10000100A00001 /* ContentView.swift in Sources */, | ||
| 131 | 			); | ||
| 132 | 			runOnlyForDeploymentPostprocessing = 0; | ||
| 133 | 		}; | ||
| 134 | /* End PBXSourcesBuildPhase section */ | ||
| 135 | |||
| 136 | /* Begin XCBuildConfiguration section */ | ||
| 137 | 		8A01A0622D10000100A00001 /* Debug */ = { | ||
| 138 | 			isa = XCBuildConfiguration; | ||
| 139 | 			buildSettings = { | ||
| 140 | 				ALWAYS_SEARCH_USER_PATHS = NO; | ||
| 141 | 				CLANG_ENABLE_MODULES = YES; | ||
| 142 | 				CLANG_ENABLE_OBJC_ARC = YES; | ||
| 143 | 				CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; | ||
| 144 | 				CLANG_WARN_BOOL_CONVERSION = YES; | ||
| 145 | 				CLANG_WARN_COMMA = YES; | ||
| 146 | 				CLANG_WARN_CONSTANT_CONVERSION = YES; | ||
| 147 | 				CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; | ||
| 148 | 				CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; | ||
| 149 | 				CLANG_WARN_DOCUMENTATION_COMMENTS = YES; | ||
| 150 | 				CLANG_WARN_EMPTY_BODY = YES; | ||
| 151 | 				CLANG_WARN_ENUM_CONVERSION = YES; | ||
| 152 | 				CLANG_WARN_INFINITE_RECURSION = YES; | ||
| 153 | 				CLANG_WARN_INT_CONVERSION = YES; | ||
| 154 | 				CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; | ||
| 155 | 				CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; | ||
| 156 | 				CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; | ||
| 157 | 				CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; | ||
| 158 | 				CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; | ||
| 159 | 				CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; | ||
| 160 | 				CLANG_WARN_STRICT_PROTOTYPES = YES; | ||
| 161 | 				CLANG_WARN_SUSPICIOUS_MOVE = YES; | ||
| 162 | 				CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; | ||
| 163 | 				CLANG_WARN_UNREACHABLE_CODE = YES; | ||
| 164 | 				CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; | ||
| 165 | 				COPY_PHASE_STRIP = NO; | ||
| 166 | 				DEBUG_INFORMATION_FORMAT = dwarf; | ||
| 167 | 				ENABLE_STRICT_OBJC_MSGSEND = YES; | ||
| 168 | 				ENABLE_TESTABILITY = YES; | ||
| 169 | 				GCC_C_LANGUAGE_STANDARD = gnu17; | ||
| 170 | 				GCC_DYNAMIC_NO_PIC = NO; | ||
| 171 | 				GCC_NO_COMMON_BLOCKS = YES; | ||
| 172 | 				GCC_OPTIMIZATION_LEVEL = 0; | ||
| 173 | 				GCC_PREPROCESSOR_DEFINITIONS = ( | ||
| 174 | 					"DEBUG=1", | ||
| 175 | 					"$(inherited)", | ||
| 176 | 				); | ||
| 177 | 				GCC_WARN_64_TO_32_BIT_CONVERSION = YES; | ||
| 178 | 				GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; | ||
| 179 | 				GCC_WARN_UNDECLARED_SELECTOR = YES; | ||
| 180 | 				GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; | ||
| 181 | 				GCC_WARN_UNUSED_FUNCTION = YES; | ||
| 182 | 				GCC_WARN_UNUSED_VARIABLE = YES; | ||
| 183 | 				IPHONEOS_DEPLOYMENT_TARGET = 27.0; | ||
| 184 | 				MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; | ||
| 185 | 				MTL_FAST_MATH = YES; | ||
| 186 | 				ONLY_ACTIVE_ARCH = YES; | ||
| 187 | 				SDKROOT = iphoneos; | ||
| 188 | 				SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG; | ||
| 189 | 				SWIFT_OPTIMIZATION_LEVEL = "-Onone"; | ||
| 190 | 				SWIFT_VERSION = 6.0; | ||
| 191 | 			}; | ||
| 192 | 			name = Debug; | ||
| 193 | 		}; | ||
| 194 | 		8A01A0632D10000100A00001 /* Release */ = { | ||
| 195 | 			isa = XCBuildConfiguration; | ||
| 196 | 			buildSettings = { | ||
| 197 | 				ALWAYS_SEARCH_USER_PATHS = NO; | ||
| 198 | 				CLANG_ENABLE_MODULES = YES; | ||
| 199 | 				CLANG_ENABLE_OBJC_ARC = YES; | ||
| 200 | 				COPY_PHASE_STRIP = NO; | ||
| 201 | 				DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; | ||
| 202 | 				ENABLE_NS_ASSERTIONS = NO; | ||
| 203 | 				GCC_C_LANGUAGE_STANDARD = gnu17; | ||
| 204 | 				IPHONEOS_DEPLOYMENT_TARGET = 27.0; | ||
| 205 | 				MTL_FAST_MATH = YES; | ||
| 206 | 				SDKROOT = iphoneos; | ||
| 207 | 				SWIFT_COMPILATION_MODE = wholemodule; | ||
| 208 | 				SWIFT_VERSION = 6.0; | ||
| 209 | 				VALIDATE_PRODUCT = YES; | ||
| 210 | 			}; | ||
| 211 | 			name = Release; | ||
| 212 | 		}; | ||
| 213 | 		8A01A0512D10000100A00001 /* Debug */ = { | ||
| 214 | 			isa = XCBuildConfiguration; | ||
| 215 | 			buildSettings = { | ||
| 216 | 				ASSETCATALOG_COMPILER_APPICON_NAME = ""; | ||
| 217 | 				CODE_SIGNING_ALLOWED = NO; | ||
| 218 | 				CODE_SIGNING_REQUIRED = NO; | ||
| 219 | 				CODE_SIGN_STYLE = Automatic; | ||
| 220 | 				DEVELOPMENT_TEAM = ""; | ||
| 221 | 				GENERATE_INFOPLIST_FILE = YES; | ||
| 222 | 				INFOPLIST_KEY_CFBundleDisplayName = "Messages Glass Lab"; | ||
| 223 | 				INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.developer-tools"; | ||
| 224 | 				INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES; | ||
| 225 | 				INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; | ||
| 226 | 				INFOPLIST_KEY_UILaunchScreen_Generation = YES; | ||
| 227 | 				INFOPLIST_KEY_UISupportedInterfaceOrientations = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; | ||
| 228 | 				IPHONEOS_DEPLOYMENT_TARGET = 27.0; | ||
| 229 | 				PRODUCT_BUNDLE_IDENTIFIER = com.t3tools.messagesglasslab; | ||
| 230 | 				PRODUCT_NAME = "$(TARGET_NAME)"; | ||
| 231 | 				SWIFT_EMIT_LOC_STRINGS = YES; | ||
| 232 | 				SWIFT_VERSION = 6.0; | ||
| 233 | 				TARGETED_DEVICE_FAMILY = "1,2"; | ||
| 234 | 			}; | ||
| 235 | 			name = Debug; | ||
| 236 | 		}; | ||
| 237 | 		8A01A0522D10000100A00001 /* Release */ = { | ||
| 238 | 			isa = XCBuildConfiguration; | ||
| 239 | 			buildSettings = { | ||
| 240 | 				ASSETCATALOG_COMPILER_APPICON_NAME = ""; | ||
| 241 | 				CODE_SIGNING_ALLOWED = NO; | ||
| 242 | 				CODE_SIGNING_REQUIRED = NO; | ||
| 243 | 				CODE_SIGN_STYLE = Automatic; | ||
| 244 | 				DEVELOPMENT_TEAM = ""; | ||
| 245 | 				GENERATE_INFOPLIST_FILE = YES; | ||
| 246 | 				INFOPLIST_KEY_CFBundleDisplayName = "Messages Glass Lab"; | ||
| 247 | 				INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.developer-tools"; | ||
| 248 | 				INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES; | ||
| 249 | 				INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; | ||
| 250 | 				INFOPLIST_KEY_UILaunchScreen_Generation = YES; | ||
| 251 | 				INFOPLIST_KEY_UISupportedInterfaceOrientations = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; | ||
| 252 | 				IPHONEOS_DEPLOYMENT_TARGET = 27.0; | ||
| 253 | 				PRODUCT_BUNDLE_IDENTIFIER = com.t3tools.messagesglasslab; | ||
| 254 | 				PRODUCT_NAME = "$(TARGET_NAME)"; | ||
| 255 | 				SWIFT_EMIT_LOC_STRINGS = YES; | ||
| 256 | 				SWIFT_VERSION = 6.0; | ||
| 257 | 				TARGETED_DEVICE_FAMILY = "1,2"; | ||
| 258 | 			}; | ||
| 259 | 			name = Release; | ||
| 260 | 		}; | ||
| 261 | /* End XCBuildConfiguration section */ | ||
| 262 | |||
| 263 | /* Begin XCConfigurationList section */ | ||
| 264 | 		8A01A0612D10000100A00001 /* Build configuration list for PBXProject "MessagesGlassLab" */ = { | ||
| 265 | 			isa = XCConfigurationList; | ||
| 266 | 			buildConfigurations = ( | ||
| 267 | 				8A01A0622D10000100A00001 /* Debug */, | ||
| 268 | 				8A01A0632D10000100A00001 /* Release */, | ||
| 269 | 			); | ||
| 270 | 			defaultConfigurationIsVisible = 0; | ||
| 271 | 			defaultConfigurationName = Debug; | ||
| 272 | 		}; | ||
| 273 | 		8A01A0502D10000100A00001 /* Build configuration list for PBXNativeTarget "MessagesGlassLab" */ = { | ||
| 274 | 			isa = XCConfigurationList; | ||
| 275 | 			buildConfigurations = ( | ||
| 276 | 				8A01A0512D10000100A00001 /* Debug */, | ||
| 277 | 				8A01A0522D10000100A00001 /* Release */, | ||
| 278 | 			); | ||
| 279 | 			defaultConfigurationIsVisible = 0; | ||
| 280 | 			defaultConfigurationName = Debug; | ||
| 281 | 		}; | ||
| 282 | /* End XCConfigurationList section */ | ||
| 283 | 	}; | ||
| 284 | 	rootObject = 8A01A0602D10000100A00001 /* Project object */; | ||
| 285 | } | ||
experiments/messages-glass-lab/MessagesGlassLab/ContentView.swift created+386| ... | @@ -0,0 +1,386 @@ | ||
| 1 | import SwiftUI | ||
| 2 | |||
| 3 | struct LabThread: Identifiable, Hashable { | ||
| 4 | let id = UUID() | ||
| 5 | let title: String | ||
| 6 | let subtitle: String | ||
| 7 | let time: String | ||
| 8 | let initials: String | ||
| 9 | let tint: Color | ||
| 10 | let preview: String | ||
| 11 | } | ||
| 12 | |||
| 13 | private let threads: [LabThread] = [ | ||
| 14 | .init(title: "Markdown rendering test", subtitle: "t3code · Julius’s Mac mini", time: "14h", initials: "MD", tint: .blue, preview: "Renderer stress test, terminal snippets, code blocks, and a very long markdown transcript."), | ||
| 15 | .init(title: "iPad rectly text correction", subtitle: "Julius’s Mac mini · main", time: "16h", initials: "IP", tint: .purple, preview: "Fix iPad layout, search behavior, hardware keyboard, and trackpad scrolling."), | ||
| 16 | .init(title: "Preview Webview Persists Off Panel", subtitle: "codething-mvp · Julius’s MacBook Pro", time: "22m", initials: "PW", tint: .teal, preview: "The browser preview should not leak outside the active panel when switching threads."), | ||
| 17 | .init(title: "Add file preview action buttons", subtitle: "codex/connection-preview", time: "10d", initials: "FP", tint: .orange, preview: "Open files at exact lines and expose copy/open actions in the renderer."), | ||
| 18 | .init(title: "Investigate v2 pipeline slowdown", subtitle: "codex-turn-runner", time: "1d", initials: "V2", tint: .gray, preview: "Compare orchestration traces and find why streamed events are delayed under load."), | ||
| 19 | .init(title: "Fix dark-mode header glass", subtitle: "t3code/ipad-responsive-mobile-layout", time: "2h", initials: "DG", tint: .indigo, preview: "Compare dark scroll-edge material against Messages and Mail, then map the behavior back to React Native Screens."), | ||
| 20 | .init(title: "Magic Keyboard sidebar scroll", subtitle: "mobile/input-polish", time: "3h", initials: "MK", tint: .cyan, preview: "Trackpad scrolling should remain fluid while swipe actions still work for touch gestures."), | ||
| 21 | .init(title: "Terminal tab key routing", subtitle: "terminal/native-pty", time: "5h", initials: "⌘", tint: .green, preview: "Hardware Tab should go to the terminal session instead of escaping to the thread search field."), | ||
| 22 | .init(title: "Diff renderer split inspector", subtitle: "review-diff/native", time: "8h", initials: "Δ", tint: .red, preview: "Keep file navigation, sticky headers, and selected hunk state stable in a three-column iPad layout."), | ||
| 23 | .init(title: "Composer glass affordance", subtitle: "composer/liquid-glass", time: "9h", initials: "CG", tint: .pink, preview: "Prototype a bottom composer that feels native without covering too much content while scrolling."), | ||
| 24 | .init(title: "Search placement audit", subtitle: "navigation/native-search", time: "11h", initials: "SP", tint: .mint, preview: "Validate whether search belongs in the bottom toolbar on iPhone and the sidebar chrome on iPad."), | ||
| 25 | .init(title: "Thread row density pass", subtitle: "home/messages-list", time: "12h", initials: "TR", tint: .brown, preview: "Tune row height, separators, preview text, chevrons, and status glyphs to match native list rhythm."), | ||
| 26 | .init(title: "Files navigator polish", subtitle: "files/inspector", time: "1d", initials: "FN", tint: .yellow, preview: "Make the file explorer feel like an iPad side inspector instead of a cramped web sidebar."), | ||
| 27 | .init(title: "Toolbar grouping experiment", subtitle: "native-toolbar-glass", time: "2d", initials: "TB", tint: .blue.opacity(0.7), preview: "Compare separate glass buttons with merged toolbar groups and spacing behavior."), | ||
| 28 | .init(title: "Scroll-edge fade comparison", subtitle: "swiftui/messages-lab", time: "3d", initials: "SE", tint: .purple.opacity(0.8), preview: "Record scroll positions to see where native headers start becoming visible over content."), | ||
| 29 | .init(title: "iPad sidebar selection state", subtitle: "split-view/sidebar", time: "4d", initials: "SS", tint: .teal.opacity(0.75), preview: "Find the right selected-row treatment for dark and light mode in a persistent sidebar."), | ||
| 30 | .init(title: "Preview webview panel bug", subtitle: "preview/browser", time: "5d", initials: "WB", tint: .orange.opacity(0.8), preview: "Ensure preview browser surfaces stay clipped to the active detail pane during navigation."), | ||
| 31 | .init(title: "Keyboard shortcuts overlay", subtitle: "hardware-keyboard", time: "6d", initials: "KS", tint: .gray.opacity(0.9), preview: "Expose discoverable commands and keep focus behavior aligned with iPadOS keyboard conventions."), | ||
| 32 | .init(title: "Thread loading skeleton", subtitle: "mobile/perceived-performance", time: "1w", initials: "LS", tint: .green.opacity(0.75), preview: "Replace jarring empty states with native-feeling loading rows while snapshots hydrate."), | ||
| 33 | .init(title: "Connection recovery UX", subtitle: "lan-pairing", time: "2w", initials: "CR", tint: .red.opacity(0.75), preview: "Make reconnect banners and retry affordances less intrusive during scroll and composition."), | ||
| 34 | ] | ||
| 35 | |||
| 36 | struct ContentView: View { | ||
| 37 | @Environment(\.horizontalSizeClass) private var horizontalSizeClass | ||
| 38 | @State private var searchText = "" | ||
| 39 | @State private var selectedThread: LabThread? = threads[0] | ||
| 40 | |||
| 41 | var body: some View { | ||
| 42 | if horizontalSizeClass == .regular { | ||
| 43 | NativeSplitLab(searchText: $searchText, selectedThread: $selectedThread) | ||
| 44 | } else { | ||
| 45 | NativePhoneLab(searchText: $searchText, selectedThread: $selectedThread) | ||
| 46 | } | ||
| 47 | } | ||
| 48 | } | ||
| 49 | |||
| 50 | private var filteredThreads: [LabThread] { | ||
| 51 | threads | ||
| 52 | } | ||
| 53 | |||
| 54 | private let glassDebugCodeLines: [String] = [ | ||
| 55 | "# Native RNS glass debug route", | ||
| 56 | "", | ||
| 57 | "This screen intentionally avoids Expo Router headers.", | ||
| 58 | "The native header below is owned by react-native-screens.", | ||
| 59 | "", | ||
| 60 | "Expected iOS 26 behavior:", | ||
| 61 | "- At rest: header should feel like app background", | ||
| 62 | "- While scrolled: content should blur behind the header", | ||
| 63 | "- No gray custom overlay", | ||
| 64 | "- No JS blur view", | ||
| 65 | "", | ||
| 66 | "Scroll edge effect should sample actual content:", | ||
| 67 | "const header = { translucent: true }", | ||
| 68 | "const scrollEdgeEffects = { top: 'soft' }", | ||
| 69 | "", | ||
| 70 | "Bright rows below make sampling failures obvious.", | ||
| 71 | "", | ||
| 72 | "node_modules", | ||
| 73 | "/.pnp", | ||
| 74 | ".pnp.*", | ||
| 75 | ".yarn/*", | ||
| 76 | "!.yarn/patches", | ||
| 77 | "!.yarn/plugins", | ||
| 78 | "!.yarn/releases", | ||
| 79 | "!.yarn/versions", | ||
| 80 | "", | ||
| 81 | "# testing", | ||
| 82 | "/coverage", | ||
| 83 | ".convex", | ||
| 84 | "e2e/.local-dev.json", | ||
| 85 | "e2e/playwright-report", | ||
| 86 | "e2e/test-results", | ||
| 87 | "", | ||
| 88 | "# app surfaces", | ||
| 89 | "threads", | ||
| 90 | "terminal", | ||
| 91 | "diff renderer", | ||
| 92 | "file explorer", | ||
| 93 | "composer", | ||
| 94 | "native header", | ||
| 95 | "scroll edge", | ||
| 96 | "liquid glass", | ||
| 97 | ] | ||
| 98 | |||
| 99 | private let glassDebugSwatches: [Color] = [ | ||
| 100 | .blue, | ||
| 101 | .green, | ||
| 102 | .orange, | ||
| 103 | .purple, | ||
| 104 | .cyan, | ||
| 105 | .pink, | ||
| 106 | ] | ||
| 107 | |||
| 108 | struct NativePhoneLab: View { | ||
| 109 | @Binding var searchText: String | ||
| 110 | @Binding var selectedThread: LabThread? | ||
| 111 | |||
| 112 | var body: some View { | ||
| 113 | NavigationStack { | ||
| 114 | NativeThreadLab(thread: selectedThread ?? threads[0]) | ||
| 115 | } | ||
| 116 | } | ||
| 117 | } | ||
| 118 | |||
| 119 | struct NativeSplitLab: View { | ||
| 120 | @Binding var searchText: String | ||
| 121 | @Binding var selectedThread: LabThread? | ||
| 122 | |||
| 123 | var body: some View { | ||
| 124 | NavigationSplitView { | ||
| 125 | List(filteredThreads, selection: $selectedThread) { thread in | ||
| 126 | MessageSidebarRow(thread: thread) | ||
| 127 | .tag(thread) | ||
| 128 | } | ||
| 129 | .listStyle(.sidebar) | ||
| 130 | .navigationTitle("Threads") | ||
| 131 | .searchable(text: $searchText, placement: .sidebar, prompt: "Search") | ||
| 132 | .toolbar { | ||
| 133 | ToolbarItemGroup(placement: .topBarTrailing) { | ||
| 134 | Menu { | ||
| 135 | Button("All Threads", systemImage: "tray.full") {} | ||
| 136 | Button("Ready", systemImage: "checkmark.circle") {} | ||
| 137 | Button("Running", systemImage: "bolt.circle") {} | ||
| 138 | } label: { | ||
| 139 | Image(systemName: "line.3.horizontal.decrease") | ||
| 140 | } | ||
| 141 | .buttonStyle(.glass) | ||
| 142 | |||
| 143 | Button {} label: { | ||
| 144 | Image(systemName: "gearshape") | ||
| 145 | } | ||
| 146 | .buttonStyle(.glass) | ||
| 147 | |||
| 148 | Button {} label: { | ||
| 149 | Image(systemName: "square.and.pencil") | ||
| 150 | } | ||
| 151 | .buttonStyle(.glass) | ||
| 152 | } | ||
| 153 | } | ||
| 154 | } detail: { | ||
| 155 | if let selectedThread { | ||
| 156 | NativeThreadLab(thread: selectedThread) | ||
| 157 | } else { | ||
| 158 | ContentUnavailableView("Select a thread", systemImage: "sidebar.left") | ||
| 159 | } | ||
| 160 | } | ||
| 161 | } | ||
| 162 | } | ||
| 163 | |||
| 164 | struct MessageListRow: View { | ||
| 165 | let thread: LabThread | ||
| 166 | |||
| 167 | var body: some View { | ||
| 168 | HStack(alignment: .top, spacing: 14) { | ||
| 169 | Circle() | ||
| 170 | .fill(thread.tint.gradient) | ||
| 171 | .frame(width: 52, height: 52) | ||
| 172 | .overlay { | ||
| 173 | Text(thread.initials) | ||
| 174 | .font(.headline.weight(.bold)) | ||
| 175 | .foregroundStyle(.white) | ||
| 176 | } | ||
| 177 | |||
| 178 | VStack(alignment: .leading, spacing: 4) { | ||
| 179 | HStack { | ||
| 180 | Text(thread.title) | ||
| 181 | .font(.headline.weight(.semibold)) | ||
| 182 | .lineLimit(1) | ||
| 183 | Spacer(minLength: 8) | ||
| 184 | Text(thread.time) | ||
| 185 | .foregroundStyle(.secondary) | ||
| 186 | Image(systemName: "chevron.right") | ||
| 187 | .font(.caption.weight(.semibold)) | ||
| 188 | .foregroundStyle(.tertiary) | ||
| 189 | } | ||
| 190 | |||
| 191 | Text(thread.preview) | ||
| 192 | .font(.subheadline) | ||
| 193 | .foregroundStyle(.secondary) | ||
| 194 | .lineLimit(2) | ||
| 195 | } | ||
| 196 | .padding(.vertical, 12) | ||
| 197 | } | ||
| 198 | } | ||
| 199 | } | ||
| 200 | |||
| 201 | struct MessageSidebarRow: View { | ||
| 202 | let thread: LabThread | ||
| 203 | |||
| 204 | var body: some View { | ||
| 205 | VStack(alignment: .leading, spacing: 4) { | ||
| 206 | HStack { | ||
| 207 | Text(thread.title) | ||
| 208 | .font(.headline.weight(.semibold)) | ||
| 209 | .lineLimit(1) | ||
| 210 | Spacer() | ||
| 211 | Text(thread.time) | ||
| 212 | .font(.subheadline) | ||
| 213 | .foregroundStyle(.secondary) | ||
| 214 | } | ||
| 215 | Text(thread.subtitle) | ||
| 216 | .font(.subheadline) | ||
| 217 | .foregroundStyle(.secondary) | ||
| 218 | .lineLimit(1) | ||
| 219 | } | ||
| 220 | .padding(.vertical, 6) | ||
| 221 | } | ||
| 222 | } | ||
| 223 | |||
| 224 | struct NativeThreadLab: View { | ||
| 225 | let thread: LabThread | ||
| 226 | @State private var draft = "Ask the repo agent, or run a command..." | ||
| 227 | @State private var scrollStep = 0 | ||
| 228 | |||
| 229 | private let scrollTimer = Timer.publish(every: 2.8, on: .main, in: .common).autoconnect() | ||
| 230 | private let scrollTargets = ["top", "swatches", "top", "code", "card"] | ||
| 231 | |||
| 232 | var body: some View { | ||
| 233 | ScrollViewReader { proxy in | ||
| 234 | ScrollView { | ||
| 235 | LazyVStack(alignment: .leading, spacing: 22) { | ||
| 236 | Color.clear | ||
| 237 | .frame(height: 0) | ||
| 238 | .id("top") | ||
| 239 | |||
| 240 | hero | ||
| 241 | .padding(.top, 8) | ||
| 242 | |||
| 243 | swatches | ||
| 244 | .id("swatches") | ||
| 245 | |||
| 246 | explanationCard | ||
| 247 | .id("card") | ||
| 248 | |||
| 249 | codeCard | ||
| 250 | .id("code") | ||
| 251 | } | ||
| 252 | .padding(.horizontal, 18) | ||
| 253 | .padding(.top, 8) | ||
| 254 | .padding(.bottom, 96) | ||
| 255 | } | ||
| 256 | .background(Color(uiColor: .systemBackground)) | ||
| 257 | .onReceive(scrollTimer) { _ in | ||
| 258 | guard !scrollTargets.isEmpty else { return } | ||
| 259 | |||
| 260 | let target = scrollTargets[scrollStep % scrollTargets.count] | ||
| 261 | scrollStep += 1 | ||
| 262 | |||
| 263 | withAnimation(.smooth(duration: 1.0)) { | ||
| 264 | proxy.scrollTo(target, anchor: .top) | ||
| 265 | } | ||
| 266 | } | ||
| 267 | } | ||
| 268 | .navigationTitle("RNS Glass") | ||
| 269 | .navigationSubtitle("plain react-native-screens") | ||
| 270 | .toolbar { | ||
| 271 | ToolbarItemGroup(placement: .topBarTrailing) { | ||
| 272 | Button {} label: { Image(systemName: "plus") } | ||
| 273 | .buttonStyle(.glass) | ||
| 274 | Button {} label: { Image(systemName: "magnifyingglass") } | ||
| 275 | .buttonStyle(.glass) | ||
| 276 | } | ||
| 277 | } | ||
| 278 | .safeAreaInset(edge: .bottom) { | ||
| 279 | composer | ||
| 280 | } | ||
| 281 | } | ||
| 282 | |||
| 283 | private var hero: some View { | ||
| 284 | VStack(alignment: .leading, spacing: 10) { | ||
| 285 | Text("plain react-native-screens") | ||
| 286 | .font(.subheadline.weight(.bold)) | ||
| 287 | .textCase(.uppercase) | ||
| 288 | .tracking(0.5) | ||
| 289 | .foregroundStyle(.secondary) | ||
| 290 | |||
| 291 | Text("Native scroll-edge glass") | ||
| 292 | .font(.system(size: 48, weight: .heavy, design: .default)) | ||
| 293 | .lineLimit(nil) | ||
| 294 | .minimumScaleFactor(0.72) | ||
| 295 | |||
| 296 | Text("This route uses RNS directly. The script scrolls automatically so the native header is captured both at rest and with bright content behind it.") | ||
| 297 | .font(.title3) | ||
| 298 | .foregroundStyle(.secondary) | ||
| 299 | } | ||
| 300 | } | ||
| 301 | |||
| 302 | private var swatches: some View { | ||
| 303 | LazyVGrid( | ||
| 304 | columns: [GridItem(.adaptive(minimum: 60), spacing: 12)], | ||
| 305 | alignment: .leading, | ||
| 306 | spacing: 12 | ||
| 307 | ) { | ||
| 308 | ForEach(Array(glassDebugSwatches.enumerated()), id: \.offset) { index, color in | ||
| 309 | Circle() | ||
| 310 | .fill(color.gradient) | ||
| 311 | .frame(width: 60, height: 60) | ||
| 312 | .overlay { | ||
| 313 | Text("\(index + 1)") | ||
| 314 | .font(.title2.weight(.heavy)) | ||
| 315 | .foregroundStyle(.white) | ||
| 316 | } | ||
| 317 | } | ||
| 318 | } | ||
| 319 | } | ||
| 320 | |||
| 321 | private var explanationCard: some View { | ||
| 322 | VStack(alignment: .leading, spacing: 10) { | ||
| 323 | Text("What this isolates") | ||
| 324 | .font(.title2.weight(.bold)) | ||
| 325 | |||
| 326 | Text("Native transparent header + iOS 26 scroll edge effect. No Expo Router header config, no custom blur overlay, no large title requirement.") | ||
| 327 | .font(.title3) | ||
| 328 | .foregroundStyle(.secondary) | ||
| 329 | } | ||
| 330 | .padding(22) | ||
| 331 | .frame(maxWidth: .infinity, alignment: .leading) | ||
| 332 | .background(.thinMaterial, in: RoundedRectangle(cornerRadius: 28, style: .continuous)) | ||
| 333 | .overlay { | ||
| 334 | RoundedRectangle(cornerRadius: 28, style: .continuous) | ||
| 335 | .stroke(.separator.opacity(0.35), lineWidth: 0.5) | ||
| 336 | } | ||
| 337 | } | ||
| 338 | |||
| 339 | private var codeCard: some View { | ||
| 340 | VStack(alignment: .leading, spacing: 0) { | ||
| 341 | ForEach(Array((glassDebugCodeLines + glassDebugCodeLines).enumerated()), id: \.offset) { index, line in | ||
| 342 | HStack(alignment: .top, spacing: 16) { | ||
| 343 | Text("\(index + 1)") | ||
| 344 | .foregroundStyle(.secondary) | ||
| 345 | .frame(width: 34, alignment: .trailing) | ||
| 346 | |||
| 347 | Text(line.isEmpty ? " " : line) | ||
| 348 | .frame(maxWidth: .infinity, alignment: .leading) | ||
| 349 | } | ||
| 350 | .font(.system(size: 16, design: .monospaced)) | ||
| 351 | .lineSpacing(4) | ||
| 352 | .padding(.horizontal, 16) | ||
| 353 | .padding(.vertical, 3) | ||
| 354 | } | ||
| 355 | } | ||
| 356 | .padding(.vertical, 14) | ||
| 357 | .background(.thinMaterial, in: RoundedRectangle(cornerRadius: 24, style: .continuous)) | ||
| 358 | .overlay { | ||
| 359 | RoundedRectangle(cornerRadius: 24, style: .continuous) | ||
| 360 | .stroke(.separator.opacity(0.35), lineWidth: 0.5) | ||
| 361 | } | ||
| 362 | } | ||
| 363 | |||
| 364 | private var composer: some View { | ||
| 365 | HStack(spacing: 10) { | ||
| 366 | Text(draft) | ||
| 367 | .foregroundStyle(.secondary) | ||
| 368 | .lineLimit(1) | ||
| 369 | Spacer() | ||
| 370 | Image(systemName: "arrow.up") | ||
| 371 | .font(.headline.weight(.bold)) | ||
| 372 | .frame(width: 44, height: 44) | ||
| 373 | .glassEffect(.regular.interactive(), in: Circle()) | ||
| 374 | } | ||
| 375 | .padding(.leading, 18) | ||
| 376 | .padding(.trailing, 6) | ||
| 377 | .frame(height: 56) | ||
| 378 | .glassEffect(.clear.interactive(), in: Capsule()) | ||
| 379 | .padding(.horizontal) | ||
| 380 | .padding(.vertical, 8) | ||
| 381 | } | ||
| 382 | } | ||
| 383 | |||
| 384 | #Preview { | ||
| 385 | ContentView() | ||
| 386 | } | ||
experiments/messages-glass-lab/MessagesGlassLab/MessagesGlassLabApp.swift created+10| ... | @@ -0,0 +1,10 @@ | ||
| 1 | import SwiftUI | ||
| 2 | |||
| 3 | @main | ||
| 4 | struct MessagesGlassLabApp: App { | ||
| 5 | var body: some Scene { | ||
| 6 | WindowGroup { | ||
| 7 | ContentView() | ||
| 8 | } | ||
| 9 | } | ||
| 10 | } | ||
packages/client-runtime/src/state/threadDetail.ts+1-1| ... | @@ -13,7 +13,7 @@ import { AsyncResult, Atom } from "effect/unstable/reactivity"; | ... | @@ -13,7 +13,7 @@ import { AsyncResult, Atom } from "effect/unstable/reactivity"; |
| 13 | 13 | ||
| 14 | import type { EnvironmentThread, EnvironmentThreadShell } from "./models.ts"; | 14 | import type { EnvironmentThread, EnvironmentThreadShell } from "./models.ts"; |
| 15 | import { scopeThread } from "./models.ts"; | 15 | import { scopeThread } from "./models.ts"; |
| 16 | import { EMPTY_ENVIRONMENT_THREAD_STATE, type EnvironmentThreadState } from "./threads.ts"; | 16 | import { EMPTY_ENVIRONMENT_THREAD_STATE, type EnvironmentThreadState } from "./threadState.ts"; |
| 17 | import { parseThreadKey, threadKey } from "./entities.ts"; | 17 | import { parseThreadKey, threadKey } from "./entities.ts"; |
| 18 | import { THREAD_STATE_IDLE_TTL_MS } from "./threadRetention.ts"; | 18 | import { THREAD_STATE_IDLE_TTL_MS } from "./threadRetention.ts"; |
| 19 | 19 |
packages/client-runtime/src/state/threadState.ts created+16| ... | @@ -0,0 +1,16 @@ | ||
| 1 | import type { OrchestrationThread } from "@t3tools/contracts"; | ||
| 2 | import * as Option from "effect/Option"; | ||
| 3 | |||
| 4 | export type EnvironmentThreadStatus = "empty" | "cached" | "synchronizing" | "live" | "deleted"; | ||
| 5 | |||
| 6 | export interface EnvironmentThreadState { | ||
| 7 | readonly data: Option.Option<OrchestrationThread>; | ||
| 8 | readonly status: EnvironmentThreadStatus; | ||
| 9 | readonly error: Option.Option<string>; | ||
| 10 | } | ||
| 11 | |||
| 12 | export const EMPTY_ENVIRONMENT_THREAD_STATE: EnvironmentThreadState = { | ||
| 13 | data: Option.none(), | ||
| 14 | status: "empty", | ||
| 15 | error: Option.none(), | ||
| 16 | }; | ||
packages/client-runtime/src/state/threads.ts+6-14| ... | @@ -22,20 +22,11 @@ import { parseThreadKey, threadKey } from "./entities.ts"; | ... | @@ -22,20 +22,11 @@ import { parseThreadKey, threadKey } from "./entities.ts"; |
| 22 | import { applyThreadDetailEvent } from "./threadReducer.ts"; | 22 | import { applyThreadDetailEvent } from "./threadReducer.ts"; |
| 23 | import { THREAD_STATE_IDLE_TTL_MS } from "./threadRetention.ts"; | 23 | import { THREAD_STATE_IDLE_TTL_MS } from "./threadRetention.ts"; |
| 24 | import { followStreamInEnvironment } from "./runtime.ts"; | 24 | import { followStreamInEnvironment } from "./runtime.ts"; |
| 25 | 25 | import { | |
| 26 | export type EnvironmentThreadStatus = "empty" | "cached" | "synchronizing" | "live" | "deleted"; | 26 | EMPTY_ENVIRONMENT_THREAD_STATE, |
| 27 | 27 | type EnvironmentThreadState, | |
| 28 | export interface EnvironmentThreadState { | 28 | type EnvironmentThreadStatus, |
| 29 | readonly data: Option.Option<OrchestrationThread>; | 29 | } from "./threadState.ts"; |
| 30 | readonly status: EnvironmentThreadStatus; | ||
| 31 | readonly error: Option.Option<string>; | ||
| 32 | } | ||
| 33 | |||
| 34 | export const EMPTY_ENVIRONMENT_THREAD_STATE: EnvironmentThreadState = { | ||
| 35 | data: Option.none(), | ||
| 36 | status: "empty", | ||
| 37 | error: Option.none(), | ||
| 38 | }; | ||
| 39 | 30 | ||
| 40 | function statusWithoutLiveData(data: Option.Option<OrchestrationThread>): EnvironmentThreadStatus { | 31 | function statusWithoutLiveData(data: Option.Option<OrchestrationThread>): EnvironmentThreadStatus { |
| 41 | return Option.isSome(data) ? "cached" : "empty"; | 32 | return Option.isSome(data) ? "cached" : "empty"; |
| ... | @@ -254,3 +245,4 @@ export * from "./threadCommands.ts"; | ... | @@ -254,3 +245,4 @@ export * from "./threadCommands.ts"; |
| 254 | export * from "./threadDetail.ts"; | 245 | export * from "./threadDetail.ts"; |
| 255 | export * from "./threadReducer.ts"; | 246 | export * from "./threadReducer.ts"; |
| 256 | export * from "./threadShell.ts"; | 247 | export * from "./threadShell.ts"; |
| 248 | export * from "./threadState.ts"; |
packages/shared/src/dpop.ts+9-22| ... | @@ -5,22 +5,20 @@ import * as Option from "effect/Option"; | ... | @@ -5,22 +5,20 @@ import * as Option from "effect/Option"; |
| 5 | import * as Result from "effect/Result"; | 5 | import * as Result from "effect/Result"; |
| 6 | import * as Schema from "effect/Schema"; | 6 | import * as Schema from "effect/Schema"; |
| 7 | 7 | ||
| 8 | import { DpopPublicJwk as DpopPublicJwkSchema, normalizeDpopHtu } from "./dpopCommon.ts"; | ||
| 9 | import type { DpopPublicJwk as DpopPublicJwkType } from "./dpopCommon.ts"; | ||
| 8 | import { stableStringify } from "./relaySigning.ts"; | 10 | import { stableStringify } from "./relaySigning.ts"; |
| 9 | 11 | ||
| 10 | const DPOP_TYP = "dpop+jwt"; | 12 | const DPOP_TYP = "dpop+jwt"; |
| 11 | const DPOP_ALG = "ES256"; | 13 | const DPOP_ALG = "ES256"; |
| 12 | const DEFAULT_MAX_AGE_SECONDS = 300; | 14 | const DEFAULT_MAX_AGE_SECONDS = 300; |
| 13 | 15 | ||
| 14 | export const DpopPublicJwk = Schema.Struct({ | 16 | export const DpopPublicJwk = DpopPublicJwkSchema; |
| 15 | kty: Schema.Literal("EC"), | 17 | export type DpopPublicJwk = DpopPublicJwkType; |
| 16 | crv: Schema.Literal("P-256"), | 18 | export { normalizeDpopHtu }; |
| 17 | x: Schema.String.check(Schema.isNonEmpty()), | ||
| 18 | y: Schema.String.check(Schema.isNonEmpty()), | ||
| 19 | }); | ||
| 20 | export type DpopPublicJwk = typeof DpopPublicJwk.Type; | ||
| 21 | 19 | ||
| 22 | const DpopJwtHeaderPublicJwk = Schema.Struct({ | 20 | const DpopJwtHeaderPublicJwk = Schema.Struct({ |
| 23 | ...DpopPublicJwk.fields, | 21 | ...DpopPublicJwkSchema.fields, |
| 24 | d: Schema.optionalKey(Schema.Never), | 22 | d: Schema.optionalKey(Schema.Never), |
| 25 | }); | 23 | }); |
| 26 | 24 | ||
| ... | @@ -68,7 +66,7 @@ function decodeBase64UrlDpopJwtPayload(value: string) { | ... | @@ -68,7 +66,7 @@ function decodeBase64UrlDpopJwtPayload(value: string) { |
| 68 | return decodeDpopJwtPayloadJson(Result.getOrThrow(Encoding.decodeBase64UrlString(value))); | 66 | return decodeDpopJwtPayloadJson(Result.getOrThrow(Encoding.decodeBase64UrlString(value))); |
| 69 | } | 67 | } |
| 70 | 68 | ||
| 71 | function dpopThumbprintInput(jwk: DpopPublicJwk): string { | 69 | function dpopThumbprintInput(jwk: DpopPublicJwkType): string { |
| 72 | return stableStringify({ | 70 | return stableStringify({ |
| 73 | crv: jwk.crv, | 71 | crv: jwk.crv, |
| 74 | kty: jwk.kty, | 72 | kty: jwk.kty, |
| ... | @@ -77,18 +75,7 @@ function dpopThumbprintInput(jwk: DpopPublicJwk): string { | ... | @@ -77,18 +75,7 @@ function dpopThumbprintInput(jwk: DpopPublicJwk): string { |
| 77 | }); | 75 | }); |
| 78 | } | 76 | } |
| 79 | 77 | ||
| 80 | export function normalizeDpopHtu(url: string): string | null { | 78 | export function computeDpopJwkThumbprint(jwk: DpopPublicJwkType): string { |
| 81 | try { | ||
| 82 | const parsed = new URL(url); | ||
| 83 | parsed.hash = ""; | ||
| 84 | parsed.search = ""; | ||
| 85 | return parsed.toString(); | ||
| 86 | } catch { | ||
| 87 | return null; | ||
| 88 | } | ||
| 89 | } | ||
| 90 | |||
| 91 | export function computeDpopJwkThumbprint(jwk: DpopPublicJwk): string { | ||
| 92 | return Encoding.encodeBase64Url(sha256(new TextEncoder().encode(dpopThumbprintInput(jwk)))); | 79 | return Encoding.encodeBase64Url(sha256(new TextEncoder().encode(dpopThumbprintInput(jwk)))); |
| 93 | } | 80 | } |
| 94 | 81 | ||
| ... | @@ -96,7 +83,7 @@ export function computeDpopAccessTokenHash(accessToken: string): string { | ... | @@ -96,7 +83,7 @@ export function computeDpopAccessTokenHash(accessToken: string): string { |
| 96 | return Encoding.encodeBase64Url(sha256(new TextEncoder().encode(accessToken))); | 83 | return Encoding.encodeBase64Url(sha256(new TextEncoder().encode(accessToken))); |
| 97 | } | 84 | } |
| 98 | 85 | ||
| 99 | function publicKeyBytesFromJwk(jwk: DpopPublicJwk): Uint8Array { | 86 | function publicKeyBytesFromJwk(jwk: DpopPublicJwkType): Uint8Array { |
| 100 | const x = base64UrlToBytes(jwk.x); | 87 | const x = base64UrlToBytes(jwk.x); |
| 101 | const y = base64UrlToBytes(jwk.y); | 88 | const y = base64UrlToBytes(jwk.y); |
| 102 | if (x.length !== 32 || y.length !== 32) { | 89 | if (x.length !== 32 || y.length !== 32) { |
patches/@expo%2Fmetro-config@56.0.13.patch deleted-93| ... | @@ -1,93 +0,0 @@ | ||
| 1 | diff --git a/build/serializer/sourceMap.js b/build/serializer/sourceMap.js | ||
| 2 | index 4cc9aa4..703dfe0 100644 | ||
| 3 | --- a/build/serializer/sourceMap.js | ||
| 4 | +++ b/build/serializer/sourceMap.js | ||
| 5 | @@ -20,6 +20,20 @@ function loadRemapping() { | ||
| 6 | } | ||
| 7 | return _remapping; | ||
| 8 | } | ||
| 9 | +let _sourceMapCodec; | ||
| 10 | +function loadSourceMapCodec() { | ||
| 11 | + if (!_sourceMapCodec) { | ||
| 12 | + _sourceMapCodec = require('@jridgewell/sourcemap-codec'); | ||
| 13 | + } | ||
| 14 | + return _sourceMapCodec; | ||
| 15 | +} | ||
| 16 | +let _traceMapping; | ||
| 17 | +function loadTraceMapping() { | ||
| 18 | + if (!_traceMapping) { | ||
| 19 | + _traceMapping = require('@jridgewell/trace-mapping'); | ||
| 20 | + } | ||
| 21 | + return _traceMapping; | ||
| 22 | +} | ||
| 23 | let _Generator; | ||
| 24 | function loadGenerator() { | ||
| 25 | if (!_Generator) { | ||
| 26 | @@ -199,6 +213,58 @@ function patchMetroSourceMapStringForPackedMaps() { | ||
| 27 | stock.sourceMapString = sourceMapString; | ||
| 28 | stock.sourceMapStringNonBlocking = sourceMapStringNonBlocking; | ||
| 29 | } | ||
| 30 | +// Hermes can emit mappings for Metro trailer/debug lines that have no | ||
| 31 | +// corresponding Metro source-map line. @jridgewell/remapping assumes every | ||
| 32 | +// referenced generated line exists, so strip those invalid bridge segments. | ||
| 33 | +function getGeneratedLineCount(map) { | ||
| 34 | + const { TraceMap, decodedMappings } = loadTraceMapping(); | ||
| 35 | + return decodedMappings(new TraceMap(map)).length; | ||
| 36 | +} | ||
| 37 | +function dropInvalidOriginalSegments(map, maxOriginalLine) { | ||
| 38 | + const { TraceMap, decodedMappings } = loadTraceMapping(); | ||
| 39 | + const decoded = decodedMappings(new TraceMap(map)); | ||
| 40 | + let didChange = false; | ||
| 41 | + const filtered = decoded.map((line) => { | ||
| 42 | + const filteredLine = line.filter((segment) => { | ||
| 43 | + if (segment.length < 4) { | ||
| 44 | + return true; | ||
| 45 | + } | ||
| 46 | + const sourceIndex = segment[1]; | ||
| 47 | + const sourceLine = segment[2]; | ||
| 48 | + const sourceColumn = segment[3]; | ||
| 49 | + return (sourceIndex >= 0 && | ||
| 50 | + sourceIndex < map.sources.length && | ||
| 51 | + sourceLine >= 0 && | ||
| 52 | + sourceLine < maxOriginalLine && | ||
| 53 | + sourceColumn >= 0); | ||
| 54 | + }); | ||
| 55 | + if (filteredLine.length !== line.length) { | ||
| 56 | + didChange = true; | ||
| 57 | + } | ||
| 58 | + return filteredLine; | ||
| 59 | + }); | ||
| 60 | + if (!didChange) { | ||
| 61 | + return map; | ||
| 62 | + } | ||
| 63 | + return { | ||
| 64 | + ...map, | ||
| 65 | + mappings: loadSourceMapCodec().encode(filtered), | ||
| 66 | + }; | ||
| 67 | +} | ||
| 68 | +function sanitizeSourceMapsForComposition(maps) { | ||
| 69 | + let sanitized = maps; | ||
| 70 | + for (let index = 1; index < maps.length; index++) { | ||
| 71 | + const maxOriginalLine = getGeneratedLineCount(sanitized[index - 1]); | ||
| 72 | + const next = dropInvalidOriginalSegments(sanitized[index], maxOriginalLine); | ||
| 73 | + if (next !== sanitized[index]) { | ||
| 74 | + if (sanitized === maps) { | ||
| 75 | + sanitized = maps.slice(); | ||
| 76 | + } | ||
| 77 | + sanitized[index] = next; | ||
| 78 | + } | ||
| 79 | + } | ||
| 80 | + return sanitized; | ||
| 81 | +} | ||
| 82 | // `maps[0]` is the original-most transform; `maps[maps.length - 1]` is | ||
| 83 | // the most recent. Built on `@jridgewell/remapping` instead of mozilla's | ||
| 84 | // `SourceMapConsumer`-based composer. | ||
| 85 | @@ -226,7 +292,7 @@ function composeSourceMaps(maps) { | ||
| 86 | return { ...map, ignoreList: map.x_google_ignoreList }; | ||
| 87 | }); | ||
| 88 | // Metro convention is original-first; remapping is most-recent first. | ||
| 89 | - const reversed = normalized.slice().reverse(); | ||
| 90 | + const reversed = sanitizeSourceMapsForComposition(normalized).slice().reverse(); | ||
| 91 | const composed = loadRemapping()(reversed, () => null); | ||
| 92 | // Re-emit as a plain object — remapping returns a `SourceMap` class | ||
| 93 | // instance, which doesn't round-trip JSON cleanly. | ||
patches/@expo%2Fmetro-config@56.0.14.patch created+93| ... | @@ -0,0 +1,93 @@ | ||
| 1 | diff --git a/build/serializer/sourceMap.js b/build/serializer/sourceMap.js | ||
| 2 | index 4cc9aa4..703dfe0 100644 | ||
| 3 | --- a/build/serializer/sourceMap.js | ||
| 4 | +++ b/build/serializer/sourceMap.js | ||
| 5 | @@ -20,6 +20,20 @@ function loadRemapping() { | ||
| 6 | } | ||
| 7 | return _remapping; | ||
| 8 | } | ||
| 9 | +let _sourceMapCodec; | ||
| 10 | +function loadSourceMapCodec() { | ||
| 11 | + if (!_sourceMapCodec) { | ||
| 12 | + _sourceMapCodec = require('@jridgewell/sourcemap-codec'); | ||
| 13 | + } | ||
| 14 | + return _sourceMapCodec; | ||
| 15 | +} | ||
| 16 | +let _traceMapping; | ||
| 17 | +function loadTraceMapping() { | ||
| 18 | + if (!_traceMapping) { | ||
| 19 | + _traceMapping = require('@jridgewell/trace-mapping'); | ||
| 20 | + } | ||
| 21 | + return _traceMapping; | ||
| 22 | +} | ||
| 23 | let _Generator; | ||
| 24 | function loadGenerator() { | ||
| 25 | if (!_Generator) { | ||
| 26 | @@ -199,6 +213,58 @@ function patchMetroSourceMapStringForPackedMaps() { | ||
| 27 | stock.sourceMapString = sourceMapString; | ||
| 28 | stock.sourceMapStringNonBlocking = sourceMapStringNonBlocking; | ||
| 29 | } | ||
| 30 | +// Hermes can emit mappings for Metro trailer/debug lines that have no | ||
| 31 | +// corresponding Metro source-map line. @jridgewell/remapping assumes every | ||
| 32 | +// referenced generated line exists, so strip those invalid bridge segments. | ||
| 33 | +function getGeneratedLineCount(map) { | ||
| 34 | + const { TraceMap, decodedMappings } = loadTraceMapping(); | ||
| 35 | + return decodedMappings(new TraceMap(map)).length; | ||
| 36 | +} | ||
| 37 | +function dropInvalidOriginalSegments(map, maxOriginalLine) { | ||
| 38 | + const { TraceMap, decodedMappings } = loadTraceMapping(); | ||
| 39 | + const decoded = decodedMappings(new TraceMap(map)); | ||
| 40 | + let didChange = false; | ||
| 41 | + const filtered = decoded.map((line) => { | ||
| 42 | + const filteredLine = line.filter((segment) => { | ||
| 43 | + if (segment.length < 4) { | ||
| 44 | + return true; | ||
| 45 | + } | ||
| 46 | + const sourceIndex = segment[1]; | ||
| 47 | + const sourceLine = segment[2]; | ||
| 48 | + const sourceColumn = segment[3]; | ||
| 49 | + return (sourceIndex >= 0 && | ||
| 50 | + sourceIndex < map.sources.length && | ||
| 51 | + sourceLine >= 0 && | ||
| 52 | + sourceLine < maxOriginalLine && | ||
| 53 | + sourceColumn >= 0); | ||
| 54 | + }); | ||
| 55 | + if (filteredLine.length !== line.length) { | ||
| 56 | + didChange = true; | ||
| 57 | + } | ||
| 58 | + return filteredLine; | ||
| 59 | + }); | ||
| 60 | + if (!didChange) { | ||
| 61 | + return map; | ||
| 62 | + } | ||
| 63 | + return { | ||
| 64 | + ...map, | ||
| 65 | + mappings: loadSourceMapCodec().encode(filtered), | ||
| 66 | + }; | ||
| 67 | +} | ||
| 68 | +function sanitizeSourceMapsForComposition(maps) { | ||
| 69 | + let sanitized = maps; | ||
| 70 | + for (let index = 1; index < maps.length; index++) { | ||
| 71 | + const maxOriginalLine = getGeneratedLineCount(sanitized[index - 1]); | ||
| 72 | + const next = dropInvalidOriginalSegments(sanitized[index], maxOriginalLine); | ||
| 73 | + if (next !== sanitized[index]) { | ||
| 74 | + if (sanitized === maps) { | ||
| 75 | + sanitized = maps.slice(); | ||
| 76 | + } | ||
| 77 | + sanitized[index] = next; | ||
| 78 | + } | ||
| 79 | + } | ||
| 80 | + return sanitized; | ||
| 81 | +} | ||
| 82 | // `maps[0]` is the original-most transform; `maps[maps.length - 1]` is | ||
| 83 | // the most recent. Built on `@jridgewell/remapping` instead of mozilla's | ||
| 84 | // `SourceMapConsumer`-based composer. | ||
| 85 | @@ -226,7 +292,7 @@ function composeSourceMaps(maps) { | ||
| 86 | return { ...map, ignoreList: map.x_google_ignoreList }; | ||
| 87 | }); | ||
| 88 | // Metro convention is original-first; remapping is most-recent first. | ||
| 89 | - const reversed = normalized.slice().reverse(); | ||
| 90 | + const reversed = sanitizeSourceMapsForComposition(normalized).slice().reverse(); | ||
| 91 | const composed = loadRemapping()(reversed, () => null); | ||
| 92 | // Re-emit as a plain object — remapping returns a `SourceMap` class | ||
| 93 | // instance, which doesn't round-trip JSON cleanly. | ||
patches/@react-native-menu__menu@2.0.0.patch created+46| ... | @@ -0,0 +1,46 @@ | ||
| 1 | diff --git a/ios/Shared/MenuViewImplementation.swift b/ios/Shared/MenuViewImplementation.swift | ||
| 2 | index 5c4e0da4292b15d3a27b5ea1555f11452a470815..ea19f2eec02dd78fbc78cc455c91b4e71e734d70 100644 | ||
| 3 | --- a/ios/Shared/MenuViewImplementation.swift | ||
| 4 | +++ b/ios/Shared/MenuViewImplementation.swift | ||
| 5 | @@ -88,6 +88,41 @@ public class MenuViewImplementation: UIButton { | ||
| 6 | |||
| 7 | self.menu = menu | ||
| 8 | self.showsMenuAsPrimaryAction = !shouldOpenOnLongPress | ||
| 9 | + // In long-press mode the button must not intercept touches: as a | ||
| 10 | + // full-bounds contentView it sits IN FRONT of the React children of | ||
| 11 | + // the hosting component view, which would swallow their taps (e.g. a | ||
| 12 | + // row Pressable wrapped by MenuView). The context-menu interaction is | ||
| 13 | + // hosted by the superview instead (see updateLongPressInteraction) so | ||
| 14 | + // long-press and pointer secondary-click still present the menu, with | ||
| 15 | + // the wrapped content as the zoom preview. | ||
| 16 | + self.isUserInteractionEnabled = !shouldOpenOnLongPress | ||
| 17 | + self.updateLongPressInteraction() | ||
| 18 | + } | ||
| 19 | + | ||
| 20 | + private var longPressInteraction: UIContextMenuInteraction? | ||
| 21 | + | ||
| 22 | + public override func didMoveToSuperview() { | ||
| 23 | + super.didMoveToSuperview() | ||
| 24 | + self.updateLongPressInteraction() | ||
| 25 | + } | ||
| 26 | + | ||
| 27 | + private func updateLongPressInteraction() { | ||
| 28 | + if shouldOpenOnLongPress, let host = self.superview { | ||
| 29 | + if let existing = longPressInteraction, existing.view === host { | ||
| 30 | + return | ||
| 31 | + } | ||
| 32 | + if let existing = longPressInteraction { | ||
| 33 | + existing.view?.removeInteraction(existing) | ||
| 34 | + } | ||
| 35 | + let interaction = UIContextMenuInteraction(delegate: self) | ||
| 36 | + host.addInteraction(interaction) | ||
| 37 | + longPressInteraction = interaction | ||
| 38 | + return | ||
| 39 | + } | ||
| 40 | + if let existing = longPressInteraction { | ||
| 41 | + existing.view?.removeInteraction(existing) | ||
| 42 | + longPressInteraction = nil | ||
| 43 | + } | ||
| 44 | } | ||
| 45 | |||
| 46 | public override func reactSetFrame(_ frame: CGRect) { | ||
patches/@react-navigation%2Fnative-stack@7.17.6.patch created+100| ... | @@ -0,0 +1,100 @@ | ||
| 1 | --- a/lib/module/views/useHeaderConfigProps.js | ||
| 2 | +++ b/lib/module/views/useHeaderConfigProps.js | ||
| 3 | @@ -19,6 +19,12 @@ | ||
| 4 | } | ||
| 5 | return item; | ||
| 6 | } | ||
| 7 | + if (item.type === 'mailSearchToolbar') { | ||
| 8 | + return { | ||
| 9 | + ...item, | ||
| 10 | + index | ||
| 11 | + }; | ||
| 12 | + } | ||
| 13 | if (item.type === 'button' || item.type === 'menu') { | ||
| 14 | if (item.type === 'menu' && item.menu == null) { | ||
| 15 | throw new Error(`Menu item must have a 'menu' property defined: ${JSON.stringify(item)}`); | ||
| 16 | @@ -79,7 +85,7 @@ | ||
| 17 | } | ||
| 18 | return processedItem; | ||
| 19 | } | ||
| 20 | - throw new Error(`Invalid item type: ${JSON.stringify(item)}. Valid types are 'button', 'menu', 'custom' and 'spacing'.`); | ||
| 21 | + throw new Error(`Invalid item type: ${JSON.stringify(item)}. Valid types are 'button', 'menu', 'custom', 'spacing' and 'mailSearchToolbar'.`); | ||
| 22 | }).filter(item => item != null); | ||
| 23 | }; | ||
| 24 | const transformIcon = icon => { | ||
| 25 | @@ -158,8 +164,12 @@ | ||
| 26 | headerBack, | ||
| 27 | route, | ||
| 28 | title, | ||
| 29 | + unstable_headerCenterItems: headerCenterItems, | ||
| 30 | unstable_headerLeftItems: headerLeftItems, | ||
| 31 | - unstable_headerRightItems: headerRightItems | ||
| 32 | + unstable_headerRightItems: headerRightItems, | ||
| 33 | + unstable_headerSubtitle: headerSubtitle, | ||
| 34 | + unstable_headerToolbarItems: headerToolbarItems, | ||
| 35 | + unstable_navigationItemStyle: navigationItemStyle | ||
| 36 | }) { | ||
| 37 | const { | ||
| 38 | direction | ||
| 39 | @@ -255,6 +265,10 @@ | ||
| 40 | tintColor, | ||
| 41 | canGoBack | ||
| 42 | }); | ||
| 43 | + const centerItems = headerCenterItems?.({ | ||
| 44 | + tintColor, | ||
| 45 | + canGoBack | ||
| 46 | + }); | ||
| 47 | let rightItems = headerRightItems?.({ | ||
| 48 | tintColor, | ||
| 49 | canGoBack | ||
| 50 | @@ -264,6 +278,10 @@ | ||
| 51 | // So we need to reverse them here to match the order | ||
| 52 | rightItems = [...rightItems].reverse(); | ||
| 53 | } | ||
| 54 | + const toolbarItems = headerToolbarItems?.({ | ||
| 55 | + tintColor, | ||
| 56 | + canGoBack | ||
| 57 | + }); | ||
| 58 | const children = /*#__PURE__*/_jsxs(_Fragment, { | ||
| 59 | children: [Platform.OS === 'ios' ? /*#__PURE__*/_jsxs(_Fragment, { | ||
| 60 | children: [leftItems ? leftItems.map((item, index) => { | ||
| 61 | @@ -278,7 +296,15 @@ | ||
| 62 | return null; | ||
| 63 | }) : headerLeftElement != null ? /*#__PURE__*/_jsx(ScreenStackHeaderLeftView, { | ||
| 64 | children: headerLeftElement | ||
| 65 | - }) : null, headerTitleElement != null ? /*#__PURE__*/_jsx(ScreenStackHeaderCenterView, { | ||
| 66 | + }) : null, centerItems ? centerItems.map((item, index) => { | ||
| 67 | + if (item.type === 'custom') { | ||
| 68 | + return /*#__PURE__*/_jsx(ScreenStackHeaderCenterView, { | ||
| 69 | + hidesSharedBackground: item.hidesSharedBackground, | ||
| 70 | + children: item.element | ||
| 71 | + }, index); | ||
| 72 | + } | ||
| 73 | + return null; | ||
| 74 | + }) : headerTitleElement != null ? /*#__PURE__*/_jsx(ScreenStackHeaderCenterView, { | ||
| 75 | children: headerTitleElement | ||
| 76 | }) : null] | ||
| 77 | }) : /*#__PURE__*/_jsxs(_Fragment, { | ||
| 78 | @@ -356,6 +382,7 @@ | ||
| 79 | largeTitleFontWeight, | ||
| 80 | largeTitleHideShadow: headerLargeTitleShadowVisible === false, | ||
| 81 | title: titleText, | ||
| 82 | + subtitle: headerSubtitle, | ||
| 83 | titleColor, | ||
| 84 | titleFontFamily, | ||
| 85 | titleFontSize, | ||
| 86 | @@ -364,9 +391,12 @@ | ||
| 87 | disableTopInsetApplication: !headerTopInsetEnabled, | ||
| 88 | translucent: translucent === true, | ||
| 89 | children, | ||
| 90 | + navigationItemStyle, | ||
| 91 | + headerToolbarItems: processBarButtonItems(toolbarItems, colors, fonts), | ||
| 92 | + headerCenterBarButtonItems: processBarButtonItems(centerItems, colors, fonts), | ||
| 93 | headerLeftBarButtonItems: processBarButtonItems(leftItems, colors, fonts), | ||
| 94 | headerRightBarButtonItems: processBarButtonItems(rightItems, colors, fonts), | ||
| 95 | experimental_userInterfaceStyle: dark ? 'dark' : 'light' | ||
| 96 | }; | ||
| 97 | } | ||
| 98 | -//# sourceMappingURL=useHeaderConfigProps.js.map | ||
| 99 | \ No newline at end of file | ||
| 100 | +//# sourceMappingURL=useHeaderConfigProps.js.map | ||
patches/expo-modules-jsi@56.0.10.patch created+21| ... | @@ -0,0 +1,21 @@ | ||
| 1 | diff --git a/apple/Sources/ExpoModulesJSI/Runtime/JavaScriptRuntime.swift b/apple/Sources/ExpoModulesJSI/Runtime/JavaScriptRuntime.swift | ||
| 2 | index a0e3e24d6070c5ec0a220af3cb56fe3373fe1968..d610cc38dd8dee363ea2f1822aea0c4a492cf825 100644 | ||
| 3 | --- a/apple/Sources/ExpoModulesJSI/Runtime/JavaScriptRuntime.swift | ||
| 4 | +++ b/apple/Sources/ExpoModulesJSI/Runtime/JavaScriptRuntime.swift | ||
| 5 | @@ -215,8 +215,15 @@ open class JavaScriptRuntime: Equatable, @unchecked Sendable { | ||
| 6 | .toOpaque() | ||
| 7 | // Pass a null setter to C++ when the Swift setter is nil so that JS assignment | ||
| 8 | // raises a `jsi::JSError` directly, without crossing the Swift boundary. | ||
| 9 | + if set == nil { | ||
| 10 | + let callbacks = expo.HostObjectCallbacks( | ||
| 11 | + context, getter, nil, propertyNamesGetter, deallocate) | ||
| 12 | + let hostObject = expo.HostObject.makeObject(pointee, consume callbacks) | ||
| 13 | + | ||
| 14 | + return JavaScriptObject(self, hostObject) | ||
| 15 | + } | ||
| 16 | let callbacks = expo.HostObjectCallbacks( | ||
| 17 | - context, getter, set == nil ? nil : setter, propertyNamesGetter, deallocate) | ||
| 18 | + context, getter, setter, propertyNamesGetter, deallocate) | ||
| 19 | let hostObject = expo.HostObject.makeObject(pointee, consume callbacks) | ||
| 20 | |||
| 21 | return JavaScriptObject(self, hostObject) | ||
patches/react-native-gesture-handler@2.31.2.patch created+124| ... | @@ -0,0 +1,124 @@ | ||
| 1 | diff --git a/lib/commonjs/components/ReanimatedSwipeable/ReanimatedSwipeable.js b/lib/commonjs/components/ReanimatedSwipeable/ReanimatedSwipeable.js | ||
| 2 | index 551ab92bf58db0b79d428dcd2f2df898ef686493..f1c355b8e40cd4f583ef3a501b044fb6770f4a24 100644 | ||
| 3 | --- a/lib/commonjs/components/ReanimatedSwipeable/ReanimatedSwipeable.js | ||
| 4 | +++ b/lib/commonjs/components/ReanimatedSwipeable/ReanimatedSwipeable.js | ||
| 5 | @@ -34,6 +34,7 @@ const Swipeable = props => { | ||
| 6 | enableTrackpadTwoFingerGesture = DEFAULT_ENABLE_TRACKING_TWO_FINGER_GESTURE, | ||
| 7 | dragOffsetFromLeftEdge = DEFAULT_DRAG_OFFSET, | ||
| 8 | dragOffsetFromRightEdge = DEFAULT_DRAG_OFFSET, | ||
| 9 | + failOffsetY, | ||
| 10 | friction = DEFAULT_FRICTION, | ||
| 11 | overshootFriction = DEFAULT_OVERSHOOT_FRICTION, | ||
| 12 | onSwipeableOpenStartDrag, | ||
| 13 | @@ -285,11 +286,14 @@ const Swipeable = props => { | ||
| 14 | }).onFinalize(() => { | ||
| 15 | dragStarted.value = false; | ||
| 16 | }); | ||
| 17 | + if (failOffsetY !== undefined) { | ||
| 18 | + pan.failOffsetY(failOffsetY); | ||
| 19 | + } | ||
| 20 | Object.entries(relationProps).forEach(([relationName, relation]) => { | ||
| 21 | (0, _utils.applyRelationProp)(pan, relationName, relation); | ||
| 22 | }); | ||
| 23 | return pan; | ||
| 24 | - }, [enabled, hitSlop, enableTrackpadTwoFingerGesture, dragOffsetFromRightEdge, dragOffsetFromLeftEdge, updateElementWidths, relationProps, userDrag, rowState, dragStarted, updateAnimatedEvent, onSwipeableOpenStartDrag, onSwipeableCloseStartDrag, handleRelease]); | ||
| 25 | + }, [enabled, hitSlop, enableTrackpadTwoFingerGesture, dragOffsetFromRightEdge, dragOffsetFromLeftEdge, failOffsetY, updateElementWidths, relationProps, userDrag, rowState, dragStarted, updateAnimatedEvent, onSwipeableOpenStartDrag, onSwipeableCloseStartDrag, handleRelease]); | ||
| 26 | (0, _react.useImperativeHandle)(ref, () => swipeableMethods, [swipeableMethods]); | ||
| 27 | const animatedStyle = (0, _reactNativeReanimated.useAnimatedStyle)(() => ({ | ||
| 28 | transform: [{ | ||
| 29 | diff --git a/lib/module/components/ReanimatedSwipeable/ReanimatedSwipeable.js b/lib/module/components/ReanimatedSwipeable/ReanimatedSwipeable.js | ||
| 30 | index a2835d5416ffd5cf9a04e98774516b9e6569691e..b73c177227bf3a232737b0eb1c0e2bb830493c22 100644 | ||
| 31 | --- a/lib/module/components/ReanimatedSwipeable/ReanimatedSwipeable.js | ||
| 32 | +++ b/lib/module/components/ReanimatedSwipeable/ReanimatedSwipeable.js | ||
| 33 | @@ -29,6 +29,7 @@ const Swipeable = props => { | ||
| 34 | enableTrackpadTwoFingerGesture = DEFAULT_ENABLE_TRACKING_TWO_FINGER_GESTURE, | ||
| 35 | dragOffsetFromLeftEdge = DEFAULT_DRAG_OFFSET, | ||
| 36 | dragOffsetFromRightEdge = DEFAULT_DRAG_OFFSET, | ||
| 37 | + failOffsetY, | ||
| 38 | friction = DEFAULT_FRICTION, | ||
| 39 | overshootFriction = DEFAULT_OVERSHOOT_FRICTION, | ||
| 40 | onSwipeableOpenStartDrag, | ||
| 41 | @@ -280,11 +281,14 @@ const Swipeable = props => { | ||
| 42 | }).onFinalize(() => { | ||
| 43 | dragStarted.value = false; | ||
| 44 | }); | ||
| 45 | + if (failOffsetY !== undefined) { | ||
| 46 | + pan.failOffsetY(failOffsetY); | ||
| 47 | + } | ||
| 48 | Object.entries(relationProps).forEach(([relationName, relation]) => { | ||
| 49 | applyRelationProp(pan, relationName, relation); | ||
| 50 | }); | ||
| 51 | return pan; | ||
| 52 | - }, [enabled, hitSlop, enableTrackpadTwoFingerGesture, dragOffsetFromRightEdge, dragOffsetFromLeftEdge, updateElementWidths, relationProps, userDrag, rowState, dragStarted, updateAnimatedEvent, onSwipeableOpenStartDrag, onSwipeableCloseStartDrag, handleRelease]); | ||
| 53 | + }, [enabled, hitSlop, enableTrackpadTwoFingerGesture, dragOffsetFromRightEdge, dragOffsetFromLeftEdge, failOffsetY, updateElementWidths, relationProps, userDrag, rowState, dragStarted, updateAnimatedEvent, onSwipeableOpenStartDrag, onSwipeableCloseStartDrag, handleRelease]); | ||
| 54 | useImperativeHandle(ref, () => swipeableMethods, [swipeableMethods]); | ||
| 55 | const animatedStyle = useAnimatedStyle(() => ({ | ||
| 56 | transform: [{ | ||
| 57 | diff --git a/lib/typescript/components/ReanimatedSwipeable/ReanimatedSwipeableProps.d.ts b/lib/typescript/components/ReanimatedSwipeable/ReanimatedSwipeableProps.d.ts | ||
| 58 | index ac8b76830d468edfbc29052b452a36221323c3de..589e329d2aa706ddfff0d1037df0d85a050edbb0 100644 | ||
| 59 | --- a/lib/typescript/components/ReanimatedSwipeable/ReanimatedSwipeableProps.d.ts | ||
| 60 | +++ b/lib/typescript/components/ReanimatedSwipeable/ReanimatedSwipeableProps.d.ts | ||
| 61 | @@ -64,6 +64,13 @@ export interface SwipeableProps { | ||
| 62 | * a swipe. The default value is 10. | ||
| 63 | */ | ||
| 64 | dragOffsetFromRightEdge?: number; | ||
| 65 | + /** | ||
| 66 | + * Range along the Y axis (in points) that, once exceeded before the swipe | ||
| 67 | + * activates, makes the pan fail. Lets vertically-dominant pans (list | ||
| 68 | + * scrolling, especially trackpad two-finger pans) skip the swipe entirely. | ||
| 69 | + * Passed straight to the underlying Pan gesture's `failOffsetY`. | ||
| 70 | + */ | ||
| 71 | + failOffsetY?: number | [start: number, end: number]; | ||
| 72 | /** | ||
| 73 | * Value indicating if the swipeable panel can be pulled further than the left | ||
| 74 | * actions panel's width. It is set to true by default as long as the left | ||
| 75 | diff --git a/src/components/ReanimatedSwipeable/ReanimatedSwipeable.tsx b/src/components/ReanimatedSwipeable/ReanimatedSwipeable.tsx | ||
| 76 | index b6134c908624adc590ebd264e90e558b88e235d5..41535348e937ad7762bd531d5b63ba6e092ea997 100644 | ||
| 77 | --- a/src/components/ReanimatedSwipeable/ReanimatedSwipeable.tsx | ||
| 78 | +++ b/src/components/ReanimatedSwipeable/ReanimatedSwipeable.tsx | ||
| 79 | @@ -58,6 +58,7 @@ const Swipeable = (props: SwipeableProps) => { | ||
| 80 | enableTrackpadTwoFingerGesture = DEFAULT_ENABLE_TRACKING_TWO_FINGER_GESTURE, | ||
| 81 | dragOffsetFromLeftEdge = DEFAULT_DRAG_OFFSET, | ||
| 82 | dragOffsetFromRightEdge = DEFAULT_DRAG_OFFSET, | ||
| 83 | + failOffsetY, | ||
| 84 | friction = DEFAULT_FRICTION, | ||
| 85 | overshootFriction = DEFAULT_OVERSHOOT_FRICTION, | ||
| 86 | onSwipeableOpenStartDrag, | ||
| 87 | @@ -537,6 +538,10 @@ const Swipeable = (props: SwipeableProps) => { | ||
| 88 | dragStarted.value = false; | ||
| 89 | }); | ||
| 90 | |||
| 91 | + if (failOffsetY !== undefined) { | ||
| 92 | + pan.failOffsetY(failOffsetY); | ||
| 93 | + } | ||
| 94 | + | ||
| 95 | Object.entries(relationProps).forEach(([relationName, relation]) => { | ||
| 96 | applyRelationProp( | ||
| 97 | pan, | ||
| 98 | @@ -552,6 +557,7 @@ const Swipeable = (props: SwipeableProps) => { | ||
| 99 | enableTrackpadTwoFingerGesture, | ||
| 100 | dragOffsetFromRightEdge, | ||
| 101 | dragOffsetFromLeftEdge, | ||
| 102 | + failOffsetY, | ||
| 103 | updateElementWidths, | ||
| 104 | relationProps, | ||
| 105 | userDrag, | ||
| 106 | diff --git a/src/components/ReanimatedSwipeable/ReanimatedSwipeableProps.ts b/src/components/ReanimatedSwipeable/ReanimatedSwipeableProps.ts | ||
| 107 | index 0c0e517e0d340faf50ff78c3d48e7a2bbcf808ec..d4b6ff508077728c8be83762923d866dc95b144c 100644 | ||
| 108 | --- a/src/components/ReanimatedSwipeable/ReanimatedSwipeableProps.ts | ||
| 109 | +++ b/src/components/ReanimatedSwipeable/ReanimatedSwipeableProps.ts | ||
| 110 | @@ -77,6 +77,14 @@ export interface SwipeableProps { | ||
| 111 | */ | ||
| 112 | dragOffsetFromRightEdge?: number; | ||
| 113 | |||
| 114 | + /** | ||
| 115 | + * Range along the Y axis (in points) that, once exceeded before the swipe | ||
| 116 | + * activates, makes the pan fail. Lets vertically-dominant pans (list | ||
| 117 | + * scrolling, especially trackpad two-finger pans) skip the swipe entirely. | ||
| 118 | + * Passed straight to the underlying Pan gesture's `failOffsetY`. | ||
| 119 | + */ | ||
| 120 | + failOffsetY?: number | [start: number, end: number]; | ||
| 121 | + | ||
| 122 | /** | ||
| 123 | * Value indicating if the swipeable panel can be pulled further than the left | ||
| 124 | * actions panel's width. It is set to true by default as long as the left | ||
patches/react-native-screens@4.25.2.patch created+1542| ... | @@ -0,0 +1,1542 @@ | ||
| 1 | diff --git a/ios/RNSBarButtonItem.h b/ios/RNSBarButtonItem.h | ||
| 2 | index ea5325ea8d17b1ddfa790ff8dab48ce83142d4d3..acd2fd7ceb7162ed300abc4d3c9f0c24f4d63898 100644 | ||
| 3 | --- a/ios/RNSBarButtonItem.h | ||
| 4 | +++ b/ios/RNSBarButtonItem.h | ||
| 5 | @@ -13,4 +13,8 @@ typedef void (^RNSBarButtonMenuItemAction)(NSString *menuId); | ||
| 6 | menuAction:(RNSBarButtonMenuItemAction)menuAction | ||
| 7 | imageLoader:(RCTImageLoader *)imageLoader; | ||
| 8 | |||
| 9 | ++ (UIMenu *)initUIMenuWithDict:(NSDictionary<NSString *, id> *)dict | ||
| 10 | + menuAction:(RNSBarButtonMenuItemAction)menuAction | ||
| 11 | + imageLoader:(RCTImageLoader *)imageLoader; | ||
| 12 | + | ||
| 13 | @end | ||
| 14 | diff --git a/ios/RNSBarButtonItem.mm b/ios/RNSBarButtonItem.mm | ||
| 15 | index 0eb1f09dee82edd99e3e614938233db5cdeaebfe..73298f10807520970f625e166d7f5dd1338206f1 100644 | ||
| 16 | --- a/ios/RNSBarButtonItem.mm | ||
| 17 | +++ b/ios/RNSBarButtonItem.mm | ||
| 18 | @@ -110,6 +110,58 @@ - (instancetype)initWithConfig:(NSDictionary<NSString *, id> *)dict | ||
| 19 | if (dict[@"accessibilityHint"]) { | ||
| 20 | self.accessibilityHint = dict[@"accessibilityHint"]; | ||
| 21 | } | ||
| 22 | +#if !TARGET_OS_TV && RNS_IPHONE_OS_VERSION_AVAILABLE(26_0) | ||
| 23 | + NSNumber *glassEffectNum = dict[@"glassEffect"]; | ||
| 24 | + if (@available(iOS 26.0, *)) { | ||
| 25 | + if ([glassEffectNum boolValue]) { | ||
| 26 | + UIButtonConfiguration *configuration = [UIButtonConfiguration glassButtonConfiguration]; | ||
| 27 | + configuration.cornerStyle = UIButtonConfigurationCornerStyleCapsule; | ||
| 28 | + UIBackgroundConfiguration *background = configuration.background; | ||
| 29 | + background.strokeWidth = 0.0; | ||
| 30 | + background.strokeColor = UIColor.clearColor; | ||
| 31 | + configuration.background = background; | ||
| 32 | + configuration.contentInsets = NSDirectionalEdgeInsetsZero; | ||
| 33 | + if (title != nil) { | ||
| 34 | + configuration.title = title; | ||
| 35 | + } | ||
| 36 | + | ||
| 37 | + UIButton *button = [UIButton buttonWithConfiguration:configuration primaryAction:nil]; | ||
| 38 | + button.translatesAutoresizingMaskIntoConstraints = NO; | ||
| 39 | + button.enabled = self.enabled; | ||
| 40 | + button.accessibilityLabel = self.accessibilityLabel; | ||
| 41 | + button.accessibilityHint = self.accessibilityHint; | ||
| 42 | + | ||
| 43 | + CGFloat resolvedWidth = width != nil ? width.doubleValue : 56.0; | ||
| 44 | + [button.widthAnchor constraintEqualToConstant:resolvedWidth].active = YES; | ||
| 45 | + [button.heightAnchor constraintEqualToConstant:50.0].active = YES; | ||
| 46 | + | ||
| 47 | + [[self class] resolveImageFromConfig:dict | ||
| 48 | + imageLoader:imageLoader | ||
| 49 | + completionBlock:^(UIImage *img) { | ||
| 50 | + UIButtonConfiguration *updatedConfiguration = button.configuration; | ||
| 51 | + updatedConfiguration.image = img; | ||
| 52 | + button.configuration = updatedConfiguration; | ||
| 53 | + }]; | ||
| 54 | + | ||
| 55 | + NSDictionary *menu = dict[@"menu"]; | ||
| 56 | + if (menu) { | ||
| 57 | + button.menu = [[self class] initUIMenuWithDict:menu menuAction:menuAction imageLoader:imageLoader]; | ||
| 58 | + button.showsMenuAsPrimaryAction = YES; | ||
| 59 | + } else { | ||
| 60 | + NSString *buttonId = dict[@"buttonId"]; | ||
| 61 | + if (buttonId && action) { | ||
| 62 | + UIAction *pressAction = [UIAction actionWithHandler:^(__kindof UIAction *_Nonnull sender) { | ||
| 63 | + action(buttonId); | ||
| 64 | + }]; | ||
| 65 | + [button addAction:pressAction forControlEvents:UIControlEventTouchUpInside]; | ||
| 66 | + } | ||
| 67 | + } | ||
| 68 | + | ||
| 69 | + self.customView = button; | ||
| 70 | + return self; | ||
| 71 | + } | ||
| 72 | + } | ||
| 73 | +#endif | ||
| 74 | |||
| 75 | #if !TARGET_OS_TV || __TV_OS_VERSION_MAX_ALLOWED >= 170000 | ||
| 76 | if (@available(tvOS 17.0, *)) { | ||
| 77 | diff --git a/ios/RNSScreenStackHeaderConfig.h b/ios/RNSScreenStackHeaderConfig.h | ||
| 78 | index 919b984edc9f91ee9ac26faf257d8a721e26457c..5bb0cd6736ed6bc51db57e2a9326f758f22c51d8 100644 | ||
| 79 | --- a/ios/RNSScreenStackHeaderConfig.h | ||
| 80 | +++ b/ios/RNSScreenStackHeaderConfig.h | ||
| 81 | @@ -21,6 +21,8 @@ | ||
| 82 | NS_ASSUME_NONNULL_BEGIN | ||
| 83 | |||
| 84 | @property (nonatomic, retain) NSString *title; | ||
| 85 | +@property (nonatomic, retain) NSString *subtitle; | ||
| 86 | +@property (nonatomic, retain) NSString *largeSubtitle; | ||
| 87 | @property (nonatomic, retain) NSString *titleFontFamily; | ||
| 88 | @property (nonatomic, retain) NSNumber *titleFontSize; | ||
| 89 | @property (nonatomic, retain) NSString *titleFontWeight; | ||
| 90 | @@ -45,9 +47,12 @@ NS_ASSUME_NONNULL_BEGIN | ||
| 91 | @property (nonatomic) BOOL backButtonInCustomView; | ||
| 92 | @property (nonatomic) UISemanticContentAttribute direction; | ||
| 93 | @property (nonatomic) UINavigationItemBackButtonDisplayMode backButtonDisplayMode; | ||
| 94 | +@property (nonatomic) NSInteger navigationItemStyle; | ||
| 95 | @property (nonatomic) RNSBlurEffectStyle blurEffect; | ||
| 96 | @property (nonatomic, copy, nullable) NSArray<NSDictionary<NSString *, id> *> *headerRightBarButtonItems; | ||
| 97 | @property (nonatomic, copy, nullable) NSArray<NSDictionary<NSString *, id> *> *headerLeftBarButtonItems; | ||
| 98 | +@property (nonatomic, copy, nullable) NSArray<NSDictionary<NSString *, id> *> *headerCenterBarButtonItems; | ||
| 99 | +@property (nonatomic, copy, nullable) NSArray<NSDictionary<NSString *, id> *> *headerToolbarItems; | ||
| 100 | @property (nonatomic, readwrite) BOOL synchronousShadowStateUpdatesEnabled; | ||
| 101 | |||
| 102 | NS_ASSUME_NONNULL_END | ||
| 103 | diff --git a/ios/RNSScreenStackHeaderConfig.mm b/ios/RNSScreenStackHeaderConfig.mm | ||
| 104 | index 5970e3e3a624b9498b8dedfc16831df03a274d0c..073a72a68688369f39e54b61d1381b8d652cbb83 100644 | ||
| 105 | --- a/ios/RNSScreenStackHeaderConfig.mm | ||
| 106 | +++ b/ios/RNSScreenStackHeaderConfig.mm | ||
| 107 | @@ -30,6 +30,20 @@ | ||
| 108 | static const NSNumber *const DEFAULT_TITLE_FONT_SIZE = @17; | ||
| 109 | static const NSNumber *const DEFAULT_TITLE_LARGE_FONT_SIZE = @34; | ||
| 110 | |||
| 111 | +static NSInteger navigationItemStyleFromCppEquivalent( | ||
| 112 | + react::RNSScreenStackHeaderConfigNavigationItemStyle navigationItemStyle) | ||
| 113 | +{ | ||
| 114 | + switch (navigationItemStyle) { | ||
| 115 | + case react::RNSScreenStackHeaderConfigNavigationItemStyle::Browser: | ||
| 116 | + return UINavigationItemStyleBrowser; | ||
| 117 | + case react::RNSScreenStackHeaderConfigNavigationItemStyle::Editor: | ||
| 118 | + return UINavigationItemStyleEditor; | ||
| 119 | + case react::RNSScreenStackHeaderConfigNavigationItemStyle::Navigator: | ||
| 120 | + default: | ||
| 121 | + return UINavigationItemStyleNavigator; | ||
| 122 | + } | ||
| 123 | +} | ||
| 124 | + | ||
| 125 | @interface RCTImageLoader (Private) | ||
| 126 | - (id<RCTImageCache>)imageCache; | ||
| 127 | @end | ||
| 128 | @@ -47,6 +61,9 @@ + (BOOL)rnscreens_isBlankOrNull:(NSString *)string | ||
| 129 | @end | ||
| 130 | |||
| 131 | @interface RNSScreenStackHeaderConfig () <RCTMountingTransactionObserving> | ||
| 132 | +#if !TARGET_OS_TV | ||
| 133 | +- (NSArray<UIBarButtonItemGroup *> *)barButtonItemGroupsFromItems:(NSArray<UIBarButtonItem *> *)items; | ||
| 134 | +#endif | ||
| 135 | @end | ||
| 136 | |||
| 137 | @implementation RNSScreenStackHeaderConfig { | ||
| 138 | @@ -81,6 +98,7 @@ - (void)initProps | ||
| 139 | self.hidden = YES; | ||
| 140 | _reactSubviews = [NSMutableArray new]; | ||
| 141 | _backTitleVisible = YES; | ||
| 142 | + _navigationItemStyle = UINavigationItemStyleNavigator; | ||
| 143 | _blurEffect = RNSBlurEffectStyleNone; | ||
| 144 | } | ||
| 145 | |||
| 146 | @@ -496,6 +514,10 @@ + (void)updateViewController:(UIViewController *)vc | ||
| 147 | |||
| 148 | if (shouldHide) { | ||
| 149 | navitem.title = config.title; | ||
| 150 | + if (@available(iOS 26.0, *)) { | ||
| 151 | + navitem.subtitle = config.subtitle; | ||
| 152 | + navitem.largeSubtitle = config.largeSubtitle; | ||
| 153 | + } | ||
| 154 | |||
| 155 | // Setting navigation bar visibility is split to mitigate iOS 26 bug with bar button items. | ||
| 156 | [navctr setNavigationBarHidden:YES animated:animated]; | ||
| 157 | @@ -512,11 +534,19 @@ + (void)updateViewController:(UIViewController *)vc | ||
| 158 | } | ||
| 159 | navitem.largeTitleDisplayMode = | ||
| 160 | config.largeTitle ? UINavigationItemLargeTitleDisplayModeAlways : UINavigationItemLargeTitleDisplayModeNever; | ||
| 161 | + | ||
| 162 | + if (@available(iOS 16.0, *)) { | ||
| 163 | + navitem.style = (UINavigationItemStyle)config.navigationItemStyle; | ||
| 164 | + } | ||
| 165 | #endif | ||
| 166 | |||
| 167 | UINavigationBarAppearance *appearance = [self buildAppearance:vc withConfig:config]; | ||
| 168 | navitem.standardAppearance = appearance; | ||
| 169 | navitem.compactAppearance = appearance; | ||
| 170 | + if (@available(iOS 26.0, *)) { | ||
| 171 | + navitem.subtitle = config.subtitle; | ||
| 172 | + navitem.largeSubtitle = config.largeSubtitle; | ||
| 173 | + } | ||
| 174 | |||
| 175 | // appearance does not apply to the tvOS so we need to use lagacy customization | ||
| 176 | #if TARGET_OS_TV | ||
| 177 | @@ -637,10 +667,286 @@ + (void)updateViewController:(UIViewController *)vc | ||
| 178 | // This assignment should be done after `navitem.titleView = ...` assignment (iOS 16.0 bug). | ||
| 179 | // See: https://github.com/software-mansion/react-native-screens/issues/1570 (comments) | ||
| 180 | navitem.title = config.title; | ||
| 181 | - navitem.leftBarButtonItems = [config barButtonItemsFromConfigs:config.headerLeftBarButtonItems | ||
| 182 | - withCurrentItems:navitem.leftBarButtonItems]; | ||
| 183 | - navitem.rightBarButtonItems = [config barButtonItemsFromConfigs:config.headerRightBarButtonItems | ||
| 184 | - withCurrentItems:navitem.rightBarButtonItems]; | ||
| 185 | + NSArray<UIBarButtonItem *> *leftBarButtonItems = [config barButtonItemsFromConfigs:config.headerLeftBarButtonItems | ||
| 186 | + withCurrentItems:navitem.leftBarButtonItems | ||
| 187 | + navigationItem:navitem]; | ||
| 188 | + NSArray<UIBarButtonItem *> *rightBarButtonItems = [config barButtonItemsFromConfigs:config.headerRightBarButtonItems | ||
| 189 | + withCurrentItems:navitem.rightBarButtonItems | ||
| 190 | + navigationItem:navitem]; | ||
| 191 | + NSArray<UIBarButtonItem *> *centerBarButtonItems = [config barButtonItemsFromConfigs:config.headerCenterBarButtonItems | ||
| 192 | + withCurrentItems:@[] | ||
| 193 | + navigationItem:navitem]; | ||
| 194 | +#if !TARGET_OS_TV | ||
| 195 | + if (@available(iOS 16.0, *)) { | ||
| 196 | + navitem.leadingItemGroups = [config barButtonItemGroupsFromItems:leftBarButtonItems]; | ||
| 197 | + navitem.trailingItemGroups = [config barButtonItemGroupsFromItems:rightBarButtonItems]; | ||
| 198 | + if (@available(iOS 26.0, *)) { | ||
| 199 | + navitem.centerItemGroups = [config barButtonItemGroupsFromItems:centerBarButtonItems]; | ||
| 200 | + } | ||
| 201 | + navitem.leftBarButtonItems = nil; | ||
| 202 | + navitem.rightBarButtonItems = nil; | ||
| 203 | + } else { | ||
| 204 | + navitem.leftBarButtonItems = leftBarButtonItems; | ||
| 205 | + navitem.rightBarButtonItems = rightBarButtonItems; | ||
| 206 | + } | ||
| 207 | +#else | ||
| 208 | + navitem.leftBarButtonItems = leftBarButtonItems; | ||
| 209 | + navitem.rightBarButtonItems = rightBarButtonItems; | ||
| 210 | +#endif | ||
| 211 | + NSDictionary<NSString *, id> *mailSearchToolbarConfig = nil; | ||
| 212 | + for (NSDictionary<NSString *, id> *toolbarConfig in config.headerToolbarItems) { | ||
| 213 | + if (toolbarConfig[@"mailSearchToolbar"]) { | ||
| 214 | + mailSearchToolbarConfig = toolbarConfig; | ||
| 215 | + break; | ||
| 216 | + } | ||
| 217 | + } | ||
| 218 | + if (mailSearchToolbarConfig == nil) { | ||
| 219 | + for (NSDictionary<NSString *, id> *rightConfig in config.headerRightBarButtonItems) { | ||
| 220 | + if ([rightConfig[@"bottomMailSearchToolbar"] boolValue]) { | ||
| 221 | + mailSearchToolbarConfig = rightConfig; | ||
| 222 | + break; | ||
| 223 | + } | ||
| 224 | + } | ||
| 225 | + } | ||
| 226 | + | ||
| 227 | + static NSInteger const RNSMailSearchToolbarViewTag = 260628; | ||
| 228 | + UIView *chromeHostView = vc.view; | ||
| 229 | + UIView *existingMailSearchToolbar = [chromeHostView viewWithTag:RNSMailSearchToolbarViewTag]; | ||
| 230 | + if (existingMailSearchToolbar == nil) { | ||
| 231 | + existingMailSearchToolbar = [vc.view viewWithTag:RNSMailSearchToolbarViewTag]; | ||
| 232 | + } | ||
| 233 | + [existingMailSearchToolbar removeFromSuperview]; | ||
| 234 | + | ||
| 235 | + if (mailSearchToolbarConfig != nil) { | ||
| 236 | +#if RNS_IPHONE_OS_VERSION_AVAILABLE(26_0) | ||
| 237 | + if (@available(iOS 26.0, *)) { | ||
| 238 | + CGFloat horizontalInset = 18.0; | ||
| 239 | + NSNumber *width = mailSearchToolbarConfig[@"width"]; | ||
| 240 | + CGFloat hostWidth = chromeHostView.bounds.size.width; | ||
| 241 | + if (hostWidth <= 0.0) { | ||
| 242 | + hostWidth = vc.view.bounds.size.width; | ||
| 243 | + } | ||
| 244 | + if (hostWidth <= 0.0) { | ||
| 245 | + hostWidth = [UIScreen mainScreen].bounds.size.width; | ||
| 246 | + } | ||
| 247 | + CGFloat fallbackWidth = MIN(560.0, MAX(300.0, hostWidth - (horizontalInset * 2.0))); | ||
| 248 | + CGFloat resolvedWidth = width != nil ? width.doubleValue : fallbackWidth; | ||
| 249 | + if (hostWidth > 0.0) { | ||
| 250 | + resolvedWidth = MIN(resolvedWidth, MAX(260.0, hostWidth - 12.0)); | ||
| 251 | + } | ||
| 252 | + | ||
| 253 | + CGFloat toolbarHeight = 48.0; | ||
| 254 | + CGFloat toolbarRadius = toolbarHeight / 2.0; | ||
| 255 | + CGFloat buttonSize = toolbarHeight; | ||
| 256 | + CGFloat sideButtonReserve = buttonSize + 8.0; | ||
| 257 | + CGFloat searchFieldHeight = 42.0; | ||
| 258 | + CGFloat toolbarBottomSpacing = 4.0; | ||
| 259 | + UIFont *searchFont = [UIFont preferredFontForTextStyle:UIFontTextStyleTitle3]; | ||
| 260 | + NSDictionary<NSAttributedStringKey, id> *placeholderAttributes = @{ | ||
| 261 | + NSForegroundColorAttributeName : UIColor.secondaryLabelColor, | ||
| 262 | + NSFontAttributeName : searchFont, | ||
| 263 | + }; | ||
| 264 | + UIKeyboardLayoutGuide *keyboardLayoutGuide = chromeHostView.keyboardLayoutGuide; | ||
| 265 | + | ||
| 266 | + UIView *toolbarHost = [[UIView alloc] init]; | ||
| 267 | + toolbarHost.tag = RNSMailSearchToolbarViewTag; | ||
| 268 | + toolbarHost.translatesAutoresizingMaskIntoConstraints = NO; | ||
| 269 | + [chromeHostView addSubview:toolbarHost]; | ||
| 270 | + // Keyboard avoidance is best-effort: on the iOS 27 beta the keyboard | ||
| 271 | + // layout guide no longer rests at the bottom safe-area edge while the | ||
| 272 | + // keyboard is hidden, which pushed the toolbar offscreen. The required | ||
| 273 | + // resting position is the safe area; the keyboard guide only pulls the | ||
| 274 | + // toolbar up when it actually tracks a visible keyboard. | ||
| 275 | + NSLayoutConstraint *keyboardAvoidConstraint = | ||
| 276 | + [toolbarHost.bottomAnchor constraintEqualToAnchor:keyboardLayoutGuide.topAnchor | ||
| 277 | + constant:-toolbarBottomSpacing]; | ||
| 278 | + keyboardAvoidConstraint.priority = UILayoutPriorityDefaultHigh; | ||
| 279 | + NSLayoutConstraint *restingBottomConstraint = | ||
| 280 | + [toolbarHost.bottomAnchor constraintEqualToAnchor:chromeHostView.safeAreaLayoutGuide.bottomAnchor | ||
| 281 | + constant:-toolbarBottomSpacing]; | ||
| 282 | + restingBottomConstraint.priority = UILayoutPriorityDefaultLow; | ||
| 283 | + [NSLayoutConstraint activateConstraints:@[ | ||
| 284 | + [toolbarHost.centerXAnchor constraintEqualToAnchor:chromeHostView.centerXAnchor], | ||
| 285 | + [toolbarHost.bottomAnchor constraintLessThanOrEqualToAnchor:chromeHostView.safeAreaLayoutGuide.bottomAnchor | ||
| 286 | + constant:-toolbarBottomSpacing], | ||
| 287 | + keyboardAvoidConstraint, | ||
| 288 | + restingBottomConstraint, | ||
| 289 | + [toolbarHost.widthAnchor constraintEqualToConstant:resolvedWidth], | ||
| 290 | + [toolbarHost.heightAnchor constraintEqualToConstant:toolbarHeight], | ||
| 291 | + ]]; | ||
| 292 | + [chromeHostView bringSubviewToFront:toolbarHost]; | ||
| 293 | + | ||
| 294 | + UIGlassEffect *glassEffect = [UIGlassEffect effectWithStyle:UIGlassEffectStyleRegular]; | ||
| 295 | + glassEffect.interactive = YES; | ||
| 296 | + UIVisualEffectView *glassView = [[UIVisualEffectView alloc] initWithEffect:glassEffect]; | ||
| 297 | + glassView.clipsToBounds = YES; | ||
| 298 | + glassView.layer.cornerRadius = toolbarRadius; | ||
| 299 | + glassView.translatesAutoresizingMaskIntoConstraints = NO; | ||
| 300 | + [toolbarHost addSubview:glassView]; | ||
| 301 | + BOOL hasFilterButton = | ||
| 302 | + mailSearchToolbarConfig[@"filterButtonId"] != nil || mailSearchToolbarConfig[@"filterMenu"] != nil; | ||
| 303 | + BOOL hasComposeButton = | ||
| 304 | + mailSearchToolbarConfig[@"composeButtonId"] != nil || mailSearchToolbarConfig[@"composeMenu"] != nil; | ||
| 305 | + CGFloat glassLeadingInset = hasFilterButton ? sideButtonReserve : 0.0; | ||
| 306 | + CGFloat glassTrailingInset = hasComposeButton ? -sideButtonReserve : 0.0; | ||
| 307 | + [NSLayoutConstraint activateConstraints:@[ | ||
| 308 | + [glassView.leadingAnchor constraintEqualToAnchor:toolbarHost.leadingAnchor constant:glassLeadingInset], | ||
| 309 | + [glassView.trailingAnchor constraintEqualToAnchor:toolbarHost.trailingAnchor constant:glassTrailingInset], | ||
| 310 | + [glassView.centerYAnchor constraintEqualToAnchor:toolbarHost.centerYAnchor], | ||
| 311 | + [glassView.heightAnchor constraintEqualToConstant:toolbarHeight], | ||
| 312 | + ]]; | ||
| 313 | + | ||
| 314 | + void (^emitButtonPress)(NSString *) = ^(NSString *buttonId) { | ||
| 315 | + auto eventEmitter = std::static_pointer_cast<const facebook::react::RNSScreenStackHeaderConfigEventEmitter>( | ||
| 316 | + config->_eventEmitter); | ||
| 317 | + if (eventEmitter && buttonId) { | ||
| 318 | + eventEmitter->onPressHeaderBarButtonItem( | ||
| 319 | + facebook::react::RNSScreenStackHeaderConfigEventEmitter::OnPressHeaderBarButtonItem{ | ||
| 320 | + .buttonId = std::string([buttonId UTF8String])}); | ||
| 321 | + } | ||
| 322 | + }; | ||
| 323 | + | ||
| 324 | + void (^emitMenuPress)(NSString *) = ^(NSString *menuId) { | ||
| 325 | + auto eventEmitter = std::static_pointer_cast<const facebook::react::RNSScreenStackHeaderConfigEventEmitter>( | ||
| 326 | + config->_eventEmitter); | ||
| 327 | + if (eventEmitter && menuId) { | ||
| 328 | + eventEmitter->onPressHeaderBarButtonMenuItem( | ||
| 329 | + facebook::react::RNSScreenStackHeaderConfigEventEmitter::OnPressHeaderBarButtonMenuItem{ | ||
| 330 | + .menuId = std::string([menuId UTF8String])}); | ||
| 331 | + } | ||
| 332 | + }; | ||
| 333 | + | ||
| 334 | + UIButton *(^makeGlassButton)(NSString *, NSString *, NSDictionary *) = | ||
| 335 | + ^UIButton *(NSString *systemImageName, NSString *buttonId, NSDictionary *menuConfig) { | ||
| 336 | + UIButtonConfiguration *configuration = [UIButtonConfiguration glassButtonConfiguration]; | ||
| 337 | + configuration.cornerStyle = UIButtonConfigurationCornerStyleCapsule; | ||
| 338 | + configuration.image = [UIImage systemImageNamed:systemImageName ?: @"circle"]; | ||
| 339 | + configuration.baseForegroundColor = UIColor.labelColor; | ||
| 340 | + UIButton *button = [UIButton buttonWithConfiguration:configuration primaryAction:nil]; | ||
| 341 | + if (menuConfig != nil) { | ||
| 342 | + button.menu = [RNSBarButtonItem initUIMenuWithDict:menuConfig | ||
| 343 | + menuAction:emitMenuPress | ||
| 344 | + imageLoader:config->_imageLoader]; | ||
| 345 | + button.showsMenuAsPrimaryAction = YES; | ||
| 346 | + } else if (buttonId != nil) { | ||
| 347 | + UIAction *action = [UIAction actionWithHandler:^(__kindof UIAction *_Nonnull action) { | ||
| 348 | + emitButtonPress(buttonId); | ||
| 349 | + }]; | ||
| 350 | + [button addAction:action forControlEvents:UIControlEventTouchUpInside]; | ||
| 351 | + } | ||
| 352 | + return button; | ||
| 353 | + }; | ||
| 354 | + | ||
| 355 | + BOOL useFallbackSearchField = [mailSearchToolbarConfig[@"useFallbackSearchField"] boolValue]; | ||
| 356 | + UISearchBar *searchBar = | ||
| 357 | + !useFallbackSearchField && navitem.searchController != nil ? navitem.searchController.searchBar : nil; | ||
| 358 | + NSString *placeholder = mailSearchToolbarConfig[@"placeholder"]; | ||
| 359 | + if (searchBar != nil) { | ||
| 360 | + if (placeholder != nil) { | ||
| 361 | + searchBar.placeholder = placeholder; | ||
| 362 | + } | ||
| 363 | + searchBar.hidden = NO; | ||
| 364 | + searchBar.alpha = 1.0; | ||
| 365 | + searchBar.userInteractionEnabled = YES; | ||
| 366 | + searchBar.searchBarStyle = UISearchBarStyleMinimal; | ||
| 367 | + searchBar.backgroundImage = [UIImage new]; | ||
| 368 | + searchBar.searchTextField.hidden = NO; | ||
| 369 | + searchBar.searchTextField.alpha = 1.0; | ||
| 370 | + searchBar.searchTextField.backgroundColor = UIColor.clearColor; | ||
| 371 | + searchBar.searchTextField.font = searchFont; | ||
| 372 | + searchBar.searchTextField.adjustsFontForContentSizeCategory = YES; | ||
| 373 | + searchBar.searchTextField.textColor = UIColor.labelColor; | ||
| 374 | + searchBar.searchTextField.tintColor = UIColor.labelColor; | ||
| 375 | + if (placeholder != nil) { | ||
| 376 | + searchBar.searchTextField.attributedPlaceholder = | ||
| 377 | + [[NSAttributedString alloc] initWithString:placeholder attributes:placeholderAttributes]; | ||
| 378 | + } | ||
| 379 | + searchBar.translatesAutoresizingMaskIntoConstraints = NO; | ||
| 380 | + [glassView.contentView addSubview:searchBar]; | ||
| 381 | + [NSLayoutConstraint activateConstraints:@[ | ||
| 382 | + [searchBar.leadingAnchor constraintEqualToAnchor:glassView.contentView.leadingAnchor constant:8.0], | ||
| 383 | + [searchBar.trailingAnchor constraintEqualToAnchor:glassView.contentView.trailingAnchor constant:-8.0], | ||
| 384 | + [searchBar.centerYAnchor constraintEqualToAnchor:glassView.contentView.centerYAnchor], | ||
| 385 | + [searchBar.heightAnchor constraintEqualToConstant:searchFieldHeight], | ||
| 386 | + ]]; | ||
| 387 | + } else { | ||
| 388 | + UISearchTextField *searchField = [UISearchTextField new]; | ||
| 389 | + if (placeholder != nil) { | ||
| 390 | + searchField.placeholder = placeholder; | ||
| 391 | + searchField.attributedPlaceholder = | ||
| 392 | + [[NSAttributedString alloc] initWithString:placeholder attributes:placeholderAttributes]; | ||
| 393 | + } | ||
| 394 | + NSString *searchTextChangeId = mailSearchToolbarConfig[@"searchTextChangeId"]; | ||
| 395 | + if (searchTextChangeId != nil) { | ||
| 396 | + [searchField addAction:[UIAction actionWithHandler:^(__kindof UIAction *_Nonnull action) { | ||
| 397 | + UISearchTextField *field = (UISearchTextField *)action.sender; | ||
| 398 | + emitButtonPress([NSString stringWithFormat:@"%@:%@", searchTextChangeId, field.text ?: @""]); | ||
| 399 | + }] | ||
| 400 | + forControlEvents:UIControlEventEditingChanged]; | ||
| 401 | + } | ||
| 402 | + searchField.borderStyle = UITextBorderStyleNone; | ||
| 403 | + searchField.font = searchFont; | ||
| 404 | + searchField.adjustsFontForContentSizeCategory = YES; | ||
| 405 | + searchField.textColor = UIColor.labelColor; | ||
| 406 | + searchField.tintColor = UIColor.labelColor; | ||
| 407 | + searchField.translatesAutoresizingMaskIntoConstraints = NO; | ||
| 408 | + [glassView.contentView addSubview:searchField]; | ||
| 409 | + [NSLayoutConstraint activateConstraints:@[ | ||
| 410 | + [searchField.leadingAnchor constraintEqualToAnchor:glassView.contentView.leadingAnchor constant:14.0], | ||
| 411 | + [searchField.trailingAnchor constraintEqualToAnchor:glassView.contentView.trailingAnchor constant:-14.0], | ||
| 412 | + [searchField.centerYAnchor constraintEqualToAnchor:glassView.contentView.centerYAnchor], | ||
| 413 | + [searchField.heightAnchor constraintEqualToConstant:searchFieldHeight], | ||
| 414 | + ]]; | ||
| 415 | + } | ||
| 416 | + | ||
| 417 | + if (hasFilterButton) { | ||
| 418 | + UIButton *filterButton = makeGlassButton( | ||
| 419 | + mailSearchToolbarConfig[@"filterSystemImageName"] ?: @"line.3.horizontal.decrease", | ||
| 420 | + mailSearchToolbarConfig[@"filterButtonId"], | ||
| 421 | + mailSearchToolbarConfig[@"filterMenu"]); | ||
| 422 | + filterButton.translatesAutoresizingMaskIntoConstraints = NO; | ||
| 423 | + [toolbarHost addSubview:filterButton]; | ||
| 424 | + [NSLayoutConstraint activateConstraints:@[ | ||
| 425 | + [filterButton.leadingAnchor constraintEqualToAnchor:toolbarHost.leadingAnchor], | ||
| 426 | + [filterButton.centerYAnchor constraintEqualToAnchor:toolbarHost.centerYAnchor], | ||
| 427 | + [filterButton.widthAnchor constraintEqualToConstant:buttonSize], | ||
| 428 | + [filterButton.heightAnchor constraintEqualToConstant:buttonSize], | ||
| 429 | + ]]; | ||
| 430 | + } | ||
| 431 | + | ||
| 432 | + if (hasComposeButton) { | ||
| 433 | + UIButton *composeButton = makeGlassButton( | ||
| 434 | + mailSearchToolbarConfig[@"composeSystemImageName"] ?: @"square.and.pencil", | ||
| 435 | + mailSearchToolbarConfig[@"composeButtonId"], | ||
| 436 | + mailSearchToolbarConfig[@"composeMenu"]); | ||
| 437 | + composeButton.translatesAutoresizingMaskIntoConstraints = NO; | ||
| 438 | + [toolbarHost addSubview:composeButton]; | ||
| 439 | + [NSLayoutConstraint activateConstraints:@[ | ||
| 440 | + [composeButton.trailingAnchor constraintEqualToAnchor:toolbarHost.trailingAnchor], | ||
| 441 | + [composeButton.centerYAnchor constraintEqualToAnchor:toolbarHost.centerYAnchor], | ||
| 442 | + [composeButton.widthAnchor constraintEqualToConstant:buttonSize], | ||
| 443 | + [composeButton.heightAnchor constraintEqualToConstant:buttonSize], | ||
| 444 | + ]]; | ||
| 445 | + } | ||
| 446 | + } | ||
| 447 | +#endif | ||
| 448 | + } | ||
| 449 | + | ||
| 450 | + NSArray<NSDictionary<NSString *, id> *> *navigationToolbarConfigs = config.headerToolbarItems; | ||
| 451 | + if (mailSearchToolbarConfig != nil) { | ||
| 452 | + navigationToolbarConfigs = @[]; | ||
| 453 | + } | ||
| 454 | + | ||
| 455 | + NSArray<UIBarButtonItem *> *toolbarItems = [config barButtonItemsFromConfigs:navigationToolbarConfigs | ||
| 456 | + withCurrentItems:@[] | ||
| 457 | + navigationItem:navitem]; | ||
| 458 | + if (toolbarItems.count > 0) { | ||
| 459 | + vc.toolbarItems = toolbarItems; | ||
| 460 | + [navctr setToolbarHidden:NO animated:animated]; | ||
| 461 | + } else { | ||
| 462 | + vc.toolbarItems = nil; | ||
| 463 | + [navctr setToolbarHidden:YES animated:animated]; | ||
| 464 | + } | ||
| 465 | |||
| 466 | // Setting navigation bar visibility is split to mitigate iOS 26 bug with bar button items | ||
| 467 | // (setting nav bar visibility should be done after `navitem.*BarButtonItems`). | ||
| 468 | @@ -773,6 +1079,7 @@ - (void)applySemanticContentAttributeIfNeededToNavCtrl:(UINavigationController * | ||
| 469 | |||
| 470 | - (NSArray<UIBarButtonItem *> *)barButtonItemsFromConfigs:(NSArray<NSDictionary<NSString *, id> *> *)dicts | ||
| 471 | withCurrentItems:(NSArray<UIBarButtonItem *> *)currentItems | ||
| 472 | + navigationItem:(UINavigationItem *)navitem | ||
| 473 | { | ||
| 474 | if (dicts.count == 0) { | ||
| 475 | return currentItems; | ||
| 476 | @@ -781,7 +1088,197 @@ - (void)applySemanticContentAttributeIfNeededToNavCtrl:(UINavigationController * | ||
| 477 | [items addObjectsFromArray:currentItems]; | ||
| 478 | for (NSUInteger i = 0; i < dicts.count; i++) { | ||
| 479 | NSDictionary *dict = dicts[i]; | ||
| 480 | - if (dict[@"buttonId"] || dict[@"menu"]) { | ||
| 481 | + if (dict[@"mailSearchToolbar"]) { | ||
| 482 | +#if RNS_IPHONE_OS_VERSION_AVAILABLE(26_0) | ||
| 483 | + if (@available(iOS 26.0, *)) { | ||
| 484 | + NSNumber *width = dict[@"width"]; | ||
| 485 | + CGFloat resolvedWidth = width != nil ? width.doubleValue : MAX(320.0, [UIScreen mainScreen].bounds.size.width - 56.0); | ||
| 486 | + CGFloat resolvedHeight = 58.0; | ||
| 487 | + UIView *container = [[UIView alloc] initWithFrame:CGRectMake(0, 0, resolvedWidth, resolvedHeight)]; | ||
| 488 | + container.translatesAutoresizingMaskIntoConstraints = NO; | ||
| 489 | + [container.widthAnchor constraintEqualToConstant:resolvedWidth].active = YES; | ||
| 490 | + [container.heightAnchor constraintEqualToConstant:resolvedHeight].active = YES; | ||
| 491 | + | ||
| 492 | + UIStackView *stackView = [[UIStackView alloc] initWithFrame:container.bounds]; | ||
| 493 | + stackView.axis = UILayoutConstraintAxisHorizontal; | ||
| 494 | + stackView.alignment = UIStackViewAlignmentCenter; | ||
| 495 | + stackView.distribution = UIStackViewDistributionFill; | ||
| 496 | + stackView.spacing = 10.0; | ||
| 497 | + stackView.translatesAutoresizingMaskIntoConstraints = NO; | ||
| 498 | + [container addSubview:stackView]; | ||
| 499 | + [NSLayoutConstraint activateConstraints:@[ | ||
| 500 | + [stackView.leadingAnchor constraintEqualToAnchor:container.leadingAnchor], | ||
| 501 | + [stackView.trailingAnchor constraintEqualToAnchor:container.trailingAnchor], | ||
| 502 | + [stackView.centerYAnchor constraintEqualToAnchor:container.centerYAnchor], | ||
| 503 | + [stackView.heightAnchor constraintEqualToConstant:50.0], | ||
| 504 | + ]]; | ||
| 505 | + | ||
| 506 | + void (^emitButtonPress)(NSString *) = ^(NSString *buttonId) { | ||
| 507 | + auto eventEmitter = std::static_pointer_cast<const facebook::react::RNSScreenStackHeaderConfigEventEmitter>( | ||
| 508 | + self->_eventEmitter); | ||
| 509 | + if (eventEmitter && buttonId) { | ||
| 510 | + eventEmitter->onPressHeaderBarButtonItem( | ||
| 511 | + facebook::react::RNSScreenStackHeaderConfigEventEmitter::OnPressHeaderBarButtonItem{ | ||
| 512 | + .buttonId = std::string([buttonId UTF8String])}); | ||
| 513 | + } | ||
| 514 | + }; | ||
| 515 | + | ||
| 516 | + void (^emitMenuPress)(NSString *) = ^(NSString *menuId) { | ||
| 517 | + auto eventEmitter = std::static_pointer_cast<const facebook::react::RNSScreenStackHeaderConfigEventEmitter>( | ||
| 518 | + self->_eventEmitter); | ||
| 519 | + if (eventEmitter && menuId) { | ||
| 520 | + eventEmitter->onPressHeaderBarButtonMenuItem( | ||
| 521 | + facebook::react::RNSScreenStackHeaderConfigEventEmitter::OnPressHeaderBarButtonMenuItem{ | ||
| 522 | + .menuId = std::string([menuId UTF8String])}); | ||
| 523 | + } | ||
| 524 | + }; | ||
| 525 | + | ||
| 526 | + UIButton *(^makeGlassButton)(NSString *, NSString *, NSDictionary *) = | ||
| 527 | + ^UIButton *(NSString *systemImageName, NSString *buttonId, NSDictionary *menuConfig) { | ||
| 528 | + UIButtonConfiguration *configuration = [UIButtonConfiguration glassButtonConfiguration]; | ||
| 529 | + configuration.cornerStyle = UIButtonConfigurationCornerStyleCapsule; | ||
| 530 | + configuration.image = [UIImage systemImageNamed:systemImageName ?: @"circle"]; | ||
| 531 | + | ||
| 532 | + UIButton *button = [UIButton buttonWithConfiguration:configuration primaryAction:nil]; | ||
| 533 | + button.translatesAutoresizingMaskIntoConstraints = NO; | ||
| 534 | + [button.widthAnchor constraintEqualToConstant:58.0].active = YES; | ||
| 535 | + [button.heightAnchor constraintEqualToConstant:50.0].active = YES; | ||
| 536 | + if (menuConfig != nil) { | ||
| 537 | + button.menu = [RNSBarButtonItem initUIMenuWithDict:menuConfig | ||
| 538 | + menuAction:emitMenuPress | ||
| 539 | + imageLoader:self->_imageLoader]; | ||
| 540 | + button.showsMenuAsPrimaryAction = YES; | ||
| 541 | + } else if (buttonId != nil) { | ||
| 542 | + UIAction *action = [UIAction actionWithHandler:^(__kindof UIAction *_Nonnull action) { | ||
| 543 | + emitButtonPress(buttonId); | ||
| 544 | + }]; | ||
| 545 | + [button addAction:action forControlEvents:UIControlEventTouchUpInside]; | ||
| 546 | + } | ||
| 547 | + return button; | ||
| 548 | + }; | ||
| 549 | + | ||
| 550 | + BOOL hasFilterButton = dict[@"filterButtonId"] != nil || dict[@"filterMenu"] != nil; | ||
| 551 | + BOOL hasComposeButton = dict[@"composeButtonId"] != nil || dict[@"composeMenu"] != nil; | ||
| 552 | + | ||
| 553 | + if (hasFilterButton) { | ||
| 554 | + UIButton *filterButton = makeGlassButton( | ||
| 555 | + dict[@"filterSystemImageName"] ?: @"line.3.horizontal.decrease", | ||
| 556 | + dict[@"filterButtonId"], | ||
| 557 | + dict[@"filterMenu"]); | ||
| 558 | + [stackView addArrangedSubview:filterButton]; | ||
| 559 | + } | ||
| 560 | + | ||
| 561 | + UISearchBar *searchBar = navitem.searchController != nil ? navitem.searchController.searchBar : [UISearchBar new]; | ||
| 562 | + NSString *placeholder = dict[@"placeholder"]; | ||
| 563 | + if (placeholder != nil) { | ||
| 564 | + searchBar.placeholder = placeholder; | ||
| 565 | + } | ||
| 566 | + searchBar.searchBarStyle = UISearchBarStyleMinimal; | ||
| 567 | + searchBar.translatesAutoresizingMaskIntoConstraints = NO; | ||
| 568 | + [searchBar.heightAnchor constraintEqualToConstant:50.0].active = YES; | ||
| 569 | + [stackView addArrangedSubview:searchBar]; | ||
| 570 | + | ||
| 571 | + if (hasComposeButton) { | ||
| 572 | + UIButton *composeButton = makeGlassButton( | ||
| 573 | + dict[@"composeSystemImageName"] ?: @"square.and.pencil", | ||
| 574 | + dict[@"composeButtonId"], | ||
| 575 | + dict[@"composeMenu"]); | ||
| 576 | + [stackView addArrangedSubview:composeButton]; | ||
| 577 | + } | ||
| 578 | + | ||
| 579 | + UIBarButtonItem *item = [[UIBarButtonItem alloc] initWithCustomView:container]; | ||
| 580 | + [items addObject:item]; | ||
| 581 | + } | ||
| 582 | +#endif | ||
| 583 | + } else if (dict[@"searchField"]) { | ||
| 584 | +#if RNS_IPHONE_OS_VERSION_AVAILABLE(26_0) | ||
| 585 | + if (@available(iOS 26.0, *)) { | ||
| 586 | + NSNumber *width = dict[@"width"]; | ||
| 587 | + CGFloat resolvedWidth = width != nil ? width.doubleValue : 280.0; | ||
| 588 | + UIView *container = [[UIView alloc] initWithFrame:CGRectMake(0, 0, resolvedWidth, 44.0)]; | ||
| 589 | + container.translatesAutoresizingMaskIntoConstraints = NO; | ||
| 590 | + [container.widthAnchor constraintEqualToConstant:resolvedWidth].active = YES; | ||
| 591 | + [container.heightAnchor constraintEqualToConstant:44.0].active = YES; | ||
| 592 | + | ||
| 593 | + UISearchTextField *searchField = [UISearchTextField new]; | ||
| 594 | + NSString *placeholder = dict[@"placeholder"]; | ||
| 595 | + if (placeholder != nil) { | ||
| 596 | + searchField.placeholder = placeholder; | ||
| 597 | + } | ||
| 598 | + searchField.translatesAutoresizingMaskIntoConstraints = NO; | ||
| 599 | + [container addSubview:searchField]; | ||
| 600 | + [NSLayoutConstraint activateConstraints:@[ | ||
| 601 | + [searchField.leadingAnchor constraintEqualToAnchor:container.leadingAnchor], | ||
| 602 | + [searchField.trailingAnchor constraintEqualToAnchor:container.trailingAnchor], | ||
| 603 | + [searchField.centerYAnchor constraintEqualToAnchor:container.centerYAnchor], | ||
| 604 | + [searchField.heightAnchor constraintEqualToConstant:40.0], | ||
| 605 | + ]]; | ||
| 606 | + | ||
| 607 | + UIBarButtonItem *item = [[UIBarButtonItem alloc] initWithCustomView:container]; | ||
| 608 | + [items addObject:item]; | ||
| 609 | + } | ||
| 610 | +#endif | ||
| 611 | + } else if (dict[@"searchBarPlacement"]) { | ||
| 612 | +#if RNS_IPHONE_OS_VERSION_AVAILABLE(26_0) | ||
| 613 | + if (@available(iOS 26.0, *)) { | ||
| 614 | + NSNumber *width = dict[@"width"]; | ||
| 615 | + UIBarButtonItem *item = nil; | ||
| 616 | + if ([dict[@"activatesSearchController"] boolValue]) { | ||
| 617 | + UIButtonConfiguration *configuration = [UIButtonConfiguration glassButtonConfiguration]; | ||
| 618 | + configuration.cornerStyle = UIButtonConfigurationCornerStyleCapsule; | ||
| 619 | + configuration.image = [UIImage systemImageNamed:@"magnifyingglass"]; | ||
| 620 | + configuration.baseForegroundColor = UIColor.labelColor; | ||
| 621 | + UIButton *button = [UIButton buttonWithConfiguration:configuration primaryAction:nil]; | ||
| 622 | + button.translatesAutoresizingMaskIntoConstraints = NO; | ||
| 623 | + [button.widthAnchor constraintEqualToConstant:56.0].active = YES; | ||
| 624 | + [button.heightAnchor constraintEqualToConstant:56.0].active = YES; | ||
| 625 | + UIAction *action = [UIAction actionWithHandler:^(__kindof UIAction *_Nonnull action) { | ||
| 626 | + if (navitem.searchController != nil) { | ||
| 627 | + [navitem.searchController setActive:YES]; | ||
| 628 | + [navitem.searchController.searchBar becomeFirstResponder]; | ||
| 629 | + } | ||
| 630 | + }]; | ||
| 631 | + [button addAction:action forControlEvents:UIControlEventTouchUpInside]; | ||
| 632 | + item = [[UIBarButtonItem alloc] initWithCustomView:button]; | ||
| 633 | + } else if ([dict[@"customView"] boolValue] && navitem.searchController != nil) { | ||
| 634 | + UISearchBar *searchBar = navitem.searchController.searchBar; | ||
| 635 | + CGFloat resolvedWidth = width != nil ? width.doubleValue : searchBar.frame.size.width; | ||
| 636 | + CGFloat resolvedHeight = searchBar.frame.size.height > 0 ? searchBar.frame.size.height : 44.0; | ||
| 637 | + UIView *container = [[UIView alloc] initWithFrame:CGRectMake(0, 0, resolvedWidth, resolvedHeight)]; | ||
| 638 | + container.translatesAutoresizingMaskIntoConstraints = NO; | ||
| 639 | + [container.widthAnchor constraintEqualToConstant:resolvedWidth].active = YES; | ||
| 640 | + [container.heightAnchor constraintEqualToConstant:resolvedHeight].active = YES; | ||
| 641 | + searchBar.frame = container.bounds; | ||
| 642 | + searchBar.autoresizingMask = UIViewAutoresizingFlexibleWidth | UIViewAutoresizingFlexibleHeight; | ||
| 643 | + [container addSubview:searchBar]; | ||
| 644 | + item = [[UIBarButtonItem alloc] initWithCustomView:container]; | ||
| 645 | + } else { | ||
| 646 | + item = navitem.searchBarPlacementBarButtonItem; | ||
| 647 | + if (width != nil) { | ||
| 648 | + item.width = width.doubleValue; | ||
| 649 | + } | ||
| 650 | + } | ||
| 651 | + NSNumber *index = dict[@"index"]; | ||
| 652 | +#if RNS_IPHONE_OS_VERSION_AVAILABLE(26_0) | ||
| 653 | + if (@available(iOS 26.0, *)) { | ||
| 654 | + NSNumber *sharesBackground = dict[@"sharesBackground"]; | ||
| 655 | + if (sharesBackground != nil) { | ||
| 656 | + item.sharesBackground = sharesBackground.boolValue; | ||
| 657 | + } | ||
| 658 | + NSNumber *hidesSharedBackground = dict[@"hidesSharedBackground"]; | ||
| 659 | + if (hidesSharedBackground != nil) { | ||
| 660 | + item.hidesSharedBackground = hidesSharedBackground.boolValue; | ||
| 661 | + } | ||
| 662 | + } | ||
| 663 | +#endif | ||
| 664 | + if (index != nil && index.integerValue < items.count) { | ||
| 665 | + [items insertObject:item atIndex:index.integerValue]; | ||
| 666 | + } else { | ||
| 667 | + [items addObject:item]; | ||
| 668 | + } | ||
| 669 | + } | ||
| 670 | +#endif | ||
| 671 | + } else if (dict[@"buttonId"] || dict[@"menu"]) { | ||
| 672 | RNSBarButtonItem *item = [[RNSBarButtonItem alloc] initWithConfig:dict | ||
| 673 | action:^(NSString *buttonId) { | ||
| 674 | auto eventEmitter = std::static_pointer_cast<const facebook::react::RNSScreenStackHeaderConfigEventEmitter>( | ||
| 675 | @@ -809,11 +1306,15 @@ - (void)applySemanticContentAttributeIfNeededToNavCtrl:(UINavigationController * | ||
| 676 | [items addObject:item]; | ||
| 677 | } | ||
| 678 | } else if (dict[@"spacing"]) { | ||
| 679 | - UIBarButtonItem *item = [[UIBarButtonItem alloc] initWithBarButtonSystemItem:UIBarButtonSystemItemFixedSpace | ||
| 680 | - target:nil | ||
| 681 | - action:nil]; | ||
| 682 | + BOOL flexible = [dict[@"flexible"] boolValue]; | ||
| 683 | + UIBarButtonItem *item = [[UIBarButtonItem alloc] | ||
| 684 | + initWithBarButtonSystemItem:(flexible ? UIBarButtonSystemItemFlexibleSpace : UIBarButtonSystemItemFixedSpace) | ||
| 685 | + target:nil | ||
| 686 | + action:nil]; | ||
| 687 | NSNumber *spacingValue = dict[@"spacing"]; | ||
| 688 | - item.width = [spacingValue doubleValue]; | ||
| 689 | + if (!flexible) { | ||
| 690 | + item.width = [spacingValue doubleValue]; | ||
| 691 | + } | ||
| 692 | NSNumber *index = dict[@"index"]; | ||
| 693 | if (index.integerValue < items.count) { | ||
| 694 | [items insertObject:item atIndex:index.integerValue]; | ||
| 695 | @@ -825,6 +1326,47 @@ - (void)applySemanticContentAttributeIfNeededToNavCtrl:(UINavigationController * | ||
| 696 | return items; | ||
| 697 | } | ||
| 698 | |||
| 699 | +#if !TARGET_OS_TV | ||
| 700 | +- (NSArray<UIBarButtonItemGroup *> *)barButtonItemGroupsFromItems:(NSArray<UIBarButtonItem *> *)items | ||
| 701 | +{ | ||
| 702 | + if (items.count == 0) { | ||
| 703 | + return @[]; | ||
| 704 | + } | ||
| 705 | + | ||
| 706 | + NSMutableArray<UIBarButtonItemGroup *> *groups = [NSMutableArray array]; | ||
| 707 | + NSMutableArray<UIBarButtonItem *> *sharedRun = [NSMutableArray array]; | ||
| 708 | + | ||
| 709 | + void (^flushSharedRun)(void) = ^{ | ||
| 710 | + if (sharedRun.count == 0) { | ||
| 711 | + return; | ||
| 712 | + } | ||
| 713 | + [groups addObject:[UIBarButtonItemGroup fixedGroupWithRepresentativeItem:nil | ||
| 714 | + items:[sharedRun copy]]]; | ||
| 715 | + [sharedRun removeAllObjects]; | ||
| 716 | + }; | ||
| 717 | + | ||
| 718 | + for (UIBarButtonItem *item in items) { | ||
| 719 | + BOOL shouldShareBackground = NO; | ||
| 720 | +#if RNS_IPHONE_OS_VERSION_AVAILABLE(26_0) | ||
| 721 | + if (@available(iOS 26.0, *)) { | ||
| 722 | + shouldShareBackground = item.sharesBackground && !item.hidesSharedBackground; | ||
| 723 | + } | ||
| 724 | +#endif | ||
| 725 | + | ||
| 726 | + if (shouldShareBackground) { | ||
| 727 | + [sharedRun addObject:item]; | ||
| 728 | + continue; | ||
| 729 | + } | ||
| 730 | + | ||
| 731 | + flushSharedRun(); | ||
| 732 | + [groups addObject:[UIBarButtonItemGroup fixedGroupWithRepresentativeItem:nil items:@[ item ]]]; | ||
| 733 | + } | ||
| 734 | + | ||
| 735 | + flushSharedRun(); | ||
| 736 | + return groups; | ||
| 737 | +} | ||
| 738 | +#endif | ||
| 739 | + | ||
| 740 | RNS_IGNORE_SUPER_CALL_BEGIN | ||
| 741 | - (void)insertReactSubview:(RNSScreenStackHeaderSubview *)subview atIndex:(NSInteger)atIndex | ||
| 742 | { | ||
| 743 | @@ -1013,6 +1555,8 @@ - (void)updateProps:(react::Props::Shared const &)props oldProps:(react::Props:: | ||
| 744 | } | ||
| 745 | |||
| 746 | _title = RCTNSStringFromStringNilIfEmpty(newScreenProps.title); | ||
| 747 | + _subtitle = RCTNSStringFromStringNilIfEmpty(newScreenProps.subtitle); | ||
| 748 | + _largeSubtitle = RCTNSStringFromStringNilIfEmpty(newScreenProps.largeSubtitle); | ||
| 749 | if (newScreenProps.titleFontFamily != oldScreenProps.titleFontFamily) { | ||
| 750 | _titleFontFamily = RCTNSStringFromStringNilIfEmpty(newScreenProps.titleFontFamily); | ||
| 751 | } | ||
| 752 | @@ -1038,6 +1582,7 @@ - (void)updateProps:(react::Props::Shared const &)props oldProps:(react::Props:: | ||
| 753 | _disableBackButtonMenu = newScreenProps.disableBackButtonMenu; | ||
| 754 | _backButtonDisplayMode = | ||
| 755 | [RNSConvert UINavigationItemBackButtonDisplayModeFromCppEquivalent:newScreenProps.backButtonDisplayMode]; | ||
| 756 | + _navigationItemStyle = navigationItemStyleFromCppEquivalent(newScreenProps.navigationItemStyle); | ||
| 757 | |||
| 758 | if (newScreenProps.userInterfaceStyle != oldScreenProps.userInterfaceStyle) { | ||
| 759 | _userInterfaceStyle = [RNSConvert UIUserInterfaceStyleFromCppEquivalent:newScreenProps.userInterfaceStyle]; | ||
| 760 | @@ -1084,6 +1629,30 @@ - (void)updateProps:(react::Props::Shared const &)props oldProps:(react::Props:: | ||
| 761 | _headerRightBarButtonItems = array; | ||
| 762 | } | ||
| 763 | |||
| 764 | + if (newScreenProps.headerCenterBarButtonItems != oldScreenProps.headerCenterBarButtonItems) { | ||
| 765 | + const auto &vec = newScreenProps.headerCenterBarButtonItems; | ||
| 766 | + NSMutableArray<NSDictionary<NSString *, id> *> *array = [NSMutableArray arrayWithCapacity:vec.size()]; | ||
| 767 | + for (const auto &item : vec) { | ||
| 768 | + NSDictionary *dict = [RNSConvert idFromFollyDynamic:item]; | ||
| 769 | + if ([dict isKindOfClass:[NSDictionary class]]) { | ||
| 770 | + [array addObject:dict]; | ||
| 771 | + } | ||
| 772 | + } | ||
| 773 | + _headerCenterBarButtonItems = array; | ||
| 774 | + } | ||
| 775 | + | ||
| 776 | + if (newScreenProps.headerToolbarItems != oldScreenProps.headerToolbarItems) { | ||
| 777 | + const auto &vec = newScreenProps.headerToolbarItems; | ||
| 778 | + NSMutableArray<NSDictionary<NSString *, id> *> *array = [NSMutableArray arrayWithCapacity:vec.size()]; | ||
| 779 | + for (const auto &item : vec) { | ||
| 780 | + NSDictionary *dict = [RNSConvert idFromFollyDynamic:item]; | ||
| 781 | + if ([dict isKindOfClass:[NSDictionary class]]) { | ||
| 782 | + [array addObject:dict]; | ||
| 783 | + } | ||
| 784 | + } | ||
| 785 | + _headerToolbarItems = array; | ||
| 786 | + } | ||
| 787 | + | ||
| 788 | [self updateViewControllerIfNeeded]; | ||
| 789 | |||
| 790 | if (needsNavigationControllerLayout) { | ||
| 791 | diff --git a/lib/commonjs/components/ScreenStackHeaderConfig.js b/lib/commonjs/components/ScreenStackHeaderConfig.js | ||
| 792 | index 16b979bb3dfb41ff247403f1632c300a9a60d549..01415d4cba66086a8d8e5af728f809aee5190d91 100644 | ||
| 793 | --- a/lib/commonjs/components/ScreenStackHeaderConfig.js | ||
| 794 | +++ b/lib/commonjs/components/ScreenStackHeaderConfig.js | ||
| 795 | @@ -23,18 +23,42 @@ const ScreenStackHeaderConfig = exports.ScreenStackHeaderConfig = /*#__PURE__*/_ | ||
| 796 | } = (0, _TopInsetApplicationContext.useTopInsetApplication)(!props.hidden, props.disableTopInsetApplication ?? false); | ||
| 797 | const { | ||
| 798 | headerLeftBarButtonItems, | ||
| 799 | - headerRightBarButtonItems | ||
| 800 | + headerRightBarButtonItems, | ||
| 801 | + headerCenterBarButtonItems, | ||
| 802 | + headerToolbarItems | ||
| 803 | } = props; | ||
| 804 | const preparedHeaderLeftBarButtonItems = headerLeftBarButtonItems && _utils.isHeaderBarButtonsAvailableForCurrentPlatform ? (0, _prepareHeaderBarButtonItems.prepareHeaderBarButtonItems)(headerLeftBarButtonItems, 'left') : undefined; | ||
| 805 | const preparedHeaderRightBarButtonItems = headerRightBarButtonItems && _utils.isHeaderBarButtonsAvailableForCurrentPlatform ? (0, _prepareHeaderBarButtonItems.prepareHeaderBarButtonItems)(headerRightBarButtonItems, 'right') : undefined; | ||
| 806 | - const hasHeaderBarButtonItems = _utils.isHeaderBarButtonsAvailableForCurrentPlatform && (preparedHeaderLeftBarButtonItems?.length || preparedHeaderRightBarButtonItems?.length); | ||
| 807 | + const preparedHeaderCenterBarButtonItems = headerCenterBarButtonItems && _utils.isHeaderBarButtonsAvailableForCurrentPlatform ? (0, _prepareHeaderBarButtonItems.prepareHeaderBarButtonItems)(headerCenterBarButtonItems, 'right') : undefined; | ||
| 808 | + const preparedHeaderToolbarItems = headerToolbarItems && _utils.isHeaderBarButtonsAvailableForCurrentPlatform ? (0, _prepareHeaderBarButtonItems.prepareHeaderBarButtonItems)(headerToolbarItems, 'right') : undefined; | ||
| 809 | + const hasHeaderBarButtonItems = _utils.isHeaderBarButtonsAvailableForCurrentPlatform && (preparedHeaderLeftBarButtonItems?.length || preparedHeaderRightBarButtonItems?.length || preparedHeaderCenterBarButtonItems?.length || preparedHeaderToolbarItems?.length); | ||
| 810 | |||
| 811 | // Handle bar button item presses | ||
| 812 | const onPressHeaderBarButtonItem = hasHeaderBarButtonItems ? event => { | ||
| 813 | - const pressedItem = [...(preparedHeaderLeftBarButtonItems ?? []), ...(preparedHeaderRightBarButtonItems ?? [])].find(item => item && 'buttonId' in item && item.buttonId === event.nativeEvent.buttonId); | ||
| 814 | + const buttonId = event.nativeEvent.buttonId; | ||
| 815 | + const allItems = [...(preparedHeaderLeftBarButtonItems ?? []), ...(preparedHeaderRightBarButtonItems ?? []), ...(preparedHeaderCenterBarButtonItems ?? []), ...(preparedHeaderToolbarItems ?? [])]; | ||
| 816 | + const pressedItem = allItems.find(item => item && 'buttonId' in item && item.buttonId === buttonId); | ||
| 817 | if (pressedItem && pressedItem.type === 'button' && pressedItem.onPress) { | ||
| 818 | pressedItem.onPress(); | ||
| 819 | } | ||
| 820 | + for (const item of allItems) { | ||
| 821 | + if (!item || item.type !== 'mailSearchToolbar') { | ||
| 822 | + continue; | ||
| 823 | + } | ||
| 824 | + if (item.filterButtonId === buttonId) { | ||
| 825 | + item.onFilterPress?.(); | ||
| 826 | + return; | ||
| 827 | + } | ||
| 828 | + if (item.composeButtonId === buttonId) { | ||
| 829 | + item.onComposePress?.(); | ||
| 830 | + return; | ||
| 831 | + } | ||
| 832 | + const searchTextChangePrefix = item.searchTextChangeId ? `${item.searchTextChangeId}:` : undefined; | ||
| 833 | + if (searchTextChangePrefix && buttonId.startsWith(searchTextChangePrefix)) { | ||
| 834 | + item.onSearchTextChange?.(buttonId.slice(searchTextChangePrefix.length)); | ||
| 835 | + return; | ||
| 836 | + } | ||
| 837 | + } | ||
| 838 | } : undefined; | ||
| 839 | |||
| 840 | // Handle bar button menu item presses by deep-searching nested menus | ||
| 841 | @@ -56,7 +80,7 @@ const ScreenStackHeaderConfig = exports.ScreenStackHeaderConfig = /*#__PURE__*/_ | ||
| 842 | }; | ||
| 843 | |||
| 844 | // Check each bar-button item with a menu | ||
| 845 | - const allItems = [...(preparedHeaderLeftBarButtonItems ?? []), ...(preparedHeaderRightBarButtonItems ?? [])]; | ||
| 846 | + const allItems = [...(preparedHeaderLeftBarButtonItems ?? []), ...(preparedHeaderRightBarButtonItems ?? []), ...(preparedHeaderCenterBarButtonItems ?? []), ...(preparedHeaderToolbarItems ?? [])]; | ||
| 847 | for (const item of allItems) { | ||
| 848 | if (item && item.type === 'menu' && item.menu) { | ||
| 849 | const action = findInMenu(item.menu, event.nativeEvent.menuId); | ||
| 850 | @@ -64,6 +88,15 @@ const ScreenStackHeaderConfig = exports.ScreenStackHeaderConfig = /*#__PURE__*/_ | ||
| 851 | action.onPress(); | ||
| 852 | return; | ||
| 853 | } | ||
| 854 | + } else if (item && item.type === 'mailSearchToolbar') { | ||
| 855 | + const toolbarMenus = [item.filterMenu, item.composeMenu].filter(Boolean); | ||
| 856 | + for (const toolbarMenu of toolbarMenus) { | ||
| 857 | + const action = findInMenu(toolbarMenu, event.nativeEvent.menuId); | ||
| 858 | + if (action) { | ||
| 859 | + action.onPress(); | ||
| 860 | + return; | ||
| 861 | + } | ||
| 862 | + } | ||
| 863 | } | ||
| 864 | } | ||
| 865 | } : undefined; | ||
| 866 | @@ -71,6 +104,8 @@ const ScreenStackHeaderConfig = exports.ScreenStackHeaderConfig = /*#__PURE__*/_ | ||
| 867 | userInterfaceStyle: props.experimental_userInterfaceStyle, | ||
| 868 | headerLeftBarButtonItems: preparedHeaderLeftBarButtonItems, | ||
| 869 | headerRightBarButtonItems: preparedHeaderRightBarButtonItems, | ||
| 870 | + headerCenterBarButtonItems: preparedHeaderCenterBarButtonItems, | ||
| 871 | + headerToolbarItems: preparedHeaderToolbarItems, | ||
| 872 | onPressHeaderBarButtonItem: onPressHeaderBarButtonItem, | ||
| 873 | onPressHeaderBarButtonMenuItem: onPressHeaderBarButtonMenuItem, | ||
| 874 | ref: ref, | ||
| 875 | diff --git a/lib/commonjs/components/helpers/prepareHeaderBarButtonItems.js b/lib/commonjs/components/helpers/prepareHeaderBarButtonItems.js | ||
| 876 | index ab93f62e4a7049d63c6681cecbd6cf8b1d07ce10..b5ea122473b023f84eabdb1914aef09a28c6d525 100644 | ||
| 877 | --- a/lib/commonjs/components/helpers/prepareHeaderBarButtonItems.js | ||
| 878 | +++ b/lib/commonjs/components/helpers/prepareHeaderBarButtonItems.js | ||
| 879 | @@ -41,10 +41,31 @@ const prepareMenu = (menu, index, side, path = '') => { | ||
| 880 | }; | ||
| 881 | }; | ||
| 882 | const prepareHeaderBarButtonItems = (barButtonItems, side) => { | ||
| 883 | - return barButtonItems?.map((item, index) => { | ||
| 884 | + const items = Array.isArray(barButtonItems) ? barButtonItems : barButtonItems && typeof barButtonItems === 'object' && 'type' in barButtonItems ? [barButtonItems] : undefined; | ||
| 885 | + return items?.map((item, index) => { | ||
| 886 | if (item.type === 'spacing') { | ||
| 887 | return item; | ||
| 888 | } | ||
| 889 | + if (item.type === 'searchBarPlacement') { | ||
| 890 | + return { | ||
| 891 | + ...item, | ||
| 892 | + searchBarPlacement: true | ||
| 893 | + }; | ||
| 894 | + } | ||
| 895 | + if (item.type === 'searchField') { | ||
| 896 | + return { | ||
| 897 | + ...item, | ||
| 898 | + searchField: true | ||
| 899 | + }; | ||
| 900 | + } | ||
| 901 | + if (item.type === 'mailSearchToolbar') { | ||
| 902 | + return { | ||
| 903 | + ...item, | ||
| 904 | + mailSearchToolbar: true, | ||
| 905 | + filterMenu: item.filterMenu ? prepareMenu(item.filterMenu, index, side, 'filter') : undefined, | ||
| 906 | + composeMenu: item.composeMenu ? prepareMenu(item.composeMenu, index, side, 'compose') : undefined | ||
| 907 | + }; | ||
| 908 | + } | ||
| 909 | let imageSource, templateSource; | ||
| 910 | if (item.icon?.type === 'imageSource') { | ||
| 911 | imageSource = _reactNative.Image.resolveAssetSource(item.icon.imageSource); | ||
| 912 | diff --git a/lib/module/components/ScreenStackHeaderConfig.js b/lib/module/components/ScreenStackHeaderConfig.js | ||
| 913 | index cf15f36f25e51d95c896fbc3a31ccba90156a5b6..dd66167c4ab0e5640ad27400e08dcfb4d20dce2a 100644 | ||
| 914 | --- a/lib/module/components/ScreenStackHeaderConfig.js | ||
| 915 | +++ b/lib/module/components/ScreenStackHeaderConfig.js | ||
| 916 | @@ -19,18 +19,42 @@ export const ScreenStackHeaderConfig = /*#__PURE__*/React.forwardRef((props, ref | ||
| 917 | } = useTopInsetApplication(!props.hidden, props.disableTopInsetApplication ?? false); | ||
| 918 | const { | ||
| 919 | headerLeftBarButtonItems, | ||
| 920 | - headerRightBarButtonItems | ||
| 921 | + headerRightBarButtonItems, | ||
| 922 | + headerCenterBarButtonItems, | ||
| 923 | + headerToolbarItems | ||
| 924 | } = props; | ||
| 925 | const preparedHeaderLeftBarButtonItems = headerLeftBarButtonItems && isHeaderBarButtonsAvailableForCurrentPlatform ? prepareHeaderBarButtonItems(headerLeftBarButtonItems, 'left') : undefined; | ||
| 926 | const preparedHeaderRightBarButtonItems = headerRightBarButtonItems && isHeaderBarButtonsAvailableForCurrentPlatform ? prepareHeaderBarButtonItems(headerRightBarButtonItems, 'right') : undefined; | ||
| 927 | - const hasHeaderBarButtonItems = isHeaderBarButtonsAvailableForCurrentPlatform && (preparedHeaderLeftBarButtonItems?.length || preparedHeaderRightBarButtonItems?.length); | ||
| 928 | + const preparedHeaderCenterBarButtonItems = headerCenterBarButtonItems && isHeaderBarButtonsAvailableForCurrentPlatform ? prepareHeaderBarButtonItems(headerCenterBarButtonItems, 'right') : undefined; | ||
| 929 | + const preparedHeaderToolbarItems = headerToolbarItems && isHeaderBarButtonsAvailableForCurrentPlatform ? prepareHeaderBarButtonItems(headerToolbarItems, 'right') : undefined; | ||
| 930 | + const hasHeaderBarButtonItems = isHeaderBarButtonsAvailableForCurrentPlatform && (preparedHeaderLeftBarButtonItems?.length || preparedHeaderRightBarButtonItems?.length || preparedHeaderCenterBarButtonItems?.length || preparedHeaderToolbarItems?.length); | ||
| 931 | |||
| 932 | // Handle bar button item presses | ||
| 933 | const onPressHeaderBarButtonItem = hasHeaderBarButtonItems ? event => { | ||
| 934 | - const pressedItem = [...(preparedHeaderLeftBarButtonItems ?? []), ...(preparedHeaderRightBarButtonItems ?? [])].find(item => item && 'buttonId' in item && item.buttonId === event.nativeEvent.buttonId); | ||
| 935 | + const buttonId = event.nativeEvent.buttonId; | ||
| 936 | + const allItems = [...(preparedHeaderLeftBarButtonItems ?? []), ...(preparedHeaderRightBarButtonItems ?? []), ...(preparedHeaderCenterBarButtonItems ?? []), ...(preparedHeaderToolbarItems ?? [])]; | ||
| 937 | + const pressedItem = allItems.find(item => item && 'buttonId' in item && item.buttonId === buttonId); | ||
| 938 | if (pressedItem && pressedItem.type === 'button' && pressedItem.onPress) { | ||
| 939 | pressedItem.onPress(); | ||
| 940 | } | ||
| 941 | + for (const item of allItems) { | ||
| 942 | + if (!item || item.type !== 'mailSearchToolbar') { | ||
| 943 | + continue; | ||
| 944 | + } | ||
| 945 | + if (item.filterButtonId === buttonId) { | ||
| 946 | + item.onFilterPress?.(); | ||
| 947 | + return; | ||
| 948 | + } | ||
| 949 | + if (item.composeButtonId === buttonId) { | ||
| 950 | + item.onComposePress?.(); | ||
| 951 | + return; | ||
| 952 | + } | ||
| 953 | + const searchTextChangePrefix = item.searchTextChangeId ? `${item.searchTextChangeId}:` : undefined; | ||
| 954 | + if (searchTextChangePrefix && buttonId.startsWith(searchTextChangePrefix)) { | ||
| 955 | + item.onSearchTextChange?.(buttonId.slice(searchTextChangePrefix.length)); | ||
| 956 | + return; | ||
| 957 | + } | ||
| 958 | + } | ||
| 959 | } : undefined; | ||
| 960 | |||
| 961 | // Handle bar button menu item presses by deep-searching nested menus | ||
| 962 | @@ -52,7 +76,7 @@ export const ScreenStackHeaderConfig = /*#__PURE__*/React.forwardRef((props, ref | ||
| 963 | }; | ||
| 964 | |||
| 965 | // Check each bar-button item with a menu | ||
| 966 | - const allItems = [...(preparedHeaderLeftBarButtonItems ?? []), ...(preparedHeaderRightBarButtonItems ?? [])]; | ||
| 967 | + const allItems = [...(preparedHeaderLeftBarButtonItems ?? []), ...(preparedHeaderRightBarButtonItems ?? []), ...(preparedHeaderCenterBarButtonItems ?? []), ...(preparedHeaderToolbarItems ?? [])]; | ||
| 968 | for (const item of allItems) { | ||
| 969 | if (item && item.type === 'menu' && item.menu) { | ||
| 970 | const action = findInMenu(item.menu, event.nativeEvent.menuId); | ||
| 971 | @@ -60,6 +84,15 @@ export const ScreenStackHeaderConfig = /*#__PURE__*/React.forwardRef((props, ref | ||
| 972 | action.onPress(); | ||
| 973 | return; | ||
| 974 | } | ||
| 975 | + } else if (item && item.type === 'mailSearchToolbar') { | ||
| 976 | + const toolbarMenus = [item.filterMenu, item.composeMenu].filter(Boolean); | ||
| 977 | + for (const toolbarMenu of toolbarMenus) { | ||
| 978 | + const action = findInMenu(toolbarMenu, event.nativeEvent.menuId); | ||
| 979 | + if (action) { | ||
| 980 | + action.onPress(); | ||
| 981 | + return; | ||
| 982 | + } | ||
| 983 | + } | ||
| 984 | } | ||
| 985 | } | ||
| 986 | } : undefined; | ||
| 987 | @@ -67,6 +100,8 @@ export const ScreenStackHeaderConfig = /*#__PURE__*/React.forwardRef((props, ref | ||
| 988 | userInterfaceStyle: props.experimental_userInterfaceStyle, | ||
| 989 | headerLeftBarButtonItems: preparedHeaderLeftBarButtonItems, | ||
| 990 | headerRightBarButtonItems: preparedHeaderRightBarButtonItems, | ||
| 991 | + headerCenterBarButtonItems: preparedHeaderCenterBarButtonItems, | ||
| 992 | + headerToolbarItems: preparedHeaderToolbarItems, | ||
| 993 | onPressHeaderBarButtonItem: onPressHeaderBarButtonItem, | ||
| 994 | onPressHeaderBarButtonMenuItem: onPressHeaderBarButtonMenuItem, | ||
| 995 | ref: ref, | ||
| 996 | diff --git a/lib/module/components/helpers/prepareHeaderBarButtonItems.js b/lib/module/components/helpers/prepareHeaderBarButtonItems.js | ||
| 997 | index 8a70ffd78617147418d628c03c580bb7ff9a8a72..8dbd0ba2ee61ee54d44cdf286a720ce26af01f26 100644 | ||
| 998 | --- a/lib/module/components/helpers/prepareHeaderBarButtonItems.js | ||
| 999 | +++ b/lib/module/components/helpers/prepareHeaderBarButtonItems.js | ||
| 1000 | @@ -35,10 +35,31 @@ const prepareMenu = (menu, index, side, path = '') => { | ||
| 1001 | }; | ||
| 1002 | }; | ||
| 1003 | export const prepareHeaderBarButtonItems = (barButtonItems, side) => { | ||
| 1004 | - return barButtonItems?.map((item, index) => { | ||
| 1005 | + const items = Array.isArray(barButtonItems) ? barButtonItems : barButtonItems && typeof barButtonItems === 'object' && 'type' in barButtonItems ? [barButtonItems] : undefined; | ||
| 1006 | + return items?.map((item, index) => { | ||
| 1007 | if (item.type === 'spacing') { | ||
| 1008 | return item; | ||
| 1009 | } | ||
| 1010 | + if (item.type === 'searchBarPlacement') { | ||
| 1011 | + return { | ||
| 1012 | + ...item, | ||
| 1013 | + searchBarPlacement: true | ||
| 1014 | + }; | ||
| 1015 | + } | ||
| 1016 | + if (item.type === 'searchField') { | ||
| 1017 | + return { | ||
| 1018 | + ...item, | ||
| 1019 | + searchField: true | ||
| 1020 | + }; | ||
| 1021 | + } | ||
| 1022 | + if (item.type === 'mailSearchToolbar') { | ||
| 1023 | + return { | ||
| 1024 | + ...item, | ||
| 1025 | + mailSearchToolbar: true, | ||
| 1026 | + filterMenu: item.filterMenu ? prepareMenu(item.filterMenu, index, side, 'filter') : undefined, | ||
| 1027 | + composeMenu: item.composeMenu ? prepareMenu(item.composeMenu, index, side, 'compose') : undefined | ||
| 1028 | + }; | ||
| 1029 | + } | ||
| 1030 | let imageSource, templateSource; | ||
| 1031 | if (item.icon?.type === 'imageSource') { | ||
| 1032 | imageSource = Image.resolveAssetSource(item.icon.imageSource); | ||
| 1033 | diff --git a/lib/typescript/fabric/ScreenStackHeaderConfigNativeComponent.d.ts b/lib/typescript/fabric/ScreenStackHeaderConfigNativeComponent.d.ts | ||
| 1034 | index b7568ecfebd4f4420f2a8d3fec5184d7fc728dd1..41ca1f273f0175929e73105faa463978323837e3 100644 | ||
| 1035 | --- a/lib/typescript/fabric/ScreenStackHeaderConfigNativeComponent.d.ts | ||
| 1036 | +++ b/lib/typescript/fabric/ScreenStackHeaderConfigNativeComponent.d.ts | ||
| 1037 | @@ -9,6 +9,7 @@ type OnPressHeaderBarButtonMenuItemEvent = Readonly<{ | ||
| 1038 | menuId: string; | ||
| 1039 | }>; | ||
| 1040 | type BackButtonDisplayMode = 'minimal' | 'default' | 'generic'; | ||
| 1041 | +type NavigationItemStyle = 'navigator' | 'browser' | 'editor'; | ||
| 1042 | type BlurEffect = 'none' | 'extraLight' | 'light' | 'dark' | 'regular' | 'prominent' | 'systemUltraThinMaterial' | 'systemThinMaterial' | 'systemMaterial' | 'systemThickMaterial' | 'systemChromeMaterial' | 'systemUltraThinMaterialLight' | 'systemThinMaterialLight' | 'systemMaterialLight' | 'systemThickMaterialLight' | 'systemChromeMaterialLight' | 'systemUltraThinMaterialDark' | 'systemThinMaterialDark' | 'systemMaterialDark' | 'systemThickMaterialDark' | 'systemChromeMaterialDark'; | ||
| 1043 | type UserInterfaceStyle = 'unspecified' | 'light' | 'dark'; | ||
| 1044 | export interface NativeProps extends ViewProps { | ||
| 1045 | @@ -32,18 +33,23 @@ export interface NativeProps extends ViewProps { | ||
| 1046 | largeTitleColor?: ColorValue | undefined; | ||
| 1047 | translucent?: boolean | undefined; | ||
| 1048 | title?: string | undefined; | ||
| 1049 | + subtitle?: string | undefined; | ||
| 1050 | + largeSubtitle?: string | undefined; | ||
| 1051 | titleFontFamily?: string | undefined; | ||
| 1052 | titleFontSize?: CT.Int32 | undefined; | ||
| 1053 | titleFontWeight?: string | undefined; | ||
| 1054 | titleColor?: ColorValue | undefined; | ||
| 1055 | disableBackButtonMenu?: boolean | undefined; | ||
| 1056 | backButtonDisplayMode?: CT.WithDefault<BackButtonDisplayMode, 'default'>; | ||
| 1057 | + navigationItemStyle?: CT.WithDefault<NavigationItemStyle, 'navigator'>; | ||
| 1058 | hideBackButton?: boolean | undefined; | ||
| 1059 | backButtonInCustomView?: boolean | undefined; | ||
| 1060 | blurEffect?: CT.WithDefault<BlurEffect, 'none'>; | ||
| 1061 | topInsetEnabled?: boolean | undefined; | ||
| 1062 | headerLeftBarButtonItems?: CT.UnsafeMixed[] | undefined; | ||
| 1063 | headerRightBarButtonItems?: CT.UnsafeMixed[] | undefined; | ||
| 1064 | + headerCenterBarButtonItems?: CT.UnsafeMixed[] | undefined; | ||
| 1065 | + headerToolbarItems?: CT.UnsafeMixed[] | undefined; | ||
| 1066 | onPressHeaderBarButtonItem?: CT.DirectEventHandler<OnPressHeaderBarButtonItemEvent> | undefined; | ||
| 1067 | onPressHeaderBarButtonMenuItem?: CT.DirectEventHandler<OnPressHeaderBarButtonMenuItemEvent> | undefined; | ||
| 1068 | synchronousShadowStateUpdatesEnabled?: CT.WithDefault<boolean, false>; | ||
| 1069 | diff --git a/lib/typescript/types.d.ts b/lib/typescript/types.d.ts | ||
| 1070 | index 3b384e03891e38e936f370372a682d73440e7ec2..1e3a966806c8b8b312f75fb367f758efe49dde9d 100644 | ||
| 1071 | --- a/lib/typescript/types.d.ts | ||
| 1072 | +++ b/lib/typescript/types.d.ts | ||
| 1073 | @@ -10,6 +10,7 @@ export type SearchBarCommands = { | ||
| 1074 | cancelSearch: () => void; | ||
| 1075 | }; | ||
| 1076 | export type BackButtonDisplayMode = 'default' | 'generic' | 'minimal'; | ||
| 1077 | +export type NavigationItemStyle = 'navigator' | 'browser' | 'editor'; | ||
| 1078 | export type StackPresentationTypes = 'push' | 'modal' | 'transparentModal' | 'containedModal' | 'containedTransparentModal' | 'fullScreenModal' | 'formSheet' | 'pageSheet'; | ||
| 1079 | export type StackAnimationTypes = 'default' | 'fade' | 'fade_from_bottom' | 'flip' | 'none' | 'simple_push' | 'slide_from_bottom' | 'slide_from_right' | 'slide_from_left' | 'ios_from_right' | 'ios_from_left'; | ||
| 1080 | export type BlurEffectTypes = BlurEffect; | ||
| 1081 | @@ -631,6 +632,14 @@ export interface ScreenStackHeaderConfigProps extends ViewProps { | ||
| 1082 | * @platform ios | ||
| 1083 | */ | ||
| 1084 | backButtonDisplayMode?: BackButtonDisplayMode | undefined; | ||
| 1085 | + /** | ||
| 1086 | + * Controls the UIKit `UINavigationItem.style` used by native iOS headers. | ||
| 1087 | + * | ||
| 1088 | + * `editor` matches modern document/editor-style bars with leading-aligned compact titles and glass controls. | ||
| 1089 | + * | ||
| 1090 | + * @platform ios | ||
| 1091 | + */ | ||
| 1092 | + navigationItemStyle?: NavigationItemStyle | undefined; | ||
| 1093 | /** | ||
| 1094 | * Array of UIBarButtomItems to the left side of the header. | ||
| 1095 | * | ||
| 1096 | @@ -643,6 +652,26 @@ export interface ScreenStackHeaderConfigProps extends ViewProps { | ||
| 1097 | * @platform ios | ||
| 1098 | */ | ||
| 1099 | headerRightBarButtonItems?: HeaderBarButtonItem[] | undefined; | ||
| 1100 | + /** | ||
| 1101 | + * Array of UIBarButtonItems to the centered item group in modern iOS headers. | ||
| 1102 | + * | ||
| 1103 | + * This maps to `UINavigationItem.centerItemGroups` on iOS 26+ and lets | ||
| 1104 | + * document/editor-style chrome keep primary actions visually centered while | ||
| 1105 | + * leaving trailing search separate. | ||
| 1106 | + * | ||
| 1107 | + * @platform ios | ||
| 1108 | + */ | ||
| 1109 | + headerCenterBarButtonItems?: HeaderBarButtonItem[] | undefined; | ||
| 1110 | + /** | ||
| 1111 | + * Array of UIBarButtonItems to display in the native toolbar attached to | ||
| 1112 | + * the current screen's navigation controller. | ||
| 1113 | + * | ||
| 1114 | + * On iOS 26+, this can contain a `searchBarPlacement` item to let UIKit | ||
| 1115 | + * render integrated Mail-style search chrome using the attached SearchBar. | ||
| 1116 | + * | ||
| 1117 | + * @platform ios | ||
| 1118 | + */ | ||
| 1119 | + headerToolbarItems?: HeaderBarButtonItem[] | undefined; | ||
| 1120 | /** | ||
| 1121 | * When set to true the header will be hidden while the parent Screen is on the top of the stack. The default value is false. | ||
| 1122 | */ | ||
| 1123 | @@ -704,6 +733,20 @@ export interface ScreenStackHeaderConfigProps extends ViewProps { | ||
| 1124 | * String that can be displayed in the header as a fallback for `headerTitle`. | ||
| 1125 | */ | ||
| 1126 | title?: string | undefined; | ||
| 1127 | + /** | ||
| 1128 | + * String displayed below the compact navigation title on iOS 26+. | ||
| 1129 | + * | ||
| 1130 | + * @platform ios | ||
| 1131 | + */ | ||
| 1132 | + subtitle?: string | undefined; | ||
| 1133 | + /** | ||
| 1134 | + * String displayed below the large navigation title on iOS 26+. | ||
| 1135 | + * | ||
| 1136 | + * Falls back to `subtitle` when omitted. | ||
| 1137 | + * | ||
| 1138 | + * @platform ios | ||
| 1139 | + */ | ||
| 1140 | + largeSubtitle?: string | undefined; | ||
| 1141 | /** | ||
| 1142 | * Allows for setting text color of the title. | ||
| 1143 | */ | ||
| 1144 | @@ -1001,6 +1044,11 @@ interface SharedHeaderBarButtonItem { | ||
| 1145 | * Read more: https://developer.apple.com/documentation/uikit/uibarbuttonitem/style-swift.property | ||
| 1146 | */ | ||
| 1147 | variant?: 'plain' | 'done' | 'prominent' | undefined; | ||
| 1148 | + /** | ||
| 1149 | + * Render the item with UIButtonConfiguration.glassButtonConfiguration. | ||
| 1150 | + * Only available from iOS 26.0 and later. | ||
| 1151 | + */ | ||
| 1152 | + glassEffect?: boolean | undefined; | ||
| 1153 | /** | ||
| 1154 | * The tint color to apply to the item. | ||
| 1155 | * | ||
| 1156 | @@ -1145,8 +1193,37 @@ export interface HeaderBarButtonItemWithMenu extends SharedHeaderBarButtonItem { | ||
| 1157 | export interface HeaderBarButtonItemSpacing { | ||
| 1158 | type: 'spacing'; | ||
| 1159 | spacing: number; | ||
| 1160 | + flexible?: boolean | undefined; | ||
| 1161 | +} | ||
| 1162 | +export interface HeaderBarButtonSearchBarPlacementItem { | ||
| 1163 | + type: 'searchBarPlacement'; | ||
| 1164 | + activatesSearchController?: boolean | undefined; | ||
| 1165 | + customView?: boolean | undefined; | ||
| 1166 | + index?: number | undefined; | ||
| 1167 | + width?: number | undefined; | ||
| 1168 | +} | ||
| 1169 | +export interface HeaderBarButtonSearchFieldItem { | ||
| 1170 | + type: 'searchField'; | ||
| 1171 | + placeholder?: string | undefined; | ||
| 1172 | + width?: number | undefined; | ||
| 1173 | +} | ||
| 1174 | +export interface HeaderBarButtonMailSearchToolbarItem { | ||
| 1175 | + type: 'mailSearchToolbar'; | ||
| 1176 | + composeButtonId?: string | undefined; | ||
| 1177 | + composeMenu?: HeaderBarButtonItemWithMenu['menu'] | undefined; | ||
| 1178 | + composeSystemImageName?: string | undefined; | ||
| 1179 | + filterButtonId?: string | undefined; | ||
| 1180 | + filterMenu?: HeaderBarButtonItemWithMenu['menu'] | undefined; | ||
| 1181 | + filterSystemImageName?: string | undefined; | ||
| 1182 | + onComposePress?: (() => void) | undefined; | ||
| 1183 | + onFilterPress?: (() => void) | undefined; | ||
| 1184 | + onSearchTextChange?: ((text: string) => void) | undefined; | ||
| 1185 | + placeholder?: string | undefined; | ||
| 1186 | + searchTextChangeId?: string | undefined; | ||
| 1187 | + useFallbackSearchField?: boolean | undefined; | ||
| 1188 | + width?: number | undefined; | ||
| 1189 | } | ||
| 1190 | -export type HeaderBarButtonItem = HeaderBarButtonItemWithAction | HeaderBarButtonItemWithMenu | HeaderBarButtonItemSpacing; | ||
| 1191 | +export type HeaderBarButtonItem = HeaderBarButtonItemWithAction | HeaderBarButtonItemWithMenu | HeaderBarButtonMailSearchToolbarItem | HeaderBarButtonSearchFieldItem | HeaderBarButtonSearchBarPlacementItem | HeaderBarButtonItemSpacing; | ||
| 1192 | /** | ||
| 1193 | * Custom Screen Transition | ||
| 1194 | */ | ||
| 1195 | diff --git a/src/components/ScreenStackHeaderConfig.tsx b/src/components/ScreenStackHeaderConfig.tsx | ||
| 1196 | index 421b3c2545426ae957271bd6515bcf827437541c..0ca6f1d7f324eaf257891a3bbb0aab210aa30f6f 100644 | ||
| 1197 | --- a/src/components/ScreenStackHeaderConfig.tsx | ||
| 1198 | +++ b/src/components/ScreenStackHeaderConfig.tsx | ||
| 1199 | @@ -39,7 +39,12 @@ export const ScreenStackHeaderConfig = React.forwardRef< | ||
| 1200 | props.disableTopInsetApplication ?? false, | ||
| 1201 | ); | ||
| 1202 | |||
| 1203 | - const { headerLeftBarButtonItems, headerRightBarButtonItems } = props; | ||
| 1204 | + const { | ||
| 1205 | + headerLeftBarButtonItems, | ||
| 1206 | + headerRightBarButtonItems, | ||
| 1207 | + headerCenterBarButtonItems, | ||
| 1208 | + headerToolbarItems, | ||
| 1209 | + } = props; | ||
| 1210 | |||
| 1211 | const preparedHeaderLeftBarButtonItems = | ||
| 1212 | headerLeftBarButtonItems && isHeaderBarButtonsAvailableForCurrentPlatform | ||
| 1213 | @@ -49,22 +54,36 @@ export const ScreenStackHeaderConfig = React.forwardRef< | ||
| 1214 | headerRightBarButtonItems && isHeaderBarButtonsAvailableForCurrentPlatform | ||
| 1215 | ? prepareHeaderBarButtonItems(headerRightBarButtonItems, 'right') | ||
| 1216 | : undefined; | ||
| 1217 | + const preparedHeaderCenterBarButtonItems = | ||
| 1218 | + headerCenterBarButtonItems && isHeaderBarButtonsAvailableForCurrentPlatform | ||
| 1219 | + ? prepareHeaderBarButtonItems(headerCenterBarButtonItems, 'right') | ||
| 1220 | + : undefined; | ||
| 1221 | + const preparedHeaderToolbarItems = | ||
| 1222 | + headerToolbarItems && isHeaderBarButtonsAvailableForCurrentPlatform | ||
| 1223 | + ? prepareHeaderBarButtonItems(headerToolbarItems, 'right') | ||
| 1224 | + : undefined; | ||
| 1225 | const hasHeaderBarButtonItems = | ||
| 1226 | isHeaderBarButtonsAvailableForCurrentPlatform && | ||
| 1227 | (preparedHeaderLeftBarButtonItems?.length || | ||
| 1228 | - preparedHeaderRightBarButtonItems?.length); | ||
| 1229 | + preparedHeaderRightBarButtonItems?.length || | ||
| 1230 | + preparedHeaderCenterBarButtonItems?.length || | ||
| 1231 | + preparedHeaderToolbarItems?.length); | ||
| 1232 | |||
| 1233 | // Handle bar button item presses | ||
| 1234 | const onPressHeaderBarButtonItem = hasHeaderBarButtonItems | ||
| 1235 | ? (event: NativeSyntheticEvent<{ buttonId: string }>) => { | ||
| 1236 | - const pressedItem = [ | ||
| 1237 | + const buttonId = event.nativeEvent.buttonId; | ||
| 1238 | + const allItems = [ | ||
| 1239 | ...(preparedHeaderLeftBarButtonItems ?? []), | ||
| 1240 | ...(preparedHeaderRightBarButtonItems ?? []), | ||
| 1241 | - ].find( | ||
| 1242 | + ...(preparedHeaderCenterBarButtonItems ?? []), | ||
| 1243 | + ...(preparedHeaderToolbarItems ?? []), | ||
| 1244 | + ]; | ||
| 1245 | + const pressedItem = allItems.find( | ||
| 1246 | item => | ||
| 1247 | item && | ||
| 1248 | 'buttonId' in item && | ||
| 1249 | - item.buttonId === event.nativeEvent.buttonId, | ||
| 1250 | + item.buttonId === buttonId, | ||
| 1251 | ); | ||
| 1252 | if ( | ||
| 1253 | pressedItem && | ||
| 1254 | @@ -73,6 +92,31 @@ export const ScreenStackHeaderConfig = React.forwardRef< | ||
| 1255 | ) { | ||
| 1256 | pressedItem.onPress(); | ||
| 1257 | } | ||
| 1258 | + for (const item of allItems) { | ||
| 1259 | + if (!item || item.type !== 'mailSearchToolbar') { | ||
| 1260 | + continue; | ||
| 1261 | + } | ||
| 1262 | + if (item.filterButtonId === buttonId) { | ||
| 1263 | + item.onFilterPress?.(); | ||
| 1264 | + return; | ||
| 1265 | + } | ||
| 1266 | + if (item.composeButtonId === buttonId) { | ||
| 1267 | + item.onComposePress?.(); | ||
| 1268 | + return; | ||
| 1269 | + } | ||
| 1270 | + const searchTextChangePrefix = item.searchTextChangeId | ||
| 1271 | + ? `${item.searchTextChangeId}:` | ||
| 1272 | + : undefined; | ||
| 1273 | + if ( | ||
| 1274 | + searchTextChangePrefix && | ||
| 1275 | + buttonId.startsWith(searchTextChangePrefix) | ||
| 1276 | + ) { | ||
| 1277 | + item.onSearchTextChange?.( | ||
| 1278 | + buttonId.slice(searchTextChangePrefix.length), | ||
| 1279 | + ); | ||
| 1280 | + return; | ||
| 1281 | + } | ||
| 1282 | + } | ||
| 1283 | } | ||
| 1284 | : undefined; | ||
| 1285 | |||
| 1286 | @@ -102,6 +146,8 @@ export const ScreenStackHeaderConfig = React.forwardRef< | ||
| 1287 | const allItems = [ | ||
| 1288 | ...(preparedHeaderLeftBarButtonItems ?? []), | ||
| 1289 | ...(preparedHeaderRightBarButtonItems ?? []), | ||
| 1290 | + ...(preparedHeaderCenterBarButtonItems ?? []), | ||
| 1291 | + ...(preparedHeaderToolbarItems ?? []), | ||
| 1292 | ]; | ||
| 1293 | for (const item of allItems) { | ||
| 1294 | if (item && item.type === 'menu' && item.menu) { | ||
| 1295 | @@ -110,6 +156,17 @@ export const ScreenStackHeaderConfig = React.forwardRef< | ||
| 1296 | action.onPress(); | ||
| 1297 | return; | ||
| 1298 | } | ||
| 1299 | + } else if (item && item.type === 'mailSearchToolbar') { | ||
| 1300 | + const toolbarMenus = [item.filterMenu, item.composeMenu].filter( | ||
| 1301 | + Boolean, | ||
| 1302 | + ); | ||
| 1303 | + for (const toolbarMenu of toolbarMenus) { | ||
| 1304 | + const action = findInMenu(toolbarMenu, event.nativeEvent.menuId); | ||
| 1305 | + if (action) { | ||
| 1306 | + action.onPress(); | ||
| 1307 | + return; | ||
| 1308 | + } | ||
| 1309 | + } | ||
| 1310 | } | ||
| 1311 | } | ||
| 1312 | } | ||
| 1313 | @@ -121,6 +178,8 @@ export const ScreenStackHeaderConfig = React.forwardRef< | ||
| 1314 | userInterfaceStyle={props.experimental_userInterfaceStyle} | ||
| 1315 | headerLeftBarButtonItems={preparedHeaderLeftBarButtonItems} | ||
| 1316 | headerRightBarButtonItems={preparedHeaderRightBarButtonItems} | ||
| 1317 | + headerCenterBarButtonItems={preparedHeaderCenterBarButtonItems} | ||
| 1318 | + headerToolbarItems={preparedHeaderToolbarItems} | ||
| 1319 | onPressHeaderBarButtonItem={onPressHeaderBarButtonItem} | ||
| 1320 | onPressHeaderBarButtonMenuItem={onPressHeaderBarButtonMenuItem} | ||
| 1321 | ref={ref} | ||
| 1322 | diff --git a/src/components/helpers/prepareHeaderBarButtonItems.ts b/src/components/helpers/prepareHeaderBarButtonItems.ts | ||
| 1323 | index be2c24dfee77f5883b5ab1d7473be80933b5dc6f..0b038d2005b2d51bbb2ab1d7dba7eaccda83d42f 100644 | ||
| 1324 | --- a/src/components/helpers/prepareHeaderBarButtonItems.ts | ||
| 1325 | +++ b/src/components/helpers/prepareHeaderBarButtonItems.ts | ||
| 1326 | @@ -50,13 +50,43 @@ const prepareMenu = ( | ||
| 1327 | }; | ||
| 1328 | |||
| 1329 | export const prepareHeaderBarButtonItems = ( | ||
| 1330 | - barButtonItems: HeaderBarButtonItem[], | ||
| 1331 | + barButtonItems: | ||
| 1332 | + | HeaderBarButtonItem[] | ||
| 1333 | + | HeaderBarButtonItem | ||
| 1334 | + | null | ||
| 1335 | + | undefined, | ||
| 1336 | side: 'left' | 'right', | ||
| 1337 | ) => { | ||
| 1338 | - return barButtonItems?.map((item, index) => { | ||
| 1339 | + const items = Array.isArray(barButtonItems) | ||
| 1340 | + ? barButtonItems | ||
| 1341 | + : barButtonItems && typeof barButtonItems === 'object' && 'type' in barButtonItems | ||
| 1342 | + ? [barButtonItems] | ||
| 1343 | + : undefined; | ||
| 1344 | + | ||
| 1345 | + return items?.map((item, index) => { | ||
| 1346 | if (item.type === 'spacing') { | ||
| 1347 | return item; | ||
| 1348 | } | ||
| 1349 | + if (item.type === 'searchBarPlacement') { | ||
| 1350 | + return { | ||
| 1351 | + ...item, | ||
| 1352 | + searchBarPlacement: true, | ||
| 1353 | + }; | ||
| 1354 | + } | ||
| 1355 | + if (item.type === 'searchField') { | ||
| 1356 | + return { | ||
| 1357 | + ...item, | ||
| 1358 | + searchField: true, | ||
| 1359 | + }; | ||
| 1360 | + } | ||
| 1361 | + if (item.type === 'mailSearchToolbar') { | ||
| 1362 | + return { | ||
| 1363 | + ...item, | ||
| 1364 | + mailSearchToolbar: true, | ||
| 1365 | + filterMenu: item.filterMenu ? prepareMenu(item.filterMenu, index, side, 'filter') : undefined, | ||
| 1366 | + composeMenu: item.composeMenu ? prepareMenu(item.composeMenu, index, side, 'compose') : undefined, | ||
| 1367 | + }; | ||
| 1368 | + } | ||
| 1369 | let imageSource, templateSource; | ||
| 1370 | if (item.icon?.type === 'imageSource') { | ||
| 1371 | imageSource = Image.resolveAssetSource(item.icon.imageSource); | ||
| 1372 | diff --git a/src/fabric/ScreenStackHeaderConfigNativeComponent.ts b/src/fabric/ScreenStackHeaderConfigNativeComponent.ts | ||
| 1373 | index ba2479de0f49c112470f78c5084059ddd9e2f3e8..8677583a8f8daa4839340467466bfab8d73111ab 100644 | ||
| 1374 | --- a/src/fabric/ScreenStackHeaderConfigNativeComponent.ts | ||
| 1375 | +++ b/src/fabric/ScreenStackHeaderConfigNativeComponent.ts | ||
| 1376 | @@ -14,6 +14,7 @@ type OnPressHeaderBarButtonItemEvent = Readonly<{ buttonId: string }>; | ||
| 1377 | type OnPressHeaderBarButtonMenuItemEvent = Readonly<{ menuId: string }>; | ||
| 1378 | |||
| 1379 | type BackButtonDisplayMode = 'minimal' | 'default' | 'generic'; | ||
| 1380 | +type NavigationItemStyle = 'navigator' | 'browser' | 'editor'; | ||
| 1381 | |||
| 1382 | type BlurEffect = | ||
| 1383 | | 'none' | ||
| 1384 | @@ -61,12 +62,15 @@ export interface NativeProps extends ViewProps { | ||
| 1385 | largeTitleColor?: ColorValue | undefined; | ||
| 1386 | translucent?: boolean | undefined; | ||
| 1387 | title?: string | undefined; | ||
| 1388 | + subtitle?: string | undefined; | ||
| 1389 | + largeSubtitle?: string | undefined; | ||
| 1390 | titleFontFamily?: string | undefined; | ||
| 1391 | titleFontSize?: CT.Int32 | undefined; | ||
| 1392 | titleFontWeight?: string | undefined; | ||
| 1393 | titleColor?: ColorValue | undefined; | ||
| 1394 | disableBackButtonMenu?: boolean | undefined; | ||
| 1395 | backButtonDisplayMode?: CT.WithDefault<BackButtonDisplayMode, 'default'>; | ||
| 1396 | + navigationItemStyle?: CT.WithDefault<NavigationItemStyle, 'navigator'>; | ||
| 1397 | hideBackButton?: boolean | undefined; | ||
| 1398 | backButtonInCustomView?: boolean | undefined; | ||
| 1399 | blurEffect?: CT.WithDefault<BlurEffect, 'none'>; | ||
| 1400 | @@ -74,6 +78,8 @@ export interface NativeProps extends ViewProps { | ||
| 1401 | topInsetEnabled?: boolean | undefined; | ||
| 1402 | headerLeftBarButtonItems?: CT.UnsafeMixed[] | undefined; | ||
| 1403 | headerRightBarButtonItems?: CT.UnsafeMixed[] | undefined; | ||
| 1404 | + headerCenterBarButtonItems?: CT.UnsafeMixed[] | undefined; | ||
| 1405 | + headerToolbarItems?: CT.UnsafeMixed[] | undefined; | ||
| 1406 | onPressHeaderBarButtonItem?: | ||
| 1407 | | CT.DirectEventHandler<OnPressHeaderBarButtonItemEvent> | ||
| 1408 | | undefined; | ||
| 1409 | diff --git a/src/types.tsx b/src/types.tsx | ||
| 1410 | index 76a83f3acb6fd3f0af7f027798848b7124100286..9e4499f076f9988e3266df4be7201e131d21242b 100644 | ||
| 1411 | --- a/src/types.tsx | ||
| 1412 | +++ b/src/types.tsx | ||
| 1413 | @@ -26,6 +26,7 @@ export type SearchBarCommands = { | ||
| 1414 | }; | ||
| 1415 | |||
| 1416 | export type BackButtonDisplayMode = 'default' | 'generic' | 'minimal'; | ||
| 1417 | +export type NavigationItemStyle = 'navigator' | 'browser' | 'editor'; | ||
| 1418 | export type StackPresentationTypes = | ||
| 1419 | | 'push' | ||
| 1420 | | 'modal' | ||
| 1421 | @@ -735,6 +736,14 @@ export interface ScreenStackHeaderConfigProps extends ViewProps { | ||
| 1422 | * @platform ios | ||
| 1423 | */ | ||
| 1424 | backButtonDisplayMode?: BackButtonDisplayMode | undefined; | ||
| 1425 | + /** | ||
| 1426 | + * Controls the UIKit `UINavigationItem.style` used by native iOS headers. | ||
| 1427 | + * | ||
| 1428 | + * `editor` matches modern document/editor-style bars with leading-aligned compact titles and glass controls. | ||
| 1429 | + * | ||
| 1430 | + * @platform ios | ||
| 1431 | + */ | ||
| 1432 | + navigationItemStyle?: NavigationItemStyle | undefined; | ||
| 1433 | /** | ||
| 1434 | * Array of UIBarButtomItems to the left side of the header. | ||
| 1435 | * | ||
| 1436 | @@ -747,6 +756,26 @@ export interface ScreenStackHeaderConfigProps extends ViewProps { | ||
| 1437 | * @platform ios | ||
| 1438 | */ | ||
| 1439 | headerRightBarButtonItems?: HeaderBarButtonItem[] | undefined; | ||
| 1440 | + /** | ||
| 1441 | + * Array of UIBarButtonItems to the centered item group in modern iOS headers. | ||
| 1442 | + * | ||
| 1443 | + * This maps to `UINavigationItem.centerItemGroups` on iOS 26+ and lets | ||
| 1444 | + * document/editor-style chrome keep primary actions visually centered while | ||
| 1445 | + * leaving trailing search separate. | ||
| 1446 | + * | ||
| 1447 | + * @platform ios | ||
| 1448 | + */ | ||
| 1449 | + headerCenterBarButtonItems?: HeaderBarButtonItem[] | undefined; | ||
| 1450 | + /** | ||
| 1451 | + * Array of UIBarButtonItems to display in the native toolbar attached to | ||
| 1452 | + * the current screen's navigation controller. | ||
| 1453 | + * | ||
| 1454 | + * On iOS 26+, this can contain a `searchBarPlacement` item to let UIKit | ||
| 1455 | + * render integrated Mail-style search chrome using the attached SearchBar. | ||
| 1456 | + * | ||
| 1457 | + * @platform ios | ||
| 1458 | + */ | ||
| 1459 | + headerToolbarItems?: HeaderBarButtonItem[] | undefined; | ||
| 1460 | /** | ||
| 1461 | * When set to true the header will be hidden while the parent Screen is on the top of the stack. The default value is false. | ||
| 1462 | */ | ||
| 1463 | @@ -808,6 +837,20 @@ export interface ScreenStackHeaderConfigProps extends ViewProps { | ||
| 1464 | * String that can be displayed in the header as a fallback for `headerTitle`. | ||
| 1465 | */ | ||
| 1466 | title?: string | undefined; | ||
| 1467 | + /** | ||
| 1468 | + * String displayed below the compact navigation title on iOS 26+. | ||
| 1469 | + * | ||
| 1470 | + * @platform ios | ||
| 1471 | + */ | ||
| 1472 | + subtitle?: string | undefined; | ||
| 1473 | + /** | ||
| 1474 | + * String displayed below the large navigation title on iOS 26+. | ||
| 1475 | + * | ||
| 1476 | + * Falls back to `subtitle` when omitted. | ||
| 1477 | + * | ||
| 1478 | + * @platform ios | ||
| 1479 | + */ | ||
| 1480 | + largeSubtitle?: string | undefined; | ||
| 1481 | /** | ||
| 1482 | * Allows for setting text color of the title. | ||
| 1483 | */ | ||
| 1484 | @@ -1125,6 +1168,11 @@ interface SharedHeaderBarButtonItem { | ||
| 1485 | * Read more: https://developer.apple.com/documentation/uikit/uibarbuttonitem/style-swift.property | ||
| 1486 | */ | ||
| 1487 | variant?: 'plain' | 'done' | 'prominent' | undefined; | ||
| 1488 | + /** | ||
| 1489 | + * Render the item with UIButtonConfiguration.glassButtonConfiguration. | ||
| 1490 | + * Only available from iOS 26.0 and later. | ||
| 1491 | + */ | ||
| 1492 | + glassEffect?: boolean | undefined; | ||
| 1493 | /** | ||
| 1494 | * The tint color to apply to the item. | ||
| 1495 | * | ||
| 1496 | @@ -1279,11 +1327,46 @@ export interface HeaderBarButtonItemWithMenu extends SharedHeaderBarButtonItem { | ||
| 1497 | export interface HeaderBarButtonItemSpacing { | ||
| 1498 | type: 'spacing'; | ||
| 1499 | spacing: number; | ||
| 1500 | + flexible?: boolean | undefined; | ||
| 1501 | +} | ||
| 1502 | + | ||
| 1503 | +export interface HeaderBarButtonSearchBarPlacementItem { | ||
| 1504 | + type: 'searchBarPlacement'; | ||
| 1505 | + activatesSearchController?: boolean | undefined; | ||
| 1506 | + customView?: boolean | undefined; | ||
| 1507 | + index?: number | undefined; | ||
| 1508 | + width?: number | undefined; | ||
| 1509 | +} | ||
| 1510 | + | ||
| 1511 | +export interface HeaderBarButtonSearchFieldItem { | ||
| 1512 | + type: 'searchField'; | ||
| 1513 | + placeholder?: string | undefined; | ||
| 1514 | + width?: number | undefined; | ||
| 1515 | +} | ||
| 1516 | + | ||
| 1517 | +export interface HeaderBarButtonMailSearchToolbarItem { | ||
| 1518 | + type: 'mailSearchToolbar'; | ||
| 1519 | + composeButtonId?: string | undefined; | ||
| 1520 | + composeMenu?: HeaderBarButtonItemWithMenu['menu'] | undefined; | ||
| 1521 | + composeSystemImageName?: string | undefined; | ||
| 1522 | + filterButtonId?: string | undefined; | ||
| 1523 | + filterMenu?: HeaderBarButtonItemWithMenu['menu'] | undefined; | ||
| 1524 | + filterSystemImageName?: string | undefined; | ||
| 1525 | + onComposePress?: (() => void) | undefined; | ||
| 1526 | + onFilterPress?: (() => void) | undefined; | ||
| 1527 | + onSearchTextChange?: ((text: string) => void) | undefined; | ||
| 1528 | + placeholder?: string | undefined; | ||
| 1529 | + searchTextChangeId?: string | undefined; | ||
| 1530 | + useFallbackSearchField?: boolean | undefined; | ||
| 1531 | + width?: number | undefined; | ||
| 1532 | } | ||
| 1533 | |||
| 1534 | export type HeaderBarButtonItem = | ||
| 1535 | | HeaderBarButtonItemWithAction | ||
| 1536 | | HeaderBarButtonItemWithMenu | ||
| 1537 | + | HeaderBarButtonMailSearchToolbarItem | ||
| 1538 | + | HeaderBarButtonSearchFieldItem | ||
| 1539 | + | HeaderBarButtonSearchBarPlacementItem | ||
| 1540 | | HeaderBarButtonItemSpacing; | ||
| 1541 | |||
| 1542 | /** | ||
pnpm-lock.yaml+742-502| ... | @@ -35,19 +35,19 @@ catalogs: | ... | @@ -35,19 +35,19 @@ catalogs: |
| 35 | version: 0.2.1 | 35 | version: 0.2.1 |
| 36 | 36 | ||
| 37 | overrides: | 37 | overrides: |
| 38 | '@clerk/backend': 3.8.3-snapshot.v20260622234151 | 38 | '@clerk/backend': 3.8.4 |
| 39 | '@clerk/clerk-js': 6.21.0-snapshot.v20260622234151 | 39 | '@clerk/clerk-js': 6.22.0 |
| 40 | '@clerk/electron': 0.0.2-snapshot.v20260622234151 | ||
| 41 | '@clerk/electron-passkeys': 0.0.2-snapshot.v20260622234151 | ||
| 42 | '@clerk/expo': 3.5.3-snapshot.v20260622234151 | ||
| 43 | '@clerk/react': 6.11.0-snapshot.v20260622234151 | ||
| 44 | '@clerk/shared': 4.21.0-snapshot.v20260622234151 | ||
| 45 | '@clerk/clerk-js>@base-org/account': '-' | 40 | '@clerk/clerk-js>@base-org/account': '-' |
| 46 | '@clerk/clerk-js>@coinbase/wallet-sdk': '-' | 41 | '@clerk/clerk-js>@coinbase/wallet-sdk': '-' |
| 47 | '@clerk/clerk-js>@solana/wallet-adapter-base': '-' | 42 | '@clerk/clerk-js>@solana/wallet-adapter-base': '-' |
| 48 | '@clerk/clerk-js>@solana/wallet-adapter-react': '-' | 43 | '@clerk/clerk-js>@solana/wallet-adapter-react': '-' |
| 49 | '@clerk/clerk-js>@solana/wallet-standard': '-' | 44 | '@clerk/clerk-js>@solana/wallet-standard': '-' |
| 50 | '@clerk/clerk-js>@wallet-standard/core': '-' | 45 | '@clerk/clerk-js>@wallet-standard/core': '-' |
| 46 | '@clerk/electron': 0.0.5 | ||
| 47 | '@clerk/electron-passkeys': 0.0.3 | ||
| 48 | '@clerk/expo': 3.6.2 | ||
| 49 | '@clerk/react': 6.11.1 | ||
| 50 | '@clerk/shared': 4.22.0 | ||
| 51 | '@effect/atom-react': 4.0.0-beta.78 | 51 | '@effect/atom-react': 4.0.0-beta.78 |
| 52 | '@effect/platform-bun': 4.0.0-beta.78 | 52 | '@effect/platform-bun': 4.0.0-beta.78 |
| 53 | '@effect/platform-node': 4.0.0-beta.78 | 53 | '@effect/platform-node': 4.0.0-beta.78 |
| ... | @@ -56,7 +56,7 @@ overrides: | ... | @@ -56,7 +56,7 @@ overrides: |
| 56 | '@effect/sql-sqlite-bun': 4.0.0-beta.78 | 56 | '@effect/sql-sqlite-bun': 4.0.0-beta.78 |
| 57 | '@effect/vitest': 4.0.0-beta.78 | 57 | '@effect/vitest': 4.0.0-beta.78 |
| 58 | '@effect/vitest>vitest': '-' | 58 | '@effect/vitest>vitest': '-' |
| 59 | '@expo/metro-config': 56.0.13 | 59 | '@expo/metro-config': 56.0.14 |
| 60 | '@pierre/diffs>@shikijs/transformers': ^4.2.0 | 60 | '@pierre/diffs>@shikijs/transformers': ^4.2.0 |
| 61 | '@types/node': 24.12.4 | 61 | '@types/node': 24.12.4 |
| 62 | effect: 4.0.0-beta.78 | 62 | effect: 4.0.0-beta.78 |
| ... | @@ -69,21 +69,36 @@ patchedDependencies: | ... | @@ -69,21 +69,36 @@ patchedDependencies: |
| 69 | '@effect/vitest@4.0.0-beta.78': | 69 | '@effect/vitest@4.0.0-beta.78': |
| 70 | hash: 42b87cc47e70d74e62496e7a8261b3fd298ecad4464d209348ab04b96f853a5f | 70 | hash: 42b87cc47e70d74e62496e7a8261b3fd298ecad4464d209348ab04b96f853a5f |
| 71 | path: patches/@effect__vitest@4.0.0-beta.78.patch | 71 | path: patches/@effect__vitest@4.0.0-beta.78.patch |
| 72 | '@expo/metro-config@56.0.13': | 72 | '@expo/metro-config@56.0.14': |
| 73 | hash: 8cb08b5bb7051ed9d2dbe46a2c293c5a1e17f1bd6ddf30de27909e18c921ff46 | 73 | hash: 8cb08b5bb7051ed9d2dbe46a2c293c5a1e17f1bd6ddf30de27909e18c921ff46 |
| 74 | path: patches/@expo%2Fmetro-config@56.0.13.patch | 74 | path: patches/@expo%2Fmetro-config@56.0.14.patch |
| 75 | '@ff-labs/fff-node@0.9.4': | 75 | '@ff-labs/fff-node@0.9.4': |
| 76 | hash: 2b16019ce7ab61aec6478dd02f79ef468cc1d5c51e9d00764f7d2ab8167210c8 | 76 | hash: 2b16019ce7ab61aec6478dd02f79ef468cc1d5c51e9d00764f7d2ab8167210c8 |
| 77 | path: patches/@ff-labs__fff-node@0.9.4.patch | 77 | path: patches/@ff-labs__fff-node@0.9.4.patch |
| 78 | '@pierre/diffs@1.3.0-beta.5': | 78 | '@pierre/diffs@1.3.0-beta.5': |
| 79 | hash: 7cb6da88544119adda056b2f46f43956f99326227732da0b345081e285a6c53a | 79 | hash: 7cb6da88544119adda056b2f46f43956f99326227732da0b345081e285a6c53a |
| 80 | path: patches/@pierre%2Fdiffs@1.3.0-beta.5.patch | 80 | path: patches/@pierre%2Fdiffs@1.3.0-beta.5.patch |
| 81 | '@react-native-menu/menu@2.0.0': | ||
| 82 | hash: 5ea3ae4bf1d9baf5443b65c269bb09621c27a68d556f713778f37b1e8d46aaae | ||
| 83 | path: patches/@react-native-menu__menu@2.0.0.patch | ||
| 84 | '@react-navigation/native-stack@7.17.6': | ||
| 85 | hash: 2d7fec7933e324ccf082b1ae0df3907c35d16d48c117d628e27d9c3921c26bca | ||
| 86 | path: patches/@react-navigation%2Fnative-stack@7.17.6.patch | ||
| 81 | effect@4.0.0-beta.78: | 87 | effect@4.0.0-beta.78: |
| 82 | hash: c502bc684210b707dfceb87d8fe6ad6843395af6e19cfc02cd65854898bde2c5 | 88 | hash: c502bc684210b707dfceb87d8fe6ad6843395af6e19cfc02cd65854898bde2c5 |
| 83 | path: patches/effect@4.0.0-beta.78.patch | 89 | path: patches/effect@4.0.0-beta.78.patch |
| 90 | expo-modules-jsi@56.0.10: | ||
| 91 | hash: 9170f8074ae4e35a0a086e756c8f815794fd3abe51eac67ca3ba02804225ec1f | ||
| 92 | path: patches/expo-modules-jsi@56.0.10.patch | ||
| 93 | react-native-gesture-handler@2.31.2: | ||
| 94 | hash: 808eb26f9e57cf4945efd3985af4d9c764da6f91f4c9764433cc868602bbf4d3 | ||
| 95 | path: patches/react-native-gesture-handler@2.31.2.patch | ||
| 84 | react-native-nitro-modules@0.35.9: | 96 | react-native-nitro-modules@0.35.9: |
| 85 | hash: 825622aae63a8fb5b904f3c77908a0e216261d727ea171709f2c0b6088422675 | 97 | hash: 825622aae63a8fb5b904f3c77908a0e216261d727ea171709f2c0b6088422675 |
| 86 | path: patches/react-native-nitro-modules@0.35.9.patch | 98 | path: patches/react-native-nitro-modules@0.35.9.patch |
| 99 | react-native-screens@4.25.2: | ||
| 100 | hash: e1b49230abc20a663115cc3f34ca2f63764a873485ccb67f2bd43b85ce2a94dc | ||
| 101 | path: patches/react-native-screens@4.25.2.patch | ||
| 87 | 102 | ||
| 88 | importers: | 103 | importers: |
| 89 | 104 | ||
| ... | @@ -111,11 +126,11 @@ importers: | ... | @@ -111,11 +126,11 @@ importers: |
| 111 | apps/desktop: | 126 | apps/desktop: |
| 112 | dependencies: | 127 | dependencies: |
| 113 | '@clerk/electron': | 128 | '@clerk/electron': |
| 114 | specifier: 0.0.2-snapshot.v20260622234151 | 129 | specifier: 0.0.5 |
| 115 | version: 0.0.2-snapshot.v20260622234151(@clerk/electron-passkeys@0.0.2-snapshot.v20260622234151)(electron-store@8.2.0)(electron@41.5.0)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) | 130 | version: 0.0.5(@clerk/electron-passkeys@0.0.3)(electron-store@8.2.0)(electron@41.5.0)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) |
| 116 | '@clerk/electron-passkeys': | 131 | '@clerk/electron-passkeys': |
| 117 | specifier: 0.0.2-snapshot.v20260622234151 | 132 | specifier: 0.0.3 |
| 118 | version: 0.0.2-snapshot.v20260622234151 | 133 | version: 0.0.3 |
| 119 | '@effect/platform-node': | 134 | '@effect/platform-node': |
| 120 | specifier: 4.0.0-beta.78 | 135 | specifier: 4.0.0-beta.78 |
| 121 | version: 4.0.0-beta.78(bufferutil@4.1.0)(effect@4.0.0-beta.78(patch_hash=c502bc684210b707dfceb87d8fe6ad6843395af6e19cfc02cd65854898bde2c5))(ioredis@5.11.0)(utf-8-validate@6.0.6) | 136 | version: 4.0.0-beta.78(bufferutil@4.1.0)(effect@4.0.0-beta.78(patch_hash=c502bc684210b707dfceb87d8fe6ad6843395af6e19cfc02cd65854898bde2c5))(ioredis@5.11.0)(utf-8-validate@6.0.6) |
| ... | @@ -192,22 +207,25 @@ importers: | ... | @@ -192,22 +207,25 @@ importers: |
| 192 | dependencies: | 207 | dependencies: |
| 193 | '@callstack/liquid-glass': | 208 | '@callstack/liquid-glass': |
| 194 | specifier: ^0.7.1 | 209 | specifier: ^0.7.1 |
| 195 | version: 0.7.1(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) | 210 | version: 0.7.1(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) |
| 196 | '@clerk/expo': | 211 | '@clerk/expo': |
| 197 | specifier: 3.5.3-snapshot.v20260622234151 | 212 | specifier: 3.6.2 |
| 198 | version: 3.5.3-snapshot.v20260622234151(expo-auth-session@56.0.13(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(expo-constants@56.0.16)(expo-crypto@56.0.4(expo@56.0.8))(expo-secure-store@56.0.4(expo@56.0.8))(expo-web-browser@56.0.5(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6)))(expo@56.0.8)(react-dom@19.2.3(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) | 213 | version: 3.6.2(expo-auth-session@56.0.14(expo@56.0.12)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(expo-constants@56.0.18)(expo-crypto@56.0.4(expo@56.0.12))(expo-secure-store@56.0.4(expo@56.0.12))(expo-web-browser@56.0.5(expo@56.0.12)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6)))(expo@56.0.12)(react-dom@19.2.3(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) |
| 199 | '@effect/atom-react': | 214 | '@effect/atom-react': |
| 200 | specifier: 4.0.0-beta.78 | 215 | specifier: 4.0.0-beta.78 |
| 201 | version: 4.0.0-beta.78(effect@4.0.0-beta.78(patch_hash=c502bc684210b707dfceb87d8fe6ad6843395af6e19cfc02cd65854898bde2c5))(react@19.2.3)(scheduler@0.27.0) | 216 | version: 4.0.0-beta.78(effect@4.0.0-beta.78(patch_hash=c502bc684210b707dfceb87d8fe6ad6843395af6e19cfc02cd65854898bde2c5))(react@19.2.3)(scheduler@0.27.0) |
| 202 | '@expo-google-fonts/dm-sans': | 217 | '@expo-google-fonts/dm-sans': |
| 203 | specifier: ^0.4.2 | 218 | specifier: ^0.4.2 |
| 204 | version: 0.4.2 | 219 | version: 0.4.2 |
| 220 | '@expo/metro-runtime': | ||
| 221 | specifier: ~56.0.15 | ||
| 222 | version: 56.0.15(@expo/log-box@56.0.13)(expo@56.0.12)(react-dom@19.2.3(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) | ||
| 205 | '@expo/ui': | 223 | '@expo/ui': |
| 206 | specifier: ~56.0.8 | 224 | specifier: ~56.0.18 |
| 207 | version: 56.0.15(961c4aa6f32829b318e3c87ef20ad401) | 225 | version: 56.0.18(19413efe5eaad64848598eedfe3a0fd3) |
| 208 | '@legendapp/list': | 226 | '@legendapp/list': |
| 209 | specifier: 3.2.0 | 227 | specifier: 3.2.0 |
| 210 | version: 3.2.0(react-dom@19.2.3(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) | 228 | version: 3.2.0(react-dom@19.2.3(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) |
| 211 | '@noble/curves': | 229 | '@noble/curves': |
| 212 | specifier: 'catalog:' | 230 | specifier: 'catalog:' |
| 213 | version: 1.9.1 | 231 | version: 1.9.1 |
| ... | @@ -219,7 +237,16 @@ importers: | ... | @@ -219,7 +237,16 @@ importers: |
| 219 | version: 1.3.0-beta.5(patch_hash=7cb6da88544119adda056b2f46f43956f99326227732da0b345081e285a6c53a)(@shikijs/themes@4.2.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) | 237 | version: 1.3.0-beta.5(patch_hash=7cb6da88544119adda056b2f46f43956f99326227732da0b345081e285a6c53a)(@shikijs/themes@4.2.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) |
| 220 | '@react-native-menu/menu': | 238 | '@react-native-menu/menu': |
| 221 | specifier: ^2.0.0 | 239 | specifier: ^2.0.0 |
| 222 | version: 2.0.0(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) | 240 | version: 2.0.0(patch_hash=5ea3ae4bf1d9baf5443b65c269bb09621c27a68d556f713778f37b1e8d46aaae)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) |
| 241 | '@react-navigation/elements': | ||
| 242 | specifier: 2.9.26 | ||
| 243 | version: 2.9.26(@react-native-masked-view/masked-view@0.3.2(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(@react-navigation/native@7.3.4(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native-safe-area-context@5.7.0(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) | ||
| 244 | '@react-navigation/native': | ||
| 245 | specifier: 7.3.4 | ||
| 246 | version: 7.3.4(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) | ||
| 247 | '@react-navigation/native-stack': | ||
| 248 | specifier: 7.17.6 | ||
| 249 | version: 7.17.6(patch_hash=2d7fec7933e324ccf082b1ae0df3907c35d16d48c117d628e27d9c3921c26bca)(889fbbb381dfeb79ac64b7284ebf7fa4) | ||
| 223 | '@shikijs/core': | 250 | '@shikijs/core': |
| 224 | specifier: 4.2.0 | 251 | specifier: 4.2.0 |
| 225 | version: 4.2.0 | 252 | version: 4.2.0 |
| ... | @@ -240,7 +267,7 @@ importers: | ... | @@ -240,7 +267,7 @@ importers: |
| 240 | version: link:../../packages/contracts | 267 | version: link:../../packages/contracts |
| 241 | '@t3tools/mobile-markdown-text': | 268 | '@t3tools/mobile-markdown-text': |
| 242 | specifier: file:./modules/t3-markdown-text | 269 | specifier: file:./modules/t3-markdown-text |
| 243 | version: file:apps/mobile/modules/t3-markdown-text(f0ff241d48c23db461fff5d47e450578) | 270 | version: file:apps/mobile/modules/t3-markdown-text(a6e4bd1e9376530ea93dbb7d8a53d32d) |
| 244 | '@t3tools/mobile-review-diff-native': | 271 | '@t3tools/mobile-review-diff-native': |
| 245 | specifier: file:./modules/t3-review-diff | 272 | specifier: file:./modules/t3-review-diff |
| 246 | version: file:apps/mobile/modules/t3-review-diff | 273 | version: file:apps/mobile/modules/t3-review-diff |
| ... | @@ -260,80 +287,77 @@ importers: | ... | @@ -260,80 +287,77 @@ importers: |
| 260 | specifier: 4.0.0-beta.78 | 287 | specifier: 4.0.0-beta.78 |
| 261 | version: 4.0.0-beta.78(patch_hash=c502bc684210b707dfceb87d8fe6ad6843395af6e19cfc02cd65854898bde2c5) | 288 | version: 4.0.0-beta.78(patch_hash=c502bc684210b707dfceb87d8fe6ad6843395af6e19cfc02cd65854898bde2c5) |
| 262 | expo: | 289 | expo: |
| 263 | specifier: ^56.0.0 | 290 | specifier: ~56.0.12 |
| 264 | version: 56.0.8(63f7aade424ad9e7b1154b679fa2a14d) | 291 | version: 56.0.12(@babel/core@7.29.7)(@expo/dom-webview@56.0.5)(@expo/metro-runtime@56.0.15)(bufferutil@4.1.0)(expo-router@56.2.11)(expo-widgets@56.0.19)(react-dom@19.2.3(react@19.2.3))(react-native-webview@13.16.1(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(typescript@6.0.3)(utf-8-validate@6.0.6) |
| 265 | expo-asset: | 292 | expo-asset: |
| 266 | specifier: ~56.0.15 | 293 | specifier: ~56.0.17 |
| 267 | version: 56.0.15(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(typescript@6.0.3) | 294 | version: 56.0.17(expo@56.0.12)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(typescript@6.0.3) |
| 268 | expo-auth-session: | 295 | expo-auth-session: |
| 269 | specifier: ~56.0.12 | 296 | specifier: ~56.0.14 |
| 270 | version: 56.0.13(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) | 297 | version: 56.0.14(expo@56.0.12)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) |
| 271 | expo-build-properties: | 298 | expo-build-properties: |
| 272 | specifier: ~56.0.15 | 299 | specifier: ~56.0.19 |
| 273 | version: 56.0.16(expo@56.0.8) | 300 | version: 56.0.19(expo@56.0.12) |
| 274 | expo-camera: | 301 | expo-camera: |
| 275 | specifier: ~56.0.7 | 302 | specifier: ~56.0.8 |
| 276 | version: 56.0.7(@types/emscripten@1.41.5)(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) | 303 | version: 56.0.8(@types/emscripten@1.41.5)(expo@56.0.12)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) |
| 277 | expo-clipboard: | 304 | expo-clipboard: |
| 278 | specifier: ~56.0.3 | 305 | specifier: ~56.0.4 |
| 279 | version: 56.0.3(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) | 306 | version: 56.0.4(expo@56.0.12)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) |
| 280 | expo-constants: | 307 | expo-constants: |
| 281 | specifier: ~56.0.16 | 308 | specifier: ~56.0.18 |
| 282 | version: 56.0.16(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6)) | 309 | version: 56.0.18(expo@56.0.12)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6)) |
| 283 | expo-crypto: | 310 | expo-crypto: |
| 284 | specifier: ~56.0.4 | 311 | specifier: ~56.0.4 |
| 285 | version: 56.0.4(expo@56.0.8) | 312 | version: 56.0.4(expo@56.0.12) |
| 286 | expo-dev-client: | 313 | expo-dev-client: |
| 287 | specifier: ~56.0.16 | 314 | specifier: ~56.0.20 |
| 288 | version: 56.0.18(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6)) | 315 | version: 56.0.20(expo@56.0.12)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6)) |
| 289 | expo-file-system: | 316 | expo-file-system: |
| 290 | specifier: ~56.0.7 | 317 | specifier: ~56.0.8 |
| 291 | version: 56.0.7(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6)) | 318 | version: 56.0.8(expo@56.0.12)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6)) |
| 292 | expo-font: | 319 | expo-font: |
| 293 | specifier: ~56.0.5 | 320 | specifier: ~56.0.7 |
| 294 | version: 56.0.5(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) | 321 | version: 56.0.7(expo@56.0.12)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) |
| 295 | expo-glass-effect: | 322 | expo-glass-effect: |
| 296 | specifier: ~56.0.4 | 323 | specifier: ~56.0.4 |
| 297 | version: 56.0.4(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) | 324 | version: 56.0.4(expo@56.0.12)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) |
| 298 | expo-haptics: | 325 | expo-haptics: |
| 299 | specifier: ~56.0.3 | 326 | specifier: ~56.0.3 |
| 300 | version: 56.0.3(expo@56.0.8) | 327 | version: 56.0.3(expo@56.0.12) |
| 301 | expo-image-picker: | 328 | expo-image-picker: |
| 302 | specifier: ~56.0.14 | 329 | specifier: ~56.0.18 |
| 303 | version: 56.0.15(expo@56.0.8) | 330 | version: 56.0.18(expo@56.0.12) |
| 304 | expo-linking: | 331 | expo-linking: |
| 305 | specifier: ~56.0.12 | 332 | specifier: ~56.0.14 |
| 306 | version: 56.0.13(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) | 333 | version: 56.0.14(expo@56.0.12)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) |
| 307 | expo-network: | 334 | expo-network: |
| 308 | specifier: ~56.0.5 | 335 | specifier: ~56.0.5 |
| 309 | version: 56.0.5(expo@56.0.8)(react@19.2.3) | 336 | version: 56.0.5(expo@56.0.12)(react@19.2.3) |
| 310 | expo-notifications: | 337 | expo-notifications: |
| 311 | specifier: ~56.0.14 | 338 | specifier: ~56.0.18 |
| 312 | version: 56.0.15(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(typescript@6.0.3) | 339 | version: 56.0.18(expo@56.0.12)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(typescript@6.0.3) |
| 313 | expo-paste-input: | 340 | expo-paste-input: |
| 314 | specifier: ^0.1.15 | 341 | specifier: ^0.1.15 |
| 315 | version: 0.1.15(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) | 342 | version: 0.1.15(expo@56.0.12)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) |
| 316 | expo-router: | ||
| 317 | specifier: ~56.2.7 | ||
| 318 | version: 56.2.8(5bfdf39b8f760e4dc2d5c3acffc97310) | ||
| 319 | expo-secure-store: | 343 | expo-secure-store: |
| 320 | specifier: ~56.0.4 | 344 | specifier: ~56.0.4 |
| 321 | version: 56.0.4(expo@56.0.8) | 345 | version: 56.0.4(expo@56.0.12) |
| 322 | expo-splash-screen: | 346 | expo-splash-screen: |
| 323 | specifier: ~56.0.10 | 347 | specifier: ~56.0.10 |
| 324 | version: 56.0.10(expo@56.0.8)(typescript@6.0.3) | 348 | version: 56.0.10(expo@56.0.12)(typescript@6.0.3) |
| 325 | expo-symbols: | 349 | expo-symbols: |
| 326 | specifier: ~56.0.5 | 350 | specifier: ~56.0.6 |
| 327 | version: 56.0.5(expo-font@56.0.5)(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) | 351 | version: 56.0.6(expo-font@56.0.7)(expo@56.0.12)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) |
| 328 | expo-updates: | 352 | expo-updates: |
| 329 | specifier: ~56.0.17 | 353 | specifier: ~56.0.19 |
| 330 | version: 56.0.17(expo-dev-client@56.0.18(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6)))(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) | 354 | version: 56.0.19(expo-dev-client@56.0.20(expo@56.0.12)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6)))(expo@56.0.12)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) |
| 331 | expo-web-browser: | 355 | expo-web-browser: |
| 332 | specifier: ~56.0.5 | 356 | specifier: ~56.0.5 |
| 333 | version: 56.0.5(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6)) | 357 | version: 56.0.5(expo@56.0.12)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6)) |
| 334 | expo-widgets: | 358 | expo-widgets: |
| 335 | specifier: ~56.0.15 | 359 | specifier: ~56.0.19 |
| 336 | version: 56.0.16(961c4aa6f32829b318e3c87ef20ad401) | 360 | version: 56.0.19(19413efe5eaad64848598eedfe3a0fd3) |
| 337 | punycode: | 361 | punycode: |
| 338 | specifier: ^2.3.1 | 362 | specifier: ^2.3.1 |
| 339 | version: 2.3.1 | 363 | version: 2.3.1 |
| ... | @@ -345,43 +369,43 @@ importers: | ... | @@ -345,43 +369,43 @@ importers: |
| 345 | version: 19.2.3(react@19.2.3) | 369 | version: 19.2.3(react@19.2.3) |
| 346 | react-native: | 370 | react-native: |
| 347 | specifier: 0.85.3 | 371 | specifier: 0.85.3 |
| 348 | version: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) | 372 | version: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) |
| 349 | react-native-gesture-handler: | 373 | react-native-gesture-handler: |
| 350 | specifier: ~2.31.1 | 374 | specifier: ~2.31.1 |
| 351 | version: 2.31.2(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) | 375 | version: 2.31.2(patch_hash=808eb26f9e57cf4945efd3985af4d9c764da6f91f4c9764433cc868602bbf4d3)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) |
| 352 | react-native-image-viewing: | 376 | react-native-image-viewing: |
| 353 | specifier: ^0.2.2 | 377 | specifier: ^0.2.2 |
| 354 | version: 0.2.2(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) | 378 | version: 0.2.2(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) |
| 355 | react-native-keyboard-controller: | 379 | react-native-keyboard-controller: |
| 356 | specifier: 1.21.7 | 380 | specifier: 1.21.6 |
| 357 | version: 1.21.7(react-native-reanimated@4.3.1(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) | 381 | version: 1.21.6(react-native-reanimated@4.3.1(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) |
| 358 | react-native-nitro-markdown: | 382 | react-native-nitro-markdown: |
| 359 | specifier: ^0.5.0 | 383 | specifier: ^0.5.0 |
| 360 | version: 0.5.8(react-native-nitro-modules@0.35.9(patch_hash=825622aae63a8fb5b904f3c77908a0e216261d727ea171709f2c0b6088422675)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native-svg@15.15.4(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) | 384 | version: 0.5.8(react-native-nitro-modules@0.35.9(patch_hash=825622aae63a8fb5b904f3c77908a0e216261d727ea171709f2c0b6088422675)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native-svg@15.15.4(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) |
| 361 | react-native-nitro-modules: | 385 | react-native-nitro-modules: |
| 362 | specifier: 0.35.9 | 386 | specifier: 0.35.9 |
| 363 | version: 0.35.9(patch_hash=825622aae63a8fb5b904f3c77908a0e216261d727ea171709f2c0b6088422675)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) | 387 | version: 0.35.9(patch_hash=825622aae63a8fb5b904f3c77908a0e216261d727ea171709f2c0b6088422675)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) |
| 364 | react-native-reanimated: | 388 | react-native-reanimated: |
| 365 | specifier: 4.3.1 | 389 | specifier: 4.3.1 |
| 366 | version: 4.3.1(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) | 390 | version: 4.3.1(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) |
| 367 | react-native-safe-area-context: | 391 | react-native-safe-area-context: |
| 368 | specifier: ~5.7.0 | 392 | specifier: ~5.7.0 |
| 369 | version: 5.7.0(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) | 393 | version: 5.7.0(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) |
| 370 | react-native-screens: | 394 | react-native-screens: |
| 371 | specifier: 4.25.2 | 395 | specifier: 4.25.2 |
| 372 | version: 4.25.2(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) | 396 | version: 4.25.2(patch_hash=e1b49230abc20a663115cc3f34ca2f63764a873485ccb67f2bd43b85ce2a94dc)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) |
| 373 | react-native-shiki-engine: | 397 | react-native-shiki-engine: |
| 374 | specifier: ^0.3.12 | 398 | specifier: ^0.3.12 |
| 375 | version: 0.3.12(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) | 399 | version: 0.3.12(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) |
| 376 | react-native-svg: | 400 | react-native-svg: |
| 377 | specifier: 15.15.4 | 401 | specifier: 15.15.4 |
| 378 | version: 15.15.4(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) | 402 | version: 15.15.4(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) |
| 379 | react-native-webview: | 403 | react-native-webview: |
| 380 | specifier: ^13.16.1 | 404 | specifier: ^13.16.1 |
| 381 | version: 13.16.1(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) | 405 | version: 13.16.1(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) |
| 382 | react-native-worklets: | 406 | react-native-worklets: |
| 383 | specifier: 0.8.3 | 407 | specifier: 0.8.3 |
| 384 | version: 0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) | 408 | version: 0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) |
| 385 | shiki: | 409 | shiki: |
| 386 | specifier: 4.2.0 | 410 | specifier: 4.2.0 |
| 387 | version: 4.2.0 | 411 | version: 4.2.0 |
| ... | @@ -390,7 +414,7 @@ importers: | ... | @@ -390,7 +414,7 @@ importers: |
| 390 | version: 3.6.0 | 414 | version: 3.6.0 |
| 391 | uniwind: | 415 | uniwind: |
| 392 | specifier: ^1.6.2 | 416 | specifier: ^1.6.2 |
| 393 | version: 1.7.0(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(tailwindcss@4.3.0) | 417 | version: 1.7.0(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(tailwindcss@4.3.0) |
| 394 | devDependencies: | 418 | devDependencies: |
| 395 | '@effect/vitest': | 419 | '@effect/vitest': |
| 396 | specifier: 4.0.0-beta.78 | 420 | specifier: 4.0.0-beta.78 |
| ... | @@ -403,7 +427,7 @@ importers: | ... | @@ -403,7 +427,7 @@ importers: |
| 403 | version: 19.2.16 | 427 | version: 19.2.16 |
| 404 | babel-preset-expo: | 428 | babel-preset-expo: |
| 405 | specifier: ~56.0.0 | 429 | specifier: ~56.0.0 |
| 406 | version: 56.0.14(@babel/core@7.29.7)(@babel/runtime@7.29.7)(expo-widgets@56.0.16)(expo@56.0.8)(react-refresh@0.14.2) | 430 | version: 56.0.14(@babel/core@7.29.7)(@babel/runtime@7.29.7)(expo-widgets@56.0.19)(expo@56.0.12)(react-refresh@0.14.2) |
| 407 | tailwindcss: | 431 | tailwindcss: |
| 408 | specifier: ^4.0.0 | 432 | specifier: ^4.0.0 |
| 409 | version: 4.3.0 | 433 | version: 4.3.0 |
| ... | @@ -481,11 +505,11 @@ importers: | ... | @@ -481,11 +505,11 @@ importers: |
| 481 | specifier: ^1.4.1 | 505 | specifier: ^1.4.1 |
| 482 | version: 1.5.0(@types/react@19.2.16)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) | 506 | version: 1.5.0(@types/react@19.2.16)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) |
| 483 | '@clerk/electron': | 507 | '@clerk/electron': |
| 484 | specifier: 0.0.2-snapshot.v20260622234151 | 508 | specifier: 0.0.5 |
| 485 | version: 0.0.2-snapshot.v20260622234151(@clerk/electron-passkeys@0.0.2-snapshot.v20260622234151)(electron-store@8.2.0)(electron@41.5.0)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) | 509 | version: 0.0.5(@clerk/electron-passkeys@0.0.3)(electron-store@8.2.0)(electron@41.5.0)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) |
| 486 | '@clerk/react': | 510 | '@clerk/react': |
| 487 | specifier: 6.11.0-snapshot.v20260622234151 | 511 | specifier: 6.11.1 |
| 488 | version: 6.11.0-snapshot.v20260622234151(react-dom@19.2.6(react@19.2.6))(react@19.2.6) | 512 | version: 6.11.1(react-dom@19.2.6(react@19.2.6))(react@19.2.6) |
| 489 | '@dnd-kit/core': | 513 | '@dnd-kit/core': |
| 490 | specifier: ^6.3.1 | 514 | specifier: ^6.3.1 |
| 491 | version: 6.3.1(react-dom@19.2.6(react@19.2.6))(react@19.2.6) | 515 | version: 6.3.1(react-dom@19.2.6(react@19.2.6))(react@19.2.6) |
| ... | @@ -635,8 +659,8 @@ importers: | ... | @@ -635,8 +659,8 @@ importers: |
| 635 | infra/relay: | 659 | infra/relay: |
| 636 | dependencies: | 660 | dependencies: |
| 637 | '@clerk/backend': | 661 | '@clerk/backend': |
| 638 | specifier: 3.8.3-snapshot.v20260622234151 | 662 | specifier: 3.8.4 |
| 639 | version: 3.8.3-snapshot.v20260622234151(react-dom@19.2.6(react@19.2.6))(react@19.2.6) | 663 | version: 3.8.4(react-dom@19.2.6(react@19.2.6))(react@19.2.6) |
| 640 | '@effect/sql-pg': | 664 | '@effect/sql-pg': |
| 641 | specifier: 4.0.0-beta.78 | 665 | specifier: 4.0.0-beta.78 |
| 642 | version: 4.0.0-beta.78(effect@4.0.0-beta.78(patch_hash=c502bc684210b707dfceb87d8fe6ad6843395af6e19cfc02cd65854898bde2c5)) | 666 | version: 4.0.0-beta.78(effect@4.0.0-beta.78(patch_hash=c502bc684210b707dfceb87d8fe6ad6843395af6e19cfc02cd65854898bde2c5)) |
| ... | @@ -1650,43 +1674,43 @@ packages: | ... | @@ -1650,43 +1674,43 @@ packages: |
| 1650 | resolution: {integrity: sha512-EYlRokl8szrP9Z25qT5aepMdBjzBvHF9ZEhzIiUBc9guz/T31EqRgvD0QSgZcpE93xiwrr+OkB4nz0BZyF6fSA==} | 1674 | resolution: {integrity: sha512-EYlRokl8szrP9Z25qT5aepMdBjzBvHF9ZEhzIiUBc9guz/T31EqRgvD0QSgZcpE93xiwrr+OkB4nz0BZyF6fSA==} |
| 1651 | engines: {node: '>= 20.12.0'} | 1675 | engines: {node: '>= 20.12.0'} |
| 1652 | 1676 | ||
| 1653 | '@clerk/backend@3.8.3-snapshot.v20260622234151': | 1677 | '@clerk/backend@3.8.4': |
| 1654 | resolution: {integrity: sha512-B5goX0n/5pibc4dMQOfMmn4mPq7eqtrbNV20tOqO9qMPzFA+TKAj9SnB0oJvFsZAXKO6+/n16uYPbqeM2PN6CA==} | 1678 | resolution: {integrity: sha512-3G+kEu8kalqQokJ/n0IynhBh2i6TtiilRzuAg5UWMburkK658/elrG0Uqsapd5yKhmkNuvizHwOPWwKAMxP2EQ==} |
| 1655 | engines: {node: '>=20.9.0'} | 1679 | engines: {node: '>=20.9.0'} |
| 1656 | 1680 | ||
| 1657 | '@clerk/clerk-js@6.21.0-snapshot.v20260622234151': | 1681 | '@clerk/clerk-js@6.22.0': |
| 1658 | resolution: {integrity: sha512-mRNn6H8GbeEkcCIzZ03WZ9c1Uy8znf70okYmmeJKzK72gsdwnrxEfbu3DYE/5yRbX6lvL7ugWaNTT3DvPEbBzg==} | 1682 | resolution: {integrity: sha512-4kN1J1GHUv7utZn1UunfOi10ZVyHOVY+d7tMGXoBxrckpb4vT/rFYep8uf8Xc6Q5QNEmi1/J0DMxZqnS3xy+ww==} |
| 1659 | engines: {node: '>=20.9.0'} | 1683 | engines: {node: '>=20.9.0'} |
| 1660 | 1684 | ||
| 1661 | '@clerk/electron-passkeys-darwin-arm64@0.0.2-snapshot.v20260622234151': | 1685 | '@clerk/electron-passkeys-darwin-arm64@0.0.3': |
| 1662 | resolution: {integrity: sha512-NZjIVGitAf0yyQvs1WXIAYOle9RjGsExpfwje2U20POTnNueFy56M0g39UTFQXJ42saTZ0zauLD8sd0cHqpOjA==} | 1686 | resolution: {integrity: sha512-oAlrd+GLqP0oREP3hNRc6yNZhXGaQgJss9iCWRQqXjpw1yZD1ZU+L+E2Gfg84vMj/NSUhxbU0Djy0RXhmeUwgg==} |
| 1663 | cpu: [arm64] | 1687 | cpu: [arm64] |
| 1664 | os: [darwin] | 1688 | os: [darwin] |
| 1665 | 1689 | ||
| 1666 | '@clerk/electron-passkeys-darwin-x64@0.0.2-snapshot.v20260622234151': | 1690 | '@clerk/electron-passkeys-darwin-x64@0.0.3': |
| 1667 | resolution: {integrity: sha512-gGAwinVoIxa9lefCJjhf5D6oA8uq1sXQ/JwqffkBPJrvczYyAv9FYuQ1/ihji3jTplC0/bpTuIrwBkBCzwj5ug==} | 1691 | resolution: {integrity: sha512-0UPAWQEni7o8gjWwd2sP97fPshJmT2w5b8eV/jOPVSP8NqIcFCm5yIFSEx1SfB53LGfzB01A4iEGi2pxiCHqyQ==} |
| 1668 | cpu: [x64] | 1692 | cpu: [x64] |
| 1669 | os: [darwin] | 1693 | os: [darwin] |
| 1670 | 1694 | ||
| 1671 | '@clerk/electron-passkeys-win32-arm64-msvc@0.0.2-snapshot.v20260622234151': | 1695 | '@clerk/electron-passkeys-win32-arm64-msvc@0.0.3': |
| 1672 | resolution: {integrity: sha512-anBFktMTAF7of37nLQsqQuW489w563J75l4QolWtblKbrBjlwFsEif95uj3vlQj0qWVVRWpAcxD21oD6wdJcwQ==} | 1696 | resolution: {integrity: sha512-bbRvifm9A/gfPkwC1UyMyF9sYGEs0aen7J2kCcaOIW3K0PXcAbHtsR/gnx4dT0bjFdd21vSa2zlIdERvHc1BiA==} |
| 1673 | cpu: [arm64] | 1697 | cpu: [arm64] |
| 1674 | os: [win32] | 1698 | os: [win32] |
| 1675 | 1699 | ||
| 1676 | '@clerk/electron-passkeys-win32-x64-msvc@0.0.2-snapshot.v20260622234151': | 1700 | '@clerk/electron-passkeys-win32-x64-msvc@0.0.3': |
| 1677 | resolution: {integrity: sha512-Pq4FOklTJNpyMTJpxY+/gq6CukvWHFJfGYjuz985cPHup1OSsVmuO/GYil36vh7Qbe8vDivT91wNe73DbEOcVw==} | 1701 | resolution: {integrity: sha512-gFaVqKlOKTF2SJ3lMwkZ0oonycQn93p1qSrp5kzzeZBeQ+tGB3gQnjXecrhQy21AzDUyWqVJJ8KrP2HRAwifWg==} |
| 1678 | cpu: [x64] | 1702 | cpu: [x64] |
| 1679 | os: [win32] | 1703 | os: [win32] |
| 1680 | 1704 | ||
| 1681 | '@clerk/electron-passkeys@0.0.2-snapshot.v20260622234151': | 1705 | '@clerk/electron-passkeys@0.0.3': |
| 1682 | resolution: {integrity: sha512-UvBweTg9+FCbygxARV7RvJ4/lcQgLN+gJC6Lj++cLmbxpgRwRAgR9xXRlKV6dVg1p5k81a4DcFNMu/oR6zBdlQ==} | 1706 | resolution: {integrity: sha512-OHhIe88qDL+FxyBalXdXNHAS5eEramr6Rerp+6iNkfkjqT8rx4hHNmfpmjg5/T1/am8QfknbOBZkqoXZlCrjPg==} |
| 1683 | engines: {node: '>=20.9.0'} | 1707 | engines: {node: '>=20.9.0'} |
| 1684 | 1708 | ||
| 1685 | '@clerk/electron@0.0.2-snapshot.v20260622234151': | 1709 | '@clerk/electron@0.0.5': |
| 1686 | resolution: {integrity: sha512-T0LUJeAPaAZxpk13Q14b9LSVKKio/X/zFo67hgdr35MaOChsn4T6lQ/rEL7laScQBduskcs1yyjr3e9ndy5ojg==} | 1710 | resolution: {integrity: sha512-usPkmcKsSBuqs9zUctVZLlde3Fhu+pYJ+8fHZPL7aPYfupIe0qvsWOgJIb6GXSrEshcBg104MdSzuVT2w7Dzxg==} |
| 1687 | engines: {node: '>=20.9.0'} | 1711 | engines: {node: '>=20.9.0'} |
| 1688 | peerDependencies: | 1712 | peerDependencies: |
| 1689 | '@clerk/electron-passkeys': 0.0.2-snapshot.v20260622234151 | 1713 | '@clerk/electron-passkeys': 0.0.3 |
| 1690 | electron: '>=28' | 1714 | electron: '>=28' |
| 1691 | electron-store: ^8.2.0 | 1715 | electron-store: ^8.2.0 |
| 1692 | react: ^18.0.0 || ~19.0.3 || ~19.1.4 || ~19.2.3 || ~19.3.0-0 | 1716 | react: ^18.0.0 || ~19.0.3 || ~19.1.4 || ~19.2.3 || ~19.3.0-0 |
| ... | @@ -1699,11 +1723,11 @@ packages: | ... | @@ -1699,11 +1723,11 @@ packages: |
| 1699 | react-dom: | 1723 | react-dom: |
| 1700 | optional: true | 1724 | optional: true |
| 1701 | 1725 | ||
| 1702 | '@clerk/expo@3.5.3-snapshot.v20260622234151': | 1726 | '@clerk/expo@3.6.2': |
| 1703 | resolution: {integrity: sha512-qeKTJYA7cTe5oCmRVCT4A2QEPRp2af0ox0VdXzIhWRqRVFNF5bnsZRNKE2e4QnsC16NYLsGHLR5uQGC15qBiug==} | 1727 | resolution: {integrity: sha512-8h4j62YLEtKlAq6eptfwB6jauOy7QQA+lMqRCY00n9PL1oQG7Y+sTGDg6ylX66O/VG2YOsqnBdlZcNUgt3xd2A==} |
| 1704 | engines: {node: '>=20.9.0'} | 1728 | engines: {node: '>=20.9.0'} |
| 1705 | peerDependencies: | 1729 | peerDependencies: |
| 1706 | '@clerk/expo-passkeys': 1.1.9-snapshot.v20260622234151 | 1730 | '@clerk/expo-passkeys': '>=0.0.6' |
| 1707 | expo: '>=53 <57' | 1731 | expo: '>=53 <57' |
| 1708 | expo-apple-authentication: '>=7.0.0' | 1732 | expo-apple-authentication: '>=7.0.0' |
| 1709 | expo-auth-session: '>=5' | 1733 | expo-auth-session: '>=5' |
| ... | @@ -1735,15 +1759,15 @@ packages: | ... | @@ -1735,15 +1759,15 @@ packages: |
| 1735 | react-dom: | 1759 | react-dom: |
| 1736 | optional: true | 1760 | optional: true |
| 1737 | 1761 | ||
| 1738 | '@clerk/react@6.11.0-snapshot.v20260622234151': | 1762 | '@clerk/react@6.11.1': |
| 1739 | resolution: {integrity: sha512-yF4jQFJqEHAqZCpOtjQ/Kg9yqZlEG6vW2vmVR5kVwgESkpOR1KPJIEMU8o3b6W86RKQai4lt3CWtY+o7CJsDyw==} | 1763 | resolution: {integrity: sha512-iEWckisZa/V3siCFlvGTNg7Yj+kndRx7HKBZ/QMSd6uEU9kd2tZ8Ymvd4GFPCpClwcki4h7jC750FujN656Jqg==} |
| 1740 | engines: {node: '>=20.9.0'} | 1764 | engines: {node: '>=20.9.0'} |
| 1741 | peerDependencies: | 1765 | peerDependencies: |
| 1742 | react: ^18.0.0 || ~19.0.3 || ~19.1.4 || ~19.2.3 || ~19.3.0-0 | 1766 | react: ^18.0.0 || ~19.0.3 || ~19.1.4 || ~19.2.3 || ~19.3.0-0 |
| 1743 | react-dom: ^18.0.0 || ~19.0.3 || ~19.1.4 || ~19.2.3 || ~19.3.0-0 | 1767 | react-dom: ^18.0.0 || ~19.0.3 || ~19.1.4 || ~19.2.3 || ~19.3.0-0 |
| 1744 | 1768 | ||
| 1745 | '@clerk/shared@4.21.0-snapshot.v20260622234151': | 1769 | '@clerk/shared@4.22.0': |
| 1746 | resolution: {integrity: sha512-OYu+hO0GHiHwYTwSFe/GCHmuQlyTHyNa7fJ8vrmtUyML09mf+dayRFeUnxgkwOYhGriyq40t1zxdVx53Td73xg==} | 1770 | resolution: {integrity: sha512-GZ56kzUB2UBb8MCF+Eo8WIey+W4RP1tAkyOL+geiHxrQxJlUcgD+xalY2YPJLH8WVFwtOnfIl8KPEo0M0e/DRg==} |
| 1747 | engines: {node: '>=20.9.0'} | 1771 | engines: {node: '>=20.9.0'} |
| 1748 | peerDependencies: | 1772 | peerDependencies: |
| 1749 | react: ^18.0.0 || ~19.0.3 || ~19.1.4 || ~19.2.3 || ~19.3.0-0 | 1773 | react: ^18.0.0 || ~19.0.3 || ~19.1.4 || ~19.2.3 || ~19.3.0-0 |
| ... | @@ -2387,8 +2411,8 @@ packages: | ... | @@ -2387,8 +2411,8 @@ packages: |
| 2387 | '@expo-google-fonts/material-symbols@0.4.38': | 2411 | '@expo-google-fonts/material-symbols@0.4.38': |
| 2388 | resolution: {integrity: sha512-IJkBtN1o8u9BW5fvSii1MyHPQ7Q0HxbWcVBvOrOzgMLpVtZw7R2w94wBTVR7kZwv3w1JNTESMmLA5Sqn1+Z36A==} | 2412 | resolution: {integrity: sha512-IJkBtN1o8u9BW5fvSii1MyHPQ7Q0HxbWcVBvOrOzgMLpVtZw7R2w94wBTVR7kZwv3w1JNTESMmLA5Sqn1+Z36A==} |
| 2389 | 2413 | ||
| 2390 | '@expo/cli@56.1.13': | 2414 | '@expo/cli@56.1.16': |
| 2391 | resolution: {integrity: sha512-7n5VzlBr7TKW0BgWgpEopWy+v8buPhMvbSEsuXD+bI1YIJBopkfWAub0qTvlc357E8wWOvV5MJXYyoeRvoOjoQ==} | 2415 | resolution: {integrity: sha512-VBQn0mqAwc67b9Cn0RVXyeodghomAx5xGRhA/bXaQzuxDjMQk0zIOb6pXMZX7yiIwJW66UZt/zQiJNSv6aWJYw==} |
| 2392 | hasBin: true | 2416 | hasBin: true |
| 2393 | peerDependencies: | 2417 | peerDependencies: |
| 2394 | expo: '*' | 2418 | expo: '*' |
| ... | @@ -2406,9 +2430,15 @@ packages: | ... | @@ -2406,9 +2430,15 @@ packages: |
| 2406 | '@expo/config-plugins@56.0.8': | 2430 | '@expo/config-plugins@56.0.8': |
| 2407 | resolution: {integrity: sha512-phTuyBhgVLfqUHMjQkAfRtbyoY6yTxoKja1awtpVnEkoJDxPJuXx1KX5uvq1eZtt4bJQ08OBJ6P95INqRSHpRg==} | 2431 | resolution: {integrity: sha512-phTuyBhgVLfqUHMjQkAfRtbyoY6yTxoKja1awtpVnEkoJDxPJuXx1KX5uvq1eZtt4bJQ08OBJ6P95INqRSHpRg==} |
| 2408 | 2432 | ||
| 2433 | '@expo/config-plugins@56.0.9': | ||
| 2434 | resolution: {integrity: sha512-/6a/S9USwx8OC9tGjHxbviLFiBHyueN3aoNWMLvWDEJoZ1CIVW800ZBzwXq/FYNK2qzcN1LxFmQtzD1zeFQKNA==} | ||
| 2435 | |||
| 2409 | '@expo/config-types@56.0.5': | 2436 | '@expo/config-types@56.0.5': |
| 2410 | resolution: {integrity: sha512-GsAHO/MwW9ZRdgnmyfRXqVGLCP/zejD6rWnp5OROp8mBGRObKm4HfrjlUyT1skjMwCj1OrURx9ZfIc6yeBAkIA==} | 2437 | resolution: {integrity: sha512-GsAHO/MwW9ZRdgnmyfRXqVGLCP/zejD6rWnp5OROp8mBGRObKm4HfrjlUyT1skjMwCj1OrURx9ZfIc6yeBAkIA==} |
| 2411 | 2438 | ||
| 2439 | '@expo/config-types@56.0.6': | ||
| 2440 | resolution: {integrity: sha512-4Y6Aum5J4Re5NnxGVofRNe1aDwUBOmWhQYkynZsqzRtX/zEA1ADUeyHXuEckv9YD9djiyT7bKtLt5gKL3mA6VQ==} | ||
| 2441 | |||
| 2412 | '@expo/config@56.0.9': | 2442 | '@expo/config@56.0.9': |
| 2413 | resolution: {integrity: sha512-/lqFeWGSrhpKJVP8tTN8LjuoIe8u8q2w7FzBL0C+wHgl+WM8l1qUIEYWy/sMvsG/NbpUIUsDHJRhQvOkU58eIw==} | 2443 | resolution: {integrity: sha512-/lqFeWGSrhpKJVP8tTN8LjuoIe8u8q2w7FzBL0C+wHgl+WM8l1qUIEYWy/sMvsG/NbpUIUsDHJRhQvOkU58eIw==} |
| 2414 | 2444 | ||
| ... | @@ -2437,18 +2467,18 @@ packages: | ... | @@ -2437,18 +2467,18 @@ packages: |
| 2437 | resolution: {integrity: sha512-9HnnIbzwTTdbwSjNLXTk0fPm9ZwMJ7c1/31tsni8HZ8Q62KzYCyspahH+V365vg5J6lr001DzNwBxVWSaYCQLg==} | 2467 | resolution: {integrity: sha512-9HnnIbzwTTdbwSjNLXTk0fPm9ZwMJ7c1/31tsni8HZ8Q62KzYCyspahH+V365vg5J6lr001DzNwBxVWSaYCQLg==} |
| 2438 | engines: {node: '>=20.12.0'} | 2468 | engines: {node: '>=20.12.0'} |
| 2439 | 2469 | ||
| 2440 | '@expo/expo-modules-macros-plugin@0.0.9': | 2470 | '@expo/expo-modules-macros-plugin@0.2.2': |
| 2441 | resolution: {integrity: sha512-odai6D7ng/gA7At8ukFcWcauNEeDdyVqzVPbQxDkyU2NTJ4kgphA4I5iigS5C4LXFicSIzEt2nzdlLM8sjsTdA==} | 2471 | resolution: {integrity: sha512-4IMzPDIo/VOXREQjsJtliSfqYVZvfzU2SLFS/9sKMWF848S8CHx+e/E+Vf0TcMvpWCCKX5umyqxb13KJJ+YUzg==} |
| 2442 | 2472 | ||
| 2443 | '@expo/fingerprint@0.19.3': | 2473 | '@expo/fingerprint@0.19.4': |
| 2444 | resolution: {integrity: sha512-w9Au2IVrtc0Ct+WRa05DVHGNHXYq6VyPZWuFP+5x055OeZ5q6k5Yg+aJ1gfShmjdOhDftRcsvmWmTdTZlWaTZw==} | 2474 | resolution: {integrity: sha512-PsowRlO8+S7JlO8go7yhNEXp7sqlsWDE2AlCwoss7zH0dcajXFo74Fy0KdXEc4UXK7kKoHD37oDgsZ8aHSLr7A==} |
| 2445 | hasBin: true | 2475 | hasBin: true |
| 2446 | 2476 | ||
| 2447 | '@expo/image-utils@0.10.1': | 2477 | '@expo/image-utils@0.10.1': |
| 2448 | resolution: {integrity: sha512-YDeefvmYdihS7Wp3ESDUVnOgOSWmj2Cczm9lVNDdm4MqQLdAKm/LPYg83HtFQPfefRlAxyHrQR/O9kIXN9C1Wg==} | 2478 | resolution: {integrity: sha512-YDeefvmYdihS7Wp3ESDUVnOgOSWmj2Cczm9lVNDdm4MqQLdAKm/LPYg83HtFQPfefRlAxyHrQR/O9kIXN9C1Wg==} |
| 2449 | 2479 | ||
| 2450 | '@expo/inline-modules@0.0.10': | 2480 | '@expo/inline-modules@0.0.12': |
| 2451 | resolution: {integrity: sha512-DKEfq877UTAmL/gOT5aFhlLNDg/EVmTSca7JQMKDGR6mjFSAcrmQf4GJNsi6ztiaqj6cTnIfoSz0hAYdnr6RJQ==} | 2481 | resolution: {integrity: sha512-SNIZr/HWfIQPTZBwmukItxpc7ws1SgMUywYq1dnQvDknQDjJcuWAasIRFUjsK15yQ1xb4G5CP7VHtbN3V4lENg==} |
| 2452 | 2482 | ||
| 2453 | '@expo/json-file@10.2.0': | 2483 | '@expo/json-file@10.2.0': |
| 2454 | resolution: {integrity: sha512-S6XzKe3R9GQeHiUPXc3xJjOv2VJhOEwFYf7xdC2z2cUqt3kZJ9mSO877sNQloVdnW/SUCtPY3bexlM7nwq+CAQ==} | 2484 | resolution: {integrity: sha512-S6XzKe3R9GQeHiUPXc3xJjOv2VJhOEwFYf7xdC2z2cUqt3kZJ9mSO877sNQloVdnW/SUCtPY3bexlM7nwq+CAQ==} |
| ... | @@ -2456,16 +2486,16 @@ packages: | ... | @@ -2456,16 +2486,16 @@ packages: |
| 2456 | '@expo/local-build-cache-provider@56.0.8': | 2486 | '@expo/local-build-cache-provider@56.0.8': |
| 2457 | resolution: {integrity: sha512-UsuXwpNi57MNhzZ3be4XThc8xW6nzk3Wu37s1+2qcfZGeJcMLKDFfwO6n8YXeIiGlCsOi0Ee1rsTdgjrKt/YJQ==} | 2487 | resolution: {integrity: sha512-UsuXwpNi57MNhzZ3be4XThc8xW6nzk3Wu37s1+2qcfZGeJcMLKDFfwO6n8YXeIiGlCsOi0Ee1rsTdgjrKt/YJQ==} |
| 2458 | 2488 | ||
| 2459 | '@expo/log-box@56.0.12': | 2489 | '@expo/log-box@56.0.13': |
| 2460 | resolution: {integrity: sha512-budE6AGmJbpOJfGSOz+JVP3+FevElT82IEIg+ukQ4gZpW/dGO7QX1unFjanKdSaYgudBwJ4FCFGMwWhW/1tXVQ==} | 2490 | resolution: {integrity: sha512-QWRZSpWPyjkDLVQio4R7oAzg/Av2MOt/DciFkfjr8qQ3qxGVn1Rt1oHP/80hvcWDcHFV7N6PqpyxRXw6nbxzKQ==} |
| 2461 | peerDependencies: | 2491 | peerDependencies: |
| 2462 | '@expo/dom-webview': ^56.0.5 | 2492 | '@expo/dom-webview': ^56.0.5 |
| 2463 | expo: '*' | 2493 | expo: '*' |
| 2464 | react: '*' | 2494 | react: '*' |
| 2465 | react-native: '*' | 2495 | react-native: '*' |
| 2466 | 2496 | ||
| 2467 | '@expo/metro-config@56.0.13': | 2497 | '@expo/metro-config@56.0.14': |
| 2468 | resolution: {integrity: sha512-OPyNYiex/6Ms8zT2POdIZsLhcAZYk7O+yJvpz5uG/4QRA7aiESfCy1I+0YHewMlR4P1YQeyxIrfTurs6m9xfZA==} | 2498 | resolution: {integrity: sha512-O3CIHruaTJhswPAf/nf3i8QQ3f2jl+mEwSea1eb3khuplabdy/wTQz+JvHN8VGUFyg7JKwUGU1QfO6T3JiSQqA==} |
| 2469 | peerDependencies: | 2499 | peerDependencies: |
| 2470 | expo: '*' | 2500 | expo: '*' |
| 2471 | peerDependenciesMeta: | 2501 | peerDependenciesMeta: |
| ... | @@ -2475,10 +2505,10 @@ packages: | ... | @@ -2475,10 +2505,10 @@ packages: |
| 2475 | '@expo/metro-file-map@56.0.3': | 2505 | '@expo/metro-file-map@56.0.3': |
| 2476 | resolution: {integrity: sha512-5OGW3z8LgEYgMJOR7F3pC8llFLkb1fVqwAewbCl6S4Vkha8AFQMwOjT+9Wbka+V4rmpljpGqOnMhF4xZbD961w==} | 2506 | resolution: {integrity: sha512-5OGW3z8LgEYgMJOR7F3pC8llFLkb1fVqwAewbCl6S4Vkha8AFQMwOjT+9Wbka+V4rmpljpGqOnMhF4xZbD961w==} |
| 2477 | 2507 | ||
| 2478 | '@expo/metro-runtime@56.0.13': | 2508 | '@expo/metro-runtime@56.0.15': |
| 2479 | resolution: {integrity: sha512-aMaFa/RPYm2iQoyYOB5q8AxDmWvf4E2yFbZ6rmBIQWaIPDdixGVUlLQeV8DlDAfZ/j+pNYO7l5M+774WbgkTgg==} | 2509 | resolution: {integrity: sha512-WIWeVsL6kCSB57oYZdUA4MTkH7c67UFMIjdNoQzKXwxZYwBFE/xL2cGPDC3z8RWt0femzJTVxAVZUOW/hiqRzA==} |
| 2480 | peerDependencies: | 2510 | peerDependencies: |
| 2481 | '@expo/log-box': ^56.0.12 | 2511 | '@expo/log-box': ^56.0.13 |
| 2482 | expo: '*' | 2512 | expo: '*' |
| 2483 | react: '*' | 2513 | react: '*' |
| 2484 | react-dom: '*' | 2514 | react-dom: '*' |
| ... | @@ -2500,8 +2530,8 @@ packages: | ... | @@ -2500,8 +2530,8 @@ packages: |
| 2500 | '@expo/plist@0.7.0': | 2530 | '@expo/plist@0.7.0': |
| 2501 | resolution: {integrity: sha512-vrpryU1GoqSIRNqRB2D3IjXDmzNYfiQpEF6AH/xknlD7eiYmEDt3mb26V7cLcedcPG8PY/1xWHdBXVQJfEAh6Q==} | 2531 | resolution: {integrity: sha512-vrpryU1GoqSIRNqRB2D3IjXDmzNYfiQpEF6AH/xknlD7eiYmEDt3mb26V7cLcedcPG8PY/1xWHdBXVQJfEAh6Q==} |
| 2502 | 2532 | ||
| 2503 | '@expo/prebuild-config@56.0.14': | 2533 | '@expo/prebuild-config@56.0.16': |
| 2504 | resolution: {integrity: sha512-JHdMqR7Mf5ApLC50ZwTL0Z86ezrHOMYwoSHcWT6Pha/+1TcC+/J+i7vjhP06wGXQ2Kvjt74p/3mKg2Pd12KjhQ==} | 2534 | resolution: {integrity: sha512-ce9ENfPWO4WUWUVQz0OaqL3KYZ7YofP8O35ncnn7CHCaKwQ7BqxcCGJbh+qvP1UjlWeNB3CjHPrXXJ3bnZwlJw==} |
| 2505 | 2535 | ||
| 2506 | '@expo/require-utils@56.1.3': | 2536 | '@expo/require-utils@56.1.3': |
| 2507 | resolution: {integrity: sha512-KyLeOn/zzQSvuPpV5YhB/FPKnpQytno4luN918bGdPDssLBoS3N/0UbC3W0rJAn9kSFu+XpfR81eABRVsSdfgQ==} | 2537 | resolution: {integrity: sha512-KyLeOn/zzQSvuPpV5YhB/FPKnpQytno4luN918bGdPDssLBoS3N/0UbC3W0rJAn9kSFu+XpfR81eABRVsSdfgQ==} |
| ... | @@ -2511,15 +2541,15 @@ packages: | ... | @@ -2511,15 +2541,15 @@ packages: |
| 2511 | typescript: | 2541 | typescript: |
| 2512 | optional: true | 2542 | optional: true |
| 2513 | 2543 | ||
| 2514 | '@expo/router-server@56.0.12': | 2544 | '@expo/router-server@56.0.14': |
| 2515 | resolution: {integrity: sha512-RqKV2/Z8BH/z8l0ngSpG6//5xxJPaF5dTQvSfPQ0nrvCjikGMeIvyj3B9BeLnmZZhxb3gBtXqrj3irAoiIp2aQ==} | 2545 | resolution: {integrity: sha512-2UCTtZfcq1ZPgp3wk8/+sq9DvFI9UxrPr1jcEKMAF2DGAJLosnpc8GWNNg2hkjt6SHUOdFHIPxujWPYyho2y3A==} |
| 2516 | peerDependencies: | 2546 | peerDependencies: |
| 2517 | '@expo/metro-runtime': ^56.0.13 | 2547 | '@expo/metro-runtime': ^56.0.15 |
| 2518 | expo: '*' | 2548 | expo: '*' |
| 2519 | expo-constants: ^56.0.16 | 2549 | expo-constants: ^56.0.18 |
| 2520 | expo-font: ^56.0.5 | 2550 | expo-font: ^56.0.6 |
| 2521 | expo-router: '*' | 2551 | expo-router: '*' |
| 2522 | expo-server: ^56.0.4 | 2552 | expo-server: ^56.0.5 |
| 2523 | react: '*' | 2553 | react: '*' |
| 2524 | react-dom: '*' | 2554 | react-dom: '*' |
| 2525 | react-server-dom-webpack: ~19.0.1 || ~19.1.2 || ~19.2.1 | 2555 | react-server-dom-webpack: ~19.0.1 || ~19.1.2 || ~19.2.1 |
| ... | @@ -2546,8 +2576,8 @@ packages: | ... | @@ -2546,8 +2576,8 @@ packages: |
| 2546 | '@expo/sudo-prompt@9.3.2': | 2576 | '@expo/sudo-prompt@9.3.2': |
| 2547 | resolution: {integrity: sha512-HHQigo3rQWKMDzYDLkubN5WQOYXJJE2eNqIQC2axC2iO3mHdwnIR7FgZVvHWtBwAdzBgAP0ECp8KqS8TiMKvgw==} | 2577 | resolution: {integrity: sha512-HHQigo3rQWKMDzYDLkubN5WQOYXJJE2eNqIQC2axC2iO3mHdwnIR7FgZVvHWtBwAdzBgAP0ECp8KqS8TiMKvgw==} |
| 2548 | 2578 | ||
| 2549 | '@expo/ui@56.0.15': | 2579 | '@expo/ui@56.0.18': |
| 2550 | resolution: {integrity: sha512-PFZBzztQGCp2bRFP8wIOb5ntP2ORH2GdQkJMSJcDOd4NldoWMe1pFqv7PdthjNlaaTHHTTHK+RsQrz+M6z6isw==} | 2580 | resolution: {integrity: sha512-2XgH5obigGtXm8zlb/V3g87NSiIcBcJ1xoQOEQYPoExL1DCNsHzaIecTh1XG/f/45ardo4OZNJwpbfYJ9X3qrQ==} |
| 2551 | peerDependencies: | 2581 | peerDependencies: |
| 2552 | '@babel/core': '*' | 2582 | '@babel/core': '*' |
| 2553 | expo: '*' | 2583 | expo: '*' |
| ... | @@ -2566,8 +2596,10 @@ packages: | ... | @@ -2566,8 +2596,10 @@ packages: |
| 2566 | react-native-worklets: | 2596 | react-native-worklets: |
| 2567 | optional: true | 2597 | optional: true |
| 2568 | 2598 | ||
| 2569 | '@expo/ws-tunnel@1.0.6': | 2599 | '@expo/ws-tunnel@2.0.0': |
| 2570 | resolution: {integrity: sha512-nDRbLmSrJar7abvUjp3smDwH8HcbZcoOEa5jVPUv9/9CajgmWw20JNRwTuBRzWIWIkEJDkz20GoNA+tSwUqk0Q==} | 2600 | resolution: {integrity: sha512-j+JfTRdCk820J9dU0sA2SqshQIKFOMo7ED84w9MJFcebfbNQgsLztEY/SABDkGnjatrW4xGqnUhVRxSBVyCkXw==} |
| 2601 | peerDependencies: | ||
| 2602 | ws: ^8.0.0 | ||
| 2571 | 2603 | ||
| 2572 | '@expo/xcpretty@4.4.4': | 2604 | '@expo/xcpretty@4.4.4': |
| 2573 | resolution: {integrity: sha512-4aQzz9vgxcNXFfo/iyNgDDYfsU5XGKKxWxZopw0cVotHiW+U8IJbIxMaxsINs6bHhtkG3StKNPcOrn3eBuxKPw==} | 2605 | resolution: {integrity: sha512-4aQzz9vgxcNXFfo/iyNgDDYfsU5XGKKxWxZopw0cVotHiW+U8IJbIxMaxsINs6bHhtkG3StKNPcOrn3eBuxKPw==} |
| ... | @@ -3842,6 +3874,41 @@ packages: | ... | @@ -3842,6 +3874,41 @@ packages: |
| 3842 | '@types/react': | 3874 | '@types/react': |
| 3843 | optional: true | 3875 | optional: true |
| 3844 | 3876 | ||
| 3877 | '@react-navigation/core@7.21.2': | ||
| 3878 | resolution: {integrity: sha512-EsJhQnsL5NuIhXsWF7URijS5hd2AaJUREdq1fOIowlyNNQBA3jCnkGU0DkW7+eI40cQ+GXH8DQw+ci7TefLIxQ==} | ||
| 3879 | peerDependencies: | ||
| 3880 | react: '>= 18.2.0' | ||
| 3881 | |||
| 3882 | '@react-navigation/elements@2.9.26': | ||
| 3883 | resolution: {integrity: sha512-EaHSJf42MjnH5VFL+KZfQIyqJvdQWK9Z27J7WUSuIVX5NXlR925sPmhWf540DX9gD1ny8BfUGPZAuw/PO4tK2w==} | ||
| 3884 | peerDependencies: | ||
| 3885 | '@react-native-masked-view/masked-view': '>= 0.2.0' | ||
| 3886 | '@react-navigation/native': ^7.3.4 | ||
| 3887 | react: '>= 18.2.0' | ||
| 3888 | react-native: '*' | ||
| 3889 | react-native-safe-area-context: '>= 4.0.0' | ||
| 3890 | peerDependenciesMeta: | ||
| 3891 | '@react-native-masked-view/masked-view': | ||
| 3892 | optional: true | ||
| 3893 | |||
| 3894 | '@react-navigation/native-stack@7.17.6': | ||
| 3895 | resolution: {integrity: sha512-CnNPnlSGnrxPyHguDp/xDg5w9STMmIoI6IFZe7cpvEeD0GF/LTzvZ0gmq+hPJTm2/KIrnuPx34emCofzpht45g==} | ||
| 3896 | peerDependencies: | ||
| 3897 | '@react-navigation/native': ^7.3.4 | ||
| 3898 | react: '>= 18.2.0' | ||
| 3899 | react-native: '*' | ||
| 3900 | react-native-safe-area-context: '>= 4.0.0' | ||
| 3901 | react-native-screens: '>= 4.0.0' | ||
| 3902 | |||
| 3903 | '@react-navigation/native@7.3.4': | ||
| 3904 | resolution: {integrity: sha512-zyAqFLeZuaakerMMnhYqBeGAzJ+WFQUTgezP3FxSlXNwFq5XxnjP28vi2XHGqm4zg4pGWls9Ay4JKshIHUOpwA==} | ||
| 3905 | peerDependencies: | ||
| 3906 | react: '>= 18.2.0' | ||
| 3907 | react-native: '*' | ||
| 3908 | |||
| 3909 | '@react-navigation/routers@7.6.0': | ||
| 3910 | resolution: {integrity: sha512-lblhDXfS75jLc7G2K7BZGM+7cjqQXk13X/MA4fq/12r62zM+fBhhreLzYflSitrDDXFRJpSvJXy0ziiGU04Xow==} | ||
| 3911 | |||
| 3845 | '@rolldown/binding-android-arm64@1.0.0-rc.17': | 3912 | '@rolldown/binding-android-arm64@1.0.0-rc.17': |
| 3846 | resolution: {integrity: sha512-s70pVGhw4zqGeFnXWvAzJDlvxhlRollagdCCKRgOsgUOH3N1l0LIxf83AtGzmb5SiVM4Hjl5HyarMRfdfj3DaQ==} | 3913 | resolution: {integrity: sha512-s70pVGhw4zqGeFnXWvAzJDlvxhlRollagdCCKRgOsgUOH3N1l0LIxf83AtGzmb5SiVM4Hjl5HyarMRfdfj3DaQ==} |
| 3847 | engines: {node: ^20.19.0 || >=22.12.0} | 3914 | engines: {node: ^20.19.0 || >=22.12.0} |
| ... | @@ -5456,6 +5523,21 @@ packages: | ... | @@ -5456,6 +5523,21 @@ packages: |
| 5456 | expo-widgets: | 5523 | expo-widgets: |
| 5457 | optional: true | 5524 | optional: true |
| 5458 | 5525 | ||
| 5526 | babel-preset-expo@56.0.15: | ||
| 5527 | resolution: {integrity: sha512-0MqbQoM6nBUbKvgu2xJ4VixZnUTGTq3HB2WwvOikdO4CiPxbQ+wGA25fOoHHSni5iEFW39wy6y1ookTWlq3wVw==} | ||
| 5528 | peerDependencies: | ||
| 5529 | '@babel/runtime': ^7.20.0 | ||
| 5530 | expo: '*' | ||
| 5531 | expo-widgets: ^56.0.18 | ||
| 5532 | react-refresh: '>=0.14.0 <1.0.0' | ||
| 5533 | peerDependenciesMeta: | ||
| 5534 | '@babel/runtime': | ||
| 5535 | optional: true | ||
| 5536 | expo: | ||
| 5537 | optional: true | ||
| 5538 | expo-widgets: | ||
| 5539 | optional: true | ||
| 5540 | |||
| 5459 | badgin@1.2.3: | 5541 | badgin@1.2.3: |
| 5460 | resolution: {integrity: sha512-NQGA7LcfCpSzIbGRbkgjgdWkjy7HI+Th5VLxTJfW5EeaAf3fnS+xWQaQOCYiny+q6QSvxqoSO04vCx+4u++EJw==} | 5542 | resolution: {integrity: sha512-NQGA7LcfCpSzIbGRbkgjgdWkjy7HI+Th5VLxTJfW5EeaAf3fnS+xWQaQOCYiny+q6QSvxqoSO04vCx+4u++EJw==} |
| 5461 | 5543 | ||
| ... | @@ -6384,26 +6466,26 @@ packages: | ... | @@ -6384,26 +6466,26 @@ packages: |
| 6384 | peerDependencies: | 6466 | peerDependencies: |
| 6385 | expo: '*' | 6467 | expo: '*' |
| 6386 | 6468 | ||
| 6387 | expo-asset@56.0.15: | 6469 | expo-asset@56.0.17: |
| 6388 | resolution: {integrity: sha512-BHGi2IAOPQTcOelkUdcz1WIknfCTRjkcpYHX1azjMwgYenrVC+J5qcqJGaC8eUOWLCRtkRJWGnmFQRYtLU1nUQ==} | 6470 | resolution: {integrity: sha512-GFN5j+8SPkyv0nfsiFHewmdB/D0tL237TsBE/gSfFOFy/J3a52py7IulcSqkA3sQE/u/UlD5BmvP5ssS4//nUg==} |
| 6389 | peerDependencies: | 6471 | peerDependencies: |
| 6390 | expo: '*' | 6472 | expo: '*' |
| 6391 | react: '*' | 6473 | react: '*' |
| 6392 | react-native: '*' | 6474 | react-native: '*' |
| 6393 | 6475 | ||
| 6394 | expo-auth-session@56.0.13: | 6476 | expo-auth-session@56.0.14: |
| 6395 | resolution: {integrity: sha512-LR8Suq8BHKRFBUcAKTMmZufCcDcr0sQa8rIYit1r7kshrqAy9glIUU4aqHt8tflW/ISN0x1vU+HU8AQaackM0A==} | 6477 | resolution: {integrity: sha512-b6URDBKXVWBjHwypnbCPW6A3PrwYyFqzLXtTrrpTGpmDlsxk7xuz6wIA77sBNziz3hMxt11Nu70iY0fJZYT4jA==} |
| 6396 | peerDependencies: | 6478 | peerDependencies: |
| 6397 | react: '*' | 6479 | react: '*' |
| 6398 | react-native: '*' | 6480 | react-native: '*' |
| 6399 | 6481 | ||
| 6400 | expo-build-properties@56.0.16: | 6482 | expo-build-properties@56.0.19: |
| 6401 | resolution: {integrity: sha512-C3avazYP2fR8efJBBmhx8yITjIRDaIe3ULPk0YfACP61QfnWC9u3LxaDNNaiIvYfZ+CLne30W+nS5F6pdgO/8g==} | 6483 | resolution: {integrity: sha512-InoviXcxWosNp4cC7L3SWoiY99Xr2HdgN+LYHb6mUm/BBVxy1mIMrZR+3PJ2gwDZzW6EJNDz8ioASWGHBTmzpA==} |
| 6402 | peerDependencies: | 6484 | peerDependencies: |
| 6403 | expo: '*' | 6485 | expo: '*' |
| 6404 | 6486 | ||
| 6405 | expo-camera@56.0.7: | 6487 | expo-camera@56.0.8: |
| 6406 | resolution: {integrity: sha512-c8z+UheidFintQyP9XLEDP43aK4PS/o9+TFLW0zEOjdqkYCBgoWq6Mw/Ps62kjBeftFY7xrp5ZLITbenNvbTaw==} | 6488 | resolution: {integrity: sha512-UDOpUUMisFRmCv1XQV1MJCKGAH2CsIC1Rs6P9Bbc6JLVmbxEKAd5dK68y6cScOdWURxVfJ0PRcjYnSuc8ayyIQ==} |
| 6407 | peerDependencies: | 6489 | peerDependencies: |
| 6408 | expo: '*' | 6490 | expo: '*' |
| 6409 | react: '*' | 6491 | react: '*' |
| ... | @@ -6413,15 +6495,15 @@ packages: | ... | @@ -6413,15 +6495,15 @@ packages: |
| 6413 | react-native-web: | 6495 | react-native-web: |
| 6414 | optional: true | 6496 | optional: true |
| 6415 | 6497 | ||
| 6416 | expo-clipboard@56.0.3: | 6498 | expo-clipboard@56.0.4: |
| 6417 | resolution: {integrity: sha512-8mCdhmAomm0yBIonJFjAhKUXvSkc2avdNh4+rBwoe7DSWF2AC4w3uy+pa419rvVFbTyVxOBmh83UHAbUwD6qAg==} | 6499 | resolution: {integrity: sha512-qb4DYlkiowHYHaUYVT2FN9nk/nI1xShXOUYsI7J9dVpQCOHcGFjCBPX1VAvEW4Ye4/Aagd6IuhOVAq/+scBOiA==} |
| 6418 | peerDependencies: | 6500 | peerDependencies: |
| 6419 | expo: '*' | 6501 | expo: '*' |
| 6420 | react: '*' | 6502 | react: '*' |
| 6421 | react-native: '*' | 6503 | react-native: '*' |
| 6422 | 6504 | ||
| 6423 | expo-constants@56.0.16: | 6505 | expo-constants@56.0.18: |
| 6424 | resolution: {integrity: sha512-6tsiN+gmTUPp/atyA+uY9Tg8VOdXdmb4s/3TVGolfn6A/oCAraw1pcPZX5XllyD+xUguxB6eBSFAT8494hZVMA==} | 6506 | resolution: {integrity: sha512-8AMtbDGl/WVPnWlmbpGmvcdnNCy9E4PFnwdVwj600vljkMDPSxcAcjw8GVXEPk3PpZ+ngTqsrkltWyj0UKYAxw==} |
| 6425 | peerDependencies: | 6507 | peerDependencies: |
| 6426 | expo: '*' | 6508 | expo: '*' |
| 6427 | react-native: '*' | 6509 | react-native: '*' |
| ... | @@ -6431,13 +6513,13 @@ packages: | ... | @@ -6431,13 +6513,13 @@ packages: |
| 6431 | peerDependencies: | 6513 | peerDependencies: |
| 6432 | expo: '*' | 6514 | expo: '*' |
| 6433 | 6515 | ||
| 6434 | expo-dev-client@56.0.18: | 6516 | expo-dev-client@56.0.20: |
| 6435 | resolution: {integrity: sha512-pTfDcYTOvrs4vCgAaM+vP2OEO93oGkczgGpTAzCY7ZTIvtPhpekJURHBxfOnKvfn97IF3Hk+8J9tMozsNDj0Gw==} | 6517 | resolution: {integrity: sha512-KebW4r8HhIiRrPzs6ZqVhp/so8buyglAO1h4No0Ibr5C2XRnlIoGWCN4zC6rW7IsI3iKUXcofLAQV9OjoxjiwQ==} |
| 6436 | peerDependencies: | 6518 | peerDependencies: |
| 6437 | expo: '*' | 6519 | expo: '*' |
| 6438 | 6520 | ||
| 6439 | expo-dev-launcher@56.0.18: | 6521 | expo-dev-launcher@56.0.20: |
| 6440 | resolution: {integrity: sha512-7acFJlkAbp3cMz7Uy787todMR/3A/Row2EOPD21RRoetvzJe4DTm9s7RwJ8PDtyNyued9rooD4+Q6rD8ijpTgw==} | 6522 | resolution: {integrity: sha512-cTuC3GkPl9CTwO3CKnVmEm9qoQ0WairhwvTh6qMlg+zr/QU/tdiU++uDBX67hf9+FuxQOkWGp5khFNosT+0cIg==} |
| 6441 | peerDependencies: | 6523 | peerDependencies: |
| 6442 | expo: '*' | 6524 | expo: '*' |
| 6443 | react-native: '*' | 6525 | react-native: '*' |
| ... | @@ -6447,8 +6529,8 @@ packages: | ... | @@ -6447,8 +6529,8 @@ packages: |
| 6447 | peerDependencies: | 6529 | peerDependencies: |
| 6448 | expo: '*' | 6530 | expo: '*' |
| 6449 | 6531 | ||
| 6450 | expo-dev-menu@56.0.16: | 6532 | expo-dev-menu@56.0.17: |
| 6451 | resolution: {integrity: sha512-aVgoe+YGhrQnpwiB5BRI7G+uQnGHMUij32bBnEVdc6eJrVZCStxQlV9NeFbbXxrDhLJt6OSqbCHbLR+XToWUUA==} | 6533 | resolution: {integrity: sha512-OofRkOOZnaDriSav3JDN4NP2lsLt2eOa/Ryptr5nMD62SwnFyK4R6n6PkPVaDU3LSsZqndAJHmN6inS+oziayQ==} |
| 6452 | peerDependencies: | 6534 | peerDependencies: |
| 6453 | expo: '*' | 6535 | expo: '*' |
| 6454 | react-native: '*' | 6536 | react-native: '*' |
| ... | @@ -6456,14 +6538,14 @@ packages: | ... | @@ -6456,14 +6538,14 @@ packages: |
| 6456 | expo-eas-client@56.0.1: | 6538 | expo-eas-client@56.0.1: |
| 6457 | resolution: {integrity: sha512-r8h0ZIExacCrSRgY+ARfhMvFqosLHLJt1L7jyhvabfr1DN/ZDKDsYbovss2tzkpEUZGxZ3BPcB5epCwUsBBdOA==} | 6539 | resolution: {integrity: sha512-r8h0ZIExacCrSRgY+ARfhMvFqosLHLJt1L7jyhvabfr1DN/ZDKDsYbovss2tzkpEUZGxZ3BPcB5epCwUsBBdOA==} |
| 6458 | 6540 | ||
| 6459 | expo-file-system@56.0.7: | 6541 | expo-file-system@56.0.8: |
| 6460 | resolution: {integrity: sha512-dcKzo8ShPloM7jgfnMcJStgQebhP8owVjCkNI/aX6NMFV1CYB8bxKGMdnzJ3mXk5nfaiW+F/lSKr2UIJ02WAUA==} | 6542 | resolution: {integrity: sha512-NrH41/8snGIBSbYicwVLB4txPdgCATd7ZYhMAGS3YJZ9GbnduhlAoV4/YCbGayjrbpE9bJb/6wegPL/zmvRMnQ==} |
| 6461 | peerDependencies: | 6543 | peerDependencies: |
| 6462 | expo: '*' | 6544 | expo: '*' |
| 6463 | react-native: '*' | 6545 | react-native: '*' |
| 6464 | 6546 | ||
| 6465 | expo-font@56.0.5: | 6547 | expo-font@56.0.7: |
| 6466 | resolution: {integrity: sha512-WLoDu9hlEgPRKXJRR01HFLJ6Z2tFcORX/WFPRYBndmYc5kjQrFGH/j4BRaF3aBRPyYEAUXiUJybNLXkKCwEXQw==} | 6548 | resolution: {integrity: sha512-hpU/vRwPzsby9lPGkA4blDqLIIXYzoWnCZHr6PxvcWbY/uPObAiyhh6q+e0WYsB65SthK+PLH95jEnVag7fwEg==} |
| 6467 | peerDependencies: | 6549 | peerDependencies: |
| 6468 | expo: '*' | 6550 | expo: '*' |
| 6469 | react: '*' | 6551 | react: '*' |
| ... | @@ -6486,8 +6568,8 @@ packages: | ... | @@ -6486,8 +6568,8 @@ packages: |
| 6486 | peerDependencies: | 6568 | peerDependencies: |
| 6487 | expo: '*' | 6569 | expo: '*' |
| 6488 | 6570 | ||
| 6489 | expo-image-picker@56.0.15: | 6571 | expo-image-picker@56.0.18: |
| 6490 | resolution: {integrity: sha512-7zmrUcqiNKwGGGL114gC+1aaquhmMbaXCNSHjUcoINdBzvIEVMJDm+DNCxkpftEYeb17X3shqInI4lCgJNVO5g==} | 6572 | resolution: {integrity: sha512-sCjQ8M27bhGUv2vUavIE+uWdYo79b2D7Q5h9B66BSDZ+Rd8YyLVSf7vYGfIzQ7nMVoENZ6c4xo/JiDkEeQ9iTg==} |
| 6491 | peerDependencies: | 6573 | peerDependencies: |
| 6492 | expo: '*' | 6574 | expo: '*' |
| 6493 | 6575 | ||
| ... | @@ -6500,8 +6582,8 @@ packages: | ... | @@ -6500,8 +6582,8 @@ packages: |
| 6500 | expo: '*' | 6582 | expo: '*' |
| 6501 | react: '*' | 6583 | react: '*' |
| 6502 | 6584 | ||
| 6503 | expo-linking@56.0.13: | 6585 | expo-linking@56.0.14: |
| 6504 | resolution: {integrity: sha512-38YrpTh6xdiDxmYSDIUffDqev1hIcEggw2fZ3IZhNp2DVLF1xvqsbO6hJD1fuBKN8P34B3Ggc9Yy26fkqdfCOA==} | 6586 | resolution: {integrity: sha512-IvVQHWC+Cj4fK5qD3iEVYqpU2a4rLW0IpAAlGJ4MH+H1fyZiHh3eN6qg2WmoclOEPfYATSuEa+dQT6wfgVpXlQ==} |
| 6505 | peerDependencies: | 6587 | peerDependencies: |
| 6506 | react: '*' | 6588 | react: '*' |
| 6507 | react-native: '*' | 6589 | react-native: '*' |
| ... | @@ -6511,12 +6593,12 @@ packages: | ... | @@ -6511,12 +6593,12 @@ packages: |
| 6511 | peerDependencies: | 6593 | peerDependencies: |
| 6512 | expo: '*' | 6594 | expo: '*' |
| 6513 | 6595 | ||
| 6514 | expo-modules-autolinking@56.0.14: | 6596 | expo-modules-autolinking@56.0.16: |
| 6515 | resolution: {integrity: sha512-9ugtZkheNPYDkW4DZopY1rH2BCbUICaafUEPxRgbLDR5UNRF5K3cdHMIMEt8pxZPq2+eX4wCm+6pbSvdY/DPHg==} | 6597 | resolution: {integrity: sha512-9JnL4N46P8ubDpDIfWolDn7nxU2j1rY67xY/dNVuyH0m+HG+r/JI16VYtjIf4COpZtEuFo4D3h3MBeFzGucMnw==} |
| 6516 | hasBin: true | 6598 | hasBin: true |
| 6517 | 6599 | ||
| 6518 | expo-modules-core@56.0.14: | 6600 | expo-modules-core@56.0.17: |
| 6519 | resolution: {integrity: sha512-dl1TlYRm1k7xk9QeAyDoMfFE2p6rNyzHUcH5ArcGwUzO8YKku+Z2tQ8+kG7zLe3OhfMoJcFR/czrFy7vGSVI6w==} | 6601 | resolution: {integrity: sha512-5J8whnT7Ccp+BrFClLmpF76omBqn95VZExroTm01Dgjm4vpty1Rb7U3we+ZUceNHtRd07Lw30u7FNfDgIhEbRQ==} |
| 6520 | peerDependencies: | 6602 | peerDependencies: |
| 6521 | react: '*' | 6603 | react: '*' |
| 6522 | react-native: '*' | 6604 | react-native: '*' |
| ... | @@ -6525,8 +6607,8 @@ packages: | ... | @@ -6525,8 +6607,8 @@ packages: |
| 6525 | react-native-worklets: | 6607 | react-native-worklets: |
| 6526 | optional: true | 6608 | optional: true |
| 6527 | 6609 | ||
| 6528 | expo-modules-jsi@56.0.7: | 6610 | expo-modules-jsi@56.0.10: |
| 6529 | resolution: {integrity: sha512-iBAj4Xeh/8HT201VVxFlmf+VBfmtQV1ZUoJdLQQENm0+j9gnD2QswZLJyNo3CmNNXl46esJeLR5lpGpYZts/zA==} | 6611 | resolution: {integrity: sha512-fHZcFpYO/o62GYa6fJyAQJZcAShzhoN0iMMDzbr7vD3ewET6e1vAlTonbEakN9F0VHEgBFJ4NREy87uwVcpCuA==} |
| 6530 | peerDependencies: | 6612 | peerDependencies: |
| 6531 | react-native: '*' | 6613 | react-native: '*' |
| 6532 | 6614 | ||
| ... | @@ -6536,8 +6618,8 @@ packages: | ... | @@ -6536,8 +6618,8 @@ packages: |
| 6536 | expo: '*' | 6618 | expo: '*' |
| 6537 | react: '*' | 6619 | react: '*' |
| 6538 | 6620 | ||
| 6539 | expo-notifications@56.0.15: | 6621 | expo-notifications@56.0.18: |
| 6540 | resolution: {integrity: sha512-F+OasAePiVnHaPNKI9JAYV8fg8bdBwo7Mh9R3ydBp8S21fRQyxKOSgJvj8fX/HoPFFIC6V2B+y1LJbG5Ovh/Fg==} | 6622 | resolution: {integrity: sha512-HHnrwyCLC5srFojcHYS2KskbNroy9o2fwPKdyhjrdjjrBu4sNRKm4LepcuZjDy98cZKEm89WIPW8O45vut8Rgw==} |
| 6541 | peerDependencies: | 6623 | peerDependencies: |
| 6542 | expo: '*' | 6624 | expo: '*' |
| 6543 | react: '*' | 6625 | react: '*' |
| ... | @@ -6550,15 +6632,15 @@ packages: | ... | @@ -6550,15 +6632,15 @@ packages: |
| 6550 | react: '*' | 6632 | react: '*' |
| 6551 | react-native: '*' | 6633 | react-native: '*' |
| 6552 | 6634 | ||
| 6553 | expo-router@56.2.8: | 6635 | expo-router@56.2.11: |
| 6554 | resolution: {integrity: sha512-l387I/ddPY/5SS+Rfpp1SrRV9gBKevxtPuZod7igMjR6L674QrxEwGiAILRq6AKCSbrP2I0ufKj7e5xz8JqA4Q==} | 6636 | resolution: {integrity: sha512-08DBTrKv3QanOc9u1JNxSEChW9c/qNFbQ0dO28OLvufWWfdSRkSdHmh365D2FgoZg1qaOzZPCDuL3tM6nGSfkQ==} |
| 6555 | peerDependencies: | 6637 | peerDependencies: |
| 6556 | '@expo/log-box': ^56.0.12 | 6638 | '@expo/log-box': ^56.0.13 |
| 6557 | '@expo/metro-runtime': ^56.0.13 | 6639 | '@expo/metro-runtime': ^56.0.15 |
| 6558 | '@testing-library/react-native': '>= 13.2.0' | 6640 | '@testing-library/react-native': '>= 13.2.0' |
| 6559 | expo: '*' | 6641 | expo: '*' |
| 6560 | expo-constants: ^56.0.16 | 6642 | expo-constants: ^56.0.18 |
| 6561 | expo-linking: ^56.0.13 | 6643 | expo-linking: ^56.0.14 |
| 6562 | react: '*' | 6644 | react: '*' |
| 6563 | react-dom: '*' | 6645 | react-dom: '*' |
| 6564 | react-native: '*' | 6646 | react-native: '*' |
| ... | @@ -6587,8 +6669,8 @@ packages: | ... | @@ -6587,8 +6669,8 @@ packages: |
| 6587 | peerDependencies: | 6669 | peerDependencies: |
| 6588 | expo: '*' | 6670 | expo: '*' |
| 6589 | 6671 | ||
| 6590 | expo-server@56.0.4: | 6672 | expo-server@56.0.5: |
| 6591 | resolution: {integrity: sha512-4dJ57KuAwDl7eQGD6aG9kTzBIftWAfHH1+6Zxy7NcPCBrKYis3/H5enGUz1asH8HHhONXfJ5BdJqfEWAEAgWxA==} | 6673 | resolution: {integrity: sha512-SmM2p2g3Jrktpiazcst+OxhjSzOHXKAY4BPURHYHXvApzzoybMmrNF4IEZ8DKZ145BhSe4ydAmlEFCRTsdtgUQ==} |
| 6592 | engines: {node: '>=20.16.0'} | 6674 | engines: {node: '>=20.16.0'} |
| 6593 | 6675 | ||
| 6594 | expo-splash-screen@56.0.10: | 6676 | expo-splash-screen@56.0.10: |
| ... | @@ -6599,8 +6681,8 @@ packages: | ... | @@ -6599,8 +6681,8 @@ packages: |
| 6599 | expo-structured-headers@56.0.0: | 6681 | expo-structured-headers@56.0.0: |
| 6600 | resolution: {integrity: sha512-Yv4x+SQxNnMQm4nu8NFfzx197YaDhdYH2N0u7tGErwWTmH9Tm1SAhqo7bLbBWLC9kf7W+kdzTshLU9rTiCXWGw==} | 6682 | resolution: {integrity: sha512-Yv4x+SQxNnMQm4nu8NFfzx197YaDhdYH2N0u7tGErwWTmH9Tm1SAhqo7bLbBWLC9kf7W+kdzTshLU9rTiCXWGw==} |
| 6601 | 6683 | ||
| 6602 | expo-symbols@56.0.5: | 6684 | expo-symbols@56.0.6: |
| 6603 | resolution: {integrity: sha512-RIukH0Xo80C7RU8qreipL2SPy2Py+Km8JFPbCmbPQpHkM3DW9Znlmg6VfhzbtUOlO5EuNSF0lAJ3l2VJi6qYrw==} | 6685 | resolution: {integrity: sha512-BrA81DjcNafdj7gXVhdrExb9LtUiSVyOf/NavyMmDAHgHMY1GqeR5cnn1PSAZeYKnSgQhee/H89XUpAxtog5hg==} |
| 6604 | peerDependencies: | 6686 | peerDependencies: |
| 6605 | expo: '*' | 6687 | expo: '*' |
| 6606 | expo-font: '*' | 6688 | expo-font: '*' |
| ... | @@ -6612,8 +6694,8 @@ packages: | ... | @@ -6612,8 +6694,8 @@ packages: |
| 6612 | peerDependencies: | 6694 | peerDependencies: |
| 6613 | expo: '*' | 6695 | expo: '*' |
| 6614 | 6696 | ||
| 6615 | expo-updates@56.0.17: | 6697 | expo-updates@56.0.19: |
| 6616 | resolution: {integrity: sha512-4005IRQQ7Vpwb01X3NSG0SwY61HTJzT2pZ0OrkeZ6guQE1WKiRfUKisekRgYXTI8sPbxSHUc9aOpIx6yC49XZQ==} | 6698 | resolution: {integrity: sha512-tTSPYO5h8wDA6a+wQ2v/SRdnOdz29x0npGHCv+4Ev31Fz5r05Ii1Wgfh3BlTXNz8mikMReDsZCf6YN71YeQKpw==} |
| 6617 | hasBin: true | 6699 | hasBin: true |
| 6618 | peerDependencies: | 6700 | peerDependencies: |
| 6619 | expo: '*' | 6701 | expo: '*' |
| ... | @@ -6630,15 +6712,15 @@ packages: | ... | @@ -6630,15 +6712,15 @@ packages: |
| 6630 | expo: '*' | 6712 | expo: '*' |
| 6631 | react-native: '*' | 6713 | react-native: '*' |
| 6632 | 6714 | ||
| 6633 | expo-widgets@56.0.16: | 6715 | expo-widgets@56.0.19: |
| 6634 | resolution: {integrity: sha512-EmWVvskad2XlgCk6j9wz/OqSAeaGxjD6x6Wji99TVjjFd+sTD6W3YJbz3QW/BKTVRcll95m6XjJDCa6B9t9f9A==} | 6716 | resolution: {integrity: sha512-D2RWectoEalVdGwXyE2LX3L9T6q6jSKh8jjvk1K3JSE1qOcCVZk+TJtvBUveTq8OoLblkeFMXqQ2fHG+kg655w==} |
| 6635 | peerDependencies: | 6717 | peerDependencies: |
| 6636 | expo: '*' | 6718 | expo: '*' |
| 6637 | react: '*' | 6719 | react: '*' |
| 6638 | react-native: '*' | 6720 | react-native: '*' |
| 6639 | 6721 | ||
| 6640 | expo@56.0.8: | 6722 | expo@56.0.12: |
| 6641 | resolution: {integrity: sha512-GzQi5450yrCk5JRSlm0epsmtURBErh0wS77uWLZImFdnPICuX912MaRWooR+Q1Sw/7aQjp9F+KXH+dvrqGxpeQ==} | 6723 | resolution: {integrity: sha512-FxgdI/Yqva6iJOThZIHfvxlKPxs4EC4uScUnEswwSArR/Fj9k430O13R590LcOQTsdNsjIs+GBHwjfoAY6vmAQ==} |
| 6642 | hasBin: true | 6724 | hasBin: true |
| 6643 | peerDependencies: | 6725 | peerDependencies: |
| 6644 | '@expo/dom-webview': '*' | 6726 | '@expo/dom-webview': '*' |
| ... | @@ -8737,8 +8819,8 @@ packages: | ... | @@ -8737,8 +8819,8 @@ packages: |
| 8737 | react: '*' | 8819 | react: '*' |
| 8738 | react-native: '*' | 8820 | react-native: '*' |
| 8739 | 8821 | ||
| 8740 | react-native-keyboard-controller@1.21.7: | 8822 | react-native-keyboard-controller@1.21.6: |
| 8741 | resolution: {integrity: sha512-gs+8nI8HYnRdDt4NWbk1iVuS6kDLf2taJvp+h/TjM1FBdtnQmlYLJ6buNiUqSnkIH4OFEAxdNr3/GOOYdLfkUQ==} | 8823 | resolution: {integrity: sha512-nAXCmar/W8Gn4iQV7O5fAVuTh57JszCsqTS+cfR95WFOLR/AfbwfPz/+sWyz/q2SOIe2VpyQzq6hzYiwErhqqw==} |
| 8742 | peerDependencies: | 8824 | peerDependencies: |
| 8743 | react: '*' | 8825 | react: '*' |
| 8744 | react-native: '*' | 8826 | react-native: '*' |
| ... | @@ -9306,6 +9388,12 @@ packages: | ... | @@ -9306,6 +9388,12 @@ packages: |
| 9306 | standard-as-callback@2.1.0: | 9388 | standard-as-callback@2.1.0: |
| 9307 | resolution: {integrity: sha512-qoRRSyROncaz1z0mvYqIE4lCd9p2R90i6GxW3uZv5ucSu8tU7B5HXUP1gG8pVZsYNVaXjk8ClXHPttLyxAL48A==} | 9389 | resolution: {integrity: sha512-qoRRSyROncaz1z0mvYqIE4lCd9p2R90i6GxW3uZv5ucSu8tU7B5HXUP1gG8pVZsYNVaXjk8ClXHPttLyxAL48A==} |
| 9308 | 9390 | ||
| 9391 | standard-navigation@0.0.5: | ||
| 9392 | resolution: {integrity: sha512-YAmzwAiiQVocZxO/VGPFiQHcu5pKiz09QIGC0MK6aRMoa3E0QkoTQgcqJr7ZZ3OMiNhu4DkaGElFI5htjOIDbw==} | ||
| 9393 | |||
| 9394 | standard-navigation@0.0.7: | ||
| 9395 | resolution: {integrity: sha512-NCGLCNyuXrFOkGHxdNZFnpsehGtiq1oXbPhKl7ZuxFO5J//H2evqqOchmD4YwEUJnkjO4kH9Xp4hQX6hdAYCKQ==} | ||
| 9396 | |||
| 9309 | standardwebhooks@1.0.0: | 9397 | standardwebhooks@1.0.0: |
| 9310 | resolution: {integrity: sha512-BbHGOQK9olHPMvQNHWul6MYlrRTAOKn03rOe4A8O3CLWhNf4YHBqq2HJKKC+sfqpxiBY52pNeesD6jIiLDz8jg==} | 9398 | resolution: {integrity: sha512-BbHGOQK9olHPMvQNHWul6MYlrRTAOKn03rOe4A8O3CLWhNf4YHBqq2HJKKC+sfqpxiBY52pNeesD6jIiLDz8jg==} |
| 9311 | 9399 | ||
| ... | @@ -10248,7 +10336,8 @@ snapshots: | ... | @@ -10248,7 +10336,8 @@ snapshots: |
| 10248 | 10336 | ||
| 10249 | 7zip-bin@5.2.0: {} | 10337 | 7zip-bin@5.2.0: {} |
| 10250 | 10338 | ||
| 10251 | '@adobe/css-tools@4.5.0': {} | 10339 | '@adobe/css-tools@4.5.0': |
| 10340 | optional: true | ||
| 10252 | 10341 | ||
| 10253 | '@alcalzone/ansi-tokenize@0.2.5': | 10342 | '@alcalzone/ansi-tokenize@0.2.5': |
| 10254 | dependencies: | 10343 | dependencies: |
| ... | @@ -11216,10 +11305,10 @@ snapshots: | ... | @@ -11216,10 +11305,10 @@ snapshots: |
| 11216 | '@bruits/satteri-win32-x64-msvc@0.9.3': | 11305 | '@bruits/satteri-win32-x64-msvc@0.9.3': |
| 11217 | optional: true | 11306 | optional: true |
| 11218 | 11307 | ||
| 11219 | '@callstack/liquid-glass@0.7.1(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)': | 11308 | '@callstack/liquid-glass@0.7.1(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)': |
| 11220 | dependencies: | 11309 | dependencies: |
| 11221 | react: 19.2.3 | 11310 | react: 19.2.3 |
| 11222 | react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) | 11311 | react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) |
| 11223 | 11312 | ||
| 11224 | '@capsizecss/unpack@4.0.1': | 11313 | '@capsizecss/unpack@4.0.1': |
| 11225 | dependencies: | 11314 | dependencies: |
| ... | @@ -11248,18 +11337,18 @@ snapshots: | ... | @@ -11248,18 +11337,18 @@ snapshots: |
| 11248 | fast-wrap-ansi: 0.2.2 | 11337 | fast-wrap-ansi: 0.2.2 |
| 11249 | sisteransi: 1.0.5 | 11338 | sisteransi: 1.0.5 |
| 11250 | 11339 | ||
| 11251 | '@clerk/backend@3.8.3-snapshot.v20260622234151(react-dom@19.2.6(react@19.2.6))(react@19.2.6)': | 11340 | '@clerk/backend@3.8.4(react-dom@19.2.6(react@19.2.6))(react@19.2.6)': |
| 11252 | dependencies: | 11341 | dependencies: |
| 11253 | '@clerk/shared': 4.21.0-snapshot.v20260622234151(react-dom@19.2.6(react@19.2.6))(react@19.2.6) | 11342 | '@clerk/shared': 4.22.0(react-dom@19.2.6(react@19.2.6))(react@19.2.6) |
| 11254 | standardwebhooks: 1.0.0 | 11343 | standardwebhooks: 1.0.0 |
| 11255 | tslib: 2.8.1 | 11344 | tslib: 2.8.1 |
| 11256 | transitivePeerDependencies: | 11345 | transitivePeerDependencies: |
| 11257 | - react | 11346 | - react |
| 11258 | - react-dom | 11347 | - react-dom |
| 11259 | 11348 | ||
| 11260 | '@clerk/clerk-js@6.21.0-snapshot.v20260622234151(react-dom@19.2.3(react@19.2.3))(react@19.2.3)': | 11349 | '@clerk/clerk-js@6.22.0(react-dom@19.2.3(react@19.2.3))(react@19.2.3)': |
| 11261 | dependencies: | 11350 | dependencies: |
| 11262 | '@clerk/shared': 4.21.0-snapshot.v20260622234151(react-dom@19.2.3(react@19.2.3))(react@19.2.3) | 11351 | '@clerk/shared': 4.22.0(react-dom@19.2.3(react@19.2.3))(react@19.2.3) |
| 11263 | '@stripe/stripe-js': 5.6.0 | 11352 | '@stripe/stripe-js': 5.6.0 |
| 11264 | '@swc/helpers': 0.5.21 | 11353 | '@swc/helpers': 0.5.21 |
| 11265 | '@tanstack/query-core': 5.100.14 | 11354 | '@tanstack/query-core': 5.100.14 |
| ... | @@ -11274,9 +11363,9 @@ snapshots: | ... | @@ -11274,9 +11363,9 @@ snapshots: |
| 11274 | - react | 11363 | - react |
| 11275 | - react-dom | 11364 | - react-dom |
| 11276 | 11365 | ||
| 11277 | '@clerk/clerk-js@6.21.0-snapshot.v20260622234151(react-dom@19.2.6(react@19.2.6))(react@19.2.6)': | 11366 | '@clerk/clerk-js@6.22.0(react-dom@19.2.6(react@19.2.6))(react@19.2.6)': |
| 11278 | dependencies: | 11367 | dependencies: |
| 11279 | '@clerk/shared': 4.21.0-snapshot.v20260622234151(react-dom@19.2.6(react@19.2.6))(react@19.2.6) | 11368 | '@clerk/shared': 4.22.0(react-dom@19.2.6(react@19.2.6))(react@19.2.6) |
| 11280 | '@stripe/stripe-js': 5.6.0 | 11369 | '@stripe/stripe-js': 5.6.0 |
| 11281 | '@swc/helpers': 0.5.21 | 11370 | '@swc/helpers': 0.5.21 |
| 11282 | '@tanstack/query-core': 5.100.14 | 11371 | '@tanstack/query-core': 5.100.14 |
| ... | @@ -11291,72 +11380,72 @@ snapshots: | ... | @@ -11291,72 +11380,72 @@ snapshots: |
| 11291 | - react | 11380 | - react |
| 11292 | - react-dom | 11381 | - react-dom |
| 11293 | 11382 | ||
| 11294 | '@clerk/electron-passkeys-darwin-arm64@0.0.2-snapshot.v20260622234151': | 11383 | '@clerk/electron-passkeys-darwin-arm64@0.0.3': |
| 11295 | optional: true | 11384 | optional: true |
| 11296 | 11385 | ||
| 11297 | '@clerk/electron-passkeys-darwin-x64@0.0.2-snapshot.v20260622234151': | 11386 | '@clerk/electron-passkeys-darwin-x64@0.0.3': |
| 11298 | optional: true | 11387 | optional: true |
| 11299 | 11388 | ||
| 11300 | '@clerk/electron-passkeys-win32-arm64-msvc@0.0.2-snapshot.v20260622234151': | 11389 | '@clerk/electron-passkeys-win32-arm64-msvc@0.0.3': |
| 11301 | optional: true | 11390 | optional: true |
| 11302 | 11391 | ||
| 11303 | '@clerk/electron-passkeys-win32-x64-msvc@0.0.2-snapshot.v20260622234151': | 11392 | '@clerk/electron-passkeys-win32-x64-msvc@0.0.3': |
| 11304 | optional: true | 11393 | optional: true |
| 11305 | 11394 | ||
| 11306 | '@clerk/electron-passkeys@0.0.2-snapshot.v20260622234151': | 11395 | '@clerk/electron-passkeys@0.0.3': |
| 11307 | optionalDependencies: | 11396 | optionalDependencies: |
| 11308 | '@clerk/electron-passkeys-darwin-arm64': 0.0.2-snapshot.v20260622234151 | 11397 | '@clerk/electron-passkeys-darwin-arm64': 0.0.3 |
| 11309 | '@clerk/electron-passkeys-darwin-x64': 0.0.2-snapshot.v20260622234151 | 11398 | '@clerk/electron-passkeys-darwin-x64': 0.0.3 |
| 11310 | '@clerk/electron-passkeys-win32-arm64-msvc': 0.0.2-snapshot.v20260622234151 | 11399 | '@clerk/electron-passkeys-win32-arm64-msvc': 0.0.3 |
| 11311 | '@clerk/electron-passkeys-win32-x64-msvc': 0.0.2-snapshot.v20260622234151 | 11400 | '@clerk/electron-passkeys-win32-x64-msvc': 0.0.3 |
| 11312 | 11401 | ||
| 11313 | '@clerk/electron@0.0.2-snapshot.v20260622234151(@clerk/electron-passkeys@0.0.2-snapshot.v20260622234151)(electron-store@8.2.0)(electron@41.5.0)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)': | 11402 | '@clerk/electron@0.0.5(@clerk/electron-passkeys@0.0.3)(electron-store@8.2.0)(electron@41.5.0)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)': |
| 11314 | dependencies: | 11403 | dependencies: |
| 11315 | '@clerk/clerk-js': 6.21.0-snapshot.v20260622234151(react-dom@19.2.6(react@19.2.6))(react@19.2.6) | 11404 | '@clerk/clerk-js': 6.22.0(react-dom@19.2.6(react@19.2.6))(react@19.2.6) |
| 11316 | '@clerk/react': 6.11.0-snapshot.v20260622234151(react-dom@19.2.6(react@19.2.6))(react@19.2.6) | 11405 | '@clerk/react': 6.11.1(react-dom@19.2.6(react@19.2.6))(react@19.2.6) |
| 11317 | '@clerk/shared': 4.21.0-snapshot.v20260622234151(react-dom@19.2.6(react@19.2.6))(react@19.2.6) | 11406 | '@clerk/shared': 4.22.0(react-dom@19.2.6(react@19.2.6))(react@19.2.6) |
| 11318 | electron: 41.5.0 | 11407 | electron: 41.5.0 |
| 11319 | react: 19.2.6 | 11408 | react: 19.2.6 |
| 11320 | tslib: 2.8.1 | 11409 | tslib: 2.8.1 |
| 11321 | optionalDependencies: | 11410 | optionalDependencies: |
| 11322 | '@clerk/electron-passkeys': 0.0.2-snapshot.v20260622234151 | 11411 | '@clerk/electron-passkeys': 0.0.3 |
| 11323 | electron-store: 8.2.0 | 11412 | electron-store: 8.2.0 |
| 11324 | react-dom: 19.2.6(react@19.2.6) | 11413 | react-dom: 19.2.6(react@19.2.6) |
| 11325 | 11414 | ||
| 11326 | '@clerk/expo@3.5.3-snapshot.v20260622234151(expo-auth-session@56.0.13(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(expo-constants@56.0.16)(expo-crypto@56.0.4(expo@56.0.8))(expo-secure-store@56.0.4(expo@56.0.8))(expo-web-browser@56.0.5(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6)))(expo@56.0.8)(react-dom@19.2.3(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)': | 11415 | '@clerk/expo@3.6.2(expo-auth-session@56.0.14(expo@56.0.12)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(expo-constants@56.0.18)(expo-crypto@56.0.4(expo@56.0.12))(expo-secure-store@56.0.4(expo@56.0.12))(expo-web-browser@56.0.5(expo@56.0.12)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6)))(expo@56.0.12)(react-dom@19.2.3(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)': |
| 11327 | dependencies: | 11416 | dependencies: |
| 11328 | '@clerk/clerk-js': 6.21.0-snapshot.v20260622234151(react-dom@19.2.3(react@19.2.3))(react@19.2.3) | 11417 | '@clerk/clerk-js': 6.22.0(react-dom@19.2.3(react@19.2.3))(react@19.2.3) |
| 11329 | '@clerk/react': 6.11.0-snapshot.v20260622234151(react-dom@19.2.3(react@19.2.3))(react@19.2.3) | 11418 | '@clerk/react': 6.11.1(react-dom@19.2.3(react@19.2.3))(react@19.2.3) |
| 11330 | '@clerk/shared': 4.21.0-snapshot.v20260622234151(react-dom@19.2.3(react@19.2.3))(react@19.2.3) | 11419 | '@clerk/shared': 4.22.0(react-dom@19.2.3(react@19.2.3))(react@19.2.3) |
| 11331 | base-64: 1.0.0 | 11420 | base-64: 1.0.0 |
| 11332 | expo: 56.0.8(63f7aade424ad9e7b1154b679fa2a14d) | 11421 | expo: 56.0.12(@babel/core@7.29.7)(@expo/dom-webview@56.0.5)(@expo/metro-runtime@56.0.15)(bufferutil@4.1.0)(expo-router@56.2.11)(expo-widgets@56.0.19)(react-dom@19.2.3(react@19.2.3))(react-native-webview@13.16.1(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(typescript@6.0.3)(utf-8-validate@6.0.6) |
| 11333 | react: 19.2.3 | 11422 | react: 19.2.3 |
| 11334 | react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) | 11423 | react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) |
| 11335 | react-native-url-polyfill: 2.0.0(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6)) | 11424 | react-native-url-polyfill: 2.0.0(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6)) |
| 11336 | tslib: 2.8.1 | 11425 | tslib: 2.8.1 |
| 11337 | optionalDependencies: | 11426 | optionalDependencies: |
| 11338 | expo-auth-session: 56.0.13(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) | 11427 | expo-auth-session: 56.0.14(expo@56.0.12)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) |
| 11339 | expo-constants: 56.0.16(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6)) | 11428 | expo-constants: 56.0.18(expo@56.0.12)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6)) |
| 11340 | expo-crypto: 56.0.4(expo@56.0.8) | 11429 | expo-crypto: 56.0.4(expo@56.0.12) |
| 11341 | expo-secure-store: 56.0.4(expo@56.0.8) | 11430 | expo-secure-store: 56.0.4(expo@56.0.12) |
| 11342 | expo-web-browser: 56.0.5(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6)) | 11431 | expo-web-browser: 56.0.5(expo@56.0.12)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6)) |
| 11343 | react-dom: 19.2.3(react@19.2.3) | 11432 | react-dom: 19.2.3(react@19.2.3) |
| 11344 | 11433 | ||
| 11345 | '@clerk/react@6.11.0-snapshot.v20260622234151(react-dom@19.2.3(react@19.2.3))(react@19.2.3)': | 11434 | '@clerk/react@6.11.1(react-dom@19.2.3(react@19.2.3))(react@19.2.3)': |
| 11346 | dependencies: | 11435 | dependencies: |
| 11347 | '@clerk/shared': 4.21.0-snapshot.v20260622234151(react-dom@19.2.3(react@19.2.3))(react@19.2.3) | 11436 | '@clerk/shared': 4.22.0(react-dom@19.2.3(react@19.2.3))(react@19.2.3) |
| 11348 | react: 19.2.3 | 11437 | react: 19.2.3 |
| 11349 | react-dom: 19.2.3(react@19.2.3) | 11438 | react-dom: 19.2.3(react@19.2.3) |
| 11350 | tslib: 2.8.1 | 11439 | tslib: 2.8.1 |
| 11351 | 11440 | ||
| 11352 | '@clerk/react@6.11.0-snapshot.v20260622234151(react-dom@19.2.6(react@19.2.6))(react@19.2.6)': | 11441 | '@clerk/react@6.11.1(react-dom@19.2.6(react@19.2.6))(react@19.2.6)': |
| 11353 | dependencies: | 11442 | dependencies: |
| 11354 | '@clerk/shared': 4.21.0-snapshot.v20260622234151(react-dom@19.2.6(react@19.2.6))(react@19.2.6) | 11443 | '@clerk/shared': 4.22.0(react-dom@19.2.6(react@19.2.6))(react@19.2.6) |
| 11355 | react: 19.2.6 | 11444 | react: 19.2.6 |
| 11356 | react-dom: 19.2.6(react@19.2.6) | 11445 | react-dom: 19.2.6(react@19.2.6) |
| 11357 | tslib: 2.8.1 | 11446 | tslib: 2.8.1 |
| 11358 | 11447 | ||
| 11359 | '@clerk/shared@4.21.0-snapshot.v20260622234151(react-dom@19.2.3(react@19.2.3))(react@19.2.3)': | 11448 | '@clerk/shared@4.22.0(react-dom@19.2.3(react@19.2.3))(react@19.2.3)': |
| 11360 | dependencies: | 11449 | dependencies: |
| 11361 | '@tanstack/query-core': 5.100.14 | 11450 | '@tanstack/query-core': 5.100.14 |
| 11362 | dequal: 2.0.3 | 11451 | dequal: 2.0.3 |
| ... | @@ -11366,7 +11455,7 @@ snapshots: | ... | @@ -11366,7 +11455,7 @@ snapshots: |
| 11366 | react: 19.2.3 | 11455 | react: 19.2.3 |
| 11367 | react-dom: 19.2.3(react@19.2.3) | 11456 | react-dom: 19.2.3(react@19.2.3) |
| 11368 | 11457 | ||
| 11369 | '@clerk/shared@4.21.0-snapshot.v20260622234151(react-dom@19.2.6(react@19.2.6))(react@19.2.6)': | 11458 | '@clerk/shared@4.22.0(react-dom@19.2.6(react@19.2.6))(react@19.2.6)': |
| 11370 | dependencies: | 11459 | dependencies: |
| 11371 | '@tanstack/query-core': 5.100.14 | 11460 | '@tanstack/query-core': 5.100.14 |
| 11372 | dequal: 2.0.3 | 11461 | dequal: 2.0.3 |
| ... | @@ -11922,29 +12011,29 @@ snapshots: | ... | @@ -11922,29 +12011,29 @@ snapshots: |
| 11922 | 12011 | ||
| 11923 | '@expo-google-fonts/material-symbols@0.4.38': {} | 12012 | '@expo-google-fonts/material-symbols@0.4.38': {} |
| 11924 | 12013 | ||
| 11925 | '@expo/cli@56.1.13(@expo/dom-webview@56.0.5)(@expo/metro-runtime@56.0.13)(bufferutil@4.1.0)(expo-constants@56.0.16)(expo-font@56.0.5)(expo-router@56.2.8)(expo@56.0.8)(react-dom@19.2.3(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(typescript@6.0.3)(utf-8-validate@6.0.6)': | 12014 | '@expo/cli@56.1.16(@expo/dom-webview@56.0.5)(@expo/metro-runtime@56.0.15)(bufferutil@4.1.0)(expo-constants@56.0.18)(expo-font@56.0.7)(expo-router@56.2.11)(expo@56.0.12)(react-dom@19.2.3(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(typescript@6.0.3)(utf-8-validate@6.0.6)': |
| 11926 | dependencies: | 12015 | dependencies: |
| 11927 | '@expo/code-signing-certificates': 0.0.6 | 12016 | '@expo/code-signing-certificates': 0.0.6 |
| 11928 | '@expo/config': 56.0.9(typescript@6.0.3) | 12017 | '@expo/config': 56.0.9(typescript@6.0.3) |
| 11929 | '@expo/config-plugins': 56.0.8(typescript@6.0.3) | 12018 | '@expo/config-plugins': 56.0.9(typescript@6.0.3) |
| 11930 | '@expo/devcert': 1.2.1 | 12019 | '@expo/devcert': 1.2.1 |
| 11931 | '@expo/env': 2.3.0 | 12020 | '@expo/env': 2.3.0 |
| 11932 | '@expo/image-utils': 0.10.1(typescript@6.0.3) | 12021 | '@expo/image-utils': 0.10.1(typescript@6.0.3) |
| 11933 | '@expo/inline-modules': 0.0.10(typescript@6.0.3) | 12022 | '@expo/inline-modules': 0.0.12(typescript@6.0.3) |
| 11934 | '@expo/json-file': 10.2.0 | 12023 | '@expo/json-file': 10.2.0 |
| 11935 | '@expo/log-box': 56.0.12(@expo/dom-webview@56.0.5)(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) | 12024 | '@expo/log-box': 56.0.13(@expo/dom-webview@56.0.5)(expo@56.0.12)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) |
| 11936 | '@expo/metro': 56.0.0(bufferutil@4.1.0)(utf-8-validate@6.0.6) | 12025 | '@expo/metro': 56.0.0(bufferutil@4.1.0)(utf-8-validate@6.0.6) |
| 11937 | '@expo/metro-config': 56.0.13(patch_hash=8cb08b5bb7051ed9d2dbe46a2c293c5a1e17f1bd6ddf30de27909e18c921ff46)(bufferutil@4.1.0)(expo@56.0.8)(typescript@6.0.3)(utf-8-validate@6.0.6) | 12026 | '@expo/metro-config': 56.0.14(patch_hash=8cb08b5bb7051ed9d2dbe46a2c293c5a1e17f1bd6ddf30de27909e18c921ff46)(bufferutil@4.1.0)(expo@56.0.12)(typescript@6.0.3)(utf-8-validate@6.0.6) |
| 11938 | '@expo/metro-file-map': 56.0.3 | 12027 | '@expo/metro-file-map': 56.0.3 |
| 11939 | '@expo/osascript': 2.6.0 | 12028 | '@expo/osascript': 2.6.0 |
| 11940 | '@expo/package-manager': 1.12.1 | 12029 | '@expo/package-manager': 1.12.1 |
| 11941 | '@expo/plist': 0.7.0 | 12030 | '@expo/plist': 0.7.0 |
| 11942 | '@expo/prebuild-config': 56.0.14(typescript@6.0.3) | 12031 | '@expo/prebuild-config': 56.0.16(typescript@6.0.3) |
| 11943 | '@expo/require-utils': 56.1.3(typescript@6.0.3) | 12032 | '@expo/require-utils': 56.1.3(typescript@6.0.3) |
| 11944 | '@expo/router-server': 56.0.12(@expo/metro-runtime@56.0.13)(expo-constants@56.0.16)(expo-font@56.0.5)(expo-router@56.2.8)(expo-server@56.0.4)(expo@56.0.8)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) | 12033 | '@expo/router-server': 56.0.14(@expo/metro-runtime@56.0.15)(expo-constants@56.0.18)(expo-font@56.0.7)(expo-router@56.2.11)(expo-server@56.0.5)(expo@56.0.12)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) |
| 11945 | '@expo/schema-utils': 56.0.1 | 12034 | '@expo/schema-utils': 56.0.1 |
| 11946 | '@expo/spawn-async': 1.8.0 | 12035 | '@expo/spawn-async': 1.8.0 |
| 11947 | '@expo/ws-tunnel': 1.0.6 | 12036 | '@expo/ws-tunnel': 2.0.0(ws@8.21.0(bufferutil@4.1.0)(utf-8-validate@6.0.6)) |
| 11948 | '@expo/xcpretty': 4.4.4 | 12037 | '@expo/xcpretty': 4.4.4 |
| 11949 | '@react-native/dev-middleware': 0.85.3(bufferutil@4.1.0)(utf-8-validate@6.0.6) | 12038 | '@react-native/dev-middleware': 0.85.3(bufferutil@4.1.0)(utf-8-validate@6.0.6) |
| 11950 | accepts: 1.3.8 | 12039 | accepts: 1.3.8 |
| ... | @@ -11957,8 +12046,8 @@ snapshots: | ... | @@ -11957,8 +12046,8 @@ snapshots: |
| 11957 | connect: 3.7.0 | 12046 | connect: 3.7.0 |
| 11958 | debug: 4.4.3 | 12047 | debug: 4.4.3 |
| 11959 | dnssd-advertise: 1.1.4 | 12048 | dnssd-advertise: 1.1.4 |
| 11960 | expo: 56.0.8(63f7aade424ad9e7b1154b679fa2a14d) | 12049 | expo: 56.0.12(@babel/core@7.29.7)(@expo/dom-webview@56.0.5)(@expo/metro-runtime@56.0.15)(bufferutil@4.1.0)(expo-router@56.2.11)(expo-widgets@56.0.19)(react-dom@19.2.3(react@19.2.3))(react-native-webview@13.16.1(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(typescript@6.0.3)(utf-8-validate@6.0.6) |
| 11961 | expo-server: 56.0.4 | 12050 | expo-server: 56.0.5 |
| 11962 | fetch-nodeshim: 0.4.10 | 12051 | fetch-nodeshim: 0.4.10 |
| 11963 | getenv: 2.0.0 | 12052 | getenv: 2.0.0 |
| 11964 | glob: 13.0.6 | 12053 | glob: 13.0.6 |
| ... | @@ -11983,8 +12072,8 @@ snapshots: | ... | @@ -11983,8 +12072,8 @@ snapshots: |
| 11983 | ws: 8.21.0(bufferutil@4.1.0)(utf-8-validate@6.0.6) | 12072 | ws: 8.21.0(bufferutil@4.1.0)(utf-8-validate@6.0.6) |
| 11984 | zod: 3.25.76 | 12073 | zod: 3.25.76 |
| 11985 | optionalDependencies: | 12074 | optionalDependencies: |
| 11986 | expo-router: 56.2.8(5bfdf39b8f760e4dc2d5c3acffc97310) | 12075 | expo-router: 56.2.11(3af01b44e50aae5a8ae60852f3cf7e3f) |
| 11987 | react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) | 12076 | react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) |
| 11988 | transitivePeerDependencies: | 12077 | transitivePeerDependencies: |
| 11989 | - '@expo/dom-webview' | 12078 | - '@expo/dom-webview' |
| 11990 | - '@expo/metro-runtime' | 12079 | - '@expo/metro-runtime' |
| ... | @@ -12021,11 +12110,32 @@ snapshots: | ... | @@ -12021,11 +12110,32 @@ snapshots: |
| 12021 | - supports-color | 12110 | - supports-color |
| 12022 | - typescript | 12111 | - typescript |
| 12023 | 12112 | ||
| 12113 | '@expo/config-plugins@56.0.9(typescript@6.0.3)': | ||
| 12114 | dependencies: | ||
| 12115 | '@expo/config-types': 56.0.6 | ||
| 12116 | '@expo/json-file': 10.2.0 | ||
| 12117 | '@expo/plist': 0.7.0 | ||
| 12118 | '@expo/require-utils': 56.1.3(typescript@6.0.3) | ||
| 12119 | '@expo/sdk-runtime-versions': 1.0.0 | ||
| 12120 | chalk: 4.1.2 | ||
| 12121 | debug: 4.4.3 | ||
| 12122 | getenv: 2.0.0 | ||
| 12123 | glob: 13.0.6 | ||
| 12124 | semver: 7.8.5 | ||
| 12125 | slugify: 1.6.9 | ||
| 12126 | xcode: 3.0.1 | ||
| 12127 | xml2js: 0.6.0 | ||
| 12128 | transitivePeerDependencies: | ||
| 12129 | - supports-color | ||
| 12130 | - typescript | ||
| 12131 | |||
| 12024 | '@expo/config-types@56.0.5': {} | 12132 | '@expo/config-types@56.0.5': {} |
| 12025 | 12133 | ||
| 12134 | '@expo/config-types@56.0.6': {} | ||
| 12135 | |||
| 12026 | '@expo/config@56.0.9(typescript@6.0.3)': | 12136 | '@expo/config@56.0.9(typescript@6.0.3)': |
| 12027 | dependencies: | 12137 | dependencies: |
| 12028 | '@expo/config-plugins': 56.0.8(typescript@6.0.3) | 12138 | '@expo/config-plugins': 56.0.9(typescript@6.0.3) |
| 12029 | '@expo/config-types': 56.0.5 | 12139 | '@expo/config-types': 56.0.5 |
| 12030 | '@expo/json-file': 10.2.0 | 12140 | '@expo/json-file': 10.2.0 |
| 12031 | '@expo/require-utils': 56.1.3(typescript@6.0.3) | 12141 | '@expo/require-utils': 56.1.3(typescript@6.0.3) |
| ... | @@ -12046,18 +12156,18 @@ snapshots: | ... | @@ -12046,18 +12156,18 @@ snapshots: |
| 12046 | transitivePeerDependencies: | 12156 | transitivePeerDependencies: |
| 12047 | - supports-color | 12157 | - supports-color |
| 12048 | 12158 | ||
| 12049 | '@expo/devtools@56.0.2(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)': | 12159 | '@expo/devtools@56.0.2(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)': |
| 12050 | dependencies: | 12160 | dependencies: |
| 12051 | chalk: 4.1.2 | 12161 | chalk: 4.1.2 |
| 12052 | optionalDependencies: | 12162 | optionalDependencies: |
| 12053 | react: 19.2.3 | 12163 | react: 19.2.3 |
| 12054 | react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) | 12164 | react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) |
| 12055 | 12165 | ||
| 12056 | '@expo/dom-webview@56.0.5(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)': | 12166 | '@expo/dom-webview@56.0.5(expo@56.0.12)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)': |
| 12057 | dependencies: | 12167 | dependencies: |
| 12058 | expo: 56.0.8(63f7aade424ad9e7b1154b679fa2a14d) | 12168 | expo: 56.0.12(@babel/core@7.29.7)(@expo/dom-webview@56.0.5)(@expo/metro-runtime@56.0.15)(bufferutil@4.1.0)(expo-router@56.2.11)(expo-widgets@56.0.19)(react-dom@19.2.3(react@19.2.3))(react-native-webview@13.16.1(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(typescript@6.0.3)(utf-8-validate@6.0.6) |
| 12059 | react: 19.2.3 | 12169 | react: 19.2.3 |
| 12060 | react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) | 12170 | react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) |
| 12061 | 12171 | ||
| 12062 | '@expo/env@2.3.0': | 12172 | '@expo/env@2.3.0': |
| 12063 | dependencies: | 12173 | dependencies: |
| ... | @@ -12067,9 +12177,9 @@ snapshots: | ... | @@ -12067,9 +12177,9 @@ snapshots: |
| 12067 | transitivePeerDependencies: | 12177 | transitivePeerDependencies: |
| 12068 | - supports-color | 12178 | - supports-color |
| 12069 | 12179 | ||
| 12070 | '@expo/expo-modules-macros-plugin@0.0.9': {} | 12180 | '@expo/expo-modules-macros-plugin@0.2.2': {} |
| 12071 | 12181 | ||
| 12072 | '@expo/fingerprint@0.19.3': | 12182 | '@expo/fingerprint@0.19.4': |
| 12073 | dependencies: | 12183 | dependencies: |
| 12074 | '@expo/env': 2.3.0 | 12184 | '@expo/env': 2.3.0 |
| 12075 | '@expo/spawn-async': 1.8.0 | 12185 | '@expo/spawn-async': 1.8.0 |
| ... | @@ -12098,9 +12208,9 @@ snapshots: | ... | @@ -12098,9 +12208,9 @@ snapshots: |
| 12098 | - supports-color | 12208 | - supports-color |
| 12099 | - typescript | 12209 | - typescript |
| 12100 | 12210 | ||
| 12101 | '@expo/inline-modules@0.0.10(typescript@6.0.3)': | 12211 | '@expo/inline-modules@0.0.12(typescript@6.0.3)': |
| 12102 | dependencies: | 12212 | dependencies: |
| 12103 | '@expo/config-plugins': 56.0.8(typescript@6.0.3) | 12213 | '@expo/config-plugins': 56.0.9(typescript@6.0.3) |
| 12104 | transitivePeerDependencies: | 12214 | transitivePeerDependencies: |
| 12105 | - supports-color | 12215 | - supports-color |
| 12106 | - typescript | 12216 | - typescript |
| ... | @@ -12118,16 +12228,16 @@ snapshots: | ... | @@ -12118,16 +12228,16 @@ snapshots: |
| 12118 | - supports-color | 12228 | - supports-color |
| 12119 | - typescript | 12229 | - typescript |
| 12120 | 12230 | ||
| 12121 | '@expo/log-box@56.0.12(@expo/dom-webview@56.0.5)(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)': | 12231 | '@expo/log-box@56.0.13(@expo/dom-webview@56.0.5)(expo@56.0.12)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)': |
| 12122 | dependencies: | 12232 | dependencies: |
| 12123 | '@expo/dom-webview': 56.0.5(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) | 12233 | '@expo/dom-webview': 56.0.5(expo@56.0.12)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) |
| 12124 | anser: 1.4.10 | 12234 | anser: 1.4.10 |
| 12125 | expo: 56.0.8(63f7aade424ad9e7b1154b679fa2a14d) | 12235 | expo: 56.0.12(@babel/core@7.29.7)(@expo/dom-webview@56.0.5)(@expo/metro-runtime@56.0.15)(bufferutil@4.1.0)(expo-router@56.2.11)(expo-widgets@56.0.19)(react-dom@19.2.3(react@19.2.3))(react-native-webview@13.16.1(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(typescript@6.0.3)(utf-8-validate@6.0.6) |
| 12126 | react: 19.2.3 | 12236 | react: 19.2.3 |
| 12127 | react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) | 12237 | react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) |
| 12128 | stacktrace-parser: 0.1.11 | 12238 | stacktrace-parser: 0.1.11 |
| 12129 | 12239 | ||
| 12130 | '@expo/metro-config@56.0.13(patch_hash=8cb08b5bb7051ed9d2dbe46a2c293c5a1e17f1bd6ddf30de27909e18c921ff46)(bufferutil@4.1.0)(expo@56.0.8)(typescript@6.0.3)(utf-8-validate@6.0.6)': | 12240 | '@expo/metro-config@56.0.14(patch_hash=8cb08b5bb7051ed9d2dbe46a2c293c5a1e17f1bd6ddf30de27909e18c921ff46)(bufferutil@4.1.0)(expo@56.0.12)(typescript@6.0.3)(utf-8-validate@6.0.6)': |
| 12131 | dependencies: | 12241 | dependencies: |
| 12132 | '@babel/code-frame': 7.29.7 | 12242 | '@babel/code-frame': 7.29.7 |
| 12133 | '@babel/core': 7.29.7 | 12243 | '@babel/core': 7.29.7 |
| ... | @@ -12149,12 +12259,11 @@ snapshots: | ... | @@ -12149,12 +12259,11 @@ snapshots: |
| 12149 | hermes-parser: 0.33.3 | 12259 | hermes-parser: 0.33.3 |
| 12150 | jsc-safe-url: 0.2.4 | 12260 | jsc-safe-url: 0.2.4 |
| 12151 | lightningcss: 1.32.0 | 12261 | lightningcss: 1.32.0 |
| 12152 | msgpackr: 2.0.2 | ||
| 12153 | picomatch: 4.0.4 | 12262 | picomatch: 4.0.4 |
| 12154 | postcss: 8.5.15 | 12263 | postcss: 8.5.15 |
| 12155 | resolve-from: 5.0.0 | 12264 | resolve-from: 5.0.0 |
| 12156 | optionalDependencies: | 12265 | optionalDependencies: |
| 12157 | expo: 56.0.8(63f7aade424ad9e7b1154b679fa2a14d) | 12266 | expo: 56.0.12(@babel/core@7.29.7)(@expo/dom-webview@56.0.5)(@expo/metro-runtime@56.0.15)(bufferutil@4.1.0)(expo-router@56.2.11)(expo-widgets@56.0.19)(react-dom@19.2.3(react@19.2.3))(react-native-webview@13.16.1(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(typescript@6.0.3)(utf-8-validate@6.0.6) |
| 12158 | transitivePeerDependencies: | 12267 | transitivePeerDependencies: |
| 12159 | - bufferutil | 12268 | - bufferutil |
| 12160 | - supports-color | 12269 | - supports-color |
| ... | @@ -12172,14 +12281,14 @@ snapshots: | ... | @@ -12172,14 +12281,14 @@ snapshots: |
| 12172 | transitivePeerDependencies: | 12281 | transitivePeerDependencies: |
| 12173 | - supports-color | 12282 | - supports-color |
| 12174 | 12283 | ||
| 12175 | '@expo/metro-runtime@56.0.13(@expo/log-box@56.0.12)(expo@56.0.8)(react-dom@19.2.3(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)': | 12284 | '@expo/metro-runtime@56.0.15(@expo/log-box@56.0.13)(expo@56.0.12)(react-dom@19.2.3(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)': |
| 12176 | dependencies: | 12285 | dependencies: |
| 12177 | '@expo/log-box': 56.0.12(@expo/dom-webview@56.0.5)(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) | 12286 | '@expo/log-box': 56.0.13(@expo/dom-webview@56.0.5)(expo@56.0.12)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) |
| 12178 | anser: 1.4.10 | 12287 | anser: 1.4.10 |
| 12179 | expo: 56.0.8(63f7aade424ad9e7b1154b679fa2a14d) | 12288 | expo: 56.0.12(@babel/core@7.29.7)(@expo/dom-webview@56.0.5)(@expo/metro-runtime@56.0.15)(bufferutil@4.1.0)(expo-router@56.2.11)(expo-widgets@56.0.19)(react-dom@19.2.3(react@19.2.3))(react-native-webview@13.16.1(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(typescript@6.0.3)(utf-8-validate@6.0.6) |
| 12180 | pretty-format: 29.7.0 | 12289 | pretty-format: 29.7.0 |
| 12181 | react: 19.2.3 | 12290 | react: 19.2.3 |
| 12182 | react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) | 12291 | react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) |
| 12183 | stacktrace-parser: 0.1.11 | 12292 | stacktrace-parser: 0.1.11 |
| 12184 | whatwg-fetch: 3.6.20 | 12293 | whatwg-fetch: 3.6.20 |
| 12185 | optionalDependencies: | 12294 | optionalDependencies: |
| ... | @@ -12225,16 +12334,16 @@ snapshots: | ... | @@ -12225,16 +12334,16 @@ snapshots: |
| 12225 | base64-js: 1.5.1 | 12334 | base64-js: 1.5.1 |
| 12226 | xmlbuilder: 15.1.1 | 12335 | xmlbuilder: 15.1.1 |
| 12227 | 12336 | ||
| 12228 | '@expo/prebuild-config@56.0.14(typescript@6.0.3)': | 12337 | '@expo/prebuild-config@56.0.16(typescript@6.0.3)': |
| 12229 | dependencies: | 12338 | dependencies: |
| 12230 | '@expo/config': 56.0.9(typescript@6.0.3) | 12339 | '@expo/config': 56.0.9(typescript@6.0.3) |
| 12231 | '@expo/config-plugins': 56.0.8(typescript@6.0.3) | 12340 | '@expo/config-plugins': 56.0.9(typescript@6.0.3) |
| 12232 | '@expo/config-types': 56.0.5 | 12341 | '@expo/config-types': 56.0.6 |
| 12233 | '@expo/image-utils': 0.10.1(typescript@6.0.3) | 12342 | '@expo/image-utils': 0.10.1(typescript@6.0.3) |
| 12234 | '@expo/json-file': 10.2.0 | 12343 | '@expo/json-file': 10.2.0 |
| 12235 | '@react-native/normalize-colors': 0.85.3 | 12344 | '@react-native/normalize-colors': 0.85.3 |
| 12236 | debug: 4.4.3 | 12345 | debug: 4.4.3 |
| 12237 | expo-modules-autolinking: 56.0.14(typescript@6.0.3) | 12346 | expo-modules-autolinking: 56.0.16(typescript@6.0.3) |
| 12238 | resolve-from: 5.0.0 | 12347 | resolve-from: 5.0.0 |
| 12239 | semver: 7.8.5 | 12348 | semver: 7.8.5 |
| 12240 | transitivePeerDependencies: | 12349 | transitivePeerDependencies: |
| ... | @@ -12251,17 +12360,17 @@ snapshots: | ... | @@ -12251,17 +12360,17 @@ snapshots: |
| 12251 | transitivePeerDependencies: | 12360 | transitivePeerDependencies: |
| 12252 | - supports-color | 12361 | - supports-color |
| 12253 | 12362 | ||
| 12254 | '@expo/router-server@56.0.12(@expo/metro-runtime@56.0.13)(expo-constants@56.0.16)(expo-font@56.0.5)(expo-router@56.2.8)(expo-server@56.0.4)(expo@56.0.8)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)': | 12363 | '@expo/router-server@56.0.14(@expo/metro-runtime@56.0.15)(expo-constants@56.0.18)(expo-font@56.0.7)(expo-router@56.2.11)(expo-server@56.0.5)(expo@56.0.12)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)': |
| 12255 | dependencies: | 12364 | dependencies: |
| 12256 | debug: 4.4.3 | 12365 | debug: 4.4.3 |
| 12257 | expo: 56.0.8(63f7aade424ad9e7b1154b679fa2a14d) | 12366 | expo: 56.0.12(@babel/core@7.29.7)(@expo/dom-webview@56.0.5)(@expo/metro-runtime@56.0.15)(bufferutil@4.1.0)(expo-router@56.2.11)(expo-widgets@56.0.19)(react-dom@19.2.3(react@19.2.3))(react-native-webview@13.16.1(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(typescript@6.0.3)(utf-8-validate@6.0.6) |
| 12258 | expo-constants: 56.0.16(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6)) | 12367 | expo-constants: 56.0.18(expo@56.0.12)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6)) |
| 12259 | expo-font: 56.0.5(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) | 12368 | expo-font: 56.0.7(expo@56.0.12)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) |
| 12260 | expo-server: 56.0.4 | 12369 | expo-server: 56.0.5 |
| 12261 | react: 19.2.3 | 12370 | react: 19.2.3 |
| 12262 | optionalDependencies: | 12371 | optionalDependencies: |
| 12263 | '@expo/metro-runtime': 56.0.13(@expo/log-box@56.0.12)(expo@56.0.8)(react-dom@19.2.3(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) | 12372 | '@expo/metro-runtime': 56.0.15(@expo/log-box@56.0.13)(expo@56.0.12)(react-dom@19.2.3(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) |
| 12264 | expo-router: 56.2.8(5bfdf39b8f760e4dc2d5c3acffc97310) | 12373 | expo-router: 56.2.11(3af01b44e50aae5a8ae60852f3cf7e3f) |
| 12265 | react-dom: 19.2.3(react@19.2.3) | 12374 | react-dom: 19.2.3(react@19.2.3) |
| 12266 | transitivePeerDependencies: | 12375 | transitivePeerDependencies: |
| 12267 | - supports-color | 12376 | - supports-color |
| ... | @@ -12276,23 +12385,25 @@ snapshots: | ... | @@ -12276,23 +12385,25 @@ snapshots: |
| 12276 | 12385 | ||
| 12277 | '@expo/sudo-prompt@9.3.2': {} | 12386 | '@expo/sudo-prompt@9.3.2': {} |
| 12278 | 12387 | ||
| 12279 | '@expo/ui@56.0.15(961c4aa6f32829b318e3c87ef20ad401)': | 12388 | '@expo/ui@56.0.18(19413efe5eaad64848598eedfe3a0fd3)': |
| 12280 | dependencies: | 12389 | dependencies: |
| 12281 | expo: 56.0.8(63f7aade424ad9e7b1154b679fa2a14d) | 12390 | expo: 56.0.12(@babel/core@7.29.7)(@expo/dom-webview@56.0.5)(@expo/metro-runtime@56.0.15)(bufferutil@4.1.0)(expo-router@56.2.11)(expo-widgets@56.0.19)(react-dom@19.2.3(react@19.2.3))(react-native-webview@13.16.1(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(typescript@6.0.3)(utf-8-validate@6.0.6) |
| 12282 | react: 19.2.3 | 12391 | react: 19.2.3 |
| 12283 | react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) | 12392 | react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) |
| 12284 | sf-symbols-typescript: 2.2.0 | 12393 | sf-symbols-typescript: 2.2.0 |
| 12285 | vaul: 1.1.2(@types/react-dom@19.2.3(@types/react@19.2.16))(@types/react@19.2.16)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) | 12394 | vaul: 1.1.2(@types/react-dom@19.2.3(@types/react@19.2.16))(@types/react@19.2.16)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) |
| 12286 | optionalDependencies: | 12395 | optionalDependencies: |
| 12287 | '@babel/core': 7.29.7 | 12396 | '@babel/core': 7.29.7 |
| 12288 | react-dom: 19.2.3(react@19.2.3) | 12397 | react-dom: 19.2.3(react@19.2.3) |
| 12289 | react-native-reanimated: 4.3.1(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) | 12398 | react-native-reanimated: 4.3.1(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) |
| 12290 | react-native-worklets: 0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) | 12399 | react-native-worklets: 0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) |
| 12291 | transitivePeerDependencies: | 12400 | transitivePeerDependencies: |
| 12292 | - '@types/react' | 12401 | - '@types/react' |
| 12293 | - '@types/react-dom' | 12402 | - '@types/react-dom' |
| 12294 | 12403 | ||
| 12295 | '@expo/ws-tunnel@1.0.6': {} | 12404 | '@expo/ws-tunnel@2.0.0(ws@8.21.0(bufferutil@4.1.0)(utf-8-validate@6.0.6))': |
| 12405 | dependencies: | ||
| 12406 | ws: 8.21.0(bufferutil@4.1.0)(utf-8-validate@6.0.6) | ||
| 12296 | 12407 | ||
| 12297 | '@expo/xcpretty@4.4.4': | 12408 | '@expo/xcpretty@4.4.4': |
| 12298 | dependencies: | 12409 | dependencies: |
| ... | @@ -12548,13 +12659,13 @@ snapshots: | ... | @@ -12548,13 +12659,13 @@ snapshots: |
| 12548 | dependencies: | 12659 | dependencies: |
| 12549 | jsbi: 4.3.2 | 12660 | jsbi: 4.3.2 |
| 12550 | 12661 | ||
| 12551 | '@legendapp/list@3.2.0(react-dom@19.2.3(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)': | 12662 | '@legendapp/list@3.2.0(react-dom@19.2.3(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)': |
| 12552 | dependencies: | 12663 | dependencies: |
| 12553 | react: 19.2.3 | 12664 | react: 19.2.3 |
| 12554 | use-sync-external-store: 1.6.0(react@19.2.3) | 12665 | use-sync-external-store: 1.6.0(react@19.2.3) |
| 12555 | optionalDependencies: | 12666 | optionalDependencies: |
| 12556 | react-dom: 19.2.3(react@19.2.3) | 12667 | react-dom: 19.2.3(react@19.2.3) |
| 12557 | react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) | 12668 | react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) |
| 12558 | 12669 | ||
| 12559 | '@legendapp/list@3.2.0(react-dom@19.2.6(react@19.2.6))(react@19.2.6)': | 12670 | '@legendapp/list@3.2.0(react-dom@19.2.6(react@19.2.6))(react@19.2.6)': |
| 12560 | dependencies: | 12671 | dependencies: |
| ... | @@ -13178,6 +13289,7 @@ snapshots: | ... | @@ -13178,6 +13289,7 @@ snapshots: |
| 13178 | optionalDependencies: | 13289 | optionalDependencies: |
| 13179 | '@types/react': 19.2.16 | 13290 | '@types/react': 19.2.16 |
| 13180 | '@types/react-dom': 19.2.3(@types/react@19.2.16) | 13291 | '@types/react-dom': 19.2.3(@types/react@19.2.16) |
| 13292 | optional: true | ||
| 13181 | 13293 | ||
| 13182 | '@radix-ui/react-compose-refs@1.1.2(@types/react@19.2.16)(react@19.2.3)': | 13294 | '@radix-ui/react-compose-refs@1.1.2(@types/react@19.2.16)(react@19.2.3)': |
| 13183 | dependencies: | 13295 | dependencies: |
| ... | @@ -13218,6 +13330,7 @@ snapshots: | ... | @@ -13218,6 +13330,7 @@ snapshots: |
| 13218 | react: 19.2.3 | 13330 | react: 19.2.3 |
| 13219 | optionalDependencies: | 13331 | optionalDependencies: |
| 13220 | '@types/react': 19.2.16 | 13332 | '@types/react': 19.2.16 |
| 13333 | optional: true | ||
| 13221 | 13334 | ||
| 13222 | '@radix-ui/react-dismissable-layer@1.1.11(@types/react-dom@19.2.3(@types/react@19.2.16))(@types/react@19.2.16)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)': | 13335 | '@radix-ui/react-dismissable-layer@1.1.11(@types/react-dom@19.2.3(@types/react@19.2.16))(@types/react@19.2.16)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)': |
| 13223 | dependencies: | 13336 | dependencies: |
| ... | @@ -13301,6 +13414,7 @@ snapshots: | ... | @@ -13301,6 +13414,7 @@ snapshots: |
| 13301 | optionalDependencies: | 13414 | optionalDependencies: |
| 13302 | '@types/react': 19.2.16 | 13415 | '@types/react': 19.2.16 |
| 13303 | '@types/react-dom': 19.2.3(@types/react@19.2.16) | 13416 | '@types/react-dom': 19.2.3(@types/react@19.2.16) |
| 13417 | optional: true | ||
| 13304 | 13418 | ||
| 13305 | '@radix-ui/react-slot@1.2.3(@types/react@19.2.16)(react@19.2.3)': | 13419 | '@radix-ui/react-slot@1.2.3(@types/react@19.2.16)(react@19.2.3)': |
| 13306 | dependencies: | 13420 | dependencies: |
| ... | @@ -13315,6 +13429,7 @@ snapshots: | ... | @@ -13315,6 +13429,7 @@ snapshots: |
| 13315 | react: 19.2.3 | 13429 | react: 19.2.3 |
| 13316 | optionalDependencies: | 13430 | optionalDependencies: |
| 13317 | '@types/react': 19.2.16 | 13431 | '@types/react': 19.2.16 |
| 13432 | optional: true | ||
| 13318 | 13433 | ||
| 13319 | '@radix-ui/react-tabs@1.1.13(@types/react-dom@19.2.3(@types/react@19.2.16))(@types/react@19.2.16)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)': | 13434 | '@radix-ui/react-tabs@1.1.13(@types/react-dom@19.2.3(@types/react@19.2.16))(@types/react@19.2.16)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)': |
| 13320 | dependencies: | 13435 | dependencies: |
| ... | @@ -13331,6 +13446,7 @@ snapshots: | ... | @@ -13331,6 +13446,7 @@ snapshots: |
| 13331 | optionalDependencies: | 13446 | optionalDependencies: |
| 13332 | '@types/react': 19.2.16 | 13447 | '@types/react': 19.2.16 |
| 13333 | '@types/react-dom': 19.2.3(@types/react@19.2.16) | 13448 | '@types/react-dom': 19.2.3(@types/react@19.2.16) |
| 13449 | optional: true | ||
| 13334 | 13450 | ||
| 13335 | '@radix-ui/react-use-callback-ref@1.1.1(@types/react@19.2.16)(react@19.2.3)': | 13451 | '@radix-ui/react-use-callback-ref@1.1.1(@types/react@19.2.16)(react@19.2.3)': |
| 13336 | dependencies: | 13452 | dependencies: |
| ... | @@ -13377,15 +13493,16 @@ snapshots: | ... | @@ -13377,15 +13493,16 @@ snapshots: |
| 13377 | prompts: 2.4.2 | 13493 | prompts: 2.4.2 |
| 13378 | tinyexec: 1.2.4 | 13494 | tinyexec: 1.2.4 |
| 13379 | 13495 | ||
| 13380 | '@react-native-masked-view/masked-view@0.3.2(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)': | 13496 | '@react-native-masked-view/masked-view@0.3.2(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)': |
| 13381 | dependencies: | 13497 | dependencies: |
| 13382 | react: 19.2.3 | 13498 | react: 19.2.3 |
| 13383 | react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) | 13499 | react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) |
| 13500 | optional: true | ||
| 13384 | 13501 | ||
| 13385 | '@react-native-menu/menu@2.0.0(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)': | 13502 | '@react-native-menu/menu@2.0.0(patch_hash=5ea3ae4bf1d9baf5443b65c269bb09621c27a68d556f713778f37b1e8d46aaae)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)': |
| 13386 | dependencies: | 13503 | dependencies: |
| 13387 | react: 19.2.3 | 13504 | react: 19.2.3 |
| 13388 | react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) | 13505 | react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) |
| 13389 | 13506 | ||
| 13390 | '@react-native/assets-registry@0.85.3': {} | 13507 | '@react-native/assets-registry@0.85.3': {} |
| 13391 | 13508 | ||
| ... | @@ -13445,7 +13562,7 @@ snapshots: | ... | @@ -13445,7 +13562,7 @@ snapshots: |
| 13445 | tinyglobby: 0.2.17 | 13562 | tinyglobby: 0.2.17 |
| 13446 | yargs: 17.7.2 | 13563 | yargs: 17.7.2 |
| 13447 | 13564 | ||
| 13448 | '@react-native/community-cli-plugin@0.85.3(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(bufferutil@4.1.0)(utf-8-validate@6.0.6)': | 13565 | '@react-native/community-cli-plugin@0.85.3(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(bufferutil@4.1.0)(utf-8-validate@6.0.6)': |
| 13449 | dependencies: | 13566 | dependencies: |
| 13450 | '@react-native/dev-middleware': 0.85.3(bufferutil@4.1.0)(utf-8-validate@6.0.6) | 13567 | '@react-native/dev-middleware': 0.85.3(bufferutil@4.1.0)(utf-8-validate@6.0.6) |
| 13451 | debug: 4.4.3 | 13568 | debug: 4.4.3 |
| ... | @@ -13455,7 +13572,7 @@ snapshots: | ... | @@ -13455,7 +13572,7 @@ snapshots: |
| 13455 | metro-core: 0.84.4 | 13572 | metro-core: 0.84.4 |
| 13456 | semver: 7.8.5 | 13573 | semver: 7.8.5 |
| 13457 | optionalDependencies: | 13574 | optionalDependencies: |
| 13458 | '@react-native/metro-config': 0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6) | 13575 | '@react-native/metro-config': 0.85.3(@babel/core@7.29.7) |
| 13459 | transitivePeerDependencies: | 13576 | transitivePeerDependencies: |
| 13460 | - bufferutil | 13577 | - bufferutil |
| 13461 | - supports-color | 13578 | - supports-color |
| ... | @@ -13503,7 +13620,7 @@ snapshots: | ... | @@ -13503,7 +13620,7 @@ snapshots: |
| 13503 | transitivePeerDependencies: | 13620 | transitivePeerDependencies: |
| 13504 | - supports-color | 13621 | - supports-color |
| 13505 | 13622 | ||
| 13506 | '@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6)': | 13623 | '@react-native/metro-config@0.85.3(@babel/core@7.29.7)': |
| 13507 | dependencies: | 13624 | dependencies: |
| 13508 | '@react-native/js-polyfills': 0.85.3 | 13625 | '@react-native/js-polyfills': 0.85.3 |
| 13509 | '@react-native/metro-babel-transformer': 0.85.3(@babel/core@7.29.7) | 13626 | '@react-native/metro-babel-transformer': 0.85.3(@babel/core@7.29.7) |
| ... | @@ -13511,21 +13628,72 @@ snapshots: | ... | @@ -13511,21 +13628,72 @@ snapshots: |
| 13511 | metro-runtime: 0.84.4 | 13628 | metro-runtime: 0.84.4 |
| 13512 | transitivePeerDependencies: | 13629 | transitivePeerDependencies: |
| 13513 | - '@babel/core' | 13630 | - '@babel/core' |
| 13514 | - bufferutil | ||
| 13515 | - supports-color | 13631 | - supports-color |
| 13516 | - utf-8-validate | ||
| 13517 | 13632 | ||
| 13518 | '@react-native/normalize-colors@0.85.3': {} | 13633 | '@react-native/normalize-colors@0.85.3': {} |
| 13519 | 13634 | ||
| 13520 | '@react-native/virtualized-lists@0.85.3(@types/react@19.2.16)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)': | 13635 | '@react-native/virtualized-lists@0.85.3(@types/react@19.2.16)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)': |
| 13521 | dependencies: | 13636 | dependencies: |
| 13522 | invariant: 2.2.4 | 13637 | invariant: 2.2.4 |
| 13523 | nullthrows: 1.1.1 | 13638 | nullthrows: 1.1.1 |
| 13524 | react: 19.2.3 | 13639 | react: 19.2.3 |
| 13525 | react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) | 13640 | react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) |
| 13526 | optionalDependencies: | 13641 | optionalDependencies: |
| 13527 | '@types/react': 19.2.16 | 13642 | '@types/react': 19.2.16 |
| 13528 | 13643 | ||
| 13644 | '@react-navigation/core@7.21.2(react@19.2.3)': | ||
| 13645 | dependencies: | ||
| 13646 | '@react-navigation/routers': 7.6.0 | ||
| 13647 | escape-string-regexp: 4.0.0 | ||
| 13648 | fast-deep-equal: 3.1.3 | ||
| 13649 | nanoid: 3.3.12 | ||
| 13650 | query-string: 7.1.3 | ||
| 13651 | react: 19.2.3 | ||
| 13652 | react-is: 19.2.7 | ||
| 13653 | use-latest-callback: 0.2.6(react@19.2.3) | ||
| 13654 | use-sync-external-store: 1.6.0(react@19.2.3) | ||
| 13655 | |||
| 13656 | '@react-navigation/elements@2.9.26(@react-native-masked-view/masked-view@0.3.2(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(@react-navigation/native@7.3.4(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native-safe-area-context@5.7.0(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)': | ||
| 13657 | dependencies: | ||
| 13658 | '@react-navigation/native': 7.3.4(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) | ||
| 13659 | color: 4.2.3 | ||
| 13660 | react: 19.2.3 | ||
| 13661 | react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) | ||
| 13662 | react-native-safe-area-context: 5.7.0(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) | ||
| 13663 | use-latest-callback: 0.2.6(react@19.2.3) | ||
| 13664 | use-sync-external-store: 1.6.0(react@19.2.3) | ||
| 13665 | optionalDependencies: | ||
| 13666 | '@react-native-masked-view/masked-view': 0.3.2(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) | ||
| 13667 | |||
| 13668 | '@react-navigation/native-stack@7.17.6(patch_hash=2d7fec7933e324ccf082b1ae0df3907c35d16d48c117d628e27d9c3921c26bca)(889fbbb381dfeb79ac64b7284ebf7fa4)': | ||
| 13669 | dependencies: | ||
| 13670 | '@react-navigation/elements': 2.9.26(@react-native-masked-view/masked-view@0.3.2(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(@react-navigation/native@7.3.4(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native-safe-area-context@5.7.0(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) | ||
| 13671 | '@react-navigation/native': 7.3.4(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) | ||
| 13672 | color: 4.2.3 | ||
| 13673 | react: 19.2.3 | ||
| 13674 | react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) | ||
| 13675 | react-native-safe-area-context: 5.7.0(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) | ||
| 13676 | react-native-screens: 4.25.2(patch_hash=e1b49230abc20a663115cc3f34ca2f63764a873485ccb67f2bd43b85ce2a94dc)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) | ||
| 13677 | sf-symbols-typescript: 2.2.0 | ||
| 13678 | warn-once: 0.1.1 | ||
| 13679 | transitivePeerDependencies: | ||
| 13680 | - '@react-native-masked-view/masked-view' | ||
| 13681 | |||
| 13682 | '@react-navigation/native@7.3.4(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)': | ||
| 13683 | dependencies: | ||
| 13684 | '@react-navigation/core': 7.21.2(react@19.2.3) | ||
| 13685 | escape-string-regexp: 4.0.0 | ||
| 13686 | fast-deep-equal: 3.1.3 | ||
| 13687 | nanoid: 3.3.12 | ||
| 13688 | react: 19.2.3 | ||
| 13689 | react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) | ||
| 13690 | standard-navigation: 0.0.7 | ||
| 13691 | use-latest-callback: 0.2.6(react@19.2.3) | ||
| 13692 | |||
| 13693 | '@react-navigation/routers@7.6.0': | ||
| 13694 | dependencies: | ||
| 13695 | nanoid: 3.3.12 | ||
| 13696 | |||
| 13529 | '@rolldown/binding-android-arm64@1.0.0-rc.17': | 13697 | '@rolldown/binding-android-arm64@1.0.0-rc.17': |
| 13530 | optional: true | 13698 | optional: true |
| 13531 | 13699 | ||
| ... | @@ -13907,15 +14075,15 @@ snapshots: | ... | @@ -13907,15 +14075,15 @@ snapshots: |
| 13907 | dependencies: | 14075 | dependencies: |
| 13908 | defer-to-connect: 2.0.1 | 14076 | defer-to-connect: 2.0.1 |
| 13909 | 14077 | ||
| 13910 | '@t3tools/mobile-markdown-text@file:apps/mobile/modules/t3-markdown-text(f0ff241d48c23db461fff5d47e450578)': | 14078 | '@t3tools/mobile-markdown-text@file:apps/mobile/modules/t3-markdown-text(a6e4bd1e9376530ea93dbb7d8a53d32d)': |
| 13911 | dependencies: | 14079 | dependencies: |
| 13912 | expo-asset: 56.0.15(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(typescript@6.0.3) | 14080 | expo-asset: 56.0.17(expo@56.0.12)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(typescript@6.0.3) |
| 13913 | expo-clipboard: 56.0.3(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) | 14081 | expo-clipboard: 56.0.4(expo@56.0.12)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) |
| 13914 | expo-haptics: 56.0.3(expo@56.0.8) | 14082 | expo-haptics: 56.0.3(expo@56.0.12) |
| 13915 | expo-symbols: 56.0.5(expo-font@56.0.5)(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) | 14083 | expo-symbols: 56.0.6(expo-font@56.0.7)(expo@56.0.12)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) |
| 13916 | react: 19.2.3 | 14084 | react: 19.2.3 |
| 13917 | react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) | 14085 | react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) |
| 13918 | react-native-nitro-markdown: 0.5.8(react-native-nitro-modules@0.35.9(patch_hash=825622aae63a8fb5b904f3c77908a0e216261d727ea171709f2c0b6088422675)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native-svg@15.15.4(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) | 14086 | react-native-nitro-markdown: 0.5.8(react-native-nitro-modules@0.35.9(patch_hash=825622aae63a8fb5b904f3c77908a0e216261d727ea171709f2c0b6088422675)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native-svg@15.15.4(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) |
| 13919 | 14087 | ||
| 13920 | '@t3tools/mobile-review-diff-native@file:apps/mobile/modules/t3-review-diff': {} | 14088 | '@t3tools/mobile-review-diff-native@file:apps/mobile/modules/t3-review-diff': {} |
| 13921 | 14089 | ||
| ... | @@ -14171,6 +14339,7 @@ snapshots: | ... | @@ -14171,6 +14339,7 @@ snapshots: |
| 14171 | dom-accessibility-api: 0.6.3 | 14339 | dom-accessibility-api: 0.6.3 |
| 14172 | picocolors: 1.1.1 | 14340 | picocolors: 1.1.1 |
| 14173 | redent: 3.0.0 | 14341 | redent: 3.0.0 |
| 14342 | optional: true | ||
| 14174 | 14343 | ||
| 14175 | '@testing-library/user-event@14.6.1(@testing-library/dom@10.4.1)': | 14344 | '@testing-library/user-event@14.6.1(@testing-library/dom@10.4.1)': |
| 14176 | dependencies: | 14345 | dependencies: |
| ... | @@ -14983,7 +15152,60 @@ snapshots: | ... | @@ -14983,7 +15152,60 @@ snapshots: |
| 14983 | transitivePeerDependencies: | 15152 | transitivePeerDependencies: |
| 14984 | - '@babel/core' | 15153 | - '@babel/core' |
| 14985 | 15154 | ||
| 14986 | babel-preset-expo@56.0.14(@babel/core@7.29.7)(@babel/runtime@7.29.7)(expo-widgets@56.0.16)(expo@56.0.8)(react-refresh@0.14.2): | 15155 | babel-preset-expo@56.0.14(@babel/core@7.29.7)(@babel/runtime@7.29.7)(expo-widgets@56.0.19)(expo@56.0.12)(react-refresh@0.14.2): |
| 15156 | dependencies: | ||
| 15157 | '@babel/generator': 7.29.7 | ||
| 15158 | '@babel/helper-module-imports': 7.29.7 | ||
| 15159 | '@babel/plugin-proposal-decorators': 7.29.7(@babel/core@7.29.7) | ||
| 15160 | '@babel/plugin-proposal-export-default-from': 7.29.7(@babel/core@7.29.7) | ||
| 15161 | '@babel/plugin-syntax-dynamic-import': 7.8.3(@babel/core@7.29.7) | ||
| 15162 | '@babel/plugin-syntax-export-default-from': 7.29.7(@babel/core@7.29.7) | ||
| 15163 | '@babel/plugin-syntax-nullish-coalescing-operator': 7.8.3(@babel/core@7.29.7) | ||
| 15164 | '@babel/plugin-syntax-optional-chaining': 7.8.3(@babel/core@7.29.7) | ||
| 15165 | '@babel/plugin-transform-async-generator-functions': 7.29.7(@babel/core@7.29.7) | ||
| 15166 | '@babel/plugin-transform-async-to-generator': 7.29.7(@babel/core@7.29.7) | ||
| 15167 | '@babel/plugin-transform-block-scoping': 7.29.7(@babel/core@7.29.7) | ||
| 15168 | '@babel/plugin-transform-class-properties': 7.29.7(@babel/core@7.29.7) | ||
| 15169 | '@babel/plugin-transform-class-static-block': 7.29.7(@babel/core@7.29.7) | ||
| 15170 | '@babel/plugin-transform-classes': 7.29.7(@babel/core@7.29.7) | ||
| 15171 | '@babel/plugin-transform-destructuring': 7.29.7(@babel/core@7.29.7) | ||
| 15172 | '@babel/plugin-transform-export-namespace-from': 7.29.7(@babel/core@7.29.7) | ||
| 15173 | '@babel/plugin-transform-flow-strip-types': 7.29.7(@babel/core@7.29.7) | ||
| 15174 | '@babel/plugin-transform-for-of': 7.29.7(@babel/core@7.29.7) | ||
| 15175 | '@babel/plugin-transform-logical-assignment-operators': 7.29.7(@babel/core@7.29.7) | ||
| 15176 | '@babel/plugin-transform-modules-commonjs': 7.29.7(@babel/core@7.29.7) | ||
| 15177 | '@babel/plugin-transform-named-capturing-groups-regex': 7.29.7(@babel/core@7.29.7) | ||
| 15178 | '@babel/plugin-transform-nullish-coalescing-operator': 7.29.7(@babel/core@7.29.7) | ||
| 15179 | '@babel/plugin-transform-object-rest-spread': 7.29.7(@babel/core@7.29.7) | ||
| 15180 | '@babel/plugin-transform-optional-catch-binding': 7.29.7(@babel/core@7.29.7) | ||
| 15181 | '@babel/plugin-transform-optional-chaining': 7.29.7(@babel/core@7.29.7) | ||
| 15182 | '@babel/plugin-transform-parameters': 7.29.7(@babel/core@7.29.7) | ||
| 15183 | '@babel/plugin-transform-private-methods': 7.29.7(@babel/core@7.29.7) | ||
| 15184 | '@babel/plugin-transform-private-property-in-object': 7.29.7(@babel/core@7.29.7) | ||
| 15185 | '@babel/plugin-transform-react-display-name': 7.29.7(@babel/core@7.29.7) | ||
| 15186 | '@babel/plugin-transform-react-jsx': 7.28.6(@babel/core@7.29.7) | ||
| 15187 | '@babel/plugin-transform-react-jsx-development': 7.29.7(@babel/core@7.29.7) | ||
| 15188 | '@babel/plugin-transform-react-pure-annotations': 7.29.7(@babel/core@7.29.7) | ||
| 15189 | '@babel/plugin-transform-runtime': 7.29.7(@babel/core@7.29.7) | ||
| 15190 | '@babel/plugin-transform-typescript': 7.29.7(@babel/core@7.29.7) | ||
| 15191 | '@babel/plugin-transform-unicode-regex': 7.29.7(@babel/core@7.29.7) | ||
| 15192 | '@babel/preset-typescript': 7.29.7(@babel/core@7.29.7) | ||
| 15193 | '@react-native/babel-plugin-codegen': 0.85.3(@babel/core@7.29.7) | ||
| 15194 | babel-plugin-react-compiler: 1.0.0 | ||
| 15195 | babel-plugin-react-native-web: 0.21.2 | ||
| 15196 | babel-plugin-syntax-hermes-parser: 0.33.3 | ||
| 15197 | babel-plugin-transform-flow-enums: 0.0.2(@babel/core@7.29.7) | ||
| 15198 | debug: 4.4.3 | ||
| 15199 | react-refresh: 0.14.2 | ||
| 15200 | optionalDependencies: | ||
| 15201 | '@babel/runtime': 7.29.7 | ||
| 15202 | expo: 56.0.12(@babel/core@7.29.7)(@expo/dom-webview@56.0.5)(@expo/metro-runtime@56.0.15)(bufferutil@4.1.0)(expo-router@56.2.11)(expo-widgets@56.0.19)(react-dom@19.2.3(react@19.2.3))(react-native-webview@13.16.1(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(typescript@6.0.3)(utf-8-validate@6.0.6) | ||
| 15203 | expo-widgets: 56.0.19(19413efe5eaad64848598eedfe3a0fd3) | ||
| 15204 | transitivePeerDependencies: | ||
| 15205 | - '@babel/core' | ||
| 15206 | - supports-color | ||
| 15207 | |||
| 15208 | babel-preset-expo@56.0.15(@babel/core@7.29.7)(@babel/runtime@7.29.7)(expo-widgets@56.0.19)(expo@56.0.12)(react-refresh@0.14.2): | ||
| 14987 | dependencies: | 15209 | dependencies: |
| 14988 | '@babel/generator': 7.29.7 | 15210 | '@babel/generator': 7.29.7 |
| 14989 | '@babel/helper-module-imports': 7.29.7 | 15211 | '@babel/helper-module-imports': 7.29.7 |
| ... | @@ -15030,8 +15252,8 @@ snapshots: | ... | @@ -15030,8 +15252,8 @@ snapshots: |
| 15030 | react-refresh: 0.14.2 | 15252 | react-refresh: 0.14.2 |
| 15031 | optionalDependencies: | 15253 | optionalDependencies: |
| 15032 | '@babel/runtime': 7.29.7 | 15254 | '@babel/runtime': 7.29.7 |
| 15033 | expo: 56.0.8(63f7aade424ad9e7b1154b679fa2a14d) | 15255 | expo: 56.0.12(@babel/core@7.29.7)(@expo/dom-webview@56.0.5)(@expo/metro-runtime@56.0.15)(bufferutil@4.1.0)(expo-router@56.2.11)(expo-widgets@56.0.19)(react-dom@19.2.3(react@19.2.3))(react-native-webview@13.16.1(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(typescript@6.0.3)(utf-8-validate@6.0.6) |
| 15034 | expo-widgets: 56.0.16(961c4aa6f32829b318e3c87ef20ad401) | 15256 | expo-widgets: 56.0.19(19413efe5eaad64848598eedfe3a0fd3) |
| 15035 | transitivePeerDependencies: | 15257 | transitivePeerDependencies: |
| 15036 | - '@babel/core' | 15258 | - '@babel/core' |
| 15037 | - supports-color | 15259 | - supports-color |
| ... | @@ -15307,7 +15529,8 @@ snapshots: | ... | @@ -15307,7 +15529,8 @@ snapshots: |
| 15307 | 15529 | ||
| 15308 | cli-width@4.1.0: {} | 15530 | cli-width@4.1.0: {} |
| 15309 | 15531 | ||
| 15310 | client-only@0.0.1: {} | 15532 | client-only@0.0.1: |
| 15533 | optional: true | ||
| 15311 | 15534 | ||
| 15312 | cliui@8.0.1: | 15535 | cliui@8.0.1: |
| 15313 | dependencies: | 15536 | dependencies: |
| ... | @@ -15499,7 +15722,8 @@ snapshots: | ... | @@ -15499,7 +15722,8 @@ snapshots: |
| 15499 | 15722 | ||
| 15500 | css-what@6.2.2: {} | 15723 | css-what@6.2.2: {} |
| 15501 | 15724 | ||
| 15502 | css.escape@1.5.1: {} | 15725 | css.escape@1.5.1: |
| 15726 | optional: true | ||
| 15503 | 15727 | ||
| 15504 | csso@5.0.5: | 15728 | csso@5.0.5: |
| 15505 | dependencies: | 15729 | dependencies: |
| ... | @@ -15622,7 +15846,8 @@ snapshots: | ... | @@ -15622,7 +15846,8 @@ snapshots: |
| 15622 | 15846 | ||
| 15623 | dom-accessibility-api@0.5.16: {} | 15847 | dom-accessibility-api@0.5.16: {} |
| 15624 | 15848 | ||
| 15625 | dom-accessibility-api@0.6.3: {} | 15849 | dom-accessibility-api@0.6.3: |
| 15850 | optional: true | ||
| 15626 | 15851 | ||
| 15627 | dom-serializer@2.0.0: | 15852 | dom-serializer@2.0.0: |
| 15628 | dependencies: | 15853 | dependencies: |
| ... | @@ -15926,154 +16151,154 @@ snapshots: | ... | @@ -15926,154 +16151,154 @@ snapshots: |
| 15926 | 16151 | ||
| 15927 | expect-type@1.4.0: {} | 16152 | expect-type@1.4.0: {} |
| 15928 | 16153 | ||
| 15929 | expo-application@56.0.3(expo@56.0.8): | 16154 | expo-application@56.0.3(expo@56.0.12): |
| 15930 | dependencies: | 16155 | dependencies: |
| 15931 | expo: 56.0.8(63f7aade424ad9e7b1154b679fa2a14d) | 16156 | expo: 56.0.12(@babel/core@7.29.7)(@expo/dom-webview@56.0.5)(@expo/metro-runtime@56.0.15)(bufferutil@4.1.0)(expo-router@56.2.11)(expo-widgets@56.0.19)(react-dom@19.2.3(react@19.2.3))(react-native-webview@13.16.1(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(typescript@6.0.3)(utf-8-validate@6.0.6) |
| 15932 | 16157 | ||
| 15933 | expo-asset@56.0.15(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(typescript@6.0.3): | 16158 | expo-asset@56.0.17(expo@56.0.12)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(typescript@6.0.3): |
| 15934 | dependencies: | 16159 | dependencies: |
| 15935 | '@expo/image-utils': 0.10.1(typescript@6.0.3) | 16160 | '@expo/image-utils': 0.10.1(typescript@6.0.3) |
| 15936 | expo: 56.0.8(63f7aade424ad9e7b1154b679fa2a14d) | 16161 | expo: 56.0.12(@babel/core@7.29.7)(@expo/dom-webview@56.0.5)(@expo/metro-runtime@56.0.15)(bufferutil@4.1.0)(expo-router@56.2.11)(expo-widgets@56.0.19)(react-dom@19.2.3(react@19.2.3))(react-native-webview@13.16.1(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(typescript@6.0.3)(utf-8-validate@6.0.6) |
| 15937 | expo-constants: 56.0.16(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6)) | 16162 | expo-constants: 56.0.18(expo@56.0.12)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6)) |
| 15938 | react: 19.2.3 | 16163 | react: 19.2.3 |
| 15939 | react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) | 16164 | react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) |
| 15940 | transitivePeerDependencies: | 16165 | transitivePeerDependencies: |
| 15941 | - supports-color | 16166 | - supports-color |
| 15942 | - typescript | 16167 | - typescript |
| 15943 | 16168 | ||
| 15944 | expo-auth-session@56.0.13(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3): | 16169 | expo-auth-session@56.0.14(expo@56.0.12)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3): |
| 15945 | dependencies: | 16170 | dependencies: |
| 15946 | expo-application: 56.0.3(expo@56.0.8) | 16171 | expo-application: 56.0.3(expo@56.0.12) |
| 15947 | expo-constants: 56.0.16(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6)) | 16172 | expo-constants: 56.0.18(expo@56.0.12)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6)) |
| 15948 | expo-crypto: 56.0.4(expo@56.0.8) | 16173 | expo-crypto: 56.0.4(expo@56.0.12) |
| 15949 | expo-linking: 56.0.13(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) | 16174 | expo-linking: 56.0.14(expo@56.0.12)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) |
| 15950 | expo-web-browser: 56.0.5(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6)) | 16175 | expo-web-browser: 56.0.5(expo@56.0.12)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6)) |
| 15951 | invariant: 2.2.4 | 16176 | invariant: 2.2.4 |
| 15952 | react: 19.2.3 | 16177 | react: 19.2.3 |
| 15953 | react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) | 16178 | react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) |
| 15954 | transitivePeerDependencies: | 16179 | transitivePeerDependencies: |
| 15955 | - expo | 16180 | - expo |
| 15956 | - supports-color | 16181 | - supports-color |
| 15957 | 16182 | ||
| 15958 | expo-build-properties@56.0.16(expo@56.0.8): | 16183 | expo-build-properties@56.0.19(expo@56.0.12): |
| 15959 | dependencies: | 16184 | dependencies: |
| 15960 | '@expo/schema-utils': 56.0.1 | 16185 | '@expo/schema-utils': 56.0.1 |
| 15961 | expo: 56.0.8(63f7aade424ad9e7b1154b679fa2a14d) | 16186 | expo: 56.0.12(@babel/core@7.29.7)(@expo/dom-webview@56.0.5)(@expo/metro-runtime@56.0.15)(bufferutil@4.1.0)(expo-router@56.2.11)(expo-widgets@56.0.19)(react-dom@19.2.3(react@19.2.3))(react-native-webview@13.16.1(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(typescript@6.0.3)(utf-8-validate@6.0.6) |
| 15962 | resolve-from: 5.0.0 | 16187 | resolve-from: 5.0.0 |
| 15963 | semver: 7.8.5 | 16188 | semver: 7.8.5 |
| 15964 | 16189 | ||
| 15965 | expo-camera@56.0.7(@types/emscripten@1.41.5)(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3): | 16190 | expo-camera@56.0.8(@types/emscripten@1.41.5)(expo@56.0.12)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3): |
| 15966 | dependencies: | 16191 | dependencies: |
| 15967 | barcode-detector: 3.2.0(@types/emscripten@1.41.5) | 16192 | barcode-detector: 3.2.0(@types/emscripten@1.41.5) |
| 15968 | expo: 56.0.8(63f7aade424ad9e7b1154b679fa2a14d) | 16193 | expo: 56.0.12(@babel/core@7.29.7)(@expo/dom-webview@56.0.5)(@expo/metro-runtime@56.0.15)(bufferutil@4.1.0)(expo-router@56.2.11)(expo-widgets@56.0.19)(react-dom@19.2.3(react@19.2.3))(react-native-webview@13.16.1(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(typescript@6.0.3)(utf-8-validate@6.0.6) |
| 15969 | react: 19.2.3 | 16194 | react: 19.2.3 |
| 15970 | react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) | 16195 | react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) |
| 15971 | transitivePeerDependencies: | 16196 | transitivePeerDependencies: |
| 15972 | - '@types/emscripten' | 16197 | - '@types/emscripten' |
| 15973 | 16198 | ||
| 15974 | expo-clipboard@56.0.3(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3): | 16199 | expo-clipboard@56.0.4(expo@56.0.12)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3): |
| 15975 | dependencies: | 16200 | dependencies: |
| 15976 | expo: 56.0.8(63f7aade424ad9e7b1154b679fa2a14d) | 16201 | expo: 56.0.12(@babel/core@7.29.7)(@expo/dom-webview@56.0.5)(@expo/metro-runtime@56.0.15)(bufferutil@4.1.0)(expo-router@56.2.11)(expo-widgets@56.0.19)(react-dom@19.2.3(react@19.2.3))(react-native-webview@13.16.1(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(typescript@6.0.3)(utf-8-validate@6.0.6) |
| 15977 | react: 19.2.3 | 16202 | react: 19.2.3 |
| 15978 | react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) | 16203 | react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) |
| 15979 | 16204 | ||
| 15980 | expo-constants@56.0.16(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6)): | 16205 | expo-constants@56.0.18(expo@56.0.12)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6)): |
| 15981 | dependencies: | 16206 | dependencies: |
| 15982 | '@expo/env': 2.3.0 | 16207 | '@expo/env': 2.3.0 |
| 15983 | expo: 56.0.8(63f7aade424ad9e7b1154b679fa2a14d) | 16208 | expo: 56.0.12(@babel/core@7.29.7)(@expo/dom-webview@56.0.5)(@expo/metro-runtime@56.0.15)(bufferutil@4.1.0)(expo-router@56.2.11)(expo-widgets@56.0.19)(react-dom@19.2.3(react@19.2.3))(react-native-webview@13.16.1(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(typescript@6.0.3)(utf-8-validate@6.0.6) |
| 15984 | react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) | 16209 | react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) |
| 15985 | transitivePeerDependencies: | 16210 | transitivePeerDependencies: |
| 15986 | - supports-color | 16211 | - supports-color |
| 15987 | 16212 | ||
| 15988 | expo-crypto@56.0.4(expo@56.0.8): | 16213 | expo-crypto@56.0.4(expo@56.0.12): |
| 15989 | dependencies: | 16214 | dependencies: |
| 15990 | expo: 56.0.8(63f7aade424ad9e7b1154b679fa2a14d) | 16215 | expo: 56.0.12(@babel/core@7.29.7)(@expo/dom-webview@56.0.5)(@expo/metro-runtime@56.0.15)(bufferutil@4.1.0)(expo-router@56.2.11)(expo-widgets@56.0.19)(react-dom@19.2.3(react@19.2.3))(react-native-webview@13.16.1(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(typescript@6.0.3)(utf-8-validate@6.0.6) |
| 15991 | 16216 | ||
| 15992 | expo-dev-client@56.0.18(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6)): | 16217 | expo-dev-client@56.0.20(expo@56.0.12)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6)): |
| 15993 | dependencies: | 16218 | dependencies: |
| 15994 | expo: 56.0.8(63f7aade424ad9e7b1154b679fa2a14d) | 16219 | expo: 56.0.12(@babel/core@7.29.7)(@expo/dom-webview@56.0.5)(@expo/metro-runtime@56.0.15)(bufferutil@4.1.0)(expo-router@56.2.11)(expo-widgets@56.0.19)(react-dom@19.2.3(react@19.2.3))(react-native-webview@13.16.1(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(typescript@6.0.3)(utf-8-validate@6.0.6) |
| 15995 | expo-dev-launcher: 56.0.18(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6)) | 16220 | expo-dev-launcher: 56.0.20(expo@56.0.12)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6)) |
| 15996 | expo-dev-menu: 56.0.16(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6)) | 16221 | expo-dev-menu: 56.0.17(expo@56.0.12)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6)) |
| 15997 | expo-dev-menu-interface: 56.0.1(expo@56.0.8) | 16222 | expo-dev-menu-interface: 56.0.1(expo@56.0.12) |
| 15998 | expo-manifests: 56.0.4(expo@56.0.8) | 16223 | expo-manifests: 56.0.4(expo@56.0.12) |
| 15999 | expo-updates-interface: 56.0.2(expo@56.0.8) | 16224 | expo-updates-interface: 56.0.2(expo@56.0.12) |
| 16000 | transitivePeerDependencies: | 16225 | transitivePeerDependencies: |
| 16001 | - react-native | 16226 | - react-native |
| 16002 | 16227 | ||
| 16003 | expo-dev-launcher@56.0.18(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6)): | 16228 | expo-dev-launcher@56.0.20(expo@56.0.12)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6)): |
| 16004 | dependencies: | 16229 | dependencies: |
| 16005 | '@expo/schema-utils': 56.0.1 | 16230 | '@expo/schema-utils': 56.0.1 |
| 16006 | expo: 56.0.8(63f7aade424ad9e7b1154b679fa2a14d) | 16231 | expo: 56.0.12(@babel/core@7.29.7)(@expo/dom-webview@56.0.5)(@expo/metro-runtime@56.0.15)(bufferutil@4.1.0)(expo-router@56.2.11)(expo-widgets@56.0.19)(react-dom@19.2.3(react@19.2.3))(react-native-webview@13.16.1(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(typescript@6.0.3)(utf-8-validate@6.0.6) |
| 16007 | expo-dev-menu: 56.0.16(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6)) | 16232 | expo-dev-menu: 56.0.17(expo@56.0.12)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6)) |
| 16008 | expo-manifests: 56.0.4(expo@56.0.8) | 16233 | expo-manifests: 56.0.4(expo@56.0.12) |
| 16009 | react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) | 16234 | react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) |
| 16010 | 16235 | ||
| 16011 | expo-dev-menu-interface@56.0.1(expo@56.0.8): | 16236 | expo-dev-menu-interface@56.0.1(expo@56.0.12): |
| 16012 | dependencies: | 16237 | dependencies: |
| 16013 | expo: 56.0.8(63f7aade424ad9e7b1154b679fa2a14d) | 16238 | expo: 56.0.12(@babel/core@7.29.7)(@expo/dom-webview@56.0.5)(@expo/metro-runtime@56.0.15)(bufferutil@4.1.0)(expo-router@56.2.11)(expo-widgets@56.0.19)(react-dom@19.2.3(react@19.2.3))(react-native-webview@13.16.1(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(typescript@6.0.3)(utf-8-validate@6.0.6) |
| 16014 | 16239 | ||
| 16015 | expo-dev-menu@56.0.16(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6)): | 16240 | expo-dev-menu@56.0.17(expo@56.0.12)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6)): |
| 16016 | dependencies: | 16241 | dependencies: |
| 16017 | expo: 56.0.8(63f7aade424ad9e7b1154b679fa2a14d) | 16242 | expo: 56.0.12(@babel/core@7.29.7)(@expo/dom-webview@56.0.5)(@expo/metro-runtime@56.0.15)(bufferutil@4.1.0)(expo-router@56.2.11)(expo-widgets@56.0.19)(react-dom@19.2.3(react@19.2.3))(react-native-webview@13.16.1(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(typescript@6.0.3)(utf-8-validate@6.0.6) |
| 16018 | expo-dev-menu-interface: 56.0.1(expo@56.0.8) | 16243 | expo-dev-menu-interface: 56.0.1(expo@56.0.12) |
| 16019 | react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) | 16244 | react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) |
| 16020 | 16245 | ||
| 16021 | expo-eas-client@56.0.1: {} | 16246 | expo-eas-client@56.0.1: {} |
| 16022 | 16247 | ||
| 16023 | expo-file-system@56.0.7(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6)): | 16248 | expo-file-system@56.0.8(expo@56.0.12)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6)): |
| 16024 | dependencies: | 16249 | dependencies: |
| 16025 | expo: 56.0.8(63f7aade424ad9e7b1154b679fa2a14d) | 16250 | expo: 56.0.12(@babel/core@7.29.7)(@expo/dom-webview@56.0.5)(@expo/metro-runtime@56.0.15)(bufferutil@4.1.0)(expo-router@56.2.11)(expo-widgets@56.0.19)(react-dom@19.2.3(react@19.2.3))(react-native-webview@13.16.1(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(typescript@6.0.3)(utf-8-validate@6.0.6) |
| 16026 | react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) | 16251 | react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) |
| 16027 | 16252 | ||
| 16028 | expo-font@56.0.5(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3): | 16253 | expo-font@56.0.7(expo@56.0.12)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3): |
| 16029 | dependencies: | 16254 | dependencies: |
| 16030 | expo: 56.0.8(63f7aade424ad9e7b1154b679fa2a14d) | 16255 | expo: 56.0.12(@babel/core@7.29.7)(@expo/dom-webview@56.0.5)(@expo/metro-runtime@56.0.15)(bufferutil@4.1.0)(expo-router@56.2.11)(expo-widgets@56.0.19)(react-dom@19.2.3(react@19.2.3))(react-native-webview@13.16.1(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(typescript@6.0.3)(utf-8-validate@6.0.6) |
| 16031 | fontfaceobserver: 2.3.0 | 16256 | fontfaceobserver: 2.3.0 |
| 16032 | react: 19.2.3 | 16257 | react: 19.2.3 |
| 16033 | react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) | 16258 | react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) |
| 16034 | 16259 | ||
| 16035 | expo-glass-effect@56.0.4(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3): | 16260 | expo-glass-effect@56.0.4(expo@56.0.12)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3): |
| 16036 | dependencies: | 16261 | dependencies: |
| 16037 | expo: 56.0.8(63f7aade424ad9e7b1154b679fa2a14d) | 16262 | expo: 56.0.12(@babel/core@7.29.7)(@expo/dom-webview@56.0.5)(@expo/metro-runtime@56.0.15)(bufferutil@4.1.0)(expo-router@56.2.11)(expo-widgets@56.0.19)(react-dom@19.2.3(react@19.2.3))(react-native-webview@13.16.1(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(typescript@6.0.3)(utf-8-validate@6.0.6) |
| 16038 | react: 19.2.3 | 16263 | react: 19.2.3 |
| 16039 | react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) | 16264 | react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) |
| 16040 | 16265 | ||
| 16041 | expo-haptics@56.0.3(expo@56.0.8): | 16266 | expo-haptics@56.0.3(expo@56.0.12): |
| 16042 | dependencies: | 16267 | dependencies: |
| 16043 | expo: 56.0.8(63f7aade424ad9e7b1154b679fa2a14d) | 16268 | expo: 56.0.12(@babel/core@7.29.7)(@expo/dom-webview@56.0.5)(@expo/metro-runtime@56.0.15)(bufferutil@4.1.0)(expo-router@56.2.11)(expo-widgets@56.0.19)(react-dom@19.2.3(react@19.2.3))(react-native-webview@13.16.1(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(typescript@6.0.3)(utf-8-validate@6.0.6) |
| 16044 | 16269 | ||
| 16045 | expo-image-loader@56.0.3(expo@56.0.8): | 16270 | expo-image-loader@56.0.3(expo@56.0.12): |
| 16046 | dependencies: | 16271 | dependencies: |
| 16047 | expo: 56.0.8(63f7aade424ad9e7b1154b679fa2a14d) | 16272 | expo: 56.0.12(@babel/core@7.29.7)(@expo/dom-webview@56.0.5)(@expo/metro-runtime@56.0.15)(bufferutil@4.1.0)(expo-router@56.2.11)(expo-widgets@56.0.19)(react-dom@19.2.3(react@19.2.3))(react-native-webview@13.16.1(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(typescript@6.0.3)(utf-8-validate@6.0.6) |
| 16048 | 16273 | ||
| 16049 | expo-image-picker@56.0.15(expo@56.0.8): | 16274 | expo-image-picker@56.0.18(expo@56.0.12): |
| 16050 | dependencies: | 16275 | dependencies: |
| 16051 | expo: 56.0.8(63f7aade424ad9e7b1154b679fa2a14d) | 16276 | expo: 56.0.12(@babel/core@7.29.7)(@expo/dom-webview@56.0.5)(@expo/metro-runtime@56.0.15)(bufferutil@4.1.0)(expo-router@56.2.11)(expo-widgets@56.0.19)(react-dom@19.2.3(react@19.2.3))(react-native-webview@13.16.1(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(typescript@6.0.3)(utf-8-validate@6.0.6) |
| 16052 | expo-image-loader: 56.0.3(expo@56.0.8) | 16277 | expo-image-loader: 56.0.3(expo@56.0.12) |
| 16053 | 16278 | ||
| 16054 | expo-json-utils@56.0.0: {} | 16279 | expo-json-utils@56.0.0: {} |
| 16055 | 16280 | ||
| 16056 | expo-keep-awake@56.0.3(expo@56.0.8)(react@19.2.3): | 16281 | expo-keep-awake@56.0.3(expo@56.0.12)(react@19.2.3): |
| 16057 | dependencies: | 16282 | dependencies: |
| 16058 | expo: 56.0.8(63f7aade424ad9e7b1154b679fa2a14d) | 16283 | expo: 56.0.12(@babel/core@7.29.7)(@expo/dom-webview@56.0.5)(@expo/metro-runtime@56.0.15)(bufferutil@4.1.0)(expo-router@56.2.11)(expo-widgets@56.0.19)(react-dom@19.2.3(react@19.2.3))(react-native-webview@13.16.1(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(typescript@6.0.3)(utf-8-validate@6.0.6) |
| 16059 | react: 19.2.3 | 16284 | react: 19.2.3 |
| 16060 | 16285 | ||
| 16061 | expo-linking@56.0.13(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3): | 16286 | expo-linking@56.0.14(expo@56.0.12)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3): |
| 16062 | dependencies: | 16287 | dependencies: |
| 16063 | expo-constants: 56.0.16(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6)) | 16288 | expo-constants: 56.0.18(expo@56.0.12)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6)) |
| 16064 | invariant: 2.2.4 | 16289 | invariant: 2.2.4 |
| 16065 | react: 19.2.3 | 16290 | react: 19.2.3 |
| 16066 | react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) | 16291 | react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) |
| 16067 | transitivePeerDependencies: | 16292 | transitivePeerDependencies: |
| 16068 | - expo | 16293 | - expo |
| 16069 | - supports-color | 16294 | - supports-color |
| 16070 | 16295 | ||
| 16071 | expo-manifests@56.0.4(expo@56.0.8): | 16296 | expo-manifests@56.0.4(expo@56.0.12): |
| 16072 | dependencies: | 16297 | dependencies: |
| 16073 | expo: 56.0.8(63f7aade424ad9e7b1154b679fa2a14d) | 16298 | expo: 56.0.12(@babel/core@7.29.7)(@expo/dom-webview@56.0.5)(@expo/metro-runtime@56.0.15)(bufferutil@4.1.0)(expo-router@56.2.11)(expo-widgets@56.0.19)(react-dom@19.2.3(react@19.2.3))(react-native-webview@13.16.1(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(typescript@6.0.3)(utf-8-validate@6.0.6) |
| 16074 | expo-json-utils: 56.0.0 | 16299 | expo-json-utils: 56.0.0 |
| 16075 | 16300 | ||
| 16076 | expo-modules-autolinking@56.0.14(typescript@6.0.3): | 16301 | expo-modules-autolinking@56.0.16(typescript@6.0.3): |
| 16077 | dependencies: | 16302 | dependencies: |
| 16078 | '@expo/require-utils': 56.1.3(typescript@6.0.3) | 16303 | '@expo/require-utils': 56.1.3(typescript@6.0.3) |
| 16079 | '@expo/spawn-async': 1.8.0 | 16304 | '@expo/spawn-async': 1.8.0 |
| ... | @@ -16083,66 +16308,66 @@ snapshots: | ... | @@ -16083,66 +16308,66 @@ snapshots: |
| 16083 | - supports-color | 16308 | - supports-color |
| 16084 | - typescript | 16309 | - typescript |
| 16085 | 16310 | ||
| 16086 | expo-modules-core@56.0.14(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3): | 16311 | expo-modules-core@56.0.17(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3): |
| 16087 | dependencies: | 16312 | dependencies: |
| 16088 | '@expo/expo-modules-macros-plugin': 0.0.9 | 16313 | '@expo/expo-modules-macros-plugin': 0.2.2 |
| 16089 | expo-modules-jsi: 56.0.7(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6)) | 16314 | expo-modules-jsi: 56.0.10(patch_hash=9170f8074ae4e35a0a086e756c8f815794fd3abe51eac67ca3ba02804225ec1f)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6)) |
| 16090 | invariant: 2.2.4 | 16315 | invariant: 2.2.4 |
| 16091 | react: 19.2.3 | 16316 | react: 19.2.3 |
| 16092 | react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) | 16317 | react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) |
| 16093 | optionalDependencies: | 16318 | optionalDependencies: |
| 16094 | react-native-worklets: 0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) | 16319 | react-native-worklets: 0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) |
| 16095 | 16320 | ||
| 16096 | expo-modules-jsi@56.0.7(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6)): | 16321 | expo-modules-jsi@56.0.10(patch_hash=9170f8074ae4e35a0a086e756c8f815794fd3abe51eac67ca3ba02804225ec1f)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6)): |
| 16097 | dependencies: | 16322 | dependencies: |
| 16098 | react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) | 16323 | react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) |
| 16099 | 16324 | ||
| 16100 | expo-network@56.0.5(expo@56.0.8)(react@19.2.3): | 16325 | expo-network@56.0.5(expo@56.0.12)(react@19.2.3): |
| 16101 | dependencies: | 16326 | dependencies: |
| 16102 | expo: 56.0.8(63f7aade424ad9e7b1154b679fa2a14d) | 16327 | expo: 56.0.12(@babel/core@7.29.7)(@expo/dom-webview@56.0.5)(@expo/metro-runtime@56.0.15)(bufferutil@4.1.0)(expo-router@56.2.11)(expo-widgets@56.0.19)(react-dom@19.2.3(react@19.2.3))(react-native-webview@13.16.1(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(typescript@6.0.3)(utf-8-validate@6.0.6) |
| 16103 | react: 19.2.3 | 16328 | react: 19.2.3 |
| 16104 | 16329 | ||
| 16105 | expo-notifications@56.0.15(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(typescript@6.0.3): | 16330 | expo-notifications@56.0.18(expo@56.0.12)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(typescript@6.0.3): |
| 16106 | dependencies: | 16331 | dependencies: |
| 16107 | '@expo/image-utils': 0.10.1(typescript@6.0.3) | 16332 | '@expo/image-utils': 0.10.1(typescript@6.0.3) |
| 16108 | abort-controller: 3.0.0 | 16333 | abort-controller: 3.0.0 |
| 16109 | badgin: 1.2.3 | 16334 | badgin: 1.2.3 |
| 16110 | expo: 56.0.8(63f7aade424ad9e7b1154b679fa2a14d) | 16335 | expo: 56.0.12(@babel/core@7.29.7)(@expo/dom-webview@56.0.5)(@expo/metro-runtime@56.0.15)(bufferutil@4.1.0)(expo-router@56.2.11)(expo-widgets@56.0.19)(react-dom@19.2.3(react@19.2.3))(react-native-webview@13.16.1(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(typescript@6.0.3)(utf-8-validate@6.0.6) |
| 16111 | expo-application: 56.0.3(expo@56.0.8) | 16336 | expo-application: 56.0.3(expo@56.0.12) |
| 16112 | expo-constants: 56.0.16(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6)) | 16337 | expo-constants: 56.0.18(expo@56.0.12)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6)) |
| 16113 | react: 19.2.3 | 16338 | react: 19.2.3 |
| 16114 | react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) | 16339 | react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) |
| 16115 | transitivePeerDependencies: | 16340 | transitivePeerDependencies: |
| 16116 | - supports-color | 16341 | - supports-color |
| 16117 | - typescript | 16342 | - typescript |
| 16118 | 16343 | ||
| 16119 | expo-paste-input@0.1.15(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3): | 16344 | expo-paste-input@0.1.15(expo@56.0.12)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3): |
| 16120 | dependencies: | 16345 | dependencies: |
| 16121 | expo: 56.0.8(63f7aade424ad9e7b1154b679fa2a14d) | 16346 | expo: 56.0.12(@babel/core@7.29.7)(@expo/dom-webview@56.0.5)(@expo/metro-runtime@56.0.15)(bufferutil@4.1.0)(expo-router@56.2.11)(expo-widgets@56.0.19)(react-dom@19.2.3(react@19.2.3))(react-native-webview@13.16.1(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(typescript@6.0.3)(utf-8-validate@6.0.6) |
| 16122 | react: 19.2.3 | 16347 | react: 19.2.3 |
| 16123 | react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) | 16348 | react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) |
| 16124 | 16349 | ||
| 16125 | expo-router@56.2.8(5bfdf39b8f760e4dc2d5c3acffc97310): | 16350 | expo-router@56.2.11(3af01b44e50aae5a8ae60852f3cf7e3f): |
| 16126 | dependencies: | 16351 | dependencies: |
| 16127 | '@expo/log-box': 56.0.12(@expo/dom-webview@56.0.5)(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) | 16352 | '@expo/log-box': 56.0.13(@expo/dom-webview@56.0.5)(expo@56.0.12)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) |
| 16128 | '@expo/metro-runtime': 56.0.13(@expo/log-box@56.0.12)(expo@56.0.8)(react-dom@19.2.3(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) | 16353 | '@expo/metro-runtime': 56.0.15(@expo/log-box@56.0.13)(expo@56.0.12)(react-dom@19.2.3(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) |
| 16129 | '@expo/schema-utils': 56.0.1 | 16354 | '@expo/schema-utils': 56.0.1 |
| 16130 | '@expo/ui': 56.0.15(961c4aa6f32829b318e3c87ef20ad401) | 16355 | '@expo/ui': 56.0.18(19413efe5eaad64848598eedfe3a0fd3) |
| 16131 | '@radix-ui/react-slot': 1.2.4(@types/react@19.2.16)(react@19.2.3) | 16356 | '@radix-ui/react-slot': 1.2.4(@types/react@19.2.16)(react@19.2.3) |
| 16132 | '@radix-ui/react-tabs': 1.1.13(@types/react-dom@19.2.3(@types/react@19.2.16))(@types/react@19.2.16)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) | 16357 | '@radix-ui/react-tabs': 1.1.13(@types/react-dom@19.2.3(@types/react@19.2.16))(@types/react@19.2.16)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) |
| 16133 | '@react-native-masked-view/masked-view': 0.3.2(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) | 16358 | '@react-native-masked-view/masked-view': 0.3.2(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) |
| 16134 | '@testing-library/jest-dom': 6.9.1 | 16359 | '@testing-library/jest-dom': 6.9.1 |
| 16135 | '@testing-library/user-event': 14.6.1(@testing-library/dom@10.4.1) | 16360 | '@testing-library/user-event': 14.6.1(@testing-library/dom@10.4.1) |
| 16136 | client-only: 0.0.1 | 16361 | client-only: 0.0.1 |
| 16137 | color: 4.2.3 | 16362 | color: 4.2.3 |
| 16138 | debug: 4.4.3 | 16363 | debug: 4.4.3 |
| 16139 | escape-string-regexp: 4.0.0 | 16364 | escape-string-regexp: 4.0.0 |
| 16140 | expo: 56.0.8(63f7aade424ad9e7b1154b679fa2a14d) | 16365 | expo: 56.0.12(@babel/core@7.29.7)(@expo/dom-webview@56.0.5)(@expo/metro-runtime@56.0.15)(bufferutil@4.1.0)(expo-router@56.2.11)(expo-widgets@56.0.19)(react-dom@19.2.3(react@19.2.3))(react-native-webview@13.16.1(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(typescript@6.0.3)(utf-8-validate@6.0.6) |
| 16141 | expo-constants: 56.0.16(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6)) | 16366 | expo-constants: 56.0.18(expo@56.0.12)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6)) |
| 16142 | expo-glass-effect: 56.0.4(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) | 16367 | expo-glass-effect: 56.0.4(expo@56.0.12)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) |
| 16143 | expo-linking: 56.0.13(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) | 16368 | expo-linking: 56.0.14(expo@56.0.12)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) |
| 16144 | expo-server: 56.0.4 | 16369 | expo-server: 56.0.5 |
| 16145 | expo-symbols: 56.0.5(expo-font@56.0.5)(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) | 16370 | expo-symbols: 56.0.6(expo-font@56.0.7)(expo@56.0.12)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) |
| 16146 | fast-deep-equal: 3.1.3 | 16371 | fast-deep-equal: 3.1.3 |
| 16147 | invariant: 2.2.4 | 16372 | invariant: 2.2.4 |
| 16148 | nanoid: 3.3.12 | 16373 | nanoid: 3.3.12 |
| ... | @@ -16150,18 +16375,19 @@ snapshots: | ... | @@ -16150,18 +16375,19 @@ snapshots: |
| 16150 | react: 19.2.3 | 16375 | react: 19.2.3 |
| 16151 | react-fast-compare: 3.2.2 | 16376 | react-fast-compare: 3.2.2 |
| 16152 | react-is: 19.2.7 | 16377 | react-is: 19.2.7 |
| 16153 | react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) | 16378 | react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) |
| 16154 | react-native-drawer-layout: 4.2.4(0e9729601f58a7a7ae26c76fe6017455) | 16379 | react-native-drawer-layout: 4.2.4(react-native-gesture-handler@2.31.2(patch_hash=808eb26f9e57cf4945efd3985af4d9c764da6f91f4c9764433cc868602bbf4d3)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native-reanimated@4.3.1(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) |
| 16155 | react-native-safe-area-context: 5.7.0(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) | 16380 | react-native-safe-area-context: 5.7.0(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) |
| 16156 | react-native-screens: 4.25.2(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) | 16381 | react-native-screens: 4.25.2(patch_hash=e1b49230abc20a663115cc3f34ca2f63764a873485ccb67f2bd43b85ce2a94dc)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) |
| 16157 | server-only: 0.0.1 | 16382 | server-only: 0.0.1 |
| 16158 | sf-symbols-typescript: 2.2.0 | 16383 | sf-symbols-typescript: 2.2.0 |
| 16159 | shallowequal: 1.1.0 | 16384 | shallowequal: 1.1.0 |
| 16385 | standard-navigation: 0.0.5 | ||
| 16160 | vaul: 1.1.2(@types/react-dom@19.2.3(@types/react@19.2.16))(@types/react@19.2.16)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) | 16386 | vaul: 1.1.2(@types/react-dom@19.2.3(@types/react@19.2.16))(@types/react@19.2.16)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) |
| 16161 | optionalDependencies: | 16387 | optionalDependencies: |
| 16162 | react-dom: 19.2.3(react@19.2.3) | 16388 | react-dom: 19.2.3(react@19.2.3) |
| 16163 | react-native-gesture-handler: 2.31.2(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) | 16389 | react-native-gesture-handler: 2.31.2(patch_hash=808eb26f9e57cf4945efd3985af4d9c764da6f91f4c9764433cc868602bbf4d3)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) |
| 16164 | react-native-reanimated: 4.3.1(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) | 16390 | react-native-reanimated: 4.3.1(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) |
| 16165 | transitivePeerDependencies: | 16391 | transitivePeerDependencies: |
| 16166 | - '@babel/core' | 16392 | - '@babel/core' |
| 16167 | - '@testing-library/dom' | 16393 | - '@testing-library/dom' |
| ... | @@ -16170,18 +16396,19 @@ snapshots: | ... | @@ -16170,18 +16396,19 @@ snapshots: |
| 16170 | - expo-font | 16396 | - expo-font |
| 16171 | - react-native-worklets | 16397 | - react-native-worklets |
| 16172 | - supports-color | 16398 | - supports-color |
| 16399 | optional: true | ||
| 16173 | 16400 | ||
| 16174 | expo-secure-store@56.0.4(expo@56.0.8): | 16401 | expo-secure-store@56.0.4(expo@56.0.12): |
| 16175 | dependencies: | 16402 | dependencies: |
| 16176 | expo: 56.0.8(63f7aade424ad9e7b1154b679fa2a14d) | 16403 | expo: 56.0.12(@babel/core@7.29.7)(@expo/dom-webview@56.0.5)(@expo/metro-runtime@56.0.15)(bufferutil@4.1.0)(expo-router@56.2.11)(expo-widgets@56.0.19)(react-dom@19.2.3(react@19.2.3))(react-native-webview@13.16.1(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(typescript@6.0.3)(utf-8-validate@6.0.6) |
| 16177 | 16404 | ||
| 16178 | expo-server@56.0.4: {} | 16405 | expo-server@56.0.5: {} |
| 16179 | 16406 | ||
| 16180 | expo-splash-screen@56.0.10(expo@56.0.8)(typescript@6.0.3): | 16407 | expo-splash-screen@56.0.10(expo@56.0.12)(typescript@6.0.3): |
| 16181 | dependencies: | 16408 | dependencies: |
| 16182 | '@expo/config-plugins': 56.0.8(typescript@6.0.3) | 16409 | '@expo/config-plugins': 56.0.8(typescript@6.0.3) |
| 16183 | '@expo/image-utils': 0.10.1(typescript@6.0.3) | 16410 | '@expo/image-utils': 0.10.1(typescript@6.0.3) |
| 16184 | expo: 56.0.8(63f7aade424ad9e7b1154b679fa2a14d) | 16411 | expo: 56.0.12(@babel/core@7.29.7)(@expo/dom-webview@56.0.5)(@expo/metro-runtime@56.0.15)(bufferutil@4.1.0)(expo-router@56.2.11)(expo-widgets@56.0.19)(react-dom@19.2.3(react@19.2.3))(react-native-webview@13.16.1(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(typescript@6.0.3)(utf-8-validate@6.0.6) |
| 16185 | xml2js: 0.6.0 | 16412 | xml2js: 0.6.0 |
| 16186 | transitivePeerDependencies: | 16413 | transitivePeerDependencies: |
| 16187 | - supports-color | 16414 | - supports-color |
| ... | @@ -16189,20 +16416,20 @@ snapshots: | ... | @@ -16189,20 +16416,20 @@ snapshots: |
| 16189 | 16416 | ||
| 16190 | expo-structured-headers@56.0.0: {} | 16417 | expo-structured-headers@56.0.0: {} |
| 16191 | 16418 | ||
| 16192 | expo-symbols@56.0.5(expo-font@56.0.5)(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3): | 16419 | expo-symbols@56.0.6(expo-font@56.0.7)(expo@56.0.12)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3): |
| 16193 | dependencies: | 16420 | dependencies: |
| 16194 | '@expo-google-fonts/material-symbols': 0.4.38 | 16421 | '@expo-google-fonts/material-symbols': 0.4.38 |
| 16195 | expo: 56.0.8(63f7aade424ad9e7b1154b679fa2a14d) | 16422 | expo: 56.0.12(@babel/core@7.29.7)(@expo/dom-webview@56.0.5)(@expo/metro-runtime@56.0.15)(bufferutil@4.1.0)(expo-router@56.2.11)(expo-widgets@56.0.19)(react-dom@19.2.3(react@19.2.3))(react-native-webview@13.16.1(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(typescript@6.0.3)(utf-8-validate@6.0.6) |
| 16196 | expo-font: 56.0.5(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) | 16423 | expo-font: 56.0.7(expo@56.0.12)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) |
| 16197 | react: 19.2.3 | 16424 | react: 19.2.3 |
| 16198 | react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) | 16425 | react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) |
| 16199 | sf-symbols-typescript: 2.2.0 | 16426 | sf-symbols-typescript: 2.2.0 |
| 16200 | 16427 | ||
| 16201 | expo-updates-interface@56.0.2(expo@56.0.8): | 16428 | expo-updates-interface@56.0.2(expo@56.0.12): |
| 16202 | dependencies: | 16429 | dependencies: |
| 16203 | expo: 56.0.8(63f7aade424ad9e7b1154b679fa2a14d) | 16430 | expo: 56.0.12(@babel/core@7.29.7)(@expo/dom-webview@56.0.5)(@expo/metro-runtime@56.0.15)(bufferutil@4.1.0)(expo-router@56.2.11)(expo-widgets@56.0.19)(react-dom@19.2.3(react@19.2.3))(react-native-webview@13.16.1(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(typescript@6.0.3)(utf-8-validate@6.0.6) |
| 16204 | 16431 | ||
| 16205 | expo-updates@56.0.17(expo-dev-client@56.0.18(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6)))(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3): | 16432 | expo-updates@56.0.19(expo-dev-client@56.0.20(expo@56.0.12)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6)))(expo@56.0.12)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3): |
| 16206 | dependencies: | 16433 | dependencies: |
| 16207 | '@expo/code-signing-certificates': 0.0.6 | 16434 | '@expo/code-signing-certificates': 0.0.6 |
| 16208 | '@expo/plist': 0.7.0 | 16435 | '@expo/plist': 0.7.0 |
| ... | @@ -16210,35 +16437,35 @@ snapshots: | ... | @@ -16210,35 +16437,35 @@ snapshots: |
| 16210 | arg: 4.1.3 | 16437 | arg: 4.1.3 |
| 16211 | chalk: 4.1.2 | 16438 | chalk: 4.1.2 |
| 16212 | debug: 4.4.3 | 16439 | debug: 4.4.3 |
| 16213 | expo: 56.0.8(63f7aade424ad9e7b1154b679fa2a14d) | 16440 | expo: 56.0.12(@babel/core@7.29.7)(@expo/dom-webview@56.0.5)(@expo/metro-runtime@56.0.15)(bufferutil@4.1.0)(expo-router@56.2.11)(expo-widgets@56.0.19)(react-dom@19.2.3(react@19.2.3))(react-native-webview@13.16.1(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(typescript@6.0.3)(utf-8-validate@6.0.6) |
| 16214 | expo-eas-client: 56.0.1 | 16441 | expo-eas-client: 56.0.1 |
| 16215 | expo-manifests: 56.0.4(expo@56.0.8) | 16442 | expo-manifests: 56.0.4(expo@56.0.12) |
| 16216 | expo-structured-headers: 56.0.0 | 16443 | expo-structured-headers: 56.0.0 |
| 16217 | expo-updates-interface: 56.0.2(expo@56.0.8) | 16444 | expo-updates-interface: 56.0.2(expo@56.0.12) |
| 16218 | getenv: 2.0.0 | 16445 | getenv: 2.0.0 |
| 16219 | glob: 13.0.6 | 16446 | glob: 13.0.6 |
| 16220 | ignore: 5.3.2 | 16447 | ignore: 5.3.2 |
| 16221 | nullthrows: 1.1.1 | 16448 | nullthrows: 1.1.1 |
| 16222 | react: 19.2.3 | 16449 | react: 19.2.3 |
| 16223 | react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) | 16450 | react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) |
| 16224 | resolve-from: 5.0.0 | 16451 | resolve-from: 5.0.0 |
| 16225 | optionalDependencies: | 16452 | optionalDependencies: |
| 16226 | expo-dev-client: 56.0.18(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6)) | 16453 | expo-dev-client: 56.0.20(expo@56.0.12)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6)) |
| 16227 | transitivePeerDependencies: | 16454 | transitivePeerDependencies: |
| 16228 | - supports-color | 16455 | - supports-color |
| 16229 | 16456 | ||
| 16230 | expo-web-browser@56.0.5(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6)): | 16457 | expo-web-browser@56.0.5(expo@56.0.12)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6)): |
| 16231 | dependencies: | 16458 | dependencies: |
| 16232 | expo: 56.0.8(63f7aade424ad9e7b1154b679fa2a14d) | 16459 | expo: 56.0.12(@babel/core@7.29.7)(@expo/dom-webview@56.0.5)(@expo/metro-runtime@56.0.15)(bufferutil@4.1.0)(expo-router@56.2.11)(expo-widgets@56.0.19)(react-dom@19.2.3(react@19.2.3))(react-native-webview@13.16.1(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(typescript@6.0.3)(utf-8-validate@6.0.6) |
| 16233 | react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) | 16460 | react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) |
| 16234 | 16461 | ||
| 16235 | expo-widgets@56.0.16(961c4aa6f32829b318e3c87ef20ad401): | 16462 | expo-widgets@56.0.19(19413efe5eaad64848598eedfe3a0fd3): |
| 16236 | dependencies: | 16463 | dependencies: |
| 16237 | '@expo/plist': 0.7.0 | 16464 | '@expo/plist': 0.7.0 |
| 16238 | '@expo/ui': 56.0.15(961c4aa6f32829b318e3c87ef20ad401) | 16465 | '@expo/ui': 56.0.18(19413efe5eaad64848598eedfe3a0fd3) |
| 16239 | expo: 56.0.8(63f7aade424ad9e7b1154b679fa2a14d) | 16466 | expo: 56.0.12(@babel/core@7.29.7)(@expo/dom-webview@56.0.5)(@expo/metro-runtime@56.0.15)(bufferutil@4.1.0)(expo-router@56.2.11)(expo-widgets@56.0.19)(react-dom@19.2.3(react@19.2.3))(react-native-webview@13.16.1(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(typescript@6.0.3)(utf-8-validate@6.0.6) |
| 16240 | react: 19.2.3 | 16467 | react: 19.2.3 |
| 16241 | react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) | 16468 | react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) |
| 16242 | transitivePeerDependencies: | 16469 | transitivePeerDependencies: |
| 16243 | - '@babel/core' | 16470 | - '@babel/core' |
| 16244 | - '@types/react' | 16471 | - '@types/react' |
| ... | @@ -16247,37 +16474,37 @@ snapshots: | ... | @@ -16247,37 +16474,37 @@ snapshots: |
| 16247 | - react-native-reanimated | 16474 | - react-native-reanimated |
| 16248 | - react-native-worklets | 16475 | - react-native-worklets |
| 16249 | 16476 | ||
| 16250 | expo@56.0.8(63f7aade424ad9e7b1154b679fa2a14d): | 16477 | expo@56.0.12(@babel/core@7.29.7)(@expo/dom-webview@56.0.5)(@expo/metro-runtime@56.0.15)(bufferutil@4.1.0)(expo-router@56.2.11)(expo-widgets@56.0.19)(react-dom@19.2.3(react@19.2.3))(react-native-webview@13.16.1(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(typescript@6.0.3)(utf-8-validate@6.0.6): |
| 16251 | dependencies: | 16478 | dependencies: |
| 16252 | '@babel/runtime': 7.29.7 | 16479 | '@babel/runtime': 7.29.7 |
| 16253 | '@expo/cli': 56.1.13(@expo/dom-webview@56.0.5)(@expo/metro-runtime@56.0.13)(bufferutil@4.1.0)(expo-constants@56.0.16)(expo-font@56.0.5)(expo-router@56.2.8)(expo@56.0.8)(react-dom@19.2.3(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(typescript@6.0.3)(utf-8-validate@6.0.6) | 16480 | '@expo/cli': 56.1.16(@expo/dom-webview@56.0.5)(@expo/metro-runtime@56.0.15)(bufferutil@4.1.0)(expo-constants@56.0.18)(expo-font@56.0.7)(expo-router@56.2.11)(expo@56.0.12)(react-dom@19.2.3(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(typescript@6.0.3)(utf-8-validate@6.0.6) |
| 16254 | '@expo/config': 56.0.9(typescript@6.0.3) | 16481 | '@expo/config': 56.0.9(typescript@6.0.3) |
| 16255 | '@expo/config-plugins': 56.0.8(typescript@6.0.3) | 16482 | '@expo/config-plugins': 56.0.9(typescript@6.0.3) |
| 16256 | '@expo/devtools': 56.0.2(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) | 16483 | '@expo/devtools': 56.0.2(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) |
| 16257 | '@expo/fingerprint': 0.19.3 | 16484 | '@expo/fingerprint': 0.19.4 |
| 16258 | '@expo/local-build-cache-provider': 56.0.8(typescript@6.0.3) | 16485 | '@expo/local-build-cache-provider': 56.0.8(typescript@6.0.3) |
| 16259 | '@expo/log-box': 56.0.12(@expo/dom-webview@56.0.5)(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) | 16486 | '@expo/log-box': 56.0.13(@expo/dom-webview@56.0.5)(expo@56.0.12)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) |
| 16260 | '@expo/metro': 56.0.0(bufferutil@4.1.0)(utf-8-validate@6.0.6) | 16487 | '@expo/metro': 56.0.0(bufferutil@4.1.0)(utf-8-validate@6.0.6) |
| 16261 | '@expo/metro-config': 56.0.13(patch_hash=8cb08b5bb7051ed9d2dbe46a2c293c5a1e17f1bd6ddf30de27909e18c921ff46)(bufferutil@4.1.0)(expo@56.0.8)(typescript@6.0.3)(utf-8-validate@6.0.6) | 16488 | '@expo/metro-config': 56.0.14(patch_hash=8cb08b5bb7051ed9d2dbe46a2c293c5a1e17f1bd6ddf30de27909e18c921ff46)(bufferutil@4.1.0)(expo@56.0.12)(typescript@6.0.3)(utf-8-validate@6.0.6) |
| 16262 | '@ungap/structured-clone': 1.3.1 | 16489 | '@ungap/structured-clone': 1.3.1 |
| 16263 | babel-preset-expo: 56.0.14(@babel/core@7.29.7)(@babel/runtime@7.29.7)(expo-widgets@56.0.16)(expo@56.0.8)(react-refresh@0.14.2) | 16490 | babel-preset-expo: 56.0.15(@babel/core@7.29.7)(@babel/runtime@7.29.7)(expo-widgets@56.0.19)(expo@56.0.12)(react-refresh@0.14.2) |
| 16264 | expo-asset: 56.0.15(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(typescript@6.0.3) | 16491 | expo-asset: 56.0.17(expo@56.0.12)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(typescript@6.0.3) |
| 16265 | expo-constants: 56.0.16(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6)) | 16492 | expo-constants: 56.0.18(expo@56.0.12)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6)) |
| 16266 | expo-file-system: 56.0.7(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6)) | 16493 | expo-file-system: 56.0.8(expo@56.0.12)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6)) |
| 16267 | expo-font: 56.0.5(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) | 16494 | expo-font: 56.0.7(expo@56.0.12)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) |
| 16268 | expo-keep-awake: 56.0.3(expo@56.0.8)(react@19.2.3) | 16495 | expo-keep-awake: 56.0.3(expo@56.0.12)(react@19.2.3) |
| 16269 | expo-modules-autolinking: 56.0.14(typescript@6.0.3) | 16496 | expo-modules-autolinking: 56.0.16(typescript@6.0.3) |
| 16270 | expo-modules-core: 56.0.14(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) | 16497 | expo-modules-core: 56.0.17(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) |
| 16271 | pretty-format: 29.7.0 | 16498 | pretty-format: 29.7.0 |
| 16272 | react: 19.2.3 | 16499 | react: 19.2.3 |
| 16273 | react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) | 16500 | react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) |
| 16274 | react-refresh: 0.14.2 | 16501 | react-refresh: 0.14.2 |
| 16275 | whatwg-url-minimum: 0.1.2 | 16502 | whatwg-url-minimum: 0.1.2 |
| 16276 | optionalDependencies: | 16503 | optionalDependencies: |
| 16277 | '@expo/dom-webview': 56.0.5(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) | 16504 | '@expo/dom-webview': 56.0.5(expo@56.0.12)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) |
| 16278 | '@expo/metro-runtime': 56.0.13(@expo/log-box@56.0.12)(expo@56.0.8)(react-dom@19.2.3(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) | 16505 | '@expo/metro-runtime': 56.0.15(@expo/log-box@56.0.13)(expo@56.0.12)(react-dom@19.2.3(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) |
| 16279 | react-dom: 19.2.3(react@19.2.3) | 16506 | react-dom: 19.2.3(react@19.2.3) |
| 16280 | react-native-webview: 13.16.1(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) | 16507 | react-native-webview: 13.16.1(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) |
| 16281 | transitivePeerDependencies: | 16508 | transitivePeerDependencies: |
| 16282 | - '@babel/core' | 16509 | - '@babel/core' |
| 16283 | - bufferutil | 16510 | - bufferutil |
| ... | @@ -16878,7 +17105,8 @@ snapshots: | ... | @@ -16878,7 +17105,8 @@ snapshots: |
| 16878 | 17105 | ||
| 16879 | immediate@3.0.6: {} | 17106 | immediate@3.0.6: {} |
| 16880 | 17107 | ||
| 16881 | indent-string@4.0.0: {} | 17108 | indent-string@4.0.0: |
| 17109 | optional: true | ||
| 16882 | 17110 | ||
| 16883 | indent-string@5.0.0: {} | 17111 | indent-string@5.0.0: {} |
| 16884 | 17112 | ||
| ... | @@ -17990,7 +18218,8 @@ snapshots: | ... | @@ -17990,7 +18218,8 @@ snapshots: |
| 17990 | 18218 | ||
| 17991 | mimic-response@3.1.0: {} | 18219 | mimic-response@3.1.0: {} |
| 17992 | 18220 | ||
| 17993 | min-indent@1.0.1: {} | 18221 | min-indent@1.0.1: |
| 18222 | optional: true | ||
| 17994 | 18223 | ||
| 17995 | minimatch@10.2.5: | 18224 | minimatch@10.2.5: |
| 17996 | dependencies: | 18225 | dependencies: |
| ... | @@ -18659,7 +18888,8 @@ snapshots: | ... | @@ -18659,7 +18888,8 @@ snapshots: |
| 18659 | dependencies: | 18888 | dependencies: |
| 18660 | react: 19.2.6 | 18889 | react: 19.2.6 |
| 18661 | 18890 | ||
| 18662 | react-fast-compare@3.2.2: {} | 18891 | react-fast-compare@3.2.2: |
| 18892 | optional: true | ||
| 18663 | 18893 | ||
| 18664 | react-freeze@1.0.4(react@19.2.3): | 18894 | react-freeze@1.0.4(react@19.2.3): |
| 18665 | dependencies: | 18895 | dependencies: |
| ... | @@ -18698,102 +18928,103 @@ snapshots: | ... | @@ -18698,102 +18928,103 @@ snapshots: |
| 18698 | transitivePeerDependencies: | 18928 | transitivePeerDependencies: |
| 18699 | - supports-color | 18929 | - supports-color |
| 18700 | 18930 | ||
| 18701 | react-native-drawer-layout@4.2.4(0e9729601f58a7a7ae26c76fe6017455): | 18931 | ? react-native-drawer-layout@4.2.4(react-native-gesture-handler@2.31.2(patch_hash=808eb26f9e57cf4945efd3985af4d9c764da6f91f4c9764433cc868602bbf4d3)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native-reanimated@4.3.1(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) |
| 18702 | dependencies: | 18932 | : dependencies: |
| 18703 | color: 4.2.3 | 18933 | color: 4.2.3 |
| 18704 | react: 19.2.3 | 18934 | react: 19.2.3 |
| 18705 | react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) | 18935 | react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) |
| 18706 | react-native-gesture-handler: 2.31.2(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) | 18936 | react-native-gesture-handler: 2.31.2(patch_hash=808eb26f9e57cf4945efd3985af4d9c764da6f91f4c9764433cc868602bbf4d3)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) |
| 18707 | react-native-reanimated: 4.3.1(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) | 18937 | react-native-reanimated: 4.3.1(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) |
| 18708 | use-latest-callback: 0.2.6(react@19.2.3) | 18938 | use-latest-callback: 0.2.6(react@19.2.3) |
| 18939 | optional: true | ||
| 18709 | 18940 | ||
| 18710 | react-native-gesture-handler@2.31.2(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3): | 18941 | react-native-gesture-handler@2.31.2(patch_hash=808eb26f9e57cf4945efd3985af4d9c764da6f91f4c9764433cc868602bbf4d3)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3): |
| 18711 | dependencies: | 18942 | dependencies: |
| 18712 | '@egjs/hammerjs': 2.0.17 | 18943 | '@egjs/hammerjs': 2.0.17 |
| 18713 | '@types/react-test-renderer': 19.1.0 | 18944 | '@types/react-test-renderer': 19.1.0 |
| 18714 | hoist-non-react-statics: 3.3.2 | 18945 | hoist-non-react-statics: 3.3.2 |
| 18715 | invariant: 2.2.4 | 18946 | invariant: 2.2.4 |
| 18716 | react: 19.2.3 | 18947 | react: 19.2.3 |
| 18717 | react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) | 18948 | react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) |
| 18718 | 18949 | ||
| 18719 | react-native-image-viewing@0.2.2(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3): | 18950 | react-native-image-viewing@0.2.2(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3): |
| 18720 | dependencies: | 18951 | dependencies: |
| 18721 | react: 19.2.3 | 18952 | react: 19.2.3 |
| 18722 | react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) | 18953 | react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) |
| 18723 | 18954 | ||
| 18724 | react-native-is-edge-to-edge@1.3.1(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3): | 18955 | react-native-is-edge-to-edge@1.3.1(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3): |
| 18725 | dependencies: | 18956 | dependencies: |
| 18726 | react: 19.2.3 | 18957 | react: 19.2.3 |
| 18727 | react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) | 18958 | react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) |
| 18728 | 18959 | ||
| 18729 | react-native-keyboard-controller@1.21.7(react-native-reanimated@4.3.1(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3): | 18960 | react-native-keyboard-controller@1.21.6(react-native-reanimated@4.3.1(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3): |
| 18730 | dependencies: | 18961 | dependencies: |
| 18731 | react: 19.2.3 | 18962 | react: 19.2.3 |
| 18732 | react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) | 18963 | react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) |
| 18733 | react-native-is-edge-to-edge: 1.3.1(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) | 18964 | react-native-is-edge-to-edge: 1.3.1(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) |
| 18734 | react-native-reanimated: 4.3.1(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) | 18965 | react-native-reanimated: 4.3.1(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) |
| 18735 | 18966 | ||
| 18736 | react-native-nitro-markdown@0.5.8(react-native-nitro-modules@0.35.9(patch_hash=825622aae63a8fb5b904f3c77908a0e216261d727ea171709f2c0b6088422675)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native-svg@15.15.4(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3): | 18967 | react-native-nitro-markdown@0.5.8(react-native-nitro-modules@0.35.9(patch_hash=825622aae63a8fb5b904f3c77908a0e216261d727ea171709f2c0b6088422675)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native-svg@15.15.4(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3): |
| 18737 | dependencies: | 18968 | dependencies: |
| 18738 | react: 19.2.3 | 18969 | react: 19.2.3 |
| 18739 | react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) | 18970 | react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) |
| 18740 | react-native-nitro-modules: 0.35.9(patch_hash=825622aae63a8fb5b904f3c77908a0e216261d727ea171709f2c0b6088422675)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) | 18971 | react-native-nitro-modules: 0.35.9(patch_hash=825622aae63a8fb5b904f3c77908a0e216261d727ea171709f2c0b6088422675)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) |
| 18741 | optionalDependencies: | 18972 | optionalDependencies: |
| 18742 | react-native-svg: 15.15.4(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) | 18973 | react-native-svg: 15.15.4(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) |
| 18743 | 18974 | ||
| 18744 | react-native-nitro-modules@0.35.9(patch_hash=825622aae63a8fb5b904f3c77908a0e216261d727ea171709f2c0b6088422675)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3): | 18975 | react-native-nitro-modules@0.35.9(patch_hash=825622aae63a8fb5b904f3c77908a0e216261d727ea171709f2c0b6088422675)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3): |
| 18745 | dependencies: | 18976 | dependencies: |
| 18746 | react: 19.2.3 | 18977 | react: 19.2.3 |
| 18747 | react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) | 18978 | react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) |
| 18748 | 18979 | ||
| 18749 | react-native-reanimated@4.3.1(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3): | 18980 | react-native-reanimated@4.3.1(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3): |
| 18750 | dependencies: | 18981 | dependencies: |
| 18751 | react: 19.2.3 | 18982 | react: 19.2.3 |
| 18752 | react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) | 18983 | react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) |
| 18753 | react-native-is-edge-to-edge: 1.3.1(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) | 18984 | react-native-is-edge-to-edge: 1.3.1(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) |
| 18754 | react-native-worklets: 0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) | 18985 | react-native-worklets: 0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) |
| 18755 | semver: 7.8.5 | 18986 | semver: 7.8.5 |
| 18756 | 18987 | ||
| 18757 | react-native-safe-area-context@5.7.0(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3): | 18988 | react-native-safe-area-context@5.7.0(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3): |
| 18758 | dependencies: | 18989 | dependencies: |
| 18759 | react: 19.2.3 | 18990 | react: 19.2.3 |
| 18760 | react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) | 18991 | react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) |
| 18761 | 18992 | ||
| 18762 | react-native-screens@4.25.2(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3): | 18993 | react-native-screens@4.25.2(patch_hash=e1b49230abc20a663115cc3f34ca2f63764a873485ccb67f2bd43b85ce2a94dc)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3): |
| 18763 | dependencies: | 18994 | dependencies: |
| 18764 | react: 19.2.3 | 18995 | react: 19.2.3 |
| 18765 | react-freeze: 1.0.4(react@19.2.3) | 18996 | react-freeze: 1.0.4(react@19.2.3) |
| 18766 | react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) | 18997 | react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) |
| 18767 | warn-once: 0.1.1 | 18998 | warn-once: 0.1.1 |
| 18768 | 18999 | ||
| 18769 | react-native-shiki-engine@0.3.12(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3): | 19000 | react-native-shiki-engine@0.3.12(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3): |
| 18770 | dependencies: | 19001 | dependencies: |
| 18771 | '@shikijs/types': 4.3.0 | 19002 | '@shikijs/types': 4.3.0 |
| 18772 | '@shikijs/vscode-textmate': 10.0.2 | 19003 | '@shikijs/vscode-textmate': 10.0.2 |
| 18773 | react: 19.2.3 | 19004 | react: 19.2.3 |
| 18774 | react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) | 19005 | react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) |
| 18775 | 19006 | ||
| 18776 | react-native-svg@15.15.4(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3): | 19007 | react-native-svg@15.15.4(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3): |
| 18777 | dependencies: | 19008 | dependencies: |
| 18778 | css-select: 5.2.2 | 19009 | css-select: 5.2.2 |
| 18779 | css-tree: 1.1.3 | 19010 | css-tree: 1.1.3 |
| 18780 | react: 19.2.3 | 19011 | react: 19.2.3 |
| 18781 | react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) | 19012 | react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) |
| 18782 | warn-once: 0.1.1 | 19013 | warn-once: 0.1.1 |
| 18783 | 19014 | ||
| 18784 | react-native-url-polyfill@2.0.0(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6)): | 19015 | react-native-url-polyfill@2.0.0(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6)): |
| 18785 | dependencies: | 19016 | dependencies: |
| 18786 | react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) | 19017 | react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) |
| 18787 | whatwg-url-without-unicode: 8.0.0-3 | 19018 | whatwg-url-without-unicode: 8.0.0-3 |
| 18788 | 19019 | ||
| 18789 | react-native-webview@13.16.1(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3): | 19020 | react-native-webview@13.16.1(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3): |
| 18790 | dependencies: | 19021 | dependencies: |
| 18791 | escape-string-regexp: 4.0.0 | 19022 | escape-string-regexp: 4.0.0 |
| 18792 | invariant: 2.2.4 | 19023 | invariant: 2.2.4 |
| 18793 | react: 19.2.3 | 19024 | react: 19.2.3 |
| 18794 | react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) | 19025 | react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) |
| 18795 | 19026 | ||
| 18796 | react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3): | 19027 | react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3): |
| 18797 | dependencies: | 19028 | dependencies: |
| 18798 | '@babel/core': 7.29.7 | 19029 | '@babel/core': 7.29.7 |
| 18799 | '@babel/plugin-transform-arrow-functions': 7.29.7(@babel/core@7.29.7) | 19030 | '@babel/plugin-transform-arrow-functions': 7.29.7(@babel/core@7.29.7) |
| ... | @@ -18805,23 +19036,23 @@ snapshots: | ... | @@ -18805,23 +19036,23 @@ snapshots: |
| 18805 | '@babel/plugin-transform-template-literals': 7.29.7(@babel/core@7.29.7) | 19036 | '@babel/plugin-transform-template-literals': 7.29.7(@babel/core@7.29.7) |
| 18806 | '@babel/plugin-transform-unicode-regex': 7.29.7(@babel/core@7.29.7) | 19037 | '@babel/plugin-transform-unicode-regex': 7.29.7(@babel/core@7.29.7) |
| 18807 | '@babel/preset-typescript': 7.29.7(@babel/core@7.29.7) | 19038 | '@babel/preset-typescript': 7.29.7(@babel/core@7.29.7) |
| 18808 | '@react-native/metro-config': 0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6) | 19039 | '@react-native/metro-config': 0.85.3(@babel/core@7.29.7) |
| 18809 | convert-source-map: 2.0.0 | 19040 | convert-source-map: 2.0.0 |
| 18810 | react: 19.2.3 | 19041 | react: 19.2.3 |
| 18811 | react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) | 19042 | react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) |
| 18812 | semver: 7.8.5 | 19043 | semver: 7.8.5 |
| 18813 | transitivePeerDependencies: | 19044 | transitivePeerDependencies: |
| 18814 | - supports-color | 19045 | - supports-color |
| 18815 | 19046 | ||
| 18816 | react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6): | 19047 | react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6): |
| 18817 | dependencies: | 19048 | dependencies: |
| 18818 | '@react-native/assets-registry': 0.85.3 | 19049 | '@react-native/assets-registry': 0.85.3 |
| 18819 | '@react-native/codegen': 0.85.3(@babel/core@7.29.7) | 19050 | '@react-native/codegen': 0.85.3(@babel/core@7.29.7) |
| 18820 | '@react-native/community-cli-plugin': 0.85.3(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(bufferutil@4.1.0)(utf-8-validate@6.0.6) | 19051 | '@react-native/community-cli-plugin': 0.85.3(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(bufferutil@4.1.0)(utf-8-validate@6.0.6) |
| 18821 | '@react-native/gradle-plugin': 0.85.3 | 19052 | '@react-native/gradle-plugin': 0.85.3 |
| 18822 | '@react-native/js-polyfills': 0.85.3 | 19053 | '@react-native/js-polyfills': 0.85.3 |
| 18823 | '@react-native/normalize-colors': 0.85.3 | 19054 | '@react-native/normalize-colors': 0.85.3 |
| 18824 | '@react-native/virtualized-lists': 0.85.3(@types/react@19.2.16)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) | 19055 | '@react-native/virtualized-lists': 0.85.3(@types/react@19.2.16)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) |
| 18825 | abort-controller: 3.0.0 | 19056 | abort-controller: 3.0.0 |
| 18826 | anser: 1.4.10 | 19057 | anser: 1.4.10 |
| 18827 | ansi-regex: 5.0.1 | 19058 | ansi-regex: 5.0.1 |
| ... | @@ -18920,6 +19151,7 @@ snapshots: | ... | @@ -18920,6 +19151,7 @@ snapshots: |
| 18920 | dependencies: | 19151 | dependencies: |
| 18921 | indent-string: 4.0.0 | 19152 | indent-string: 4.0.0 |
| 18922 | strip-indent: 3.0.0 | 19153 | strip-indent: 3.0.0 |
| 19154 | optional: true | ||
| 18923 | 19155 | ||
| 18924 | redis-errors@1.2.0: {} | 19156 | redis-errors@1.2.0: {} |
| 18925 | 19157 | ||
| ... | @@ -19351,7 +19583,8 @@ snapshots: | ... | @@ -19351,7 +19583,8 @@ snapshots: |
| 19351 | transitivePeerDependencies: | 19583 | transitivePeerDependencies: |
| 19352 | - supports-color | 19584 | - supports-color |
| 19353 | 19585 | ||
| 19354 | server-only@0.0.1: {} | 19586 | server-only@0.0.1: |
| 19587 | optional: true | ||
| 19355 | 19588 | ||
| 19356 | setimmediate@1.0.5: {} | 19589 | setimmediate@1.0.5: {} |
| 19357 | 19590 | ||
| ... | @@ -19359,7 +19592,8 @@ snapshots: | ... | @@ -19359,7 +19592,8 @@ snapshots: |
| 19359 | 19592 | ||
| 19360 | sf-symbols-typescript@2.2.0: {} | 19593 | sf-symbols-typescript@2.2.0: {} |
| 19361 | 19594 | ||
| 19362 | shallowequal@1.1.0: {} | 19595 | shallowequal@1.1.0: |
| 19596 | optional: true | ||
| 19363 | 19597 | ||
| 19364 | sharp@0.34.5: | 19598 | sharp@0.34.5: |
| 19365 | dependencies: | 19599 | dependencies: |
| ... | @@ -19523,6 +19757,11 @@ snapshots: | ... | @@ -19523,6 +19757,11 @@ snapshots: |
| 19523 | 19757 | ||
| 19524 | standard-as-callback@2.1.0: {} | 19758 | standard-as-callback@2.1.0: {} |
| 19525 | 19759 | ||
| 19760 | standard-navigation@0.0.5: | ||
| 19761 | optional: true | ||
| 19762 | |||
| 19763 | standard-navigation@0.0.7: {} | ||
| 19764 | |||
| 19526 | standardwebhooks@1.0.0: | 19765 | standardwebhooks@1.0.0: |
| 19527 | dependencies: | 19766 | dependencies: |
| 19528 | '@stablelib/base64': 1.0.1 | 19767 | '@stablelib/base64': 1.0.1 |
| ... | @@ -19585,6 +19824,7 @@ snapshots: | ... | @@ -19585,6 +19824,7 @@ snapshots: |
| 19585 | strip-indent@3.0.0: | 19824 | strip-indent@3.0.0: |
| 19586 | dependencies: | 19825 | dependencies: |
| 19587 | min-indent: 1.0.1 | 19826 | min-indent: 1.0.1 |
| 19827 | optional: true | ||
| 19588 | 19828 | ||
| 19589 | strnum@2.3.0: {} | 19829 | strnum@2.3.0: {} |
| 19590 | 19830 | ||
| ... | @@ -19870,14 +20110,14 @@ snapshots: | ... | @@ -19870,14 +20110,14 @@ snapshots: |
| 19870 | 20110 | ||
| 19871 | universalify@2.0.1: {} | 20111 | universalify@2.0.1: {} |
| 19872 | 20112 | ||
| 19873 | uniwind@1.7.0(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(tailwindcss@4.3.0): | 20113 | uniwind@1.7.0(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(tailwindcss@4.3.0): |
| 19874 | dependencies: | 20114 | dependencies: |
| 19875 | '@tailwindcss/node': 4.2.1 | 20115 | '@tailwindcss/node': 4.2.1 |
| 19876 | '@tailwindcss/oxide': 4.2.1 | 20116 | '@tailwindcss/oxide': 4.2.1 |
| 19877 | culori: 4.0.2 | 20117 | culori: 4.0.2 |
| 19878 | lightningcss: 1.30.1 | 20118 | lightningcss: 1.30.1 |
| 19879 | react: 19.2.3 | 20119 | react: 19.2.3 |
| 19880 | react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) | 20120 | react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) |
| 19881 | tailwindcss: 4.3.0 | 20121 | tailwindcss: 4.3.0 |
| 19882 | 20122 | ||
| 19883 | unpipe@1.0.0: {} | 20123 | unpipe@1.0.0: {} |
pnpm-workspace.yaml+19-19| ... | @@ -20,13 +20,13 @@ supportedArchitectures: | ... | @@ -20,13 +20,13 @@ supportedArchitectures: |
| 20 | - glibc | 20 | - glibc |
| 21 | 21 | ||
| 22 | catalog: | 22 | catalog: |
| 23 | "@clerk/backend": 3.8.3-snapshot.v20260622234151 | 23 | "@clerk/backend": 3.8.4 |
| 24 | "@clerk/clerk-js": 6.21.0-snapshot.v20260622234151 | 24 | "@clerk/clerk-js": 6.22.0 |
| 25 | "@clerk/electron": 0.0.2-snapshot.v20260622234151 | 25 | "@clerk/electron": 0.0.5 |
| 26 | "@clerk/electron-passkeys": 0.0.2-snapshot.v20260622234151 | 26 | "@clerk/electron-passkeys": 0.0.3 |
| 27 | "@clerk/expo": 3.5.3-snapshot.v20260622234151 | 27 | "@clerk/expo": 3.6.2 |
| 28 | "@clerk/react": 6.11.0-snapshot.v20260622234151 | 28 | "@clerk/react": 6.11.1 |
| 29 | "@clerk/shared": 4.21.0-snapshot.v20260622234151 | 29 | "@clerk/shared": 4.22.0 |
| 30 | "@effect/atom-react": 4.0.0-beta.78 | 30 | "@effect/atom-react": 4.0.0-beta.78 |
| 31 | "@effect/openapi-generator": 4.0.0-beta.78 | 31 | "@effect/openapi-generator": 4.0.0-beta.78 |
| 32 | "@effect/platform-bun": 4.0.0-beta.78 | 32 | "@effect/platform-bun": 4.0.0-beta.78 |
| ... | @@ -59,22 +59,19 @@ onlyBuiltDependencies: | ... | @@ -59,22 +59,19 @@ onlyBuiltDependencies: |
| 59 | - sharp | 59 | - sharp |
| 60 | 60 | ||
| 61 | overrides: | 61 | overrides: |
| 62 | # Keep every Clerk consumer on the same snapshot train. Clerk publishes wallet | ||
| 63 | # auth integrations as required dependencies, but T3 Code does not support | ||
| 64 | # wallet auth, so keep that unused dependency tree out of installs. | ||
| 65 | "@clerk/backend": "catalog:" | 62 | "@clerk/backend": "catalog:" |
| 66 | "@clerk/clerk-js": "catalog:" | 63 | "@clerk/clerk-js": "catalog:" |
| 67 | "@clerk/electron": "catalog:" | ||
| 68 | "@clerk/electron-passkeys": "catalog:" | ||
| 69 | "@clerk/expo": "catalog:" | ||
| 70 | "@clerk/react": "catalog:" | ||
| 71 | "@clerk/shared": "catalog:" | ||
| 72 | "@clerk/clerk-js>@base-org/account": "-" | 64 | "@clerk/clerk-js>@base-org/account": "-" |
| 73 | "@clerk/clerk-js>@coinbase/wallet-sdk": "-" | 65 | "@clerk/clerk-js>@coinbase/wallet-sdk": "-" |
| 74 | "@clerk/clerk-js>@solana/wallet-adapter-base": "-" | 66 | "@clerk/clerk-js>@solana/wallet-adapter-base": "-" |
| 75 | "@clerk/clerk-js>@solana/wallet-adapter-react": "-" | 67 | "@clerk/clerk-js>@solana/wallet-adapter-react": "-" |
| 76 | "@clerk/clerk-js>@solana/wallet-standard": "-" | 68 | "@clerk/clerk-js>@solana/wallet-standard": "-" |
| 77 | "@clerk/clerk-js>@wallet-standard/core": "-" | 69 | "@clerk/clerk-js>@wallet-standard/core": "-" |
| 70 | "@clerk/electron": "catalog:" | ||
| 71 | "@clerk/electron-passkeys": "catalog:" | ||
| 72 | "@clerk/expo": "catalog:" | ||
| 73 | "@clerk/react": "catalog:" | ||
| 74 | "@clerk/shared": "catalog:" | ||
| 78 | "@effect/atom-react": "catalog:" | 75 | "@effect/atom-react": "catalog:" |
| 79 | "@effect/platform-bun": "catalog:" | 76 | "@effect/platform-bun": "catalog:" |
| 80 | "@effect/platform-node": "catalog:" | 77 | "@effect/platform-node": "catalog:" |
| ... | @@ -82,10 +79,8 @@ overrides: | ... | @@ -82,10 +79,8 @@ overrides: |
| 82 | "@effect/sql-pg": "catalog:" | 79 | "@effect/sql-pg": "catalog:" |
| 83 | "@effect/sql-sqlite-bun": "catalog:" | 80 | "@effect/sql-sqlite-bun": "catalog:" |
| 84 | "@effect/vitest": "catalog:" | 81 | "@effect/vitest": "catalog:" |
| 85 | # @effect/vitest is patched to use vite-plus/test; do not auto-install its | ||
| 86 | # upstream Vitest peer and create a second Vite/Vitest toolchain. | ||
| 87 | "@effect/vitest>vitest": "-" | 82 | "@effect/vitest>vitest": "-" |
| 88 | "@expo/metro-config": 56.0.13 | 83 | "@expo/metro-config": 56.0.14 |
| 89 | "@pierre/diffs>@shikijs/transformers": ^4.2.0 | 84 | "@pierre/diffs>@shikijs/transformers": ^4.2.0 |
| 90 | "@types/node": "catalog:" | 85 | "@types/node": "catalog:" |
| 91 | effect: "catalog:" | 86 | effect: "catalog:" |
| ... | @@ -105,11 +100,16 @@ packageExtensions: | ... | @@ -105,11 +100,16 @@ packageExtensions: |
| 105 | 100 | ||
| 106 | patchedDependencies: | 101 | patchedDependencies: |
| 107 | "@effect/vitest@4.0.0-beta.78": patches/@effect__vitest@4.0.0-beta.78.patch | 102 | "@effect/vitest@4.0.0-beta.78": patches/@effect__vitest@4.0.0-beta.78.patch |
| 108 | "@expo/metro-config@56.0.13": patches/@expo%2Fmetro-config@56.0.13.patch | 103 | "@expo/metro-config@56.0.14": patches/@expo%2Fmetro-config@56.0.14.patch |
| 109 | "@ff-labs/fff-node@0.9.4": patches/@ff-labs__fff-node@0.9.4.patch | 104 | "@ff-labs/fff-node@0.9.4": patches/@ff-labs__fff-node@0.9.4.patch |
| 110 | "@pierre/diffs@1.3.0-beta.5": patches/@pierre%2Fdiffs@1.3.0-beta.5.patch | 105 | "@pierre/diffs@1.3.0-beta.5": patches/@pierre%2Fdiffs@1.3.0-beta.5.patch |
| 106 | "@react-native-menu/menu@2.0.0": patches/@react-native-menu__menu@2.0.0.patch | ||
| 107 | "@react-navigation/native-stack@7.17.6": patches/@react-navigation%2Fnative-stack@7.17.6.patch | ||
| 111 | effect@4.0.0-beta.78: patches/effect@4.0.0-beta.78.patch | 108 | effect@4.0.0-beta.78: patches/effect@4.0.0-beta.78.patch |
| 109 | expo-modules-jsi@56.0.10: patches/expo-modules-jsi@56.0.10.patch | ||
| 110 | react-native-gesture-handler@2.31.2: patches/react-native-gesture-handler@2.31.2.patch | ||
| 112 | react-native-nitro-modules@0.35.9: patches/react-native-nitro-modules@0.35.9.patch | 111 | react-native-nitro-modules@0.35.9: patches/react-native-nitro-modules@0.35.9.patch |
| 112 | react-native-screens@4.25.2: patches/react-native-screens@4.25.2.patch | ||
| 113 | 113 | ||
| 114 | peerDependencyRules: | 114 | peerDependencyRules: |
| 115 | allowAny: | 115 | allowAny: |