Unified · Split
| @@ -0,0 +1 @@ | ||
| 1 | +vp staged | |
| @@ -1,6 +1,6 @@ | ||
| 1 | 1 | { |
| 2 | 2 | "name": "@t3tools/desktop", |
| 3 | − "version": "0.0.27", | |
| 3 | + "version": "0.0.28", | |
| 4 | 4 | "private": true, |
| 5 | 5 | "type": "module", |
| 6 | 6 | "main": "dist-electron/main.cjs", |
| @@ -358,10 +358,7 @@ function resolveLinuxSandboxArgs(electronBinaryPath) { · 357 unmodified lines | ||
| 358 | 358 | } |
| 359 | 359 | |
| 360 | 360 | export function resolveElectronPath() { |
| 361 | − ensureElectronRuntime(); | |
| 362 | − | |
| 363 | − const require = NodeModule.createRequire(import.meta.url); | |
| 364 | − const electronBinaryPath = require("electron"); | |
| 361 | + const electronBinaryPath = resolveElectronBinaryPath(); | |
| 365 | 362 | |
| 366 | 363 | if (hostPlatform !== "darwin") { |
| 367 | 364 | return electronBinaryPath; |
| @@ -378,13 +375,23 @@ export function resolveElectronLaunchCommand(args = []) { · 10 unmodified lines | ||
| 378 | 375 | }; |
| 379 | 376 | } |
| 380 | 377 | |
| 378 | +export function resolveElectronBinaryPath({ | |
| 379 | + ensureRuntime = ensureElectronRuntime, | |
| 380 | + createRequire = NodeModule.createRequire, | |
| 381 | + moduleUrl = import.meta.url, | |
| 382 | +} = {}) { | |
| 383 | + ensureRuntime(); | |
| 384 | + | |
| 385 | + const require = createRequire(moduleUrl); | |
| 386 | + return require("electron"); | |
| 387 | +} | |
| 388 | + | |
| 381 | 389 | export function resolveDevProtocolClient() { |
| 382 | 390 | if (hostPlatform !== "darwin" || !isDevelopment) { |
| 383 | 391 | return null; |
| 384 | 392 | } |
| 385 | 393 | |
| 386 | − const require = NodeModule.createRequire(import.meta.url); | |
| 387 | − const electronBinaryPath = require("electron"); | |
| 394 | + const electronBinaryPath = resolveElectronBinaryPath(); | |
| 388 | 395 | const launcherBinaryPath = buildMacLauncher(electronBinaryPath); |
| 389 | 396 | return { |
| 390 | 397 | appBundlePath: NodePath.resolve(launcherBinaryPath, "..", "..", ".."), |
| @@ -1,6 +1,6 @@ | ||
| 1 | 1 | import { assert, describe, it } from "vite-plus/test"; |
| 2 | 2 | |
| 3 | −import { makeDevelopmentLauncherScript } from "./electron-launcher.mjs"; | |
| 3 | +import { makeDevelopmentLauncherScript, resolveElectronBinaryPath } from "./electron-launcher.mjs"; | |
| 4 | 4 | |
| 5 | 5 | describe("electron development launcher", () => { |
| 6 | 6 | it("uses captured values only as fallbacks for a live runner environment", () => { |
| @@ -25,4 +25,24 @@ describe("electron development launcher", () => { · 18 unmodified lines | ||
| 25 | 25 | "exec '/repo/node_modules/electron/Electron' --t3code-dev-root='/repo/apps/desktop' '/repo/apps/desktop/dist-electron/main.cjs' \"$@\"", |
| 26 | 26 | ); |
| 27 | 27 | }); |
| 28 | + | |
| 29 | + it("repairs Electron before loading the package entrypoint", () => { | |
| 30 | + const calls = []; | |
| 31 | + const electronPath = resolveElectronBinaryPath({ | |
| 32 | + ensureRuntime: () => { | |
| 33 | + calls.push("ensure"); | |
| 34 | + }, | |
| 35 | + createRequire: () => (specifier) => { | |
| 36 | + calls.push(`require:${specifier}`); | |
| 37 | + return "/repo/node_modules/electron/dist/Electron.app/Contents/MacOS/Electron"; | |
| 38 | + }, | |
| 39 | + moduleUrl: import.meta.url, | |
| 40 | + }); | |
| 41 | + | |
| 42 | + assert.equal( | |
| 43 | + electronPath, | |
| 44 | + "/repo/node_modules/electron/dist/Electron.app/Contents/MacOS/Electron", | |
| 45 | + ); | |
| 46 | + assert.deepEqual(calls, ["ensure", "require:electron"]); | |
| 47 | + }); | |
| 28 | 48 | }); |
| @@ -607,6 +607,7 @@ export const make = Effect.gen(function* () { · 606 unmodified lines | ||
| 607 | 607 | const backendExposure = yield* serverExposure.backendConfig; |
| 608 | 608 | const persistedSettings = yield* settings.get; |
| 609 | 609 | const shared = yield* sharedInputs; |
| 610 | + yield* wslEnvironment.preWarm(persistedSettings.wslDistro); | |
| 610 | 611 | return yield* resolveWslStartConfig({ |
| 611 | 612 | ...shared, |
| 612 | 613 | port: backendExposure.port, |
| @@ -1,6 +1,7 @@ | ||
| 1 | 1 | included: |
| 2 | 2 | - ios/T3Code |
| 3 | 3 | - modules/t3-composer-editor/ios |
| 4 | + - modules/t3-native-controls/ios | |
| 4 | 5 | - modules/t3-terminal/ios |
| 5 | 6 | - modules/t3-review-diff/ios |
| 6 | 7 | |
| @@ -17,6 +17,7 @@ const VARIANT_CONFIG: Record< · 16 unmodified lines | ||
| 17 | 17 | readonly iosIcon: string; |
| 18 | 18 | readonly iosBundleIdentifier: string; |
| 19 | 19 | readonly androidPackage: string; |
| 20 | + readonly relyingParty?: string; | |
| 20 | 21 | } |
| 21 | 22 | > = { |
| 22 | 23 | development: { |
| @@ -25,6 +26,7 @@ const VARIANT_CONFIG: Record< · 2 unmodified lines | ||
| 25 | 26 | iosIcon: "./assets/icon-composer-dev.icon", |
| 26 | 27 | iosBundleIdentifier: "com.t3tools.t3code.dev", |
| 27 | 28 | androidPackage: "com.t3tools.t3code.dev", |
| 29 | + relyingParty: "clerk.t3.codes", | |
| 28 | 30 | }, |
| 29 | 31 | preview: { |
| 30 | 32 | appName: "T3 Code Preview", |
| @@ -32,6 +34,7 @@ const VARIANT_CONFIG: Record< · 1 unmodified line | ||
| 32 | 34 | iosIcon: "./assets/icon-composer-prod.icon", |
| 33 | 35 | iosBundleIdentifier: "com.t3tools.t3code.preview", |
| 34 | 36 | androidPackage: "com.t3tools.t3code.preview", |
| 37 | + relyingParty: "clerk.t3.codes", | |
| 35 | 38 | }, |
| 36 | 39 | production: { |
| 37 | 40 | appName: "T3 Code", |
| @@ -39,6 +42,7 @@ const VARIANT_CONFIG: Record< · 1 unmodified line | ||
| 39 | 42 | iosIcon: "./assets/icon-composer-prod.icon", |
| 40 | 43 | iosBundleIdentifier: "com.t3tools.t3code", |
| 41 | 44 | androidPackage: "com.t3tools.t3code", |
| 45 | + relyingParty: "clerk.t3.codes", | |
| 42 | 46 | }, |
| 43 | 47 | }; |
| 44 | 48 | |
| @@ -77,6 +81,14 @@ const config: ExpoConfig = { · 32 unmodified lines | ||
| 77 | 81 | icon: variant.iosIcon, |
| 78 | 82 | supportsTablet: true, |
| 79 | 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 | 92 | infoPlist: { |
| 81 | 93 | NSAppTransportSecurity: { |
| 82 | 94 | NSAllowsArbitraryLoads: true, |
| @@ -101,7 +113,6 @@ const config: ExpoConfig = { · 18 unmodified lines | ||
| 101 | 113 | favicon: "./assets/favicon.png", |
| 102 | 114 | }, |
| 103 | 115 | plugins: [ |
| 104 | − "expo-router", | |
| 105 | 116 | "expo-font", |
| 106 | 117 | "expo-secure-store", |
| 107 | 118 | ["@clerk/expo", { theme: "./clerk-theme.json" }], |
| @@ -139,6 +150,7 @@ const config: ExpoConfig = { · 31 unmodified lines | ||
| 139 | 150 | }, |
| 140 | 151 | }, |
| 141 | 152 | ], |
| 153 | + "./plugins/withIosCocoaPodsUuidCache.cjs", | |
| 142 | 154 | [ |
| 143 | 155 | "expo-widgets", |
| 144 | 156 | { |
| @@ -155,6 +167,7 @@ const config: ExpoConfig = { · 10 unmodified lines | ||
| 155 | 167 | ], |
| 156 | 168 | }, |
| 157 | 169 | ], |
| 170 | + "./plugins/withIosSceneLifecycle.cjs", | |
| 158 | 171 | "./plugins/withAndroidCleartextTraffic.cjs", |
| 159 | 172 | ], |
| 160 | 173 | extra: { |
| @@ -199,24 +199,24 @@ · 198 unmodified lines | ||
| 199 | 199 | --font-bold: "DMSans_700Bold"; |
| 200 | 200 | |
| 201 | 201 | /* Keep this scale aligned with src/lib/typography.ts for native style props. */ |
| 202 | − --text-3xs: 10px; | |
| 203 | − --text-3xs--line-height: 13px; | |
| 204 | − --text-2xs: 11px; | |
| 205 | − --text-2xs--line-height: 15px; | |
| 206 | − --text-xs: 12px; | |
| 207 | − --text-xs--line-height: 16px; | |
| 208 | − --text-sm: 13px; | |
| 209 | − --text-sm--line-height: 18px; | |
| 210 | − --text-base: 15px; | |
| 211 | − --text-base--line-height: 22px; | |
| 212 | − --text-lg: 17px; | |
| 213 | − --text-lg--line-height: 22px; | |
| 214 | − --text-xl: 20px; | |
| 215 | − --text-xl--line-height: 26px; | |
| 216 | − --text-2xl: 24px; | |
| 217 | − --text-2xl--line-height: 30px; | |
| 218 | − --text-3xl: 28px; | |
| 219 | − --text-3xl--line-height: 34px; | |
| 202 | + --text-3xs: 11px; | |
| 203 | + --text-3xs--line-height: 14px; | |
| 204 | + --text-2xs: 12px; | |
| 205 | + --text-2xs--line-height: 16px; | |
| 206 | + --text-xs: 13px; | |
| 207 | + --text-xs--line-height: 17px; | |
| 208 | + --text-sm: 14px; | |
| 209 | + --text-sm--line-height: 19px; | |
| 210 | + --text-base: 16px; | |
| 211 | + --text-base--line-height: 23px; | |
| 212 | + --text-lg: 18px; | |
| 213 | + --text-lg--line-height: 23px; | |
| 214 | + --text-xl: 21px; | |
| 215 | + --text-xl--line-height: 28px; | |
| 216 | + --text-2xl: 26px; | |
| 217 | + --text-2xl--line-height: 32px; | |
| 218 | + --text-3xl: 30px; | |
| 219 | + --text-3xl--line-height: 36px; | |
| 220 | 220 | } |
| 221 | 221 | |
| 222 | 222 | /* ─── Custom utilities ──────────────────────────────────────────────── */ |
| @@ -1,2 +1,11 @@ | ||
| 1 | +import { registerRootComponent } from "expo"; | |
| 1 | 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); | |
| @@ -47,6 +47,7 @@ public class T3ComposerEditorModule: Module { · 46 unmodified lines | ||
| 47 | 47 | "onComposerSelectionChange", |
| 48 | 48 | "onComposerFocus", |
| 49 | 49 | "onComposerBlur", |
| 50 | + "onComposerSubmit", | |
| 50 | 51 | "onComposerPasteImages", |
| 51 | 52 | "onComposerContentSizeChange" |
| 52 | 53 | ) |
| @@ -63,6 +63,24 @@ private final class ComposerTextView: UITextView { · 62 unmodified lines | ||
| 63 | 63 | |
| 64 | 64 | var onPasteImages: (([String]) -> Void)? |
| 65 | 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 | 85 | override func canPerformAction(_ action: Selector, withSender sender: Any?) -> Bool { |
| 68 | 86 | if action == #selector(paste(_:)) { |
| @@ -299,6 +317,7 @@ public final class T3ComposerEditorView: ExpoView, UITextViewDelegate { · 230 unmodified lines | ||
| 299 | 317 | let onComposerSelectionChange = EventDispatcher() |
| 300 | 318 | let onComposerFocus = EventDispatcher() |
| 301 | 319 | let onComposerBlur = EventDispatcher() |
| 320 | + let onComposerSubmit = EventDispatcher() | |
| 302 | 321 | let onComposerPasteImages = EventDispatcher() |
| 303 | 322 | let onComposerContentSizeChange = EventDispatcher() |
| 304 | 323 | |
| @@ -320,6 +339,9 @@ public final class T3ComposerEditorView: ExpoView, UITextViewDelegate { · 15 unmodified lines | ||
| 320 | 339 | textView.onAttributedMutation = { [weak self] in |
| 321 | 340 | self?.emitTextChange() |
| 322 | 341 | } |
| 342 | + textView.onSubmit = { [weak self] in | |
| 343 | + self?.onComposerSubmit([:]) | |
| 344 | + } | |
| 323 | 345 | addSubview(textView) |
| 324 | 346 | |
| 325 | 347 | placeholderLabel.numberOfLines = 0 |
| @@ -26,6 +26,15 @@ function nodeKey(node: MarkdownNode, index: number): string { · 25 unmodified lines | ||
| 26 | 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 | 38 | function nodeText(node: MarkdownNode): string { |
| 30 | 39 | if (node.content !== undefined) { |
| 31 | 40 | return node.content; |
| @@ -161,8 +170,8 @@ function HighlightedCodeText(props: { · 129 unmodified lines | ||
| 161 | 170 | style={{ |
| 162 | 171 | color: props.textStyle.codeColor, |
| 163 | 172 | fontFamily: "ui-monospace", |
| 164 | − fontSize: 12, | |
| 165 | − lineHeight: 18, | |
| 173 | + fontSize: codeBlockFontSize(props.textStyle), | |
| 174 | + lineHeight: codeBlockLineHeight(props.textStyle), | |
| 166 | 175 | }} |
| 167 | 176 | > |
| 168 | 177 | {props.content} |
| @@ -196,8 +205,8 @@ function HighlightedCodeText(props: { · 27 unmodified lines | ||
| 196 | 205 | style={{ |
| 197 | 206 | color: props.textStyle.codeColor, |
| 198 | 207 | fontFamily: "ui-monospace", |
| 199 | − fontSize: 12, | |
| 200 | − lineHeight: 18, | |
| 208 | + fontSize: codeBlockFontSize(props.textStyle), | |
| 209 | + lineHeight: codeBlockLineHeight(props.textStyle), | |
| 201 | 210 | }} |
| 202 | 211 | > |
| 203 | 212 | {keyedLines.map((line, lineIndex) => ( |
| @@ -264,7 +273,7 @@ function NativeCodeBlock(props: { · 60 unmodified lines | ||
| 264 | 273 | flex: 1, |
| 265 | 274 | color: props.textStyle.mutedColor, |
| 266 | 275 | fontFamily: "ui-monospace", |
| 267 | − fontSize: 12, | |
| 276 | + fontSize: codeBlockFontSize(props.textStyle), | |
| 268 | 277 | }} |
| 269 | 278 | > |
| 270 | 279 | {languageLabel} |
| @@ -32,11 +32,25 @@ function runKeySignature(run: NativeMarkdownTextRun): string { · 31 unmodified lines | ||
| 32 | 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 | 49 | function runStyle(run: NativeMarkdownTextRun, textStyle: NativeMarkdownTextStyle): TextStyle { |
| 36 | 50 | const isFile = run.fileIcon != null; |
| 37 | 51 | const isSkill = run.skillName != null; |
| 38 | 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 | 54 | const isHeading = run.role === "heading"; |
| 41 | 55 | const isCodeBlock = run.role === "code-block" || run.role === "code-language"; |
| 42 | 56 | const hasParagraphStyle = run.headIndent !== undefined; |
| @@ -88,7 +102,7 @@ function runStyle(run: NativeMarkdownTextRun, textStyle: NativeMarkdownTextStyle · 45 unmodified lines | ||
| 88 | 102 | : isHeading |
| 89 | 103 | ? headingFontSize |
| 90 | 104 | : run.role === "code-language" |
| 91 | − ? 11 | |
| 105 | + ? Math.max(10, Math.round(textStyle.fontSize * 0.73)) | |
| 92 | 106 | : run.code || isCodeBlock |
| 93 | 107 | ? Math.max(12, textStyle.fontSize - 2) |
| 94 | 108 | : textStyle.fontSize, |
| @@ -98,9 +112,9 @@ function runStyle(run: NativeMarkdownTextRun, textStyle: NativeMarkdownTextStyle · 3 unmodified lines | ||
| 98 | 112 | : run.role === "list-break" |
| 99 | 113 | ? textStyle.lineHeight + (run.spacing ?? 0) |
| 100 | 114 | : isHeading |
| 101 | − ? Math.max(headingFontSize + 6, 20) | |
| 115 | + ? Math.max(headingFontSize + 6, textStyle.lineHeight + 2) | |
| 102 | 116 | : isCodeBlock |
| 103 | − ? 18 | |
| 117 | + ? Math.max(16, textStyle.lineHeight - 2) | |
| 104 | 118 | : textStyle.lineHeight, |
| 105 | 119 | fontStyle: run.italic ? "italic" : "normal", |
| 106 | 120 | fontWeight: isHeading || run.bold || isFile || isSkill ? "700" : "400", |
| @@ -148,6 +162,9 @@ export function NativeMarkdownSelectableText(props: { · 41 unmodified lines | ||
| 148 | 162 | // color-only child update can otherwise leave the previous appearance cached. |
| 149 | 163 | const appearanceKey = [ |
| 150 | 164 | colorScheme ?? "unspecified", |
| 165 | + props.textStyle.fontSize, | |
| 166 | + props.textStyle.lineHeight, | |
| 167 | + props.textStyle.headingFontSizes?.join(","), | |
| 151 | 168 | props.textStyle.color, |
| 152 | 169 | props.textStyle.strongColor, |
| 153 | 170 | props.textStyle.mutedColor, |
| @@ -16,6 +16,7 @@ export interface NativeMarkdownTextStyle { · 15 unmodified lines | ||
| 16 | 16 | readonly fontFamily: string; |
| 17 | 17 | readonly headingFontFamily: string; |
| 18 | 18 | readonly boldFontFamily: string; |
| 19 | + readonly headingFontSizes?: ReadonlyArray<number>; | |
| 19 | 20 | } |
| 20 | 21 | |
| 21 | 22 | export interface MarkdownHighlightedToken { |
| @@ -0,0 +1,6 @@ | ||
| 1 | +{ | |
| 2 | + "platforms": ["apple"], | |
| 3 | + "apple": { | |
| 4 | + "modules": ["T3NativeControlsModule", "T3KeyboardCommandsModule"] | |
| 5 | + } | |
| 6 | +} | |
| @@ -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 | +} | |
| @@ -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 | +} | |
| @@ -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 | |
| @@ -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 | +} | |
| @@ -5,22 +5,14 @@ public class T3ReviewDiffModule: Module { · 4 unmodified lines | ||
| 5 | 5 | Name("T3ReviewDiffSurface") |
| 6 | 6 | |
| 7 | 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 | 8 | Prop("tokensResetKey") { (view: T3ReviewDiffView, tokensResetKey: String) in |
| 21 | 9 | view.setTokensResetKey(tokensResetKey) |
| 22 | 10 | } |
| 23 | 11 | |
| 12 | + Prop("contentResetKey") { (view: T3ReviewDiffView, contentResetKey: String) in | |
| 13 | + view.setContentResetKey(contentResetKey) | |
| 14 | + } | |
| 15 | + | |
| 24 | 16 | Prop("collapsedFileIdsJson") { (view: T3ReviewDiffView, collapsedFileIdsJson: String) in |
| 25 | 17 | view.setCollapsedFileIdsJson(collapsedFileIdsJson) |
| 26 | 18 | } |
| @@ -61,7 +53,42 @@ public class T3ReviewDiffModule: Module { · 34 unmodified lines | ||
| 61 | 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 | } |
| @@ -1,7 +1,7 @@ | ||
| 1 | 1 | import ExpoModulesCore |
| 2 | 2 | import UIKit |
| 3 | 3 | |
| 4 | −private struct ReviewDiffNativeRow: Decodable { | |
| 4 | +private struct ReviewDiffNativeRow: Decodable, Sendable { | |
| 5 | 5 | let kind: String |
| 6 | 6 | let id: String |
| 7 | 7 | let fileId: String? |
| @@ -21,18 +21,18 @@ private struct ReviewDiffNativeRow: Decodable { · 13 unmodified lines | ||
| 21 | 21 | let commentSectionTitle: String? |
| 22 | 22 | } |
| 23 | 23 | |
| 24 | −private struct ReviewDiffNativeWordDiffRange: Decodable { | |
| 24 | +private struct ReviewDiffNativeWordDiffRange: Decodable, Sendable { | |
| 25 | 25 | let start: Int |
| 26 | 26 | let end: Int |
| 27 | 27 | } |
| 28 | 28 | |
| 29 | −private struct ReviewDiffNativeToken: Decodable { | |
| 29 | +private struct ReviewDiffNativeToken: Decodable, Sendable { | |
| 30 | 30 | let content: String |
| 31 | 31 | let color: String? |
| 32 | 32 | let fontStyle: Int? |
| 33 | 33 | } |
| 34 | 34 | |
| 35 | −private struct ReviewDiffNativeTokenPatch: Decodable { | |
| 35 | +private struct ReviewDiffNativeTokenPatch: Decodable, Sendable { | |
| 36 | 36 | let resetKey: String? |
| 37 | 37 | let chunkIndex: Int? |
| 38 | 38 | let tokensByRowId: [String: [ReviewDiffNativeToken]]? |
| @@ -312,6 +312,10 @@ private struct ReviewDiffNativeStyle { · 273 unmodified lines | ||
| 312 | 312 | } |
| 313 | 313 | |
| 314 | 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 | 319 | private let scrollView = UIScrollView() |
| 316 | 320 | private let contentView = ReviewDiffContentView() |
| 317 | 321 | private var rows: [ReviewDiffNativeRow] = [] |
| @@ -323,14 +327,25 @@ public final class T3ReviewDiffView: ExpoView, UIScrollViewDelegate { · 5 unmodified lines | ||
| 323 | 327 | private var lastMetricsDebugKey = "" |
| 324 | 328 | private var lastVisibleRangeDebugKey = "" |
| 325 | 329 | private var tokensResetKey = "" |
| 330 | + private var contentResetKey = "" | |
| 326 | 331 | private var initialRowIndex: Int? |
| 327 | 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 | 340 | let onDebug = EventDispatcher() |
| 341 | + let onVisibleFileChange = EventDispatcher() | |
| 330 | 342 | let onToggleFile = EventDispatcher() |
| 331 | 343 | let onToggleViewedFile = EventDispatcher() |
| 332 | 344 | let onPressLine = EventDispatcher() |
| 333 | 345 | let onToggleComment = EventDispatcher() |
| 346 | + let onPullToRefresh = EventDispatcher() | |
| 347 | + | |
| 348 | + private let pullRefreshControl = UIRefreshControl() | |
| 334 | 349 | |
| 335 | 350 | public required init(appContext: AppContext? = nil) { |
| 336 | 351 | super.init(appContext: appContext) |
| @@ -345,6 +360,8 @@ public final class T3ReviewDiffView: ExpoView, UIScrollViewDelegate { · 8 unmodified lines | ||
| 345 | 360 | scrollView.showsVerticalScrollIndicator = true |
| 346 | 361 | scrollView.showsHorizontalScrollIndicator = false |
| 347 | 362 | scrollView.backgroundColor = contentView.theme.background |
| 363 | + pullRefreshControl.addTarget(self, action: #selector(handlePullToRefresh), for: .valueChanged) | |
| 364 | + scrollView.refreshControl = pullRefreshControl | |
| 348 | 365 | addSubview(scrollView) |
| 349 | 366 | |
| 350 | 367 | contentView.backgroundColor = contentView.theme.background |
| @@ -379,6 +396,13 @@ public final class T3ReviewDiffView: ExpoView, UIScrollViewDelegate { · 28 unmodified lines | ||
| 379 | 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 | 406 | public func scrollViewDidEndDragging(_ scrollView: UIScrollView, willDecelerate decelerate: Bool) { |
| 383 | 407 | guard !decelerate else { |
| 384 | 408 | return |
| @@ -396,77 +420,125 @@ public final class T3ReviewDiffView: ExpoView, UIScrollViewDelegate { · 11 unmodified lines | ||
| 396 | 420 | } |
| 397 | 421 | |
| 398 | 422 | func setRowsJson(_ rowsJson: String) { |
| 399 | − guard let data = rowsJson.data(using: .utf8) else { | |
| 400 | − return | |
| 401 | − } | |
| 423 | + rowsDecodeGeneration += 1 | |
| 424 | + let generation = rowsDecodeGeneration | |
| 402 | 425 | |
| 403 | − do { | |
| 404 | − rows = try JSONDecoder().decode([ReviewDiffNativeRow].self, from: data) | |
| 405 | − contentView.rows = rows | |
| 406 | − hasAppliedInitialRowIndex = false | |
| 407 | − emitDebug("rows-decoded", [ | |
| 408 | − "rows": rows.count, | |
| 409 | − "firstKind": rows.first?.kind ?? "none", | |
| 410 | − ]) | |
| 411 | − updateContentMetrics() | |
| 412 | − } catch { | |
| 413 | − rows = [] | |
| 414 | − contentView.rows = [] | |
| 415 | − hasAppliedInitialRowIndex = false | |
| 416 | − updateContentMetrics() | |
| 417 | − emitDebug("rows-decode-failed", [ | |
| 418 | − "error": error.localizedDescription, | |
| 419 | − ]) | |
| 426 | + payloadDecodeQueue.async { [weak self] in | |
| 427 | + guard let data = rowsJson.data(using: .utf8) else { | |
| 428 | + return | |
| 429 | + } | |
| 430 | + | |
| 431 | + do { | |
| 432 | + let decodedRows = try JSONDecoder().decode([ReviewDiffNativeRow].self, from: data) | |
| 433 | + DispatchQueue.main.async { [weak self] in | |
| 434 | + guard let self, generation == self.rowsDecodeGeneration else { | |
| 435 | + return | |
| 436 | + } | |
| 437 | + self.rows = decodedRows | |
| 438 | + self.contentView.rows = decodedRows | |
| 439 | + self.hasAppliedInitialRowIndex = false | |
| 440 | + self.lastVisibleFileId = nil | |
| 441 | + self.emitDebug("rows-decoded", [ | |
| 442 | + "rows": decodedRows.count, | |
| 443 | + "firstKind": decodedRows.first?.kind ?? "none", | |
| 444 | + ]) | |
| 445 | + self.updateContentMetrics() | |
| 446 | + self.applyPendingScrollIfNeeded() | |
| 447 | + } | |
| 448 | + } catch { | |
| 449 | + let message = error.localizedDescription | |
| 450 | + DispatchQueue.main.async { [weak self] in | |
| 451 | + guard let self, generation == self.rowsDecodeGeneration else { | |
| 452 | + return | |
| 453 | + } | |
| 454 | + self.rows = [] | |
| 455 | + self.contentView.rows = [] | |
| 456 | + self.hasAppliedInitialRowIndex = false | |
| 457 | + self.lastVisibleFileId = nil | |
| 458 | + self.pendingScrollFileId = nil | |
| 459 | + self.updateContentMetrics() | |
| 460 | + self.emitDebug("rows-decode-failed", ["error": message]) | |
| 461 | + } | |
| 462 | + } | |
| 420 | 463 | } |
| 421 | 464 | } |
| 422 | 465 | |
| 423 | 466 | func setTokensJson(_ tokensJson: String) { |
| 424 | − guard let data = tokensJson.data(using: .utf8) else { | |
| 425 | − return | |
| 426 | − } | |
| 467 | + tokensDecodeGeneration += 1 | |
| 468 | + let generation = tokensDecodeGeneration | |
| 427 | 469 | |
| 428 | − do { | |
| 429 | − contentView.tokensByRowId = try JSONDecoder().decode( | |
| 430 | − [String: [ReviewDiffNativeToken]].self, | |
| 431 | − from: data | |
| 432 | − ) | |
| 433 | − } catch { | |
| 434 | − contentView.tokensByRowId = [:] | |
| 435 | − emitDebug("tokens-decode-failed", [ | |
| 436 | − "error": error.localizedDescription, | |
| 437 | − ]) | |
| 470 | + payloadDecodeQueue.async { [weak self] in | |
| 471 | + guard let data = tokensJson.data(using: .utf8) else { | |
| 472 | + return | |
| 473 | + } | |
| 474 | + | |
| 475 | + do { | |
| 476 | + let decodedTokens = try JSONDecoder().decode( | |
| 477 | + [String: [ReviewDiffNativeToken]].self, | |
| 478 | + from: data | |
| 479 | + ) | |
| 480 | + DispatchQueue.main.async { [weak self] in | |
| 481 | + guard let self, generation == self.tokensDecodeGeneration else { | |
| 482 | + return | |
| 483 | + } | |
| 484 | + self.contentView.tokensByRowId = decodedTokens | |
| 485 | + } | |
| 486 | + } catch { | |
| 487 | + let message = error.localizedDescription | |
| 488 | + DispatchQueue.main.async { [weak self] in | |
| 489 | + guard let self, generation == self.tokensDecodeGeneration else { | |
| 490 | + return | |
| 491 | + } | |
| 492 | + self.contentView.tokensByRowId = [:] | |
| 493 | + self.emitDebug("tokens-decode-failed", ["error": message]) | |
| 494 | + } | |
| 495 | + } | |
| 438 | 496 | } |
| 439 | 497 | } |
| 440 | 498 | |
| 441 | 499 | func setTokensPatchJson(_ tokensPatchJson: String) { |
| 442 | − guard let data = tokensPatchJson.data(using: .utf8) else { | |
| 443 | − return | |
| 444 | − } | |
| 500 | + let contentResetKey = self.contentResetKey | |
| 445 | 501 | |
| 446 | − do { | |
| 447 | − let patch = try JSONDecoder().decode(ReviewDiffNativeTokenPatch.self, from: data) | |
| 448 | − if let resetKey = patch.resetKey, resetKey != tokensResetKey { | |
| 449 | − tokensResetKey = resetKey | |
| 450 | − contentView.tokensByRowId = [:] | |
| 451 | − } | |
| 452 | − | |
| 453 | − let tokensByRowId = patch.tokensByRowId ?? [:] | |
| 454 | − if tokensByRowId.isEmpty { | |
| 502 | + payloadDecodeQueue.async { [weak self] in | |
| 503 | + guard let data = tokensPatchJson.data(using: .utf8) else { | |
| 455 | 504 | return |
| 456 | 505 | } |
| 457 | 506 | |
| 458 | − contentView.mergeTokensByRowId(tokensByRowId) | |
| 459 | − if let chunkIndex = patch.chunkIndex, chunkIndex < 5 || chunkIndex.isMultiple(of: 10) { | |
| 460 | − emitDebug("tokens-patch-decoded", [ | |
| 461 | − "chunkIndex": chunkIndex, | |
| 462 | − "rows": tokensByRowId.count, | |
| 463 | − "totalRows": contentView.tokensByRowId.count, | |
| 464 | − ]) | |
| 507 | + do { | |
| 508 | + let patch = try JSONDecoder().decode(ReviewDiffNativeTokenPatch.self, from: data) | |
| 509 | + DispatchQueue.main.async { [weak self] in | |
| 510 | + guard let self else { | |
| 511 | + return | |
| 512 | + } | |
| 513 | + guard contentResetKey == self.contentResetKey else { | |
| 514 | + return | |
| 515 | + } | |
| 516 | + // A highlighter request from the previous file can finish after the view has | |
| 517 | + // already reset. Never let that stale patch roll the native token state back. | |
| 518 | + if let resetKey = patch.resetKey, resetKey != self.tokensResetKey { | |
| 519 | + return | |
| 520 | + } | |
| 521 | + | |
| 522 | + let tokensByRowId = patch.tokensByRowId ?? [:] | |
| 523 | + if tokensByRowId.isEmpty { | |
| 524 | + return | |
| 525 | + } | |
| 526 | + | |
| 527 | + self.contentView.mergeTokensByRowId(tokensByRowId) | |
| 528 | + if let chunkIndex = patch.chunkIndex, chunkIndex < 5 || chunkIndex.isMultiple(of: 10) { | |
| 529 | + self.emitDebug("tokens-patch-decoded", [ | |
| 530 | + "chunkIndex": chunkIndex, | |
| 531 | + "rows": tokensByRowId.count, | |
| 532 | + "totalRows": self.contentView.tokensByRowId.count, | |
| 533 | + ]) | |
| 534 | + } | |
| 535 | + } | |
| 536 | + } catch { | |
| 537 | + let message = error.localizedDescription | |
| 538 | + DispatchQueue.main.async { [weak self] in | |
| 539 | + self?.emitDebug("tokens-patch-decode-failed", ["error": message]) | |
| 540 | + } | |
| 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 { · 9 unmodified lines | ||
| 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 | 578 | func setCollapsedFileIdsJson(_ collapsedFileIdsJson: String) { |
| 486 | 579 | let nextCollapsedFileIds = decodeFileIdSet(collapsedFileIdsJson) |
| 487 | 580 | let changedFileIds = contentView.collapsedFileIds.symmetricDifference(nextCollapsedFileIds) |
| @@ -573,6 +666,8 @@ public final class T3ReviewDiffView: ExpoView, UIScrollViewDelegate { · 85 unmodified lines | ||
| 573 | 666 | contentView.invalidateVisibleViewport() |
| 574 | 667 | contentView.setNeedsDisplay() |
| 575 | 668 | applyInitialRowIndexIfNeeded() |
| 669 | + applyPendingScrollIfNeeded() | |
| 670 | + emitVisibleFileIfNeeded() | |
| 576 | 671 | |
| 577 | 672 | let debugKey = "\(rows.count):\(Int(bounds.width)):\(Int(bounds.height)):\(Int(height))" |
| 578 | 673 | if debugKey != lastMetricsDebugKey { |
| @@ -596,11 +691,41 @@ public final class T3ReviewDiffView: ExpoView, UIScrollViewDelegate { · 17 unmodified lines | ||
| 596 | 691 | } |
| 597 | 692 | |
| 598 | 693 | private func finishVerticalScroll() { |
| 694 | + isProgrammaticScrollActive = false | |
| 599 | 695 | contentView.isVerticalScrollActive = false |
| 600 | 696 | updateViewportFrame() |
| 601 | 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 | 729 | private func emitVisibleRange(reason: String) { |
| 605 | 730 | guard let range = contentView.currentVisibleRowRange() else { |
| 606 | 731 | return |
| @@ -670,6 +795,33 @@ public final class T3ReviewDiffView: ExpoView, UIScrollViewDelegate { · 63 unmodified lines | ||
| 670 | 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 | 825 | private func applyStyle() { |
| 674 | 826 | contentView.style = ReviewDiffNativeStyle |
| 675 | 827 | .resolve(stylePayload) |
| @@ -686,6 +838,38 @@ public final class T3ReviewDiffView: ExpoView, UIScrollViewDelegate { · 10 unmodified lines | ||
| 686 | 838 | ) |
| 687 | 839 | contentView.verticalOffset = scrollView.contentOffset.y |
| 688 | 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 | 875 | private func applyInitialRowIndexIfNeeded() { |
| @@ -1255,6 +1439,32 @@ private final class ReviewDiffContentView: UIView, UIGestureRecognizerDelegate { · 563 unmodified lines | ||
| 1255 | 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 | 1468 | private func fileHeaderRowIndex(forFileId fileId: String) -> Int? { |
| 1259 | 1469 | fileHeaderRowIndices.first { rowIndex in |
| 1260 | 1470 | rows.indices.contains(rowIndex) && resolvedFileId(for: rows[rowIndex]) == fileId |
| @@ -7,6 +7,12 @@ class T3TerminalModule : Module() { · 6 unmodified lines | ||
| 7 | 7 | override fun definition() = ModuleDefinition { |
| 8 | 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 | 16 | View(T3TerminalView::class) { |
| 11 | 17 | Prop("terminalKey") { view: T3TerminalView, terminalKey: String -> |
| 12 | 18 | view.terminalKey = terminalKey |
| @@ -20,6 +26,10 @@ class T3TerminalModule : Module() { · 7 unmodified lines | ||
| 20 | 26 | view.fontSize = fontSize.toFloat() |
| 21 | 27 | } |
| 22 | 28 | |
| 29 | + Prop("focusRequest") { view: T3TerminalView, focusRequest: Double -> | |
| 30 | + view.focusRequest = focusRequest | |
| 31 | + } | |
| 32 | + | |
| 23 | 33 | Prop("appearanceScheme") { view: T3TerminalView, appearanceScheme: String -> |
| 24 | 34 | view.appearanceScheme = appearanceScheme |
| 25 | 35 | } |
| @@ -62,6 +62,15 @@ class T3TerminalView(context: Context, appContext: AppContext) : ExpoView(contex · 61 unmodified lines | ||
| 62 | 62 | |
| 63 | 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 | 74 | var backgroundColorHex: String = "#24292E" |
| 66 | 75 | set(value) { |
| 67 | 76 | field = value |
| @@ -113,11 +122,19 @@ class T3TerminalView(context: Context, appContext: AppContext) : ExpoView(contex · 45 unmodified lines | ||
| 113 | 122 | } |
| 114 | 123 | inputView.setOnKeyListener { _, keyCode, event -> |
| 115 | 124 | if (event.action != android.view.KeyEvent.ACTION_DOWN) return@setOnKeyListener false |
| 116 | − when (keyCode) { | |
| 117 | − android.view.KeyEvent.KEYCODE_DEL -> { | |
| 125 | + when { | |
| 126 | + keyCode == android.view.KeyEvent.KEYCODE_DEL -> { | |
| 118 | 127 | onInput(mapOf("data" to "\u007F")) |
| 119 | 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 | 138 | else -> false |
| 122 | 139 | } |
| 123 | 140 | } |
| @@ -4,6 +4,12 @@ public class T3TerminalModule: Module { · 3 unmodified lines | ||
| 4 | 4 | public func definition() -> ModuleDefinition { |
| 5 | 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 | 13 | View(T3TerminalView.self) { |
| 8 | 14 | Prop("terminalKey") { (view: T3TerminalView, terminalKey: String) in |
| 9 | 15 | view.terminalKey = terminalKey |
| @@ -17,6 +23,10 @@ public class T3TerminalModule: Module { · 7 unmodified lines | ||
| 17 | 23 | view.fontSize = CGFloat(fontSize) |
| 18 | 24 | } |
| 19 | 25 | |
| 26 | + Prop("focusRequest") { (view: T3TerminalView, focusRequest: Double) in | |
| 27 | + view.focusRequest = focusRequest | |
| 28 | + } | |
| 29 | + | |
| 20 | 30 | Prop("appearanceScheme") { (view: T3TerminalView, appearanceScheme: String) in |
| 21 | 31 | view.appearanceScheme = appearanceScheme |
| 22 | 32 | } |
| @@ -22,13 +22,129 @@ private enum GhosttyRuntime { · 21 unmodified lines | ||
| 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 | 122 | private final class TerminalInputField: UITextField { |
| 26 | 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 | 134 | override func deleteBackward() { |
| 29 | 135 | onDeleteBackward?() |
| 30 | 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 | 150 | private enum TerminalAppearanceScheme: String { |
| @@ -108,6 +224,15 @@ public final class T3TerminalView: ExpoView, UITextFieldDelegate { · 73 unmodified lines | ||
| 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 | 236 | var appearanceScheme: String = TerminalAppearanceScheme.dark.rawValue { |
| 112 | 237 | didSet { |
| 113 | 238 | guard oldValue != appearanceScheme else { return } |
| @@ -167,6 +292,9 @@ public final class T3TerminalView: ExpoView, UITextFieldDelegate { · 53 unmodified lines | ||
| 167 | 292 | inputField.onDeleteBackward = { [weak self] in |
| 168 | 293 | self?.emitInput("\u{7F}") |
| 169 | 294 | } |
| 295 | + inputField.onInsert = { [weak self] data in | |
| 296 | + self?.emitInput(data) | |
| 297 | + } | |
| 170 | 298 | |
| 171 | 299 | focusTapGesture.addTarget(self, action: #selector(handleViewportTap)) |
| 172 | 300 | terminalViewport.addGestureRecognizer(focusTapGesture) |
| @@ -5,7 +5,7 @@ · 4 unmodified lines | ||
| 5 | 5 | "main": "index.ts", |
| 6 | 6 | "scripts": { |
| 7 | 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 | 9 | "start": "expo start", |
| 10 | 10 | "start:dev": "APP_VARIANT=development expo start", |
| 11 | 11 | "start:preview": "APP_VARIANT=preview expo start", |
| @@ -43,12 +43,16 @@ · 31 unmodified lines | ||
| 43 | 43 | "@clerk/expo": "catalog:", |
| 44 | 44 | "@effect/atom-react": "catalog:", |
| 45 | 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 | 48 | "@legendapp/list": "3.2.0", |
| 48 | 49 | "@noble/curves": "catalog:", |
| 49 | 50 | "@noble/hashes": "catalog:", |
| 50 | 51 | "@pierre/diffs": "catalog:", |
| 51 | 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 | 56 | "@shikijs/core": "4.2.0", |
| 53 | 57 | "@shikijs/engine-javascript": "4.2.0", |
| 54 | 58 | "@shikijs/langs": "4.2.0", |
| @@ -62,38 +66,37 @@ · 7 unmodified lines | ||
| 62 | 66 | "clsx": "^2.1.1", |
| 63 | 67 | "diff": "8.0.3", |
| 64 | 68 | "effect": "catalog:", |
| 65 | − "expo": "^56.0.0", | |
| 66 | − "expo-asset": "~56.0.15", | |
| 67 | − "expo-auth-session": "~56.0.12", | |
| 68 | − "expo-build-properties": "~56.0.15", | |
| 69 | − "expo-camera": "~56.0.7", | |
| 70 | − "expo-clipboard": "~56.0.3", | |
| 71 | − "expo-constants": "~56.0.16", | |
| 69 | + "expo": "~56.0.12", | |
| 70 | + "expo-asset": "~56.0.17", | |
| 71 | + "expo-auth-session": "~56.0.14", | |
| 72 | + "expo-build-properties": "~56.0.19", | |
| 73 | + "expo-camera": "~56.0.8", | |
| 74 | + "expo-clipboard": "~56.0.4", | |
| 75 | + "expo-constants": "~56.0.18", | |
| 72 | 76 | "expo-crypto": "~56.0.4", |
| 73 | − "expo-dev-client": "~56.0.16", | |
| 74 | − "expo-file-system": "~56.0.7", | |
| 75 | − "expo-font": "~56.0.5", | |
| 77 | + "expo-dev-client": "~56.0.20", | |
| 78 | + "expo-file-system": "~56.0.8", | |
| 79 | + "expo-font": "~56.0.7", | |
| 76 | 80 | "expo-glass-effect": "~56.0.4", |
| 77 | 81 | "expo-haptics": "~56.0.3", |
| 78 | − "expo-image-picker": "~56.0.14", | |
| 79 | − "expo-linking": "~56.0.12", | |
| 82 | + "expo-image-picker": "~56.0.18", | |
| 83 | + "expo-linking": "~56.0.14", | |
| 80 | 84 | "expo-network": "~56.0.5", |
| 81 | − "expo-notifications": "~56.0.14", | |
| 85 | + "expo-notifications": "~56.0.18", | |
| 82 | 86 | "expo-paste-input": "^0.1.15", |
| 83 | − "expo-router": "~56.2.7", | |
| 84 | 87 | "expo-secure-store": "~56.0.4", |
| 85 | 88 | "expo-splash-screen": "~56.0.10", |
| 86 | − "expo-symbols": "~56.0.5", | |
| 87 | − "expo-updates": "~56.0.17", | |
| 89 | + "expo-symbols": "~56.0.6", | |
| 90 | + "expo-updates": "~56.0.19", | |
| 88 | 91 | "expo-web-browser": "~56.0.5", |
| 89 | − "expo-widgets": "~56.0.15", | |
| 92 | + "expo-widgets": "~56.0.19", | |
| 90 | 93 | "punycode": "^2.3.1", |
| 91 | 94 | "react": "19.2.3", |
| 92 | 95 | "react-dom": "19.2.3", |
| 93 | 96 | "react-native": "0.85.3", |
| 94 | 97 | "react-native-gesture-handler": "~2.31.1", |
| 95 | 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 | 100 | "react-native-nitro-markdown": "^0.5.0", |
| 98 | 101 | "react-native-nitro-modules": "0.35.9", |
| 99 | 102 | "react-native-reanimated": "4.3.1", |
| @@ -117,5 +120,13 @@ · 17 unmodified lines | ||
| 117 | 120 | }, |
| 118 | 121 | "overrides": { |
| 119 | 122 | "react-native-nitro-markdown": "file:deps/react-native-nitro-markdown-0.5.0.tgz" |
| 123 | + }, | |
| 124 | + "expo": { | |
| 125 | + "autolinking": { | |
| 126 | + "buildFromSource": [ | |
| 127 | + "react-native-screens", | |
| 128 | + "@react-native-menu/menu" | |
| 129 | + ] | |
| 130 | + } | |
| 120 | 131 | } |
| 121 | 132 | } |
| @@ -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 | +}; | |
| @@ -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 | +}; | |
| @@ -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 | +} | |
| @@ -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 | +} | |
| @@ -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 | −} | |
| @@ -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 | −} | |
| @@ -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 | −} | |
| @@ -1,130 +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 | − onNewThreadInProject={(project) => { | |
| 106 | − router.push({ | |
| 107 | − pathname: "/new/draft", | |
| 108 | − params: { | |
| 109 | − environmentId: project.environmentId, | |
| 110 | − projectId: project.id, | |
| 111 | − title: project.title, | |
| 112 | − }, | |
| 113 | − }); | |
| 114 | − }} | |
| 115 | − onOpenEnvironments={() => router.push("/settings/environments")} | |
| 116 | − onSelectThread={(thread) => { | |
| 117 | − router.push(buildThreadRoutePath(thread)); | |
| 118 | − }} | |
| 119 | − projectGroupingMode={listOptions.projectGroupingMode} | |
| 120 | − projects={projects} | |
| 121 | − projectSortOrder={listOptions.projectSortOrder} | |
| 122 | − savedConnectionsById={savedConnectionsById} | |
| 123 | − searchQuery={searchQuery} | |
| 124 | − selectedEnvironmentId={selectedEnvironmentId} | |
| 125 | − threads={threads} | |
| 126 | − threadSortOrder={listOptions.threadSortOrder} | |
| 127 | − /> | |
| 128 | − </> | |
| 129 | − ); | |
| 130 | −} | |
| @@ -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 | −} | |
| @@ -1,5 +0,0 @@ | ||
| 1 | −import { AddProjectDestinationScreen } from "../../../features/projects/AddProjectScreen"; | |
| 2 | − | |
| 3 | −export default function AddProjectDestinationRoute() { | |
| 4 | − return <AddProjectDestinationScreen />; | |
| 5 | −} | |
| @@ -1,5 +0,0 @@ | ||
| 1 | −import { AddProjectSourceScreen } from "../../../features/projects/AddProjectScreen"; | |
| 2 | − | |
| 3 | −export default function AddProjectRoute() { | |
| 4 | − return <AddProjectSourceScreen />; | |
| 5 | −} | |
| @@ -1,5 +0,0 @@ | ||
| 1 | −import { AddProjectLocalFolderScreen } from "../../../features/projects/AddProjectScreen"; | |
| 2 | − | |
| 3 | −export default function AddProjectLocalRoute() { | |
| 4 | − return <AddProjectLocalFolderScreen />; | |
| 5 | −} | |
| @@ -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 | −} | |
| @@ -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 | −} | |
| @@ -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 | −} | |
| @@ -1,3 +0,0 @@ | ||
| 1 | −import { ArchivedThreadsRouteScreen } from "../../features/archive/ArchivedThreadsRouteScreen"; | |
| 2 | − | |
| 3 | −export default ArchivedThreadsRouteScreen; | |
| @@ -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 | −} | |
| @@ -1 +0,0 @@ | ||
| 1 | −export { default } from "../connections/new"; | |
| @@ -1,109 +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 | − headerBackVisible: true, | |
| 22 | − headerBackButtonDisplayMode: "minimal", | |
| 23 | − }} | |
| 24 | − /> | |
| 25 | − <Stack.Screen | |
| 26 | − name="git" | |
| 27 | − options={{ | |
| 28 | − contentStyle: sheetStyle, | |
| 29 | − gestureEnabled: true, | |
| 30 | − headerShown: false, | |
| 31 | − presentation: "formSheet" as const, | |
| 32 | − sheetAllowedDetents: [0.85], | |
| 33 | − sheetGrabberVisible: true, | |
| 34 | − }} | |
| 35 | − /> | |
| 36 | − <Stack.Screen | |
| 37 | − name="git-confirm" | |
| 38 | − options={{ | |
| 39 | − contentStyle: sheetStyle, | |
| 40 | − gestureEnabled: true, | |
| 41 | − headerShown: false, | |
| 42 | − presentation: "formSheet" as const, | |
| 43 | − sheetAllowedDetents: [0.4], | |
| 44 | − sheetGrabberVisible: true, | |
| 45 | − }} | |
| 46 | − /> | |
| 47 | − <Stack.Screen | |
| 48 | − name="review" | |
| 49 | − options={{ | |
| 50 | − animation: "slide_from_right", | |
| 51 | − contentStyle: sheetStyle, | |
| 52 | − headerBackButtonDisplayMode: "minimal", | |
| 53 | − headerShown: true, | |
| 54 | − headerTitle: "Files changed", | |
| 55 | − headerBackTitle: "", | |
| 56 | − headerShadowVisible: false, | |
| 57 | − headerStyle: headerBg, | |
| 58 | − }} | |
| 59 | − /> | |
| 60 | − <Stack.Screen | |
| 61 | − name="files/index" | |
| 62 | − options={{ | |
| 63 | − animation: "slide_from_right", | |
| 64 | − contentStyle: sheetStyle, | |
| 65 | − headerBackButtonDisplayMode: "minimal", | |
| 66 | − headerShown: true, | |
| 67 | − headerTitle: "Files", | |
| 68 | − headerBackTitle: "", | |
| 69 | − headerShadowVisible: false, | |
| 70 | − headerStyle: headerBg, | |
| 71 | − }} | |
| 72 | − /> | |
| 73 | − <Stack.Screen | |
| 74 | − name="files/[...path]" | |
| 75 | − options={{ | |
| 76 | − animation: "slide_from_right", | |
| 77 | − contentStyle: sheetStyle, | |
| 78 | − headerBackButtonDisplayMode: "minimal", | |
| 79 | − headerShown: true, | |
| 80 | − headerTitle: "File", | |
| 81 | − headerBackTitle: "", | |
| 82 | − headerShadowVisible: false, | |
| 83 | − headerStyle: headerBg, | |
| 84 | − }} | |
| 85 | − /> | |
| 86 | − <Stack.Screen | |
| 87 | − name="review-comment" | |
| 88 | − options={{ | |
| 89 | − contentStyle: sheetStyle, | |
| 90 | − gestureEnabled: true, | |
| 91 | − headerShown: false, | |
| 92 | − presentation: "formSheet" as const, | |
| 93 | − sheetAllowedDetents: [0.72, 0.92], | |
| 94 | − sheetGrabberVisible: true, | |
| 95 | − }} | |
| 96 | − /> | |
| 97 | − <Stack.Screen | |
| 98 | − name="terminal" | |
| 99 | − options={{ | |
| 100 | − animation: "slide_from_right", | |
| 101 | − contentStyle: { backgroundColor: "#050505" }, | |
| 102 | − headerBackButtonDisplayMode: "minimal", | |
| 103 | − headerShown: true, | |
| 104 | − headerShadowVisible: false, | |
| 105 | − }} | |
| 106 | − /> | |
| 107 | − </Stack> | |
| 108 | − ); | |
| 109 | −} | |
| @@ -1,5 +0,0 @@ | ||
| 1 | −import { ThreadFileScreen } from "../../../../../features/files/ThreadFilesRouteScreen"; | |
| 2 | − | |
| 3 | −export default function ThreadFileRoute() { | |
| 4 | − return <ThreadFileScreen />; | |
| 5 | −} | |
| @@ -1,5 +0,0 @@ | ||
| 1 | −import { ThreadFilesTreeScreen } from "../../../../../features/files/ThreadFilesRouteScreen"; | |
| 2 | − | |
| 3 | −export default function ThreadFilesIndexRoute() { | |
| 4 | − return <ThreadFilesTreeScreen />; | |
| 5 | −} | |
| @@ -1,5 +0,0 @@ | ||
| 1 | −import { GitConfirmSheet } from "../../../../features/threads/git/GitConfirmSheet"; | |
| 2 | − | |
| 3 | −export default function GitConfirmRoute() { | |
| 4 | − return <GitConfirmSheet />; | |
| 5 | −} | |
| @@ -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 | −} | |
| @@ -1,5 +0,0 @@ | ||
| 1 | −import { GitBranchesSheet } from "../../../../../features/threads/git/GitBranchesSheet"; | |
| 2 | − | |
| 3 | −export default function GitBranchesRoute() { | |
| 4 | − return <GitBranchesSheet />; | |
| 5 | −} | |
| @@ -1,5 +0,0 @@ | ||
| 1 | −import { GitCommitSheet } from "../../../../../features/threads/git/GitCommitSheet"; | |
| 2 | − | |
| 3 | −export default function GitCommitRoute() { | |
| 4 | − return <GitCommitSheet />; | |
| 5 | −} | |
| @@ -1,5 +0,0 @@ | ||
| 1 | −import { GitOverviewSheet } from "../../../../../features/threads/git/GitOverviewSheet"; | |
| 2 | − | |
| 3 | −export default function GitRoute() { | |
| 4 | − return <GitOverviewSheet />; | |
| 5 | −} | |
| @@ -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 | −} | |
| @@ -1,5 +0,0 @@ | ||
| 1 | −import { ThreadRouteScreen } from "../../../../features/threads/ThreadRouteScreen"; | |
| 2 | − | |
| 3 | −export default function ThreadRoute() { | |
| 4 | − return <ThreadRouteScreen />; | |
| 5 | −} | |
| @@ -1,5 +0,0 @@ | ||
| 1 | −import { ReviewCommentComposerSheet } from "../../../../features/review/ReviewCommentComposerSheet"; | |
| 2 | − | |
| 3 | −export default function ReviewCommentRoute() { | |
| 4 | − return <ReviewCommentComposerSheet />; | |
| 5 | −} | |
| @@ -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 | −} | |
| @@ -1,5 +0,0 @@ | ||
| 1 | −import { ThreadTerminalRouteScreen } from "../../../../features/terminal/ThreadTerminalRouteScreen"; | |
| 2 | − | |
| 3 | −export default function ThreadTerminalRoute() { | |
| 4 | − return <ThreadTerminalRouteScreen />; | |
| 5 | −} | |
| @@ -1,4 +1,3 @@ | ||
| 1 | −import type { Ref } from "react"; | |
| 2 | 1 | import { |
| 3 | 2 | Text as RNText, |
| 4 | 3 | TextInput as RNTextInput, |
| @@ -21,7 +20,7 @@ export function AppText({ className, ...props }: AppTextProps) { · 16 unmodified lines | ||
| 21 | 20 | |
| 22 | 21 | export type AppTextInputProps = RNTextInputProps & { |
| 23 | 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({ · 12 unmodified lines | ||
| 40 | 39 | <RNTextInput |
| 41 | 40 | ref={ref} |
| 42 | 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 | 43 | className, |
| 45 | 44 | )} |
| 46 | 45 | placeholderTextColor={placeholderTextColor ?? placeholderColor} |
| @@ -75,7 +75,7 @@ export function ControlPill(props: { · 74 unmodified lines | ||
| 75 | 75 | } |
| 76 | 76 | |
| 77 | 77 | export function ControlPillMenu( |
| 78 | − props: Omit<ComponentProps<typeof MenuView>, "children" | "style" | "themeVariant"> & { | |
| 78 | + props: Omit<ComponentProps<typeof MenuView>, "children" | "themeVariant"> & { | |
| 79 | 79 | readonly children: ReactNode; |
| 80 | 80 | }, |
| 81 | 81 | ) { |
| @@ -7,11 +7,33 @@ export function EmptyState(props: { · 6 unmodified lines | ||
| 7 | 7 | readonly detail: string; |
| 8 | 8 | readonly actionLabel?: string; |
| 9 | 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 | 33 | return ( |
| 12 | 34 | <View className="rounded-[22px] border border-border bg-card p-5"> |
| 13 | 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 | 37 | {props.detail} |
| 16 | 38 | </Text> |
| 17 | 39 | {props.actionLabel && props.onAction ? ( |
| @@ -4,7 +4,7 @@ import { AppText as Text } from "./AppText"; · 3 unmodified lines | ||
| 4 | 4 | export function ErrorBanner(props: { readonly message: string }) { |
| 5 | 5 | return ( |
| 6 | 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 | 8 | {props.message} |
| 9 | 9 | </Text> |
| 10 | 10 | </View> |
| @@ -1,12 +1,12 @@ | ||
| 1 | 1 | import { useEffect, useRef } from "react"; |
| 2 | 2 | import * as Notifications from "expo-notifications"; |
| 3 | −import { useRouter } from "expo-router"; | |
| 3 | +import { useLinkTo } from "@react-navigation/native"; | |
| 4 | 4 | |
| 5 | 5 | import { routeAgentNotificationResponseOnce } from "./notificationPayload"; |
| 6 | 6 | import { consumeLastAgentNotificationResponse } from "./notificationResponseConsumer"; |
| 7 | 7 | |
| 8 | 8 | export function useAgentNotificationNavigation(): void { |
| 9 | − const router = useRouter(); | |
| 9 | + const linkTo = useLinkTo(); | |
| 10 | 10 | const handledResponseIds = useRef(new Set<string>()); |
| 11 | 11 | |
| 12 | 12 | useEffect(() => { |
| @@ -14,7 +14,7 @@ export function useAgentNotificationNavigation(): void { · 1 unmodified line | ||
| 14 | 14 | routeAgentNotificationResponseOnce({ |
| 15 | 15 | handledResponseIds: handledResponseIds.current, |
| 16 | 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 { · 7 unmodified lines | ||
| 28 | 28 | return () => { |
| 29 | 29 | subscription.remove(); |
| 30 | 30 | }; |
| 31 | − }, [router]); | |
| 31 | + }, [linkTo]); | |
| 32 | 32 | } |
| @@ -1,7 +1,7 @@ | ||
| 1 | 1 | import type { EnvironmentId } from "@t3tools/contracts"; |
| 2 | 2 | import * as Arr from "effect/Array"; |
| 3 | 3 | import * as Order from "effect/Order"; |
| 4 | −import { useFocusEffect } from "expo-router"; | |
| 4 | +import { useFocusEffect } from "@react-navigation/native"; | |
| 5 | 5 | import { useCallback, useMemo, useState } from "react"; |
| 6 | 6 | |
| 7 | 7 | import { useSavedRemoteConnections } from "../../state/use-remote-environment-registry"; |
| @@ -2,23 +2,22 @@ import type { · 1 unmodified line | ||
| 2 | 2 | EnvironmentProject, |
| 3 | 3 | EnvironmentThreadShell, |
| 4 | 4 | } from "@t3tools/client-runtime/state/shell"; |
| 5 | +import { LegendList } from "@legendapp/list/react-native"; | |
| 5 | 6 | import type { EnvironmentId } from "@t3tools/contracts"; |
| 6 | 7 | import type { MenuAction } from "@react-native-menu/menu"; |
| 7 | −import * as Haptics from "expo-haptics"; | |
| 8 | −import { Stack } from "expo-router"; | |
| 8 | +import { NativeHeaderToolbar, NativeStackScreenOptions } from "../../native/StackHeader"; | |
| 9 | 9 | import { SymbolView } from "expo-symbols"; |
| 10 | −import { useCallback, useRef } from "react"; | |
| 10 | +import { useCallback, useMemo, useRef, type ComponentProps } from "react"; | |
| 11 | 11 | import { |
| 12 | 12 | ActivityIndicator, |
| 13 | + Platform, | |
| 13 | 14 | Pressable, |
| 14 | 15 | RefreshControl, |
| 15 | − ScrollView, | |
| 16 | 16 | useWindowDimensions, |
| 17 | 17 | View, |
| 18 | 18 | } from "react-native"; |
| 19 | −import ReanimatedSwipeable, { | |
| 20 | − type SwipeableMethods, | |
| 21 | −} from "react-native-gesture-handler/ReanimatedSwipeable"; | |
| 19 | +import { Gesture, GestureDetector } from "react-native-gesture-handler"; | |
| 20 | +import type { SwipeableMethods } from "react-native-gesture-handler/ReanimatedSwipeable"; | |
| 22 | 21 | |
| 23 | 22 | import { AppText as Text } from "../../components/AppText"; |
| 24 | 23 | import { ControlPillMenu } from "../../components/ControlPill"; |
| @@ -26,11 +25,8 @@ import { EmptyState } from "../../components/EmptyState"; · 1 unmodified line | ||
| 26 | 25 | import { ProjectFavicon } from "../../components/ProjectFavicon"; |
| 27 | 26 | import { relativeTime } from "../../lib/time"; |
| 28 | 27 | import { useThemeColor } from "../../lib/useThemeColor"; |
| 29 | −import { | |
| 30 | − THREAD_SWIPE_ACTIONS_WIDTH, | |
| 31 | − THREAD_SWIPE_SPRING, | |
| 32 | − ThreadSwipeActions, | |
| 33 | −} from "../home/thread-swipe-actions"; | |
| 28 | +import { ThreadSwipeable } from "../home/thread-swipe-actions"; | |
| 29 | +import { createNativeMailSearchToolbarItem } from "../layout/native-mail-search-toolbar"; | |
| 34 | 30 | import type { ArchivedThreadGroup, ArchivedThreadSortOrder } from "./archivedThreadList"; |
| 35 | 31 | |
| 36 | 32 | export interface ArchivedThreadsHeaderEnvironment { |
| @@ -52,84 +48,185 @@ const THREAD_ACTIONS: MenuAction[] = [ · 15 unmodified lines | ||
| 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 | 67 | function ArchivedThreadsHeader(props: { |
| 56 | 68 | readonly environments: ReadonlyArray<ArchivedThreadsHeaderEnvironment>; |
| 57 | 69 | readonly selectedEnvironmentId: EnvironmentId | null; |
| 58 | 70 | readonly sortOrder: ArchivedThreadSortOrder; |
| 59 | 71 | readonly onEnvironmentChange: (environmentId: EnvironmentId | null) => void; |
| 72 | + readonly onRefresh: () => void; | |
| 60 | 73 | readonly onSearchQueryChange: (query: string) => void; |
| 61 | 74 | readonly onSortOrderChange: (sortOrder: ArchivedThreadSortOrder) => void; |
| 62 | 75 | }) { |
| 76 | + const { width } = useWindowDimensions(); | |
| 63 | 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 | 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 | 130 | options={{ |
| 69 | − title: "Archived Threads", | |
| 70 | − headerSearchBarOptions: { | |
| 71 | − autoCapitalize: "none", | |
| 72 | − hideNavigationBar: false, | |
| 73 | − obscureBackground: false, | |
| 74 | − placeholder: "Search archived threads", | |
| 75 | − placement: "stacked", | |
| 76 | − onChangeText: (event) => { | |
| 77 | − props.onSearchQueryChange(event.nativeEvent.text); | |
| 78 | − }, | |
| 79 | − onCancelButtonPress: () => { | |
| 80 | − props.onSearchQueryChange(""); | |
| 81 | − }, | |
| 82 | − }, | |
| 131 | + unstable_headerToolbarItems: usesCompactMailToolbar | |
| 132 | + ? () => [ | |
| 133 | + createNativeMailSearchToolbarItem({ | |
| 134 | + composeButtonId: "archived-refresh", | |
| 135 | + composeSystemImageName: "arrow.clockwise", | |
| 136 | + filterMenu: archiveFilterMenu, | |
| 137 | + filterButtonId: "archived-filter", | |
| 138 | + filterSystemImageName: hasCustomFilter | |
| 139 | + ? "line.3.horizontal.decrease.circle.fill" | |
| 140 | + : "line.3.horizontal.decrease", | |
| 141 | + onComposePress: props.onRefresh, | |
| 142 | + onSearchTextChange: props.onSearchQueryChange, | |
| 143 | + placeholder: "Search", | |
| 144 | + searchTextChangeId: "archived-search-text", | |
| 145 | + }), | |
| 146 | + ] | |
| 147 | + : undefined, | |
| 148 | + headerSearchBarOptions: usesCompactMailToolbar | |
| 149 | + ? undefined | |
| 150 | + : { | |
| 151 | + ...(usesNativeChrome | |
| 152 | + ? { | |
| 153 | + allowToolbarIntegration: true, | |
| 154 | + placement: "integratedButton" as const, | |
| 155 | + } | |
| 156 | + : { | |
| 157 | + placement: "stacked" as const, | |
| 158 | + }), | |
| 159 | + autoCapitalize: "none", | |
| 160 | + hideNavigationBar: false, | |
| 161 | + obscureBackground: false, | |
| 162 | + placeholder: "Search archived threads", | |
| 163 | + onChangeText: (event) => { | |
| 164 | + props.onSearchQueryChange(event.nativeEvent.text); | |
| 165 | + }, | |
| 166 | + onCancelButtonPress: () => { | |
| 167 | + props.onSearchQueryChange(""); | |
| 168 | + }, | |
| 169 | + }, | |
| 83 | 170 | }} |
| 84 | 171 | /> |
| 85 | 172 | |
| 86 | − <Stack.Toolbar placement="right"> | |
| 87 | − <Stack.Toolbar.Menu | |
| 88 | − accessibilityLabel="Filter and sort archived threads" | |
| 89 | − icon={ | |
| 90 | − hasCustomFilter | |
| 91 | − ? "line.3.horizontal.decrease.circle.fill" | |
| 92 | − : "line.3.horizontal.decrease.circle" | |
| 93 | − } | |
| 94 | − separateBackground | |
| 95 | − title="Archived thread options" | |
| 96 | − > | |
| 97 | − <Stack.Toolbar.Menu title="Environment"> | |
| 98 | − <Stack.Toolbar.Label>Environment</Stack.Toolbar.Label> | |
| 99 | − <Stack.Toolbar.MenuAction | |
| 100 | − isOn={props.selectedEnvironmentId === null} | |
| 101 | − onPress={() => props.onEnvironmentChange(null)} | |
| 102 | − > | |
| 103 | − <Stack.Toolbar.Label>All environments</Stack.Toolbar.Label> | |
| 104 | − </Stack.Toolbar.MenuAction> | |
| 105 | − {props.environments.map((environment) => ( | |
| 106 | − <Stack.Toolbar.MenuAction | |
| 107 | − key={environment.environmentId} | |
| 108 | − isOn={props.selectedEnvironmentId === environment.environmentId} | |
| 109 | − onPress={() => props.onEnvironmentChange(environment.environmentId)} | |
| 173 | + {usesCompactMailToolbar ? null : ( | |
| 174 | + <NativeHeaderToolbar placement="right"> | |
| 175 | + {usesNativeChrome ? ( | |
| 176 | + <NativeHeaderToolbar.Button | |
| 177 | + accessibilityLabel="Refresh archived threads" | |
| 178 | + icon="arrow.clockwise" | |
| 179 | + onPress={props.onRefresh} | |
| 180 | + separateBackground | |
| 181 | + /> | |
| 182 | + ) : null} | |
| 183 | + <NativeHeaderToolbar.Menu | |
| 184 | + accessibilityLabel="Filter and sort archived threads" | |
| 185 | + icon={ | |
| 186 | + hasCustomFilter | |
| 187 | + ? "line.3.horizontal.decrease.circle.fill" | |
| 188 | + : "line.3.horizontal.decrease.circle" | |
| 189 | + } | |
| 190 | + separateBackground | |
| 191 | + title="Archived thread options" | |
| 192 | + > | |
| 193 | + <NativeHeaderToolbar.Menu title="Environment"> | |
| 194 | + <NativeHeaderToolbar.Label>Environment</NativeHeaderToolbar.Label> | |
| 195 | + <NativeHeaderToolbar.MenuAction | |
| 196 | + isOn={props.selectedEnvironmentId === null} | |
| 197 | + onPress={() => props.onEnvironmentChange(null)} | |
| 110 | 198 | > |
| 111 | − <Stack.Toolbar.Label>{environment.label}</Stack.Toolbar.Label> | |
| 112 | − </Stack.Toolbar.MenuAction> | |
| 113 | − ))} | |
| 114 | − </Stack.Toolbar.Menu> | |
| 199 | + <NativeHeaderToolbar.Label>All environments</NativeHeaderToolbar.Label> | |
| 200 | + </NativeHeaderToolbar.MenuAction> | |
| 201 | + {props.environments.map((environment) => ( | |
| 202 | + <NativeHeaderToolbar.MenuAction | |
| 203 | + key={environment.environmentId} | |
| 204 | + isOn={props.selectedEnvironmentId === environment.environmentId} | |
| 205 | + onPress={() => props.onEnvironmentChange(environment.environmentId)} | |
| 206 | + > | |
| 207 | + <NativeHeaderToolbar.Label>{environment.label}</NativeHeaderToolbar.Label> | |
| 208 | + </NativeHeaderToolbar.MenuAction> | |
| 209 | + ))} | |
| 210 | + </NativeHeaderToolbar.Menu> | |
| 115 | 211 | |
| 116 | − <Stack.Toolbar.Menu title="Sort by archived date"> | |
| 117 | − <Stack.Toolbar.Label>Sort by archived date</Stack.Toolbar.Label> | |
| 118 | − <Stack.Toolbar.MenuAction | |
| 119 | − isOn={props.sortOrder === "newest"} | |
| 120 | − onPress={() => props.onSortOrderChange("newest")} | |
| 121 | − > | |
| 122 | − <Stack.Toolbar.Label>Newest first</Stack.Toolbar.Label> | |
| 123 | − </Stack.Toolbar.MenuAction> | |
| 124 | − <Stack.Toolbar.MenuAction | |
| 125 | − isOn={props.sortOrder === "oldest"} | |
| 126 | − onPress={() => props.onSortOrderChange("oldest")} | |
| 127 | − > | |
| 128 | − <Stack.Toolbar.Label>Oldest first</Stack.Toolbar.Label> | |
| 129 | − </Stack.Toolbar.MenuAction> | |
| 130 | − </Stack.Toolbar.Menu> | |
| 131 | − </Stack.Toolbar.Menu> | |
| 132 | − </Stack.Toolbar> | |
| 212 | + <NativeHeaderToolbar.Menu title="Sort by archived date"> | |
| 213 | + <NativeHeaderToolbar.Label>Sort by archived date</NativeHeaderToolbar.Label> | |
| 214 | + <NativeHeaderToolbar.MenuAction | |
| 215 | + isOn={props.sortOrder === "newest"} | |
| 216 | + onPress={() => props.onSortOrderChange("newest")} | |
| 217 | + > | |
| 218 | + <NativeHeaderToolbar.Label>Newest first</NativeHeaderToolbar.Label> | |
| 219 | + </NativeHeaderToolbar.MenuAction> | |
| 220 | + <NativeHeaderToolbar.MenuAction | |
| 221 | + isOn={props.sortOrder === "oldest"} | |
| 222 | + onPress={() => props.onSortOrderChange("oldest")} | |
| 223 | + > | |
| 224 | + <NativeHeaderToolbar.Label>Oldest first</NativeHeaderToolbar.Label> | |
| 225 | + </NativeHeaderToolbar.MenuAction> | |
| 226 | + </NativeHeaderToolbar.Menu> | |
| 227 | + </NativeHeaderToolbar.Menu> | |
| 228 | + </NativeHeaderToolbar> | |
| 229 | + )} | |
| 133 | 230 | </> |
| 134 | 231 | ); |
| 135 | 232 | } |
| @@ -164,30 +261,25 @@ function ProjectGroupLabel(props: { · 28 unmodified lines | ||
| 164 | 261 | |
| 165 | 262 | function ArchivedThreadRow(props: { |
| 166 | 263 | readonly environmentLabel: string | null; |
| 264 | + readonly isFirst: boolean; | |
| 167 | 265 | readonly isLast: boolean; |
| 168 | 266 | readonly onDelete: () => void; |
| 169 | 267 | readonly onSwipeableClose: (methods: SwipeableMethods) => void; |
| 170 | 268 | readonly onSwipeableWillOpen: (methods: SwipeableMethods) => void; |
| 269 | + readonly simultaneousSwipeGesture?: ComponentProps< | |
| 270 | + typeof ThreadSwipeable | |
| 271 | + >["simultaneousWithExternalGesture"]; | |
| 171 | 272 | readonly onUnarchive: () => void; |
| 172 | 273 | readonly thread: EnvironmentThreadShell; |
| 173 | 274 | }) { |
| 174 | − const swipeableRef = useRef<SwipeableMethods | null>(null); | |
| 175 | − const fullSwipeArmedRef = useRef(false); | |
| 176 | 275 | const { width: windowWidth } = useWindowDimensions(); |
| 177 | 276 | const cardColor = useThemeColor("--color-card"); |
| 178 | 277 | const iconColor = useThemeColor("--color-icon-subtle"); |
| 179 | 278 | const separatorColor = useThemeColor("--color-separator"); |
| 180 | − const fullSwipeThreshold = Math.max(THREAD_SWIPE_ACTIONS_WIDTH + 44, (windowWidth - 32) * 0.58); | |
| 181 | 279 | const timestamp = relativeTime(props.thread.archivedAt ?? props.thread.updatedAt); |
| 182 | 280 | const subtitle = [props.environmentLabel, props.thread.branch].filter((part): part is string => |
| 183 | 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 | 283 | const handleMenuAction = useCallback( |
| 192 | 284 | (event: { nativeEvent: { event: string } }) => { |
| 193 | 285 | if (event.nativeEvent.event === "unarchive") { |
| @@ -200,114 +292,84 @@ function ArchivedThreadRow(props: { · 6 unmodified lines | ||
| 200 | 292 | ); |
| 201 | 293 | |
| 202 | 294 | return ( |
| 203 | − <ReanimatedSwipeable | |
| 204 | − ref={swipeableRef} | |
| 205 | − animationOptions={THREAD_SWIPE_SPRING} | |
| 206 | − childrenContainerStyle={{ backgroundColor: cardColor }} | |
| 207 | − containerStyle={{ backgroundColor: cardColor }} | |
| 208 | − dragOffsetFromRightEdge={8} | |
| 209 | − enableTrackpadTwoFingerGesture | |
| 210 | − friction={1} | |
| 211 | − onSwipeableClose={() => { | |
| 212 | − fullSwipeArmedRef.current = false; | |
| 213 | − if (swipeableRef.current) { | |
| 214 | − props.onSwipeableClose(swipeableRef.current); | |
| 215 | − } | |
| 216 | − }} | |
| 217 | − onSwipeableOpenStartDrag={() => { | |
| 218 | − if (swipeableRef.current) { | |
| 219 | − props.onSwipeableWillOpen(swipeableRef.current); | |
| 220 | − } | |
| 295 | + <ThreadSwipeable | |
| 296 | + backgroundColor={cardColor} | |
| 297 | + fullSwipeWidth={windowWidth - 32} | |
| 298 | + onDelete={props.onDelete} | |
| 299 | + onSwipeableClose={props.onSwipeableClose} | |
| 300 | + onSwipeableWillOpen={props.onSwipeableWillOpen} | |
| 301 | + primaryAction={{ | |
| 302 | + accessibilityLabel: `Unarchive ${props.thread.title}`, | |
| 303 | + icon: "arrow.uturn.backward", | |
| 304 | + label: "Unarchive", | |
| 305 | + onPress: props.onUnarchive, | |
| 221 | 306 | }} |
| 222 | − onSwipeableWillOpen={() => { | |
| 223 | − const methods = swipeableRef.current; | |
| 224 | − if (!methods) return; | |
| 225 | − | |
| 226 | − props.onSwipeableWillOpen(methods); | |
| 227 | − if (fullSwipeArmedRef.current) { | |
| 228 | − fullSwipeArmedRef.current = false; | |
| 229 | − methods.close(); | |
| 230 | − props.onDelete(); | |
| 231 | − } | |
| 232 | − }} | |
| 233 | − overshootFriction={1} | |
| 234 | − overshootRight | |
| 235 | − renderRightActions={(_progress, translation, methods) => ( | |
| 236 | − <ThreadSwipeActions | |
| 237 | − backgroundColor={cardColor} | |
| 238 | − fullSwipeThreshold={fullSwipeThreshold} | |
| 239 | − onDelete={props.onDelete} | |
| 240 | − onFullSwipeArmedChange={handleFullSwipeArmedChange} | |
| 241 | − primaryAction={{ | |
| 242 | − accessibilityLabel: `Unarchive ${props.thread.title}`, | |
| 243 | − icon: "arrow.uturn.backward", | |
| 244 | − label: "Unarchive", | |
| 245 | − onPress: props.onUnarchive, | |
| 246 | − }} | |
| 247 | − swipeableMethods={methods} | |
| 248 | − threadTitle={props.thread.title} | |
| 249 | − translation={translation} | |
| 250 | − /> | |
| 251 | − )} | |
| 252 | − rightThreshold={THREAD_SWIPE_ACTIONS_WIDTH * 0.42} | |
| 307 | + simultaneousWithExternalGesture={props.simultaneousSwipeGesture} | |
| 308 | + threadTitle={props.thread.title} | |
| 253 | 309 | > |
| 254 | − <View | |
| 255 | − className="flex-row items-center gap-3 bg-card px-4 py-3" | |
| 256 | − style={{ | |
| 257 | − borderBottomColor: separatorColor, | |
| 258 | − borderBottomWidth: props.isLast ? 0 : 1, | |
| 259 | − }} | |
| 260 | − > | |
| 261 | − <View className="h-[34px] w-[34px] items-center justify-center rounded-[11px] bg-subtle"> | |
| 262 | − <SymbolView name="archivebox.fill" size={15} tintColor={iconColor} type="monochrome" /> | |
| 263 | − </View> | |
| 264 | − | |
| 265 | − <View className="min-w-0 flex-1 gap-1"> | |
| 266 | − <View className="flex-row items-center gap-2"> | |
| 267 | − <Text | |
| 268 | − className="min-w-0 flex-1 text-base font-t3-bold leading-[20px] text-foreground" | |
| 269 | − numberOfLines={1} | |
| 270 | − > | |
| 271 | − {props.thread.title} | |
| 272 | − </Text> | |
| 273 | − <Text | |
| 274 | − className="text-xs text-foreground-tertiary" | |
| 275 | − style={{ fontVariant: ["tabular-nums"], minWidth: 30, textAlign: "right" }} | |
| 276 | − > | |
| 277 | − {timestamp} | |
| 278 | − </Text> | |
| 310 | + {() => ( | |
| 311 | + <View | |
| 312 | + className="flex-row items-center gap-3 bg-card px-4 py-3" | |
| 313 | + style={{ | |
| 314 | + borderBottomColor: separatorColor, | |
| 315 | + borderBottomWidth: props.isLast ? 0 : 1, | |
| 316 | + borderTopLeftRadius: props.isFirst ? 20 : 0, | |
| 317 | + borderTopRightRadius: props.isFirst ? 20 : 0, | |
| 318 | + borderBottomLeftRadius: props.isLast ? 20 : 0, | |
| 319 | + borderBottomRightRadius: props.isLast ? 20 : 0, | |
| 320 | + }} | |
| 321 | + > | |
| 322 | + <View className="h-[34px] w-[34px] items-center justify-center rounded-[11px] bg-subtle"> | |
| 323 | + <SymbolView name="archivebox.fill" size={15} tintColor={iconColor} type="monochrome" /> | |
| 279 | 324 | </View> |
| 280 | − {subtitle.length > 0 ? ( | |
| 281 | − <View className="flex-row items-center gap-1.5"> | |
| 282 | − <SymbolView | |
| 283 | − name="arrow.triangle.branch" | |
| 284 | − size={10} | |
| 285 | − tintColor={iconColor} | |
| 286 | − type="monochrome" | |
| 287 | − /> | |
| 325 | + | |
| 326 | + <View className="min-w-0 flex-1 gap-1"> | |
| 327 | + <View className="flex-row items-center gap-2"> | |
| 288 | 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 | 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"], minWidth: 30, textAlign: "right" }} | |
| 337 | + > | |
| 338 | + {timestamp} | |
| 294 | 339 | </Text> |
| 295 | 340 | </View> |
| 296 | − ) : null} | |
| 297 | − </View> | |
| 341 | + {subtitle.length > 0 ? ( | |
| 342 | + <View className="flex-row items-center gap-1.5"> | |
| 343 | + <SymbolView | |
| 344 | + name="arrow.triangle.branch" | |
| 345 | + size={10} | |
| 346 | + tintColor={iconColor} | |
| 347 | + type="monochrome" | |
| 348 | + /> | |
| 349 | + <Text | |
| 350 | + className="min-w-0 flex-1 text-2xs text-foreground-tertiary" | |
| 351 | + numberOfLines={1} | |
| 352 | + style={{ fontFamily: "monospace" }} | |
| 353 | + > | |
| 354 | + {subtitle.join(" · ")} | |
| 355 | + </Text> | |
| 356 | + </View> | |
| 357 | + ) : null} | |
| 358 | + </View> | |
| 298 | 359 | |
| 299 | − <ControlPillMenu actions={THREAD_ACTIONS} onPressAction={handleMenuAction}> | |
| 300 | − <Pressable | |
| 301 | − accessibilityLabel={`Actions for ${props.thread.title}`} | |
| 302 | − accessibilityRole="button" | |
| 303 | − className="h-8 w-8 items-center justify-center rounded-full active:bg-subtle" | |
| 304 | − hitSlop={6} | |
| 305 | − > | |
| 306 | − <SymbolView name="ellipsis" size={16} tintColor={iconColor} type="monochrome" /> | |
| 307 | − </Pressable> | |
| 308 | − </ControlPillMenu> | |
| 309 | − </View> | |
| 310 | − </ReanimatedSwipeable> | |
| 360 | + <ControlPillMenu actions={THREAD_ACTIONS} onPressAction={handleMenuAction}> | |
| 361 | + <Pressable | |
| 362 | + accessibilityLabel={`Actions for ${props.thread.title}`} | |
| 363 | + accessibilityRole="button" | |
| 364 | + className="h-8 w-8 items-center justify-center rounded-full active:bg-subtle" | |
| 365 | + hitSlop={6} | |
| 366 | + > | |
| 367 | + <SymbolView name="ellipsis" size={16} tintColor={iconColor} type="monochrome" /> | |
| 368 | + </Pressable> | |
| 369 | + </ControlPillMenu> | |
| 370 | + </View> | |
| 371 | + )} | |
| 372 | + </ThreadSwipeable> | |
| 311 | 373 | ); |
| 312 | 374 | } |
| 313 | 375 | |
| @@ -317,7 +379,7 @@ function ArchiveError(props: { readonly message: string; readonly onRetry: () => · 3 unmodified lines | ||
| 317 | 379 | <Text className="text-base font-t3-bold text-danger-foreground"> |
| 318 | 380 | Could not load every archive |
| 319 | 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 | 383 | <Pressable className="mt-3 self-start active:opacity-60" onPress={props.onRetry}> |
| 322 | 384 | <Text className="text-sm font-t3-bold text-danger-foreground">Try again</Text> |
| 323 | 385 | </Pressable> |
| @@ -340,8 +402,41 @@ export function ArchivedThreadsScreen(props: { · 16 unmodified lines | ||
| 340 | 402 | readonly onSortOrderChange: (sortOrder: ArchivedThreadSortOrder) => void; |
| 341 | 403 | readonly onUnarchiveThread: (thread: EnvironmentThreadShell) => void; |
| 342 | 404 | }) { |
| 405 | + const { onDeleteThread, onUnarchiveThread } = props; | |
| 343 | 406 | const openSwipeableRef = useRef<SwipeableMethods | null>(null); |
| 407 | + const archiveScrollGesture = useMemo(() => Gesture.Native(), []); | |
| 344 | 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 | 440 | const handleSwipeableWillOpen = useCallback((methods: SwipeableMethods) => { |
| 346 | 441 | if (openSwipeableRef.current && openSwipeableRef.current !== methods) { |
| 347 | 442 | openSwipeableRef.current.close(); |
| @@ -355,82 +450,103 @@ export function ArchivedThreadsScreen(props: { · 7 unmodified lines | ||
| 355 | 450 | }, []); |
| 356 | 451 | const isInitialLoad = props.isLoading && props.groups.length === 0 && props.error === null; |
| 357 | 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 | 507 | return ( |
| 360 | 508 | <View className="flex-1 bg-sheet"> |
| 361 | 509 | <ArchivedThreadsHeader |
| 362 | 510 | environments={props.environments} |
| 363 | 511 | onEnvironmentChange={props.onEnvironmentChange} |
| 512 | + onRefresh={props.onRefresh} | |
| 364 | 513 | onSearchQueryChange={props.onSearchQueryChange} |
| 365 | 514 | onSortOrderChange={props.onSortOrderChange} |
| 366 | 515 | selectedEnvironmentId={props.selectedEnvironmentId} |
| 367 | 516 | sortOrder={props.sortOrder} |
| 368 | 517 | /> |
| 369 | 518 | |
| 370 | − <ScrollView | |
| 371 | − className="flex-1" | |
| 372 | − contentContainerStyle={{ gap: 20, paddingBottom: 32, paddingHorizontal: 16, paddingTop: 8 }} | |
| 373 | − contentInsetAdjustmentBehavior="automatic" | |
| 374 | − keyboardDismissMode="on-drag" | |
| 375 | − keyboardShouldPersistTaps="handled" | |
| 376 | − onScrollBeginDrag={() => openSwipeableRef.current?.close()} | |
| 377 | − refreshControl={ | |
| 378 | − <RefreshControl | |
| 379 | − onRefresh={props.onRefresh} | |
| 380 | − refreshing={props.isLoading && !isInitialLoad} | |
| 381 | − tintColor={String(refreshTint)} | |
| 382 | − /> | |
| 383 | − } | |
| 384 | − showsVerticalScrollIndicator={false} | |
| 385 | − > | |
| 386 | − {props.error ? <ArchiveError message={props.error} onRetry={props.onRefresh} /> : null} | |
| 387 | − | |
| 388 | − {isInitialLoad ? ( | |
| 389 | − <View className="items-center py-16"> | |
| 390 | − <ActivityIndicator color={refreshTint} /> | |
| 391 | − <Text className="mt-3 text-sm text-foreground-muted">Loading archive…</Text> | |
| 392 | − </View> | |
| 393 | − ) : props.groups.length === 0 ? ( | |
| 394 | − <EmptyState | |
| 395 | − detail={ | |
| 396 | − isFiltered | |
| 397 | − ? "Try another search or environment." | |
| 398 | − : "Threads you archive will appear here." | |
| 399 | − } | |
| 400 | − title={isFiltered ? "No matching threads" : "No archived threads"} | |
| 401 | − /> | |
| 402 | − ) : ( | |
| 403 | − props.groups.map((group) => { | |
| 404 | − const environmentLabel = | |
| 405 | − props.environments.find( | |
| 406 | − (environment) => environment.environmentId === group.project.environmentId, | |
| 407 | − )?.label ?? null; | |
| 408 | − | |
| 409 | − return ( | |
| 410 | − <View key={group.key} collapsable={false}> | |
| 411 | − <ProjectGroupLabel environmentLabel={environmentLabel} project={group.project} /> | |
| 412 | − <View | |
| 413 | − className="overflow-hidden rounded-[20px] bg-card" | |
| 414 | − style={{ borderCurve: "continuous" }} | |
| 415 | − > | |
| 416 | − {group.threads.map((thread, index) => ( | |
| 417 | − <ArchivedThreadRow | |
| 418 | − key={`${thread.environmentId}:${thread.id}`} | |
| 419 | − environmentLabel={environmentLabel} | |
| 420 | − isLast={index === group.threads.length - 1} | |
| 421 | − onDelete={() => props.onDeleteThread(thread)} | |
| 422 | − onSwipeableClose={handleSwipeableClose} | |
| 423 | − onSwipeableWillOpen={handleSwipeableWillOpen} | |
| 424 | − onUnarchive={() => props.onUnarchiveThread(thread)} | |
| 425 | − thread={thread} | |
| 426 | − /> | |
| 427 | − ))} | |
| 428 | − </View> | |
| 429 | − </View> | |
| 430 | − ); | |
| 431 | − }) | |
| 432 | − )} | |
| 433 | − </ScrollView> | |
| 519 | + <GestureDetector gesture={archiveScrollGesture}> | |
| 520 | + <LegendList | |
| 521 | + className="flex-1" | |
| 522 | + contentContainerStyle={{ | |
| 523 | + paddingBottom: 32, | |
| 524 | + paddingHorizontal: 16, | |
| 525 | + paddingTop: 4, | |
| 526 | + }} | |
| 527 | + contentInsetAdjustmentBehavior="automatic" | |
| 528 | + data={listItems} | |
| 529 | + estimatedItemSize={62} | |
| 530 | + getItemType={(item) => item.kind} | |
| 531 | + keyboardDismissMode="on-drag" | |
| 532 | + keyboardShouldPersistTaps="handled" | |
| 533 | + keyExtractor={(item) => item.key} | |
| 534 | + ListEmptyComponent={listEmptyComponent} | |
| 535 | + ListHeaderComponent={ | |
| 536 | + props.error ? <ArchiveError message={props.error} onRetry={props.onRefresh} /> : null | |
| 537 | + } | |
| 538 | + onScrollBeginDrag={() => openSwipeableRef.current?.close()} | |
| 539 | + refreshControl={ | |
| 540 | + <RefreshControl | |
| 541 | + onRefresh={props.onRefresh} | |
| 542 | + refreshing={props.isLoading && !isInitialLoad} | |
| 543 | + tintColor={String(refreshTint)} | |
| 544 | + /> | |
| 545 | + } | |
| 546 | + renderItem={renderListItem} | |
| 547 | + showsVerticalScrollIndicator={false} | |
| 548 | + /> | |
| 549 | + </GestureDetector> | |
| 434 | 550 | </View> |
| 435 | 551 | ); |
| 436 | 552 | } |
| @@ -2,7 +2,6 @@ import { useWaitlist } from "@clerk/expo"; · 1 unmodified line | ||
| 2 | 2 | import { ActivityIndicator, Pressable, StyleSheet, Text, TextInput, View } from "react-native"; |
| 3 | 3 | import { useState } from "react"; |
| 4 | 4 | |
| 5 | −import { MOBILE_TYPOGRAPHY } from "../../lib/typography"; | |
| 6 | 5 | import { useThemeColor } from "../../lib/useThemeColor"; |
| 7 | 6 | import { CloudWaitlistJoinRejectedError, joinCloudWaitlist } from "./cloudWaitlistJoin"; |
| 8 | 7 | |
| @@ -36,8 +35,10 @@ export function CloudWaitlistEnrollment(props: { readonly onSignIn: () => void } · 27 unmodified lines | ||
| 36 | 35 | if (waitlist.id) { |
| 37 | 36 | return ( |
| 38 | 37 | <View style={styles.content}> |
| 39 | − <Text style={[styles.title, { color: colors.foreground }]}>You are on the waitlist</Text> | |
| 40 | − <Text style={[styles.body, styles.confirmationBody, { color: colors.secondaryForeground }]}> | |
| 38 | + <Text className="text-center font-t3-bold text-xl text-foreground"> | |
| 39 | + You are on the waitlist | |
| 40 | + </Text> | |
| 41 | + <Text className="text-center font-sans text-base text-foreground-secondary"> | |
| 41 | 42 | We will email you when your T3 Cloud access is ready. |
| 42 | 43 | </Text> |
| 43 | 44 | <SignInAction onPress={props.onSignIn} /> |
| @@ -47,17 +48,18 @@ export function CloudWaitlistEnrollment(props: { readonly onSignIn: () => void } · 3 unmodified lines | ||
| 47 | 48 | |
| 48 | 49 | return ( |
| 49 | 50 | <View style={styles.content}> |
| 50 | − <Text style={[styles.body, { color: colors.secondaryForeground }]}> | |
| 51 | + <Text className="font-sans text-base text-foreground-secondary"> | |
| 51 | 52 | Enter your email and we will let you know when access is ready. |
| 52 | 53 | </Text> |
| 53 | 54 | |
| 54 | 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 | 57 | <TextInput |
| 57 | 58 | accessibilityLabel="Email address" |
| 58 | 59 | autoCapitalize="none" |
| 59 | 60 | autoComplete="email" |
| 60 | 61 | autoCorrect={false} |
| 62 | + className="font-sans text-lg text-foreground" | |
| 61 | 63 | keyboardType="email-address" |
| 62 | 64 | onChangeText={(value) => { |
| 63 | 65 | setEmailAddress(value); |
| @@ -73,7 +75,6 @@ export function CloudWaitlistEnrollment(props: { readonly onSignIn: () => void } · 9 unmodified lines | ||
| 73 | 75 | backgroundColor: colors.input, |
| 74 | 76 | borderColor: |
| 75 | 77 | fieldError || requestError ? colors.dangerForeground : colors.inputBorder, |
| 76 | − color: colors.foreground, | |
| 77 | 78 | }, |
| 78 | 79 | ]} |
| 79 | 80 | textContentType="emailAddress" |
| @@ -82,7 +83,7 @@ export function CloudWaitlistEnrollment(props: { readonly onSignIn: () => void } · 2 unmodified lines | ||
| 82 | 83 | {fieldError || requestError ? ( |
| 83 | 84 | <Text |
| 84 | 85 | accessibilityLiveRegion="polite" |
| 85 | − style={[styles.error, { color: colors.dangerForeground }]} | |
| 86 | + className="font-sans text-sm text-danger-foreground" | |
| 86 | 87 | selectable |
| 87 | 88 | > |
| 88 | 89 | {fieldError ?? requestError} |
| @@ -107,7 +108,7 @@ export function CloudWaitlistEnrollment(props: { readonly onSignIn: () => void } · 18 unmodified lines | ||
| 107 | 108 | ]} |
| 108 | 109 | > |
| 109 | 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 | 112 | {isSubmitting ? "Joining" : "Join the waitlist"} |
| 112 | 113 | </Text> |
| 113 | 114 | </Pressable> |
| @@ -118,12 +119,11 @@ export function CloudWaitlistEnrollment(props: { readonly onSignIn: () => void } · 4 unmodified lines | ||
| 118 | 119 | } |
| 119 | 120 | |
| 120 | 121 | function SignInAction(props: { readonly onPress: () => void }) { |
| 121 | − const colors = useCloudWaitlistColors(); | |
| 122 | 122 | return ( |
| 123 | 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 | 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 | 127 | </Pressable> |
| 128 | 128 | </View> |
| 129 | 129 | ); |
| @@ -132,35 +132,18 @@ function SignInAction(props: { readonly onPress: () => void }) { · 2 unmodified lines | ||
| 132 | 132 | function useCloudWaitlistColors() { |
| 133 | 133 | return { |
| 134 | 134 | dangerForeground: String(useThemeColor("--color-danger-foreground")), |
| 135 | − foreground: String(useThemeColor("--color-foreground")), | |
| 136 | 135 | input: String(useThemeColor("--color-input")), |
| 137 | 136 | inputBorder: String(useThemeColor("--color-input-border")), |
| 138 | 137 | placeholder: String(useThemeColor("--color-placeholder")), |
| 139 | 138 | primary: String(useThemeColor("--color-primary")), |
| 140 | 139 | primaryForeground: String(useThemeColor("--color-primary-foreground")), |
| 141 | − secondaryForeground: String(useThemeColor("--color-foreground-secondary")), | |
| 142 | 140 | }; |
| 143 | 141 | } |
| 144 | 142 | |
| 145 | 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 | 144 | content: { |
| 155 | 145 | gap: 18, |
| 156 | 146 | }, |
| 157 | − confirmationBody: { | |
| 158 | − textAlign: "center", | |
| 159 | − }, | |
| 160 | − error: { | |
| 161 | − fontFamily: "DMSans_400Regular", | |
| 162 | − ...MOBILE_TYPOGRAPHY.footnote, | |
| 163 | − }, | |
| 164 | 147 | field: { |
| 165 | 148 | gap: 8, |
| 166 | 149 | }, |
| @@ -168,16 +151,10 @@ const styles = StyleSheet.create({ · 1 unmodified line | ||
| 168 | 151 | borderCurve: "continuous", |
| 169 | 152 | borderRadius: 16, |
| 170 | 153 | borderWidth: 1, |
| 171 | − fontFamily: "DMSans_400Regular", | |
| 172 | − fontSize: MOBILE_TYPOGRAPHY.headline.fontSize, | |
| 173 | 154 | minHeight: 54, |
| 174 | 155 | paddingHorizontal: 16, |
| 175 | 156 | paddingVertical: 14, |
| 176 | 157 | }, |
| 177 | − label: { | |
| 178 | − fontFamily: "DMSans_700Bold", | |
| 179 | − ...MOBILE_TYPOGRAPHY.footnote, | |
| 180 | − }, | |
| 181 | 158 | primaryButton: { |
| 182 | 159 | alignItems: "center", |
| 183 | 160 | borderRadius: 999, |
| @@ -195,13 +172,4 @@ const styles = StyleSheet.create({ · 11 unmodified lines | ||
| 195 | 172 | justifyContent: "center", |
| 196 | 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 | }); |
| @@ -8,12 +8,7 @@ import * as Schema from "effect/Schema"; · 7 unmodified lines | ||
| 8 | 8 | import * as ExpoCrypto from "expo-crypto"; |
| 9 | 9 | import * as SecureStore from "expo-secure-store"; |
| 10 | 10 | import { p256 } from "@noble/curves/nist"; |
| 11 | −import { | |
| 12 | − computeDpopAccessTokenHash, | |
| 13 | − computeDpopJwkThumbprint, | |
| 14 | − DpopPublicJwk, | |
| 15 | − normalizeDpopHtu, | |
| 16 | −} from "@t3tools/shared/dpop"; | |
| 11 | +import { DpopPublicJwk, normalizeDpopHtu } from "@t3tools/shared/dpopCommon"; | |
| 17 | 12 | import * as Layer from "effect/Layer"; |
| 18 | 13 | |
| 19 | 14 | export class CloudDpopError extends Data.TaggedError("CloudDpopError")<{ |
| @@ -107,6 +102,40 @@ function sha256Digest( · 87 unmodified lines | ||
| 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 | 139 | function secureRandomBytes( |
| 111 | 140 | byteCount: number, |
| 112 | 141 | message: string, |
| @@ -153,7 +182,7 @@ export function generateDpopProofKeyPair(): Effect.Effect< · 40 unmodified lines | ||
| 153 | 182 | try: () => publicJwkFromUncompressedPublicKey(p256.getPublicKey(privateKey, false)), |
| 154 | 183 | catch: cloudDpopError("Generated DPoP public key is invalid."), |
| 155 | 184 | }); |
| 156 | − const thumbprint = computeDpopJwkThumbprint(publicJwk); | |
| 185 | + const thumbprint = yield* computeDpopJwkThumbprintEffect(publicJwk); | |
| 157 | 186 | return { |
| 158 | 187 | privateJwk: privateJwkFromPrivateKey(privateKey, publicJwk), |
| 159 | 188 | publicJwk, |
| @@ -189,9 +218,10 @@ export function loadOrCreateDpopProofKeyPair(): Effect.Effect< · 29 unmodified lines | ||
| 189 | 218 | }, |
| 190 | 219 | catch: cloudDpopError("Stored DPoP proof key is invalid."), |
| 191 | 220 | }); |
| 221 | + const thumbprint = yield* computeDpopJwkThumbprintEffect(restored.publicJwk); | |
| 192 | 222 | return { |
| 193 | 223 | ...restored, |
| 194 | − thumbprint: computeDpopJwkThumbprint(restored.publicJwk), | |
| 224 | + thumbprint, | |
| 195 | 225 | }; |
| 196 | 226 | } |
| 197 | 227 | const generated = yield* generateDpopProofKeyPair(); |
| @@ -243,7 +273,9 @@ export function createDpopProof(input: { · 45 unmodified lines | ||
| 243 | 273 | Effect.map(Encoding.encodeBase64Url), |
| 244 | 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 | 279 | const payload = yield* encodeDpopJwtPayloadJson({ |
| 248 | 280 | htm: input.method.toUpperCase(), |
| 249 | 281 | htu, |
| @@ -76,16 +76,16 @@ export function ConnectionEnvironmentRow(props: { · 75 unmodified lines | ||
| 76 | 76 | /> |
| 77 | 77 | |
| 78 | 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 | 80 | {props.environment.environmentLabel} |
| 81 | 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 | 83 | {props.environment.displayUrl} |
| 84 | 84 | </Text> |
| 85 | 85 | {statusLabel ? ( |
| 86 | 86 | <Text |
| 87 | 87 | className={cn( |
| 88 | − "text-xs leading-[16px]", | |
| 88 | + "text-xs", | |
| 89 | 89 | hasConnectionFailure ? "text-rose-500 dark:text-rose-400" : "text-foreground-muted", |
| 90 | 90 | )} |
| 91 | 91 | numberOfLines={props.expanded ? undefined : 1} |
| @@ -134,7 +134,7 @@ export function ConnectionEnvironmentRow(props: { · 42 unmodified lines | ||
| 134 | 134 | className="gap-3 px-4 pb-4" |
| 135 | 135 | > |
| 136 | 136 | {props.environment.isRelayManaged ? ( |
| 137 | − <Text className="text-sm leading-[18px] text-foreground-muted"> | |
| 137 | + <Text className="text-sm text-foreground-muted"> | |
| 138 | 138 | Managed by T3 Cloud. Tunnel details update automatically. |
| 139 | 139 | </Text> |
| 140 | 140 | ) : ( |
| @@ -1,5 +1,6 @@ | ||
| 1 | 1 | import { CameraView, useCameraPermissions } from "expo-camera"; |
| 2 | −import { Stack, useLocalSearchParams, useRouter } from "expo-router"; | |
| 2 | +import { NativeHeaderToolbar, NativeStackScreenOptions } from "../../native/StackHeader"; | |
| 3 | +import { StackActions, useNavigation, type StaticScreenProps } from "@react-navigation/native"; | |
| 3 | 4 | import { AsyncResult } from "effect/unstable/reactivity"; |
| 4 | 5 | import { useCallback, useEffect, useState } from "react"; |
| 5 | 6 | import { Alert, ScrollView, View } from "react-native"; |
| @@ -8,21 +9,26 @@ import { useThemeColor } from "../../lib/useThemeColor"; · 2 unmodified lines | ||
| 8 | 9 | |
| 9 | 10 | import { AppText as Text, AppTextInput as TextInput } from "../../components/AppText"; |
| 10 | 11 | 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"; | |
| 12 | +import { ConnectionSheetButton } from "./ConnectionSheetButton"; | |
| 13 | +import { extractPairingUrlFromQrPayload } from "./pairing"; | |
| 14 | 14 | import { useRemoteConnections } from "../../state/use-remote-environment-registry"; |
| 15 | −import { buildPairingUrl, parsePairingUrl } from "../../features/connection/pairing"; | |
| 15 | +import { buildPairingUrl, parsePairingUrl } from "./pairing"; | |
| 16 | 16 | |
| 17 | −export default function ConnectionsNewRouteScreen() { | |
| 17 | +type ConnectionsNewRouteParams = { | |
| 18 | + readonly mode?: string; | |
| 19 | +}; | |
| 20 | + | |
| 21 | +export function ConnectionsNewRouteScreen({ | |
| 22 | + route, | |
| 23 | +}: StaticScreenProps<ConnectionsNewRouteParams | undefined>) { | |
| 18 | 24 | const { |
| 19 | 25 | connectionPairingUrl, |
| 20 | 26 | onChangeConnectionPairingUrl, |
| 21 | 27 | onConnectPress, |
| 22 | 28 | pairingConnectionError, |
| 23 | 29 | } = useRemoteConnections(); |
| 24 | − const router = useRouter(); | |
| 25 | − const params = useLocalSearchParams<{ mode?: string }>(); | |
| 30 | + const navigation = useNavigation(); | |
| 31 | + const params = route.params ?? {}; | |
| 26 | 32 | const insets = useSafeAreaInsets(); |
| 27 | 33 | const [hostInput, setHostInput] = useState(""); |
| 28 | 34 | const [codeInput, setCodeInput] = useState(""); |
| @@ -31,6 +37,7 @@ export default function ConnectionsNewRouteScreen() { · 2 unmodified lines | ||
| 31 | 37 | const [cameraPermission, requestCameraPermission] = useCameraPermissions(); |
| 32 | 38 | const [scannerLocked, setScannerLocked] = useState(false); |
| 33 | 39 | |
| 40 | + const headerIconColor = useThemeColor("--color-icon"); | |
| 34 | 41 | const placeholderColor = useThemeColor("--color-placeholder"); |
| 35 | 42 | |
| 36 | 43 | const connectDisabled = isSubmitting || hostInput.trim().length === 0; |
| @@ -116,21 +123,25 @@ export default function ConnectionsNewRouteScreen() { · 79 unmodified lines | ||
| 116 | 123 | onChangeConnectionPairingUrl(pairingUrl); |
| 117 | 124 | const result = await onConnectPress(pairingUrl); |
| 118 | 125 | if (AsyncResult.isSuccess(result)) { |
| 119 | − dismissRoute(router); | |
| 126 | + if (navigation.canGoBack()) { | |
| 127 | + navigation.goBack(); | |
| 128 | + } else { | |
| 129 | + navigation.dispatch(StackActions.replace("Home")); | |
| 130 | + } | |
| 120 | 131 | } else { |
| 121 | 132 | setIsSubmitting(false); |
| 122 | 133 | } |
| 123 | − }, [codeInput, hostInput, onChangeConnectionPairingUrl, onConnectPress, router]); | |
| 134 | + }, [codeInput, hostInput, onChangeConnectionPairingUrl, onConnectPress, navigation]); | |
| 124 | 135 | |
| 125 | 136 | return ( |
| 126 | 137 | <View collapsable={false} className="flex-1 bg-sheet"> |
| 127 | − <Stack.Screen | |
| 138 | + <NativeStackScreenOptions | |
| 128 | 139 | options={{ |
| 129 | 140 | title: showScanner ? "Scan QR Code" : "Add Environment", |
| 130 | 141 | }} |
| 131 | 142 | /> |
| 132 | − <Stack.Toolbar placement="right"> | |
| 133 | − <Stack.Toolbar.Button | |
| 143 | + <NativeHeaderToolbar placement="right"> | |
| 144 | + <NativeHeaderToolbar.Button | |
| 134 | 145 | icon={showScanner ? "xmark" : "qrcode.viewfinder"} |
| 135 | 146 | onPress={() => { |
| 136 | 147 | if (showScanner) { |
| @@ -140,8 +151,9 @@ export default function ConnectionsNewRouteScreen() { · 3 unmodified lines | ||
| 140 | 151 | } |
| 141 | 152 | }} |
| 142 | 153 | separateBackground |
| 154 | + tintColor={headerIconColor} | |
| 143 | 155 | /> |
| 144 | − </Stack.Toolbar> | |
| 156 | + </NativeHeaderToolbar> | |
| 145 | 157 | |
| 146 | 158 | <ScrollView |
| 147 | 159 | contentInsetAdjustmentBehavior="automatic" |
| @@ -171,7 +183,7 @@ export default function ConnectionsNewRouteScreen() { · 23 unmodified lines | ||
| 171 | 183 | className="items-center gap-3 rounded-[24px] bg-card px-5 py-8" |
| 172 | 184 | style={{ borderCurve: "continuous" }} |
| 173 | 185 | > |
| 174 | − <Text className="text-center text-sm leading-[20px] text-foreground-muted"> | |
| 186 | + <Text className="text-center text-sm leading-normal text-foreground-muted"> | |
| 175 | 187 | Camera permission is required to scan a QR code. |
| 176 | 188 | </Text> |
| 177 | 189 | <ConnectionSheetButton |
| @@ -1,4 +1,5 @@ | ||
| 1 | −import { Stack, useRouter } from "expo-router"; | |
| 1 | +import { NativeHeaderToolbar } from "../../native/StackHeader"; | |
| 2 | +import { useNavigation } from "@react-navigation/native"; | |
| 2 | 3 | import { SymbolView } from "expo-symbols"; |
| 3 | 4 | import type { EnvironmentId } from "@t3tools/contracts"; |
| 4 | 5 | import { useCallback, useState } from "react"; |
| @@ -9,16 +10,16 @@ import { useThemeColor } from "../../lib/useThemeColor"; · 4 unmodified lines | ||
| 9 | 10 | import { AppText as Text } from "../../components/AppText"; |
| 10 | 11 | import { cn } from "../../lib/cn"; |
| 11 | 12 | import { useRemoteConnections } from "../../state/use-remote-environment-registry"; |
| 12 | −import { ConnectionEnvironmentRow } from "../../features/connection/ConnectionEnvironmentRow"; | |
| 13 | +import { ConnectionEnvironmentRow } from "./ConnectionEnvironmentRow"; | |
| 13 | 14 | |
| 14 | −export default function ConnectionsRouteScreen() { | |
| 15 | +export function ConnectionsRouteScreen() { | |
| 15 | 16 | const { |
| 16 | 17 | connectedEnvironments, |
| 17 | 18 | onReconnectEnvironment, |
| 18 | 19 | onRemoveEnvironmentPress, |
| 19 | 20 | onUpdateEnvironment, |
| 20 | 21 | } = useRemoteConnections(); |
| 21 | − const router = useRouter(); | |
| 22 | + const navigation = useNavigation(); | |
| 22 | 23 | const insets = useSafeAreaInsets(); |
| 23 | 24 | const hasEnvironments = connectedEnvironments.length > 0; |
| 24 | 25 | const [expandedId, setExpandedId] = useState<EnvironmentId | null>(null); |
| @@ -31,18 +32,13 @@ export default function ConnectionsRouteScreen() { · 6 unmodified lines | ||
| 31 | 32 | |
| 32 | 33 | return ( |
| 33 | 34 | <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 | |
| 35 | + <NativeHeaderToolbar placement="right"> | |
| 36 | + <NativeHeaderToolbar.Button | |
| 41 | 37 | icon="plus" |
| 42 | − onPress={() => router.push("/connections/new")} | |
| 38 | + onPress={() => navigation.navigate("ConnectionsNew")} | |
| 43 | 39 | separateBackground |
| 44 | 40 | /> |
| 45 | − </Stack.Toolbar> | |
| 41 | + </NativeHeaderToolbar> | |
| 46 | 42 | <ScrollView |
| 47 | 43 | contentInsetAdjustmentBehavior="automatic" |
| 48 | 44 | showsVerticalScrollIndicator={false} |
| @@ -85,7 +81,7 @@ export default function ConnectionsRouteScreen() { · 36 unmodified lines | ||
| 85 | 81 | type="monochrome" |
| 86 | 82 | /> |
| 87 | 83 | </View> |
| 88 | − <Text className="text-center text-sm leading-[20px] text-foreground-muted"> | |
| 84 | + <Text className="text-center text-sm leading-normal text-foreground-muted"> | |
| 89 | 85 | No environments connected yet.{"\n"}Tap{" "} |
| 90 | 86 | <Text className="font-t3-bold text-foreground">+</Text> to add one. |
| 91 | 87 | </Text> |
| @@ -73,10 +73,10 @@ export function EnvironmentConnectionNotice(props: { · 72 unmodified lines | ||
| 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 | 77 | {noticeTitle(props.connection.phase, props.environmentLabel)} |
| 78 | 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 | 80 | {noticeDetail(props.connection.phase, props.resourceName, props.connection.error)} |
| 81 | 81 | {props.connection.traceId ? ( |
| 82 | 82 | <> |
| @@ -33,11 +33,14 @@ describe("resolveNativeReviewDiffView", () => { · 32 unmodified lines | ||
| 33 | 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 | 37 | setExpoViewConfigAvailable(); |
| 38 | 38 | expoMocks.requireNativeView.mockReturnValue(nativeView); |
| 39 | 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 | 44 | expect(expoMocks.requireNativeView).toHaveBeenCalledWith("T3ReviewDiffSurface"); |
| 42 | 45 | }); |
| 43 | 46 | |
| @@ -78,3 +81,20 @@ describe("resolveNativeReviewDiffView", () => { · 34 unmodified lines | ||
| 78 | 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 | +}); | |
| @@ -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 | 9 | import type { NativeSyntheticEvent, ViewProps } from "react-native"; |
| 3 | 10 | import { requireNativeView } from "expo"; |
| 4 | 11 | |
| 5 | 12 | import { NativeViewResolutionError } from "../../native/nativeViewResolutionError"; |
| 6 | 13 | |
| 7 | 14 | const NATIVE_REVIEW_DIFF_MODULE_NAME = "T3ReviewDiffSurface"; |
| 15 | +const NATIVE_REVIEW_DIFF_PAYLOAD_RETRY_FRAMES = 60; | |
| 8 | 16 | |
| 9 | 17 | interface ExpoGlobalWithViewConfig { |
| 10 | 18 | readonly expo?: { |
| @@ -103,6 +111,7 @@ export interface NativeReviewDiffViewProps extends ViewProps { · 92 unmodified lines | ||
| 103 | 111 | readonly tokensJson?: string; |
| 104 | 112 | readonly tokensPatchJson?: string; |
| 105 | 113 | readonly tokensResetKey?: string; |
| 114 | + readonly contentResetKey?: string; | |
| 106 | 115 | readonly collapsedFileIdsJson?: string; |
| 107 | 116 | readonly viewedFileIdsJson?: string; |
| 108 | 117 | readonly selectedRowIdsJson?: string; |
| @@ -113,7 +122,13 @@ export interface NativeReviewDiffViewProps extends ViewProps { · 4 unmodified lines | ||
| 113 | 122 | readonly rowHeight: number; |
| 114 | 123 | readonly contentWidth: number; |
| 115 | 124 | readonly initialRowIndex?: number; |
| 125 | + readonly refreshing?: boolean; | |
| 126 | + readonly nativeViewRef?: Ref<NativeReviewDiffViewHandle>; | |
| 127 | + readonly onPullToRefresh?: (event: NativeSyntheticEvent<Record<string, never>>) => void; | |
| 116 | 128 | readonly onDebug?: (event: NativeSyntheticEvent<Record<string, unknown>>) => void; |
| 129 | + readonly onVisibleFileChange?: ( | |
| 130 | + event: NativeSyntheticEvent<{ readonly fileId?: string | null }>, | |
| 131 | + ) => void; | |
| 117 | 132 | readonly onToggleFile?: (event: NativeSyntheticEvent<{ readonly fileId?: string }>) => void; |
| 118 | 133 | readonly onToggleViewedFile?: (event: NativeSyntheticEvent<{ readonly fileId?: string }>) => void; |
| 119 | 134 | readonly onPressLine?: ( |
| @@ -129,18 +144,130 @@ export interface NativeReviewDiffViewProps extends ViewProps { · 9 unmodified lines | ||
| 129 | 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 | 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 | 235 | function getExpoViewConfig(moduleName: string) { |
| 136 | 236 | return (globalThis as typeof globalThis & ExpoGlobalWithViewConfig).expo?.getViewConfig?.( |
| 137 | 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 | 268 | export function resolveNativeReviewDiffView(): ComponentType<NativeReviewDiffViewProps> | null { |
| 142 | − if (cachedNativeReviewDiffView) { | |
| 143 | − return cachedNativeReviewDiffView; | |
| 269 | + if (cachedNativeReviewDiffRawView) { | |
| 270 | + return NativeReviewDiffView; | |
| 144 | 271 | } |
| 145 | 272 | |
| 146 | 273 | if (nativeReviewDiffViewResolutionFailed) { |
| @@ -152,7 +279,7 @@ export function resolveNativeReviewDiffView(): ComponentType<NativeReviewDiffVie · 5 unmodified lines | ||
| 152 | 279 | } |
| 153 | 280 | |
| 154 | 281 | try { |
| 155 | − cachedNativeReviewDiffView = requireNativeView<NativeReviewDiffViewProps>( | |
| 282 | + cachedNativeReviewDiffRawView = requireNativeView<NativeReviewDiffRawViewProps>( | |
| 156 | 283 | NATIVE_REVIEW_DIFF_MODULE_NAME, |
| 157 | 284 | ); |
| 158 | 285 | } catch (cause) { |
| @@ -166,5 +293,5 @@ export function resolveNativeReviewDiffView(): ComponentType<NativeReviewDiffVie · 7 unmodified lines | ||
| 166 | 293 | return null; |
| 167 | 294 | } |
| 168 | 295 | |
| 169 | − return cachedNativeReviewDiffView ?? null; | |
| 296 | + return cachedNativeReviewDiffRawView ? NativeReviewDiffView : null; | |
| 170 | 297 | } |
| @@ -1,15 +1,19 @@ | ||
| 1 | −import { useCallback, useMemo } from "react"; | |
| 1 | +import { useCallback, useMemo, useState } from "react"; | |
| 2 | 2 | import { |
| 3 | 3 | Markdown, |
| 4 | 4 | type CustomRenderers, |
| 5 | 5 | type NodeStyleOverrides, |
| 6 | 6 | type PartialMarkdownTheme, |
| 7 | 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 | 10 | import { tryOpenExternalUrl } from "../../lib/openExternalUrl"; |
| 11 | −import { MOBILE_TYPOGRAPHY } from "../../lib/typography"; | |
| 11 | +import { | |
| 12 | + resolveMarkdownFontSizes, | |
| 13 | + resolveNativeMarkdownTypography, | |
| 14 | +} from "../../lib/appearancePreferences"; | |
| 12 | 15 | import { useThemeColor } from "../../lib/useThemeColor"; |
| 16 | +import { useAppearancePreferences } from "../settings/appearance/AppearancePreferencesProvider"; | |
| 13 | 17 | import { |
| 14 | 18 | hasNativeSelectableMarkdownText, |
| 15 | 19 | SelectableMarkdownText, |
| @@ -24,6 +28,15 @@ interface MarkdownPreviewStyles { · 8 unmodified lines | ||
| 24 | 28 | } |
| 25 | 29 | |
| 26 | 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 | 40 | const body = String(useThemeColor("--color-md-body")); |
| 28 | 41 | const strong = String(useThemeColor("--color-md-strong")); |
| 29 | 42 | const link = String(useThemeColor("--color-md-link")); |
| @@ -74,7 +87,8 @@ function useMarkdownPreviewStyles(): MarkdownPreviewStyles { · 44 unmodified lines | ||
| 74 | 87 | text: { |
| 75 | 88 | color: body, |
| 76 | 89 | fontFamily: "DMSans_400Regular", |
| 77 | − ...MOBILE_TYPOGRAPHY.body, | |
| 90 | + fontSize: markdownFontSizes.m, | |
| 91 | + lineHeight: markdownFontSizes.bodyLineHeight, | |
| 78 | 92 | }, |
| 79 | 93 | heading: { |
| 80 | 94 | color: strong, |
| @@ -124,7 +138,9 @@ function useMarkdownPreviewStyles(): MarkdownPreviewStyles { · 43 unmodified lines | ||
| 124 | 138 | skillTextColor: codeText, |
| 125 | 139 | quoteMarkerColor: blockquoteBorder, |
| 126 | 140 | dividerColor: horizontalRule, |
| 127 | − ...MOBILE_TYPOGRAPHY.body, | |
| 141 | + fontSize: nativeMarkdownTypography.fontSize, | |
| 142 | + lineHeight: nativeMarkdownTypography.lineHeight, | |
| 143 | + headingFontSizes: nativeMarkdownTypography.headingFontSizes, | |
| 128 | 144 | fontFamily: "DMSans_400Regular", |
| 129 | 145 | headingFontFamily: "DMSans_700Bold", |
| 130 | 146 | boldFontFamily: "DMSans_700Bold", |
| @@ -138,18 +154,46 @@ function useMarkdownPreviewStyles(): MarkdownPreviewStyles { · 7 unmodified lines | ||
| 138 | 154 | codeText, |
| 139 | 155 | horizontalRule, |
| 140 | 156 | link, |
| 157 | + markdownFontSizes, | |
| 158 | + nativeMarkdownTypography, | |
| 141 | 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 | 179 | const styles = useMarkdownPreviewStyles(); |
| 147 | 180 | const onLinkPress = useCallback((href: string) => { |
| 148 | 181 | void tryOpenExternalUrl(href, "markdown-link"); |
| 149 | 182 | }, []); |
| 150 | 183 | |
| 151 | 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 | 197 | <View className="mx-auto w-full max-w-[760px]"> |
| 154 | 198 | {hasNativeSelectableMarkdownText() ? ( |
| 155 | 199 | <SelectableMarkdownText |
| @@ -1,7 +1,15 @@ | ||
| 1 | 1 | import type { ProjectEntry } from "@t3tools/contracts"; |
| 2 | 2 | import { SymbolView } from "expo-symbols"; |
| 3 | −import { memo, useCallback, useEffect, useMemo, useState } from "react"; | |
| 4 | −import { ActivityIndicator, FlatList, Pressable, RefreshControl, View } from "react-native"; | |
| 3 | +import { memo, useCallback, useEffect, useMemo, useRef, useState } from "react"; | |
| 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 | 14 | import { AppText as Text } from "../../components/AppText"; |
| 7 | 15 | import { PierreEntryIcon } from "../../components/PierreEntryIcon"; |
| @@ -11,9 +19,25 @@ import { · 3 unmodified lines | ||
| 11 | 19 | buildFileTree, |
| 12 | 20 | defaultExpandedTreePaths, |
| 13 | 21 | flattenFileTree, |
| 22 | + type FileTreeNode, | |
| 14 | 23 | type VisibleFileTreeNode, |
| 15 | 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 | 41 | function ancestorPaths(path: string): ReadonlyArray<string> { |
| 18 | 42 | const parts = path.split("/").filter(Boolean); |
| 19 | 43 | const ancestors: string[] = []; |
| @@ -25,19 +49,24 @@ function ancestorPaths(path: string): ReadonlyArray<string> { · 5 unmodified lines | ||
| 25 | 49 | |
| 26 | 50 | const FileTreeRow = memo(function FileTreeRow(props: { |
| 27 | 51 | readonly item: VisibleFileTreeNode; |
| 28 | − readonly selectedPath: string | null; | |
| 52 | + readonly selected: boolean; | |
| 29 | 53 | readonly expanded: boolean; |
| 30 | 54 | readonly iconColor: string; |
| 31 | 55 | readonly onPressDirectory: (path: string) => void; |
| 56 | + readonly onPreviewFile?: (path: string) => void; | |
| 32 | 57 | readonly onPressFile: (path: string) => void; |
| 33 | 58 | }) { |
| 34 | 59 | const { node, depth } = props.item; |
| 35 | − const selected = node.kind === "file" && node.path === props.selectedPath; | |
| 36 | 60 | |
| 37 | 61 | return ( |
| 38 | 62 | <Pressable |
| 39 | 63 | accessibilityRole="button" |
| 40 | 64 | accessibilityLabel={node.path} |
| 65 | + onPressIn={() => { | |
| 66 | + if (node.kind === "file") { | |
| 67 | + props.onPreviewFile?.(node.path); | |
| 68 | + } | |
| 69 | + }} | |
| 41 | 70 | onPress={() => { |
| 42 | 71 | if (node.kind === "directory") { |
| 43 | 72 | props.onPressDirectory(node.path); |
| @@ -47,7 +76,7 @@ const FileTreeRow = memo(function FileTreeRow(props: { · 3 unmodified lines | ||
| 47 | 76 | }} |
| 48 | 77 | className={cn( |
| 49 | 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 | 81 | style={{ paddingLeft: 8 + depth * 18 }} |
| 53 | 82 | > |
| @@ -64,8 +93,10 @@ const FileTreeRow = memo(function FileTreeRow(props: { · 10 unmodified lines | ||
| 64 | 93 | <PierreEntryIcon path={node.path} kind={node.kind} size={17} /> |
| 65 | 94 | <Text |
| 66 | 95 | className={cn( |
| 67 | − "min-w-0 flex-1 text-sm leading-[19px]", | |
| 68 | − selected ? "font-t3-bold text-foreground" : "font-t3-medium text-foreground-secondary", | |
| 96 | + "min-w-0 flex-1 text-sm leading-normal", | |
| 97 | + props.selected | |
| 98 | + ? "font-t3-bold text-foreground" | |
| 99 | + : "font-t3-medium text-foreground-secondary", | |
| 69 | 100 | )} |
| 70 | 101 | numberOfLines={1} |
| 71 | 102 | > |
| @@ -86,13 +117,30 @@ export function FileTreeBrowser(props: { · 14 unmodified lines | ||
| 86 | 117 | readonly isPending: boolean; |
| 87 | 118 | readonly searchQuery: string; |
| 88 | 119 | readonly selectedPath: string | null; |
| 120 | + readonly onPreviewFile?: (path: string) => void; | |
| 89 | 121 | readonly onRefresh: () => void; |
| 90 | 122 | readonly onSelectFile: (path: string) => void; |
| 91 | 123 | }) { |
| 92 | 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 | 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 | 144 | const defaultExpanded = useMemo(() => defaultExpandedTreePaths(tree), [tree]); |
| 97 | 145 | const visibleNodes = useMemo( |
| 98 | 146 | () => |
| @@ -114,17 +162,30 @@ export function FileTreeBrowser(props: { · 15 unmodified lines | ||
| 114 | 162 | }, [defaultExpanded]); |
| 115 | 163 | |
| 116 | 164 | useEffect(() => { |
| 117 | − if (!props.selectedPath) { | |
| 165 | + if (!controlledSelectedPath) { | |
| 118 | 166 | return; |
| 119 | 167 | } |
| 120 | 168 | setExpandedPaths((current) => { |
| 169 | + const ancestors = ancestorPaths(controlledSelectedPath); | |
| 170 | + if (ancestors.every((ancestor) => current.has(ancestor))) { | |
| 171 | + return current; | |
| 172 | + } | |
| 121 | 173 | const next = new Set(current); |
| 122 | − for (const ancestor of ancestorPaths(props.selectedPath ?? "")) { | |
| 174 | + for (const ancestor of ancestors) { | |
| 123 | 175 | next.add(ancestor); |
| 124 | 176 | } |
| 125 | 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 | 190 | const toggleDirectory = useCallback((path: string) => { |
| 130 | 191 | setExpandedPaths((current) => { |
| @@ -137,53 +198,86 @@ export function FileTreeBrowser(props: { · 6 unmodified lines | ||
| 137 | 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 | 247 | return ( |
| 142 | − <View className="flex-1 bg-sheet"> | |
| 143 | − {props.error && props.entries.length === 0 ? ( | |
| 248 | + <FlatList | |
| 249 | + style={{ flex: 1 }} | |
| 250 | + data={visibleNodes} | |
| 251 | + keyExtractor={(item) => item.node.path} | |
| 252 | + contentInsetAdjustmentBehavior={Platform.OS === "ios" ? "automatic" : "never"} | |
| 253 | + scrollIndicatorInsets={ | |
| 254 | + Platform.OS === "ios" ? { top: headerInset, left: 0, right: 0, bottom: 0 } : undefined | |
| 255 | + } | |
| 256 | + keyboardDismissMode="on-drag" | |
| 257 | + keyboardShouldPersistTaps="handled" | |
| 258 | + initialNumToRender={FILE_TREE_INITIAL_RENDER_COUNT} | |
| 259 | + maxToRenderPerBatch={FILE_TREE_RENDER_BATCH_SIZE} | |
| 260 | + updateCellsBatchingPeriod={16} | |
| 261 | + windowSize={5} | |
| 262 | + contentContainerStyle={{ paddingTop: 8, paddingBottom: 8 }} | |
| 263 | + refreshControl={<RefreshControl refreshing={props.isPending} onRefresh={props.onRefresh} />} | |
| 264 | + renderItem={renderItem} | |
| 265 | + ListEmptyComponent={ | |
| 144 | 266 | <View className="px-4 py-5"> |
| 145 | − <Text className="text-sm font-t3-bold text-foreground">Files unavailable</Text> | |
| 146 | − <Text className="mt-1 text-xs leading-[18px] text-foreground-muted">{props.error}</Text> | |
| 147 | − </View> | |
| 148 | − ) : ( | |
| 149 | − <FlatList | |
| 150 | − data={visibleNodes} | |
| 151 | − keyExtractor={(item) => item.node.path} | |
| 152 | − contentInsetAdjustmentBehavior="automatic" | |
| 153 | − keyboardDismissMode="on-drag" | |
| 154 | − keyboardShouldPersistTaps="handled" | |
| 155 | − contentContainerStyle={{ paddingVertical: 8 }} | |
| 156 | − refreshControl={ | |
| 157 | − <RefreshControl refreshing={props.isPending} onRefresh={props.onRefresh} /> | |
| 158 | − } | |
| 159 | − renderItem={({ item }) => ( | |
| 160 | − <FileTreeRow | |
| 161 | − item={item} | |
| 162 | − selectedPath={props.selectedPath} | |
| 163 | − expanded={expandedPaths.has(item.node.path)} | |
| 164 | − iconColor={iconColor} | |
| 165 | − onPressDirectory={toggleDirectory} | |
| 166 | − onPressFile={props.onSelectFile} | |
| 167 | − /> | |
| 267 | + {props.isPending ? ( | |
| 268 | + <ActivityIndicator size="small" /> | |
| 269 | + ) : ( | |
| 270 | + <> | |
| 271 | + <Text className="text-sm font-t3-bold text-foreground">No files found</Text> | |
| 272 | + <Text className="mt-1 text-xs leading-normal text-foreground-muted"> | |
| 273 | + {props.searchQuery.trim().length > 0 | |
| 274 | + ? "Try a different search." | |
| 275 | + : "The workspace file index is empty."} | |
| 276 | + </Text> | |
| 277 | + </> | |
| 168 | 278 | )} |
| 169 | − ListEmptyComponent={ | |
| 170 | − <View className="px-4 py-5"> | |
| 171 | − {props.isPending ? ( | |
| 172 | − <ActivityIndicator size="small" /> | |
| 173 | − ) : ( | |
| 174 | − <> | |
| 175 | − <Text className="text-sm font-t3-bold text-foreground">No files found</Text> | |
| 176 | − <Text className="mt-1 text-xs leading-[18px] text-foreground-muted"> | |
| 177 | − {props.searchQuery.trim().length > 0 | |
| 178 | − ? "Try a different search." | |
| 179 | − : "The workspace file index is empty."} | |
| 180 | − </Text> | |
| 181 | − </> | |
| 182 | − )} | |
| 183 | − </View> | |
| 184 | − } | |
| 185 | − /> | |
| 186 | − )} | |
| 187 | − </View> | |
| 279 | + </View> | |
| 280 | + } | |
| 281 | + /> | |
| 188 | 282 | ); |
| 189 | 283 | } |
| @@ -1,8 +1,15 @@ | ||
| 1 | 1 | import { useAtomValue } from "@effect/atom-react"; |
| 2 | 2 | import { AsyncResult } from "effect/unstable/reactivity"; |
| 3 | 3 | import type { ComponentType } from "react"; |
| 4 | −import { memo, useCallback, useEffect, useMemo, useRef } from "react"; | |
| 5 | −import { FlatList, ScrollView, Text as NativeText, useColorScheme, View } from "react-native"; | |
| 4 | +import { memo, useCallback, useEffect, useMemo, useRef, useState } from "react"; | |
| 5 | +import { | |
| 6 | + FlatList, | |
| 7 | + ScrollView, | |
| 8 | + Text as NativeText, | |
| 9 | + useColorScheme, | |
| 10 | + useWindowDimensions, | |
| 11 | + View, | |
| 12 | +} from "react-native"; | |
| 6 | 13 | |
| 7 | 14 | import { AppText as Text } from "../../components/AppText"; |
| 8 | 15 | import { LoadingStrip } from "../../components/LoadingStrip"; |
| @@ -11,71 +18,62 @@ import { · 2 unmodified lines | ||
| 11 | 18 | resolveNativeReviewDiffView, |
| 12 | 19 | } from "../diffs/nativeReviewDiffSurface"; |
| 13 | 20 | import { createNativeReviewDiffTheme } from "../review/nativeReviewDiffAdapter"; |
| 14 | −import { | |
| 15 | − REVIEW_DIFF_LINE_HEIGHT, | |
| 16 | − REVIEW_MONO_FONT_FAMILY, | |
| 17 | − renderVisibleWhitespace, | |
| 18 | −} from "../review/reviewDiffRendering"; | |
| 21 | +import { REVIEW_MONO_FONT_FAMILY, renderVisibleWhitespace } from "../review/reviewDiffRendering"; | |
| 19 | 22 | import type { ReviewHighlightedToken } from "../review/shikiReviewHighlighter"; |
| 20 | 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 | 26 | import { |
| 23 | − buildNativeSourceRows, | |
| 24 | 27 | buildNativeSourceTokens, |
| 25 | 28 | NATIVE_SOURCE_CONTENT_WIDTH, |
| 26 | − NATIVE_SOURCE_ROW_HEIGHT, | |
| 27 | − NATIVE_SOURCE_STYLE, | |
| 28 | 29 | nativeSourceRowId, |
| 29 | 30 | } from "./nativeSourceFileAdapter"; |
| 31 | +import { prepareSourceFileDocument } from "./source-file-document"; | |
| 30 | 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 | 34 | interface SourceFileSurfaceProps { |
| 37 | 35 | readonly contents: string; |
| 38 | 36 | readonly path: string; |
| 39 | 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 | 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 | 44 | const HighlightedSourceLine = memo(function HighlightedSourceLine(props: { |
| 45 | + readonly codeSurface: ResolvedMobileCodeSurface; | |
| 49 | 46 | readonly index: number; |
| 50 | 47 | readonly line: string; |
| 51 | 48 | readonly tokens: ReadonlyArray<ReviewHighlightedToken> | null; |
| 52 | 49 | readonly highlighted: boolean; |
| 50 | + readonly wordBreak: boolean; | |
| 53 | 51 | }) { |
| 54 | 52 | return ( |
| 55 | 53 | <View |
| 56 | 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 | 57 | <NativeText |
| 60 | 58 | className="select-none pr-3 text-right text-foreground-tertiary" |
| 61 | 59 | style={{ |
| 62 | − width: SOURCE_LINE_NUMBER_WIDTH, | |
| 60 | + width: props.codeSurface.gutterWidth, | |
| 63 | 61 | fontFamily: REVIEW_MONO_FONT_FAMILY, |
| 64 | − fontSize: MOBILE_CODE_SURFACE.lineNumberFontSize, | |
| 65 | − lineHeight: MOBILE_CODE_SURFACE.rowHeight, | |
| 62 | + fontSize: props.codeSurface.lineNumberFontSize, | |
| 63 | + lineHeight: props.codeSurface.rowHeight, | |
| 66 | 64 | }} |
| 67 | 65 | > |
| 68 | 66 | {props.index + 1} |
| 69 | 67 | </NativeText> |
| 70 | 68 | <NativeText |
| 71 | 69 | selectable |
| 72 | − numberOfLines={1} | |
| 73 | − className="font-normal text-foreground" | |
| 70 | + numberOfLines={props.wordBreak ? undefined : 1} | |
| 71 | + className="flex-1 font-normal text-foreground" | |
| 74 | 72 | style={{ |
| 75 | 73 | fontFamily: REVIEW_MONO_FONT_FAMILY, |
| 76 | − fontSize: MOBILE_CODE_SURFACE.fontSize, | |
| 77 | − lineHeight: MOBILE_CODE_SURFACE.rowHeight, | |
| 78 | − minWidth: 320, | |
| 74 | + fontSize: props.codeSurface.fontSize, | |
| 75 | + lineHeight: props.codeSurface.rowHeight, | |
| 76 | + minWidth: props.wordBreak ? undefined : 320, | |
| 79 | 77 | }} |
| 80 | 78 | > |
| 81 | 79 | {props.tokens && props.tokens.length > 0 |
| @@ -119,11 +117,8 @@ const HighlightedSourceLine = memo(function HighlightedSourceLine(props: { · 37 unmodified lines | ||
| 119 | 117 | function useSourceFileModel(props: SourceFileSurfaceProps) { |
| 120 | 118 | const colorScheme = useColorScheme(); |
| 121 | 119 | const theme: "dark" | "light" = colorScheme === "dark" ? "dark" : "light"; |
| 122 | − const normalizedContents = useMemo( | |
| 123 | − () => props.contents.replace(/\r\n?/g, "\n"), | |
| 124 | − [props.contents], | |
| 125 | − ); | |
| 126 | − const lines = useMemo(() => splitSourceLines(normalizedContents), [normalizedContents]); | |
| 120 | + const document = useMemo(() => prepareSourceFileDocument(props.contents), [props.contents]); | |
| 121 | + const { contents: normalizedContents, lines, rowsJson } = document; | |
| 127 | 122 | const targetIndex = |
| 128 | 123 | props.initialLine !== null && props.initialLine !== undefined && props.initialLine > 0 |
| 129 | 124 | ? Math.min(Math.floor(props.initialLine) - 1, Math.max(0, lines.length - 1)) |
| @@ -140,7 +135,7 @@ function useSourceFileModel(props: SourceFileSurfaceProps) { · 10 unmodified lines | ||
| 140 | 135 | ? "ready" |
| 141 | 136 | : "highlighting"; |
| 142 | 137 | |
| 143 | − return { lines, status, targetIndex, theme, tokens }; | |
| 138 | + return { lines, rowsJson, status, targetIndex, theme, tokens }; | |
| 144 | 139 | } |
| 145 | 140 | |
| 146 | 141 | function SourceHighlightStatusView(props: { readonly status: SourceHighlightStatus }) { |
| @@ -162,39 +157,63 @@ function NativeSourceFileSurface( · 15 unmodified lines | ||
| 162 | 157 | readonly NativeView: ComponentType<NativeReviewDiffViewProps>; |
| 163 | 158 | }, |
| 164 | 159 | ) { |
| 165 | − const { NativeView } = props; | |
| 166 | − const { lines, status, targetIndex, theme, tokens } = useSourceFileModel(props); | |
| 167 | − const rowsJson = useMemo(() => JSON.stringify(buildNativeSourceRows(lines)), [lines]); | |
| 160 | + const { NativeView, onRefresh } = props; | |
| 161 | + const { codeSurface, codeWordBreak, nativeSourceStyle } = useAppearanceCodeSurface(); | |
| 162 | + const { width: viewportWidth } = useWindowDimensions(); | |
| 163 | + const { rowsJson, status, targetIndex, theme, tokens } = useSourceFileModel(props); | |
| 164 | + const [isPullRefreshing, setIsPullRefreshing] = useState(false); | |
| 165 | + const handlePullToRefresh = useCallback(async () => { | |
| 166 | + if (!onRefresh) { | |
| 167 | + return; | |
| 168 | + } | |
| 169 | + setIsPullRefreshing(true); | |
| 170 | + try { | |
| 171 | + await onRefresh(); | |
| 172 | + } finally { | |
| 173 | + setIsPullRefreshing(false); | |
| 174 | + } | |
| 175 | + }, [onRefresh]); | |
| 168 | 176 | const tokensJson = useMemo(() => JSON.stringify(buildNativeSourceTokens(tokens)), [tokens]); |
| 169 | 177 | const selectedRowIdsJson = useMemo( |
| 170 | 178 | () => JSON.stringify(targetIndex === null ? [] : [nativeSourceRowId(targetIndex)]), |
| 171 | 179 | [targetIndex], |
| 172 | 180 | ); |
| 173 | 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 | 187 | return ( |
| 176 | − <View className="relative flex-1 bg-card"> | |
| 188 | + <View className="relative flex-1 bg-sheet"> | |
| 177 | 189 | <SourceHighlightStatusView status={status} /> |
| 178 | 190 | <NativeView |
| 179 | − key={props.path} | |
| 180 | 191 | collapsable={false} |
| 181 | 192 | testID="source-native-code-view" |
| 182 | 193 | style={{ flex: 1 }} |
| 183 | 194 | appearanceScheme={theme} |
| 184 | − contentWidth={NATIVE_SOURCE_CONTENT_WIDTH} | |
| 195 | + contentResetKey={props.path} | |
| 196 | + contentWidth={contentWidth} | |
| 185 | 197 | initialRowIndex={targetIndex ?? -1} |
| 186 | − rowHeight={NATIVE_SOURCE_ROW_HEIGHT} | |
| 198 | + rowHeight={nativeSourceStyle.rowHeight ?? codeSurface.rowHeight} | |
| 187 | 199 | rowsJson={rowsJson} |
| 188 | 200 | selectedRowIdsJson={selectedRowIdsJson} |
| 189 | − styleJson={NATIVE_SOURCE_STYLE_JSON} | |
| 201 | + styleJson={styleJson} | |
| 190 | 202 | themeJson={themeJson} |
| 191 | 203 | tokensJson={tokensJson} |
| 204 | + {...(onRefresh | |
| 205 | + ? { | |
| 206 | + refreshing: isPullRefreshing, | |
| 207 | + onPullToRefresh: () => void handlePullToRefresh(), | |
| 208 | + } | |
| 209 | + : {})} | |
| 192 | 210 | /> |
| 193 | 211 | </View> |
| 194 | 212 | ); |
| 195 | 213 | } |
| 196 | 214 | |
| 197 | 215 | function JavaScriptSourceFileSurface(props: SourceFileSurfaceProps) { |
| 216 | + const { codeSurface, codeWordBreak } = useAppearanceCodeSurface(); | |
| 198 | 217 | const { lines, status, targetIndex, tokens } = useSourceFileModel(props); |
| 199 | 218 | const listRef = useRef<FlatList<string>>(null); |
| 200 | 219 | |
| @@ -211,39 +230,53 @@ function JavaScriptSourceFileSurface(props: SourceFileSurfaceProps) { · 10 unmodified lines | ||
| 211 | 230 | const renderLine = useCallback( |
| 212 | 231 | ({ item, index }: { item: string; index: number }) => ( |
| 213 | 232 | <HighlightedSourceLine |
| 233 | + codeSurface={codeSurface} | |
| 214 | 234 | index={index} |
| 215 | 235 | line={item} |
| 216 | 236 | tokens={tokens?.[index] ?? null} |
| 217 | 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 | 270 | return ( |
| 224 | − <View className="relative flex-1 bg-card"> | |
| 271 | + <View className="relative flex-1 bg-sheet"> | |
| 225 | 272 | <SourceHighlightStatusView status={status} /> |
| 226 | − <ScrollView horizontal bounces={false} className="flex-1"> | |
| 227 | − <FlatList | |
| 228 | − ref={listRef} | |
| 229 | − data={lines} | |
| 230 | − keyExtractor={(_line, index) => String(index)} | |
| 231 | − initialNumToRender={80} | |
| 232 | − maxToRenderPerBatch={80} | |
| 233 | − windowSize={12} | |
| 234 | − getItemLayout={(_data, index) => ({ | |
| 235 | − length: SOURCE_LINE_HEIGHT, | |
| 236 | − offset: SOURCE_LINE_HEIGHT * index, | |
| 237 | − index, | |
| 238 | − })} | |
| 239 | − contentContainerStyle={{ | |
| 240 | − minWidth: "100%", | |
| 241 | − paddingBottom: REVIEW_DIFF_LINE_HEIGHT, | |
| 242 | − paddingTop: 8, | |
| 243 | − }} | |
| 244 | − renderItem={renderLine} | |
| 245 | − /> | |
| 246 | − </ScrollView> | |
| 273 | + {codeWordBreak ? ( | |
| 274 | + list | |
| 275 | + ) : ( | |
| 276 | + <ScrollView horizontal bounces={false} className="flex-1"> | |
| 277 | + {list} | |
| 278 | + </ScrollView> | |
| 279 | + )} | |
| 247 | 280 | </View> |
| 248 | 281 | ); |
| 249 | 282 | } |
| @@ -1,8 +1,15 @@ | ||
| 1 | −import Stack from "expo-router/stack"; | |
| 2 | −import { SymbolView } from "expo-symbols"; | |
| 3 | −import { useLocalSearchParams, useRouter } from "expo-router"; | |
| 4 | −import { useCallback, useMemo, useRef, useState } from "react"; | |
| 5 | −import { ActivityIndicator, Pressable, ScrollView, Text as RNText, View } from "react-native"; | |
| 1 | +import { NativeHeaderToolbar, NativeStackScreenOptions } from "../../native/StackHeader"; | |
| 2 | +import { StackActions, useNavigation, type StaticScreenProps } from "@react-navigation/native"; | |
| 3 | +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; | |
| 4 | +import { | |
| 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 | 13 | import Svg, { Defs, LinearGradient, Rect, Stop } from "react-native-svg"; |
| 7 | 14 | import { |
| 8 | 15 | EnvironmentId, |
| @@ -12,22 +19,31 @@ import { · 3 unmodified lines | ||
| 12 | 19 | } from "@t3tools/contracts"; |
| 13 | 20 | |
| 14 | 21 | import { AppText as Text } from "../../components/AppText"; |
| 15 | −import { CopyTextButton } from "../../components/CopyTextButton"; | |
| 16 | 22 | import { EmptyState } from "../../components/EmptyState"; |
| 17 | 23 | import { LoadingScreen } from "../../components/LoadingScreen"; |
| 18 | 24 | import { cn } from "../../lib/cn"; |
| 25 | +import { resolveFileSelectionNavigationAction } from "../../lib/adaptive-navigation"; | |
| 26 | +import { copyTextWithHaptic } from "../../lib/copyTextWithHaptic"; | |
| 19 | 27 | import { tryOpenExternalUrl } from "../../lib/openExternalUrl"; |
| 20 | −import { buildThreadFilesNavigation } from "../../lib/routes"; | |
| 21 | −import { MOBILE_TYPOGRAPHY } from "../../lib/typography"; | |
| 22 | 28 | import { useThemeColor } from "../../lib/useThemeColor"; |
| 23 | 29 | import { useThreadSelection } from "../../state/use-thread-selection"; |
| 24 | 30 | import { useSelectedThreadWorktree } from "../../state/use-selected-thread-worktree"; |
| 25 | 31 | import { useEnvironmentQuery } from "../../state/query"; |
| 26 | 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 | 40 | import { ReviewHighlighterProvider } from "../review/ReviewHighlighterProvider"; |
| 41 | +import { ThreadRouteScreen } from "../threads/ThreadRouteScreen"; | |
| 28 | 42 | import { FileMarkdownPreview } from "./FileMarkdownPreview"; |
| 29 | 43 | import { FileTreeBrowser } from "./FileTreeBrowser"; |
| 44 | +import { preloadWorkspaceFileContents } from "./preload-workspace-file"; | |
| 30 | 45 | import { SourceFileSurface } from "./SourceFileSurface"; |
| 46 | +import { ThreadFileNavigatorPane } from "./thread-file-navigator-pane"; | |
| 31 | 47 | import { WorkspaceFileImagePreview } from "./WorkspaceFileImagePreview"; |
| 32 | 48 | import { WorkspaceFileWebPreview } from "./WorkspaceFileWebPreview"; |
| 33 | 49 | import { |
| @@ -72,196 +88,6 @@ function defaultViewMode(path: string | null): FileViewMode { · 38 unmodified lines | ||
| 72 | 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 | 91 | function FileContent(props: { |
| 266 | 92 | readonly activeMode: FileViewMode; |
| 267 | 93 | readonly previewUri: string | null; |
| @@ -270,6 +96,7 @@ function FileContent(props: { · 2 unmodified lines | ||
| 270 | 96 | readonly relativePath: string; |
| 271 | 97 | readonly initialLine: number | null; |
| 272 | 98 | readonly truncated: boolean; |
| 99 | + readonly onRefresh?: () => Promise<void> | void; | |
| 273 | 100 | }) { |
| 274 | 101 | const isMarkdown = isMarkdownPreviewFile(props.relativePath); |
| 275 | 102 | const isBrowserFile = isBrowserPreviewFile(props.relativePath); |
| @@ -293,7 +120,7 @@ function FileContent(props: { · 17 unmodified lines | ||
| 293 | 120 | |
| 294 | 121 | if (props.fileError && props.fileContents === null) { |
| 295 | 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 | 124 | <EmptyState title="File unavailable" detail={props.fileError} /> |
| 298 | 125 | </View> |
| 299 | 126 | ); |
| @@ -301,7 +128,7 @@ function FileContent(props: { · 1 unmodified line | ||
| 301 | 128 | |
| 302 | 129 | if (props.fileContents === null) { |
| 303 | 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 | 132 | <ActivityIndicator /> |
| 306 | 133 | <Text className="text-center text-sm text-foreground-muted">Loading file...</Text> |
| 307 | 134 | </View> |
| @@ -309,35 +136,47 @@ function FileContent(props: { · 1 unmodified line | ||
| 309 | 136 | } |
| 310 | 137 | |
| 311 | 138 | return ( |
| 312 | − <View className="flex-1 bg-card"> | |
| 139 | + <View className="flex-1 bg-sheet"> | |
| 313 | 140 | {props.truncated ? ( |
| 314 | 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 | 142 | <Text className="text-2xs font-t3-bold uppercase text-amber-700 dark:text-amber-300"> |
| 316 | 143 | Partial file |
| 317 | 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 | 146 | Preview limited to the first 1 MB of a truncated file. |
| 320 | 147 | </Text> |
| 321 | 148 | </View> |
| 322 | 149 | ) : null} |
| 323 | 150 | {props.activeMode === "preview" && isMarkdown ? ( |
| 324 | − <FileMarkdownPreview markdown={props.fileContents} /> | |
| 151 | + <FileMarkdownPreview markdown={props.fileContents} onRefresh={props.onRefresh} /> | |
| 325 | 152 | ) : ( |
| 326 | 153 | <SourceFileSurface |
| 327 | 154 | contents={props.fileContents} |
| 328 | 155 | path={props.relativePath} |
| 329 | 156 | initialLine={props.initialLine} |
| 157 | + onRefresh={props.onRefresh} | |
| 330 | 158 | /> |
| 331 | 159 | )} |
| 332 | 160 | </View> |
| 333 | 161 | ); |
| 334 | 162 | } |
| 335 | 163 | |
| 336 | −function useThreadFilesWorkspace() { | |
| 337 | − const params = useLocalSearchParams<{ | |
| 338 | − environmentId?: string | string[]; | |
| 339 | − threadId?: string | string[]; | |
| 340 | − }>(); | |
| 164 | +type ThreadFilesRouteScreenProps = StaticScreenProps<{ | |
| 165 | + readonly environmentId: string; | |
| 166 | + readonly threadId: string; | |
| 167 | +}>; | |
| 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 | 180 | const routeEnvironmentId = firstRouteParam(params.environmentId); |
| 342 | 181 | const routeThreadId = firstRouteParam(params.threadId); |
| 343 | 182 | const { selectedThread, selectedThreadProject } = useThreadSelection(); |
| @@ -364,7 +203,7 @@ function useThreadFilesWorkspace() { · 20 unmodified lines | ||
| 364 | 203 | function FilesUnavailable() { |
| 365 | 204 | return ( |
| 366 | 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 | 207 | <EmptyState |
| 369 | 208 | title="Files unavailable" |
| 370 | 209 | detail="This thread does not have an active workspace path." |
| @@ -373,40 +212,6 @@ function FilesUnavailable() { · 2 unmodified lines | ||
| 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 | 215 | function FilesToolbarBottomFade() { |
| 411 | 216 | const sheetColor = String(useThemeColor("--color-sheet")); |
| 412 | 217 | |
| @@ -442,12 +247,20 @@ function FilesToolbarBottomFade() { · 29 unmodified lines | ||
| 442 | 247 | ); |
| 443 | 248 | } |
| 444 | 249 | |
| 445 | −export function ThreadFilesTreeScreen() { | |
| 446 | − const router = useRouter(); | |
| 250 | +export function ThreadFilesTreeScreen(props: ThreadFilesRouteScreenProps) { | |
| 251 | + useAdaptiveWorkspacePaneRole("inspector"); | |
| 252 | + const navigation = useNavigation(); | |
| 253 | + const { fileInspector, layout, panes, showAuxiliaryPane, togglePrimarySidebar } = | |
| 254 | + useAdaptiveWorkspaceLayout(); | |
| 447 | 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 | 262 | const entriesQuery = useEnvironmentQuery( |
| 450 | − environmentId !== null && cwd !== null | |
| 263 | + environmentId !== null && cwd !== null && !fileInspector.supported | |
| 451 | 264 | ? projectEnvironment.listEntries({ |
| 452 | 265 | environmentId, |
| 453 | 266 | input: { cwd }, |
| @@ -455,18 +268,87 @@ export function ThreadFilesTreeScreen() { · 1 unmodified line | ||
| 455 | 268 | : null, |
| 456 | 269 | ); |
| 457 | 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 | 286 | const handleSelectFile = useCallback( |
| 460 | 287 | (path: string) => { |
| 461 | 288 | if (environmentId === null || threadId === null) { |
| 462 | 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 | 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 | 352 | return <LoadingScreen message="Opening files..." messagePlacement="above-spinner" />; |
| 471 | 353 | } |
| 472 | 354 | |
| @@ -474,62 +356,95 @@ export function ThreadFilesTreeScreen() { · 1 unmodified line | ||
| 474 | 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 | 371 | return ( |
| 478 | − <View className="flex-1 bg-sheet"> | |
| 479 | − <Stack.Screen | |
| 372 | + <> | |
| 373 | + {/* Static header config (glass preset, title, contentStyle) lives in Stack.tsx. | |
| 374 | + Only genuinely dynamic options are set here. */} | |
| 375 | + <NativeStackScreenOptions | |
| 480 | 376 | options={{ |
| 481 | − title: "Files", | |
| 482 | − headerShown: true, | |
| 483 | − headerTransparent: true, | |
| 484 | − headerStyle: { backgroundColor: "transparent" }, | |
| 485 | − headerShadowVisible: false, | |
| 486 | − headerTitle: () => <FilesHeaderTitle projectName={projectName} />, | |
| 487 | − headerSearchBarOptions: { | |
| 488 | − allowToolbarIntegration: true, | |
| 489 | − autoCapitalize: "none", | |
| 490 | − hideNavigationBar: false, | |
| 491 | − placeholder: "Search files", | |
| 492 | − onChangeText: (event) => { | |
| 493 | − setSearchQuery(event.nativeEvent.text); | |
| 494 | − }, | |
| 495 | − onCancelButtonPress: () => { | |
| 496 | − setSearchQuery(""); | |
| 497 | − }, | |
| 498 | − }, | |
| 377 | + unstable_headerSubtitle: | |
| 378 | + Platform.OS === "ios" && projectName.length > 0 ? projectName : undefined, | |
| 379 | + // No refresh button: the list already supports pull-to-refresh. | |
| 380 | + unstable_headerToolbarItems: usesCompactMailToolbar | |
| 381 | + ? () => [ | |
| 382 | + createNativeMailSearchToolbarItem({ | |
| 383 | + onSearchTextChange: setSearchQuery, | |
| 384 | + placeholder: "Search files", | |
| 385 | + searchTextChangeId: "files-search-text", | |
| 386 | + }), | |
| 387 | + ] | |
| 388 | + : undefined, | |
| 389 | + headerSearchBarOptions: usesCompactMailToolbar | |
| 390 | + ? undefined | |
| 391 | + : { | |
| 392 | + allowToolbarIntegration: true, | |
| 393 | + autoCapitalize: "none", | |
| 394 | + hideNavigationBar: false, | |
| 395 | + placeholder: "Search files", | |
| 396 | + onChangeText: (event) => { | |
| 397 | + setSearchQuery(event.nativeEvent.text); | |
| 398 | + }, | |
| 399 | + onCancelButtonPress: () => { | |
| 400 | + setSearchQuery(""); | |
| 401 | + }, | |
| 402 | + }, | |
| 499 | 403 | }} |
| 500 | 404 | /> |
| 501 | − <Stack.Toolbar placement="right"> | |
| 502 | − <Stack.Toolbar.Button | |
| 503 | − accessibilityLabel="Refresh files" | |
| 504 | − icon="arrow.clockwise" | |
| 505 | − onPress={entriesQuery.refresh} | |
| 506 | − /> | |
| 507 | − </Stack.Toolbar> | |
| 508 | − <Stack.Toolbar placement="bottom"> | |
| 509 | − <Stack.Toolbar.SearchBarSlot /> | |
| 510 | − </Stack.Toolbar> | |
| 405 | + {layout.usesSplitView ? ( | |
| 406 | + <NativeHeaderToolbar placement="left"> | |
| 407 | + <NativeHeaderToolbar.Button | |
| 408 | + accessibilityLabel={panes.primarySidebarVisible ? "Maximize files" : "Show threads"} | |
| 409 | + icon={ | |
| 410 | + panes.primarySidebarVisible ? "arrow.up.left.and.arrow.down.right" : "sidebar.left" | |
| 411 | + } | |
| 412 | + onPress={togglePrimarySidebar} | |
| 413 | + separateBackground | |
| 414 | + /> | |
| 415 | + </NativeHeaderToolbar> | |
| 416 | + ) : null} | |
| 417 | + {usesCompactMailToolbar ? null : ( | |
| 418 | + <NativeHeaderToolbar placement="bottom"> | |
| 419 | + <NativeHeaderToolbar.SearchBarSlot /> | |
| 420 | + </NativeHeaderToolbar> | |
| 421 | + )} | |
| 511 | 422 | <FileTreeBrowser |
| 512 | 423 | entries={entriesData?.entries ?? []} |
| 513 | 424 | error={entriesQuery.error} |
| 514 | 425 | isPending={entriesQuery.isPending} |
| 515 | 426 | searchQuery={searchQuery} |
| 516 | 427 | selectedPath={null} |
| 428 | + onPreviewFile={handlePreviewFile} | |
| 517 | 429 | onRefresh={entriesQuery.refresh} |
| 518 | 430 | onSelectFile={handleSelectFile} |
| 519 | 431 | /> |
| 520 | 432 | <FilesToolbarBottomFade /> |
| 521 | − </View> | |
| 433 | + </> | |
| 522 | 434 | ); |
| 523 | 435 | } |
| 524 | 436 | |
| 525 | −export function ThreadFileScreen() { | |
| 526 | − const params = useLocalSearchParams<{ | |
| 527 | − line?: string | string[]; | |
| 528 | − path?: string | string[]; | |
| 529 | − }>(); | |
| 437 | +export function ThreadFileScreen(props: ThreadFileRouteScreenProps) { | |
| 438 | + useAdaptiveWorkspacePaneRole("inspector"); | |
| 439 | + const navigation = useNavigation(); | |
| 440 | + const { fileInspector, panes, toggleAuxiliaryPane } = useAdaptiveWorkspaceLayout(); | |
| 441 | + const iconColor = useThemeColor("--color-icon"); | |
| 442 | + const params = props.route.params; | |
| 530 | 443 | const relativePath = normalizeRoutePath(params.path); |
| 531 | 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 | 448 | const [modeOverride, setModeOverride] = useState<{ |
| 534 | 449 | readonly path: string; |
| 535 | 450 | readonly mode: FileViewMode; |
| @@ -568,6 +483,43 @@ export function ThreadFileScreen() { · 32 unmodified lines | ||
| 568 | 483 | ); |
| 569 | 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 | 523 | if (selectedThread === null || environmentId === null || threadId === null) { |
| 572 | 524 | return <LoadingScreen message="Opening file..." messagePlacement="above-spinner" />; |
| 573 | 525 | } |
| @@ -579,38 +531,104 @@ export function ThreadFileScreen() { · 5 unmodified lines | ||
| 579 | 531 | if (relativePath === null) { |
| 580 | 532 | return ( |
| 581 | 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 | 535 | <EmptyState title="File unavailable" detail="This file path is invalid." /> |
| 584 | 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 | 543 | return ( |
| 589 | 544 | <ReviewHighlighterProvider> |
| 590 | 545 | <View className="flex-1 bg-sheet"> |
| 591 | − <Stack.Screen options={{ title: basename(relativePath) }} /> | |
| 592 | − <Stack.Toolbar placement="right"> | |
| 593 | − <Stack.Toolbar.Button | |
| 594 | − icon="arrow.clockwise" | |
| 595 | − onPress={() => { | |
| 596 | − if (resolvedActiveMode === "preview" && (isBrowserFile || isImageFile)) { | |
| 597 | − setPreviewRevision((current) => current + 1); | |
| 598 | − return; | |
| 599 | − } | |
| 600 | − fileQuery.refresh(); | |
| 601 | − }} | |
| 602 | − /> | |
| 603 | − </Stack.Toolbar> | |
| 604 | − <FilePreviewHeader | |
| 605 | − activeMode={resolvedActiveMode} | |
| 606 | − showModeSelector={canPreview && !isImageFile} | |
| 607 | − externalPreviewUri={isBrowserFile ? assetPreviewUri : undefined} | |
| 608 | − projectName={projectName} | |
| 609 | − relativePath={relativePath} | |
| 610 | − onSetMode={(mode) => { | |
| 611 | − setModeOverride({ path: relativePath, mode }); | |
| 546 | + <NativeStackScreenOptions | |
| 547 | + options={{ | |
| 548 | + // Static header config lives in Stack.tsx (SOLID_HEADER_OPTIONS: solid | |
| 549 | + // sheet-colored header — this route's content scrolls internally, so | |
| 550 | + // there is nothing for glass to sample). Only dynamic values here. | |
| 551 | + headerTintColor: iconColor, | |
| 552 | + headerTitle: basename(relativePath), | |
| 553 | + title: basename(relativePath), | |
| 554 | + unstable_headerSubtitle: | |
| 555 | + Platform.OS === "ios" && headerSubtitle.length > 0 ? headerSubtitle : undefined, | |
| 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 | 632 | <FileContent |
| 615 | 633 | activeMode={resolvedActiveMode} |
| 616 | 634 | previewUri={previewUri} |
| @@ -619,6 +637,7 @@ export function ThreadFileScreen() { · 2 unmodified lines | ||
| 619 | 637 | initialLine={targetLine} |
| 620 | 638 | relativePath={relativePath} |
| 621 | 639 | truncated={fileData?.truncated ?? false} |
| 640 | + onRefresh={() => fileQuery.refresh()} | |
| 622 | 641 | /> |
| 623 | 642 | </View> |
| 624 | 643 | </ReviewHighlighterProvider> |
| @@ -24,7 +24,7 @@ export function WorkspaceFileWebPreview(props: { readonly uri: string | null }) · 23 unmodified lines | ||
| 24 | 24 | {loadError ? ( |
| 25 | 25 | <View className="border-b border-border bg-card px-4 py-2"> |
| 26 | 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 | 28 | </View> |
| 29 | 29 | ) : null} |
| 30 | 30 | <WebView |
| @@ -3,26 +3,38 @@ import type { · 2 unmodified lines | ||
| 3 | 3 | NativeReviewDiffStyle, |
| 4 | 4 | NativeReviewDiffToken, |
| 5 | 5 | } from "../diffs/nativeReviewDiffSurface"; |
| 6 | +import type { ResolvedMobileCodeSurface } from "../../lib/appearancePreferences"; | |
| 7 | +import { resolveMobileCodeSurface } from "../../lib/appearancePreferences"; | |
| 6 | 8 | import { MOBILE_CODE_SURFACE, MOBILE_TYPOGRAPHY } from "../../lib/typography"; |
| 7 | 9 | import type { SourceHighlightTokens } from "./sourceHighlightingState"; |
| 8 | 10 | |
| 9 | 11 | export const NATIVE_SOURCE_ROW_HEIGHT = MOBILE_CODE_SURFACE.rowHeight; |
| 10 | 12 | export const NATIVE_SOURCE_CONTENT_WIDTH = 32_000; |
| 11 | 13 | |
| 12 | −export const NATIVE_SOURCE_STYLE: NativeReviewDiffStyle = { | |
| 13 | − rowHeight: NATIVE_SOURCE_ROW_HEIGHT, | |
| 14 | − contentWidth: NATIVE_SOURCE_CONTENT_WIDTH, | |
| 15 | − changeBarWidth: 0, | |
| 16 | − gutterWidth: MOBILE_CODE_SURFACE.gutterWidth, | |
| 17 | − codePadding: MOBILE_CODE_SURFACE.codePadding, | |
| 18 | − textVerticalInset: MOBILE_CODE_SURFACE.textVerticalInset, | |
| 19 | − codeFontSize: MOBILE_CODE_SURFACE.fontSize, | |
| 20 | − codeFontWeight: "regular", | |
| 21 | − lineNumberFontSize: MOBILE_CODE_SURFACE.lineNumberFontSize, | |
| 22 | − lineNumberFontWeight: "regular", | |
| 23 | − emptyStateFontSize: MOBILE_TYPOGRAPHY.label.fontSize, | |
| 24 | − emptyStateFontWeight: "medium", | |
| 25 | −}; | |
| 14 | +export const NATIVE_SOURCE_STYLE: NativeReviewDiffStyle = createNativeSourceStyle( | |
| 15 | + resolveMobileCodeSurface(MOBILE_CODE_SURFACE.fontSize), | |
| 16 | +); | |
| 17 | + | |
| 18 | +export function createNativeSourceStyle( | |
| 19 | + codeSurface: ResolvedMobileCodeSurface, | |
| 20 | +): NativeReviewDiffStyle { | |
| 21 | + return { | |
| 22 | + rowHeight: codeSurface.rowHeight, | |
| 23 | + contentWidth: NATIVE_SOURCE_CONTENT_WIDTH, | |
| 24 | + changeBarWidth: 0, | |
| 25 | + gutterWidth: codeSurface.gutterWidth, | |
| 26 | + codePadding: codeSurface.codePadding, | |
| 27 | + textVerticalInset: codeSurface.textVerticalInset, | |
| 28 | + codeFontSize: codeSurface.fontSize, | |
| 29 | + codeFontWeight: "regular", | |
| 30 | + lineNumberFontSize: codeSurface.lineNumberFontSize, | |
| 31 | + lineNumberFontWeight: "regular", | |
| 32 | + emptyStateFontSize: Math.round( | |
| 33 | + MOBILE_TYPOGRAPHY.label.fontSize * (codeSurface.fontSize / MOBILE_CODE_SURFACE.fontSize), | |
| 34 | + ), | |
| 35 | + emptyStateFontWeight: "medium", | |
| 36 | + }; | |
| 37 | +} | |
| 26 | 38 | |
| 27 | 39 | const SOURCE_FILE_ID = "source-file"; |
| 28 | 40 | |
| @@ -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 | +} | |
| @@ -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 | +}); | |
| @@ -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 | +} | |
| @@ -88,7 +88,7 @@ describe("sourceHighlightingState", () => { · 87 unmodified lines | ||
| 88 | 88 | expect(AsyncResult.isSuccess(registry.get(atom))).toBe(true); |
| 89 | 89 | }); |
| 90 | 90 | firstUnmount(); |
| 91 | − await new Promise((resolve) => setTimeout(resolve, 25)); | |
| 91 | + await new Promise((resolve) => setTimeout(resolve, 100)); | |
| 92 | 92 | |
| 93 | 93 | const secondUnmount = registry.mount(atom); |
| 94 | 94 | await vi.waitFor(() => { |
| @@ -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 | +} | |
| @@ -3,60 +3,30 @@ import type { · 2 unmodified lines | ||
| 3 | 3 | SidebarProjectGroupingMode, |
| 4 | 4 | SidebarThreadSortOrder, |
| 5 | 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 { Stack } from "expo-router"; | |
| 12 | −import { Text as RNText, View } from "react-native"; | |
| 6 | +import { NativeHeaderToolbar, NativeStackScreenOptions } from "../../native/StackHeader"; | |
| 7 | +import { useCallback, useRef } from "react"; | |
| 8 | +import { Platform } from "react-native"; | |
| 9 | +import type { SearchBarCommands } from "react-native-screens"; | |
| 13 | 10 | |
| 11 | +import { nativeHeaderScrollEdgeEffects } from "../../native/StackHeader"; | |
| 14 | 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 | 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 { | |
| 19 | − readonly environmentId: EnvironmentId; | |
| 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 | −]; | |
| 28 | +export type HomeHeaderEnvironment = HomeListFilterMenuEnvironment; | |
| 29 | +const HEADER_SCROLL_EDGE_EFFECTS = nativeHeaderScrollEdgeEffects(Platform.OS, Platform.Version); | |
| 60 | 30 | |
| 61 | 31 | export function HomeHeader(props: { |
| 62 | 32 | readonly environments: ReadonlyArray<HomeHeaderEnvironment>; |
| @@ -72,174 +42,170 @@ export function HomeHeader(props: { · 9 unmodified lines | ||
| 72 | 42 | readonly onOpenSettings: () => void; |
| 73 | 43 | readonly onStartNewTask: () => void; |
| 74 | 44 | }) { |
| 45 | + const searchBarRef = useRef<SearchBarCommands>(null); | |
| 75 | 46 | const iconColor = useThemeColor("--color-icon"); |
| 76 | − const mutedColor = useThemeColor("--color-foreground-muted"); | |
| 77 | − const subtleColor = useThemeColor("--color-subtle"); | |
| 78 | − const hasCustomListOptions = | |
| 79 | − props.selectedEnvironmentId !== null || | |
| 80 | − props.projectSortOrder !== DEFAULT_SIDEBAR_PROJECT_SORT_ORDER || | |
| 81 | − props.threadSortOrder !== DEFAULT_SIDEBAR_THREAD_SORT_ORDER || | |
| 82 | − props.projectGroupingMode !== DEFAULT_SIDEBAR_PROJECT_GROUPING_MODE; | |
| 47 | + const hasCustomListOptions = hasCustomHomeListOptions(props); | |
| 48 | + const focusSearch = useCallback(() => { | |
| 49 | + searchBarRef.current?.focus(); | |
| 50 | + return searchBarRef.current !== null; | |
| 51 | + }, []); | |
| 52 | + useHardwareKeyboardCommand("focusSearch", focusSearch); | |
| 53 | + const filterMenu = buildHomeListFilterMenu(props); | |
| 83 | 54 | |
| 84 | 55 | return ( |
| 85 | 56 | <> |
| 86 | − <Stack.Screen | |
| 57 | + <NativeStackScreenOptions | |
| 87 | 58 | options={{ |
| 88 | − headerShown: true, | |
| 89 | − headerTransparent: true, | |
| 90 | − headerStyle: { backgroundColor: "transparent" }, | |
| 91 | − headerShadowVisible: false, | |
| 59 | + // Static header config (glass, title, fonts) lives in Stack.tsx | |
| 60 | + // (GLASS_HEADER_OPTIONS). Only dynamic values are set here. | |
| 92 | 61 | headerTintColor: iconColor, |
| 93 | − headerTitle: "", | |
| 94 | − headerSearchBarOptions: { | |
| 95 | − placeholder: "Search threads", | |
| 96 | − hideNavigationBar: false, | |
| 97 | − onChangeText: (event) => { | |
| 98 | − props.onSearchQueryChange(event.nativeEvent.text); | |
| 99 | − }, | |
| 100 | − onCancelButtonPress: () => { | |
| 101 | − props.onSearchQueryChange(""); | |
| 102 | − }, | |
| 103 | − allowToolbarIntegration: true, | |
| 104 | − }, | |
| 62 | + unstable_headerRightItems: | |
| 63 | + Platform.OS === "ios" | |
| 64 | + ? () => [ | |
| 65 | + withNativeGlassHeaderItem({ | |
| 66 | + accessibilityLabel: "Open settings", | |
| 67 | + icon: { name: "ellipsis", type: "sfSymbol" } as const, | |
| 68 | + identifier: "home-settings", | |
| 69 | + label: "", | |
| 70 | + onPress: props.onOpenSettings, | |
| 71 | + type: "button", | |
| 72 | + }), | |
| 73 | + ] | |
| 74 | + : undefined, | |
| 75 | + unstable_headerToolbarItems: | |
| 76 | + Platform.OS === "ios" | |
| 77 | + ? () => [ | |
| 78 | + createNativeMailSearchToolbarItem({ | |
| 79 | + composeButtonId: "home-new-task", | |
| 80 | + composeSystemImageName: "square.and.pencil", | |
| 81 | + filterMenu, | |
| 82 | + filterButtonId: "home-filter", | |
| 83 | + filterSystemImageName: hasCustomListOptions | |
| 84 | + ? "line.3.horizontal.decrease.circle.fill" | |
| 85 | + : "line.3.horizontal.decrease", | |
| 86 | + onComposePress: props.onStartNewTask, | |
| 87 | + onSearchTextChange: props.onSearchQueryChange, | |
| 88 | + placeholder: "Search", | |
| 89 | + searchTextChangeId: "home-search-text", | |
| 90 | + }), | |
| 91 | + ] | |
| 92 | + : undefined, | |
| 93 | + headerSearchBarOptions: | |
| 94 | + Platform.OS === "ios" | |
| 95 | + ? undefined | |
| 96 | + : { | |
| 97 | + ref: searchBarRef, | |
| 98 | + allowToolbarIntegration: true, | |
| 99 | + hideNavigationBar: false, | |
| 100 | + placeholder: "Search", | |
| 101 | + onCancelButtonPress: () => { | |
| 102 | + props.onSearchQueryChange(""); | |
| 103 | + }, | |
| 104 | + onChangeText: (event) => { | |
| 105 | + props.onSearchQueryChange(event.nativeEvent.text); | |
| 106 | + }, | |
| 107 | + }, | |
| 105 | 108 | }} |
| 106 | 109 | /> |
| 107 | 110 | |
| 108 | − <Stack.Toolbar placement="left"> | |
| 109 | − <Stack.Toolbar.View hidesSharedBackground> | |
| 110 | − <View | |
| 111 | − style={{ | |
| 112 | − width: 128, | |
| 113 | − height: 32, | |
| 114 | − flexDirection: "row", | |
| 115 | − alignItems: "center", | |
| 116 | − gap: 8, | |
| 117 | − }} | |
| 118 | − > | |
| 119 | − <RNText | |
| 120 | − style={{ | |
| 121 | − fontFamily: "DMSans_700Bold", | |
| 122 | − fontSize: MOBILE_TYPOGRAPHY.headline.fontSize, | |
| 123 | − color: iconColor, | |
| 124 | − letterSpacing: -0.4, | |
| 125 | − }} | |
| 126 | − > | |
| 127 | − T3 Code | |
| 128 | − </RNText> | |
| 129 | − <View | |
| 130 | − style={{ | |
| 131 | − backgroundColor: subtleColor, | |
| 132 | − borderRadius: 99, | |
| 133 | − paddingHorizontal: 8, | |
| 134 | − paddingVertical: 3, | |
| 135 | − }} | |
| 136 | − > | |
| 137 | − <RNText | |
| 138 | − style={{ | |
| 139 | − fontFamily: "DMSans_700Bold", | |
| 140 | − fontSize: MOBILE_TYPOGRAPHY.micro.fontSize, | |
| 141 | − color: mutedColor, | |
| 142 | − letterSpacing: 1.1, | |
| 143 | − textTransform: "uppercase", | |
| 144 | − }} | |
| 145 | − > | |
| 146 | − Alpha | |
| 147 | − </RNText> | |
| 148 | − </View> | |
| 149 | − </View> | |
| 150 | − </Stack.Toolbar.View> | |
| 151 | − </Stack.Toolbar> | |
| 152 | − | |
| 153 | − <Stack.Toolbar placement="right"> | |
| 154 | − <Stack.Toolbar.Menu | |
| 155 | − accessibilityLabel="Filter and sort threads" | |
| 156 | − icon={ | |
| 157 | − hasCustomListOptions | |
| 158 | − ? "line.3.horizontal.decrease.circle.fill" | |
| 159 | − : "line.3.horizontal.decrease.circle" | |
| 160 | − } | |
| 161 | − separateBackground | |
| 162 | − title="Thread list options" | |
| 163 | − > | |
| 164 | − <Stack.Toolbar.Menu title="Environment"> | |
| 165 | − <Stack.Toolbar.Label>Environment</Stack.Toolbar.Label> | |
| 166 | − <Stack.Toolbar.MenuAction | |
| 167 | − isOn={props.selectedEnvironmentId === null} | |
| 168 | − onPress={() => props.onEnvironmentChange(null)} | |
| 169 | − subtitle="Show threads from every environment" | |
| 170 | − > | |
| 171 | − <Stack.Toolbar.Label>All environments</Stack.Toolbar.Label> | |
| 172 | − </Stack.Toolbar.MenuAction> | |
| 173 | − {props.environments.map((environment) => ( | |
| 174 | − <Stack.Toolbar.MenuAction | |
| 175 | − key={environment.environmentId} | |
| 176 | − isOn={props.selectedEnvironmentId === environment.environmentId} | |
| 177 | − onPress={() => props.onEnvironmentChange(environment.environmentId)} | |
| 178 | − > | |
| 179 | − <Stack.Toolbar.Label>{environment.label}</Stack.Toolbar.Label> | |
| 180 | − </Stack.Toolbar.MenuAction> | |
| 181 | − ))} | |
| 182 | − </Stack.Toolbar.Menu> | |
| 111 | + {Platform.OS === "ios" ? null : ( | |
| 112 | + <NativeHeaderToolbar placement="right"> | |
| 113 | + <NativeHeaderToolbar.Button | |
| 114 | + accessibilityLabel="Open settings" | |
| 115 | + icon="gearshape" | |
| 116 | + onPress={props.onOpenSettings} | |
| 117 | + separateBackground | |
| 118 | + /> | |
| 119 | + </NativeHeaderToolbar> | |
| 120 | + )} | |
| 183 | 121 | |
| 184 | − <Stack.Toolbar.Menu title="Sort projects"> | |
| 185 | − <Stack.Toolbar.Label>Sort projects</Stack.Toolbar.Label> | |
| 186 | − {PROJECT_SORT_OPTIONS.map((option) => ( | |
| 187 | − <Stack.Toolbar.MenuAction | |
| 188 | − key={option.value} | |
| 189 | − isOn={props.projectSortOrder === option.value} | |
| 190 | − onPress={() => props.onProjectSortOrderChange(option.value)} | |
| 191 | − > | |
| 192 | − <Stack.Toolbar.Label>{option.label}</Stack.Toolbar.Label> | |
| 193 | − </Stack.Toolbar.MenuAction> | |
| 194 | − ))} | |
| 195 | − </Stack.Toolbar.Menu> | |
| 122 | + {Platform.OS === "ios" ? null : ( | |
| 123 | + <NativeHeaderToolbar placement="bottom"> | |
| 124 | + <NativeHeaderToolbar.Menu | |
| 125 | + accessibilityLabel="Filter and sort threads" | |
| 126 | + icon={ | |
| 127 | + hasCustomListOptions | |
| 128 | + ? "line.3.horizontal.decrease.circle.fill" | |
| 129 | + : "line.3.horizontal.decrease.circle" | |
| 130 | + } | |
| 131 | + title="Thread list options" | |
| 132 | + separateBackground | |
| 133 | + > | |
| 134 | + <NativeHeaderToolbar.MenuAction onPress={props.onOpenSettings}> | |
| 135 | + <NativeHeaderToolbar.Label>Settings</NativeHeaderToolbar.Label> | |
| 136 | + </NativeHeaderToolbar.MenuAction> | |
| 196 | 137 | |
| 197 | − <Stack.Toolbar.Menu title="Sort threads"> | |
| 198 | − <Stack.Toolbar.Label>Sort threads</Stack.Toolbar.Label> | |
| 199 | − {THREAD_SORT_OPTIONS.map((option) => ( | |
| 200 | − <Stack.Toolbar.MenuAction | |
| 201 | − key={option.value} | |
| 202 | − isOn={props.threadSortOrder === option.value} | |
| 203 | − onPress={() => props.onThreadSortOrderChange(option.value)} | |
| 138 | + <NativeHeaderToolbar.Menu title="Environment"> | |
| 139 | + <NativeHeaderToolbar.Label>Environment</NativeHeaderToolbar.Label> | |
| 140 | + <NativeHeaderToolbar.MenuAction | |
| 141 | + isOn={props.selectedEnvironmentId === null} | |
| 142 | + onPress={() => props.onEnvironmentChange(null)} | |
| 143 | + subtitle="Show threads from every environment" | |
| 204 | 144 | > |
| 205 | − <Stack.Toolbar.Label>{option.label}</Stack.Toolbar.Label> | |
| 206 | − </Stack.Toolbar.MenuAction> | |
| 207 | − ))} | |
| 208 | − </Stack.Toolbar.Menu> | |
| 145 | + <NativeHeaderToolbar.Label>All environments</NativeHeaderToolbar.Label> | |
| 146 | + </NativeHeaderToolbar.MenuAction> | |
| 147 | + {props.environments.map((environment) => ( | |
| 148 | + <NativeHeaderToolbar.MenuAction | |
| 149 | + key={environment.environmentId} | |
| 150 | + isOn={props.selectedEnvironmentId === environment.environmentId} | |
| 151 | + onPress={() => props.onEnvironmentChange(environment.environmentId)} | |
| 152 | + > | |
| 153 | + <NativeHeaderToolbar.Label>{environment.label}</NativeHeaderToolbar.Label> | |
| 154 | + </NativeHeaderToolbar.MenuAction> | |
| 155 | + ))} | |
| 156 | + </NativeHeaderToolbar.Menu> | |
| 209 | 157 | |
| 210 | − <Stack.Toolbar.Menu title="Group projects"> | |
| 211 | − <Stack.Toolbar.Label>Group projects</Stack.Toolbar.Label> | |
| 212 | − {PROJECT_GROUPING_OPTIONS.map((option) => ( | |
| 213 | − <Stack.Toolbar.MenuAction | |
| 214 | − key={option.value} | |
| 215 | − isOn={props.projectGroupingMode === option.value} | |
| 216 | − onPress={() => props.onProjectGroupingModeChange(option.value)} | |
| 217 | − subtitle={option.subtitle} | |
| 218 | − > | |
| 219 | − <Stack.Toolbar.Label>{option.label}</Stack.Toolbar.Label> | |
| 220 | − </Stack.Toolbar.MenuAction> | |
| 221 | − ))} | |
| 222 | − </Stack.Toolbar.Menu> | |
| 223 | − </Stack.Toolbar.Menu> | |
| 158 | + <NativeHeaderToolbar.Menu title="Sort projects"> | |
| 159 | + <NativeHeaderToolbar.Label>Sort projects</NativeHeaderToolbar.Label> | |
| 160 | + {PROJECT_SORT_OPTIONS.map((option) => ( | |
| 161 | + <NativeHeaderToolbar.MenuAction | |
| 162 | + key={option.value} | |
| 163 | + isOn={props.projectSortOrder === option.value} | |
| 164 | + onPress={() => props.onProjectSortOrderChange(option.value)} | |
| 165 | + > | |
| 166 | + <NativeHeaderToolbar.Label>{option.label}</NativeHeaderToolbar.Label> | |
| 167 | + </NativeHeaderToolbar.MenuAction> | |
| 168 | + ))} | |
| 169 | + </NativeHeaderToolbar.Menu> | |
| 224 | 170 | |
| 225 | − <Stack.Toolbar.Button | |
| 226 | − accessibilityLabel="Open settings" | |
| 227 | − icon="gearshape" | |
| 228 | − onPress={props.onOpenSettings} | |
| 229 | − separateBackground | |
| 230 | − /> | |
| 231 | − </Stack.Toolbar> | |
| 171 | + <NativeHeaderToolbar.Menu title="Sort threads"> | |
| 172 | + <NativeHeaderToolbar.Label>Sort threads</NativeHeaderToolbar.Label> | |
| 173 | + {THREAD_SORT_OPTIONS.map((option) => ( | |
| 174 | + <NativeHeaderToolbar.MenuAction | |
| 175 | + key={option.value} | |
| 176 | + isOn={props.threadSortOrder === option.value} | |
| 177 | + onPress={() => props.onThreadSortOrderChange(option.value)} | |
| 178 | + > | |
| 179 | + <NativeHeaderToolbar.Label>{option.label}</NativeHeaderToolbar.Label> | |
| 180 | + </NativeHeaderToolbar.MenuAction> | |
| 181 | + ))} | |
| 182 | + </NativeHeaderToolbar.Menu> | |
| 232 | 183 | |
| 233 | − <Stack.Toolbar placement="bottom"> | |
| 234 | − <Stack.Toolbar.SearchBarSlot /> | |
| 235 | − <Stack.Toolbar.Spacer width={8} sharesBackground={false} /> | |
| 236 | − <Stack.Toolbar.Button | |
| 237 | − accessibilityLabel="New task" | |
| 238 | − icon="square.and.pencil" | |
| 239 | − onPress={props.onStartNewTask} | |
| 240 | − separateBackground | |
| 241 | − /> | |
| 242 | − </Stack.Toolbar> | |
| 184 | + <NativeHeaderToolbar.Menu title="Group projects"> | |
| 185 | + <NativeHeaderToolbar.Label>Group projects</NativeHeaderToolbar.Label> | |
| 186 | + {PROJECT_GROUPING_OPTIONS.map((option) => ( | |
| 187 | + <NativeHeaderToolbar.MenuAction | |
| 188 | + key={option.value} | |
| 189 | + isOn={props.projectGroupingMode === option.value} | |
| 190 | + onPress={() => props.onProjectGroupingModeChange(option.value)} | |
| 191 | + subtitle={option.subtitle} | |
| 192 | + > | |
| 193 | + <NativeHeaderToolbar.Label>{option.label}</NativeHeaderToolbar.Label> | |
| 194 | + </NativeHeaderToolbar.MenuAction> | |
| 195 | + ))} | |
| 196 | + </NativeHeaderToolbar.Menu> | |
| 197 | + </NativeHeaderToolbar.Menu> | |
| 198 | + <NativeHeaderToolbar.Spacer width={8} sharesBackground={false} /> | |
| 199 | + <NativeHeaderToolbar.SearchBarSlot /> | |
| 200 | + <NativeHeaderToolbar.Spacer width={8} sharesBackground={false} /> | |
| 201 | + <NativeHeaderToolbar.Button | |
| 202 | + accessibilityLabel="New task" | |
| 203 | + icon="square.and.pencil" | |
| 204 | + onPress={props.onStartNewTask} | |
| 205 | + separateBackground | |
| 206 | + /> | |
| 207 | + </NativeHeaderToolbar> | |
| 208 | + )} | |
| 243 | 209 | </> |
| 244 | 210 | ); |
| 245 | 211 | } |
| @@ -0,0 +1,149 @@ | ||
| 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 { usePendingNewTasks } from "../../state/use-pending-new-tasks"; | |
| 9 | +import { useWorkspaceState } from "../../state/workspace"; | |
| 10 | +import { useSavedRemoteConnections } from "../../state/use-remote-environment-registry"; | |
| 11 | +import { useAdaptiveWorkspaceLayout } from "../layout/AdaptiveWorkspaceLayout"; | |
| 12 | +import { WorkspaceEmptyDetail } from "../layout/WorkspaceEmptyDetail"; | |
| 13 | +import { WorkspaceSidebarToolbar } from "../layout/workspace-sidebar-toolbar"; | |
| 14 | +import { HomeScreen } from "./HomeScreen"; | |
| 15 | +import { HomeHeader } from "./HomeHeader"; | |
| 16 | +import { useHomeListOptions } from "./home-list-options"; | |
| 17 | +import { usePendingTaskListActions } from "./usePendingTaskListActions"; | |
| 18 | +import { useThreadListActions } from "./useThreadListActions"; | |
| 19 | + | |
| 20 | +/* ─── Route screen ───────────────────────────────────────────────────── */ | |
| 21 | + | |
| 22 | +export function HomeRouteScreen() { | |
| 23 | + const { layout } = useAdaptiveWorkspaceLayout(); | |
| 24 | + const projects = useProjects(); | |
| 25 | + const threads = useThreadShells(); | |
| 26 | + const { state: catalogState } = useWorkspaceState(); | |
| 27 | + const { savedConnectionsById } = useSavedRemoteConnections(); | |
| 28 | + const navigation = useNavigation(); | |
| 29 | + const [searchQuery, setSearchQuery] = useState(""); | |
| 30 | + const { archiveThread, confirmDeleteThread } = useThreadListActions(); | |
| 31 | + const pendingTasks = usePendingNewTasks(); | |
| 32 | + const { openPendingTask, confirmDeletePendingTask } = usePendingTaskListActions(); | |
| 33 | + const environments = useMemo( | |
| 34 | + () => | |
| 35 | + Arr.sort( | |
| 36 | + Object.values(savedConnectionsById).map((connection) => ({ | |
| 37 | + environmentId: connection.environmentId, | |
| 38 | + label: connection.environmentLabel, | |
| 39 | + })), | |
| 40 | + Order.mapInput( | |
| 41 | + Order.String, | |
| 42 | + (environment: { readonly label: string }) => environment.label, | |
| 43 | + ), | |
| 44 | + ), | |
| 45 | + [savedConnectionsById], | |
| 46 | + ); | |
| 47 | + const availableEnvironmentIds = useMemo( | |
| 48 | + () => new Set(environments.map((environment) => environment.environmentId)), | |
| 49 | + [environments], | |
| 50 | + ); | |
| 51 | + const { | |
| 52 | + options: listOptions, | |
| 53 | + setSelectedEnvironmentId, | |
| 54 | + setProjectGroupingMode, | |
| 55 | + setProjectSortOrder, | |
| 56 | + setThreadSortOrder, | |
| 57 | + } = useHomeListOptions(availableEnvironmentIds); | |
| 58 | + const selectedEnvironmentId = listOptions.selectedEnvironmentId; | |
| 59 | + | |
| 60 | + // In split layouts the persistent sidebar IS the thread list — Home becomes | |
| 61 | + // an empty detail pane so selecting a thread never transitions layouts. | |
| 62 | + if (layout.usesSplitView) { | |
| 63 | + return ( | |
| 64 | + <> | |
| 65 | + <NativeStackScreenOptions options={{ title: "", headerTitle: "" }} /> | |
| 66 | + <WorkspaceSidebarToolbar | |
| 67 | + afterSidebarButton={ | |
| 68 | + <NativeHeaderToolbar.Button | |
| 69 | + accessibilityLabel="New task" | |
| 70 | + icon="square.and.pencil" | |
| 71 | + onPress={() => navigation.navigate("NewTaskSheet", { screen: "NewTask" })} | |
| 72 | + /> | |
| 73 | + } | |
| 74 | + /> | |
| 75 | + <WorkspaceEmptyDetail | |
| 76 | + onStartNewTask={() => navigation.navigate("NewTaskSheet", { screen: "NewTask" })} | |
| 77 | + /> | |
| 78 | + </> | |
| 79 | + ); | |
| 80 | + } | |
| 81 | + | |
| 82 | + return ( | |
| 83 | + <> | |
| 84 | + {/* Restore the compact title in case the split branch blanked it. */} | |
| 85 | + <NativeStackScreenOptions options={{ title: "Threads", headerTitle: "Threads" }} /> | |
| 86 | + <HomeHeader | |
| 87 | + environments={environments} | |
| 88 | + selectedEnvironmentId={selectedEnvironmentId} | |
| 89 | + projectSortOrder={listOptions.projectSortOrder} | |
| 90 | + threadSortOrder={listOptions.threadSortOrder} | |
| 91 | + projectGroupingMode={listOptions.projectGroupingMode} | |
| 92 | + onEnvironmentChange={setSelectedEnvironmentId} | |
| 93 | + onOpenSettings={() => navigation.navigate("SettingsSheet", { screen: "Settings" })} | |
| 94 | + onProjectGroupingModeChange={setProjectGroupingMode} | |
| 95 | + onProjectSortOrderChange={setProjectSortOrder} | |
| 96 | + onSearchQueryChange={setSearchQuery} | |
| 97 | + onStartNewTask={() => navigation.navigate("NewTaskSheet", { screen: "NewTask" })} | |
| 98 | + onThreadSortOrderChange={setThreadSortOrder} | |
| 99 | + /> | |
| 100 | + | |
| 101 | + <HomeScreen | |
| 102 | + catalogState={catalogState} | |
| 103 | + environments={environments} | |
| 104 | + onAddConnection={() => | |
| 105 | + navigation.navigate("SettingsSheet", { screen: "SettingsEnvironmentNew" }) | |
| 106 | + } | |
| 107 | + onArchiveThread={archiveThread} | |
| 108 | + onDeleteThread={confirmDeleteThread} | |
| 109 | + onEnvironmentChange={setSelectedEnvironmentId} | |
| 110 | + onOpenEnvironments={() => | |
| 111 | + navigation.navigate("SettingsSheet", { screen: "SettingsEnvironments" }) | |
| 112 | + } | |
| 113 | + onOpenSettings={() => navigation.navigate("SettingsSheet", { screen: "Settings" })} | |
| 114 | + onProjectGroupingModeChange={setProjectGroupingMode} | |
| 115 | + onProjectSortOrderChange={setProjectSortOrder} | |
| 116 | + onSearchQueryChange={setSearchQuery} | |
| 117 | + onSelectThread={(thread) => { | |
| 118 | + navigation.navigate("Thread", { | |
| 119 | + environmentId: thread.environmentId, | |
| 120 | + threadId: thread.id, | |
| 121 | + }); | |
| 122 | + }} | |
| 123 | + onSelectPendingTask={openPendingTask} | |
| 124 | + onDeletePendingTask={confirmDeletePendingTask} | |
| 125 | + onNewThreadInProject={(project) => { | |
| 126 | + navigation.navigate("NewTaskSheet", { | |
| 127 | + screen: "NewTaskDraft", | |
| 128 | + params: { | |
| 129 | + environmentId: String(project.environmentId), | |
| 130 | + projectId: String(project.id), | |
| 131 | + title: project.title, | |
| 132 | + }, | |
| 133 | + }); | |
| 134 | + }} | |
| 135 | + onStartNewTask={() => navigation.navigate("NewTaskSheet", { screen: "NewTask" })} | |
| 136 | + onThreadSortOrderChange={setThreadSortOrder} | |
| 137 | + pendingTasks={pendingTasks} | |
| 138 | + projectGroupingMode={listOptions.projectGroupingMode} | |
| 139 | + projects={projects} | |
| 140 | + projectSortOrder={listOptions.projectSortOrder} | |
| 141 | + savedConnectionsById={savedConnectionsById} | |
| 142 | + searchQuery={searchQuery} | |
| 143 | + selectedEnvironmentId={selectedEnvironmentId} | |
| 144 | + threads={threads} | |
| 145 | + threadSortOrder={listOptions.threadSortOrder} | |
| 146 | + /> | |
| 147 | + </> | |
| 148 | + ); | |
| 149 | +} | |
| @@ -1,3 +1,8 @@ | ||
| 1 | +import { | |
| 2 | + LegendList, | |
| 3 | + type LegendListRef, | |
| 4 | + type LegendListRenderItemProps, | |
| 5 | +} from "@legendapp/list/react-native"; | |
| 1 | 6 | import { |
| 2 | 7 | type EnvironmentProject, |
| 3 | 8 | type EnvironmentThreadShell, |
| @@ -7,102 +12,74 @@ import type { · 3 unmodified lines | ||
| 7 | 12 | SidebarProjectGroupingMode, |
| 8 | 13 | SidebarThreadSortOrder, |
| 9 | 14 | } from "@t3tools/contracts"; |
| 10 | −import * as Haptics from "expo-haptics"; | |
| 11 | −import { SymbolView } from "expo-symbols"; | |
| 12 | 15 | import { useCallback, useMemo, useRef, useState } from "react"; |
| 13 | −import { ActivityIndicator, Pressable, ScrollView, useWindowDimensions, View } from "react-native"; | |
| 14 | −import 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"; | |
| 16 | +import { ActivityIndicator, Platform, View } from "react-native"; | |
| 17 | +import type { SwipeableMethods } from "react-native-gesture-handler/ReanimatedSwipeable"; | |
| 24 | 18 | import { useSafeAreaInsets } from "react-native-safe-area-context"; |
| 25 | 19 | import { useThemeColor } from "../../lib/useThemeColor"; |
| 26 | 20 | |
| 27 | −import { AppText as Text } from "../../components/AppText"; | |
| 28 | 21 | import { EmptyState } from "../../components/EmptyState"; |
| 29 | −import { ProjectFavicon } from "../../components/ProjectFavicon"; | |
| 30 | 22 | import type { WorkspaceState } from "../../state/workspaceModel"; |
| 31 | 23 | import type { SavedRemoteConnection } from "../../lib/connection"; |
| 32 | −import { relativeTime } from "../../lib/time"; | |
| 33 | −import { threadStatusTone } from "../threads/threadPresentation"; | |
| 34 | −import { buildHomeThreadGroups, type HomeProjectSortOrder } from "./homeThreadList"; | |
| 24 | +import { scopedProjectKey } from "../../lib/scopedEntities"; | |
| 25 | +import type { PendingNewTask } from "../../state/use-pending-new-tasks"; | |
| 35 | 26 | import { |
| 36 | − THREAD_SWIPE_ACTIONS_WIDTH, | |
| 37 | − THREAD_SWIPE_SPRING, | |
| 38 | − ThreadSwipeActions, | |
| 39 | −} from "./thread-swipe-actions"; | |
| 27 | + PendingTaskListRow, | |
| 28 | + ThreadListGroupHeader, | |
| 29 | + ThreadListRow, | |
| 30 | + ThreadListShowMoreRow, | |
| 31 | +} from "../threads/thread-list-items"; | |
| 32 | +import type { HomeListFilterMenuEnvironment } from "./home-list-filter-menu"; | |
| 33 | +import { | |
| 34 | + buildHomeListLayout, | |
| 35 | + DEFAULT_GROUP_DISPLAY_STATE, | |
| 36 | + homeListItemsAreEqual, | |
| 37 | + nextGroupDisplayState, | |
| 38 | + type HomeGroupDisplayAction, | |
| 39 | + type HomeGroupDisplayState, | |
| 40 | + type HomeListItem, | |
| 41 | +} from "./homeListItems"; | |
| 42 | +import { buildHomeThreadGroups, type HomeProjectSortOrder } from "./homeThreadList"; | |
| 43 | +import { SwipeableScrollGateProvider, useSwipeableScrollGate } from "./thread-swipe-actions"; | |
| 44 | +import { WorkspaceConnectionStatus } from "./WorkspaceConnectionStatus"; | |
| 45 | +import { shouldShowWorkspaceConnectionStatus } from "./workspace-connection-status"; | |
| 40 | 46 | |
| 41 | 47 | /* ─── Types ──────────────────────────────────────────────────────────── */ |
| 42 | 48 | |
| 43 | 49 | interface HomeScreenProps { |
| 44 | 50 | readonly projects: ReadonlyArray<EnvironmentProject>; |
| 45 | 51 | readonly threads: ReadonlyArray<EnvironmentThreadShell>; |
| 52 | + readonly pendingTasks: ReadonlyArray<PendingNewTask>; | |
| 46 | 53 | readonly catalogState: WorkspaceState; |
| 47 | 54 | readonly savedConnectionsById: Readonly<Record<string, SavedRemoteConnection>>; |
| 55 | + readonly environments: ReadonlyArray<HomeListFilterMenuEnvironment>; | |
| 48 | 56 | readonly searchQuery: string; |
| 49 | 57 | readonly selectedEnvironmentId: EnvironmentId | null; |
| 50 | 58 | readonly projectSortOrder: HomeProjectSortOrder; |
| 51 | 59 | readonly threadSortOrder: SidebarThreadSortOrder; |
| 52 | 60 | readonly projectGroupingMode: SidebarProjectGroupingMode; |
| 61 | + readonly onSearchQueryChange: (query: string) => void; | |
| 62 | + readonly onEnvironmentChange: (environmentId: EnvironmentId | null) => void; | |
| 63 | + readonly onProjectSortOrderChange: (sortOrder: HomeProjectSortOrder) => void; | |
| 64 | + readonly onThreadSortOrderChange: (sortOrder: SidebarThreadSortOrder) => void; | |
| 65 | + readonly onProjectGroupingModeChange: (mode: SidebarProjectGroupingMode) => void; | |
| 53 | 66 | readonly onAddConnection: () => void; |
| 54 | 67 | readonly onOpenEnvironments: () => void; |
| 68 | + readonly onOpenSettings: () => void; | |
| 69 | + readonly onStartNewTask: () => void; | |
| 55 | 70 | readonly onSelectThread: (thread: EnvironmentThreadShell) => void; |
| 56 | 71 | readonly onArchiveThread: (thread: EnvironmentThreadShell) => void; |
| 57 | 72 | readonly onDeleteThread: (thread: EnvironmentThreadShell) => void; |
| 73 | + readonly onSelectPendingTask: (pendingTask: PendingNewTask) => void; | |
| 74 | + readonly onDeletePendingTask: (pendingTask: PendingNewTask) => void; | |
| 58 | 75 | readonly onNewThreadInProject: (project: EnvironmentProject) => void; |
| 59 | 76 | } |
| 60 | 77 | |
| 61 | −/* ─── Status indicator colors ────────────────────────────────────────── */ | |
| 62 | − | |
| 63 | −function statusColors(thread: EnvironmentThreadShell): { bg: string; fg: string } { | |
| 64 | − switch (thread.session?.status) { | |
| 65 | − case "running": | |
| 66 | − return { bg: "rgba(249,115,22,0.14)", fg: "#f97316" }; | |
| 67 | − case "ready": | |
| 68 | − return { bg: "rgba(34,197,94,0.14)", fg: "#22c55e" }; | |
| 69 | − case "starting": | |
| 70 | − return { bg: "rgba(59,130,246,0.14)", fg: "#3b82f6" }; | |
| 71 | − case "error": | |
| 72 | − return { bg: "rgba(239,68,68,0.14)", fg: "#ef4444" }; | |
| 73 | − default: | |
| 74 | − return { bg: "rgba(163,163,163,0.10)", fg: "#a3a3a3" }; | |
| 75 | − } | |
| 76 | −} | |
| 77 | − | |
| 78 | −const COLLAPSED_THREAD_LIMIT = 6; | |
| 79 | −const THREAD_LAYOUT_TRANSITION = LinearTransition.duration(220).easing(Easing.out(Easing.cubic)); | |
| 78 | +/* ─── Layout constants ───────────────────────────────────────────────── */ | |
| 80 | 79 | |
| 81 | −function threadRowExit(values: ExitAnimationsValues) { | |
| 82 | − "worklet"; | |
| 83 | − | |
| 84 | − return { | |
| 85 | − initialValues: { | |
| 86 | − height: values.currentHeight, | |
| 87 | − opacity: 1, | |
| 88 | − originX: values.currentOriginX, | |
| 89 | − }, | |
| 90 | − animations: { | |
| 91 | − height: withDelay( | |
| 92 | − 90, | |
| 93 | − withTiming(0, { | |
| 94 | − duration: 170, | |
| 95 | − easing: Easing.inOut(Easing.cubic), | |
| 96 | − }), | |
| 97 | − ), | |
| 98 | − opacity: withDelay(80, withTiming(0, { duration: 100 })), | |
| 99 | − originX: withTiming(values.currentOriginX - values.windowWidth, { | |
| 100 | − duration: 190, | |
| 101 | − easing: Easing.out(Easing.cubic), | |
| 102 | − }), | |
| 103 | − }, | |
| 104 | − }; | |
| 105 | −} | |
| 80 | +const ESTIMATED_THREAD_ROW_HEIGHT = 72; | |
| 81 | +/** Height of the floating custom header on non-iOS platforms. */ | |
| 82 | +const CUSTOM_HEADER_HEIGHT = 78; | |
| 106 | 83 | |
| 107 | 84 | function deriveEmptyState(props: { |
| 108 | 85 | readonly catalogState: WorkspaceState; |
| @@ -167,304 +144,28 @@ function deriveEmptyState(props: { · 58 unmodified lines | ||
| 167 | 144 | }; |
| 168 | 145 | } |
| 169 | 146 | |
| 170 | −/* ─── Project group header ───────────────────────────────────────────── */ | |
| 171 | − | |
| 172 | −function ProjectGroupLabel(props: { | |
| 173 | − readonly project: EnvironmentProject; | |
| 174 | − readonly title: string; | |
| 175 | − readonly hiddenCount: number; | |
| 176 | − readonly isExpanded: boolean; | |
| 177 | − readonly onToggleExpand: () => void; | |
| 178 | − readonly onNewThread: () => void; | |
| 179 | −}) { | |
| 180 | − const iconMutedColor = useThemeColor("--color-icon-muted"); | |
| 181 | − | |
| 182 | − return ( | |
| 183 | − <View className="flex-row items-center gap-2.5 px-1 pb-2"> | |
| 184 | − <ProjectFavicon | |
| 185 | − environmentId={props.project.environmentId} | |
| 186 | − size={18} | |
| 187 | − projectTitle={props.project.title} | |
| 188 | − workspaceRoot={props.project.workspaceRoot} | |
| 189 | − /> | |
| 190 | − <Text | |
| 191 | − className="flex-1 text-xs font-t3-medium uppercase text-foreground-muted" | |
| 192 | − style={{ letterSpacing: 0.5 }} | |
| 193 | − numberOfLines={1} | |
| 194 | − > | |
| 195 | − {props.title} | |
| 196 | − </Text> | |
| 197 | − | |
| 198 | − <Pressable | |
| 199 | − accessibilityLabel={`Create new thread in ${props.title}`} | |
| 200 | − accessibilityRole="button" | |
| 201 | − hitSlop={8} | |
| 202 | − onPress={props.onNewThread} | |
| 203 | − style={({ pressed }) => ({ opacity: pressed ? 0.5 : 1 })} | |
| 204 | − > | |
| 205 | − <SymbolView | |
| 206 | − name="square.and.pencil" | |
| 207 | − size={15} | |
| 208 | − tintColor={iconMutedColor} | |
| 209 | − type="monochrome" | |
| 210 | − weight="medium" | |
| 211 | − /> | |
| 212 | − </Pressable> | |
| 213 | − | |
| 214 | − {props.hiddenCount > 0 ? ( | |
| 215 | − <Pressable onPress={props.onToggleExpand} hitSlop={8}> | |
| 216 | − <Text | |
| 217 | − className="text-xs font-t3-medium text-foreground-muted" | |
| 218 | − style={{ letterSpacing: 0.4 }} | |
| 219 | − > | |
| 220 | − {props.isExpanded ? "Show less" : `${props.hiddenCount} more`} | |
| 221 | − </Text> | |
| 222 | − </Pressable> | |
| 223 | − ) : null} | |
| 224 | − </View> | |
| 225 | − ); | |
| 226 | −} | |
| 227 | − | |
| 228 | −/* ─── Thread row ─────────────────────────────────────────────────────── */ | |
| 229 | − | |
| 230 | −function ThreadRow(props: { | |
| 231 | − readonly thread: EnvironmentThreadShell; | |
| 232 | − readonly environmentLabel: string | null; | |
| 233 | − readonly onPress: () => void; | |
| 234 | − readonly onArchive: () => void; | |
| 235 | − readonly onDelete: () => void; | |
| 236 | − readonly onSwipeableWillOpen: (methods: SwipeableMethods) => void; | |
| 237 | − readonly onSwipeableClose: (methods: SwipeableMethods) => void; | |
| 238 | − readonly isLast: boolean; | |
| 239 | −}) { | |
| 240 | − const swipeableRef = useRef<SwipeableMethods | null>(null); | |
| 241 | − const fullSwipeArmedRef = useRef(false); | |
| 242 | − const { width: windowWidth } = useWindowDimensions(); | |
| 243 | − const separatorColor = useThemeColor("--color-separator"); | |
| 244 | − const iconSubtleColor = useThemeColor("--color-icon-subtle"); | |
| 245 | − const cardColor = useThemeColor("--color-card"); | |
| 246 | − const fullSwipeThreshold = Math.max(THREAD_SWIPE_ACTIONS_WIDTH + 44, (windowWidth - 32) * 0.58); | |
| 247 | − const { bg, fg } = statusColors(props.thread); | |
| 248 | − const tone = threadStatusTone(props.thread); | |
| 249 | − const timestamp = relativeTime( | |
| 250 | − props.thread.latestUserMessageAt ?? props.thread.updatedAt ?? props.thread.createdAt, | |
| 251 | − ); | |
| 252 | − const branch = props.thread.branch; | |
| 253 | − const subtitleParts = [props.environmentLabel, branch].filter((part): part is string => | |
| 254 | − Boolean(part), | |
| 255 | − ); | |
| 256 | − const handleFullSwipeArmedChange = useCallback((armed: boolean) => { | |
| 257 | − if (armed && !fullSwipeArmedRef.current && process.env.EXPO_OS === "ios") { | |
| 258 | − void Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Medium); | |
| 259 | − } | |
| 260 | − fullSwipeArmedRef.current = armed; | |
| 261 | − }, []); | |
| 262 | − | |
| 263 | − return ( | |
| 264 | − <ReanimatedSwipeable | |
| 265 | − ref={swipeableRef} | |
| 266 | − animationOptions={THREAD_SWIPE_SPRING} | |
| 267 | − childrenContainerStyle={{ backgroundColor: cardColor }} | |
| 268 | − containerStyle={{ backgroundColor: cardColor }} | |
| 269 | − dragOffsetFromRightEdge={8} | |
| 270 | − enableTrackpadTwoFingerGesture | |
| 271 | − friction={1} | |
| 272 | − onSwipeableClose={() => { | |
| 273 | − fullSwipeArmedRef.current = false; | |
| 274 | − if (swipeableRef.current) { | |
| 275 | − props.onSwipeableClose(swipeableRef.current); | |
| 276 | − } | |
| 277 | − }} | |
| 278 | − onSwipeableOpenStartDrag={() => { | |
| 279 | − if (swipeableRef.current) { | |
| 280 | − props.onSwipeableWillOpen(swipeableRef.current); | |
| 281 | − } | |
| 282 | − }} | |
| 283 | − onSwipeableWillOpen={() => { | |
| 284 | − const methods = swipeableRef.current; | |
| 285 | − if (!methods) { | |
| 286 | − return; | |
| 287 | − } | |
| 288 | − | |
| 289 | − props.onSwipeableWillOpen(methods); | |
| 290 | − if (fullSwipeArmedRef.current) { | |
| 291 | − fullSwipeArmedRef.current = false; | |
| 292 | − methods.close(); | |
| 293 | − props.onDelete(); | |
| 294 | − } | |
| 295 | − }} | |
| 296 | − overshootFriction={1} | |
| 297 | − overshootRight | |
| 298 | − renderRightActions={(_progress, translation, methods) => ( | |
| 299 | − <ThreadSwipeActions | |
| 300 | − backgroundColor={cardColor} | |
| 301 | − fullSwipeThreshold={fullSwipeThreshold} | |
| 302 | − onDelete={props.onDelete} | |
| 303 | − onFullSwipeArmedChange={handleFullSwipeArmedChange} | |
| 304 | − primaryAction={{ | |
| 305 | − accessibilityLabel: `Archive ${props.thread.title}`, | |
| 306 | − icon: "archivebox", | |
| 307 | − label: "Archive", | |
| 308 | − onPress: props.onArchive, | |
| 309 | − }} | |
| 310 | − swipeableMethods={methods} | |
| 311 | − threadTitle={props.thread.title} | |
| 312 | − translation={translation} | |
| 313 | − /> | |
| 314 | − )} | |
| 315 | − rightThreshold={THREAD_SWIPE_ACTIONS_WIDTH * 0.42} | |
| 316 | − > | |
| 317 | − <Pressable | |
| 318 | − accessibilityHint="Swipe left for archive and delete actions" | |
| 319 | − accessibilityLabel={props.thread.title} | |
| 320 | − accessibilityRole="button" | |
| 321 | − className="bg-card" | |
| 322 | − onPress={() => { | |
| 323 | − swipeableRef.current?.close(); | |
| 324 | − props.onPress(); | |
| 325 | − }} | |
| 326 | − style={({ pressed }) => ({ opacity: pressed ? 0.7 : 1 })} | |
| 327 | − > | |
| 328 | − <View | |
| 329 | − style={{ | |
| 330 | − flexDirection: "row", | |
| 331 | − paddingLeft: 16, | |
| 332 | − paddingRight: 16, | |
| 333 | − paddingVertical: 10, | |
| 334 | − gap: 12, | |
| 335 | − borderBottomWidth: props.isLast ? 0 : 1, | |
| 336 | − borderBottomColor: separatorColor, | |
| 337 | − }} | |
| 338 | − > | |
| 339 | − <View | |
| 340 | − style={{ | |
| 341 | − width: 30, | |
| 342 | − height: 30, | |
| 343 | − borderRadius: 9, | |
| 344 | − backgroundColor: bg, | |
| 345 | − alignItems: "center", | |
| 346 | − justifyContent: "center", | |
| 347 | − marginTop: 2, | |
| 348 | − }} | |
| 349 | − > | |
| 350 | − <SymbolView name="arrow.triangle.branch" size={13} tintColor={fg} type="monochrome" /> | |
| 351 | − </View> | |
| 352 | − | |
| 353 | − <View style={{ flex: 1, gap: 3 }}> | |
| 354 | − <View className="flex-row items-center justify-between gap-2"> | |
| 355 | − <Text | |
| 356 | − className="flex-1 text-base font-t3-bold leading-[20px] text-foreground" | |
| 357 | − numberOfLines={1} | |
| 358 | − > | |
| 359 | − {props.thread.title} | |
| 360 | − </Text> | |
| 361 | − <View className="flex-row items-center gap-2"> | |
| 362 | − <View | |
| 363 | − className={tone.pillClassName} | |
| 364 | − style={{ borderRadius: 99, paddingHorizontal: 6, paddingVertical: 2 }} | |
| 365 | − > | |
| 366 | − <Text className={`text-3xs font-t3-bold ${tone.textClassName}`}> | |
| 367 | − {tone.label} | |
| 368 | − </Text> | |
| 369 | − </View> | |
| 370 | − <Text | |
| 371 | − className="text-xs text-foreground-tertiary" | |
| 372 | − style={{ fontVariant: ["tabular-nums"], minWidth: 30, textAlign: "right" }} | |
| 373 | − > | |
| 374 | − {timestamp} | |
| 375 | − </Text> | |
| 376 | − </View> | |
| 377 | − </View> | |
| 378 | − | |
| 379 | − {subtitleParts.length > 0 ? ( | |
| 380 | − <View className="flex-row items-center gap-1.5" style={{ marginTop: 1 }}> | |
| 381 | − <SymbolView | |
| 382 | − name="arrow.triangle.branch" | |
| 383 | − size={10} | |
| 384 | − tintColor={iconSubtleColor} | |
| 385 | − type="monochrome" | |
| 386 | − /> | |
| 387 | − <Text | |
| 388 | − className="text-2xs text-foreground-tertiary" | |
| 389 | − numberOfLines={1} | |
| 390 | − style={{ fontFamily: "monospace" }} | |
| 391 | − > | |
| 392 | − {subtitleParts.join(" · ")} | |
| 393 | − </Text> | |
| 394 | − </View> | |
| 395 | − ) : null} | |
| 396 | − </View> | |
| 397 | − </View> | |
| 398 | − </Pressable> | |
| 399 | − </ReanimatedSwipeable> | |
| 400 | − ); | |
| 147 | +function HomeTopContentSpacer(props: { readonly topInset: number }) { | |
| 148 | + return <View style={{ height: props.topInset + CUSTOM_HEADER_HEIGHT }} />; | |
| 401 | 149 | } |
| 402 | 150 | |
| 403 | 151 | /* ─── Main screen ────────────────────────────────────────────────────── */ |
| 404 | 152 | |
| 405 | −function staleCatalogPillLabel(props: { readonly catalogState: WorkspaceState }): string { | |
| 406 | − if (props.catalogState.networkStatus === "offline") { | |
| 407 | − return "You are offline"; | |
| 408 | − } | |
| 409 | − const connectingEnvironments = props.catalogState.connectingEnvironments; | |
| 410 | − if (connectingEnvironments.length === 1) { | |
| 411 | − return `Reconnecting to ${connectingEnvironments[0]!.environmentLabel}`; | |
| 412 | − } | |
| 413 | − if (connectingEnvironments.length > 1) { | |
| 414 | − return `Reconnecting ${connectingEnvironments.length} environments`; | |
| 415 | − } | |
| 416 | − return "Not connected"; | |
| 417 | −} | |
| 418 | − | |
| 419 | −function StaleCatalogStatusPill(props: { | |
| 420 | − readonly catalogState: WorkspaceState; | |
| 421 | − readonly onPress: () => void; | |
| 422 | −}) { | |
| 423 | − const iconColor = useThemeColor("--color-icon-muted"); | |
| 424 | − const label = staleCatalogPillLabel(props); | |
| 425 | − const isReconnecting = props.catalogState.connectingEnvironments.length > 0; | |
| 426 | − | |
| 427 | − return ( | |
| 428 | − <Pressable | |
| 429 | − accessibilityRole="button" | |
| 430 | − onPress={props.onPress} | |
| 431 | − className="flex-row items-center gap-2 rounded-full bg-card px-4 py-2.5" | |
| 432 | − style={{ | |
| 433 | − shadowColor: "#000", | |
| 434 | − shadowOffset: { width: 0, height: 10 }, | |
| 435 | − shadowOpacity: 0.12, | |
| 436 | − shadowRadius: 24, | |
| 437 | − }} | |
| 438 | − > | |
| 439 | − {isReconnecting ? ( | |
| 440 | − <ActivityIndicator color={iconColor} size="small" /> | |
| 441 | − ) : ( | |
| 442 | − <SymbolView | |
| 443 | − name="wifi.slash" | |
| 444 | − size={15} | |
| 445 | − tintColor={iconColor} | |
| 446 | − type="monochrome" | |
| 447 | − weight="semibold" | |
| 448 | − /> | |
| 449 | − )} | |
| 450 | − <Text className="max-w-[260px] text-sm font-t3-bold text-foreground" numberOfLines={1}> | |
| 451 | − {label} | |
| 452 | − </Text> | |
| 453 | − </Pressable> | |
| 454 | − ); | |
| 455 | −} | |
| 456 | − | |
| 457 | 153 | export function HomeScreen(props: HomeScreenProps) { |
| 458 | − const [expandedProjects, setExpandedProjects] = useState<Set<string>>(() => new Set()); | |
| 154 | + const [groupDisplayStates, setGroupDisplayStates] = useState< | |
| 155 | + ReadonlyMap<string, HomeGroupDisplayState> | |
| 156 | + >(() => new Map()); | |
| 459 | 157 | const openSwipeableRef = useRef<SwipeableMethods | null>(null); |
| 158 | + const listRef = useRef<LegendListRef | null>(null); | |
| 460 | 159 | const insets = useSafeAreaInsets(); |
| 461 | 160 | const accentColor = useThemeColor("--color-icon-muted"); |
| 462 | 161 | |
| 463 | − const toggleExpanded = useCallback((key: string) => { | |
| 464 | − setExpandedProjects((prev) => { | |
| 465 | − const next = new Set(prev); | |
| 466 | − if (next.has(key)) next.delete(key); | |
| 467 | − else next.add(key); | |
| 162 | + const updateGroupDisplay = useCallback((key: string, action: HomeGroupDisplayAction) => { | |
| 163 | + setGroupDisplayStates((previous) => { | |
| 164 | + const next = new Map(previous); | |
| 165 | + next.set( | |
| 166 | + key, | |
| 167 | + nextGroupDisplayState(previous.get(key) ?? DEFAULT_GROUP_DISPLAY_STATE, action), | |
| 168 | + ); | |
| 468 | 169 | return next; |
| 469 | 170 | }); |
| 470 | 171 | }, []); |
| @@ -482,11 +183,19 @@ export function HomeScreen(props: HomeScreenProps) { · 11 unmodified lines | ||
| 482 | 183 | } |
| 483 | 184 | }, []); |
| 484 | 185 | |
| 186 | + const handleScrollBeginDrag = useCallback(() => { | |
| 187 | + openSwipeableRef.current?.close(); | |
| 188 | + }, []); | |
| 189 | + const { swipeEnabled, scrollGateHandlers } = useSwipeableScrollGate({ | |
| 190 | + onScrollBeginDrag: handleScrollBeginDrag, | |
| 191 | + }); | |
| 192 | + | |
| 485 | 193 | const projectGroups = useMemo( |
| 486 | 194 | () => |
| 487 | 195 | buildHomeThreadGroups({ |
| 488 | 196 | projects: props.projects, |
| 489 | 197 | threads: props.threads, |
| 198 | + pendingTasks: props.pendingTasks, | |
| 490 | 199 | environmentId: props.selectedEnvironmentId, |
| 491 | 200 | searchQuery: props.searchQuery, |
| 492 | 201 | projectSortOrder: props.projectSortOrder, |
| @@ -494,6 +203,7 @@ export function HomeScreen(props: HomeScreenProps) { · 1 unmodified line | ||
| 494 | 203 | projectGroupingMode: props.projectGroupingMode, |
| 495 | 204 | }), |
| 496 | 205 | [ |
| 206 | + props.pendingTasks, | |
| 497 | 207 | props.projectGroupingMode, |
| 498 | 208 | props.projects, |
| 499 | 209 | props.projectSortOrder, |
| @@ -504,137 +214,236 @@ export function HomeScreen(props: HomeScreenProps) { · 4 unmodified lines | ||
| 504 | 214 | ], |
| 505 | 215 | ); |
| 506 | 216 | |
| 217 | + const hasSearchQuery = props.searchQuery.trim().length > 0; | |
| 218 | + const listLayout = useMemo( | |
| 219 | + () => | |
| 220 | + buildHomeListLayout({ | |
| 221 | + groups: projectGroups, | |
| 222 | + displayStates: groupDisplayStates, | |
| 223 | + showAllThreads: hasSearchQuery, | |
| 224 | + }), | |
| 225 | + [projectGroups, groupDisplayStates, hasSearchQuery], | |
| 226 | + ); | |
| 227 | + | |
| 228 | + const projectCwdByKey = useMemo(() => { | |
| 229 | + const map = new Map<string, string>(); | |
| 230 | + for (const project of props.projects) { | |
| 231 | + map.set(scopedProjectKey(project.environmentId, project.id), project.workspaceRoot); | |
| 232 | + } | |
| 233 | + return map; | |
| 234 | + }, [props.projects]); | |
| 235 | + | |
| 236 | + const extraData = useMemo( | |
| 237 | + () => ({ savedConnectionsById: props.savedConnectionsById, projectCwdByKey }), | |
| 238 | + [props.savedConnectionsById, projectCwdByKey], | |
| 239 | + ); | |
| 240 | + | |
| 241 | + const renderItem = useCallback( | |
| 242 | + ({ item }: LegendListRenderItemProps<HomeListItem>) => { | |
| 243 | + switch (item.type) { | |
| 244 | + case "header": | |
| 245 | + return ( | |
| 246 | + <ThreadListGroupHeader | |
| 247 | + variant="compact" | |
| 248 | + collapsed={item.collapsed} | |
| 249 | + isFirst={item.isFirst} | |
| 250 | + groupKey={item.group.key} | |
| 251 | + onGroupAction={updateGroupDisplay} | |
| 252 | + onNewThread={props.onNewThreadInProject} | |
| 253 | + project={item.group.representative} | |
| 254 | + threadCount={item.group.threads.length + item.group.pendingTasks.length} | |
| 255 | + title={item.group.title} | |
| 256 | + /> | |
| 257 | + ); | |
| 258 | + case "pending-task": | |
| 259 | + return ( | |
| 260 | + <PendingTaskListRow | |
| 261 | + variant="compact" | |
| 262 | + pendingTask={item.pendingTask} | |
| 263 | + environmentLabel={ | |
| 264 | + props.savedConnectionsById[item.pendingTask.message.environmentId] | |
| 265 | + ?.environmentLabel ?? null | |
| 266 | + } | |
| 267 | + isLast={item.isLast} | |
| 268 | + onSelectPendingTask={props.onSelectPendingTask} | |
| 269 | + onDeletePendingTask={props.onDeletePendingTask} | |
| 270 | + /> | |
| 271 | + ); | |
| 272 | + case "thread": { | |
| 273 | + const thread = item.thread; | |
| 274 | + return ( | |
| 275 | + <ThreadListRow | |
| 276 | + variant="compact" | |
| 277 | + thread={thread} | |
| 278 | + environmentLabel={ | |
| 279 | + props.savedConnectionsById[thread.environmentId]?.environmentLabel ?? null | |
| 280 | + } | |
| 281 | + projectCwd={ | |
| 282 | + projectCwdByKey.get(scopedProjectKey(thread.environmentId, thread.projectId)) ?? | |
| 283 | + null | |
| 284 | + } | |
| 285 | + isLast={item.isLast} | |
| 286 | + onArchiveThread={props.onArchiveThread} | |
| 287 | + onDeleteThread={props.onDeleteThread} | |
| 288 | + onSelectThread={props.onSelectThread} | |
| 289 | + onSwipeableClose={handleSwipeableClose} | |
| 290 | + onSwipeableWillOpen={handleSwipeableWillOpen} | |
| 291 | + /> | |
| 292 | + ); | |
| 293 | + } | |
| 294 | + case "show-more": | |
| 295 | + return ( | |
| 296 | + <ThreadListShowMoreRow | |
| 297 | + variant="compact" | |
| 298 | + hiddenCount={item.hiddenCount} | |
| 299 | + canShowLess={item.canShowLess} | |
| 300 | + groupKey={item.groupKey} | |
| 301 | + onGroupAction={updateGroupDisplay} | |
| 302 | + /> | |
| 303 | + ); | |
| 304 | + } | |
| 305 | + }, | |
| 306 | + [ | |
| 307 | + handleSwipeableClose, | |
| 308 | + handleSwipeableWillOpen, | |
| 309 | + projectCwdByKey, | |
| 310 | + props.onArchiveThread, | |
| 311 | + props.onDeletePendingTask, | |
| 312 | + props.onDeleteThread, | |
| 313 | + props.onNewThreadInProject, | |
| 314 | + props.onSelectPendingTask, | |
| 315 | + props.onSelectThread, | |
| 316 | + props.savedConnectionsById, | |
| 317 | + updateGroupDisplay, | |
| 318 | + ], | |
| 319 | + ); | |
| 320 | + | |
| 321 | + const keyExtractor = useCallback((item: HomeListItem) => item.key, []); | |
| 322 | + | |
| 507 | 323 | /* Empty states */ |
| 508 | − const hasAnyThreads = props.threads.some((thread) => thread.archivedAt === null); | |
| 324 | + const hasAnyThreads = | |
| 325 | + props.threads.some((thread) => thread.archivedAt === null) || props.pendingTasks.length > 0; | |
| 509 | 326 | const hasResults = projectGroups.length > 0; |
| 510 | 327 | const selectedEnvironmentLabel = |
| 511 | 328 | props.selectedEnvironmentId === null |
| 512 | 329 | ? null |
| 513 | 330 | : (props.savedConnectionsById[props.selectedEnvironmentId]?.environmentLabel ?? |
| 514 | 331 | "this environment"); |
| 515 | − const hasSearchQuery = props.searchQuery.trim().length > 0; | |
| 516 | − const shouldShowConnectionStatus = | |
| 517 | − props.catalogState.networkStatus === "offline" || | |
| 518 | − props.catalogState.hasConnectingEnvironment || | |
| 519 | − (props.catalogState.hasLoadedShellSnapshot && !props.catalogState.hasReadyEnvironment); | |
| 332 | + const shouldShowConnectionStatus = shouldShowWorkspaceConnectionStatus(props.catalogState); | |
| 520 | 333 | const emptyState = deriveEmptyState({ |
| 521 | 334 | catalogState: props.catalogState, |
| 522 | 335 | projectCount: props.projects.length, |
| 523 | 336 | }); |
| 524 | − | |
| 525 | − return ( | |
| 526 | − <View className="flex-1 bg-screen"> | |
| 527 | − <ScrollView | |
| 528 | − contentInsetAdjustmentBehavior="automatic" | |
| 529 | − showsVerticalScrollIndicator={false} | |
| 530 | − keyboardDismissMode="on-drag" | |
| 531 | − keyboardShouldPersistTaps="handled" | |
| 532 | − onScrollBeginDrag={() => openSwipeableRef.current?.close()} | |
| 533 | − className="flex-1" | |
| 534 | − contentContainerStyle={{ | |
| 535 | − paddingHorizontal: 16, | |
| 536 | − paddingTop: 8, | |
| 537 | − paddingBottom: 24, | |
| 538 | − gap: 20, | |
| 337 | + const connectionStatus = | |
| 338 | + shouldShowConnectionStatus && Platform.OS !== "ios" ? ( | |
| 339 | + <View | |
| 340 | + className="absolute left-0 right-0 items-center" | |
| 341 | + style={{ bottom: Math.max(insets.bottom, 18) + 76 }} | |
| 342 | + > | |
| 343 | + <WorkspaceConnectionStatus state={props.catalogState} onPress={props.onOpenEnvironments} /> | |
| 344 | + </View> | |
| 345 | + ) : null; | |
| 346 | + | |
| 347 | + if (!hasAnyThreads) { | |
| 348 | + return ( | |
| 349 | + <View | |
| 350 | + className="flex-1 items-center justify-center bg-screen px-8" | |
| 351 | + style={{ | |
| 352 | + paddingBottom: Math.max(insets.bottom, 24), | |
| 353 | + paddingTop: Platform.OS === "ios" ? insets.top + 72 : insets.top, | |
| 539 | 354 | }} |
| 540 | 355 | > |
| 541 | − {!hasAnyThreads ? ( | |
| 542 | − <View> | |
| 543 | − <EmptyState | |
| 544 | − title={emptyState.title} | |
| 545 | − detail={emptyState.detail} | |
| 546 | − actionLabel={!props.catalogState.hasReadyEnvironment ? "Add environment" : undefined} | |
| 547 | − onAction={!props.catalogState.hasReadyEnvironment ? props.onAddConnection : undefined} | |
| 548 | − /> | |
| 549 | − {emptyState.loading ? ( | |
| 550 | − <View className="absolute right-5 top-5"> | |
| 551 | − <ActivityIndicator color={accentColor} /> | |
| 552 | − </View> | |
| 553 | − ) : null} | |
| 554 | − </View> | |
| 555 | − ) : !hasResults && hasSearchQuery ? ( | |
| 556 | − <EmptyState title="No results" detail={`No threads matching "${props.searchQuery}".`} /> | |
| 557 | − ) : !hasResults && selectedEnvironmentLabel ? ( | |
| 558 | − <EmptyState | |
| 559 | − title={`No threads in ${selectedEnvironmentLabel}`} | |
| 560 | − detail="Choose another environment or create a new task." | |
| 561 | − /> | |
| 562 | − ) : !hasResults ? ( | |
| 356 | + <View className="w-full max-w-[430px]"> | |
| 563 | 357 | <EmptyState |
| 564 | − title="No threads yet" | |
| 565 | − detail="Create a task to start a new coding session." | |
| 358 | + title={emptyState.title} | |
| 359 | + detail={emptyState.detail} | |
| 360 | + actionLabel={!props.catalogState.hasReadyEnvironment ? "Add environment" : undefined} | |
| 361 | + onAction={!props.catalogState.hasReadyEnvironment ? props.onAddConnection : undefined} | |
| 362 | + variant="plain" | |
| 566 | 363 | /> |
| 567 | − ) : ( | |
| 568 | − projectGroups.map((group) => { | |
| 569 | − const isExpanded = expandedProjects.has(group.key); | |
| 570 | − // Collapsed shows recent threads only; expanding reveals full history. | |
| 571 | − const collapsedThreads = group.recentThreads.slice(0, COLLAPSED_THREAD_LIMIT); | |
| 572 | − const visibleThreads = isExpanded ? group.threads : collapsedThreads; | |
| 573 | − const hiddenCount = group.threads.length - collapsedThreads.length; | |
| 364 | + {emptyState.loading ? ( | |
| 365 | + <View className="mt-4 items-center"> | |
| 366 | + <ActivityIndicator color={accentColor} /> | |
| 367 | + </View> | |
| 368 | + ) : null} | |
| 369 | + </View> | |
| 370 | + {connectionStatus} | |
| 371 | + </View> | |
| 372 | + ); | |
| 373 | + } | |
| 574 | 374 | |
| 575 | − return ( | |
| 576 | − <Animated.View | |
| 577 | − key={group.key} | |
| 578 | − collapsable={false} | |
| 579 | − exiting={threadRowExit} | |
| 580 | − layout={THREAD_LAYOUT_TRANSITION} | |
| 581 | − style={{ overflow: "hidden" }} | |
| 582 | − > | |
| 583 | − <ProjectGroupLabel | |
| 584 | − isExpanded={isExpanded} | |
| 585 | − onNewThread={() => props.onNewThreadInProject(group.representative)} | |
| 586 | − onToggleExpand={() => toggleExpanded(group.key)} | |
| 587 | − project={group.representative} | |
| 588 | − title={group.title} | |
| 589 | − hiddenCount={hiddenCount} | |
| 590 | − /> | |
| 591 | − <View | |
| 592 | − className="overflow-hidden rounded-[20px] bg-card" | |
| 593 | − style={{ borderCurve: "continuous" }} | |
| 594 | − > | |
| 595 | − {visibleThreads.map((thread, i) => { | |
| 596 | − const threadKey = `${thread.environmentId}:${thread.id}`; | |
| 597 | − return ( | |
| 598 | − <Animated.View | |
| 599 | − key={threadKey} | |
| 600 | − collapsable={false} | |
| 601 | − exiting={threadRowExit} | |
| 602 | − layout={THREAD_LAYOUT_TRANSITION} | |
| 603 | − style={{ overflow: "hidden" }} | |
| 604 | − > | |
| 605 | − <ThreadRow | |
| 606 | − thread={thread} | |
| 607 | − environmentLabel={ | |
| 608 | − props.savedConnectionsById[thread.environmentId]?.environmentLabel ?? | |
| 609 | − null | |
| 610 | − } | |
| 611 | − isLast={i === visibleThreads.length - 1} | |
| 612 | − onArchive={() => props.onArchiveThread(thread)} | |
| 613 | − onDelete={() => props.onDeleteThread(thread)} | |
| 614 | − onPress={() => props.onSelectThread(thread)} | |
| 615 | − onSwipeableClose={handleSwipeableClose} | |
| 616 | − onSwipeableWillOpen={handleSwipeableWillOpen} | |
| 617 | − /> | |
| 618 | − </Animated.View> | |
| 619 | − ); | |
| 620 | − })} | |
| 621 | − </View> | |
| 622 | − </Animated.View> | |
| 623 | − ); | |
| 624 | − }) | |
| 625 | − )} | |
| 626 | − </ScrollView> | |
| 627 | − {shouldShowConnectionStatus ? ( | |
| 628 | − <View | |
| 629 | − className="absolute left-0 right-0 items-center" | |
| 630 | − style={{ bottom: Math.max(insets.bottom, 18) + 76 }} | |
| 631 | − > | |
| 632 | − <StaleCatalogStatusPill | |
| 633 | − catalogState={props.catalogState} | |
| 375 | + const listHeader = ( | |
| 376 | + <> | |
| 377 | + {Platform.OS === "ios" ? null : <HomeTopContentSpacer topInset={insets.top} />} | |
| 378 | + | |
| 379 | + {shouldShowConnectionStatus && Platform.OS === "ios" ? ( | |
| 380 | + <View style={{ paddingBottom: 16 }}> | |
| 381 | + <WorkspaceConnectionStatus | |
| 382 | + state={props.catalogState} | |
| 634 | 383 | onPress={props.onOpenEnvironments} |
| 384 | + variant="sidebar" | |
| 635 | 385 | /> |
| 636 | 386 | </View> |
| 637 | 387 | ) : null} |
| 388 | + </> | |
| 389 | + ); | |
| 390 | + | |
| 391 | + const listEmpty = !hasResults ? ( | |
| 392 | + hasSearchQuery ? ( | |
| 393 | + <EmptyState title="No results" detail={`No threads matching "${props.searchQuery}".`} /> | |
| 394 | + ) : selectedEnvironmentLabel ? ( | |
| 395 | + <EmptyState | |
| 396 | + title={`No threads in ${selectedEnvironmentLabel}`} | |
| 397 | + detail="Choose another environment or create a new task." | |
| 398 | + /> | |
| 399 | + ) : ( | |
| 400 | + <EmptyState title="No threads yet" detail="Create a task to start a new coding session." /> | |
| 401 | + ) | |
| 402 | + ) : null; | |
| 403 | + | |
| 404 | + return ( | |
| 405 | + <View className="flex-1 bg-screen"> | |
| 406 | + {/* Sticky headers are deliberately not wired up: LegendList's JS sticky | |
| 407 | + implementation mispositions pinned headers at mount under iOS | |
| 408 | + automatic content insets (headers render one nav-inset too low until | |
| 409 | + the first scroll event) and blanks non-pinned headers after | |
| 410 | + collapse/expand data changes. The flattened layout still exposes | |
| 411 | + `stickyHeaderIndices` if this gets revisited. */} | |
| 412 | + <SwipeableScrollGateProvider enabled={swipeEnabled}> | |
| 413 | + <LegendList | |
| 414 | + ref={listRef} | |
| 415 | + data={listLayout.items} | |
| 416 | + renderItem={renderItem} | |
| 417 | + keyExtractor={keyExtractor} | |
| 418 | + itemsAreEqual={homeListItemsAreEqual} | |
| 419 | + drawDistance={500} | |
| 420 | + estimatedItemSize={ESTIMATED_THREAD_ROW_HEIGHT} | |
| 421 | + extraData={extraData} | |
| 422 | + ListHeaderComponent={listHeader} | |
| 423 | + ListEmptyComponent={listEmpty} | |
| 424 | + style={{ flex: 1 }} | |
| 425 | + automaticallyAdjustsScrollIndicatorInsets={Platform.OS === "ios"} | |
| 426 | + contentInsetAdjustmentBehavior={Platform.OS === "ios" ? "automatic" : "never"} | |
| 427 | + showsVerticalScrollIndicator={false} | |
| 428 | + keyboardDismissMode="on-drag" | |
| 429 | + keyboardShouldPersistTaps="handled" | |
| 430 | + {...scrollGateHandlers} | |
| 431 | + recycleItems | |
| 432 | + scrollEventThrottle={16} | |
| 433 | + contentContainerStyle={{ | |
| 434 | + paddingBottom: Platform.OS === "ios" ? Math.max(insets.bottom, 24) + 24 : 24, | |
| 435 | + }} | |
| 436 | + scrollIndicatorInsets={ | |
| 437 | + Platform.OS === "ios" | |
| 438 | + ? { | |
| 439 | + bottom: Math.max(insets.bottom, 16) + 24, | |
| 440 | + top: 0, | |
| 441 | + } | |
| 442 | + : undefined | |
| 443 | + } | |
| 444 | + /> | |
| 445 | + </SwipeableScrollGateProvider> | |
| 446 | + {connectionStatus} | |
| 638 | 447 | </View> |
| 639 | 448 | ); |
| 640 | 449 | } |
| @@ -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 | +}); | |
| @@ -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 | +} | |
| @@ -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 | +} | |
| @@ -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 | +}); | |
| @@ -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 | +} | |
| @@ -0,0 +1,197 @@ | ||
| 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 | + const threads = Array.from({ length: threadCount }, (_, index) => | |
| 61 | + makeThread(`${key}-thread-${index}`, project.id), | |
| 62 | + ); | |
| 63 | + return { | |
| 64 | + key, | |
| 65 | + title: key, | |
| 66 | + representative: project, | |
| 67 | + projects: [project], | |
| 68 | + pendingTasks: [], | |
| 69 | + threads, | |
| 70 | + // All threads inside the recency window, so the baseline stays at the | |
| 71 | + // initial page size and the pagination expectations below hold. | |
| 72 | + recentThreads: threads, | |
| 73 | + }; | |
| 74 | +} | |
| 75 | + | |
| 76 | +function itemTypes(items: ReadonlyArray<HomeListItem>): string[] { | |
| 77 | + return items.map((item) => item.type); | |
| 78 | +} | |
| 79 | + | |
| 80 | +function displayStates( | |
| 81 | + entries: Record<string, HomeGroupDisplayState>, | |
| 82 | +): ReadonlyMap<string, HomeGroupDisplayState> { | |
| 83 | + return new Map(Object.entries(entries)); | |
| 84 | +} | |
| 85 | + | |
| 86 | +describe("buildHomeListLayout", () => { | |
| 87 | + it("renders a header plus all threads for a small group without a show-more row", () => { | |
| 88 | + const layout = buildHomeListLayout({ | |
| 89 | + groups: [makeGroup("alpha", 3)], | |
| 90 | + displayStates: displayStates({}), | |
| 91 | + }); | |
| 92 | + | |
| 93 | + expect(itemTypes(layout.items)).toEqual(["header", "thread", "thread", "thread"]); | |
| 94 | + expect(layout.stickyHeaderIndices).toEqual([0]); | |
| 95 | + expect(layout.items.at(-1)).toMatchObject({ type: "thread", isLast: true }); | |
| 96 | + }); | |
| 97 | + | |
| 98 | + it("limits large groups to the initial visible count with a show-more row", () => { | |
| 99 | + const layout = buildHomeListLayout({ | |
| 100 | + groups: [makeGroup("alpha", 133)], | |
| 101 | + displayStates: displayStates({}), | |
| 102 | + }); | |
| 103 | + | |
| 104 | + const threadItems = layout.items.filter((item) => item.type === "thread"); | |
| 105 | + expect(threadItems).toHaveLength(HOME_INITIAL_VISIBLE_THREADS); | |
| 106 | + expect(layout.items.at(-1)).toMatchObject({ | |
| 107 | + type: "show-more", | |
| 108 | + groupKey: "alpha", | |
| 109 | + hiddenCount: 133 - HOME_INITIAL_VISIBLE_THREADS, | |
| 110 | + canShowLess: false, | |
| 111 | + }); | |
| 112 | + // The show-more row takes over the last slot, so no thread is marked last. | |
| 113 | + expect(threadItems.every((item) => item.type === "thread" && !item.isLast)).toBe(true); | |
| 114 | + }); | |
| 115 | + | |
| 116 | + it("reveals more threads per show-more step and offers show-less when exhausted", () => { | |
| 117 | + const group = makeGroup("alpha", 20); | |
| 118 | + | |
| 119 | + const expandedOnce = buildHomeListLayout({ | |
| 120 | + groups: [group], | |
| 121 | + displayStates: displayStates({ | |
| 122 | + alpha: nextGroupDisplayState(DEFAULT_GROUP_DISPLAY_STATE, "show-more"), | |
| 123 | + }), | |
| 124 | + }); | |
| 125 | + expect(expandedOnce.items.filter((item) => item.type === "thread")).toHaveLength( | |
| 126 | + HOME_INITIAL_VISIBLE_THREADS + HOME_SHOW_MORE_STEP, | |
| 127 | + ); | |
| 128 | + expect(expandedOnce.items.at(-1)).toMatchObject({ | |
| 129 | + type: "show-more", | |
| 130 | + hiddenCount: 4, | |
| 131 | + canShowLess: true, | |
| 132 | + }); | |
| 133 | + | |
| 134 | + const fullyExpanded = buildHomeListLayout({ | |
| 135 | + groups: [group], | |
| 136 | + displayStates: displayStates({ | |
| 137 | + alpha: nextGroupDisplayState( | |
| 138 | + nextGroupDisplayState(DEFAULT_GROUP_DISPLAY_STATE, "show-more"), | |
| 139 | + "show-more", | |
| 140 | + ), | |
| 141 | + }), | |
| 142 | + }); | |
| 143 | + expect(fullyExpanded.items.filter((item) => item.type === "thread")).toHaveLength(20); | |
| 144 | + expect(fullyExpanded.items.at(-1)).toMatchObject({ | |
| 145 | + type: "show-more", | |
| 146 | + hiddenCount: 0, | |
| 147 | + canShowLess: true, | |
| 148 | + }); | |
| 149 | + | |
| 150 | + const reset = nextGroupDisplayState( | |
| 151 | + nextGroupDisplayState( | |
| 152 | + nextGroupDisplayState(DEFAULT_GROUP_DISPLAY_STATE, "show-more"), | |
| 153 | + "show-more", | |
| 154 | + ), | |
| 155 | + "show-less", | |
| 156 | + ); | |
| 157 | + expect(reset.visibleCount).toBe(HOME_INITIAL_VISIBLE_THREADS); | |
| 158 | + }); | |
| 159 | + | |
| 160 | + it("hides threads and the show-more row for collapsed groups", () => { | |
| 161 | + const layout = buildHomeListLayout({ | |
| 162 | + groups: [makeGroup("alpha", 12), makeGroup("beta", 2)], | |
| 163 | + displayStates: displayStates({ | |
| 164 | + alpha: nextGroupDisplayState(DEFAULT_GROUP_DISPLAY_STATE, "toggle-collapsed"), | |
| 165 | + }), | |
| 166 | + }); | |
| 167 | + | |
| 168 | + expect(itemTypes(layout.items)).toEqual(["header", "header", "thread", "thread"]); | |
| 169 | + expect(layout.items[0]).toMatchObject({ type: "header", collapsed: true, isFirst: true }); | |
| 170 | + expect(layout.items[1]).toMatchObject({ type: "header", collapsed: false, isFirst: false }); | |
| 171 | + expect(layout.stickyHeaderIndices).toEqual([0, 1]); | |
| 172 | + }); | |
| 173 | + | |
| 174 | + it("suspends collapse and pagination while searching", () => { | |
| 175 | + const layout = buildHomeListLayout({ | |
| 176 | + groups: [makeGroup("alpha", 12)], | |
| 177 | + displayStates: displayStates({ | |
| 178 | + alpha: nextGroupDisplayState(DEFAULT_GROUP_DISPLAY_STATE, "toggle-collapsed"), | |
| 179 | + }), | |
| 180 | + showAllThreads: true, | |
| 181 | + }); | |
| 182 | + | |
| 183 | + expect(layout.items.filter((item) => item.type === "thread")).toHaveLength(12); | |
| 184 | + expect(layout.items.some((item) => item.type === "show-more")).toBe(false); | |
| 185 | + }); | |
| 186 | + | |
| 187 | + it("keeps sticky indices aligned across multiple expanded groups", () => { | |
| 188 | + const layout = buildHomeListLayout({ | |
| 189 | + groups: [makeGroup("alpha", 8), makeGroup("beta", 1)], | |
| 190 | + displayStates: displayStates({}), | |
| 191 | + }); | |
| 192 | + | |
| 193 | + // header + 6 threads + show-more = 8 items, so beta's header is index 8. | |
| 194 | + expect(layout.stickyHeaderIndices).toEqual([0, 8]); | |
| 195 | + expect(layout.items[8]).toMatchObject({ type: "header", isFirst: false }); | |
| 196 | + }); | |
| 197 | +}); | |
| @@ -0,0 +1,204 @@ | ||
| 1 | +import type { EnvironmentThreadShell } from "@t3tools/client-runtime/state/shell"; | |
| 2 | + | |
| 3 | +import type { PendingNewTask } from "../../state/use-pending-new-tasks"; | |
| 4 | +import type { HomeThreadGroup } from "./homeThreadList"; | |
| 5 | + | |
| 6 | +/** Threads shown per project before the "Show more" affordance appears. */ | |
| 7 | +export const HOME_INITIAL_VISIBLE_THREADS = 6; | |
| 8 | +/** Additional threads revealed per "Show more" tap. */ | |
| 9 | +export const HOME_SHOW_MORE_STEP = 10; | |
| 10 | + | |
| 11 | +export interface HomeGroupDisplayState { | |
| 12 | + readonly collapsed: boolean; | |
| 13 | + /** How many threads are currently revealed (clamped to the group size). */ | |
| 14 | + readonly visibleCount: number; | |
| 15 | +} | |
| 16 | + | |
| 17 | +export const DEFAULT_GROUP_DISPLAY_STATE: HomeGroupDisplayState = { | |
| 18 | + collapsed: false, | |
| 19 | + visibleCount: HOME_INITIAL_VISIBLE_THREADS, | |
| 20 | +}; | |
| 21 | + | |
| 22 | +export interface HomeHeaderListItem { | |
| 23 | + readonly type: "header"; | |
| 24 | + readonly key: string; | |
| 25 | + readonly group: HomeThreadGroup; | |
| 26 | + readonly collapsed: boolean; | |
| 27 | + readonly isFirst: boolean; | |
| 28 | +} | |
| 29 | + | |
| 30 | +export interface HomeThreadListItem { | |
| 31 | + readonly type: "thread"; | |
| 32 | + readonly key: string; | |
| 33 | + readonly thread: EnvironmentThreadShell; | |
| 34 | + readonly isLast: boolean; | |
| 35 | +} | |
| 36 | + | |
| 37 | +export interface HomePendingTaskListItem { | |
| 38 | + readonly type: "pending-task"; | |
| 39 | + readonly key: string; | |
| 40 | + readonly pendingTask: PendingNewTask; | |
| 41 | + readonly isLast: boolean; | |
| 42 | +} | |
| 43 | + | |
| 44 | +export interface HomeShowMoreListItem { | |
| 45 | + readonly type: "show-more"; | |
| 46 | + readonly key: string; | |
| 47 | + readonly groupKey: string; | |
| 48 | + /** Threads still hidden. 0 means the group is fully expanded. */ | |
| 49 | + readonly hiddenCount: number; | |
| 50 | + /** Whether more than the initial count is revealed, so "Show less" applies. */ | |
| 51 | + readonly canShowLess: boolean; | |
| 52 | +} | |
| 53 | + | |
| 54 | +export type HomeListItem = | |
| 55 | + | HomeHeaderListItem | |
| 56 | + | HomePendingTaskListItem | |
| 57 | + | HomeThreadListItem | |
| 58 | + | HomeShowMoreListItem; | |
| 59 | + | |
| 60 | +export interface HomeListLayout { | |
| 61 | + readonly items: ReadonlyArray<HomeListItem>; | |
| 62 | + readonly stickyHeaderIndices: ReadonlyArray<number>; | |
| 63 | +} | |
| 64 | + | |
| 65 | +export type HomeGroupDisplayAction = "toggle-collapsed" | "show-more" | "show-less"; | |
| 66 | + | |
| 67 | +export function nextGroupDisplayState( | |
| 68 | + current: HomeGroupDisplayState, | |
| 69 | + action: HomeGroupDisplayAction, | |
| 70 | +): HomeGroupDisplayState { | |
| 71 | + switch (action) { | |
| 72 | + case "toggle-collapsed": | |
| 73 | + return { ...current, collapsed: !current.collapsed }; | |
| 74 | + case "show-more": | |
| 75 | + return { ...current, visibleCount: current.visibleCount + HOME_SHOW_MORE_STEP }; | |
| 76 | + case "show-less": | |
| 77 | + return { ...current, visibleCount: HOME_INITIAL_VISIBLE_THREADS }; | |
| 78 | + } | |
| 79 | +} | |
| 80 | + | |
| 81 | +/** | |
| 82 | + * Structural equality for list items. Item objects are rebuilt on every | |
| 83 | + * collapse/show-more toggle; without this the lists would consider every | |
| 84 | + * mounted row changed and re-render all of them (each carrying a swipeable + | |
| 85 | + * a vcs-status subscription). Group/thread references are stable across | |
| 86 | + * toggles. | |
| 87 | + */ | |
| 88 | +export function homeListItemsAreEqual(previous: HomeListItem, item: HomeListItem): boolean { | |
| 89 | + switch (item.type) { | |
| 90 | + case "header": | |
| 91 | + return ( | |
| 92 | + previous.type === "header" && | |
| 93 | + previous.group === item.group && | |
| 94 | + previous.collapsed === item.collapsed && | |
| 95 | + previous.isFirst === item.isFirst | |
| 96 | + ); | |
| 97 | + case "pending-task": | |
| 98 | + return ( | |
| 99 | + previous.type === "pending-task" && | |
| 100 | + previous.pendingTask === item.pendingTask && | |
| 101 | + previous.isLast === item.isLast | |
| 102 | + ); | |
| 103 | + case "thread": | |
| 104 | + return ( | |
| 105 | + previous.type === "thread" && | |
| 106 | + previous.thread === item.thread && | |
| 107 | + previous.isLast === item.isLast | |
| 108 | + ); | |
| 109 | + case "show-more": | |
| 110 | + return ( | |
| 111 | + previous.type === "show-more" && | |
| 112 | + previous.groupKey === item.groupKey && | |
| 113 | + previous.hiddenCount === item.hiddenCount && | |
| 114 | + previous.canShowLess === item.canShowLess | |
| 115 | + ); | |
| 116 | + } | |
| 117 | +} | |
| 118 | + | |
| 119 | +export function buildHomeListLayout(input: { | |
| 120 | + readonly groups: ReadonlyArray<HomeThreadGroup>; | |
| 121 | + readonly displayStates: ReadonlyMap<string, HomeGroupDisplayState>; | |
| 122 | + /** | |
| 123 | + * When searching, pagination is suspended so every match stays visible. | |
| 124 | + */ | |
| 125 | + readonly showAllThreads?: boolean; | |
| 126 | +}): HomeListLayout { | |
| 127 | + const items: HomeListItem[] = []; | |
| 128 | + const stickyHeaderIndices: number[] = []; | |
| 129 | + | |
| 130 | + for (const [groupIndex, group] of input.groups.entries()) { | |
| 131 | + const display = input.displayStates.get(group.key) ?? DEFAULT_GROUP_DISPLAY_STATE; | |
| 132 | + const collapsed = display.collapsed && input.showAllThreads !== true; | |
| 133 | + | |
| 134 | + stickyHeaderIndices.push(items.length); | |
| 135 | + items.push({ | |
| 136 | + type: "header", | |
| 137 | + key: `header:${group.key}`, | |
| 138 | + group, | |
| 139 | + collapsed, | |
| 140 | + isFirst: groupIndex === 0, | |
| 141 | + }); | |
| 142 | + | |
| 143 | + if (collapsed) { | |
| 144 | + continue; | |
| 145 | + } | |
| 146 | + | |
| 147 | + const totalCount = group.threads.length; | |
| 148 | + // Default to the group's recent-activity window (last few days, or a small | |
| 149 | + // fallback for stale projects), capped at the initial page size. Until the | |
| 150 | + // user taps "Show more", older threads stay hidden to save vertical space; | |
| 151 | + // "Show less" resets visibleCount to the initial constant, which lands back | |
| 152 | + // here at the recency baseline. | |
| 153 | + const baselineCount = Math.min( | |
| 154 | + group.recentThreads.length, | |
| 155 | + HOME_INITIAL_VISIBLE_THREADS, | |
| 156 | + totalCount, | |
| 157 | + ); | |
| 158 | + const visibleCount = input.showAllThreads | |
| 159 | + ? totalCount | |
| 160 | + : Math.min( | |
| 161 | + display.visibleCount > HOME_INITIAL_VISIBLE_THREADS | |
| 162 | + ? display.visibleCount | |
| 163 | + : baselineCount, | |
| 164 | + totalCount, | |
| 165 | + ); | |
| 166 | + const visibleThreads = group.threads.slice(0, visibleCount); | |
| 167 | + const hiddenCount = totalCount - visibleCount; | |
| 168 | + const hasShowMoreRow = !input.showAllThreads && totalCount > baselineCount; | |
| 169 | + | |
| 170 | + // Pending (unsent) tasks lead the group and are never paginated away. | |
| 171 | + for (const [pendingIndex, pendingTask] of group.pendingTasks.entries()) { | |
| 172 | + items.push({ | |
| 173 | + type: "pending-task", | |
| 174 | + key: `pending-task:${pendingTask.message.messageId}`, | |
| 175 | + pendingTask, | |
| 176 | + isLast: | |
| 177 | + pendingIndex === group.pendingTasks.length - 1 && | |
| 178 | + visibleThreads.length === 0 && | |
| 179 | + !hasShowMoreRow, | |
| 180 | + }); | |
| 181 | + } | |
| 182 | + | |
| 183 | + for (const [threadIndex, thread] of visibleThreads.entries()) { | |
| 184 | + items.push({ | |
| 185 | + type: "thread", | |
| 186 | + key: `thread:${thread.environmentId}:${thread.id}`, | |
| 187 | + thread, | |
| 188 | + isLast: threadIndex === visibleThreads.length - 1 && !hasShowMoreRow, | |
| 189 | + }); | |
| 190 | + } | |
| 191 | + | |
| 192 | + if (hasShowMoreRow) { | |
| 193 | + items.push({ | |
| 194 | + type: "show-more", | |
| 195 | + key: `show-more:${group.key}`, | |
| 196 | + groupKey: group.key, | |
| 197 | + hiddenCount, | |
| 198 | + canShowLess: visibleCount > HOME_INITIAL_VISIBLE_THREADS, | |
| 199 | + }); | |
| 200 | + } | |
| 201 | + } | |
| 202 | + | |
| 203 | + return { items, stickyHeaderIndices }; | |
| 204 | +} | |
| @@ -17,6 +17,7 @@ import * as Arr from "effect/Array"; · 16 unmodified lines | ||
| 17 | 17 | import * as Order from "effect/Order"; |
| 18 | 18 | |
| 19 | 19 | import { scopedProjectKey } from "../../lib/scopedEntities"; |
| 20 | +import type { PendingNewTask } from "../../state/use-pending-new-tasks"; | |
| 20 | 21 | |
| 21 | 22 | export type HomeProjectSortOrder = Exclude<SidebarProjectSortOrder, "manual">; |
| 22 | 23 | |
| @@ -33,6 +34,7 @@ export interface HomeThreadGroup { · 10 unmodified lines | ||
| 33 | 34 | readonly title: string; |
| 34 | 35 | readonly representative: EnvironmentProject; |
| 35 | 36 | readonly projects: ReadonlyArray<EnvironmentProject>; |
| 37 | + readonly pendingTasks: ReadonlyArray<PendingNewTask>; | |
| 36 | 38 | /** Full sorted thread history for the group (revealed when expanded / searching). */ |
| 37 | 39 | readonly threads: ReadonlyArray<EnvironmentThreadShell>; |
| 38 | 40 | /** Subset shown by default: threads from the last few days, or the most recent few. */ |
| @@ -42,14 +44,19 @@ export interface HomeThreadGroup { · 3 unmodified lines | ||
| 42 | 44 | interface MutableHomeThreadGroup { |
| 43 | 45 | readonly key: string; |
| 44 | 46 | readonly projects: EnvironmentProject[]; |
| 47 | + readonly pendingTasks: PendingNewTask[]; | |
| 45 | 48 | readonly threads: EnvironmentThreadShell[]; |
| 46 | 49 | } |
| 47 | 50 | |
| 48 | 51 | function groupSortTimestamp(group: HomeThreadGroup, sortOrder: HomeProjectSortOrder): number { |
| 49 | − return group.threads.reduce( | |
| 52 | + const latestThread = group.threads.reduce( | |
| 50 | 53 | (latest, thread) => Math.max(latest, getThreadSortTimestamp(thread, sortOrder)), |
| 51 | 54 | Number.NEGATIVE_INFINITY, |
| 52 | 55 | ); |
| 56 | + return group.pendingTasks.reduce((latest, pendingTask) => { | |
| 57 | + const timestamp = Date.parse(pendingTask.message.createdAt); | |
| 58 | + return Number.isNaN(timestamp) ? latest : Math.max(latest, timestamp); | |
| 59 | + }, latestThread); | |
| 53 | 60 | } |
| 54 | 61 | |
| 55 | 62 | /** |
| @@ -73,6 +80,7 @@ function selectRecentThreads( · 17 unmodified lines | ||
| 73 | 80 | export function buildHomeThreadGroups(input: { |
| 74 | 81 | readonly projects: ReadonlyArray<EnvironmentProject>; |
| 75 | 82 | readonly threads: ReadonlyArray<EnvironmentThreadShell>; |
| 83 | + readonly pendingTasks?: ReadonlyArray<PendingNewTask>; | |
| 76 | 84 | readonly environmentId: EnvironmentId | null; |
| 77 | 85 | readonly searchQuery: string; |
| 78 | 86 | readonly projectSortOrder: HomeProjectSortOrder; |
| @@ -100,10 +108,49 @@ export function buildHomeThreadGroups(input: { · 21 unmodified lines | ||
| 100 | 108 | if (existing) { |
| 101 | 109 | existing.projects.push(project); |
| 102 | 110 | } else { |
| 103 | − groups.set(groupKey, { key: groupKey, projects: [project], threads: [] }); | |
| 111 | + groups.set(groupKey, { key: groupKey, projects: [project], pendingTasks: [], threads: [] }); | |
| 104 | 112 | } |
| 105 | 113 | } |
| 106 | 114 | |
| 115 | + for (const pendingTask of input.pendingTasks ?? []) { | |
| 116 | + if (input.environmentId !== null && pendingTask.message.environmentId !== input.environmentId) { | |
| 117 | + continue; | |
| 118 | + } | |
| 119 | + | |
| 120 | + const physicalKey = scopedProjectKey( | |
| 121 | + pendingTask.message.environmentId, | |
| 122 | + pendingTask.creation.projectId, | |
| 123 | + ); | |
| 124 | + let groupKey = groupKeyByProjectKey.get(physicalKey); | |
| 125 | + if (!groupKey) { | |
| 126 | + // The project shell is not loaded (environment offline / project gone). | |
| 127 | + // A queued task must stay visible and deletable regardless, so build a | |
| 128 | + // standalone group from the metadata snapshotted at enqueue time. | |
| 129 | + groupKey = `pending-project:${physicalKey}`; | |
| 130 | + groupKeyByProjectKey.set(physicalKey, groupKey); | |
| 131 | + groups.set(groupKey, { | |
| 132 | + key: groupKey, | |
| 133 | + projects: [ | |
| 134 | + { | |
| 135 | + environmentId: pendingTask.message.environmentId, | |
| 136 | + id: pendingTask.creation.projectId, | |
| 137 | + title: pendingTask.creation.projectTitle ?? "Unknown project", | |
| 138 | + workspaceRoot: | |
| 139 | + pendingTask.creation.projectCwd ?? String(pendingTask.creation.projectId), | |
| 140 | + repositoryIdentity: null, | |
| 141 | + defaultModelSelection: null, | |
| 142 | + scripts: [], | |
| 143 | + createdAt: pendingTask.message.createdAt, | |
| 144 | + updatedAt: pendingTask.message.createdAt, | |
| 145 | + }, | |
| 146 | + ], | |
| 147 | + pendingTasks: [], | |
| 148 | + threads: [], | |
| 149 | + }); | |
| 150 | + } | |
| 151 | + groups.get(groupKey)?.pendingTasks.push(pendingTask); | |
| 152 | + } | |
| 153 | + | |
| 107 | 154 | for (const thread of input.threads) { |
| 108 | 155 | if (thread.archivedAt !== null) { |
| 109 | 156 | continue; |
| @@ -125,7 +172,7 @@ export function buildHomeThreadGroups(input: { · 15 unmodified lines | ||
| 125 | 172 | |
| 126 | 173 | for (const group of groups.values()) { |
| 127 | 174 | const representative = group.projects[0]; |
| 128 | − if (!representative || group.threads.length === 0) { | |
| 175 | + if (!representative || (group.threads.length === 0 && group.pendingTasks.length === 0)) { | |
| 129 | 176 | continue; |
| 130 | 177 | } |
| 131 | 178 | |
| @@ -140,8 +187,13 @@ export function buildHomeThreadGroups(input: { · 8 unmodified lines | ||
| 140 | 187 | const matchingThreads = groupMatches |
| 141 | 188 | ? group.threads |
| 142 | 189 | : group.threads.filter((thread) => thread.title.toLocaleLowerCase().includes(query)); |
| 190 | + const matchingPendingTasks = groupMatches | |
| 191 | + ? group.pendingTasks | |
| 192 | + : group.pendingTasks.filter((pendingTask) => | |
| 193 | + pendingTask.title.toLocaleLowerCase().includes(query), | |
| 194 | + ); | |
| 143 | 195 | |
| 144 | − if (matchingThreads.length === 0) { | |
| 196 | + if (matchingThreads.length === 0 && matchingPendingTasks.length === 0) { | |
| 145 | 197 | continue; |
| 146 | 198 | } |
| 147 | 199 | |
| @@ -158,6 +210,7 @@ export function buildHomeThreadGroups(input: { · 10 unmodified lines | ||
| 158 | 210 | title, |
| 159 | 211 | representative, |
| 160 | 212 | projects: group.projects, |
| 213 | + pendingTasks: matchingPendingTasks, | |
| 161 | 214 | threads: sortedThreads, |
| 162 | 215 | recentThreads, |
| 163 | 216 | }); |
| @@ -1,8 +1,26 @@ | ||
| 1 | 1 | import { SymbolView } from "expo-symbols"; |
| 2 | −import type { ComponentProps } from "react"; | |
| 3 | −import type { ColorValue } from "react-native"; | |
| 2 | +import * as Haptics from "expo-haptics"; | |
| 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 | 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 | 24 | import Animated, { |
| 7 | 25 | Extrapolation, |
| 8 | 26 | interpolate, |
| @@ -26,6 +44,231 @@ export const THREAD_SWIPE_SPRING = { · 17 unmodified lines | ||
| 26 | 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 | 272 | function SwipeActionButton(props: { |
| 30 | 273 | readonly accessibilityLabel: string; |
| 31 | 274 | readonly backgroundColor: string; |
| @@ -179,12 +422,7 @@ export function ThreadSwipeActions(props: { · 147 unmodified lines | ||
| 179 | 422 | readonly fullSwipeThreshold: number; |
| 180 | 423 | readonly onDelete: () => void; |
| 181 | 424 | readonly onFullSwipeArmedChange: (armed: boolean) => void; |
| 182 | − readonly primaryAction: { | |
| 183 | − readonly accessibilityLabel: string; | |
| 184 | − readonly icon: ComponentProps<typeof SymbolView>["name"]; | |
| 185 | − readonly label: string; | |
| 186 | − readonly onPress: () => void; | |
| 187 | − }; | |
| 425 | + readonly primaryAction: ThreadSwipePrimaryAction; | |
| 188 | 426 | readonly swipeableMethods: SwipeableMethods; |
| 189 | 427 | readonly threadTitle: string; |
| 190 | 428 | readonly translation: SharedValue<number>; |
| @@ -0,0 +1,57 @@ | ||
| 1 | +import { useNavigation } from "@react-navigation/native"; | |
| 2 | +import { useCallback } from "react"; | |
| 3 | +import { Alert } from "react-native"; | |
| 4 | + | |
| 5 | +import { removeThreadOutboxMessage } from "../../state/thread-outbox"; | |
| 6 | +import type { PendingNewTask } from "../../state/use-pending-new-tasks"; | |
| 7 | +import { releaseEditingQueuedMessage } from "../../state/use-thread-outbox"; | |
| 8 | + | |
| 9 | +export function usePendingTaskListActions(): { | |
| 10 | + readonly openPendingTask: (pendingTask: PendingNewTask) => void; | |
| 11 | + readonly confirmDeletePendingTask: (pendingTask: PendingNewTask) => void; | |
| 12 | +} { | |
| 13 | + const navigation = useNavigation(); | |
| 14 | + | |
| 15 | + const openPendingTask = useCallback( | |
| 16 | + (pendingTask: PendingNewTask) => { | |
| 17 | + navigation.navigate("NewTaskSheet", { | |
| 18 | + screen: "NewTaskDraft", | |
| 19 | + params: { | |
| 20 | + environmentId: String(pendingTask.message.environmentId), | |
| 21 | + projectId: String(pendingTask.creation.projectId), | |
| 22 | + pendingTaskId: String(pendingTask.message.messageId), | |
| 23 | + }, | |
| 24 | + }); | |
| 25 | + }, | |
| 26 | + [navigation], | |
| 27 | + ); | |
| 28 | + | |
| 29 | + const confirmDeletePendingTask = useCallback((pendingTask: PendingNewTask) => { | |
| 30 | + Alert.alert( | |
| 31 | + "Delete pending task?", | |
| 32 | + `“${pendingTask.title}” has not been sent yet and will be removed from the outbox.`, | |
| 33 | + [ | |
| 34 | + { text: "Cancel", style: "cancel" }, | |
| 35 | + { | |
| 36 | + text: "Delete", | |
| 37 | + style: "destructive", | |
| 38 | + onPress: () => { | |
| 39 | + // Release the edit lock only after removal succeeds, and only if | |
| 40 | + // it is held for THIS task — clearing it up front (or for another | |
| 41 | + // task) would let the drain deliver a mid-edit payload. | |
| 42 | + void removeThreadOutboxMessage(pendingTask.message) | |
| 43 | + .then(() => releaseEditingQueuedMessage(pendingTask.message.messageId)) | |
| 44 | + .catch((error) => { | |
| 45 | + Alert.alert( | |
| 46 | + "Could not delete pending task", | |
| 47 | + error instanceof Error ? error.message : "The pending task could not be removed.", | |
| 48 | + ); | |
| 49 | + }); | |
| 50 | + }, | |
| 51 | + }, | |
| 52 | + ], | |
| 53 | + ); | |
| 54 | + }, []); | |
| 55 | + | |
| 56 | + return { openPendingTask, confirmDeletePendingTask }; | |
| 57 | +} | |
| @@ -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 | +} | |
| @@ -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 | +} | |
| @@ -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 | +}); | |
| @@ -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 | +} | |
| @@ -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 | +} | |
| @@ -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 | +} | |
| @@ -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 | +} | |
| @@ -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 | +} | |
| @@ -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 | +} | |
| @@ -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; | |
| @@ -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 | +}); | |
| @@ -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 | +} | |
| @@ -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 | +} | |
| @@ -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 | +} | |
| @@ -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 | +} | |
| @@ -22,7 +22,7 @@ import { · 21 unmodified lines | ||
| 22 | 22 | isFilesystemBrowseQuery, |
| 23 | 23 | } from "@t3tools/client-runtime/state/projects"; |
| 24 | 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 | 26 | import { SymbolView } from "expo-symbols"; |
| 27 | 27 | import { useCallback, useEffect, useMemo, useState, type ReactNode } from "react"; |
| 28 | 28 | import { ActivityIndicator, Alert, Pressable, ScrollView, View } from "react-native"; |
| @@ -168,9 +168,9 @@ function ListRow(props: { · 139 unmodified lines | ||
| 168 | 168 | {props.icon} |
| 169 | 169 | </View> |
| 170 | 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 | 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 | 174 | {props.subtitle} |
| 175 | 175 | </Text> |
| 176 | 176 | ) : null} |
| @@ -215,7 +215,7 @@ function ProjectPathInput(props: { · 38 unmodified lines | ||
| 215 | 215 | }) { |
| 216 | 216 | return ( |
| 217 | 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 | 219 | value={props.value} |
| 220 | 220 | onChangeText={props.onChangeText} |
| 221 | 221 | autoCapitalize="none" |
| @@ -250,35 +250,31 @@ function useSelectedEnvironment(): { · 28 unmodified lines | ||
| 250 | 250 | readonly selectedEnvironment: EnvironmentOption | null; |
| 251 | 251 | readonly setSelectedEnvironmentId: (environmentId: EnvironmentId) => void; |
| 252 | 252 | } { |
| 253 | − const router = useRouter(); | |
| 254 | − const params = useLocalSearchParams<{ environmentId?: string }>(); | |
| 253 | + const [selectedEnvironmentId, setSelectedEnvironmentId] = useState<EnvironmentId | null>(null); | |
| 255 | 254 | const environmentOptions = useEnvironmentOptions(); |
| 256 | − const requestedEnvironmentId = stringParam(params.environmentId) as EnvironmentId | null; | |
| 257 | 255 | const selectedEnvironment = |
| 258 | − environmentOptions.find( | |
| 259 | − (environment) => environment.environmentId === requestedEnvironmentId, | |
| 260 | − ) ?? | |
| 256 | + environmentOptions.find((environment) => environment.environmentId === selectedEnvironmentId) ?? | |
| 261 | 257 | environmentOptions[0] ?? |
| 262 | 258 | null; |
| 263 | 259 | |
| 264 | 260 | return { |
| 265 | 261 | environmentOptions, |
| 266 | 262 | selectedEnvironment, |
| 267 | − setSelectedEnvironmentId: (environmentId) => router.setParams({ environmentId }), | |
| 263 | + setSelectedEnvironmentId, | |
| 268 | 264 | }; |
| 269 | 265 | } |
| 270 | 266 | |
| 271 | 267 | function EmptyEnvironmentState() { |
| 272 | − const router = useRouter(); | |
| 268 | + const navigation = useNavigation(); | |
| 273 | 269 | |
| 274 | 270 | return ( |
| 275 | 271 | <View className="items-center gap-3 rounded-2xl bg-card px-5 py-8"> |
| 276 | 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 | 274 | Add an environment before adding a project. |
| 279 | 275 | </Text> |
| 280 | 276 | <Pressable |
| 281 | − onPress={() => router.replace("/connections/new")} | |
| 277 | + onPress={() => navigation.dispatch(StackActions.replace("ConnectionsNew"))} | |
| 282 | 278 | className="mt-1 rounded-full bg-primary px-4 py-2.5 active:opacity-70" |
| 283 | 279 | > |
| 284 | 280 | <Text className="text-sm font-t3-bold text-primary-foreground">Add environment</Text> |
| @@ -294,7 +290,7 @@ function SourceControlRow(props: { · 9 unmodified lines | ||
| 294 | 290 | readonly hint: string; |
| 295 | 291 | readonly isFirst: boolean; |
| 296 | 292 | }) { |
| 297 | − const router = useRouter(); | |
| 293 | + const navigation = useNavigation(); | |
| 298 | 294 | const iconColor = useThemeColor("--color-icon"); |
| 299 | 295 | const title = |
| 300 | 296 | props.source === "url" ? "Git URL" : `${addProjectRemoteSourceLabel(props.source)} repository`; |
| @@ -322,8 +318,8 @@ function SourceControlRow(props: { · 21 unmodified lines | ||
| 322 | 318 | icon={icon} |
| 323 | 319 | isFirst={props.isFirst} |
| 324 | 320 | onPress={() => |
| 325 | − router.push({ | |
| 326 | − pathname: "/new/add-project/repository", | |
| 321 | + navigation.navigate("NewTaskSheet", { | |
| 322 | + screen: "AddProjectRepository", | |
| 327 | 323 | params: { |
| 328 | 324 | environmentId: props.selectedEnvironmentId, |
| 329 | 325 | source: props.source, |
| @@ -335,7 +331,7 @@ function SourceControlRow(props: { · 5 unmodified lines | ||
| 335 | 331 | } |
| 336 | 332 | |
| 337 | 333 | export function AddProjectSourceScreen() { |
| 338 | − const router = useRouter(); | |
| 334 | + const navigation = useNavigation(); | |
| 339 | 335 | const accentColor = useThemeColor("--color-icon-muted"); |
| 340 | 336 | const iconColor = useThemeColor("--color-icon"); |
| 341 | 337 | const { environmentOptions, selectedEnvironment, setSelectedEnvironmentId } = |
| @@ -409,9 +405,11 @@ export function AddProjectSourceScreen() { · 67 unmodified lines | ||
| 409 | 405 | } |
| 410 | 406 | isFirst |
| 411 | 407 | onPress={() => |
| 412 | − router.push({ | |
| 413 | − pathname: "/new/add-project/local", | |
| 414 | − params: { environmentId: selectedEnvironment.environmentId }, | |
| 408 | + navigation.navigate("NewTaskSheet", { | |
| 409 | + screen: "AddProjectLocal", | |
| 410 | + params: { | |
| 411 | + environmentId: selectedEnvironment.environmentId, | |
| 412 | + }, | |
| 415 | 413 | }) |
| 416 | 414 | } |
| 417 | 415 | /> |
| @@ -440,7 +438,7 @@ export function AddProjectSourceScreen() { · 22 unmodified lines | ||
| 440 | 438 | } |
| 441 | 439 | |
| 442 | 440 | function useCreateProject(environment: EnvironmentOption | null) { |
| 443 | − const router = useRouter(); | |
| 441 | + const navigation = useNavigation(); | |
| 444 | 442 | const createProject = useAtomCommand(projectEnvironment.create, { reportFailure: false }); |
| 445 | 443 | const projects = useProjects(); |
| 446 | 444 | |
| @@ -455,14 +453,13 @@ function useCreateProject(environment: EnvironmentOption | null) { · 8 unmodified lines | ||
| 455 | 453 | }); |
| 456 | 454 | if (existing) { |
| 457 | 455 | Alert.alert("Project already exists", existing.title); |
| 458 | − router.replace({ | |
| 459 | − pathname: "/new/draft", | |
| 460 | − params: { | |
| 456 | + navigation.dispatch( | |
| 457 | + StackActions.replace("NewTaskDraft", { | |
| 461 | 458 | environmentId: existing.environmentId, |
| 462 | 459 | projectId: existing.id, |
| 463 | 460 | title: existing.title, |
| 464 | − }, | |
| 465 | − }); | |
| 461 | + }), | |
| 462 | + ); | |
| 466 | 463 | return; |
| 467 | 464 | } |
| 468 | 465 | |
| @@ -480,24 +477,24 @@ function useCreateProject(environment: EnvironmentOption | null) { · 11 unmodified lines | ||
| 480 | 477 | if (AsyncResult.isFailure(result)) { |
| 481 | 478 | return result; |
| 482 | 479 | } |
| 483 | − router.replace({ | |
| 484 | − pathname: "/new/draft", | |
| 485 | − params: { | |
| 480 | + navigation.dispatch( | |
| 481 | + StackActions.replace("NewTaskDraft", { | |
| 486 | 482 | environmentId: environment.environmentId, |
| 487 | 483 | projectId, |
| 488 | 484 | title: inferProjectTitleFromPath(workspaceRoot), |
| 489 | − }, | |
| 490 | − }); | |
| 485 | + }), | |
| 486 | + ); | |
| 491 | 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 { | |
| 498 | − const params = useLocalSearchParams<{ environmentId?: string }>(); | |
| 493 | +function useEnvironmentFromParam( | |
| 494 | + environmentIdParam: string | string[] | undefined, | |
| 495 | +): EnvironmentOption | null { | |
| 499 | 496 | const environmentOptions = useEnvironmentOptions(); |
| 500 | − const environmentId = stringParam(params.environmentId) as EnvironmentId | null; | |
| 497 | + const environmentId = stringParam(environmentIdParam) as EnvironmentId | null; | |
| 501 | 498 | return ( |
| 502 | 499 | environmentOptions.find((environment) => environment.environmentId === environmentId) ?? |
| 503 | 500 | environmentOptions[0] ?? |
| @@ -505,14 +502,16 @@ function useEnvironmentFromParam(): EnvironmentOption | null { · 1 unmodified line | ||
| 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 | 509 | const lookupRepositoryQuery = useAtomQueryRunner(sourceControlEnvironment.repository, { |
| 510 | 510 | reportFailure: false, |
| 511 | 511 | }); |
| 512 | − const router = useRouter(); | |
| 513 | − const params = useLocalSearchParams<{ environmentId?: string; source?: string }>(); | |
| 514 | − const environment = useEnvironmentFromParam(); | |
| 515 | − const source = sourceFromParam(params.source); | |
| 512 | + const navigation = useNavigation(); | |
| 513 | + const environment = useEnvironmentFromParam(props.environmentId); | |
| 514 | + const source = sourceFromParam(props.source); | |
| 516 | 515 | const [repositoryInput, setRepositoryInput] = useState(""); |
| 517 | 516 | const [isSubmitting, setIsSubmitting] = useState(false); |
| 518 | 517 | const [error, setError] = useState<string | null>(null); |
| @@ -524,8 +523,8 @@ export function AddProjectRepositoryScreen() { · 5 unmodified lines | ||
| 524 | 523 | const provider = addProjectRemoteSourceProvider(source); |
| 525 | 524 | if (!provider) { |
| 526 | 525 | const remoteUrl = repositoryInput.trim(); |
| 527 | − router.push({ | |
| 528 | − pathname: "/new/add-project/destination", | |
| 526 | + navigation.navigate("NewTaskSheet", { | |
| 527 | + screen: "AddProjectDestination", | |
| 529 | 528 | params: { |
| 530 | 529 | environmentId: environment.environmentId, |
| 531 | 530 | source, |
| @@ -548,8 +547,8 @@ export function AddProjectRepositoryScreen() { · 16 unmodified lines | ||
| 548 | 547 | setError(errorMessage(Cause.squash(result.cause))); |
| 549 | 548 | } else { |
| 550 | 549 | const repository = result.value; |
| 551 | − router.push({ | |
| 552 | − pathname: "/new/add-project/destination", | |
| 550 | + navigation.navigate("NewTaskSheet", { | |
| 551 | + screen: "AddProjectDestination", | |
| 553 | 552 | params: { |
| 554 | 553 | environmentId: environment.environmentId, |
| 555 | 554 | source, |
| @@ -559,13 +558,13 @@ export function AddProjectRepositoryScreen() { · 3 unmodified lines | ||
| 559 | 558 | }); |
| 560 | 559 | } |
| 561 | 560 | setIsSubmitting(false); |
| 562 | − }, [environment, isSubmitting, lookupRepositoryQuery, repositoryInput, router, source]); | |
| 561 | + }, [environment, isSubmitting, lookupRepositoryQuery, repositoryInput, navigation, source]); | |
| 563 | 562 | |
| 564 | 563 | return ( |
| 565 | 564 | <AddProjectShell> |
| 566 | 565 | {error ? <ErrorBanner message={error} /> : null} |
| 567 | 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 | 568 | value={repositoryInput} |
| 570 | 569 | onChangeText={setRepositoryInput} |
| 571 | 570 | autoCapitalize="none" |
| @@ -681,8 +680,8 @@ function FolderBrowser(props: { · 109 unmodified lines | ||
| 681 | 680 | ); |
| 682 | 681 | } |
| 683 | 682 | |
| 684 | −export function AddProjectLocalFolderScreen() { | |
| 685 | − const environment = useEnvironmentFromParam(); | |
| 683 | +export function AddProjectLocalFolderScreen(props: { readonly environmentId?: string | string[] }) { | |
| 684 | + const environment = useEnvironmentFromParam(props.environmentId); | |
| 686 | 685 | const createProject = useCreateProject(environment); |
| 687 | 686 | const [pathInput, setPathInput] = useState(() => |
| 688 | 687 | getAddProjectInitialQuery(environment?.baseDirectory), |
| @@ -745,19 +744,18 @@ export function AddProjectLocalFolderScreen() { · 56 unmodified lines | ||
| 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 | 752 | const cloneRepository = useAtomCommand(sourceControlEnvironment.cloneRepository, { |
| 750 | 753 | reportFailure: false, |
| 751 | 754 | }); |
| 752 | − const params = useLocalSearchParams<{ | |
| 753 | − environmentId?: string; | |
| 754 | − remoteUrl?: string; | |
| 755 | − repositoryTitle?: string; | |
| 756 | − }>(); | |
| 757 | − const environment = useEnvironmentFromParam(); | |
| 755 | + const environment = useEnvironmentFromParam(props.environmentId); | |
| 758 | 756 | const createProject = useCreateProject(environment); |
| 759 | − const remoteUrl = stringParam(params.remoteUrl); | |
| 760 | − const repositoryTitle = stringParam(params.repositoryTitle); | |
| 757 | + const remoteUrl = stringParam(props.remoteUrl); | |
| 758 | + const repositoryTitle = stringParam(props.repositoryTitle); | |
| 761 | 759 | const [pathInput, setPathInput] = useState(() => |
| 762 | 760 | getAddProjectInitialQuery(environment?.baseDirectory), |
| 763 | 761 | ); |
| @@ -0,0 +1,5 @@ | ||
| 1 | +import { AddProjectSourceScreen } from "./AddProjectScreen"; | |
| 2 | + | |
| 3 | +export function AddProjectSourceRoute() { | |
| 4 | + return <AddProjectSourceScreen />; | |
| 5 | +} | |
| @@ -1,4 +1,4 @@ | ||
| 1 | −import { useLocalSearchParams, useRouter } from "expo-router"; | |
| 1 | +import { useNavigation, type StaticScreenProps } from "@react-navigation/native"; | |
| 2 | 2 | import { SymbolView } from "expo-symbols"; |
| 3 | 3 | import { TextInputWrapper } from "expo-paste-input"; |
| 4 | 4 | import type { EnvironmentId, ThreadId } from "@t3tools/contracts"; |
| @@ -25,10 +25,10 @@ import { · 20 unmodified lines | ||
| 25 | 25 | getSelectedReviewCommentLines, |
| 26 | 26 | useReviewCommentTarget, |
| 27 | 27 | } from "./reviewCommentSelection"; |
| 28 | +import { useAppearanceCodeSurface } from "../settings/appearance/useAppearanceCodeSurface"; | |
| 28 | 29 | import { |
| 29 | 30 | changeTone, |
| 30 | 31 | DiffTokenText, |
| 31 | − REVIEW_DIFF_LINE_HEIGHT, | |
| 32 | 32 | REVIEW_MONO_FONT_FAMILY, |
| 33 | 33 | ReviewChangeBar, |
| 34 | 34 | } from "./reviewDiffRendering"; |
| @@ -40,17 +40,20 @@ import { · 5 unmodified lines | ||
| 40 | 40 | |
| 41 | 41 | const REVIEW_COMMENT_PREVIEW_MAX_LINES = 5; |
| 42 | 42 | |
| 43 | −export function ReviewCommentComposerSheet() { | |
| 44 | − const router = useRouter(); | |
| 43 | +type ReviewCommentComposerSheetProps = StaticScreenProps<{ | |
| 44 | + readonly environmentId: EnvironmentId; | |
| 45 | + readonly threadId: ThreadId; | |
| 46 | +}>; | |
| 47 | + | |
| 48 | +export function ReviewCommentComposerSheet(props: ReviewCommentComposerSheetProps) { | |
| 49 | + const navigation = useNavigation(); | |
| 45 | 50 | const insets = useSafeAreaInsets(); |
| 46 | 51 | const { width } = useWindowDimensions(); |
| 47 | 52 | const colorScheme = useColorScheme(); |
| 48 | 53 | const iconTint = String(useThemeColor("--color-icon")); |
| 49 | 54 | const target = useReviewCommentTarget(); |
| 50 | − const { environmentId, threadId } = useLocalSearchParams<{ | |
| 51 | − environmentId: EnvironmentId; | |
| 52 | − threadId: ThreadId; | |
| 53 | − }>(); | |
| 55 | + const { codeSurface } = useAppearanceCodeSurface(); | |
| 56 | + const { environmentId, threadId } = props.route.params; | |
| 54 | 57 | const [commentText, setCommentText] = useState(""); |
| 55 | 58 | const [highlightedLinesById, setHighlightedLinesById] = useState< |
| 56 | 59 | Record<string, ReadonlyArray<ReviewHighlightedToken>> |
| @@ -78,14 +81,14 @@ export function ReviewCommentComposerSheet() { · 21 unmodified lines | ||
| 78 | 81 | ? `Lines ${firstNumber}-${lastNumber}` |
| 79 | 82 | : `${selectedLines.length} lines selected`; |
| 80 | 83 | const previewHeight = Math.max( |
| 81 | − Math.min(selectedLines.length, REVIEW_COMMENT_PREVIEW_MAX_LINES) * REVIEW_DIFF_LINE_HEIGHT, | |
| 82 | − REVIEW_DIFF_LINE_HEIGHT, | |
| 84 | + Math.min(selectedLines.length, REVIEW_COMMENT_PREVIEW_MAX_LINES) * codeSurface.rowHeight, | |
| 85 | + codeSurface.rowHeight, | |
| 83 | 86 | ); |
| 84 | 87 | const previewViewportWidth = Math.max(width - 40, 280); |
| 85 | 88 | const dismissComposer = useCallback(() => { |
| 86 | 89 | clearReviewCommentTarget(); |
| 87 | − router.dismiss(); | |
| 88 | − }, [router]); | |
| 90 | + navigation.goBack(); | |
| 91 | + }, [navigation]); | |
| 89 | 92 | const handleNativePaste = useNativePaste((uris) => { |
| 90 | 93 | void (async () => { |
| 91 | 94 | try { |
| @@ -167,7 +170,7 @@ export function ReviewCommentComposerSheet() { · 75 unmodified lines | ||
| 167 | 170 | {!target ? ( |
| 168 | 171 | <View className="rounded-[22px] border border-border bg-card px-4 py-5"> |
| 169 | 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 | 174 | Select a diff line or range first. |
| 172 | 175 | </Text> |
| 173 | 176 | </View> |
| @@ -178,7 +181,7 @@ export function ReviewCommentComposerSheet() { · 4 unmodified lines | ||
| 178 | 181 | {selectionLabel} |
| 179 | 182 | </Text> |
| 180 | 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 | 185 | ellipsizeMode="middle" |
| 183 | 186 | numberOfLines={2} |
| 184 | 187 | > |
| @@ -211,9 +214,9 @@ export function ReviewCommentComposerSheet() { · 26 unmodified lines | ||
| 211 | 214 | <View |
| 212 | 215 | key={line.id} |
| 213 | 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 | 220 | <Text |
| 218 | 221 | className="w-9 py-1 pr-1 text-right text-2xs font-t3-medium text-foreground-muted" |
| 219 | 222 | style={{ fontFamily: REVIEW_MONO_FONT_FAMILY }} |
| @@ -225,6 +228,8 @@ export function ReviewCommentComposerSheet() { · 5 unmodified lines | ||
| 225 | 228 | fallback={line.content} |
| 226 | 229 | tokens={highlightedLinesById[line.id] ?? null} |
| 227 | 230 | change={line.change} |
| 231 | + fontSize={codeSurface.fontSize} | |
| 232 | + lineHeight={codeSurface.rowHeight} | |
| 228 | 233 | /> |
| 229 | 234 | </View> |
| 230 | 235 | </View> |
| @@ -1,14 +1,30 @@ | ||
| 1 | 1 | import type { EnvironmentId, ThreadId } from "@t3tools/contracts"; |
| 2 | −import { useLocalSearchParams } from "expo-router"; | |
| 3 | −import Stack from "expo-router/stack"; | |
| 2 | +import { useNavigation, type StaticScreenProps } from "@react-navigation/native"; | |
| 3 | +import { | |
| 4 | + NativeHeaderToolbar, | |
| 5 | + NativeStackScreenOptions, | |
| 6 | + nativeHeaderScrollEdgeEffects, | |
| 7 | +} from "../../native/StackHeader"; | |
| 8 | +import { Screen, ScreenStack, ScreenStackHeaderConfig } from "react-native-screens"; | |
| 4 | 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 | 21 | import { |
| 7 | 22 | ActivityIndicator, |
| 23 | + FlatList, | |
| 24 | + Platform, | |
| 8 | 25 | Pressable, |
| 9 | 26 | ScrollView, |
| 10 | 27 | type NativeSyntheticEvent, |
| 11 | − Text as NativeText, | |
| 12 | 28 | StyleSheet, |
| 13 | 29 | useColorScheme, |
| 14 | 30 | View, |
| @@ -20,23 +36,38 @@ import { environmentCatalog } from "../../connection/catalog"; · 5 unmodified lines | ||
| 20 | 36 | import { useEnvironmentPresentation } from "../../state/presentation"; |
| 21 | 37 | import { useAtomCommand } from "../../state/use-atom-command"; |
| 22 | 38 | import { useThemeColor } from "../../lib/useThemeColor"; |
| 23 | −import { MOBILE_TYPOGRAPHY } from "../../lib/typography"; | |
| 24 | 39 | import { useThreadDraftForThread } from "../../state/use-thread-composer-state"; |
| 25 | 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 | 54 | import { useReviewCacheForThread } from "./reviewState"; |
| 27 | −import { resolveNativeReviewDiffView } from "../diffs/nativeReviewDiffSurface"; | |
| 28 | 55 | import { |
| 29 | − NATIVE_REVIEW_DIFF_CONTENT_WIDTH, | |
| 30 | − NATIVE_REVIEW_DIFF_ROW_HEIGHT, | |
| 31 | −} from "./nativeReviewDiffAdapter"; | |
| 56 | + type NativeReviewDiffViewHandle, | |
| 57 | + resolveNativeReviewDiffView, | |
| 58 | +} from "../diffs/nativeReviewDiffSurface"; | |
| 59 | +import { NATIVE_REVIEW_DIFF_CONTENT_WIDTH } from "./nativeReviewDiffAdapter"; | |
| 60 | +import { useAppearanceCodeSurface } from "../settings/appearance/useAppearanceCodeSurface"; | |
| 32 | 61 | import { useReviewDiffData } from "./useReviewDiffData"; |
| 62 | +import { useReviewDiffPrewarming } from "./useReviewDiffPrewarming"; | |
| 33 | 63 | import { useReviewFileVisibility } from "./reviewFileVisibility"; |
| 34 | 64 | import { useReviewSections } from "./useReviewSections"; |
| 35 | 65 | import { useNativeReviewDiffBridge } from "./useNativeReviewDiffBridge"; |
| 36 | 66 | import { useReviewCommentSelectionController } from "./useReviewCommentSelectionController"; |
| 37 | 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 | 71 | const REVIEW_HEADER_SPACING = 0; |
| 41 | 72 | |
| 42 | 73 | const ReviewNotice = memo(function ReviewNotice(props: { readonly notice: string }) { |
| @@ -45,7 +76,7 @@ const ReviewNotice = memo(function ReviewNotice(props: { readonly notice: string · 2 unmodified lines | ||
| 45 | 76 | <Text className="text-xs font-t3-bold uppercase text-amber-700 dark:text-amber-300"> |
| 46 | 77 | Partial diff |
| 47 | 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 | 80 | {props.notice} |
| 50 | 81 | </Text> |
| 51 | 82 | </View> |
| @@ -110,36 +141,244 @@ function ReviewSelectionActionBar(props: { · 58 unmodified lines | ||
| 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 | 337 | const insets = useSafeAreaInsets(); |
| 115 | 338 | const colorScheme = useColorScheme(); |
| 116 | − const headerForeground = String(useThemeColor("--color-foreground")); | |
| 117 | − const headerMuted = String(useThemeColor("--color-foreground-muted")); | |
| 118 | 339 | const headerIcon = String(useThemeColor("--color-icon")); |
| 119 | − const { environmentId, threadId } = useLocalSearchParams<{ | |
| 120 | − environmentId: EnvironmentId; | |
| 121 | − threadId: ThreadId; | |
| 122 | − }>(); | |
| 340 | + const { environmentId, threadId } = props.route.params; | |
| 123 | 341 | const environment = useEnvironmentPresentation(environmentId); |
| 124 | 342 | const retryEnvironment = useAtomCommand(environmentCatalog.retryNow, "environment retry"); |
| 125 | 343 | const isEnvironmentReady = environment.presentation?.connection.phase === "connected"; |
| 126 | 344 | const { draftMessage } = useThreadDraftForThread({ environmentId, threadId }); |
| 127 | 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 | 363 | const selectedTheme = colorScheme === "dark" ? "dark" : "light"; |
| 129 | − const topContentInset = insets.top + IOS_NAV_BAR_HEIGHT; | |
| 130 | − const { | |
| 131 | − error, | |
| 132 | − loadingGitDiffs, | |
| 133 | − loadingTurnIds, | |
| 134 | − reviewSections, | |
| 135 | − selectedSection, | |
| 136 | − refreshSelectedSection, | |
| 137 | − selectSection, | |
| 138 | − } = useReviewSections({ | |
| 139 | − enabled: isEnvironmentReady, | |
| 140 | − environmentId, | |
| 141 | − threadId, | |
| 142 | − reviewCache, | |
| 364 | + // With a solid (non-overlay) header the content lays out below the header | |
| 365 | + // natively, so no manual top inset is needed. | |
| 366 | + const topContentInset = 0; | |
| 367 | + | |
| 368 | + useEffect(() => { | |
| 369 | + showAuxiliaryPane("inspector"); | |
| 370 | + }, [environmentId, showAuxiliaryPane, threadId]); | |
| 371 | + const { error, reviewSections, selectedSection, refreshSelectedSection, selectSection } = | |
| 372 | + useReviewSections({ | |
| 373 | + enabled: isEnvironmentReady, | |
| 374 | + environmentId, | |
| 375 | + threadId, | |
| 376 | + reviewCache, | |
| 377 | + }); | |
| 378 | + useReviewDiffPrewarming({ | |
| 379 | + threadKey: reviewCache.threadKey, | |
| 380 | + sections: reviewSections, | |
| 381 | + selectedSectionId: selectedSection?.id ?? null, | |
| 143 | 382 | }); |
| 144 | 383 | const { headerDiffSummary, nativeReviewDiffData, parsedDiff, pendingReviewCommentCount } = |
| 145 | 384 | useReviewDiffData({ |
| @@ -148,6 +387,18 @@ export function ReviewSheet() { · 2 unmodified lines | ||
| 148 | 387 | draftMessage, |
| 149 | 388 | }); |
| 150 | 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 | 402 | const reviewFiles = parsedDiff.kind === "files" ? parsedDiff.files : []; |
| 152 | 403 | const fileVisibility = useReviewFileVisibility({ |
| 153 | 404 | threadKey: reviewCache.threadKey, |
| @@ -179,6 +430,43 @@ export function ReviewSheet() { · 25 unmodified lines | ||
| 179 | 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 | 470 | const handleNativeToggleFile = useCallback( |
| 183 | 471 | (event: NativeSyntheticEvent<{ readonly fileId?: string }>) => { |
| 184 | 472 | const { fileId } = event.nativeEvent; |
| @@ -203,6 +491,7 @@ export function ReviewSheet() { · 18 unmodified lines | ||
| 203 | 491 | parsedDiff.kind === "files" || parsedDiff.kind === "raw" ? parsedDiff.notice : null; |
| 204 | 492 | const hasCachedSelectedDiff = selectedSection?.diff != null; |
| 205 | 493 | const hasAnyCachedDiff = reviewSections.some((section) => section.diff != null); |
| 494 | + const sectionMenu = useMemo(() => buildReviewSectionMenu(reviewSections), [reviewSections]); | |
| 206 | 495 | const { showConnectionNotice, showSectionToolbar } = resolveReviewAvailability({ |
| 207 | 496 | hasEnvironmentPresentation: environment.isReady, |
| 208 | 497 | isEnvironmentConnected: isEnvironmentReady, |
| @@ -212,6 +501,22 @@ export function ReviewSheet() { · 3 unmodified lines | ||
| 212 | 501 | const handleRetryEnvironment = useCallback(() => { |
| 213 | 502 | void retryEnvironment(environmentId); |
| 214 | 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 | 521 | const listHeader = useMemo(() => { |
| 217 | 522 | const children: ReactElement[] = []; |
| @@ -220,7 +525,7 @@ export function ReviewSheet() { · 2 unmodified lines | ||
| 220 | 525 | children.push( |
| 221 | 526 | <View key="review-error" className="border-b border-border bg-card px-4 py-3"> |
| 222 | 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 | 529 | </View>, |
| 225 | 530 | ); |
| 226 | 531 | } |
| @@ -235,134 +540,117 @@ export function ReviewSheet() { · 8 unmodified lines | ||
| 235 | 540 | |
| 236 | 541 | return <>{children}</>; |
| 237 | 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 | 554 | return ( |
| 240 | 555 | <> |
| 241 | − <Stack.Screen | |
| 556 | + <NativeStackScreenOptions | |
| 242 | 557 | options={{ |
| 243 | − headerTransparent: true, | |
| 244 | − headerShadowVisible: false, | |
| 558 | + // Static header config lives in Stack.tsx (SOLID_HEADER_OPTIONS — the native | |
| 559 | + // diff scrolls internally, nothing for glass to sample). Only dynamic values | |
| 560 | + // here. | |
| 245 | 561 | headerTintColor: headerIcon, |
| 246 | − headerStyle: { | |
| 247 | − backgroundColor: "transparent", | |
| 248 | − }, | |
| 249 | − headerTitle: () => ( | |
| 250 | − <View style={{ alignItems: "center" }}> | |
| 251 | − <NativeText | |
| 252 | − numberOfLines={1} | |
| 253 | − style={{ | |
| 254 | − fontFamily: "DMSans_700Bold", | |
| 255 | − fontSize: MOBILE_TYPOGRAPHY.headline.fontSize, | |
| 256 | − fontWeight: "900", | |
| 257 | − color: headerForeground, | |
| 258 | − letterSpacing: -0.4, | |
| 259 | − }} | |
| 260 | − > | |
| 261 | − Files Changed | |
| 262 | − </NativeText> | |
| 263 | − <View | |
| 264 | − style={{ | |
| 265 | − flexDirection: "row", | |
| 266 | − alignItems: "center", | |
| 267 | − justifyContent: "center", | |
| 268 | − gap: 6, | |
| 269 | − flexWrap: "wrap", | |
| 270 | − }} | |
| 271 | − > | |
| 272 | − {headerDiffSummary.additions && headerDiffSummary.deletions ? ( | |
| 273 | − <> | |
| 274 | − <NativeText | |
| 275 | − style={{ | |
| 276 | − fontFamily: "DMSans_700Bold", | |
| 277 | − fontSize: MOBILE_TYPOGRAPHY.label.fontSize, | |
| 278 | − fontWeight: "700", | |
| 279 | − color: "#16a34a", | |
| 280 | − }} | |
| 281 | − > | |
| 282 | − {headerDiffSummary.additions} | |
| 283 | − </NativeText> | |
| 284 | − <NativeText | |
| 285 | − style={{ | |
| 286 | − fontFamily: "DMSans_700Bold", | |
| 287 | − fontSize: MOBILE_TYPOGRAPHY.label.fontSize, | |
| 288 | − fontWeight: "700", | |
| 289 | − color: "#e11d48", | |
| 290 | − }} | |
| 291 | − > | |
| 292 | − {headerDiffSummary.deletions} | |
| 293 | − </NativeText> | |
| 294 | − {pendingReviewCommentCount > 0 ? ( | |
| 295 | − <NativeText | |
| 296 | − style={{ | |
| 297 | − fontFamily: "DMSans_700Bold", | |
| 298 | − fontSize: MOBILE_TYPOGRAPHY.label.fontSize, | |
| 299 | − fontWeight: "700", | |
| 300 | − color: "#b45309", | |
| 301 | − }} | |
| 302 | − > | |
| 303 | − {pendingReviewCommentCount} pending | |
| 304 | − </NativeText> | |
| 305 | − ) : null} | |
| 306 | − </> | |
| 307 | − ) : ( | |
| 308 | − <View style={{ flexDirection: "row", alignItems: "center", gap: 6 }}> | |
| 309 | − <NativeText | |
| 310 | − numberOfLines={1} | |
| 311 | − style={{ | |
| 312 | − fontFamily: "DMSans_700Bold", | |
| 313 | − fontSize: MOBILE_TYPOGRAPHY.label.fontSize, | |
| 314 | − fontWeight: "700", | |
| 315 | − color: headerMuted, | |
| 316 | − }} | |
| 317 | − > | |
| 318 | − {selectedSection?.title ?? "Review changes"} | |
| 319 | − </NativeText> | |
| 320 | − {pendingReviewCommentCount > 0 ? ( | |
| 321 | − <NativeText | |
| 322 | − style={{ | |
| 323 | − fontFamily: "DMSans_700Bold", | |
| 324 | − fontSize: MOBILE_TYPOGRAPHY.label.fontSize, | |
| 325 | − fontWeight: "700", | |
| 326 | − color: "#b45309", | |
| 327 | − }} | |
| 328 | − > | |
| 329 | − {pendingReviewCommentCount} pending | |
| 330 | − </NativeText> | |
| 331 | − ) : null} | |
| 332 | − </View> | |
| 333 | − )} | |
| 334 | − </View> | |
| 335 | − </View> | |
| 336 | − ), | |
| 562 | + headerTitle: headerTitleText, | |
| 563 | + title: headerTitleText, | |
| 564 | + unstable_headerSubtitle: | |
| 565 | + Platform.OS === "ios" && headerSubtitle.length > 0 ? headerSubtitle : undefined, | |
| 337 | 566 | }} |
| 338 | 567 | /> |
| 339 | 568 | |
| 340 | − {showSectionToolbar ? ( | |
| 341 | − <Stack.Toolbar placement="right"> | |
| 342 | − <Stack.Toolbar.Menu icon="ellipsis.circle" title="Select diff" separateBackground> | |
| 343 | − {reviewSections.map((section) => ( | |
| 344 | − <Stack.Toolbar.MenuAction | |
| 345 | − key={section.id} | |
| 346 | − icon={section.id === selectedSection?.id ? "checkmark" : "circle"} | |
| 347 | − onPress={() => selectSection(section.id)} | |
| 348 | − subtitle={section.subtitle ?? undefined} | |
| 349 | − > | |
| 350 | − <Stack.Toolbar.Label>{section.title}</Stack.Toolbar.Label> | |
| 351 | − </Stack.Toolbar.MenuAction> | |
| 352 | − ))} | |
| 353 | − <Stack.Toolbar.MenuAction | |
| 354 | − icon="arrow.clockwise" | |
| 355 | − disabled={ | |
| 356 | − loadingGitDiffs || | |
| 357 | − (selectedSection?.kind === "turn" && loadingTurnIds[selectedSection.id] === true) | |
| 569 | + <WorkspaceSidebarToolbar> | |
| 570 | + <NativeHeaderToolbar.Button | |
| 571 | + accessibilityLabel="Back to chat" | |
| 572 | + icon="chevron.left" | |
| 573 | + onPress={handleReturnToThread} | |
| 574 | + /> | |
| 575 | + </WorkspaceSidebarToolbar> | |
| 576 | + | |
| 577 | + {showSectionToolbar || panes.supportsAuxiliaryPane || gitMenuAvailable ? ( | |
| 578 | + <NativeHeaderToolbar placement="right"> | |
| 579 | + {panes.supportsAuxiliaryPane ? ( | |
| 580 | + <NativeHeaderToolbar.Button | |
| 581 | + accessibilityLabel={ | |
| 582 | + panes.auxiliaryPaneVisible ? "Hide changed files" : "Show changed files" | |
| 358 | 583 | } |
| 359 | − onPress={() => void refreshSelectedSection()} | |
| 360 | − subtitle="Reload current diff" | |
| 361 | − > | |
| 362 | − <Stack.Toolbar.Label>Refresh</Stack.Toolbar.Label> | |
| 363 | − </Stack.Toolbar.MenuAction> | |
| 364 | − </Stack.Toolbar.Menu> | |
| 365 | − </Stack.Toolbar> | |
| 584 | + icon="sidebar.right" | |
| 585 | + onPress={toggleAuxiliaryPane} | |
| 586 | + separateBackground | |
| 587 | + /> | |
| 588 | + ) : null} | |
| 589 | + {gitMenuAvailable && selectedThread !== null ? ( | |
| 590 | + <ThreadGitMenu | |
| 591 | + environmentId={environmentId} | |
| 592 | + threadId={threadId} | |
| 593 | + currentBranch={selectedThread.branch ?? null} | |
| 594 | + gitStatus={gitStatusQuery.data} | |
| 595 | + gitOperationLabel={gitState.gitOperationLabel} | |
| 596 | + onPull={gitActions.onPullSelectedThreadBranch} | |
| 597 | + onRunAction={gitActions.onRunSelectedThreadGitAction} | |
| 598 | + /> | |
| 599 | + ) : null} | |
| 600 | + {showSectionToolbar ? ( | |
| 601 | + <NativeHeaderToolbar.Menu icon="ellipsis" title="Select diff" separateBackground> | |
| 602 | + <NativeHeaderToolbar.Menu inline> | |
| 603 | + <NativeHeaderToolbar.MenuAction | |
| 604 | + disabled={sectionMenu.workingTree === null} | |
| 605 | + isOn={selectedSection?.id === sectionMenu.workingTree?.id} | |
| 606 | + onPress={() => { | |
| 607 | + if (sectionMenu.workingTree) { | |
| 608 | + selectSection(sectionMenu.workingTree.id); | |
| 609 | + } | |
| 610 | + }} | |
| 611 | + > | |
| 612 | + <NativeHeaderToolbar.Label>Working tree</NativeHeaderToolbar.Label> | |
| 613 | + </NativeHeaderToolbar.MenuAction> | |
| 614 | + <NativeHeaderToolbar.MenuAction | |
| 615 | + disabled={sectionMenu.branchChanges === null} | |
| 616 | + isOn={selectedSection?.id === sectionMenu.branchChanges?.id} | |
| 617 | + onPress={() => { | |
| 618 | + if (sectionMenu.branchChanges) { | |
| 619 | + selectSection(sectionMenu.branchChanges.id); | |
| 620 | + } | |
| 621 | + }} | |
| 622 | + > | |
| 623 | + <NativeHeaderToolbar.Label>Branch changes</NativeHeaderToolbar.Label> | |
| 624 | + </NativeHeaderToolbar.MenuAction> | |
| 625 | + <NativeHeaderToolbar.MenuAction | |
| 626 | + disabled={sectionMenu.latestTurn === null} | |
| 627 | + isOn={selectedSection?.id === sectionMenu.latestTurn?.id} | |
| 628 | + onPress={() => { | |
| 629 | + if (sectionMenu.latestTurn) { | |
| 630 | + selectSection(sectionMenu.latestTurn.id); | |
| 631 | + } | |
| 632 | + }} | |
| 633 | + > | |
| 634 | + <NativeHeaderToolbar.Label>Latest turn</NativeHeaderToolbar.Label> | |
| 635 | + </NativeHeaderToolbar.MenuAction> | |
| 636 | + {sectionMenu.turns.length > 0 ? ( | |
| 637 | + <NativeHeaderToolbar.Menu title="Turn"> | |
| 638 | + {sectionMenu.turns.map((section) => ( | |
| 639 | + <NativeHeaderToolbar.MenuAction | |
| 640 | + key={section.id} | |
| 641 | + isOn={section.id === selectedSection?.id} | |
| 642 | + onPress={() => selectSection(section.id)} | |
| 643 | + subtitle={section.subtitle ?? undefined} | |
| 644 | + > | |
| 645 | + <NativeHeaderToolbar.Label>{section.title}</NativeHeaderToolbar.Label> | |
| 646 | + </NativeHeaderToolbar.MenuAction> | |
| 647 | + ))} | |
| 648 | + </NativeHeaderToolbar.Menu> | |
| 649 | + ) : null} | |
| 650 | + </NativeHeaderToolbar.Menu> | |
| 651 | + </NativeHeaderToolbar.Menu> | |
| 652 | + ) : null} | |
| 653 | + </NativeHeaderToolbar> | |
| 366 | 654 | ) : null} |
| 367 | 655 | |
| 368 | 656 | <View className="flex-1 bg-sheet"> |
| @@ -386,34 +674,42 @@ export function ReviewSheet() { · 17 unmodified lines | ||
| 386 | 674 | className="flex-1" |
| 387 | 675 | style={{ |
| 388 | 676 | backgroundColor: nativeBridge.theme.background, |
| 389 | − paddingTop: topContentInset + REVIEW_HEADER_SPACING, | |
| 390 | 677 | }} |
| 391 | 678 | > |
| 392 | − {listHeader} | |
| 393 | − <View className="flex-1" collapsable={false}> | |
| 394 | − <NativeReviewDiffView | |
| 395 | − key={`${reviewCache.threadKey}:${selectedSection.id}`} | |
| 396 | − collapsable={false} | |
| 397 | − testID="review-native-diff-view" | |
| 398 | − style={StyleSheet.absoluteFill} | |
| 399 | − appearanceScheme={selectedTheme} | |
| 400 | − collapsedFileIdsJson={nativeBridge.collapsedFileIdsJson} | |
| 401 | − collapsedCommentIdsJson={nativeBridge.collapsedCommentIdsJson} | |
| 402 | − contentWidth={NATIVE_REVIEW_DIFF_CONTENT_WIDTH} | |
| 403 | − rowHeight={NATIVE_REVIEW_DIFF_ROW_HEIGHT} | |
| 404 | − rowsJson={nativeBridge.rowsJson} | |
| 405 | − selectedRowIdsJson={nativeBridge.selectedRowIdsJson} | |
| 406 | − styleJson={nativeBridge.styleJson} | |
| 407 | − themeJson={nativeBridge.themeJson} | |
| 408 | − tokensPatchJson={nativeBridge.tokensPatchJson} | |
| 409 | − tokensResetKey={nativeBridge.tokensResetKey} | |
| 410 | − viewedFileIdsJson={nativeBridge.viewedFileIdsJson} | |
| 411 | − onDebug={nativeBridge.onDebug} | |
| 412 | − onPressLine={commentSelection.onPressLine} | |
| 413 | − onToggleComment={nativeBridge.onToggleComment} | |
| 414 | − onToggleFile={handleNativeToggleFile} | |
| 415 | − onToggleViewedFile={handleNativeToggleViewedFile} | |
| 416 | − /> | |
| 679 | + <View | |
| 680 | + className="min-w-0 flex-1" | |
| 681 | + style={{ paddingTop: topContentInset + REVIEW_HEADER_SPACING }} | |
| 682 | + > | |
| 683 | + {listHeader} | |
| 684 | + <View className="min-w-0 flex-1" collapsable={false}> | |
| 685 | + <NativeReviewDiffView | |
| 686 | + collapsable={false} | |
| 687 | + testID="review-native-diff-view" | |
| 688 | + refreshing={isPullRefreshing} | |
| 689 | + onPullToRefresh={() => void handlePullToRefresh()} | |
| 690 | + style={StyleSheet.absoluteFill} | |
| 691 | + appearanceScheme={selectedTheme} | |
| 692 | + collapsedFileIdsJson={nativeBridge.collapsedFileIdsJson} | |
| 693 | + collapsedCommentIdsJson={nativeBridge.collapsedCommentIdsJson} | |
| 694 | + contentResetKey={`${reviewCache.threadKey}:${selectedSection.id}`} | |
| 695 | + contentWidth={NATIVE_REVIEW_DIFF_CONTENT_WIDTH} | |
| 696 | + nativeViewRef={nativeReviewDiffViewRef} | |
| 697 | + rowHeight={nativeReviewDiffStyle.rowHeight} | |
| 698 | + rowsJson={nativeBridge.rowsJson} | |
| 699 | + selectedRowIdsJson={nativeBridge.selectedRowIdsJson} | |
| 700 | + styleJson={nativeBridge.styleJson} | |
| 701 | + themeJson={nativeBridge.themeJson} | |
| 702 | + tokensPatchJson={nativeBridge.tokensPatchJson} | |
| 703 | + tokensResetKey={nativeBridge.tokensResetKey} | |
| 704 | + viewedFileIdsJson={nativeBridge.viewedFileIdsJson} | |
| 705 | + onDebug={nativeBridge.onDebug} | |
| 706 | + onPressLine={commentSelection.onPressLine} | |
| 707 | + onVisibleFileChange={handleVisibleFileChange} | |
| 708 | + onToggleComment={nativeBridge.onToggleComment} | |
| 709 | + onToggleFile={handleNativeToggleFile} | |
| 710 | + onToggleViewedFile={handleNativeToggleViewedFile} | |
| 711 | + /> | |
| 712 | + </View> | |
| 417 | 713 | </View> |
| 418 | 714 | </View> |
| 419 | 715 | ) : ( |
| @@ -432,7 +728,7 @@ export function ReviewSheet() { · 12 unmodified lines | ||
| 432 | 728 | {!selectedSection ? ( |
| 433 | 729 | <View className="border-b border-border bg-card px-4 py-5"> |
| 434 | 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 | 732 | This thread has no ready turn diffs and the worktree diff is empty. |
| 437 | 733 | </Text> |
| 438 | 734 | </View> |
| @@ -444,17 +740,17 @@ export function ReviewSheet() { · 5 unmodified lines | ||
| 444 | 740 | ) : parsedDiff.kind === "empty" ? ( |
| 445 | 741 | <View className="border-b border-border bg-card px-4 py-5"> |
| 446 | 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 | 744 | {selectedSection.subtitle ?? "This diff is empty."} |
| 449 | 745 | </Text> |
| 450 | 746 | </View> |
| 451 | 747 | ) : parsedDiff.kind === "raw" ? ( |
| 452 | 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 | 750 | {parsedDiff.reason} |
| 455 | 751 | </Text> |
| 456 | 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 | 754 | {parsedDiff.text} |
| 459 | 755 | </Text> |
| 460 | 756 | </ScrollView> |
| @@ -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 | +}); | |
| @@ -5,6 +5,8 @@ import type { · 4 unmodified lines | ||
| 5 | 5 | } from "../diffs/nativeReviewDiffTypes"; |
| 6 | 6 | import * as Arr from "effect/Array"; |
| 7 | 7 | import { pipe } from "effect/Function"; |
| 8 | +import type { ResolvedMobileCodeSurface } from "../../lib/appearancePreferences"; | |
| 9 | +import { resolveMobileCodeSurface } from "../../lib/appearancePreferences"; | |
| 8 | 10 | import { MOBILE_CODE_SURFACE } from "../../lib/typography"; |
| 9 | 11 | import { getPierreTerminalTheme, type TerminalAppearanceScheme } from "../terminal/terminalTheme"; |
| 10 | 12 | import { computeWordAltDiffRanges } from "./reviewWordDiffs"; |
| @@ -22,38 +24,44 @@ const NATIVE_REVIEW_MAX_WORD_DIFF_COVERAGE = 0.45; · 11 unmodified lines | ||
| 22 | 24 | export const NATIVE_REVIEW_DIFF_ROW_HEIGHT = MOBILE_CODE_SURFACE.rowHeight; |
| 23 | 25 | export const NATIVE_REVIEW_DIFF_CONTENT_WIDTH = 2_800; |
| 24 | 26 | |
| 25 | −export const NATIVE_REVIEW_DIFF_STYLE = { | |
| 26 | − rowHeight: NATIVE_REVIEW_DIFF_ROW_HEIGHT, | |
| 27 | − contentWidth: NATIVE_REVIEW_DIFF_CONTENT_WIDTH, | |
| 28 | − changeBarWidth: 4, | |
| 29 | − gutterWidth: MOBILE_CODE_SURFACE.gutterWidth, | |
| 30 | − codePadding: MOBILE_CODE_SURFACE.codePadding, | |
| 31 | − textVerticalInset: MOBILE_CODE_SURFACE.textVerticalInset, | |
| 32 | − fileHeaderHeight: 56, | |
| 33 | − fileHeaderHorizontalMargin: 8, | |
| 34 | − fileHeaderVerticalMargin: 6, | |
| 35 | − fileHeaderCornerRadius: 10, | |
| 36 | − fileHeaderHorizontalPadding: 10, | |
| 37 | − fileHeaderPathRightPadding: 118, | |
| 38 | − fileHeaderCountColumnWidth: 38, | |
| 39 | − fileHeaderCountGap: 5, | |
| 40 | − codeFontSize: MOBILE_CODE_SURFACE.fontSize, | |
| 41 | − codeFontWeight: "regular", | |
| 42 | − lineNumberFontSize: MOBILE_CODE_SURFACE.lineNumberFontSize, | |
| 43 | − lineNumberFontWeight: "regular", | |
| 44 | − hunkFontSize: 11, | |
| 45 | − hunkFontWeight: "medium", | |
| 46 | − fileHeaderFontSize: 11, | |
| 47 | − fileHeaderFontWeight: "semibold", | |
| 48 | − fileHeaderMetaFontSize: 10, | |
| 49 | − fileHeaderMetaFontWeight: "semibold", | |
| 50 | − fileHeaderSubtextFontSize: 11, | |
| 51 | − fileHeaderSubtextFontWeight: "medium", | |
| 52 | − fileHeaderStatusFontSize: 9, | |
| 53 | − fileHeaderStatusFontWeight: "bold", | |
| 54 | − emptyStateFontSize: 12, | |
| 55 | − emptyStateFontWeight: "medium", | |
| 56 | −} as const; | |
| 27 | +export const NATIVE_REVIEW_DIFF_STYLE = createNativeReviewDiffStyle( | |
| 28 | + resolveMobileCodeSurface(MOBILE_CODE_SURFACE.fontSize), | |
| 29 | +); | |
| 30 | + | |
| 31 | +export function createNativeReviewDiffStyle(codeSurface: ResolvedMobileCodeSurface) { | |
| 32 | + return { | |
| 33 | + rowHeight: codeSurface.rowHeight, | |
| 34 | + contentWidth: NATIVE_REVIEW_DIFF_CONTENT_WIDTH, | |
| 35 | + changeBarWidth: 4, | |
| 36 | + gutterWidth: codeSurface.gutterWidth, | |
| 37 | + codePadding: codeSurface.codePadding, | |
| 38 | + textVerticalInset: codeSurface.textVerticalInset, | |
| 39 | + fileHeaderHeight: 56, | |
| 40 | + fileHeaderHorizontalMargin: 8, | |
| 41 | + fileHeaderVerticalMargin: 6, | |
| 42 | + fileHeaderCornerRadius: 10, | |
| 43 | + fileHeaderHorizontalPadding: 10, | |
| 44 | + fileHeaderPathRightPadding: 118, | |
| 45 | + fileHeaderCountColumnWidth: 38, | |
| 46 | + fileHeaderCountGap: 5, | |
| 47 | + codeFontSize: codeSurface.fontSize, | |
| 48 | + codeFontWeight: "regular", | |
| 49 | + lineNumberFontSize: codeSurface.lineNumberFontSize, | |
| 50 | + lineNumberFontWeight: "regular", | |
| 51 | + hunkFontSize: 11, | |
| 52 | + hunkFontWeight: "medium", | |
| 53 | + fileHeaderFontSize: 11, | |
| 54 | + fileHeaderFontWeight: "semibold", | |
| 55 | + fileHeaderMetaFontSize: 10, | |
| 56 | + fileHeaderMetaFontWeight: "semibold", | |
| 57 | + fileHeaderSubtextFontSize: 11, | |
| 58 | + fileHeaderSubtextFontWeight: "medium", | |
| 59 | + fileHeaderStatusFontSize: 9, | |
| 60 | + fileHeaderStatusFontWeight: "bold", | |
| 61 | + emptyStateFontSize: 12, | |
| 62 | + emptyStateFontWeight: "medium", | |
| 63 | + } as const; | |
| 64 | +} | |
| 57 | 65 | |
| 58 | 66 | export interface NativeReviewDiffData { |
| 59 | 67 | readonly rows: ReadonlyArray<NativeReviewDiffRow>; |
| @@ -75,6 +83,33 @@ export interface BuildNativeReviewDiffDataInput { · 15 unmodified lines | ||
| 75 | 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 | 113 | export function createNativeReviewDiffTheme( |
| 79 | 114 | scheme: TerminalAppearanceScheme, |
| 80 | 115 | ): NativeReviewDiffTheme { |
| @@ -83,10 +118,12 @@ export function createNativeReviewDiffTheme( · 2 unmodified lines | ||
| 83 | 118 | |
| 84 | 119 | if (scheme === "dark") { |
| 85 | 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 | 124 | text: terminalTheme.foreground, |
| 88 | 125 | mutedText: terminalTheme.mutedForeground, |
| 89 | − headerBackground: terminalTheme.background, | |
| 126 | + headerBackground: "#0e0e0e", | |
| 90 | 127 | border: terminalTheme.border, |
| 91 | 128 | hunkBackground: "#071f28", |
| 92 | 129 | hunkText: terminalBlue ?? "#009fff", |
| @@ -100,10 +137,12 @@ export function createNativeReviewDiffTheme( · 7 unmodified lines | ||
| 100 | 137 | } |
| 101 | 138 | |
| 102 | 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 | 143 | text: "#070707", |
| 105 | 144 | mutedText: terminalTheme.mutedForeground, |
| 106 | − headerBackground: "#ffffff", | |
| 145 | + headerBackground: "#f2f2f7", | |
| 107 | 146 | border: terminalTheme.border, |
| 108 | 147 | hunkBackground: "#e0f2ff", |
| 109 | 148 | hunkText: terminalBlue ?? "#009fff", |
| @@ -430,3 +469,26 @@ export function buildNativeReviewDiffData( · 320 unmodified lines | ||
| 430 | 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 | +} | |
| @@ -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 | +}); | |
| @@ -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 | +} | |
| @@ -1,6 +1,6 @@ | ||
| 1 | 1 | import { describe, expect, it } from "vite-plus/test"; |
| 2 | 2 | |
| 3 | −import { buildNativeReviewTokensResetKey, hashReviewDiffKey } from "./useNativeReviewDiffBridge"; | |
| 3 | +import { buildNativeReviewTokensResetKey, hashReviewDiffKey } from "./reviewDiffBridgeKeys"; | |
| 4 | 4 | |
| 5 | 5 | describe("native review diff bridge", () => { |
| 6 | 6 | it("builds stable reset keys from the rendered diff identity", () => { |
| @@ -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 | +} | |
| @@ -13,7 +13,6 @@ export const REVIEW_MONO_FONT_FAMILY = Platform.select({ · 12 unmodified lines | ||
| 13 | 13 | }); |
| 14 | 14 | |
| 15 | 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 | 17 | export function renderVisibleWhitespace(value: string): string { |
| 19 | 18 | const expandedTabs = value.replace(/\t/g, " "); |
| @@ -38,12 +37,16 @@ function diffHighlightColor(change: ReviewRenderableLineRow["change"]): string | · 18 unmodified lines | ||
| 38 | 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 | 45 | if (props.change === "delete") { |
| 43 | 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 | 48 | <View> |
| 46 | − {Array.from({ length: REVIEW_DELETE_STRIPE_COUNT }, (_, index) => ( | |
| 49 | + {Array.from({ length: Math.ceil(height / 2) }, (_, index) => ( | |
| 47 | 50 | <View key={index}> |
| 48 | 51 | <View className="w-[5px] bg-rose-400" style={{ height: 1 }} /> |
| 49 | 52 | <View style={{ height: 1 }} /> |
| @@ -55,7 +58,7 @@ export function ReviewChangeBar(props: { readonly change: ReviewRenderableLineRo · 5 unmodified lines | ||
| 55 | 58 | } |
| 56 | 59 | |
| 57 | 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 | 62 | <View className={cn("h-full w-[5px] flex-1", changeBarTone(props.change))} /> |
| 60 | 63 | </View> |
| 61 | 64 | ); |
| @@ -66,7 +69,11 @@ export function DiffTokenText(props: { · 4 unmodified lines | ||
| 66 | 69 | readonly fallback: string; |
| 67 | 70 | readonly change?: ReviewRenderableLineRow["change"]; |
| 68 | 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 | 77 | if (!props.tokens || props.tokens.length === 0) { |
| 71 | 78 | return ( |
| 72 | 79 | <NativeText |
| @@ -75,8 +82,8 @@ export function DiffTokenText(props: { · 2 unmodified lines | ||
| 75 | 82 | className={cn("font-normal text-foreground", props.className)} |
| 76 | 83 | style={{ |
| 77 | 84 | fontFamily: REVIEW_MONO_FONT_FAMILY, |
| 78 | − fontSize: MOBILE_CODE_SURFACE.fontSize, | |
| 79 | − lineHeight: MOBILE_CODE_SURFACE.rowHeight, | |
| 85 | + fontSize, | |
| 86 | + lineHeight, | |
| 80 | 87 | }} |
| 81 | 88 | > |
| 82 | 89 | {renderVisibleWhitespace(props.fallback || " ")} |
| @@ -91,8 +98,8 @@ export function DiffTokenText(props: { · 8 unmodified lines | ||
| 91 | 98 | className={cn("font-normal text-foreground", props.className)} |
| 92 | 99 | style={{ |
| 93 | 100 | fontFamily: REVIEW_MONO_FONT_FAMILY, |
| 94 | − fontSize: MOBILE_CODE_SURFACE.fontSize, | |
| 95 | − lineHeight: MOBILE_CODE_SURFACE.rowHeight, | |
| 101 | + fontSize, | |
| 102 | + lineHeight, | |
| 96 | 103 | }} |
| 97 | 104 | > |
| 98 | 105 | {(() => { |
| @@ -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 | +}); | |
| @@ -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 | +} | |
| @@ -2,43 +2,12 @@ import { useCallback, useMemo, useState } from "react"; · 1 unmodified line | ||
| 2 | 2 | import type { NativeSyntheticEvent } from "react-native"; |
| 3 | 3 | |
| 4 | 4 | import { type NativeReviewDiffHighlightScheme } from "../diffs/nativeReviewDiffHighlighter"; |
| 5 | −import { | |
| 6 | − createNativeReviewDiffTheme, | |
| 7 | − NATIVE_REVIEW_DIFF_STYLE, | |
| 8 | − type NativeReviewDiffData, | |
| 9 | −} from "./nativeReviewDiffAdapter"; | |
| 5 | +import { createNativeReviewDiffTheme, type NativeReviewDiffData } from "./nativeReviewDiffAdapter"; | |
| 6 | +import { useAppearanceCodeSurface } from "../settings/appearance/useAppearanceCodeSurface"; | |
| 10 | 7 | import { useNativeReviewDiffHighlighting } from "./useNativeReviewDiffHighlighting"; |
| 8 | +import { buildNativeReviewTokensResetKey } from "./reviewDiffBridgeKeys"; | |
| 11 | 9 | |
| 12 | −export function hashReviewDiffKey(diff: string | null | undefined): string { | |
| 13 | − if (!diff) { | |
| 14 | − return "empty"; | |
| 15 | − } | |
| 16 | − | |
| 17 | − let hash = 5381; | |
| 18 | − for (let index = 0; index < diff.length; index += 1) { | |
| 19 | − hash = (hash * 33) ^ diff.charCodeAt(index); | |
| 20 | − } | |
| 21 | − | |
| 22 | − return `${diff.length}:${(hash >>> 0).toString(36)}`; | |
| 23 | −} | |
| 24 | − | |
| 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 | −} | |
| 10 | +export { buildNativeReviewTokensResetKey, hashReviewDiffKey } from "./reviewDiffBridgeKeys"; | |
| 42 | 11 | |
| 43 | 12 | export function useNativeReviewDiffBridge(input: { |
| 44 | 13 | readonly threadKey: string | null; |
| @@ -62,6 +31,7 @@ export function useNativeReviewDiffBridge(input: { · 17 unmodified lines | ||
| 62 | 31 | threadKey, |
| 63 | 32 | viewedFileIds, |
| 64 | 33 | } = input; |
| 34 | + const { nativeReviewDiffStyle } = useAppearanceCodeSurface(); | |
| 65 | 35 | const [collapsedCommentIds, setCollapsedCommentIds] = useState<ReadonlySet<string>>( |
| 66 | 36 | () => new Set(), |
| 67 | 37 | ); |
| @@ -76,7 +46,7 @@ export function useNativeReviewDiffBridge(input: { · 8 unmodified lines | ||
| 76 | 46 | [collapsedCommentIds], |
| 77 | 47 | ); |
| 78 | 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 | 50 | const tokensResetKey = useMemo( |
| 81 | 51 | () => |
| 82 | 52 | buildNativeReviewTokensResetKey({ |
| @@ -1,12 +1,11 @@ | ||
| 1 | 1 | import { useCallback, useEffect, useMemo, useState } from "react"; |
| 2 | 2 | import type { NativeSyntheticEvent } from "react-native"; |
| 3 | −import { useRouter } from "expo-router"; | |
| 3 | +import { useNavigation } from "@react-navigation/native"; | |
| 4 | 4 | import * as Arr from "effect/Array"; |
| 5 | 5 | import { pipe } from "effect/Function"; |
| 6 | 6 | import * as Result from "effect/Result"; |
| 7 | 7 | |
| 8 | 8 | import type { EnvironmentId, ThreadId } from "@t3tools/contracts"; |
| 9 | − | |
| 10 | 9 | import { |
| 11 | 10 | buildReviewCommentTarget, |
| 12 | 11 | clearReviewCommentTarget, |
| @@ -34,7 +33,7 @@ export function useReviewCommentSelectionController(input: { · 21 unmodified lines | ||
| 34 | 33 | readonly nativeReviewDiffData: NativeReviewDiffData; |
| 35 | 34 | }) { |
| 36 | 35 | const { environmentId, nativeReviewDiffData, selectedSection, threadId } = input; |
| 37 | − const { push } = useRouter(); | |
| 36 | + const navigation = useNavigation(); | |
| 38 | 37 | const activeCommentTarget = useReviewCommentTarget(); |
| 39 | 38 | const [pendingNativeCommentSelection, setPendingNativeCommentSelection] = |
| 40 | 39 | useState<PendingNativeCommentSelection | null>(null); |
| @@ -44,11 +43,11 @@ export function useReviewCommentSelectionController(input: { · 3 unmodified lines | ||
| 44 | 43 | return; |
| 45 | 44 | } |
| 46 | 45 | |
| 47 | − push({ | |
| 48 | − pathname: "/threads/[environmentId]/[threadId]/review-comment", | |
| 49 | − params: { environmentId, threadId }, | |
| 46 | + navigation.navigate("ThreadReviewComment", { | |
| 47 | + environmentId, | |
| 48 | + threadId, | |
| 50 | 49 | }); |
| 51 | − }, [environmentId, push, threadId]); | |
| 50 | + }, [environmentId, navigation, threadId]); | |
| 52 | 51 | |
| 53 | 52 | const selectedRowIds = useMemo(() => { |
| 54 | 53 | if ( |
| @@ -1,11 +1,13 @@ | ||
| 1 | 1 | import { useEffect, useMemo } from "react"; |
| 2 | 2 | |
| 3 | 3 | import { countReviewCommentContexts, parseReviewInlineComments } from "./reviewCommentSelection"; |
| 4 | −import { buildNativeReviewDiffData } from "./nativeReviewDiffAdapter"; | |
| 4 | +import { getCachedNativeReviewDiffData } from "./nativeReviewDiffAdapter"; | |
| 5 | 5 | import { markReviewEvent, measureReviewWork } from "./reviewPerf"; |
| 6 | 6 | import { getCachedReviewParsedDiff } from "./reviewState"; |
| 7 | 7 | import type { ReviewParsedDiff, ReviewSectionItem } from "./reviewModel"; |
| 8 | 8 | |
| 9 | +const EMPTY_INLINE_REVIEW_COMMENTS = Object.freeze([]); | |
| 10 | + | |
| 9 | 11 | function isReviewDiffDebugLoggingEnabled(): boolean { |
| 10 | 12 | return typeof __DEV__ !== "undefined" ? __DEV__ : false; |
| 11 | 13 | } |
| @@ -43,6 +45,7 @@ export function useReviewDiffData(input: { · 31 unmodified lines | ||
| 43 | 45 | readonly draftMessage: string; |
| 44 | 46 | }) { |
| 45 | 47 | const { draftMessage, selectedSection, threadKey } = input; |
| 48 | + const selectedSectionId = selectedSection?.id ?? null; | |
| 46 | 49 | const parsedDiff = useMemo( |
| 47 | 50 | () => |
| 48 | 51 | measureReviewWork("parse-diff", () => |
| @@ -59,17 +62,16 @@ export function useReviewDiffData(input: { · 10 unmodified lines | ||
| 59 | 62 | () => parseReviewInlineComments(draftMessage), |
| 60 | 63 | [draftMessage], |
| 61 | 64 | ); |
| 62 | − const selectedSectionInlineComments = useMemo( | |
| 63 | − () => | |
| 64 | − selectedSection | |
| 65 | − ? inlineReviewComments.filter((comment) => comment.sectionId === selectedSection.id) | |
| 66 | − : [], | |
| 67 | − [inlineReviewComments, selectedSection], | |
| 68 | − ); | |
| 65 | + const selectedSectionInlineComments = useMemo(() => { | |
| 66 | + if (!selectedSectionId || inlineReviewComments.length === 0) { | |
| 67 | + return EMPTY_INLINE_REVIEW_COMMENTS; | |
| 68 | + } | |
| 69 | + return inlineReviewComments.filter((comment) => comment.sectionId === selectedSectionId); | |
| 70 | + }, [inlineReviewComments, selectedSectionId]); | |
| 69 | 71 | const nativeReviewDiffData = useMemo( |
| 70 | 72 | () => |
| 71 | 73 | measureReviewWork("build-native-diff-data", () => |
| 72 | − buildNativeReviewDiffData({ | |
| 74 | + getCachedNativeReviewDiffData({ | |
| 73 | 75 | parsedDiff, |
| 74 | 76 | comments: selectedSectionInlineComments, |
| 75 | 77 | }), |
| @@ -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 | +} | |
| @@ -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 | +} | |
| @@ -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 | +} | |
| @@ -1,5 +1,6 @@ | ||
| 1 | 1 | import { useAuth } from "@clerk/expo"; |
| 2 | −import { Stack, useRouter } from "expo-router"; | |
| 2 | +import { NativeHeaderToolbar } from "../../native/StackHeader"; | |
| 3 | +import { useNavigation } from "@react-navigation/native"; | |
| 3 | 4 | import { SymbolView } from "expo-symbols"; |
| 4 | 5 | import { |
| 5 | 6 | connectionStatusText, |
| @@ -22,26 +23,26 @@ import { AppText as Text } from "../../components/AppText"; · 16 unmodified lines | ||
| 22 | 23 | import { |
| 23 | 24 | type RelayEnvironmentView, |
| 24 | 25 | 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"; | |
| 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"; | |
| 31 | 32 | import { cn } from "../../lib/cn"; |
| 32 | 33 | import { copyTextWithHaptic } from "../../lib/copyTextWithHaptic"; |
| 33 | 34 | import { useThemeColor } from "../../lib/useThemeColor"; |
| 34 | 35 | import type { ConnectedEnvironmentSummary } from "../../state/remote-runtime-types"; |
| 35 | 36 | import { useRemoteConnections } from "../../state/use-remote-environment-registry"; |
| 36 | 37 | |
| 37 | −export default function SettingsEnvironmentsRouteScreen() { | |
| 38 | +export function SettingsEnvironmentsRouteScreen() { | |
| 38 | 39 | const { |
| 39 | 40 | connectedEnvironments, |
| 40 | 41 | onReconnectEnvironment, |
| 41 | 42 | onRemoveEnvironmentPress, |
| 42 | 43 | onUpdateEnvironment, |
| 43 | 44 | } = useRemoteConnections(); |
| 44 | − const router = useRouter(); | |
| 45 | + const navigation = useNavigation(); | |
| 45 | 46 | const insets = useSafeAreaInsets(); |
| 46 | 47 | const { localEnvironments, connectedCloudEnvironments } = splitEnvironmentSections({ |
| 47 | 48 | connectedEnvironments, |
| @@ -50,6 +51,7 @@ export default function SettingsEnvironmentsRouteScreen() { · 2 unmodified lines | ||
| 50 | 51 | const hasLocalEnvironments = localEnvironments.length > 0; |
| 51 | 52 | const [expandedId, setExpandedId] = useState<EnvironmentId | null>(null); |
| 52 | 53 | const accentColor = useThemeColor("--color-icon-muted"); |
| 54 | + const headerIconColor = useThemeColor("--color-icon"); | |
| 53 | 55 | |
| 54 | 56 | const handleToggle = useCallback((environmentId: EnvironmentId) => { |
| 55 | 57 | setExpandedId((prev) => (prev === environmentId ? null : environmentId)); |
| @@ -57,18 +59,14 @@ export default function SettingsEnvironmentsRouteScreen() { · 1 unmodified line | ||
| 57 | 59 | |
| 58 | 60 | return ( |
| 59 | 61 | <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 | |
| 62 | + <NativeHeaderToolbar placement="right"> | |
| 63 | + <NativeHeaderToolbar.Button | |
| 67 | 64 | icon="plus" |
| 68 | − onPress={() => router.push("/settings/environment-new")} | |
| 65 | + onPress={() => navigation.navigate("SettingsSheet", { screen: "SettingsEnvironmentNew" })} | |
| 69 | 66 | separateBackground |
| 67 | + tintColor={headerIconColor} | |
| 70 | 68 | /> |
| 71 | − </Stack.Toolbar> | |
| 69 | + </NativeHeaderToolbar> | |
| 72 | 70 | <ScrollView |
| 73 | 71 | contentInsetAdjustmentBehavior="automatic" |
| 74 | 72 | showsVerticalScrollIndicator={false} |
| @@ -111,7 +109,7 @@ export default function SettingsEnvironmentsRouteScreen() { · 36 unmodified lines | ||
| 111 | 109 | type="monochrome" |
| 112 | 110 | /> |
| 113 | 111 | </View> |
| 114 | − <Text className="text-center text-sm leading-[20px] text-foreground-muted"> | |
| 112 | + <Text className="text-center text-sm leading-normal text-foreground-muted"> | |
| 115 | 113 | No environments connected yet.{"\n"}Tap{" "} |
| 116 | 114 | <Text className="font-t3-bold text-foreground">+</Text> to add one. |
| 117 | 115 | </Text> |
| @@ -204,7 +202,7 @@ function ConfiguredCloudEnvironmentRows(props: { · 86 unmodified lines | ||
| 204 | 202 | ) : controller.relayDiscovery.isRefreshing ? ( |
| 205 | 203 | <View collapsable={false} className="items-center gap-3 rounded-[24px] bg-card p-6"> |
| 206 | 204 | <ActivityIndicator color={iconColor} /> |
| 207 | − <Text className="text-center text-sm leading-[20px] text-foreground-muted"> | |
| 205 | + <Text className="text-center text-sm leading-normal text-foreground-muted"> | |
| 208 | 206 | Loading linked cloud environments. |
| 209 | 207 | </Text> |
| 210 | 208 | </View> |
| @@ -213,16 +211,14 @@ function ConfiguredCloudEnvironmentRows(props: { · 2 unmodified lines | ||
| 213 | 211 | <Text className="text-base font-t3-bold text-foreground"> |
| 214 | 212 | Could not load T3 Cloud environments |
| 215 | 213 | </Text> |
| 216 | − <Text className="text-sm leading-[18px] text-foreground-muted"> | |
| 217 | − {controller.relayDiscovery.error} | |
| 218 | − </Text> | |
| 214 | + <Text className="text-sm text-foreground-muted">{controller.relayDiscovery.error}</Text> | |
| 219 | 215 | {controller.relayDiscovery.errorTraceId ? ( |
| 220 | 216 | <CopyTraceIdButton traceId={controller.relayDiscovery.errorTraceId} /> |
| 221 | 217 | ) : null} |
| 222 | 218 | </View> |
| 223 | 219 | ) : ( |
| 224 | 220 | <View collapsable={false} className="rounded-[24px] bg-card p-5"> |
| 225 | − <Text className="text-sm leading-[20px] text-foreground-muted"> | |
| 221 | + <Text className="text-sm leading-normal text-foreground-muted"> | |
| 226 | 222 | No additional linked cloud environments. |
| 227 | 223 | </Text> |
| 228 | 224 | </View> |
| @@ -361,7 +357,7 @@ function CloudEnvironmentRowShell(props: { · 132 unmodified lines | ||
| 361 | 357 | <View className="min-w-0 flex-row items-center gap-2"> |
| 362 | 358 | <ConnectionStatusDot state={props.connectionState} pulse={shouldPulse} size={7} /> |
| 363 | 359 | <Text |
| 364 | − className="min-w-0 flex-shrink text-base font-t3-bold leading-[21px] text-foreground" | |
| 360 | + className="min-w-0 flex-shrink text-base font-t3-bold leading-snug text-foreground" | |
| 365 | 361 | numberOfLines={1} |
| 366 | 362 | > |
| 367 | 363 | {props.label} |
| @@ -370,7 +366,7 @@ function CloudEnvironmentRowShell(props: { · 2 unmodified lines | ||
| 370 | 366 | {props.connectionError ? ( |
| 371 | 367 | <Text |
| 372 | 368 | aria-hidden |
| 373 | − className={cn("absolute left-0 right-0 text-xs leading-[16px]", statusClassName)} | |
| 369 | + className={cn("absolute left-0 right-0 text-xs", statusClassName)} | |
| 374 | 370 | onTextLayout={onMeasuredErrorTextLayout} |
| 375 | 371 | style={{ opacity: 0, zIndex: -1 }} |
| 376 | 372 | > |
| @@ -384,7 +380,7 @@ function CloudEnvironmentRowShell(props: { · 7 unmodified lines | ||
| 384 | 380 | className="min-w-0 flex-row items-start gap-1" |
| 385 | 381 | > |
| 386 | 382 | <Text |
| 387 | − className={cn("min-w-0 flex-1 text-xs leading-[16px]", statusClassName)} | |
| 383 | + className={cn("min-w-0 flex-1 text-xs", statusClassName)} | |
| 388 | 384 | numberOfLines={isErrorExpanded ? undefined : 1} |
| 389 | 385 | > |
| 390 | 386 | {statusText} |
| @@ -394,7 +390,7 @@ function CloudEnvironmentRowShell(props: { · 3 unmodified lines | ||
| 394 | 390 | <Text |
| 395 | 391 | accessibilityHint="Copies the trace ID" |
| 396 | 392 | accessibilityRole="button" |
| 397 | − className={cn("text-xs leading-[16px] underline", statusClassName)} | |
| 393 | + className={cn("text-xs underline", statusClassName)} | |
| 398 | 394 | onLongPress={(event) => { |
| 399 | 395 | event.stopPropagation(); |
| 400 | 396 | copyTextWithHaptic(errorTraceId, { target: "connection-trace-id" }); |
| @@ -1,11 +1,11 @@ | ||
| 1 | 1 | import { useAuth, useUser } from "@clerk/expo"; |
| 2 | 2 | import * as Notifications from "expo-notifications"; |
| 3 | −import { Link, Stack, useRouter } from "expo-router"; | |
| 3 | +import { useNavigation } from "@react-navigation/native"; | |
| 4 | +import { NativeStackScreenOptions } from "../../native/StackHeader"; | |
| 4 | 5 | import { SymbolView } from "expo-symbols"; |
| 5 | 6 | import * as Effect from "effect/Effect"; |
| 6 | 7 | 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"; | |
| 8 | +import { Alert, Linking, Platform, ScrollView, View } from "react-native"; | |
| 9 | 9 | import { useSafeAreaInsets } from "react-native-safe-area-context"; |
| 10 | 10 | |
| 11 | 11 | import { |
| @@ -16,25 +16,51 @@ import { · 4 unmodified lines | ||
| 16 | 16 | squashAtomCommandFailure, |
| 17 | 17 | } from "@t3tools/client-runtime/state/runtime"; |
| 18 | 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"; | |
| 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"; | |
| 28 | 27 | import { runtime } from "../../lib/runtime"; |
| 29 | 28 | import { loadPreferences } from "../../lib/storage"; |
| 30 | 29 | import { useThemeColor } from "../../lib/useThemeColor"; |
| 31 | 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"; | |
| 32 | 34 | |
| 33 | 35 | type NotificationStatus = "checking" | "enabled" | "disabled" | "unsupported"; |
| 34 | 36 | type LiveActivityStatus = "checking" | "enabled" | "disabled" | "signed-out" | "linking"; |
| 35 | 37 | |
| 36 | −export default function SettingsRouteScreen() { | |
| 37 | − return hasCloudPublicConfig() ? <ConfiguredSettingsRouteScreen /> : <LocalSettingsRouteScreen />; | |
| 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 | + ); | |
| 38 | 64 | } |
| 39 | 65 | |
| 40 | 66 | function LocalSettingsRouteScreen() { |
| @@ -44,7 +70,6 @@ function LocalSettingsRouteScreen() { · 3 unmodified lines | ||
| 44 | 70 | |
| 45 | 71 | return ( |
| 46 | 72 | <View collapsable={false} className="flex-1 bg-sheet"> |
| 47 | − <Stack.Screen options={{ title: "Settings" }} /> | |
| 48 | 73 | <ScrollView |
| 49 | 74 | contentInsetAdjustmentBehavior="automatic" |
| 50 | 75 | showsVerticalScrollIndicator={false} |
| @@ -61,10 +86,14 @@ function LocalSettingsRouteScreen() { · 10 unmodified lines | ||
| 61 | 86 | icon="desktopcomputer" |
| 62 | 87 | label="Environments" |
| 63 | 88 | value={`${environmentCount}`} |
| 64 | − href="/settings/environments" | |
| 89 | + target="SettingsEnvironments" | |
| 65 | 90 | /> |
| 66 | 91 | </SettingsSection> |
| 67 | 92 | |
| 93 | + <SettingsSection title="Appearance"> | |
| 94 | + <SettingsRow icon="paintbrush" label="Appearance" target="SettingsAppearance" /> | |
| 95 | + </SettingsSection> | |
| 96 | + | |
| 68 | 97 | <ArchivedThreadsSettingsSection /> |
| 69 | 98 | |
| 70 | 99 | <AppSettingsSection /> |
| @@ -75,7 +104,7 @@ function LocalSettingsRouteScreen() { · 4 unmodified lines | ||
| 75 | 104 | |
| 76 | 105 | function ConfiguredSettingsRouteScreen() { |
| 77 | 106 | const insets = useSafeAreaInsets(); |
| 78 | − const { push } = useRouter(); | |
| 107 | + const navigation = useNavigation(); | |
| 79 | 108 | const { expand: expandClerkSheet } = useClerkSettingsSheetDetent(); |
| 80 | 109 | const { getToken, isLoaded, isSignedIn } = useAuth({ treatPendingAsSignedOut: false }); |
| 81 | 110 | const { user } = useUser(); |
| @@ -186,10 +215,13 @@ function ConfiguredSettingsRouteScreen() { · 104 unmodified lines | ||
| 186 | 215 | "Live Activity updates require approved T3 Cloud access so relay can deliver updates to this device.", |
| 187 | 216 | [ |
| 188 | 217 | { text: "Cancel", style: "cancel" }, |
| 189 | − { text: "Continue", onPress: () => push("/settings/waitlist") }, | |
| 218 | + { | |
| 219 | + text: "Continue", | |
| 220 | + onPress: () => navigation.navigate("SettingsSheet", { screen: "SettingsWaitlist" }), | |
| 221 | + }, | |
| 190 | 222 | ], |
| 191 | 223 | ); |
| 192 | − }, [push]); | |
| 224 | + }, [navigation]); | |
| 193 | 225 | |
| 194 | 226 | const linkEnvironments = useCallback(async () => { |
| 195 | 227 | if (!isSignedIn) { |
| @@ -316,16 +348,15 @@ function ConfiguredSettingsRouteScreen() { · 120 unmodified lines | ||
| 316 | 348 | const openAccount = useCallback(() => { |
| 317 | 349 | if (!isLoaded) return; |
| 318 | 350 | if (!isSignedIn) { |
| 319 | − push("/settings/waitlist"); | |
| 351 | + navigation.navigate("SettingsSheet", { screen: "SettingsWaitlist" }); | |
| 320 | 352 | return; |
| 321 | 353 | } |
| 322 | 354 | expandClerkSheet(); |
| 323 | − push("/settings/auth"); | |
| 324 | − }, [expandClerkSheet, isLoaded, isSignedIn, push]); | |
| 355 | + navigation.navigate("SettingsSheet", { screen: "SettingsAuth" }); | |
| 356 | + }, [expandClerkSheet, isLoaded, isSignedIn, navigation]); | |
| 325 | 357 | |
| 326 | 358 | return ( |
| 327 | 359 | <View collapsable={false} className="flex-1 bg-sheet"> |
| 328 | − <Stack.Screen options={{ title: "Settings" }} /> | |
| 329 | 360 | <ScrollView |
| 330 | 361 | contentInsetAdjustmentBehavior="automatic" |
| 331 | 362 | showsVerticalScrollIndicator={false} |
| @@ -346,7 +377,7 @@ function ConfiguredSettingsRouteScreen() { · 14 unmodified lines | ||
| 346 | 377 | onPress={openAccount} |
| 347 | 378 | /> |
| 348 | 379 | </SettingsSection> |
| 349 | − <Text className="px-2 text-sm leading-[18px] text-foreground-muted"> | |
| 380 | + <Text className="px-2 text-sm text-foreground-muted"> | |
| 350 | 381 | T3 Code works locally without signing in. Cloud features are optional. |
| 351 | 382 | </Text> |
| 352 | 383 | </View> |
| @@ -356,7 +387,7 @@ function ConfiguredSettingsRouteScreen() { · 3 unmodified lines | ||
| 356 | 387 | icon="desktopcomputer" |
| 357 | 388 | label="Environments" |
| 358 | 389 | value={`${environmentCount}`} |
| 359 | − href="/settings/environments" | |
| 390 | + target="SettingsEnvironments" | |
| 360 | 391 | /> |
| 361 | 392 | <SettingsSwitchRow |
| 362 | 393 | icon="bell.badge" |
| @@ -376,6 +407,10 @@ function ConfiguredSettingsRouteScreen() { · 13 unmodified lines | ||
| 376 | 407 | /> |
| 377 | 408 | </SettingsSection> |
| 378 | 409 | |
| 410 | + <SettingsSection title="Appearance"> | |
| 411 | + <SettingsRow icon="paintbrush" label="Appearance" target="SettingsAppearance" /> | |
| 412 | + </SettingsSection> | |
| 413 | + | |
| 379 | 414 | <ArchivedThreadsSettingsSection /> |
| 380 | 415 | |
| 381 | 416 | <AppSettingsSection /> |
| @@ -384,22 +419,6 @@ function ConfiguredSettingsRouteScreen() { · 2 unmodified lines | ||
| 384 | 419 | ); |
| 385 | 420 | } |
| 386 | 421 | |
| 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 | 422 | function AppSettingsSection() { |
| 404 | 423 | const icon = useThemeColor("--color-icon"); |
| 405 | 424 | |
| @@ -423,93 +442,7 @@ function AppSettingsSection() { · 17 unmodified lines | ||
| 423 | 442 | function ArchivedThreadsSettingsSection() { |
| 424 | 443 | return ( |
| 425 | 444 | <SettingsSection title="Threads"> |
| 426 | − <SettingsRow icon="archivebox" label="Archived Threads" href="/settings/archive" /> | |
| 445 | + <SettingsRow icon="archivebox" label="Archived Threads" target="SettingsArchive" /> | |
| 427 | 446 | </SettingsSection> |
| 428 | 447 | ); |
| 429 | 448 | } |
| 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 | −} | |
| @@ -1,36 +1,41 @@ | ||
| 1 | 1 | import { useAuth } from "@clerk/expo"; |
| 2 | −import { Redirect, Stack, useFocusEffect, useRouter } from "expo-router"; | |
| 2 | +import { StackActions, useFocusEffect, useNavigation } from "@react-navigation/native"; | |
| 3 | 3 | import { useCallback } from "react"; |
| 4 | 4 | import { ScrollView } from "react-native"; |
| 5 | 5 | |
| 6 | −import { CloudWaitlistEnrollment } from "../../features/cloud/CloudWaitlistEnrollment"; | |
| 7 | −import { useClerkSettingsSheetDetent } from "../../features/cloud/ClerkSettingsSheetDetent"; | |
| 8 | −import { hasCloudPublicConfig } from "../../features/cloud/publicConfig"; | |
| 6 | +import { CloudWaitlistEnrollment } from "../cloud/CloudWaitlistEnrollment"; | |
| 7 | +import { useClerkSettingsSheetDetent } from "../cloud/ClerkSettingsSheetDetent"; | |
| 8 | +import { hasCloudPublicConfig } from "../cloud/publicConfig"; | |
| 9 | 9 | |
| 10 | −export default function SettingsWaitlistRouteScreen() { | |
| 11 | − return hasCloudPublicConfig() ? ( | |
| 12 | − <ConfiguredSettingsWaitlistRouteScreen /> | |
| 13 | − ) : ( | |
| 14 | − <Redirect href="/settings" /> | |
| 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]), | |
| 15 | 19 | ); |
| 20 | + | |
| 21 | + return hasCloudPublicConfig() ? <ConfiguredSettingsWaitlistRouteScreen /> : null; | |
| 16 | 22 | } |
| 17 | 23 | |
| 18 | 24 | function ConfiguredSettingsWaitlistRouteScreen() { |
| 19 | 25 | const { isLoaded, isSignedIn } = useAuth({ treatPendingAsSignedOut: false }); |
| 20 | 26 | const { expand } = useClerkSettingsSheetDetent(); |
| 21 | − const router = useRouter(); | |
| 27 | + const navigation = useNavigation(); | |
| 22 | 28 | |
| 23 | 29 | useFocusEffect( |
| 24 | 30 | useCallback(() => { |
| 25 | 31 | if (isLoaded && isSignedIn) { |
| 26 | − router.replace("/settings"); | |
| 32 | + navigation.dispatch(StackActions.replace("Settings")); | |
| 27 | 33 | } |
| 28 | − }, [isLoaded, isSignedIn, router]), | |
| 34 | + }, [isLoaded, isSignedIn, navigation]), | |
| 29 | 35 | ); |
| 30 | 36 | |
| 31 | 37 | return ( |
| 32 | 38 | <> |
| 33 | − <Stack.Screen options={{ title: "Join the waitlist" }} /> | |
| 34 | 39 | <ScrollView |
| 35 | 40 | automaticallyAdjustKeyboardInsets |
| 36 | 41 | contentContainerStyle={{ |
| @@ -46,7 +51,7 @@ function ConfiguredSettingsWaitlistRouteScreen() { · 9 unmodified lines | ||
| 46 | 51 | <CloudWaitlistEnrollment |
| 47 | 52 | onSignIn={() => { |
| 48 | 53 | expand(); |
| 49 | − router.push("/settings/auth"); | |
| 54 | + navigation.navigate("SettingsSheet", { screen: "SettingsAuth" }); | |
| 50 | 55 | }} |
| 51 | 56 | /> |
| 52 | 57 | </ScrollView> |
| @@ -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 | +} | |
| @@ -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 | +} | |
| @@ -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 | +} | |
| @@ -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 | +} | |
| @@ -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 | +} | |
| @@ -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 | +} | |
| @@ -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 | +} | |
| @@ -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 | +} | |
| @@ -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 | +} | |
| @@ -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 | +} | |
| @@ -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 | +} | |
| @@ -0,0 +1 @@ | ||
| 1 | +export type SettingsSheetTarget = "SettingsEnvironments" | "SettingsArchive" | "SettingsAppearance"; | |
| @@ -12,7 +12,10 @@ import { · 11 unmodified lines | ||
| 12 | 12 | |
| 13 | 13 | import { AppText as Text } from "../../components/AppText"; |
| 14 | 14 | import { MOBILE_TYPOGRAPHY } from "../../lib/typography"; |
| 15 | −import { resolveNativeTerminalSurfaceView } from "./nativeTerminalModule"; | |
| 15 | +import { | |
| 16 | + getNativeTerminalHardwareKeyRevision, | |
| 17 | + resolveNativeTerminalSurfaceView, | |
| 18 | +} from "./nativeTerminalModule"; | |
| 16 | 19 | import { |
| 17 | 20 | buildGhosttyThemeConfig, |
| 18 | 21 | getPierreTerminalTheme, |
| @@ -92,9 +95,9 @@ const FallbackTerminalSurface = memo(function FallbackTerminalSurface(props: Ter · 73 unmodified lines | ||
| 92 | 95 | > |
| 93 | 96 | <View style={{ flex: 1, paddingHorizontal: 10, paddingVertical: 8 }}> |
| 94 | 97 | <Text |
| 98 | + className="text-2xs" | |
| 95 | 99 | style={{ |
| 96 | 100 | color: theme.mutedForeground, |
| 97 | − fontSize: MOBILE_TYPOGRAPHY.caption.fontSize, | |
| 98 | 101 | paddingBottom: 8, |
| 99 | 102 | }} |
| 100 | 103 | > |
| @@ -137,11 +140,11 @@ const FallbackTerminalSurface = memo(function FallbackTerminalSurface(props: Ter · 36 unmodified lines | ||
| 137 | 140 | placeholder="type and press return" |
| 138 | 141 | placeholderTextColor={theme.mutedForeground} |
| 139 | 142 | returnKeyType="send" |
| 143 | + className="text-sm" | |
| 140 | 144 | style={{ |
| 141 | 145 | color: theme.foreground, |
| 142 | 146 | flex: 1, |
| 143 | 147 | fontFamily: "Menlo", |
| 144 | − fontSize: MOBILE_TYPOGRAPHY.footnote.fontSize, | |
| 145 | 148 | padding: 0, |
| 146 | 149 | }} |
| 147 | 150 | onSubmitEditing={(event) => { |
| @@ -163,10 +166,10 @@ const FallbackTerminalSurface = memo(function FallbackTerminalSurface(props: Ter · 15 unmodified lines | ||
| 163 | 166 | onPress={() => props.onInput("\u0003")} |
| 164 | 167 | > |
| 165 | 168 | <Text |
| 169 | + className="text-2xs" | |
| 166 | 170 | style={{ |
| 167 | 171 | color: theme.foreground, |
| 168 | 172 | fontFamily: "DMSans_700Bold", |
| 169 | − fontSize: MOBILE_TYPOGRAPHY.caption.fontSize, | |
| 170 | 173 | }} |
| 171 | 174 | > |
| 172 | 175 | Ctrl-C |
| @@ -179,7 +182,6 @@ const FallbackTerminalSurface = memo(function FallbackTerminalSurface(props: Ter · 6 unmodified lines | ||
| 179 | 182 | |
| 180 | 183 | export const TerminalSurface = memo(function TerminalSurface(props: TerminalSurfaceProps) { |
| 181 | 184 | const fontSize = props.fontSize ?? MOBILE_TYPOGRAPHY.label.fontSize; |
| 182 | − const keyboardInputRef = useRef<TextInput>(null); | |
| 183 | 185 | const appearanceScheme = useColorScheme() === "light" ? "light" : "dark"; |
| 184 | 186 | const theme = props.theme ?? getPierreTerminalTheme(appearanceScheme); |
| 185 | 187 | const { onInput, onResize } = props; |
| @@ -190,15 +192,23 @@ export const TerminalSurface = memo(function TerminalSurface(props: TerminalSurf · 4 unmodified lines | ||
| 190 | 192 | terminalDebugLog("native:surface", { |
| 191 | 193 | terminalKey: props.terminalKey, |
| 192 | 194 | native: hasNativeSurface, |
| 195 | + // null = installed binary predates native hardware-key handling (rebuild needed). | |
| 196 | + hardwareKeyRevision: getNativeTerminalHardwareKeyRevision(), | |
| 193 | 197 | bufferLen: props.buffer.length, |
| 194 | 198 | isRunning: props.isRunning, |
| 195 | 199 | }); |
| 196 | 200 | }, [hasNativeSurface, props.buffer.length, props.isRunning, props.terminalKey]); |
| 197 | 201 | const handleNativeInput = useCallback( |
| 198 | 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 | 209 | onInput(event.nativeEvent.data); |
| 200 | 210 | }, |
| 201 | − [onInput], | |
| 211 | + [onInput, props.isRunning], | |
| 202 | 212 | ); |
| 203 | 213 | const handleNativeResize = useCallback( |
| 204 | 214 | (event: NativeSyntheticEvent<TerminalResizeEvent>) => { |
| @@ -210,33 +220,13 @@ export const TerminalSurface = memo(function TerminalSurface(props: TerminalSurf · 5 unmodified lines | ||
| 210 | 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 | 223 | if (NativeTerminalSurfaceView) { |
| 235 | 224 | return ( |
| 236 | 225 | <View style={props.style}> |
| 237 | 226 | <NativeTerminalSurfaceView |
| 238 | 227 | appearanceScheme={appearanceScheme} |
| 239 | 228 | backgroundColor={theme.background} |
| 229 | + focusRequest={props.isRunning ? (props.keyboardFocusRequest ?? 0) : 0} | |
| 240 | 230 | foregroundColor={theme.foreground} |
| 241 | 231 | mutedForegroundColor={theme.mutedForeground} |
| 242 | 232 | terminalKey={props.terminalKey} |
| @@ -247,23 +237,6 @@ export const TerminalSurface = memo(function TerminalSurface(props: TerminalSurf · 4 unmodified lines | ||
| 247 | 237 | onInput={handleNativeInput} |
| 248 | 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 | 240 | </View> |
| 268 | 241 | ); |
| 269 | 242 | } |
| @@ -1,9 +1,10 @@ | ||
| 1 | 1 | import { DEFAULT_TERMINAL_ID, EnvironmentId, ThreadId } from "@t3tools/contracts"; |
| 2 | 2 | import { type KnownTerminalSession } from "@t3tools/client-runtime/state/terminal"; |
| 3 | 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 | 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 | 8 | import { |
| 8 | 9 | KeyboardController, |
| 9 | 10 | KeyboardEvents, |
| @@ -24,8 +25,13 @@ import { useEnvironmentPresentation } from "../../state/presentation"; · 14 unmodified lines | ||
| 24 | 25 | import { terminalEnvironment } from "../../state/terminal"; |
| 25 | 26 | import { useAtomCommand } from "../../state/use-atom-command"; |
| 26 | 27 | import { useWorkspaceState } from "../../state/workspace"; |
| 27 | −import { buildThreadTerminalNavigation } from "../../lib/routes"; | |
| 28 | −import { MOBILE_TYPOGRAPHY } from "../../lib/typography"; | |
| 28 | +import { | |
| 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 | 35 | import { |
| 30 | 36 | useAttachedTerminalSession, |
| 31 | 37 | useKnownTerminalSessions, |
| @@ -33,9 +39,9 @@ import { · 1 unmodified line | ||
| 33 | 39 | import { useThreadSelection } from "../../state/use-thread-selection"; |
| 34 | 40 | import { useSelectedThreadDetail } from "../../state/use-thread-detail"; |
| 35 | 41 | import { EnvironmentConnectionNotice } from "../connection/EnvironmentConnectionNotice"; |
| 42 | +import { useAdaptiveWorkspaceLayout } from "../layout/AdaptiveWorkspaceLayout"; | |
| 36 | 43 | import { TerminalSurface } from "./NativeTerminalSurface"; |
| 37 | 44 | import { getPierreTerminalTheme } from "./terminalTheme"; |
| 38 | −import { loadPreferences, savePreferencesPatch } from "../../lib/storage"; | |
| 39 | 45 | import { terminalDebugLog } from "./terminalDebugLog"; |
| 40 | 46 | import { |
| 41 | 47 | getTerminalBufferReplayKey, |
| @@ -55,19 +61,7 @@ import { · 13 unmodified lines | ||
| 55 | 61 | resolveTerminalSessionLabel, |
| 56 | 62 | type TerminalMenuSession, |
| 57 | 63 | } from "./terminalMenu"; |
| 58 | −import { | |
| 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"; | |
| 64 | +import { cacheTerminalGridSize, getCachedTerminalGridSize } from "./terminalUiState"; | |
| 71 | 65 | |
| 72 | 66 | const DEFAULT_TERMINAL_COLS = 80; |
| 73 | 67 | const DEFAULT_TERMINAL_ROWS = 24; |
| @@ -153,19 +147,22 @@ function pickRunningTerminalSessionForBootstrap( · 79 unmodified lines | ||
| 153 | 147 | ); |
| 154 | 148 | } |
| 155 | 149 | |
| 156 | −export function ThreadTerminalRouteScreen() { | |
| 157 | − const router = useRouter(); | |
| 150 | +type ThreadTerminalRouteScreenProps = StaticScreenProps<{ | |
| 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 | 158 | const writeTerminal = useAtomCommand(terminalEnvironment.write, "terminal write"); |
| 159 | 159 | const resizeTerminal = useAtomCommand(terminalEnvironment.resize, "terminal resize"); |
| 160 | 160 | const clearTerminal = useAtomCommand(terminalEnvironment.clear, "terminal clear"); |
| 161 | 161 | const retryEnvironment = useAtomCommand(environmentCatalog.retryNow, "environment retry"); |
| 162 | 162 | const appearanceScheme = useColorScheme() === "light" ? "light" : "dark"; |
| 163 | 163 | const { state: workspaceState } = useWorkspaceState(); |
| 164 | − const params = useLocalSearchParams<{ | |
| 165 | − environmentId?: string | string[]; | |
| 166 | − threadId?: string | string[]; | |
| 167 | − terminalId?: string | string[]; | |
| 168 | − }>(); | |
| 164 | + const { layout, panes, togglePrimarySidebar } = useAdaptiveWorkspaceLayout(); | |
| 165 | + const params = props.route.params; | |
| 169 | 166 | const { selectedThread, selectedThreadProject, selectedEnvironmentConnection } = |
| 170 | 167 | useThreadSelection(); |
| 171 | 168 | const selectedThreadDetail = useSelectedThreadDetail(); |
| @@ -179,7 +176,12 @@ export function ThreadTerminalRouteScreen() { · 7 unmodified lines | ||
| 179 | 176 | const isEnvironmentReady = environment.presentation?.connection.phase === "connected"; |
| 180 | 177 | const requestedTerminalId = firstRouteParam(params.terminalId); |
| 181 | 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 | 185 | const cachedRouteGridSize = |
| 184 | 186 | routeEnvironmentId && routeThreadId |
| 185 | 187 | ? getCachedTerminalGridSize({ |
| @@ -239,7 +241,6 @@ export function ThreadTerminalRouteScreen() { · 53 unmodified lines | ||
| 239 | 241 | rows: DEFAULT_TERMINAL_ROWS, |
| 240 | 242 | }, |
| 241 | 243 | ); |
| 242 | − const [fontSize, setFontSize] = useState(cachedFontSize ?? DEFAULT_TERMINAL_FONT_SIZE); | |
| 243 | 244 | const [keyboardFocusRequest, setKeyboardFocusRequest] = useState(0); |
| 244 | 245 | const [isAccessoryDismissed, setIsAccessoryDismissed] = useState(false); |
| 245 | 246 | const bufferReplayTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null); |
| @@ -247,9 +248,6 @@ export function ThreadTerminalRouteScreen() { · 1 unmodified line | ||
| 247 | 248 | const lastBufferReplayKeyRef = useRef<string | null>(null); |
| 248 | 249 | const sentInitialInputKeyRef = useRef<string | null>(null); |
| 249 | 250 | const [readyBufferReplayKey, setReadyBufferReplayKey] = useState<string | null>(null); |
| 250 | − const [hasResolvedFontPreference, setHasResolvedFontPreference] = useState( | |
| 251 | − cachedFontSize !== null, | |
| 252 | − ); | |
| 253 | 251 | /** Default grid is always valid for attach; onResize refines cols/rows. Requiring a cached size blocked bootstrap for new terminal routes. */ |
| 254 | 252 | const [hasMeasuredSurface, setHasMeasuredSurface] = useState(true); |
| 255 | 253 | const [pendingModifierState, setPendingModifierState] = useState<{ |
| @@ -404,24 +402,10 @@ export function ThreadTerminalRouteScreen() { · 148 unmodified lines | ||
| 404 | 402 | ); |
| 405 | 403 | |
| 406 | 404 | const terminalTheme = getPierreTerminalTheme(appearanceScheme); |
| 405 | + const usesNativeHeaderGlass = Platform.OS === "ios"; | |
| 407 | 406 | const pendingModifier = |
| 408 | 407 | pendingModifierState.terminalId === terminalId ? pendingModifierState.value : null; |
| 409 | − const headerTitle = useMemo(() => { | |
| 410 | − const topLineParts = [ | |
| 411 | − selectedEnvironmentConnection?.environmentLabel ?? null, | |
| 412 | − selectedThreadProject?.title ?? null, | |
| 413 | − ].filter((value): value is string => Boolean(value)); | |
| 414 | − | |
| 415 | − return { | |
| 416 | − topLine: topLineParts.join(" \u00b7 "), | |
| 417 | − bottomLine: cwd ?? selectedThreadProject?.workspaceRoot ?? "", | |
| 418 | − }; | |
| 419 | − }, [ | |
| 420 | − cwd, | |
| 421 | − selectedEnvironmentConnection?.environmentLabel, | |
| 422 | − selectedThreadProject?.title, | |
| 423 | − selectedThreadProject?.workspaceRoot, | |
| 424 | − ]); | |
| 408 | + const headerSubtitle = selectedThreadProject?.title ?? ""; | |
| 425 | 409 | const terminalToolbarActions = useMemo<ReadonlyArray<TerminalToolbarAction>>(() => { |
| 426 | 410 | const modifierActions: ReadonlyArray<TerminalToolbarAction> = |
| 427 | 411 | hostPlatform === "mac" |
| @@ -546,8 +530,14 @@ export function ThreadTerminalRouteScreen() { · 118 unmodified lines | ||
| 546 | 530 | if (!shouldRedirectToRunningTerminal || !selectedThread || !runningSession) { |
| 547 | 531 | return; |
| 548 | 532 | } |
| 549 | − router.replace(buildThreadTerminalNavigation(selectedThread, runningSession.target.terminalId)); | |
| 550 | − }, [router, runningSession, selectedThread, shouldRedirectToRunningTerminal]); | |
| 533 | + navigation.dispatch( | |
| 534 | + StackActions.replace("ThreadTerminal", { | |
| 535 | + environmentId: String(selectedThread.environmentId), | |
| 536 | + threadId: String(selectedThread.id), | |
| 537 | + terminalId: runningSession.target.terminalId, | |
| 538 | + }), | |
| 539 | + ); | |
| 540 | + }, [navigation, runningSession, selectedThread, shouldRedirectToRunningTerminal]); | |
| 551 | 541 | |
| 552 | 542 | useEffect(() => { |
| 553 | 543 | const initialInput = pendingLaunch?.initialInput; |
| @@ -637,42 +627,6 @@ export function ThreadTerminalRouteScreen() { · 83 unmodified lines | ||
| 637 | 627 | setHasMeasuredSurface(true); |
| 638 | 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 | 630 | const writeInput = useCallback( |
| 677 | 631 | (data: string) => { |
| 678 | 632 | if (!selectedThread || !isRunning) { |
| @@ -772,9 +726,15 @@ export function ThreadTerminalRouteScreen() { · 93 unmodified lines | ||
| 772 | 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 | 740 | const handleOpenNewTerminal = useCallback(() => { |
| @@ -782,30 +742,25 @@ export function ThreadTerminalRouteScreen() { · 1 unmodified line | ||
| 782 | 742 | return; |
| 783 | 743 | } |
| 784 | 744 | |
| 785 | − router.replace( | |
| 786 | − buildThreadTerminalNavigation( | |
| 787 | − selectedThread, | |
| 788 | − nextOpenTerminalId({ | |
| 745 | + navigation.dispatch( | |
| 746 | + StackActions.replace("ThreadTerminal", { | |
| 747 | + environmentId: String(selectedThread.environmentId), | |
| 748 | + threadId: String(selectedThread.id), | |
| 749 | + terminalId: nextOpenTerminalId({ | |
| 789 | 750 | listedTerminalIds: terminalMenuSessions.map((session) => session.terminalId), |
| 790 | 751 | activeRouteTerminalId: terminalId, |
| 791 | 752 | }), |
| 792 | − ), | |
| 753 | + }), | |
| 793 | 754 | ); |
| 794 | − }, [router, selectedThread, terminalId, terminalMenuSessions]); | |
| 795 | − | |
| 796 | − const adjustFontSize = useCallback((delta: number) => { | |
| 797 | − setTimeout(() => { | |
| 798 | − setFontSize((current) => cacheTerminalFontSize(current + delta)); | |
| 799 | − }, 0); | |
| 800 | − }, []); | |
| 755 | + }, [navigation, selectedThread, terminalId, terminalMenuSessions]); | |
| 801 | 756 | |
| 802 | 757 | const handleDecreaseFontSize = useCallback(() => { |
| 803 | − adjustFontSize(-TERMINAL_FONT_SIZE_STEP); | |
| 804 | − }, [adjustFontSize]); | |
| 758 | + setTerminalFontSize(stepTerminalFontSize(fontSize, -1)); | |
| 759 | + }, [fontSize, setTerminalFontSize]); | |
| 805 | 760 | |
| 806 | 761 | const handleIncreaseFontSize = useCallback(() => { |
| 807 | − adjustFontSize(TERMINAL_FONT_SIZE_STEP); | |
| 808 | − }, [adjustFontSize]); | |
| 762 | + setTerminalFontSize(stepTerminalFontSize(fontSize, 1)); | |
| 763 | + }, [fontSize, setTerminalFontSize]); | |
| 809 | 764 | |
| 810 | 765 | const handleClearTerminal = useCallback(() => { |
| 811 | 766 | if (!selectedThread) { |
| @@ -898,80 +853,58 @@ export function ThreadTerminalRouteScreen() { · 86 unmodified lines | ||
| 898 | 853 | |
| 899 | 854 | return ( |
| 900 | 855 | <> |
| 901 | − <Stack.Screen | |
| 856 | + <NativeStackScreenOptions | |
| 902 | 857 | options={{ |
| 903 | − headerShown: true, | |
| 904 | − headerBackButtonDisplayMode: "minimal", | |
| 905 | − headerBackTitle: "", | |
| 906 | − headerShadowVisible: false, | |
| 907 | − headerStyle: { backgroundColor: terminalTheme.background }, | |
| 908 | − headerTintColor: terminalTheme.foreground, | |
| 909 | − headerTitleAlign: "center", | |
| 910 | − title: "", | |
| 911 | − headerTitle: () => ( | |
| 912 | − <View | |
| 913 | − style={{ | |
| 914 | − alignItems: "center", | |
| 915 | − gap: 1, | |
| 916 | − maxWidth: 240, | |
| 917 | − }} | |
| 918 | − > | |
| 919 | − <RNText | |
| 920 | − numberOfLines={1} | |
| 921 | − style={{ | |
| 922 | − color: terminalTheme.foreground, | |
| 923 | − fontFamily: "DMSans_700Bold", | |
| 924 | − fontSize: MOBILE_TYPOGRAPHY.footnote.fontSize, | |
| 925 | − lineHeight: 16, | |
| 926 | − }} | |
| 927 | − > | |
| 928 | − {headerTitle.topLine} | |
| 929 | − </RNText> | |
| 930 | − <RNText | |
| 931 | − ellipsizeMode="middle" | |
| 932 | − numberOfLines={1} | |
| 933 | − style={{ | |
| 934 | − color: terminalTheme.mutedForeground, | |
| 935 | − fontFamily: "Menlo", | |
| 936 | − fontSize: MOBILE_TYPOGRAPHY.caption.fontSize, | |
| 937 | − lineHeight: 14, | |
| 938 | − }} | |
| 939 | − > | |
| 940 | − {headerTitle.bottomLine} | |
| 941 | − </RNText> | |
| 942 | − </View> | |
| 943 | − ), | |
| 858 | + // Static header config lives in Stack.tsx (SOLID_HEADER_OPTIONS — the pty | |
| 859 | + // scrolls internally, nothing for glass to sample). Default title/subtitle | |
| 860 | + // styling, like every other page. | |
| 861 | + title: "Terminal", | |
| 862 | + unstable_headerSubtitle: | |
| 863 | + usesNativeHeaderGlass && headerSubtitle.length > 0 ? headerSubtitle : undefined, | |
| 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 | 880 | {isEnvironmentReady ? ( |
| 948 | − <Stack.Toolbar placement="right"> | |
| 949 | − <Stack.Toolbar.Menu icon="terminal" title="Terminal options" separateBackground> | |
| 950 | − <Stack.Toolbar.Label> | |
| 881 | + <NativeHeaderToolbar placement="right"> | |
| 882 | + <NativeHeaderToolbar.Menu icon="terminal" title="Terminal options" separateBackground> | |
| 883 | + <NativeHeaderToolbar.Label> | |
| 951 | 884 | {getTerminalStatusLabel({ |
| 952 | 885 | status: terminal.status, |
| 953 | 886 | hasRunningSubprocess: terminal.hasRunningSubprocess, |
| 954 | 887 | })} |
| 955 | − </Stack.Toolbar.Label> | |
| 956 | − <Stack.Toolbar.Menu icon="textformat.size" inline title="Text size"> | |
| 957 | − <Stack.Toolbar.Label>Text size</Stack.Toolbar.Label> | |
| 958 | − <Stack.Toolbar.MenuAction | |
| 888 | + </NativeHeaderToolbar.Label> | |
| 889 | + <NativeHeaderToolbar.Menu icon="textformat.size" inline title="Text size"> | |
| 890 | + <NativeHeaderToolbar.Label>Text size</NativeHeaderToolbar.Label> | |
| 891 | + <NativeHeaderToolbar.MenuAction | |
| 959 | 892 | disabled={fontSize <= MIN_TERMINAL_FONT_SIZE} |
| 960 | 893 | discoverabilityLabel="Decrease terminal text size" |
| 961 | 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> | |
| 964 | − </Stack.Toolbar.MenuAction> | |
| 965 | − <Stack.Toolbar.MenuAction | |
| 896 | + <NativeHeaderToolbar.Label>{`A- ${Math.max(MIN_TERMINAL_FONT_SIZE, fontSize - TERMINAL_FONT_SIZE_STEP).toFixed(1)} pt`}</NativeHeaderToolbar.Label> | |
| 897 | + </NativeHeaderToolbar.MenuAction> | |
| 898 | + <NativeHeaderToolbar.MenuAction | |
| 966 | 899 | disabled={fontSize >= MAX_TERMINAL_FONT_SIZE} |
| 967 | 900 | discoverabilityLabel="Increase terminal text size" |
| 968 | 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> | |
| 971 | − </Stack.Toolbar.MenuAction> | |
| 972 | − </Stack.Toolbar.Menu> | |
| 903 | + <NativeHeaderToolbar.Label>{`A+ ${Math.min(MAX_TERMINAL_FONT_SIZE, fontSize + TERMINAL_FONT_SIZE_STEP).toFixed(1)} pt`}</NativeHeaderToolbar.Label> | |
| 904 | + </NativeHeaderToolbar.MenuAction> | |
| 905 | + </NativeHeaderToolbar.Menu> | |
| 973 | 906 | {terminalMenuSessions.map((session) => ( |
| 974 | − <Stack.Toolbar.MenuAction | |
| 907 | + <NativeHeaderToolbar.MenuAction | |
| 975 | 908 | key={session.terminalId} |
| 976 | 909 | icon={session.terminalId === terminalId ? "checkmark" : "terminal"} |
| 977 | 910 | onPress={() => handleSelectTerminal(session.terminalId)} |
| @@ -982,18 +915,18 @@ export function ThreadTerminalRouteScreen() { · 4 unmodified lines | ||
| 982 | 915 | .filter(Boolean) |
| 983 | 916 | .join(" · ")} |
| 984 | 917 | > |
| 985 | − <Stack.Toolbar.Label>{session.displayLabel}</Stack.Toolbar.Label> | |
| 986 | − </Stack.Toolbar.MenuAction> | |
| 918 | + <NativeHeaderToolbar.Label>{session.displayLabel}</NativeHeaderToolbar.Label> | |
| 919 | + </NativeHeaderToolbar.MenuAction> | |
| 987 | 920 | ))} |
| 988 | − <Stack.Toolbar.MenuAction | |
| 921 | + <NativeHeaderToolbar.MenuAction | |
| 989 | 922 | icon="plus" |
| 990 | 923 | onPress={handleOpenNewTerminal} |
| 991 | 924 | subtitle={`Start another shell in ${basename(selectedThreadProject.workspaceRoot) ?? "this workspace"}`} |
| 992 | 925 | > |
| 993 | − <Stack.Toolbar.Label>Open new terminal</Stack.Toolbar.Label> | |
| 994 | − </Stack.Toolbar.MenuAction> | |
| 995 | − </Stack.Toolbar.Menu> | |
| 996 | − </Stack.Toolbar> | |
| 926 | + <NativeHeaderToolbar.Label>Open new terminal</NativeHeaderToolbar.Label> | |
| 927 | + </NativeHeaderToolbar.MenuAction> | |
| 928 | + </NativeHeaderToolbar.Menu> | |
| 929 | + </NativeHeaderToolbar> | |
| 997 | 930 | ) : null} |
| 998 | 931 | |
| 999 | 932 | <View style={{ flex: 1, backgroundColor: terminalTheme.background }}> |
| @@ -1,6 +1,6 @@ | ||
| 1 | 1 | import type { ComponentType } from "react"; |
| 2 | 2 | import type { NativeSyntheticEvent, ViewProps } from "react-native"; |
| 3 | −import { requireNativeView } from "expo"; | |
| 3 | +import { requireNativeView, requireOptionalNativeModule } from "expo"; | |
| 4 | 4 | |
| 5 | 5 | import { NativeViewResolutionError } from "../../native/nativeViewResolutionError"; |
| 6 | 6 | |
| @@ -23,6 +23,7 @@ interface TerminalResizeEvent { · 16 unmodified lines | ||
| 23 | 23 | |
| 24 | 24 | export interface NativeTerminalSurfaceProps extends ViewProps { |
| 25 | 25 | readonly appearanceScheme?: "light" | "dark"; |
| 26 | + readonly focusRequest?: number; | |
| 26 | 27 | readonly themeConfig?: string; |
| 27 | 28 | readonly backgroundColor?: string; |
| 28 | 29 | readonly foregroundColor?: string; |
| @@ -74,6 +75,25 @@ export function resolveNativeTerminalSurfaceView(): ComponentType<NativeTerminal · 45 unmodified lines | ||
| 74 | 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 | 97 | export function hasNativeTerminalSurface() { |
| 78 | 98 | return resolveNativeTerminalSurfaceView() !== null; |
| 79 | 99 | } |
| @@ -1,4 +1,4 @@ | ||
| 1 | −export const DEFAULT_TERMINAL_FONT_SIZE = 10; | |
| 1 | +export const DEFAULT_TERMINAL_FONT_SIZE = 10.5; | |
| 2 | 2 | export const TERMINAL_FONT_SIZE_STEP = 0.5; |
| 3 | 3 | export const MIN_TERMINAL_FONT_SIZE = 6; |
| 4 | 4 | export const MAX_TERMINAL_FONT_SIZE = 14; |
| @@ -7,8 +7,6 @@ import { Pressable, ScrollView, useColorScheme, View, type ViewStyle } from "rea · 6 unmodified lines | ||
| 7 | 7 | |
| 8 | 8 | import { AppText as Text } from "../../components/AppText"; |
| 9 | 9 | import { PierreEntryIcon } from "../../components/PierreEntryIcon"; |
| 10 | −import { MOBILE_TYPOGRAPHY } from "../../lib/typography"; | |
| 11 | − | |
| 12 | 10 | export type ComposerCommandItem = |
| 13 | 11 | | { |
| 14 | 12 | readonly id: string; |
| @@ -165,11 +163,11 @@ const CommandRow = memo(function CommandRow(props: { · 150 unmodified lines | ||
| 165 | 163 | </Text> |
| 166 | 164 | {props.item.description ? ( |
| 167 | 165 | <Text |
| 166 | + className="text-xs" | |
| 168 | 167 | numberOfLines={1} |
| 169 | 168 | style={{ |
| 170 | 169 | flex: 1, |
| 171 | 170 | minWidth: 0, |
| 172 | − fontSize: MOBILE_TYPOGRAPHY.label.fontSize, | |
| 173 | 171 | color: "#a1a1aa", |
| 174 | 172 | }} |
| 175 | 173 | > |
| @@ -0,0 +1,45 @@ | ||
| 1 | +import type { StaticScreenProps } from "@react-navigation/native"; | |
| 2 | +import { useMemo } from "react"; | |
| 3 | +import { NativeStackScreenOptions } from "../../native/StackHeader"; | |
| 4 | + | |
| 5 | +import { NewTaskDraftScreen } from "./NewTaskDraftScreen"; | |
| 6 | + | |
| 7 | +type NewTaskDraftRouteParams = { | |
| 8 | + readonly environmentId?: string | string[]; | |
| 9 | + readonly projectId?: string | string[]; | |
| 10 | + readonly title?: string | string[]; | |
| 11 | + readonly pendingTaskId?: string | string[]; | |
| 12 | +}; | |
| 13 | + | |
| 14 | +export function NewTaskDraftRouteScreen({ route }: StaticScreenProps<NewTaskDraftRouteParams>) { | |
| 15 | + const params = route.params ?? {}; | |
| 16 | + | |
| 17 | + // Keyed on the params object so a fresh navigation to this (already | |
| 18 | + // mounted) screen produces a new reference, letting the draft screen | |
| 19 | + // re-apply the requested project. | |
| 20 | + const initialProjectRef = useMemo( | |
| 21 | + () => ({ | |
| 22 | + environmentId: Array.isArray(params.environmentId) | |
| 23 | + ? params.environmentId[0] | |
| 24 | + : params.environmentId, | |
| 25 | + projectId: Array.isArray(params.projectId) ? params.projectId[0] : params.projectId, | |
| 26 | + }), | |
| 27 | + [route.params], | |
| 28 | + ); | |
| 29 | + | |
| 30 | + return ( | |
| 31 | + <> | |
| 32 | + <NativeStackScreenOptions | |
| 33 | + options={{ | |
| 34 | + title: Array.isArray(params.title) ? params.title[0] : (params.title ?? "New task"), | |
| 35 | + }} | |
| 36 | + /> | |
| 37 | + <NewTaskDraftScreen | |
| 38 | + initialProjectRef={initialProjectRef} | |
| 39 | + pendingTaskId={ | |
| 40 | + Array.isArray(params.pendingTaskId) ? params.pendingTaskId[0] : params.pendingTaskId | |
| 41 | + } | |
| 42 | + /> | |
| 43 | + </> | |
| 44 | + ); | |
| 45 | +} | |
| @@ -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 | 3 | import { useCallback, useEffect, useMemo, useRef } from "react"; |
| 3 | 4 | import { Alert, InteractionManager, View, useColorScheme } from "react-native"; |
| 4 | 5 | import { KeyboardAvoidingView, useKeyboardState } from "react-native-keyboard-controller"; |
| @@ -22,6 +23,7 @@ import { ComposerAttachmentStrip } from "../../components/ComposerAttachmentStri · 17 unmodified lines | ||
| 22 | 23 | import { ControlPillMenu } from "../../components/ControlPill"; |
| 23 | 24 | import { ProviderIcon } from "../../components/ProviderIcon"; |
| 24 | 25 | |
| 26 | +import { makeTurnCommandMetadata } from "../../lib/commandMetadata"; | |
| 25 | 27 | import { convertPastedImagesToAttachments, pickComposerImages } from "../../lib/composerImages"; |
| 26 | 28 | import { |
| 27 | 29 | applyProviderOptionMenuEvent, |
| @@ -29,11 +31,11 @@ import { · 1 unmodified line | ||
| 29 | 31 | providerOptionsConfigurationLabel, |
| 30 | 32 | resolveProviderOptionDescriptors, |
| 31 | 33 | } from "../../lib/providerOptions"; |
| 32 | −import { buildThreadRoutePath } from "../../lib/routes"; | |
| 33 | −import { scopedProjectKey } from "../../lib/scopedEntities"; | |
| 34 | −import { MOBILE_TYPOGRAPHY } from "../../lib/typography"; | |
| 34 | +import { useScaledTextRole } from "../settings/appearance/useScaledTextRole"; | |
| 35 | 35 | import { getComposerDraftSnapshot } from "../../state/use-composer-drafts"; |
| 36 | 36 | import { useProjects } from "../../state/entities"; |
| 37 | +import { enqueueThreadOutboxMessage, removeThreadOutboxMessage } from "../../state/thread-outbox"; | |
| 38 | +import { useRemoteConnectionStatus } from "../../state/use-remote-environment-registry"; | |
| 37 | 39 | import { branchBadgeLabel, useNewTaskFlow } from "./new-task-flow-provider"; |
| 38 | 40 | import { useCreateProjectThread } from "./use-project-actions"; |
| 39 | 41 | |
| @@ -54,25 +56,79 @@ export function NewTaskDraftScreen(props: { · 14 unmodified lines | ||
| 54 | 56 | readonly environmentId?: string; |
| 55 | 57 | readonly projectId?: string; |
| 56 | 58 | }; |
| 59 | + /** Queued outbox message id when editing an existing pending task. */ | |
| 60 | + readonly pendingTaskId?: string; | |
| 57 | 61 | }) { |
| 58 | 62 | const projects = useProjects(); |
| 59 | 63 | const createProjectThread = useCreateProjectThread(); |
| 60 | 64 | const flow = useNewTaskFlow(); |
| 61 | − const router = useRouter(); | |
| 65 | + const navigation = useNavigation(); | |
| 62 | 66 | const insets = useSafeAreaInsets(); |
| 63 | 67 | const colorScheme = useColorScheme(); |
| 64 | 68 | const isKeyboardVisible = useKeyboardState((state) => state.isVisible); |
| 65 | 69 | const controlsBottomPadding = isKeyboardVisible ? 8 : Math.max(insets.bottom, 10); |
| 66 | 70 | const { logicalProjects, selectedProject, setProject } = flow; |
| 71 | + const { connectedEnvironments } = useRemoteConnectionStatus(); | |
| 72 | + const environmentConnected = | |
| 73 | + selectedProject !== null && | |
| 74 | + connectedEnvironments.find( | |
| 75 | + (environment) => environment.environmentId === selectedProject.environmentId, | |
| 76 | + )?.connectionState === "connected"; | |
| 67 | 77 | const promptInputRef = useRef<ComposerEditorHandle>(null); |
| 68 | 78 | const loadedBranchesProjectKeyRef = useRef<string | null>(null); |
| 69 | − const appliedInitialRefKeyRef = useRef<string | null>(null); | |
| 79 | + const appliedInitialProjectKeyRef = useRef<string | null>(null); | |
| 80 | + useEffect(() => { | |
| 81 | + return () => { | |
| 82 | + appliedInitialProjectKeyRef.current = null; | |
| 83 | + }; | |
| 84 | + }, []); | |
| 85 | + | |
| 86 | + const { beginEditingPendingTask, cancelEditingPendingTask, editingPendingTask } = flow; | |
| 87 | + const attemptedPendingTaskIdRef = useRef<string | null>(null); | |
| 88 | + useEffect(() => { | |
| 89 | + if (!props.pendingTaskId || editingPendingTask?.messageId === props.pendingTaskId) { | |
| 90 | + return; | |
| 91 | + } | |
| 92 | + // Attempt each pending task once: after it is delivered or deleted the | |
| 93 | + // editing session legitimately ends, and re-running must not navigate. | |
| 94 | + if (attemptedPendingTaskIdRef.current === props.pendingTaskId) { | |
| 95 | + return; | |
| 96 | + } | |
| 97 | + attemptedPendingTaskIdRef.current = props.pendingTaskId; | |
| 98 | + if (!beginEditingPendingTask(props.pendingTaskId)) { | |
| 99 | + // The queued task no longer exists (sent or deleted before opening). | |
| 100 | + navigation.dispatch(StackActions.replace("NewTask")); | |
| 101 | + } | |
| 102 | + }, [beginEditingPendingTask, editingPendingTask?.messageId, navigation, props.pendingTaskId]); | |
| 103 | + | |
| 104 | + useEffect(() => { | |
| 105 | + if (!props.pendingTaskId) return; | |
| 106 | + return () => { | |
| 107 | + // Allow a later navigation for the same pending task to re-hydrate it. | |
| 108 | + attemptedPendingTaskIdRef.current = null; | |
| 109 | + cancelEditingPendingTask(); | |
| 110 | + }; | |
| 111 | + }, [props.pendingTaskId, cancelEditingPendingTask]); | |
| 70 | 112 | |
| 71 | 113 | const borderColor = useThemeColor("--color-border"); |
| 114 | + const headlineText = useScaledTextRole("headline"); | |
| 72 | 115 | const sheetFadeOpaque = colorScheme === "dark" ? "rgba(14,14,14,0.98)" : "rgba(242,242,247,0.98)"; |
| 73 | 116 | const sheetFadeTransparent = colorScheme === "dark" ? "rgba(14,14,14,0)" : "rgba(242,242,247,0)"; |
| 74 | 117 | |
| 118 | + // A new navigation to this mounted screen delivers a fresh initialProjectRef | |
| 119 | + // reference — treat it as a new request and let it apply again. | |
| 120 | + const lastInitialProjectRefRef = useRef(props.initialProjectRef); | |
| 121 | + | |
| 75 | 122 | useEffect(() => { |
| 123 | + // Pending-task editing owns project selection (and must not fall through | |
| 124 | + // to the replace("NewTask") fallback while its hydration is in flight). | |
| 125 | + if (props.pendingTaskId) { | |
| 126 | + return; | |
| 127 | + } | |
| 128 | + if (lastInitialProjectRefRef.current !== props.initialProjectRef) { | |
| 129 | + lastInitialProjectRefRef.current = props.initialProjectRef; | |
| 130 | + appliedInitialProjectKeyRef.current = null; | |
| 131 | + } | |
| 76 | 132 | const initialEnvironmentId = props.initialProjectRef?.environmentId; |
| 77 | 133 | const initialProjectId = props.initialProjectRef?.projectId; |
| 78 | 134 | if (initialEnvironmentId && initialProjectId) { |
| @@ -83,19 +139,20 @@ export function NewTaskDraftScreen(props: { · 4 unmodified lines | ||
| 83 | 139 | ) ?? null; |
| 84 | 140 | |
| 85 | 141 | if (directProject) { |
| 86 | − // Honor the route's project ref only once. Re-applying it on every | |
| 87 | − // selection change would immediately revert a manual computer switch, | |
| 88 | − // silently snapping back to the machine the draft was opened from. | |
| 89 | − const initialKey = `${initialEnvironmentId}:${initialProjectId}`; | |
| 90 | − if (appliedInitialRefKeyRef.current !== initialKey) { | |
| 91 | − appliedInitialRefKeyRef.current = initialKey; | |
| 92 | − if ( | |
| 93 | − selectedProject?.environmentId !== directProject.environmentId || | |
| 94 | − selectedProject.id !== directProject.id | |
| 95 | − ) { | |
| 96 | − setProject(directProject); | |
| 97 | − } | |
| 142 | + // Apply the route's project once. Re-applying on every change would | |
| 143 | + // instantly revert environment/project switches made in the picker. | |
| 144 | + const directProjectKey = `${directProject.environmentId}:${directProject.id}`; | |
| 145 | + if (appliedInitialProjectKeyRef.current === directProjectKey) { | |
| 146 | + return; | |
| 98 | 147 | } |
| 148 | + appliedInitialProjectKeyRef.current = directProjectKey; | |
| 149 | + if ( | |
| 150 | + selectedProject?.environmentId === directProject.environmentId && | |
| 151 | + selectedProject.id === directProject.id | |
| 152 | + ) { | |
| 153 | + return; | |
| 154 | + } | |
| 155 | + setProject(directProject); | |
| 99 | 156 | return; |
| 100 | 157 | } |
| 101 | 158 | } |
| @@ -109,13 +166,13 @@ export function NewTaskDraftScreen(props: { · 7 unmodified lines | ||
| 109 | 166 | return; |
| 110 | 167 | } |
| 111 | 168 | |
| 112 | − router.replace("/new"); | |
| 169 | + navigation.dispatch(StackActions.replace("NewTask")); | |
| 113 | 170 | }, [ |
| 114 | 171 | logicalProjects, |
| 115 | 172 | projects, |
| 116 | − props.initialProjectRef?.environmentId, | |
| 117 | − props.initialProjectRef?.projectId, | |
| 118 | − router, | |
| 173 | + props.initialProjectRef, | |
| 174 | + props.pendingTaskId, | |
| 175 | + navigation, | |
| 119 | 176 | selectedProject, |
| 120 | 177 | setProject, |
| 121 | 178 | ]); |
| @@ -281,12 +338,24 @@ export function NewTaskDraftScreen(props: { · 159 unmodified lines | ||
| 281 | 338 | subtitle: flow.selectedBranchName ?? "Choose branch", |
| 282 | 339 | subactions: branchActions, |
| 283 | 340 | }, |
| 341 | + ...(flow.workspaceMode === "worktree" | |
| 342 | + ? [ | |
| 343 | + { | |
| 344 | + id: "workspace:start-from-origin", | |
| 345 | + title: "Start from origin", | |
| 346 | + subtitle: "Base the worktree on the latest origin branch", | |
| 347 | + image: "arrow.triangle.pull", | |
| 348 | + state: flow.startFromOrigin ? ("on" as const) : undefined, | |
| 349 | + }, | |
| 350 | + ] | |
| 351 | + : []), | |
| 284 | 352 | ]; |
| 285 | 353 | }, [ |
| 286 | 354 | flow.availableBranches, |
| 287 | 355 | flow.branchesLoading, |
| 288 | 356 | flow.selectedBranchName, |
| 289 | 357 | flow.selectedProject, |
| 358 | + flow.startFromOrigin, | |
| 290 | 359 | flow.workspaceMode, |
| 291 | 360 | ]); |
| 292 | 361 | |
| @@ -351,6 +420,10 @@ export function NewTaskDraftScreen(props: { · 58 unmodified lines | ||
| 351 | 420 | ); |
| 352 | 421 | return; |
| 353 | 422 | } |
| 423 | + if (event === "workspace:start-from-origin") { | |
| 424 | + flow.setStartFromOrigin(!flow.startFromOrigin); | |
| 425 | + return; | |
| 426 | + } | |
| 354 | 427 | if (event.startsWith("workspace:branch:")) { |
| 355 | 428 | const branchName = event.slice("workspace:branch:".length); |
| 356 | 429 | const branch = flow.availableBranches.find((candidate) => candidate.name === branchName); |
| @@ -386,17 +459,17 @@ export function NewTaskDraftScreen(props: { · 29 unmodified lines | ||
| 386 | 459 | |
| 387 | 460 | async function handleStart(): Promise<void> { |
| 388 | 461 | const selectedProject = flow.selectedProject; |
| 389 | − if (!selectedProject) { | |
| 462 | + const draftKey = flow.draftKey; | |
| 463 | + if (!selectedProject || !draftKey) { | |
| 390 | 464 | return; |
| 391 | 465 | } |
| 392 | − const draft = getComposerDraftSnapshot( | |
| 393 | − `new-task:${scopedProjectKey(selectedProject.environmentId, selectedProject.id)}`, | |
| 394 | − ); | |
| 466 | + const draft = getComposerDraftSnapshot(draftKey); | |
| 395 | 467 | const modelSelection = draft.modelSelection ?? flow.selectedModel; |
| 396 | 468 | const workspaceMode = draft.workspaceSelection?.mode ?? flow.workspaceMode; |
| 397 | 469 | const selectedBranchName = draft.workspaceSelection?.branch ?? flow.selectedBranchName; |
| 398 | 470 | const selectedWorktreePath = |
| 399 | 471 | draft.workspaceSelection?.worktreePath ?? flow.selectedWorktreePath; |
| 472 | + const startFromOrigin = draft.workspaceSelection?.startFromOrigin ?? flow.startFromOrigin; | |
| 400 | 473 | const runtimeMode = draft.runtimeMode ?? flow.runtimeMode; |
| 401 | 474 | const interactionMode = draft.interactionMode ?? flow.interactionMode; |
| 402 | 475 | const initialMessageText = draft.text.trim(); |
| @@ -410,6 +483,46 @@ export function NewTaskDraftScreen(props: { · 7 unmodified lines | ||
| 410 | 483 | return; |
| 411 | 484 | } |
| 412 | 485 | |
| 486 | + const editingPendingTask = flow.editingPendingTask; | |
| 487 | + | |
| 488 | + if (!environmentConnected) { | |
| 489 | + // Offline: park the task in the outbox; the drain sends it when the | |
| 490 | + // environment reconnects. Editing an existing pending task re-queues it | |
| 491 | + // under its original identifiers. | |
| 492 | + const metadata = editingPendingTask | |
| 493 | + ? { | |
| 494 | + threadId: editingPendingTask.threadId, | |
| 495 | + commandId: editingPendingTask.commandId, | |
| 496 | + messageId: editingPendingTask.messageId, | |
| 497 | + createdAt: editingPendingTask.createdAt, | |
| 498 | + } | |
| 499 | + : makeTurnCommandMetadata(); | |
| 500 | + const message = flow.buildPendingTaskMessage(metadata); | |
| 501 | + if (!message) { | |
| 502 | + return; | |
| 503 | + } | |
| 504 | + flow.setSubmitting(true); | |
| 505 | + try { | |
| 506 | + await enqueueThreadOutboxMessage(message); | |
| 507 | + } catch (error) { | |
| 508 | + Alert.alert( | |
| 509 | + "Could not queue task", | |
| 510 | + error instanceof Error ? error.message : "The task could not be saved to the outbox.", | |
| 511 | + ); | |
| 512 | + return; | |
| 513 | + } finally { | |
| 514 | + flow.setSubmitting(false); | |
| 515 | + } | |
| 516 | + if (editingPendingTask) { | |
| 517 | + flow.finishEditingPendingTask(); | |
| 518 | + } else { | |
| 519 | + flow.setPrompt(""); | |
| 520 | + flow.clearAttachments(); | |
| 521 | + } | |
| 522 | + navigation.getParent()?.goBack(); | |
| 523 | + return; | |
| 524 | + } | |
| 525 | + | |
| 413 | 526 | flow.setSubmitting(true); |
| 414 | 527 | const result = await createProjectThread({ |
| 415 | 528 | project: selectedProject, |
| @@ -417,10 +530,21 @@ export function NewTaskDraftScreen(props: { · 1 unmodified line | ||
| 417 | 530 | envMode: workspaceMode, |
| 418 | 531 | branch: selectedBranchName, |
| 419 | 532 | worktreePath: workspaceMode === "worktree" ? null : selectedWorktreePath, |
| 533 | + startFromOrigin, | |
| 420 | 534 | runtimeMode, |
| 421 | 535 | interactionMode, |
| 422 | 536 | initialMessageText, |
| 423 | 537 | initialAttachments: draft.attachments, |
| 538 | + ...(editingPendingTask | |
| 539 | + ? { | |
| 540 | + turnMetadata: { | |
| 541 | + threadId: editingPendingTask.threadId, | |
| 542 | + commandId: editingPendingTask.commandId, | |
| 543 | + messageId: editingPendingTask.messageId, | |
| 544 | + createdAt: editingPendingTask.createdAt, | |
| 545 | + }, | |
| 546 | + } | |
| 547 | + : {}), | |
| 424 | 548 | }); |
| 425 | 549 | flow.setSubmitting(false); |
| 426 | 550 | |
| @@ -435,22 +559,36 @@ export function NewTaskDraftScreen(props: { · 8 unmodified lines | ||
| 435 | 559 | return; |
| 436 | 560 | } |
| 437 | 561 | |
| 438 | − flow.setPrompt(""); | |
| 439 | − flow.clearAttachments(); | |
| 440 | − router.replace(buildThreadRoutePath(result.value)); | |
| 562 | + if (editingPendingTask) { | |
| 563 | + try { | |
| 564 | + await removeThreadOutboxMessage(editingPendingTask); | |
| 565 | + } catch (error) { | |
| 566 | + console.warn("[new-task] failed to remove delivered pending task", error); | |
| 567 | + } | |
| 568 | + flow.finishEditingPendingTask(); | |
| 569 | + } else { | |
| 570 | + flow.setPrompt(""); | |
| 571 | + flow.clearAttachments(); | |
| 572 | + } | |
| 573 | + navigation.dispatch( | |
| 574 | + StackActions.replace("Thread", { | |
| 575 | + environmentId: String(result.value.environmentId), | |
| 576 | + threadId: String(result.value.threadId), | |
| 577 | + }), | |
| 578 | + ); | |
| 441 | 579 | } |
| 442 | 580 | |
| 443 | 581 | if (!selectedProject) { |
| 444 | 582 | return ( |
| 445 | 583 | <View className="flex-1 bg-sheet"> |
| 446 | − <Stack.Screen options={{ title: "Loading task" }} /> | |
| 584 | + <NativeStackScreenOptions options={{ title: "Loading task" }} /> | |
| 447 | 585 | </View> |
| 448 | 586 | ); |
| 449 | 587 | } |
| 450 | 588 | |
| 451 | 589 | return ( |
| 452 | 590 | <View className="flex-1 bg-sheet"> |
| 453 | − <Stack.Screen options={{ title: selectedProject.title }} /> | |
| 591 | + <NativeStackScreenOptions options={{ title: selectedProject.title }} /> | |
| 454 | 592 | |
| 455 | 593 | <KeyboardAvoidingView automaticOffset behavior="padding" style={{ flex: 1 }}> |
| 456 | 594 | <View style={{ flex: 1, minHeight: 0, paddingHorizontal: 20, paddingTop: 8 }}> |
| @@ -465,7 +603,7 @@ export function NewTaskDraftScreen(props: { · 8 unmodified lines | ||
| 465 | 603 | onPasteImages={(uris) => void handleNativePasteImages(uris)} |
| 466 | 604 | placeholder={`Describe a coding task in ${selectedProject.title}`} |
| 467 | 605 | style={{ flex: 1, minHeight: 0 }} |
| 468 | − textStyle={MOBILE_TYPOGRAPHY.headline} | |
| 606 | + textStyle={headlineText} | |
| 469 | 607 | /> |
| 470 | 608 | </View> |
| 471 | 609 | |
| @@ -540,8 +678,14 @@ export function NewTaskDraftScreen(props: { · 68 unmodified lines | ||
| 540 | 678 | </ControlPillMenu> |
| 541 | 679 | </ComposerToolbarScroller> |
| 542 | 680 | <ComposerToolbarButton |
| 543 | − accessibilityLabel={flow.submitting ? "Starting task" : "Start task"} | |
| 544 | − icon="arrow.up" | |
| 681 | + accessibilityLabel={ | |
| 682 | + flow.submitting | |
| 683 | + ? "Starting task" | |
| 684 | + : environmentConnected | |
| 685 | + ? "Start task" | |
| 686 | + : "Queue task" | |
| 687 | + } | |
| 688 | + icon={environmentConnected ? "arrow.up" : "tray.and.arrow.up"} | |
| 545 | 689 | onPress={() => void handleStart()} |
| 546 | 690 | variant="primary" |
| 547 | 691 | showChevron={false} |
| @@ -1,4 +1,5 @@ | ||
| 1 | −import { Link, Stack, useRouter } from "expo-router"; | |
| 1 | +import { NativeHeaderToolbar } from "../../native/StackHeader"; | |
| 2 | +import { useNavigation } from "@react-navigation/native"; | |
| 2 | 3 | import { SymbolView } from "expo-symbols"; |
| 3 | 4 | import type { EnvironmentId, ProjectId } from "@t3tools/contracts"; |
| 4 | 5 | import { useMemo } from "react"; |
| @@ -12,6 +13,7 @@ import { useProjects, useThreadShells } from "../../state/entities"; · 7 unmodified lines | ||
| 12 | 13 | import type { WorkspaceState } from "../../state/workspaceModel"; |
| 13 | 14 | import { useWorkspaceState } from "../../state/workspace"; |
| 14 | 15 | import { groupProjectsByRepository } from "../../lib/repositoryGroups"; |
| 16 | +import { useAdaptiveWorkspaceLayout } from "../layout/AdaptiveWorkspaceLayout"; | |
| 15 | 17 | |
| 16 | 18 | function deriveProjectEmptyState(catalogState: WorkspaceState): { |
| 17 | 19 | readonly title: string; |
| @@ -68,11 +70,12 @@ function deriveProjectEmptyState(catalogState: WorkspaceState): { · 50 unmodified lines | ||
| 68 | 70 | }; |
| 69 | 71 | } |
| 70 | 72 | |
| 71 | −export default function NewTaskRoute() { | |
| 73 | +export function NewTaskRouteScreen() { | |
| 72 | 74 | const projects = useProjects(); |
| 73 | 75 | const threads = useThreadShells(); |
| 74 | 76 | const { state: catalogState } = useWorkspaceState(); |
| 75 | − const router = useRouter(); | |
| 77 | + const navigation = useNavigation(); | |
| 78 | + const { layout } = useAdaptiveWorkspaceLayout(); | |
| 76 | 79 | const insets = useSafeAreaInsets(); |
| 77 | 80 | const chevronColor = useThemeColor("--color-chevron"); |
| 78 | 81 | const accentColor = useThemeColor("--color-icon-muted"); |
| @@ -108,16 +111,24 @@ export default function NewTaskRoute() { · 29 unmodified lines | ||
| 108 | 111 | |
| 109 | 112 | return ( |
| 110 | 113 | <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 | + <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 | |
| 114 | 124 | icon="plus" |
| 115 | − onPress={() => router.push("/new/add-project")} | |
| 125 | + onPress={() => navigation.navigate("NewTaskSheet", { screen: "AddProject" })} | |
| 116 | 126 | separateBackground |
| 117 | 127 | /> |
| 118 | − </Stack.Toolbar> | |
| 128 | + </NativeHeaderToolbar> | |
| 119 | 129 | |
| 120 | 130 | <ScrollView |
| 131 | + contentInsetAdjustmentBehavior="automatic" | |
| 121 | 132 | showsVerticalScrollIndicator={false} |
| 122 | 133 | style={{ flex: 1 }} |
| 123 | 134 | contentInset={{ bottom: Math.max(insets.bottom, 18) + 18 }} |
| @@ -132,13 +143,13 @@ export default function NewTaskRoute() { · 8 unmodified lines | ||
| 132 | 143 | <Text className="text-center text-lg font-t3-bold text-foreground"> |
| 133 | 144 | {projectEmptyState.title} |
| 134 | 145 | </Text> |
| 135 | − <Text className="text-center text-sm leading-[20px] text-foreground-muted"> | |
| 146 | + <Text className="text-center text-sm leading-normal text-foreground-muted"> | |
| 136 | 147 | {projectEmptyState.detail} |
| 137 | 148 | </Text> |
| 138 | 149 | {!catalogState.hasReadyEnvironment ? ( |
| 139 | 150 | <Pressable |
| 140 | 151 | className="mt-1 rounded-full bg-primary px-4 py-2.5 active:opacity-70" |
| 141 | − onPress={() => router.push("/connections/new")} | |
| 152 | + onPress={() => navigation.navigate("ConnectionsNew")} | |
| 142 | 153 | > |
| 143 | 154 | <Text className="text-sm font-t3-bold text-primary-foreground"> |
| 144 | 155 | Add environment |
| @@ -147,7 +158,7 @@ export default function NewTaskRoute() { · 2 unmodified lines | ||
| 147 | 158 | ) : ( |
| 148 | 159 | <Pressable |
| 149 | 160 | className="mt-1 rounded-full bg-primary px-4 py-2.5 active:opacity-70" |
| 150 | − onPress={() => router.push("/new/add-project")} | |
| 161 | + onPress={() => navigation.navigate("NewTaskSheet", { screen: "AddProject" })} | |
| 151 | 162 | > |
| 152 | 163 | <Text className="text-sm font-t3-bold text-primary-foreground"> |
| 153 | 164 | Add new project |
| @@ -162,52 +173,50 @@ export default function NewTaskRoute() { · 8 unmodified lines | ||
| 162 | 173 | const isLast = index === items.length - 1; |
| 163 | 174 | |
| 164 | 175 | return ( |
| 165 | − <Link | |
| 176 | + <Pressable | |
| 166 | 177 | key={item.key} |
| 167 | − href={{ | |
| 168 | − pathname: "/new/draft", | |
| 169 | − params: { | |
| 170 | − environmentId: item.environmentId, | |
| 171 | − projectId: item.id, | |
| 172 | − title: item.title, | |
| 173 | − }, | |
| 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, | |
| 174 | 198 | }} |
| 175 | − asChild | |
| 176 | 199 | > |
| 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" | |
| 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 | 207 | /> |
| 208 | 208 | </View> |
| 209 | − </Pressable> | |
| 210 | − </Link> | |
| 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> | |
| 211 | 220 | ); |
| 212 | 221 | })} |
| 213 | 222 | </View> |
| @@ -23,7 +23,7 @@ export function PendingApprovalCard(props: PendingApprovalCardProps) { · 22 unmodified lines | ||
| 23 | 23 | {props.approval.requestKind} |
| 24 | 24 | </Text> |
| 25 | 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 | 27 | {props.approval.detail} |
| 28 | 28 | </Text> |
| 29 | 29 | ) : null} |
| @@ -39,7 +39,7 @@ export function PendingUserInputCard(props: PendingUserInputCardProps) { · 38 unmodified lines | ||
| 39 | 39 | <Text className="font-t3-bold text-xs uppercase tracking-[1px] text-neutral-500 dark:text-neutral-500"> |
| 40 | 40 | {question.header} |
| 41 | 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 | 43 | {question.question} |
| 44 | 44 | </Text> |
| 45 | 45 | <View className="flex-row flex-wrap gap-2.5"> |
| @@ -26,6 +26,7 @@ import { · 25 unmodified lines | ||
| 26 | 26 | } from "react-native"; |
| 27 | 27 | import ImageViewing from "react-native-image-viewing"; |
| 28 | 28 | import { useThemeColor } from "../../lib/useThemeColor"; |
| 29 | +import { scopedThreadKey } from "../../lib/scopedEntities"; | |
| 29 | 30 | |
| 30 | 31 | import { AppText as Text } from "../../components/AppText"; |
| 31 | 32 | import { ComposerAttachmentStrip } from "../../components/ComposerAttachmentStrip"; |
| @@ -44,7 +45,7 @@ import { ControlPill, ControlPillMenu } from "../../components/ControlPill"; · 12 unmodified lines | ||
| 44 | 45 | import { ProviderIcon } from "../../components/ProviderIcon"; |
| 45 | 46 | import type { DraftComposerImageAttachment } from "../../lib/composerImages"; |
| 46 | 47 | import { buildModelOptions, groupByProvider } from "../../lib/modelOptions"; |
| 47 | −import { MOBILE_TYPOGRAPHY } from "../../lib/typography"; | |
| 48 | +import { useScaledTextRole } from "../settings/appearance/useScaledTextRole"; | |
| 48 | 49 | import type { RemoteClientConnectionState } from "../../lib/connection"; |
| 49 | 50 | import { |
| 50 | 51 | insertRankedSearchResult, |
| @@ -76,10 +77,17 @@ export interface ThreadComposerProps { · 25 unmodified lines | ||
| 76 | 77 | readonly draftMessage: string; |
| 77 | 78 | readonly draftAttachments: ReadonlyArray<DraftComposerImageAttachment>; |
| 78 | 79 | readonly placeholder: string; |
| 80 | + readonly contentMaxWidth?: number; | |
| 79 | 81 | readonly bottomInset?: number; |
| 80 | 82 | readonly connectionState: RemoteClientConnectionState; |
| 81 | 83 | readonly connectionError: string | null; |
| 82 | 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 | 91 | readonly selectedThread: OrchestrationThreadShell; |
| 84 | 92 | readonly serverConfig: T3ServerConfig | null; |
| 85 | 93 | readonly queueCount: number; |
| @@ -109,41 +117,61 @@ function ComposerSurface(props: { · 23 unmodified lines | ||
| 109 | 117 | readonly style: ViewStyle; |
| 110 | 118 | readonly isDarkMode: boolean; |
| 111 | 119 | }) { |
| 120 | + // Drop shadow lives on a wrapper: `overflow: "hidden"` on the surface itself | |
| 121 | + // (needed to clip content to the pill shape) would clip the shadow on iOS. | |
| 122 | + const shadowStyle: ViewStyle = { | |
| 123 | + borderRadius: props.style.borderRadius, | |
| 124 | + shadowColor: "#000000", | |
| 125 | + shadowOpacity: props.isDarkMode ? 0.35 : 0.12, | |
| 126 | + shadowRadius: 14, | |
| 127 | + shadowOffset: { width: 0, height: 6 }, | |
| 128 | + elevation: 10, | |
| 129 | + }; | |
| 130 | + | |
| 112 | 131 | if (isLiquidGlassSupported) { |
| 113 | 132 | return ( |
| 114 | − <LiquidGlassView | |
| 115 | − effect="clear" | |
| 116 | − interactive | |
| 117 | − tintColor={props.isDarkMode ? "rgba(44,44,46,0.5)" : "rgba(255,255,255,0.45)"} | |
| 118 | − colorScheme={props.isDarkMode ? "dark" : "light"} | |
| 119 | − style={props.style} | |
| 120 | − > | |
| 121 | − {props.children} | |
| 122 | − </LiquidGlassView> | |
| 133 | + <View style={shadowStyle}> | |
| 134 | + <LiquidGlassView | |
| 135 | + effect="regular" | |
| 136 | + interactive | |
| 137 | + colorScheme={props.isDarkMode ? "dark" : "light"} | |
| 138 | + style={props.style} | |
| 139 | + > | |
| 140 | + {props.children} | |
| 141 | + </LiquidGlassView> | |
| 142 | + </View> | |
| 123 | 143 | ); |
| 124 | 144 | } |
| 125 | 145 | |
| 126 | 146 | return ( |
| 127 | − <View | |
| 128 | − style={[ | |
| 129 | − props.style, | |
| 130 | − { | |
| 131 | − backgroundColor: props.isDarkMode ? "rgba(44,44,46,0.96)" : "rgba(255,255,255,0.96)", | |
| 132 | − borderWidth: 1, | |
| 133 | − borderColor: props.isDarkMode ? "rgba(255,255,255,0.08)" : "rgba(0,0,0,0.06)", | |
| 134 | − }, | |
| 135 | − ]} | |
| 136 | − > | |
| 137 | − {props.children} | |
| 147 | + <View style={shadowStyle}> | |
| 148 | + <View | |
| 149 | + style={[ | |
| 150 | + props.style, | |
| 151 | + { | |
| 152 | + backgroundColor: props.isDarkMode ? "rgba(44,44,46,0.96)" : "rgba(255,255,255,0.96)", | |
| 153 | + borderWidth: 1, | |
| 154 | + borderColor: props.isDarkMode ? "rgba(255,255,255,0.08)" : "rgba(0,0,0,0.06)", | |
| 155 | + }, | |
| 156 | + ]} | |
| 157 | + > | |
| 158 | + {props.children} | |
| 159 | + </View> | |
| 138 | 160 | </View> |
| 139 | 161 | ); |
| 140 | 162 | } |
| 141 | 163 | |
| 164 | +type ComposerStatusPillState = { | |
| 165 | + readonly kind: "unavailable" | "reconnecting" | "syncing"; | |
| 166 | + readonly label: string; | |
| 167 | +}; | |
| 168 | + | |
| 142 | 169 | function composerConnectionStatus(input: { |
| 143 | 170 | readonly connectionError: string | null; |
| 144 | 171 | readonly connectionState: RemoteClientConnectionState; |
| 145 | 172 | readonly environmentLabel: string | null; |
| 146 | −}): { readonly kind: "unavailable" | "reconnecting"; readonly label: string } | null { | |
| 173 | + readonly threadSyncPhase?: "loading" | "syncing" | null; | |
| 174 | +}): ComposerStatusPillState | null { | |
| 147 | 175 | const environmentLabel = input.environmentLabel ?? "Environment"; |
| 148 | 176 | |
| 149 | 177 | switch (input.connectionState) { |
| @@ -168,15 +196,27 @@ function composerConnectionStatus(input: { · 18 unmodified lines | ||
| 168 | 196 | case "available": |
| 169 | 197 | return { kind: "unavailable", label: `${environmentLabel} is not connected` }; |
| 170 | 198 | case "connected": |
| 199 | + break; | |
| 200 | + } | |
| 201 | + | |
| 202 | + // Connected: the pill is the single loading/sync indicator. One stable | |
| 203 | + // label per open — "Loading" when starting from scratch, "Syncing" when | |
| 204 | + // cached messages are already visible. | |
| 205 | + switch (input.threadSyncPhase) { | |
| 206 | + case "loading": | |
| 207 | + return { kind: "syncing", label: "Loading messages..." }; | |
| 208 | + case "syncing": | |
| 209 | + return { kind: "syncing", label: "Syncing messages..." }; | |
| 210 | + default: | |
| 171 | 211 | return null; |
| 172 | 212 | } |
| 173 | 213 | } |
| 174 | 214 | |
| 175 | 215 | const ComposerConnectionStatusPill = memo(function ComposerConnectionStatusPill(props: { |
| 176 | 216 | readonly onPress: () => void; |
| 177 | − readonly status: { readonly kind: "unavailable" | "reconnecting"; readonly label: string }; | |
| 217 | + readonly status: ComposerStatusPillState; | |
| 178 | 218 | }) { |
| 179 | − const isReconnecting = props.status.kind === "reconnecting"; | |
| 219 | + const isReconnecting = props.status.kind !== "unavailable"; | |
| 180 | 220 | |
| 181 | 221 | return ( |
| 182 | 222 | <View className="items-center pb-2"> |
| @@ -191,7 +231,7 @@ const ComposerConnectionStatusPill = memo(function ComposerConnectionStatusPill( · 8 unmodified lines | ||
| 191 | 231 | <View className="h-2 w-2 rounded-full bg-red-500" /> |
| 192 | 232 | )} |
| 193 | 233 | <Text |
| 194 | − className="max-w-[260px] text-sm font-t3-bold leading-[17px] text-foreground" | |
| 234 | + className="max-w-[260px] text-sm font-t3-bold leading-snug text-foreground" | |
| 195 | 235 | numberOfLines={1} |
| 196 | 236 | > |
| 197 | 237 | {props.status.label} |
| @@ -204,10 +244,12 @@ const ComposerConnectionStatusPill = memo(function ComposerConnectionStatusPill( · 6 unmodified lines | ||
| 204 | 244 | export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposerProps) { |
| 205 | 245 | const isDarkMode = useColorScheme() === "dark"; |
| 206 | 246 | const foregroundColor = useThemeColor("--color-foreground"); |
| 247 | + const bodyText = useScaledTextRole("body"); | |
| 207 | 248 | const fallbackInputRef = useRef<ComposerEditorHandle>(null); |
| 208 | 249 | const inputRef = props.editorRef ?? fallbackInputRef; |
| 209 | 250 | const [isFocused, setIsFocused] = useState(false); |
| 210 | 251 | const wasExpandedBeforePreviewRef = useRef(false); |
| 252 | + const inFlightThreadIdsRef = useRef(new Set<string>()); | |
| 211 | 253 | const { onExpandedChange } = props; |
| 212 | 254 | |
| 213 | 255 | const [previewImageUri, setPreviewImageUri] = useState<string | null>(null); |
| @@ -254,6 +296,7 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer · 40 unmodified lines | ||
| 254 | 296 | connectionError: props.connectionError, |
| 255 | 297 | connectionState: props.connectionState, |
| 256 | 298 | environmentLabel: props.environmentLabel, |
| 299 | + threadSyncPhase: props.threadSyncPhase, | |
| 257 | 300 | }); |
| 258 | 301 | const toolbarFadeOpaque = isDarkMode ? "rgba(0,0,0,0.95)" : "rgba(255,255,255,0.95)"; |
| 259 | 302 | const toolbarFadeTransparent = isDarkMode ? "rgba(0,0,0,0)" : "rgba(255,255,255,0)"; |
| @@ -447,9 +490,16 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer · 187 unmodified lines | ||
| 447 | 490 | // ── Handle command selection ────────────────────────────── |
| 448 | 491 | const { onChangeDraftMessage, onUpdateInteractionMode, draftMessage, onSendMessage } = props; |
| 449 | 492 | |
| 450 | − const handleSend = useCallback(() => { | |
| 451 | − void onSendMessage(); | |
| 452 | − }, [onSendMessage]); | |
| 493 | + const handleSend = useCallback(async () => { | |
| 494 | + const threadKey = scopedThreadKey(props.environmentId, props.selectedThread.id); | |
| 495 | + if (inFlightThreadIdsRef.current.has(threadKey)) return; | |
| 496 | + inFlightThreadIdsRef.current.add(threadKey); | |
| 497 | + try { | |
| 498 | + await onSendMessage(); | |
| 499 | + } finally { | |
| 500 | + inFlightThreadIdsRef.current.delete(threadKey); | |
| 501 | + } | |
| 502 | + }, [onSendMessage, props.environmentId, props.selectedThread.id]); | |
| 453 | 503 | const handleCommandSelect = useCallback( |
| 454 | 504 | (item: ComposerCommandItem) => { |
| 455 | 505 | if (!composerTrigger) return; |
| @@ -625,11 +675,14 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer · 169 unmodified lines | ||
| 625 | 675 | paddingTop: isExpanded ? 8 : 6, |
| 626 | 676 | paddingBottom: (props.bottomInset ?? 0) + (isExpanded ? 8 : 6), |
| 627 | 677 | experimental_backgroundImage: isDarkMode |
| 628 | − ? "linear-gradient(to bottom, rgba(0,0,0,0) 0%, rgba(0,0,0,0.85) 40%, rgba(0,0,0,0.95) 100%)" | |
| 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%)", | |
| 678 | + ? "linear-gradient(to bottom, rgba(0,0,0,0) 0%, rgba(0,0,0,0.6) 55%, rgba(0,0,0,0.9) 100%)" | |
| 679 | + : "linear-gradient(to bottom, rgba(255,255,255,0) 0%, rgba(255,255,255,0.6) 55%, rgba(255,255,255,0.9) 100%)", | |
| 630 | 680 | }} |
| 631 | 681 | > |
| 632 | − <View className="w-full" style={{ position: "relative" }}> | |
| 682 | + <View | |
| 683 | + className="w-full" | |
| 684 | + style={{ alignSelf: "center", maxWidth: props.contentMaxWidth, position: "relative" }} | |
| 685 | + > | |
| 633 | 686 | {composerTrigger && composerMenuItems.length > 0 ? ( |
| 634 | 687 | <View |
| 635 | 688 | style={{ |
| @@ -702,6 +755,7 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer · 66 unmodified lines | ||
| 702 | 755 | placeholder={props.placeholder} |
| 703 | 756 | onFocus={handleFocus} |
| 704 | 757 | onBlur={handleBlur} |
| 758 | + onSubmit={handleSend} | |
| 705 | 759 | scrollEnabled={isExpanded} |
| 706 | 760 | contentInsetVertical={isExpanded ? 0 : 6} |
| 707 | 761 | style={ |
| @@ -717,7 +771,7 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer · 9 unmodified lines | ||
| 717 | 771 | } |
| 718 | 772 | } |
| 719 | 773 | textStyle={{ |
| 720 | − ...MOBILE_TYPOGRAPHY.composer, | |
| 774 | + ...bodyText, | |
| 721 | 775 | color: foregroundColor, |
| 722 | 776 | fontFamily: "DMSans_400Regular", |
| 723 | 777 | }} |
| @@ -827,13 +881,7 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer · 103 unmodified lines | ||
| 827 | 881 | |
| 828 | 882 | {/* Queue count */} |
| 829 | 883 | {props.queueCount > 0 ? ( |
| 830 | − <Text | |
| 831 | − className="text-foreground-muted" | |
| 832 | − style={{ | |
| 833 | − ...MOBILE_TYPOGRAPHY.label, | |
| 834 | − paddingTop: 8, | |
| 835 | − }} | |
| 836 | − > | |
| 884 | + <Text className="text-xs text-foreground-muted" style={{ paddingTop: 8 }}> | |
| 837 | 885 | {props.queueCount} queued message{props.queueCount === 1 ? "" : "s"} will send |
| 838 | 886 | automatically. |
| 839 | 887 | </Text> |
| @@ -1,4 +1,5 @@ | ||
| 1 | 1 | import { type EnvironmentConnectionPhase } from "@t3tools/client-runtime/connection"; |
| 2 | +import type { EnvironmentThreadStatus } from "@t3tools/client-runtime/state/threads"; | |
| 2 | 3 | import { useKeyboardChatComposerInset, useKeyboardScrollToEnd } from "@legendapp/list/keyboard"; |
| 3 | 4 | import type { LegendListRef } from "@legendapp/list/react-native"; |
| 4 | 5 | import type { |
| @@ -15,17 +16,18 @@ import type { · 10 unmodified lines | ||
| 15 | 16 | } from "@t3tools/contracts"; |
| 16 | 17 | import { formatElapsed } from "@t3tools/shared/orchestrationTiming"; |
| 17 | 18 | import * as Haptics from "expo-haptics"; |
| 18 | −import { useHeaderHeight } from "expo-router/build/react-navigation/elements"; | |
| 19 | 19 | import { memo, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react"; |
| 20 | 20 | import { View, type GestureResponderEvent } from "react-native"; |
| 21 | +import { Gesture, GestureDetector } from "react-native-gesture-handler"; | |
| 21 | 22 | import { KeyboardController, KeyboardStickyView } from "react-native-keyboard-controller"; |
| 22 | 23 | import { useSafeAreaInsets } from "react-native-safe-area-context"; |
| 24 | +import { runOnJS } from "react-native-reanimated"; | |
| 23 | 25 | |
| 24 | 26 | import { AppText as Text } from "../../components/AppText"; |
| 25 | 27 | import type { ComposerEditorHandle } from "../../components/ComposerEditor"; |
| 26 | 28 | import type { StatusTone } from "../../components/StatusPill"; |
| 27 | 29 | import type { DraftComposerImageAttachment } from "../../lib/composerImages"; |
| 28 | −import type { LayoutVariant } from "../../lib/layout"; | |
| 30 | +import { CHAT_CONTENT_MAX_WIDTH, type LayoutVariant } from "../../lib/layout"; | |
| 29 | 31 | import { scopedThreadKey } from "../../lib/scopedEntities"; |
| 30 | 32 | import type { |
| 31 | 33 | PendingApproval, |
| @@ -60,6 +62,8 @@ export interface ThreadDetailScreenProps { · 28 unmodified lines | ||
| 60 | 62 | readonly draftMessage: string; |
| 61 | 63 | readonly draftAttachments: ReadonlyArray<DraftComposerImageAttachment>; |
| 62 | 64 | readonly connectionStateLabel: EnvironmentConnectionPhase; |
| 65 | + /** Message sync status for the selected thread (drives the composer status pill). */ | |
| 66 | + readonly threadSyncStatus?: EnvironmentThreadStatus; | |
| 63 | 67 | readonly activeThreadBusy: boolean; |
| 64 | 68 | readonly environmentId: EnvironmentId; |
| 65 | 69 | readonly projectWorkspaceRoot: string | null; |
| @@ -67,6 +71,9 @@ export interface ThreadDetailScreenProps { · 1 unmodified line | ||
| 67 | 71 | readonly selectedThreadQueueCount: number; |
| 68 | 72 | readonly serverConfig: T3ServerConfig | null; |
| 69 | 73 | readonly layoutVariant?: LayoutVariant; |
| 74 | + readonly usesAutomaticContentInsets?: boolean; | |
| 75 | + readonly onHeaderMaterialVisibilityChange?: (visible: boolean) => void; | |
| 76 | + readonly onOpenDrawer: () => void; | |
| 70 | 77 | readonly onOpenConnectionEditor: () => void; |
| 71 | 78 | readonly onChangeDraftMessage: (value: string) => void; |
| 72 | 79 | readonly onPickDraftImages: () => Promise<void>; |
| @@ -201,8 +208,9 @@ const WorkingDurationPill = memo(function WorkingDurationPill(props: { · 128 unmodified lines | ||
| 201 | 208 | }); |
| 202 | 209 | |
| 203 | 210 | export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: ThreadDetailScreenProps) { |
| 211 | + const { onOpenDrawer } = props; | |
| 212 | + | |
| 204 | 213 | const insets = useSafeAreaInsets(); |
| 205 | − const headerHeight = useHeaderHeight(); | |
| 206 | 214 | const agentLabel = `${props.selectedThread.modelSelection.instanceId} agent`; |
| 207 | 215 | const selectedThreadKey = scopedThreadKey(props.environmentId, props.selectedThread.id); |
| 208 | 216 | const composerEditorRef = useRef<ComposerEditorHandle>(null); |
| @@ -215,6 +223,22 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread · 6 unmodified lines | ||
| 215 | 223 | const [anchorMessageId, setAnchorMessageId] = useState<MessageId | null>(null); |
| 216 | 224 | const composerBottomInset = composerExpanded ? 0 : Math.max(insets.bottom, 12); |
| 217 | 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 | + })(); | |
| 218 | 242 | const selectedThreadFeed = props.selectedThreadFeed; |
| 219 | 243 | const composerChrome = composerExpanded ? COMPOSER_EXPANDED_CHROME : COMPOSER_COLLAPSED_CHROME; |
| 220 | 244 | const composerOverlapHeight = composerChrome + composerBottomInset; |
| @@ -228,6 +252,8 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread · 7 unmodified lines | ||
| 228 | 252 | const { freeze, scrollMessageToEnd } = useKeyboardScrollToEnd({ listRef }); |
| 229 | 253 | const showContent = props.showContent ?? true; |
| 230 | 254 | const layoutVariant = props.layoutVariant ?? "compact"; |
| 255 | + const isSplitLayout = layoutVariant === "split"; | |
| 256 | + const contentMaxWidth = isSplitLayout ? CHAT_CONTENT_MAX_WIDTH : undefined; | |
| 231 | 257 | const selectedInstanceId = props.selectedThread.modelSelection.instanceId; |
| 232 | 258 | useStreamingHaptics(props.selectedThread.id, props.selectedThreadFeed); |
| 233 | 259 | const selectedProviderSkills = useMemo( |
| @@ -237,6 +263,28 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread · 3 unmodified lines | ||
| 237 | 263 | [props.serverConfig, selectedInstanceId], |
| 238 | 264 | ); |
| 239 | 265 | |
| 266 | + const completeDrawerGesture = useCallback(() => { | |
| 267 | + void Haptics.selectionAsync(); | |
| 268 | + onOpenDrawer(); | |
| 269 | + }, [onOpenDrawer]); | |
| 270 | + | |
| 271 | + const drawerGestureThreshold = 80; | |
| 272 | + const headerDrawerGesture = useMemo( | |
| 273 | + () => | |
| 274 | + Gesture.Pan() | |
| 275 | + .enabled(!isSplitLayout) | |
| 276 | + .hitSlop({ left: 0, width: 40 }) | |
| 277 | + .activeOffsetX([10, 999]) | |
| 278 | + .failOffsetY([-24, 24]) | |
| 279 | + .onEnd((event) => { | |
| 280 | + const translationX = Math.max(event.translationX, 0); | |
| 281 | + if (event.y < drawerGestureThreshold && translationX > 56) { | |
| 282 | + runOnJS(completeDrawerGesture)(); | |
| 283 | + } | |
| 284 | + }), | |
| 285 | + [completeDrawerGesture, isSplitLayout], | |
| 286 | + ); | |
| 287 | + | |
| 240 | 288 | useLayoutEffect(() => { |
| 241 | 289 | selectedThreadKeyRef.current = selectedThreadKey; |
| 242 | 290 | }, [selectedThreadKey]); |
| @@ -346,102 +394,115 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread · 103 unmodified lines | ||
| 346 | 394 | }, []); |
| 347 | 395 | |
| 348 | 396 | return ( |
| 349 | − <View className="flex-1"> | |
| 350 | − {showContent ? ( | |
| 351 | − <View | |
| 352 | − style={{ flex: 1 }} | |
| 353 | − onTouchStart={handleFeedTouchStart} | |
| 354 | − onTouchMove={handleFeedTouchMove} | |
| 355 | − onTouchEnd={handleFeedTouchEnd} | |
| 356 | − onTouchCancel={handleFeedTouchCancel} | |
| 357 | − > | |
| 358 | − <ThreadFeed | |
| 359 | − key={props.selectedThread.id} | |
| 360 | − environmentId={props.environmentId} | |
| 361 | − threadId={props.selectedThread.id} | |
| 362 | − workspaceRoot={props.threadCwd} | |
| 363 | − feed={props.selectedThreadFeed} | |
| 364 | − contentPresentation={props.contentPresentation} | |
| 365 | − agentLabel={agentLabel} | |
| 366 | − latestTurn={props.selectedThread.latestTurn} | |
| 367 | − listRef={listRef} | |
| 368 | − freeze={freeze} | |
| 369 | − anchorMessageId={anchorMessageId} | |
| 370 | − contentInsetEndAdjustment={contentInsetEndAdjustment} | |
| 371 | − contentTopInset={headerHeight} | |
| 372 | − contentBottomInset={estimatedOverlayHeight} | |
| 373 | − layoutVariant={layoutVariant} | |
| 374 | − skills={selectedProviderSkills} | |
| 375 | − /> | |
| 376 | − </View> | |
| 377 | − ) : ( | |
| 378 | − <View style={{ flex: 1 }} /> | |
| 379 | − )} | |
| 380 | − | |
| 381 | − {/* Floating composer — sticks to keyboard via KeyboardStickyView */} | |
| 382 | − {showContent ? ( | |
| 383 | − <KeyboardStickyView | |
| 384 | − style={{ position: "absolute", bottom: 0, left: 0, right: 0 }} | |
| 385 | − offset={{ closed: 0, opened: 0 }} | |
| 386 | − > | |
| 387 | − <View ref={composerOverlayRef} onLayout={onComposerLayout} style={{ paddingTop: 8 }}> | |
| 388 | − {props.activeWorkStartedAt ? ( | |
| 389 | − <WorkingDurationPill startedAt={props.activeWorkStartedAt} /> | |
| 390 | − ) : null} | |
| 391 | − | |
| 392 | − {props.activePendingApproval || props.activePendingUserInput ? ( | |
| 393 | − <View className="gap-3 px-4 pb-3" style={{ flexShrink: 0 }}> | |
| 394 | − {props.activePendingApproval ? ( | |
| 395 | − <PendingApprovalCard | |
| 396 | − approval={props.activePendingApproval} | |
| 397 | − respondingApprovalId={props.respondingApprovalId} | |
| 398 | − onRespond={props.onRespondToApproval} | |
| 399 | − /> | |
| 400 | − ) : null} | |
| 401 | − {props.activePendingUserInput ? ( | |
| 402 | − <PendingUserInputCard | |
| 403 | − pendingUserInput={props.activePendingUserInput} | |
| 404 | − drafts={props.activePendingUserInputDrafts} | |
| 405 | − answers={props.activePendingUserInputAnswers} | |
| 406 | − respondingUserInputId={props.respondingUserInputId} | |
| 407 | − onSelectOption={props.onSelectUserInputOption} | |
| 408 | − onChangeCustomAnswer={props.onChangeUserInputCustomAnswer} | |
| 409 | − onSubmit={props.onSubmitUserInput} | |
| 410 | − /> | |
| 411 | − ) : null} | |
| 412 | − </View> | |
| 413 | − ) : null} | |
| 414 | − | |
| 415 | − <ThreadComposer | |
| 416 | − editorRef={composerEditorRef} | |
| 417 | − draftMessage={props.draftMessage} | |
| 418 | − draftAttachments={props.draftAttachments} | |
| 419 | − placeholder="Ask the repo agent, or run a command…" | |
| 420 | − connectionState={props.connectionStateLabel} | |
| 421 | − connectionError={props.connectionError} | |
| 422 | − environmentLabel={props.environmentLabel} | |
| 423 | − selectedThread={props.selectedThread} | |
| 424 | − serverConfig={props.serverConfig} | |
| 425 | − queueCount={props.selectedThreadQueueCount} | |
| 426 | − activeThreadBusy={props.activeThreadBusy} | |
| 397 | + <GestureDetector gesture={headerDrawerGesture}> | |
| 398 | + <View className="flex-1"> | |
| 399 | + {showContent ? ( | |
| 400 | + <View | |
| 401 | + style={{ flex: 1 }} | |
| 402 | + onTouchStart={handleFeedTouchStart} | |
| 403 | + onTouchMove={handleFeedTouchMove} | |
| 404 | + onTouchEnd={handleFeedTouchEnd} | |
| 405 | + onTouchCancel={handleFeedTouchCancel} | |
| 406 | + > | |
| 407 | + <ThreadFeed | |
| 408 | + key={props.selectedThread.id} | |
| 427 | 409 | environmentId={props.environmentId} |
| 428 | − projectCwd={props.projectWorkspaceRoot} | |
| 429 | − bottomInset={composerBottomInset} | |
| 430 | − onChangeDraftMessage={props.onChangeDraftMessage} | |
| 431 | − onPickDraftImages={props.onPickDraftImages} | |
| 432 | − onNativePasteImages={props.onNativePasteImages} | |
| 433 | − onRemoveDraftImage={props.onRemoveDraftImage} | |
| 434 | − onStopThread={props.onStopThread} | |
| 435 | − onSendMessage={handleSendMessage} | |
| 436 | − onReconnectEnvironment={props.onReconnectEnvironment} | |
| 437 | − onUpdateModelSelection={props.onUpdateThreadModelSelection} | |
| 438 | − onUpdateRuntimeMode={props.onUpdateThreadRuntimeMode} | |
| 439 | − onUpdateInteractionMode={props.onUpdateThreadInteractionMode} | |
| 440 | − onExpandedChange={setComposerExpanded} | |
| 410 | + threadId={props.selectedThread.id} | |
| 411 | + workspaceRoot={props.threadCwd} | |
| 412 | + feed={props.selectedThreadFeed} | |
| 413 | + contentPresentation={props.contentPresentation} | |
| 414 | + agentLabel={agentLabel} | |
| 415 | + latestTurn={props.selectedThread.latestTurn} | |
| 416 | + listRef={listRef} | |
| 417 | + freeze={freeze} | |
| 418 | + anchorMessageId={anchorMessageId} | |
| 419 | + contentInsetEndAdjustment={contentInsetEndAdjustment} | |
| 420 | + contentTopInset={0} | |
| 421 | + contentBottomInset={estimatedOverlayHeight} | |
| 422 | + contentMaxWidth={contentMaxWidth} | |
| 423 | + layoutVariant={layoutVariant} | |
| 424 | + usesAutomaticContentInsets={props.usesAutomaticContentInsets} | |
| 425 | + onHeaderMaterialVisibilityChange={props.onHeaderMaterialVisibilityChange} | |
| 426 | + skills={selectedProviderSkills} | |
| 441 | 427 | /> |
| 442 | 428 | </View> |
| 443 | − </KeyboardStickyView> | |
| 444 | − ) : null} | |
| 445 | − </View> | |
| 429 | + ) : ( | |
| 430 | + <View style={{ flex: 1 }} /> | |
| 431 | + )} | |
| 432 | + | |
| 433 | + {/* Floating composer — sticks to keyboard via KeyboardStickyView */} | |
| 434 | + {showContent ? ( | |
| 435 | + <KeyboardStickyView | |
| 436 | + style={{ position: "absolute", bottom: 0, left: 0, right: 0 }} | |
| 437 | + offset={{ closed: 0, opened: 0 }} | |
| 438 | + > | |
| 439 | + <View | |
| 440 | + ref={composerOverlayRef} | |
| 441 | + onLayout={onComposerLayout} | |
| 442 | + style={{ width: "100%", paddingTop: 8 }} | |
| 443 | + > | |
| 444 | + <View style={{ alignSelf: "center", maxWidth: contentMaxWidth, width: "100%" }}> | |
| 445 | + {props.activeWorkStartedAt ? ( | |
| 446 | + <WorkingDurationPill startedAt={props.activeWorkStartedAt} /> | |
| 447 | + ) : null} | |
| 448 | + | |
| 449 | + {props.activePendingApproval || props.activePendingUserInput ? ( | |
| 450 | + <View className="gap-3 px-4 pb-3" style={{ flexShrink: 0 }}> | |
| 451 | + {props.activePendingApproval ? ( | |
| 452 | + <PendingApprovalCard | |
| 453 | + approval={props.activePendingApproval} | |
| 454 | + respondingApprovalId={props.respondingApprovalId} | |
| 455 | + onRespond={props.onRespondToApproval} | |
| 456 | + /> | |
| 457 | + ) : null} | |
| 458 | + {props.activePendingUserInput ? ( | |
| 459 | + <PendingUserInputCard | |
| 460 | + pendingUserInput={props.activePendingUserInput} | |
| 461 | + drafts={props.activePendingUserInputDrafts} | |
| 462 | + answers={props.activePendingUserInputAnswers} | |
| 463 | + respondingUserInputId={props.respondingUserInputId} | |
| 464 | + onSelectOption={props.onSelectUserInputOption} | |
| 465 | + onChangeCustomAnswer={props.onChangeUserInputCustomAnswer} | |
| 466 | + onSubmit={props.onSubmitUserInput} | |
| 467 | + /> | |
| 468 | + ) : null} | |
| 469 | + </View> | |
| 470 | + ) : null} | |
| 471 | + </View> | |
| 472 | + | |
| 473 | + <ThreadComposer | |
| 474 | + editorRef={composerEditorRef} | |
| 475 | + draftMessage={props.draftMessage} | |
| 476 | + draftAttachments={props.draftAttachments} | |
| 477 | + placeholder="Ask the repo agent, or run a command…" | |
| 478 | + contentMaxWidth={contentMaxWidth} | |
| 479 | + connectionState={props.connectionStateLabel} | |
| 480 | + connectionError={props.connectionError} | |
| 481 | + environmentLabel={props.environmentLabel} | |
| 482 | + threadSyncPhase={threadSyncPhase} | |
| 483 | + selectedThread={props.selectedThread} | |
| 484 | + serverConfig={props.serverConfig} | |
| 485 | + queueCount={props.selectedThreadQueueCount} | |
| 486 | + activeThreadBusy={props.activeThreadBusy} | |
| 487 | + environmentId={props.environmentId} | |
| 488 | + projectCwd={props.projectWorkspaceRoot} | |
| 489 | + bottomInset={composerBottomInset} | |
| 490 | + onChangeDraftMessage={props.onChangeDraftMessage} | |
| 491 | + onPickDraftImages={props.onPickDraftImages} | |
| 492 | + onNativePasteImages={props.onNativePasteImages} | |
| 493 | + onRemoveDraftImage={props.onRemoveDraftImage} | |
| 494 | + onStopThread={props.onStopThread} | |
| 495 | + onSendMessage={handleSendMessage} | |
| 496 | + onReconnectEnvironment={props.onReconnectEnvironment} | |
| 497 | + onUpdateModelSelection={props.onUpdateThreadModelSelection} | |
| 498 | + onUpdateRuntimeMode={props.onUpdateThreadRuntimeMode} | |
| 499 | + onUpdateInteractionMode={props.onUpdateThreadInteractionMode} | |
| 500 | + onExpandedChange={setComposerExpanded} | |
| 501 | + /> | |
| 502 | + </View> | |
| 503 | + </KeyboardStickyView> | |
| 504 | + ) : null} | |
| 505 | + </View> | |
| 506 | + </GestureDetector> | |
| 446 | 507 | ); |
| 447 | 508 | }); |
| @@ -4,7 +4,7 @@ import { type LegendListRef } from "@legendapp/list/react-native"; · 3 unmodified lines | ||
| 4 | 4 | import type { EnvironmentId, MessageId, ThreadId, TurnId } from "@t3tools/contracts"; |
| 5 | 5 | import { CHAT_LIST_ANCHOR_OFFSET, resolveChatListAnchoredEndSpace } from "@t3tools/shared/chatList"; |
| 6 | 6 | import { SymbolView } from "expo-symbols"; |
| 7 | −import { useRouter } from "expo-router"; | |
| 7 | +import { useNavigation } from "@react-navigation/native"; | |
| 8 | 8 | import { |
| 9 | 9 | memo, |
| 10 | 10 | useCallback, |
| @@ -25,6 +25,10 @@ import { · 14 unmodified lines | ||
| 25 | 25 | ActivityIndicator, |
| 26 | 26 | Image, |
| 27 | 27 | Linking, |
| 28 | + Platform, | |
| 29 | + type LayoutChangeEvent, | |
| 30 | + type NativeScrollEvent, | |
| 31 | + type NativeSyntheticEvent, | |
| 28 | 32 | Pressable, |
| 29 | 33 | ScrollView, |
| 30 | 34 | StyleSheet, |
| @@ -37,7 +41,7 @@ import { · 6 unmodified lines | ||
| 37 | 41 | import { TouchableOpacity } from "react-native-gesture-handler"; |
| 38 | 42 | import ImageViewing from "react-native-image-viewing"; |
| 39 | 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 | 45 | import { useThemeColor } from "../../lib/useThemeColor"; |
| 42 | 46 | import { copyTextWithHaptic } from "../../lib/copyTextWithHaptic"; |
| 43 | 47 | import { |
| @@ -58,14 +62,16 @@ import { · 14 unmodified lines | ||
| 58 | 62 | buildNativeReviewDiffData, |
| 59 | 63 | createNativeReviewDiffTheme, |
| 60 | 64 | NATIVE_REVIEW_DIFF_CONTENT_WIDTH, |
| 61 | − NATIVE_REVIEW_DIFF_ROW_HEIGHT, | |
| 62 | − NATIVE_REVIEW_DIFF_STYLE, | |
| 63 | 65 | } from "../review/nativeReviewDiffAdapter"; |
| 64 | 66 | import { buildReviewParsedDiff } from "../review/reviewModel"; |
| 65 | 67 | import { cn } from "../../lib/cn"; |
| 66 | −import type { LayoutVariant } from "../../lib/layout"; | |
| 67 | −import { buildThreadFilesNavigation } from "../../lib/routes"; | |
| 68 | −import { MOBILE_CODE_SURFACE, MOBILE_TYPOGRAPHY } from "../../lib/typography"; | |
| 68 | +import { deriveCenteredContentHorizontalPadding, type LayoutVariant } from "../../lib/layout"; | |
| 69 | +import { | |
| 70 | + resolveMarkdownFontSizes, | |
| 71 | + resolveNativeMarkdownTypography, | |
| 72 | +} from "../../lib/appearancePreferences"; | |
| 73 | +import { useAppearancePreferences } from "../settings/appearance/AppearancePreferencesProvider"; | |
| 74 | +import { useAppearanceCodeSurface } from "../settings/appearance/useAppearanceCodeSurface"; | |
| 69 | 75 | import { markdownFileIconSource } from "@t3tools/mobile-markdown-text/file-icons"; |
| 70 | 76 | import { resolveMarkdownLinkPresentation } from "@t3tools/mobile-markdown-text/links"; |
| 71 | 77 | import { |
| @@ -82,7 +88,6 @@ const MESSAGE_TIME_FORMATTER = new Intl.DateTimeFormat(undefined, { · 10 unmodified lines | ||
| 82 | 88 | hour: "numeric", |
| 83 | 89 | minute: "2-digit", |
| 84 | 90 | }); |
| 85 | − | |
| 86 | 91 | function formatMessageTime(input: string): string { |
| 87 | 92 | const timestamp = Date.parse(input); |
| 88 | 93 | if (Number.isNaN(timestamp)) { |
| @@ -105,7 +110,10 @@ export interface ThreadFeedProps { · 16 unmodified lines | ||
| 105 | 110 | readonly contentInsetEndAdjustment: SharedValue<number>; |
| 106 | 111 | readonly contentTopInset?: number; |
| 107 | 112 | readonly contentBottomInset?: number; |
| 113 | + readonly contentMaxWidth?: number; | |
| 108 | 114 | readonly layoutVariant?: LayoutVariant; |
| 115 | + readonly usesAutomaticContentInsets?: boolean; | |
| 116 | + readonly onHeaderMaterialVisibilityChange?: (visible: boolean) => void; | |
| 109 | 117 | readonly skills?: ReadonlyArray<SelectableMarkdownSkill>; |
| 110 | 118 | } |
| 111 | 119 | |
| @@ -273,6 +281,15 @@ function useReviewCommentColors(): ReviewCommentColors { · 161 unmodified lines | ||
| 273 | 281 | |
| 274 | 282 | function useMarkdownStyles(onLinkPress: (href: string) => void): MarkdownStyleSets { |
| 275 | 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 | 293 | const colors = MARKDOWN_COLORS[colorScheme === "dark" ? "dark" : "light"]; |
| 277 | 294 | const inlineSkillForeground = String(useThemeColor("--color-inline-skill-foreground")); |
| 278 | 295 | |
| @@ -316,14 +333,14 @@ function useMarkdownStyles(onLinkPress: (href: string) => void): MarkdownStyleSe · 37 unmodified lines | ||
| 316 | 333 | xl: 16, |
| 317 | 334 | }, |
| 318 | 335 | fontSizes: { |
| 319 | − s: 13, | |
| 320 | − m: 15, | |
| 321 | − h1: 20, | |
| 322 | − h2: 18, | |
| 323 | − h3: 16, | |
| 324 | − h4: 14, | |
| 325 | − h5: 14, | |
| 326 | − h6: 14, | |
| 336 | + s: markdownFontSizes.s, | |
| 337 | + m: markdownFontSizes.m, | |
| 338 | + h1: markdownFontSizes.h1, | |
| 339 | + h2: markdownFontSizes.h2, | |
| 340 | + h3: markdownFontSizes.h3, | |
| 341 | + h4: markdownFontSizes.h4, | |
| 342 | + h5: markdownFontSizes.h5, | |
| 343 | + h6: markdownFontSizes.h6, | |
| 327 | 344 | }, |
| 328 | 345 | fontFamilies: { |
| 329 | 346 | regular: "DMSans_400Regular", |
| @@ -345,7 +362,7 @@ function useMarkdownStyles(onLinkPress: (href: string) => void): MarkdownStyleSe · 15 unmodified lines | ||
| 345 | 362 | list: { marginTop: 4, marginBottom: 8 }, |
| 346 | 363 | list_item: { marginTop: 0, marginBottom: 4 }, |
| 347 | 364 | task_list_item: { marginTop: 0, marginBottom: 4 }, |
| 348 | − text: { lineHeight: 22 }, | |
| 365 | + text: { lineHeight: markdownFontSizes.bodyLineHeight }, | |
| 349 | 366 | bold: { |
| 350 | 367 | fontWeight: "700", |
| 351 | 368 | color: markdownStrongColor, |
| @@ -454,7 +471,8 @@ function useMarkdownStyles(onLinkPress: (href: string) => void): MarkdownStyleSe · 102 unmodified lines | ||
| 454 | 471 | marginRight: 5, |
| 455 | 472 | color: inlineTextColor, |
| 456 | 473 | fontFamily: "DMSans_400Regular", |
| 457 | − ...MOBILE_TYPOGRAPHY.body, | |
| 474 | + fontSize: markdownFontSizes.m, | |
| 475 | + lineHeight: markdownFontSizes.bodyLineHeight, | |
| 458 | 476 | textAlign: ordered ? "right" : "center", |
| 459 | 477 | }} |
| 460 | 478 | > |
| @@ -475,8 +493,8 @@ function useMarkdownStyles(onLinkPress: (href: string) => void): MarkdownStyleSe · 14 unmodified lines | ||
| 475 | 493 | style={{ |
| 476 | 494 | color: inlineCodeTextColor, |
| 477 | 495 | fontFamily: "ui-monospace", |
| 478 | − fontSize: MOBILE_TYPOGRAPHY.label.fontSize, | |
| 479 | − lineHeight: 22, | |
| 496 | + fontSize: markdownFontSizes.codeBlockFontSize, | |
| 497 | + lineHeight: markdownFontSizes.bodyLineHeight, | |
| 480 | 498 | }} |
| 481 | 499 | > |
| 482 | 500 | {value} |
| @@ -512,7 +530,7 @@ function useMarkdownStyles(onLinkPress: (href: string) => void): MarkdownStyleSe · 29 unmodified lines | ||
| 512 | 530 | style={{ |
| 513 | 531 | color: markdownBodyColor, |
| 514 | 532 | fontFamily: "ui-monospace", |
| 515 | − fontSize: MOBILE_TYPOGRAPHY.label.fontSize, | |
| 533 | + fontSize: markdownFontSizes.codeBlockFontSize, | |
| 516 | 534 | opacity: 0.7, |
| 517 | 535 | textTransform: "uppercase", |
| 518 | 536 | }} |
| @@ -532,8 +550,8 @@ function useMarkdownStyles(onLinkPress: (href: string) => void): MarkdownStyleSe · 13 unmodified lines | ||
| 532 | 550 | style={{ |
| 533 | 551 | color: blockTextColor, |
| 534 | 552 | fontFamily: "ui-monospace", |
| 535 | − fontSize: MOBILE_TYPOGRAPHY.label.fontSize, | |
| 536 | − lineHeight: 18, | |
| 553 | + fontSize: markdownFontSizes.codeBlockFontSize, | |
| 554 | + lineHeight: markdownFontSizes.codeBlockLineHeight, | |
| 537 | 555 | }} |
| 538 | 556 | > |
| 539 | 557 | {content} |
| @@ -612,7 +630,9 @@ function useMarkdownStyles(onLinkPress: (href: string) => void): MarkdownStyleSe · 72 unmodified lines | ||
| 612 | 630 | skillTextColor: "#f0abfc", |
| 613 | 631 | quoteMarkerColor: markdownUserBodyColor, |
| 614 | 632 | dividerColor: markdownUserBodyColor, |
| 615 | − ...MOBILE_TYPOGRAPHY.body, | |
| 633 | + fontSize: nativeMarkdownTypography.fontSize, | |
| 634 | + lineHeight: nativeMarkdownTypography.lineHeight, | |
| 635 | + headingFontSizes: nativeMarkdownTypography.headingFontSizes, | |
| 616 | 636 | fontFamily: "DMSans_400Regular", |
| 617 | 637 | headingFontFamily: "DMSans_700Bold", |
| 618 | 638 | boldFontFamily: "DMSans_700Bold", |
| @@ -641,14 +661,16 @@ function useMarkdownStyles(onLinkPress: (href: string) => void): MarkdownStyleSe · 22 unmodified lines | ||
| 641 | 661 | skillTextColor: inlineSkillForeground, |
| 642 | 662 | quoteMarkerColor: markdownBlockquoteBorder, |
| 643 | 663 | dividerColor: markdownHrColor, |
| 644 | − ...MOBILE_TYPOGRAPHY.body, | |
| 664 | + fontSize: nativeMarkdownTypography.fontSize, | |
| 665 | + lineHeight: nativeMarkdownTypography.lineHeight, | |
| 666 | + headingFontSizes: nativeMarkdownTypography.headingFontSizes, | |
| 645 | 667 | fontFamily: "DMSans_400Regular", |
| 646 | 668 | headingFontFamily: "DMSans_700Bold", |
| 647 | 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 | 676 | function renderFeedEntry( |
| @@ -924,6 +946,7 @@ const ReviewCommentCard = memo(function ReviewCommentCard(props: { · 269 unmodified lines | ||
| 924 | 946 | readonly comment: ReviewInlineComment; |
| 925 | 947 | readonly colors: ReviewCommentColors; |
| 926 | 948 | }) { |
| 949 | + const { codeSurface, nativeReviewDiffStyle } = useAppearanceCodeSurface(); | |
| 927 | 950 | const colorScheme = useColorScheme(); |
| 928 | 951 | const appearanceScheme = colorScheme === "light" ? "light" : "dark"; |
| 929 | 952 | const NativeReviewDiffView = resolveNativeReviewDiffView(); |
| @@ -946,18 +969,21 @@ const ReviewCommentCard = memo(function ReviewCommentCard(props: { · 16 unmodified lines | ||
| 946 | 969 | () => JSON.stringify(nativeReviewDiffTheme), |
| 947 | 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 | 976 | const nativeDiffHeight = useMemo( |
| 951 | 977 | () => |
| 952 | 978 | Math.min( |
| 953 | 979 | 360, |
| 954 | 980 | Math.max( |
| 955 | 981 | 112, |
| 956 | − compactNativeRows.length * NATIVE_REVIEW_DIFF_ROW_HEIGHT + | |
| 957 | − NATIVE_REVIEW_DIFF_STYLE.fileHeaderVerticalMargin, | |
| 982 | + compactNativeRows.length * nativeReviewDiffStyle.rowHeight + | |
| 983 | + nativeReviewDiffStyle.fileHeaderVerticalMargin, | |
| 958 | 984 | ), |
| 959 | 985 | ), |
| 960 | − [compactNativeRows.length], | |
| 986 | + [compactNativeRows.length, nativeReviewDiffStyle], | |
| 961 | 987 | ); |
| 962 | 988 | const shouldRenderNativeDiff = NativeReviewDiffView != null && compactNativeRows.length > 0; |
| 963 | 989 | |
| @@ -987,7 +1013,7 @@ const ReviewCommentCard = memo(function ReviewCommentCard(props: { · 23 unmodified lines | ||
| 987 | 1013 | </View> |
| 988 | 1014 | <View className="min-w-0 flex-1"> |
| 989 | 1015 | <Text |
| 990 | − className="font-mono text-xs leading-[16px]" | |
| 1016 | + className="font-mono text-xs" | |
| 991 | 1017 | numberOfLines={1} |
| 992 | 1018 | style={{ color: props.colors.text }} |
| 993 | 1019 | > |
| @@ -1010,7 +1036,7 @@ const ReviewCommentCard = memo(function ReviewCommentCard(props: { · 16 unmodified lines | ||
| 1010 | 1036 | style={StyleSheet.absoluteFill} |
| 1011 | 1037 | appearanceScheme={appearanceScheme} |
| 1012 | 1038 | contentWidth={NATIVE_REVIEW_DIFF_CONTENT_WIDTH} |
| 1013 | − rowHeight={NATIVE_REVIEW_DIFF_ROW_HEIGHT} | |
| 1039 | + rowHeight={nativeReviewDiffStyle.rowHeight} | |
| 1014 | 1040 | rowsJson={nativeRowsJson} |
| 1015 | 1041 | styleJson={nativeStyleJson} |
| 1016 | 1042 | themeJson={nativeThemeJson} |
| @@ -1032,8 +1058,8 @@ const ReviewCommentCard = memo(function ReviewCommentCard(props: { · 15 unmodified lines | ||
| 1032 | 1058 | style={{ |
| 1033 | 1059 | color: props.colors.text, |
| 1034 | 1060 | fontFamily: "ui-monospace", |
| 1035 | − fontSize: MOBILE_CODE_SURFACE.fontSize, | |
| 1036 | − lineHeight: MOBILE_CODE_SURFACE.rowHeight, | |
| 1061 | + fontSize: codeSurface.fontSize, | |
| 1062 | + lineHeight: codeSurface.rowHeight, | |
| 1037 | 1063 | }} |
| 1038 | 1064 | > |
| 1039 | 1065 | {props.comment.diff.trim()} |
| @@ -1042,11 +1068,7 @@ const ReviewCommentCard = memo(function ReviewCommentCard(props: { · 2 unmodified lines | ||
| 1042 | 1068 | ) : null} |
| 1043 | 1069 | {props.comment.text.length > 0 ? ( |
| 1044 | 1070 | <View className="border-t px-3 py-3" style={{ borderColor: props.colors.border }}> |
| 1045 | − <Text | |
| 1046 | − selectable | |
| 1047 | − className="text-base leading-[21px]" | |
| 1048 | − style={{ color: props.colors.text }} | |
| 1049 | − > | |
| 1071 | + <Text selectable className="text-base leading-snug" style={{ color: props.colors.text }}> | |
| 1050 | 1072 | {props.comment.text} |
| 1051 | 1073 | </Text> |
| 1052 | 1074 | </View> |
| @@ -1087,7 +1109,6 @@ function ThreadFeedPlaceholder(props: { · 34 unmodified lines | ||
| 1087 | 1109 | readonly bottomInset: number; |
| 1088 | 1110 | readonly detail: string; |
| 1089 | 1111 | readonly horizontalPadding: number; |
| 1090 | − readonly loading?: boolean; | |
| 1091 | 1112 | readonly title: string; |
| 1092 | 1113 | readonly topInset: number; |
| 1093 | 1114 | }) { |
| @@ -1104,9 +1125,8 @@ function ThreadFeedPlaceholder(props: { · 10 unmodified lines | ||
| 1104 | 1125 | }} |
| 1105 | 1126 | > |
| 1106 | 1127 | <View className="max-w-[320px] items-center gap-2"> |
| 1107 | − {props.loading ? <ActivityIndicator style={{ marginBottom: 6 }} /> : null} | |
| 1108 | 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 | 1130 | {props.detail} |
| 1111 | 1131 | </Text> |
| 1112 | 1132 | </View> |
| @@ -1115,13 +1135,17 @@ function ThreadFeedPlaceholder(props: { · 2 unmodified lines | ||
| 1115 | 1135 | } |
| 1116 | 1136 | |
| 1117 | 1137 | export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { |
| 1118 | − const router = useRouter(); | |
| 1138 | + const navigation = useNavigation(); | |
| 1119 | 1139 | const copyFeedbackTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null); |
| 1120 | 1140 | const foldSettleFrameRef = useRef<number | null>(null); |
| 1121 | 1141 | const foldSettleSecondFrameRef = useRef<number | null>(null); |
| 1122 | 1142 | const disclosureAnchorKeyRef = useRef<string | null>(null); |
| 1143 | + const headerMaterialVisibleRef = useRef(false); | |
| 1123 | 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 | 1149 | const [disclosureToggleSettling, setDisclosureToggleSettling] = useState(false); |
| 1126 | 1150 | const [interactionState, setInteractionState] = useState<{ |
| 1127 | 1151 | readonly copiedRowId: string | null; |
| @@ -1140,12 +1164,19 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { · 12 unmodified lines | ||
| 1140 | 1164 | headers?: Record<string, string>; |
| 1141 | 1165 | } | null>(null); |
| 1142 | 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 | 1173 | const userBubbleMaxWidth = contentWidth * 0.85; |
| 1145 | 1174 | const reviewCommentBubbleWidth = Math.min(Math.max(280, contentWidth * 0.85), contentWidth); |
| 1146 | 1175 | const insets = useSafeAreaInsets(); |
| 1147 | 1176 | const topContentInset = props.contentTopInset ?? insets.top + 44; |
| 1148 | 1177 | const bottomContentInset = props.contentBottomInset ?? 18; |
| 1178 | + const usesNativeAutomaticInsets = | |
| 1179 | + props.usesAutomaticContentInsets === true && Platform.OS === "ios"; | |
| 1149 | 1180 | |
| 1150 | 1181 | const iconSubtleColor = useThemeColor("--color-icon-subtle"); |
| 1151 | 1182 | const userBubbleColor = useThemeColor("--color-user-bubble"); |
| @@ -1159,13 +1190,12 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { · 7 unmodified lines | ||
| 1159 | 1190 | ); |
| 1160 | 1191 | if (relativePath) { |
| 1161 | 1192 | void Haptics.selectionAsync(); |
| 1162 | − router.push( | |
| 1163 | − buildThreadFilesNavigation( | |
| 1164 | − { environmentId: props.environmentId, threadId: props.threadId }, | |
| 1165 | − relativePath, | |
| 1166 | − presentation.line, | |
| 1167 | − ), | |
| 1168 | − ); | |
| 1193 | + navigation.navigate("ThreadFile", { | |
| 1194 | + environmentId: String(props.environmentId), | |
| 1195 | + threadId: String(props.threadId), | |
| 1196 | + path: relativePath.split("/").filter((segment) => segment.length > 0), | |
| 1197 | + ...(presentation.line ? { line: String(presentation.line) } : {}), | |
| 1198 | + }); | |
| 1169 | 1199 | } |
| 1170 | 1200 | return; |
| 1171 | 1201 | } |
| @@ -1174,7 +1204,7 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { · 2 unmodified lines | ||
| 1174 | 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 | 1209 | const markdownStyles = useMarkdownStyles(onMarkdownLinkPress); |
| 1180 | 1210 | const reviewCommentColors = useReviewCommentColors(); |
| @@ -1188,6 +1218,7 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { · 7 unmodified lines | ||
| 1188 | 1218 | markdownStyles, |
| 1189 | 1219 | reviewCommentColors, |
| 1190 | 1220 | userBubbleColor, |
| 1221 | + viewportWidth, | |
| 1191 | 1222 | }), |
| 1192 | 1223 | [ |
| 1193 | 1224 | copiedRowId, |
| @@ -1196,8 +1227,34 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { · 2 unmodified lines | ||
| 1196 | 1227 | markdownStyles, |
| 1197 | 1228 | reviewCommentColors, |
| 1198 | 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 | 1258 | const expandedWorkGroupIds = useMemo(() => { |
| 1202 | 1259 | const ids = new Set<string>(); |
| 1203 | 1260 | for (const [groupId, expanded] of Object.entries(expandedWorkGroups)) { |
| @@ -1217,6 +1274,7 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { · 13 unmodified lines | ||
| 1217 | 1274 | ), |
| 1218 | 1275 | [expandedTurnIds, expandedWorkGroupIds, props.feed, props.latestTurn], |
| 1219 | 1276 | ); |
| 1277 | + | |
| 1220 | 1278 | const anchoredEndSpace = useMemo( |
| 1221 | 1279 | () => |
| 1222 | 1280 | resolveChatListAnchoredEndSpace( |
| @@ -1424,19 +1482,6 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { · 201 unmodified lines | ||
| 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 | 1485 | if (props.contentPresentation.kind === "unavailable") { |
| 1441 | 1486 | return ( |
| 1442 | 1487 | <ThreadFeedPlaceholder |
| @@ -1451,52 +1496,76 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { · 8 unmodified lines | ||
| 1451 | 1496 | |
| 1452 | 1497 | return ( |
| 1453 | 1498 | <> |
| 1454 | − <View style={{ flex: 1 }}> | |
| 1455 | − <KeyboardAwareLegendList | |
| 1456 | − ref={props.listRef} | |
| 1457 | − key={props.threadId} | |
| 1458 | − style={{ flex: 1 }} | |
| 1459 | − // RN 0.81+ drops touches inside the contentInset area | |
| 1460 | − // (facebook/react-native#54123); the anchored end space after a send | |
| 1461 | − // is pure inset, so without this the blank region can't be scrolled. | |
| 1462 | − applyWorkaroundForContentInsetHitTestBug | |
| 1463 | − automaticallyAdjustsScrollIndicatorInsets={false} | |
| 1464 | − scrollIndicatorInsets={{ top: topContentInset, bottom: 0 }} | |
| 1465 | − {...(anchoredEndSpace ? { anchoredEndSpace } : {})} | |
| 1466 | − contentInsetEndAdjustment={props.contentInsetEndAdjustment} | |
| 1467 | − freeze={props.freeze} | |
| 1468 | − maintainScrollAtEnd={ | |
| 1469 | − disclosureToggleSettling | |
| 1470 | − ? false | |
| 1471 | − : { | |
| 1472 | − animated: false, | |
| 1473 | − on: { | |
| 1474 | − dataChange: true, | |
| 1475 | − itemLayout: true, | |
| 1476 | − layout: true, | |
| 1477 | − }, | |
| 1499 | + <View style={{ flex: 1 }} onLayout={handleViewportLayout}> | |
| 1500 | + <View style={{ flex: 1 }}> | |
| 1501 | + <KeyboardAwareLegendList | |
| 1502 | + ref={props.listRef} | |
| 1503 | + // The empty↔filled key remounts the list when messages first | |
| 1504 | + // arrive. LegendList's maintainScrollAtEnd calls scrollToEnd(), | |
| 1505 | + // which is blind to UIKit's adjustedContentInset — inserting into | |
| 1506 | + // an already-attached list under a transparent header can pin | |
| 1507 | + // short content at offset 0 (one header-height too high). A fresh | |
| 1508 | + // mount positions during attach, where UIKit applies the inset. | |
| 1509 | + key={`${props.threadId}:${props.feed.length === 0 ? "empty" : "filled"}`} | |
| 1510 | + style={{ flex: 1 }} | |
| 1511 | + // RN 0.81+ drops touches inside the contentInset area | |
| 1512 | + // (facebook/react-native#54123); the anchored end space after a send | |
| 1513 | + // is pure inset, so without this the blank region can't be scrolled. | |
| 1514 | + applyWorkaroundForContentInsetHitTestBug | |
| 1515 | + contentInsetAdjustmentBehavior={usesNativeAutomaticInsets ? "automatic" : "never"} | |
| 1516 | + automaticallyAdjustsScrollIndicatorInsets={usesNativeAutomaticInsets} | |
| 1517 | + {...(usesNativeAutomaticInsets | |
| 1518 | + ? { | |
| 1519 | + // Do NOT pass a manual `contentInset` here. Like the Home | |
| 1520 | + // ScrollView, we rely purely on `contentInsetAdjustmentBehavior: | |
| 1521 | + // "automatic"` so UIKit derives the top inset from the transparent | |
| 1522 | + // header. A manual contentInset (which LegendList consumes into its | |
| 1523 | + // own layout math) collapses the scroll view's adjustedContentInset | |
| 1524 | + // top to 0, leaving the iOS 26/27 scroll-edge effect no region to | |
| 1525 | + // render into — which is why the header blur was missing on threads. | |
| 1526 | + scrollIndicatorInsets: { top: 0, left: 0, right: 0, bottom: 0 }, | |
| 1478 | 1527 | } |
| 1479 | − } | |
| 1480 | − maintainVisibleContentPosition={maintainVisibleContentPosition} | |
| 1481 | − data={presentedFeed} | |
| 1482 | − extraData={listAppearanceData} | |
| 1483 | − renderItem={renderItem} | |
| 1484 | − keyExtractor={(entry) => entry.id} | |
| 1485 | − getItemType={(entry) => | |
| 1486 | − entry.type === "message" ? `message:${entry.message.role}` : entry.type | |
| 1487 | − } | |
| 1488 | − keyboardShouldPersistTaps="always" | |
| 1489 | − keyboardDismissMode="none" | |
| 1490 | − keyboardLiftBehavior="whenAtEnd" | |
| 1491 | − estimatedItemSize={180} | |
| 1492 | − initialScrollAtEnd | |
| 1493 | − ListHeaderComponent={<View style={{ height: topContentInset }} />} | |
| 1494 | − contentContainerStyle={{ | |
| 1495 | − paddingTop: 12, | |
| 1496 | − paddingHorizontal: horizontalPadding, | |
| 1497 | − }} | |
| 1498 | − /> | |
| 1499 | − {props.feed.length === 0 ? ( | |
| 1528 | + : { scrollIndicatorInsets: { top: topContentInset, bottom: 0 } })} | |
| 1529 | + {...(anchoredEndSpace ? { anchoredEndSpace } : {})} | |
| 1530 | + contentInsetEndAdjustment={props.contentInsetEndAdjustment} | |
| 1531 | + freeze={props.freeze} | |
| 1532 | + maintainScrollAtEnd={ | |
| 1533 | + disclosureToggleSettling | |
| 1534 | + ? false | |
| 1535 | + : { | |
| 1536 | + animated: false, | |
| 1537 | + on: { | |
| 1538 | + dataChange: true, | |
| 1539 | + itemLayout: true, | |
| 1540 | + layout: true, | |
| 1541 | + }, | |
| 1542 | + } | |
| 1543 | + } | |
| 1544 | + maintainVisibleContentPosition={maintainVisibleContentPosition} | |
| 1545 | + data={presentedFeed} | |
| 1546 | + extraData={listAppearanceData} | |
| 1547 | + renderItem={renderItem} | |
| 1548 | + keyExtractor={(entry) => entry.id} | |
| 1549 | + getItemType={(entry) => | |
| 1550 | + entry.type === "message" ? `message:${entry.message.role}` : entry.type | |
| 1551 | + } | |
| 1552 | + keyboardShouldPersistTaps="always" | |
| 1553 | + keyboardDismissMode="none" | |
| 1554 | + keyboardLiftBehavior="whenAtEnd" | |
| 1555 | + estimatedItemSize={180} | |
| 1556 | + initialScrollAtEnd | |
| 1557 | + onScroll={handleScroll} | |
| 1558 | + scrollEventThrottle={16} | |
| 1559 | + ListHeaderComponent={ | |
| 1560 | + usesNativeAutomaticInsets ? null : <View style={{ height: topContentInset }} /> | |
| 1561 | + } | |
| 1562 | + contentContainerStyle={{ | |
| 1563 | + paddingTop: 12, | |
| 1564 | + paddingHorizontal: contentHorizontalPadding, | |
| 1565 | + }} | |
| 1566 | + /> | |
| 1567 | + </View> | |
| 1568 | + {props.feed.length === 0 && props.contentPresentation.kind === "ready" ? ( | |
| 1500 | 1569 | <View pointerEvents="none" style={StyleSheet.absoluteFill}> |
| 1501 | 1570 | <ThreadFeedPlaceholder |
| 1502 | 1571 | title="No conversation yet" |
| @@ -1,20 +1,19 @@ | ||
| 1 | −import type { | |
| 1 | +import { | |
| 2 | 2 | EnvironmentId, |
| 3 | − GitRunStackedActionResult, | |
| 4 | − ProjectScript, | |
| 3 | + type GitRunStackedActionResult, | |
| 4 | + type ProjectScript, | |
| 5 | 5 | ThreadId, |
| 6 | − VcsStatusResult, | |
| 6 | + type VcsStatusResult, | |
| 7 | 7 | } from "@t3tools/contracts"; |
| 8 | 8 | import { |
| 9 | 9 | type GitActionRequestInput, |
| 10 | 10 | requiresDefaultBranchConfirmation, |
| 11 | 11 | resolveQuickAction, |
| 12 | 12 | } from "@t3tools/client-runtime/state/vcs"; |
| 13 | −import { useLocalSearchParams, useRouter } from "expo-router"; | |
| 14 | −import Stack from "expo-router/stack"; | |
| 13 | +import { useNavigation } from "@react-navigation/native"; | |
| 14 | +import { NativeHeaderToolbar } from "../../native/StackHeader"; | |
| 15 | 15 | import { useCallback, useMemo } from "react"; |
| 16 | 16 | import { Alert } from "react-native"; |
| 17 | −import { buildThreadFilesNavigation, buildThreadReviewRoutePath } from "../../lib/routes"; | |
| 18 | 17 | import { tryOpenExternalUrl } from "../../lib/openExternalUrl"; |
| 19 | 18 | import { |
| 20 | 19 | basename, |
| @@ -65,25 +64,52 @@ function compactMenuStatus(gitStatus: VcsStatusResult | null): string { · 44 unmodified lines | ||
| 65 | 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 | 84 | readonly currentBranch: string | null; |
| 70 | 85 | readonly gitStatus: VcsStatusResult | null; |
| 71 | 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 | 98 | readonly canOpenTerminal: boolean; |
| 73 | 99 | readonly canOpenFiles: boolean; |
| 74 | 100 | readonly projectScripts: ReadonlyArray<ProjectScript>; |
| 75 | 101 | readonly terminalSessions: ReadonlyArray<TerminalMenuSession>; |
| 102 | + readonly showActionControls?: boolean; | |
| 103 | + readonly showDirectFileControl?: boolean; | |
| 76 | 104 | readonly onOpenTerminal: (terminalId?: string | null) => void; |
| 77 | 105 | readonly onOpenNewTerminal: () => void; |
| 78 | 106 | readonly onRunProjectScript: (script: ProjectScript) => Promise<void>; |
| 79 | − readonly onPull: () => Promise<void>; | |
| 80 | − readonly onRunAction: (input: GitActionRequestInput) => Promise<GitRunStackedActionResult | null>; | |
| 81 | −}) { | |
| 82 | − const router = useRouter(); | |
| 83 | − const { environmentId, threadId } = useLocalSearchParams<{ | |
| 84 | − environmentId: EnvironmentId; | |
| 85 | − threadId: ThreadId; | |
| 86 | − }>(); | |
| 107 | +}; | |
| 108 | + | |
| 109 | +function useThreadGitControlModel(props: ThreadGitMenuProps) { | |
| 110 | + const navigation = useNavigation(); | |
| 111 | + const environmentId = props.environmentId; | |
| 112 | + const threadId = props.threadId; | |
| 87 | 113 | const { gitStatus, gitOperationLabel, onPull, onRunAction } = props; |
| 88 | 114 | |
| 89 | 115 | const currentBranchLabel = gitStatus?.refName ?? props.currentBranch ?? "Detached HEAD"; |
| @@ -109,7 +135,7 @@ export function ThreadGitControls(props: { · 19 unmodified lines | ||
| 109 | 135 | ? (quickAction.hint ?? "This action is unavailable.") |
| 110 | 136 | : null; |
| 111 | 137 | |
| 112 | − const quickActionIcon = (() => { | |
| 138 | + const quickActionIcon: QuickActionIcon = (() => { | |
| 113 | 139 | if (quickAction.kind === "run_pull") return "arrow.down.circle"; |
| 114 | 140 | if (quickAction.kind === "open_pr") return "arrow.up.right.circle"; |
| 115 | 141 | if (quickAction.kind === "run_action") { |
| @@ -147,24 +173,21 @@ export function ThreadGitControls(props: { · 31 unmodified lines | ||
| 147 | 173 | !input.featureBranch && |
| 148 | 174 | requiresDefaultBranchConfirmation(input.action, isDefaultRef) |
| 149 | 175 | ) { |
| 150 | − router.push({ | |
| 151 | − pathname: "/threads/[environmentId]/[threadId]/git-confirm", | |
| 152 | − params: { | |
| 153 | − environmentId, | |
| 154 | − threadId, | |
| 155 | − confirmAction: confirmableAction, | |
| 156 | − branchName, | |
| 157 | − includesCommit: String( | |
| 158 | − input.action === "commit_push" || input.action === "commit_push_pr", | |
| 159 | − ), | |
| 160 | − }, | |
| 176 | + navigation.navigate("GitConfirm", { | |
| 177 | + environmentId: String(environmentId), | |
| 178 | + threadId: String(threadId), | |
| 179 | + confirmAction: confirmableAction, | |
| 180 | + branchName, | |
| 181 | + includesCommit: String( | |
| 182 | + input.action === "commit_push" || input.action === "commit_push_pr", | |
| 183 | + ), | |
| 161 | 184 | }); |
| 162 | 185 | return; |
| 163 | 186 | } |
| 164 | 187 | |
| 165 | 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 | 193 | const runQuickAction = useCallback(async () => { |
| @@ -181,102 +204,339 @@ export function ThreadGitControls(props: { · 10 unmodified lines | ||
| 181 | 204 | } |
| 182 | 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 | 417 | return ( |
| 185 | − <Stack.Toolbar placement="right"> | |
| 186 | − <Stack.Toolbar.Menu icon="terminal" disabled={!props.canOpenTerminal} separateBackground> | |
| 187 | − {props.projectScripts.length > 0 ? ( | |
| 188 | − props.projectScripts.map((script) => ( | |
| 189 | − <Stack.Toolbar.MenuAction | |
| 190 | − key={script.id} | |
| 191 | − icon={projectScriptMenuIcon(script.icon)} | |
| 192 | − onPress={() => void props.onRunProjectScript(script)} | |
| 193 | − subtitle={script.command} | |
| 418 | + <NativeHeaderToolbar placement="right"> | |
| 419 | + {showActionControls && props.auxiliaryPaneControl ? ( | |
| 420 | + <NativeHeaderToolbar.Button | |
| 421 | + accessibilityLabel={props.auxiliaryPaneControl.accessibilityLabel} | |
| 422 | + icon="sidebar.right" | |
| 423 | + onPress={props.auxiliaryPaneControl.onPress} | |
| 424 | + separateBackground | |
| 425 | + /> | |
| 426 | + ) : null} | |
| 427 | + {showActionControls ? ( | |
| 428 | + <NativeHeaderToolbar.Menu | |
| 429 | + icon="terminal" | |
| 430 | + disabled={!props.canOpenTerminal} | |
| 431 | + separateBackground | |
| 432 | + > | |
| 433 | + {props.projectScripts.length > 0 ? ( | |
| 434 | + props.projectScripts.map((script) => ( | |
| 435 | + <NativeHeaderToolbar.MenuAction | |
| 436 | + key={script.id} | |
| 437 | + icon={projectScriptMenuIcon(script.icon)} | |
| 438 | + onPress={() => void props.onRunProjectScript(script)} | |
| 439 | + subtitle={script.command} | |
| 440 | + > | |
| 441 | + <NativeHeaderToolbar.Label> | |
| 442 | + {projectScriptMenuLabel(script)} | |
| 443 | + </NativeHeaderToolbar.Label> | |
| 444 | + </NativeHeaderToolbar.MenuAction> | |
| 445 | + )) | |
| 446 | + ) : ( | |
| 447 | + <NativeHeaderToolbar.MenuAction | |
| 448 | + icon="play" | |
| 449 | + disabled | |
| 450 | + onPress={() => {}} | |
| 451 | + subtitle="This project has no saved scripts yet" | |
| 194 | 452 | > |
| 195 | − <Stack.Toolbar.Label>{projectScriptMenuLabel(script)}</Stack.Toolbar.Label> | |
| 196 | − </Stack.Toolbar.MenuAction> | |
| 197 | − )) | |
| 198 | − ) : ( | |
| 199 | − <Stack.Toolbar.MenuAction | |
| 200 | − icon="play" | |
| 201 | − disabled | |
| 202 | − onPress={() => {}} | |
| 203 | − subtitle="This project has no saved scripts yet" | |
| 204 | − > | |
| 205 | − <Stack.Toolbar.Label>No project scripts</Stack.Toolbar.Label> | |
| 206 | − </Stack.Toolbar.MenuAction> | |
| 207 | − )} | |
| 208 | − {props.terminalSessions.map((session) => ( | |
| 209 | − <Stack.Toolbar.MenuAction | |
| 210 | − key={session.terminalId} | |
| 211 | − icon="terminal" | |
| 212 | − onPress={() => props.onOpenTerminal(session.terminalId)} | |
| 213 | − subtitle={[ | |
| 214 | − getTerminalStatusLabel({ | |
| 215 | − status: session.status, | |
| 216 | − hasRunningSubprocess: session.hasRunningSubprocess, | |
| 217 | − }), | |
| 218 | − basename(session.cwd), | |
| 219 | − ] | |
| 220 | − .filter(Boolean) | |
| 221 | − .join(" · ")} | |
| 453 | + <NativeHeaderToolbar.Label>No project scripts</NativeHeaderToolbar.Label> | |
| 454 | + </NativeHeaderToolbar.MenuAction> | |
| 455 | + )} | |
| 456 | + {props.terminalSessions.map((session) => ( | |
| 457 | + <NativeHeaderToolbar.MenuAction | |
| 458 | + key={session.terminalId} | |
| 459 | + icon="terminal" | |
| 460 | + onPress={() => props.onOpenTerminal(session.terminalId)} | |
| 461 | + subtitle={[ | |
| 462 | + getTerminalStatusLabel({ | |
| 463 | + status: session.status, | |
| 464 | + hasRunningSubprocess: session.hasRunningSubprocess, | |
| 465 | + }), | |
| 466 | + basename(session.cwd), | |
| 467 | + ] | |
| 468 | + .filter(Boolean) | |
| 469 | + .join(" · ")} | |
| 470 | + > | |
| 471 | + <NativeHeaderToolbar.Label>{session.displayLabel}</NativeHeaderToolbar.Label> | |
| 472 | + </NativeHeaderToolbar.MenuAction> | |
| 473 | + ))} | |
| 474 | + <NativeHeaderToolbar.MenuAction | |
| 475 | + icon="plus" | |
| 476 | + onPress={props.onOpenNewTerminal} | |
| 477 | + subtitle="Start another shell for this thread" | |
| 222 | 478 | > |
| 223 | − <Stack.Toolbar.Label>{session.displayLabel}</Stack.Toolbar.Label> | |
| 224 | − </Stack.Toolbar.MenuAction> | |
| 225 | − ))} | |
| 226 | − <Stack.Toolbar.MenuAction | |
| 227 | − icon="plus" | |
| 228 | − onPress={props.onOpenNewTerminal} | |
| 229 | − subtitle="Start another shell for this thread" | |
| 230 | − > | |
| 231 | − <Stack.Toolbar.Label>Open new terminal</Stack.Toolbar.Label> | |
| 232 | − </Stack.Toolbar.MenuAction> | |
| 233 | − </Stack.Toolbar.Menu> | |
| 234 | − <Stack.Toolbar.Menu icon="point.topleft.down.curvedto.point.bottomright.up"> | |
| 235 | − <Stack.Toolbar.MenuAction | |
| 236 | − icon="point.topleft.down.curvedto.point.bottomright.up" | |
| 237 | − disabled | |
| 238 | − onPress={() => {}} | |
| 239 | − subtitle={compactMenuStatus(gitStatus)} | |
| 240 | − > | |
| 241 | − <Stack.Toolbar.Label>{compactMenuBranchLabel(currentBranchLabel)}</Stack.Toolbar.Label> | |
| 242 | − </Stack.Toolbar.MenuAction> | |
| 243 | − <Stack.Toolbar.MenuAction | |
| 244 | − icon={quickActionIcon} | |
| 245 | − disabled={quickAction.disabled} | |
| 246 | − onPress={() => void runQuickAction()} | |
| 247 | − subtitle={quickActionHint ?? undefined} | |
| 248 | − > | |
| 249 | − <Stack.Toolbar.Label>{quickAction.label}</Stack.Toolbar.Label> | |
| 250 | − </Stack.Toolbar.MenuAction> | |
| 251 | − <Stack.Toolbar.MenuAction | |
| 252 | − icon="text.bubble" | |
| 253 | − disabled={!isRepo} | |
| 254 | − onPress={() => router.push(buildThreadReviewRoutePath({ environmentId, threadId }))} | |
| 255 | − subtitle="Turn diffs and worktree changes" | |
| 256 | − > | |
| 257 | − <Stack.Toolbar.Label>Review changes</Stack.Toolbar.Label> | |
| 258 | − </Stack.Toolbar.MenuAction> | |
| 259 | − <Stack.Toolbar.MenuAction | |
| 260 | − icon="folder" | |
| 479 | + <NativeHeaderToolbar.Label>Open new terminal</NativeHeaderToolbar.Label> | |
| 480 | + </NativeHeaderToolbar.MenuAction> | |
| 481 | + </NativeHeaderToolbar.Menu> | |
| 482 | + ) : null} | |
| 483 | + {showActionControls && props.showDirectFileControl ? ( | |
| 484 | + <NativeHeaderToolbar.Button | |
| 485 | + accessibilityLabel="Open files" | |
| 261 | 486 | disabled={!props.canOpenFiles} |
| 262 | − onPress={() => router.push(buildThreadFilesNavigation({ environmentId, threadId }))} | |
| 263 | − subtitle="Browse this workspace" | |
| 264 | − > | |
| 265 | − <Stack.Toolbar.Label>Files</Stack.Toolbar.Label> | |
| 266 | − </Stack.Toolbar.MenuAction> | |
| 267 | − <Stack.Toolbar.MenuAction | |
| 268 | − icon="ellipsis.circle" | |
| 269 | − onPress={() => | |
| 270 | − router.push({ | |
| 271 | − pathname: "/threads/[environmentId]/[threadId]/git", | |
| 272 | − params: { environmentId, threadId }, | |
| 273 | − }) | |
| 274 | − } | |
| 275 | − subtitle="Commit, files, branches" | |
| 276 | − > | |
| 277 | − <Stack.Toolbar.Label>More</Stack.Toolbar.Label> | |
| 278 | − </Stack.Toolbar.MenuAction> | |
| 279 | − </Stack.Toolbar.Menu> | |
| 280 | − </Stack.Toolbar> | |
| 487 | + icon="folder" | |
| 488 | + onPress={model.openFiles} | |
| 489 | + separateBackground | |
| 490 | + /> | |
| 491 | + ) : null} | |
| 492 | + {showActionControls ? <ThreadGitMenu {...props} /> : null} | |
| 493 | + </NativeHeaderToolbar> | |
| 494 | + ); | |
| 495 | +} | |
| 496 | + | |
| 497 | +/** | |
| 498 | + * The standalone git actions menu (branch status, quick commit/push action, | |
| 499 | + * review, more). Rendered inside a NativeHeaderToolbar by both the thread | |
| 500 | + * chat header and the review screen's toolbar. | |
| 501 | + */ | |
| 502 | +export function ThreadGitMenu(props: ThreadGitMenuProps) { | |
| 503 | + const model = useThreadGitControlModel(props); | |
| 504 | + | |
| 505 | + return ( | |
| 506 | + <NativeHeaderToolbar.Menu icon="point.topleft.down.curvedto.point.bottomright.up"> | |
| 507 | + <NativeHeaderToolbar.MenuAction | |
| 508 | + icon="point.topleft.down.curvedto.point.bottomright.up" | |
| 509 | + disabled | |
| 510 | + onPress={() => {}} | |
| 511 | + subtitle={compactMenuStatus(props.gitStatus)} | |
| 512 | + > | |
| 513 | + <NativeHeaderToolbar.Label> | |
| 514 | + {compactMenuBranchLabel(model.currentBranchLabel)} | |
| 515 | + </NativeHeaderToolbar.Label> | |
| 516 | + </NativeHeaderToolbar.MenuAction> | |
| 517 | + <NativeHeaderToolbar.MenuAction | |
| 518 | + icon={model.quickActionIcon} | |
| 519 | + disabled={model.quickAction.disabled} | |
| 520 | + onPress={() => void model.runQuickAction()} | |
| 521 | + subtitle={model.quickActionHint ?? undefined} | |
| 522 | + > | |
| 523 | + <NativeHeaderToolbar.Label>{model.quickAction.label}</NativeHeaderToolbar.Label> | |
| 524 | + </NativeHeaderToolbar.MenuAction> | |
| 525 | + <NativeHeaderToolbar.MenuAction | |
| 526 | + icon="text.bubble" | |
| 527 | + disabled={!model.isRepo} | |
| 528 | + onPress={model.openReview} | |
| 529 | + subtitle="Turn diffs and worktree changes" | |
| 530 | + > | |
| 531 | + <NativeHeaderToolbar.Label>Review changes</NativeHeaderToolbar.Label> | |
| 532 | + </NativeHeaderToolbar.MenuAction> | |
| 533 | + <NativeHeaderToolbar.MenuAction | |
| 534 | + icon="ellipsis" | |
| 535 | + onPress={model.openGitInspector} | |
| 536 | + subtitle="Commit, files, branches" | |
| 537 | + > | |
| 538 | + <NativeHeaderToolbar.Label>More</NativeHeaderToolbar.Label> | |
| 539 | + </NativeHeaderToolbar.MenuAction> | |
| 540 | + </NativeHeaderToolbar.Menu> | |
| 281 | 541 | ); |
| 282 | 542 | } |
| @@ -8,8 +8,6 @@ import { · 7 unmodified lines | ||
| 8 | 8 | useWindowDimensions, |
| 9 | 9 | View, |
| 10 | 10 | } from "react-native"; |
| 11 | −import * as Arr from "effect/Array"; | |
| 12 | −import * as Order from "effect/Order"; | |
| 13 | 11 | import { Gesture, GestureDetector } from "react-native-gesture-handler"; |
| 14 | 12 | import { useSafeAreaInsets } from "react-native-safe-area-context"; |
| 15 | 13 | import Animated, { |
| @@ -23,22 +21,11 @@ import { useThemeColor } from "../../lib/useThemeColor"; · 7 unmodified lines | ||
| 23 | 21 | import { AppText as Text } from "../../components/AppText"; |
| 24 | 22 | import { StatusPill } from "../../components/StatusPill"; |
| 25 | 23 | import { useProjects, useThreadShells } from "../../state/entities"; |
| 26 | −import { groupProjectsByRepository } from "../../lib/repositoryGroups"; | |
| 27 | 24 | import { scopedThreadKey } from "../../lib/scopedEntities"; |
| 28 | 25 | import { relativeTime } from "../../lib/time"; |
| 29 | −import { threadStatusTone } from "./threadPresentation"; | |
| 30 | −import { EnvironmentThreadShell } from "@t3tools/client-runtime/state/shell"; | |
| 31 | − | |
| 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 | −); | |
| 26 | +import { resolveThreadStatus } from "./threadPresentation"; | |
| 27 | +import type { EnvironmentThreadShell } from "@t3tools/client-runtime/state/shell"; | |
| 28 | +import { buildThreadNavigationGroups } from "./thread-navigation-groups"; | |
| 42 | 29 | |
| 43 | 30 | export function ThreadNavigationDrawer(props: { |
| 44 | 31 | readonly visible: boolean; |
| @@ -192,24 +179,9 @@ function ThreadNavigationDrawerContent(props: { · 147 unmodified lines | ||
| 192 | 179 | }) { |
| 193 | 180 | const projects = useProjects(); |
| 194 | 181 | const threads = useThreadShells(); |
| 195 | − const repositoryGroups = useMemo( | |
| 196 | − () => groupProjectsByRepository({ projects, threads }), | |
| 197 | − [projects, threads], | |
| 198 | − ); | |
| 199 | 182 | const groupedThreads = useMemo( |
| 200 | − () => | |
| 201 | − repositoryGroups.map((group) => { | |
| 202 | − const threads: EnvironmentThreadShell[] = []; | |
| 203 | − for (const projectGroup of group.projects) { | |
| 204 | − threads.push(...projectGroup.threads); | |
| 205 | − } | |
| 206 | − return { | |
| 207 | − key: group.key, | |
| 208 | − title: group.projects[0]?.project.title ?? group.title, | |
| 209 | − threads: Arr.sort(threads, threadActivityOrder), | |
| 210 | − }; | |
| 211 | − }), | |
| 212 | − [repositoryGroups], | |
| 183 | + () => buildThreadNavigationGroups({ projects, threads }), | |
| 184 | + [projects, threads], | |
| 213 | 185 | ); |
| 214 | 186 | |
| 215 | 187 | return ( |
| @@ -239,6 +211,7 @@ function ThreadNavigationDrawerContent(props: { · 23 unmodified lines | ||
| 239 | 211 | group.threads.map((thread, index) => { |
| 240 | 212 | const threadKey = scopedThreadKey(thread.environmentId, thread.id); |
| 241 | 213 | const selected = props.selectedThreadKey === threadKey; |
| 214 | + const status = resolveThreadStatus(thread); | |
| 242 | 215 | |
| 243 | 216 | return ( |
| 244 | 217 | <Pressable |
| @@ -268,7 +241,7 @@ function ThreadNavigationDrawerContent(props: { · 23 unmodified lines | ||
| 268 | 241 | {relativeTime(thread.updatedAt ?? thread.createdAt)} |
| 269 | 242 | </Text> |
| 270 | 243 | </View> |
| 271 | − <StatusPill {...threadStatusTone(thread)} /> | |
| 244 | + {status ? <StatusPill {...status} /> : null} | |
| 272 | 245 | </View> |
| 273 | 246 | </Pressable> |
| 274 | 247 | ); |
| @@ -0,0 +1,820 @@ | ||
| 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 { usePendingNewTasks } from "../../state/use-pending-new-tasks"; | |
| 22 | +import { useWorkspaceState } from "../../state/workspace"; | |
| 23 | +import { useSavedRemoteConnections } from "../../state/use-remote-environment-registry"; | |
| 24 | +import { useHardwareKeyboardCommand } from "../keyboard/hardwareKeyboardCommands"; | |
| 25 | +import { | |
| 26 | + hasCustomHomeListOptions, | |
| 27 | + PROJECT_GROUPING_OPTIONS, | |
| 28 | + PROJECT_SORT_OPTIONS, | |
| 29 | + THREAD_SORT_OPTIONS, | |
| 30 | + useHomeListOptions, | |
| 31 | +} from "../home/home-list-options"; | |
| 32 | +import { buildHomeListFilterMenu } from "../home/home-list-filter-menu"; | |
| 33 | +import { | |
| 34 | + buildHomeListLayout, | |
| 35 | + DEFAULT_GROUP_DISPLAY_STATE, | |
| 36 | + homeListItemsAreEqual, | |
| 37 | + nextGroupDisplayState, | |
| 38 | + type HomeGroupDisplayAction, | |
| 39 | + type HomeGroupDisplayState, | |
| 40 | + type HomeListItem, | |
| 41 | +} from "../home/homeListItems"; | |
| 42 | +import { buildHomeThreadGroups } from "../home/homeThreadList"; | |
| 43 | +import { SwipeableScrollGateProvider, useSwipeableScrollGate } from "../home/thread-swipe-actions"; | |
| 44 | +import { usePendingTaskListActions } from "../home/usePendingTaskListActions"; | |
| 45 | +import { useThreadListActions } from "../home/useThreadListActions"; | |
| 46 | +import { WorkspaceConnectionStatus } from "../home/WorkspaceConnectionStatus"; | |
| 47 | +import { shouldShowWorkspaceConnectionStatus } from "../home/workspace-connection-status"; | |
| 48 | +import { SidebarHeaderActions } from "./sidebar-header-actions"; | |
| 49 | +import { SidebarFilterButton } from "./sidebar-filter-button"; | |
| 50 | +import { createSidebarHeaderItems } from "./sidebar-native-header-items"; | |
| 51 | +import { SidebarNavigationShell } from "./sidebar-navigation-shell"; | |
| 52 | +import { | |
| 53 | + PendingTaskListRow, | |
| 54 | + ThreadListGroupHeader, | |
| 55 | + ThreadListRow, | |
| 56 | + ThreadListShowMoreRow, | |
| 57 | +} from "./thread-list-items"; | |
| 58 | + | |
| 59 | +/** | |
| 60 | + * Shared capsule behind the sidebar header buttons — a native liquid-glass | |
| 61 | + * surface on iOS 26+, a tinted pill everywhere else. | |
| 62 | + */ | |
| 63 | +function SidebarHeaderButtonGroup(props: { | |
| 64 | + readonly children: ReactNode; | |
| 65 | + readonly colorScheme: "light" | "dark"; | |
| 66 | +}) { | |
| 67 | + if (isLiquidGlassSupported) { | |
| 68 | + return ( | |
| 69 | + <LiquidGlassView | |
| 70 | + colorScheme={props.colorScheme} | |
| 71 | + effect="regular" | |
| 72 | + interactive | |
| 73 | + style={styles.headerButtonGroup} | |
| 74 | + > | |
| 75 | + {props.children} | |
| 76 | + </LiquidGlassView> | |
| 77 | + ); | |
| 78 | + } | |
| 79 | + | |
| 80 | + return ( | |
| 81 | + <View | |
| 82 | + style={[ | |
| 83 | + styles.headerButtonGroup, | |
| 84 | + props.colorScheme === "dark" | |
| 85 | + ? { backgroundColor: "rgba(118,118,128,0.24)", borderColor: "rgba(255,255,255,0.08)" } | |
| 86 | + : { backgroundColor: "rgba(255,255,255,0.72)", borderColor: "rgba(0,0,0,0.08)" }, | |
| 87 | + { borderWidth: StyleSheet.hairlineWidth }, | |
| 88 | + ]} | |
| 89 | + > | |
| 90 | + {props.children} | |
| 91 | + </View> | |
| 92 | + ); | |
| 93 | +} | |
| 94 | + | |
| 95 | +const SIDEBAR_STICKY_HEADER_HEIGHT = 106; | |
| 96 | +const SIDEBAR_STICKY_HEADER_FADE_HEIGHT = 44; | |
| 97 | +const IOS_SEARCH_FILL_DARK = "rgba(118, 118, 128, 0.24)"; | |
| 98 | +const IOS_SEARCH_FILL_LIGHT = "rgba(118, 118, 128, 0.12)"; | |
| 99 | +const SIDEBAR_HEADER_WASH_OPACITY = { | |
| 100 | + dark: [0.22, 0.14, 0.04], | |
| 101 | + light: [0.46, 0.3, 0.08], | |
| 102 | +} as const; | |
| 103 | + | |
| 104 | +interface ThreadNavigationSidebarProps { | |
| 105 | + readonly width: number; | |
| 106 | + readonly visible: boolean; | |
| 107 | + readonly selectedThreadKey: string | null; | |
| 108 | + readonly onOpenSettings: () => void; | |
| 109 | + readonly onOpenEnvironmentSettings: () => void; | |
| 110 | + readonly onSearchQueryChange: (query: string) => void; | |
| 111 | + readonly onSelectThread: (thread: EnvironmentThreadShell) => void; | |
| 112 | + readonly onRequestVisibility: () => void; | |
| 113 | + readonly searchQuery: string; | |
| 114 | +} | |
| 115 | + | |
| 116 | +/** | |
| 117 | + * iPad/large-width sidebar column. | |
| 118 | + * | |
| 119 | + * On iOS the pane is hosted inside its own navigation-inert single-screen | |
| 120 | + * native stack (SidebarNavigationShell) so the header is a real | |
| 121 | + * UINavigationBar: large title, native bar-button items, and a | |
| 122 | + * UISearchController search field — the same chrome a UISplitViewController | |
| 123 | + * column gets. Other platforms keep the custom header chrome. | |
| 124 | + */ | |
| 125 | +export function ThreadNavigationSidebar(props: ThreadNavigationSidebarProps) { | |
| 126 | + if (Platform.OS !== "ios") { | |
| 127 | + return <ThreadNavigationSidebarPane {...props} nativeChrome={false} />; | |
| 128 | + } | |
| 129 | + return <NativeSidebarContainer {...props} />; | |
| 130 | +} | |
| 131 | + | |
| 132 | +function NativeSidebarContainer(props: ThreadNavigationSidebarProps) { | |
| 133 | + const backgroundColor = useThemeColor("--color-drawer"); | |
| 134 | + const borderColor = useThemeColor("--color-border"); | |
| 135 | + | |
| 136 | + return ( | |
| 137 | + <View | |
| 138 | + testID="thread-navigation-sidebar" | |
| 139 | + style={[ | |
| 140 | + styles.container, | |
| 141 | + { | |
| 142 | + width: props.width, | |
| 143 | + backgroundColor, | |
| 144 | + borderRightColor: borderColor, | |
| 145 | + borderRightWidth: StyleSheet.hairlineWidth, | |
| 146 | + }, | |
| 147 | + ]} | |
| 148 | + > | |
| 149 | + <SidebarNavigationShell> | |
| 150 | + <ThreadNavigationSidebarPane {...props} nativeChrome /> | |
| 151 | + </SidebarNavigationShell> | |
| 152 | + </View> | |
| 153 | + ); | |
| 154 | +} | |
| 155 | + | |
| 156 | +function ThreadNavigationSidebarPane( | |
| 157 | + props: ThreadNavigationSidebarProps & { readonly nativeChrome: boolean }, | |
| 158 | +) { | |
| 159 | + const insets = useSafeAreaInsets(); | |
| 160 | + const colorScheme = useColorScheme() === "dark" ? "dark" : "light"; | |
| 161 | + const projects = useProjects(); | |
| 162 | + const threads = useThreadShells(); | |
| 163 | + const { state: catalogState } = useWorkspaceState(); | |
| 164 | + const { savedConnectionsById } = useSavedRemoteConnections(); | |
| 165 | + const [headerIsOverContent, setHeaderIsOverContent] = useState(false); | |
| 166 | + const searchInputRef = useRef<TextInput>(null); | |
| 167 | + const searchBarRef = useRef<SearchBarCommands>(null); | |
| 168 | + const openSwipeableRef = useRef<SwipeableMethods | null>(null); | |
| 169 | + const headerIsOverContentRef = useRef(false); | |
| 170 | + const sidebarScrollGesture = useMemo(() => Gesture.Native(), []); | |
| 171 | + const { archiveThread, confirmDeleteThread } = useThreadListActions(); | |
| 172 | + const pendingTasks = usePendingNewTasks(); | |
| 173 | + const { openPendingTask, confirmDeletePendingTask } = usePendingTaskListActions(); | |
| 174 | + const environments = useMemo( | |
| 175 | + () => | |
| 176 | + Object.values(savedConnectionsById) | |
| 177 | + .map((connection) => ({ | |
| 178 | + environmentId: connection.environmentId, | |
| 179 | + label: connection.environmentLabel, | |
| 180 | + })) | |
| 181 | + .sort((left, right) => left.label.localeCompare(right.label)), | |
| 182 | + [savedConnectionsById], | |
| 183 | + ); | |
| 184 | + const availableEnvironmentIds = useMemo( | |
| 185 | + () => new Set(environments.map((environment) => environment.environmentId)), | |
| 186 | + [environments], | |
| 187 | + ); | |
| 188 | + const { | |
| 189 | + options, | |
| 190 | + setSelectedEnvironmentId, | |
| 191 | + setProjectGroupingMode, | |
| 192 | + setProjectSortOrder, | |
| 193 | + setThreadSortOrder, | |
| 194 | + } = useHomeListOptions(availableEnvironmentIds); | |
| 195 | + const groups = useMemo( | |
| 196 | + () => | |
| 197 | + buildHomeThreadGroups({ | |
| 198 | + projects, | |
| 199 | + threads, | |
| 200 | + pendingTasks, | |
| 201 | + environmentId: options.selectedEnvironmentId, | |
| 202 | + searchQuery: props.searchQuery, | |
| 203 | + projectSortOrder: options.projectSortOrder, | |
| 204 | + threadSortOrder: options.threadSortOrder, | |
| 205 | + projectGroupingMode: options.projectGroupingMode, | |
| 206 | + }), | |
| 207 | + [options, pendingTasks, projects, props.searchQuery, threads], | |
| 208 | + ); | |
| 209 | + const [groupDisplayStates, setGroupDisplayStates] = useState< | |
| 210 | + ReadonlyMap<string, HomeGroupDisplayState> | |
| 211 | + >(() => new Map()); | |
| 212 | + const updateGroupDisplay = useCallback((key: string, action: HomeGroupDisplayAction) => { | |
| 213 | + setGroupDisplayStates((previous) => { | |
| 214 | + const next = new Map(previous); | |
| 215 | + next.set( | |
| 216 | + key, | |
| 217 | + nextGroupDisplayState(previous.get(key) ?? DEFAULT_GROUP_DISPLAY_STATE, action), | |
| 218 | + ); | |
| 219 | + return next; | |
| 220 | + }); | |
| 221 | + }, []); | |
| 222 | + const hasSearchQuery = props.searchQuery.trim().length > 0; | |
| 223 | + const listLayout = useMemo( | |
| 224 | + () => | |
| 225 | + buildHomeListLayout({ | |
| 226 | + groups, | |
| 227 | + displayStates: groupDisplayStates, | |
| 228 | + showAllThreads: hasSearchQuery, | |
| 229 | + }), | |
| 230 | + [groups, groupDisplayStates, hasSearchQuery], | |
| 231 | + ); | |
| 232 | + const projectCwdByKey = useMemo(() => { | |
| 233 | + const map = new Map<string, string>(); | |
| 234 | + for (const project of projects) { | |
| 235 | + map.set(scopedProjectKey(project.environmentId, project.id), project.workspaceRoot); | |
| 236 | + } | |
| 237 | + return map; | |
| 238 | + }, [projects]); | |
| 239 | + const showsConnectionStatus = shouldShowWorkspaceConnectionStatus(catalogState); | |
| 240 | + const listMenuActions = useMemo<MenuAction[]>( | |
| 241 | + () => [ | |
| 242 | + { | |
| 243 | + id: "environment", | |
| 244 | + title: "Environment", | |
| 245 | + subactions: [ | |
| 246 | + { | |
| 247 | + id: "environment:all", | |
| 248 | + title: "All environments", | |
| 249 | + subtitle: "Show threads from every environment", | |
| 250 | + state: options.selectedEnvironmentId === null ? "on" : "off", | |
| 251 | + }, | |
| 252 | + ...environments.map((environment) => ({ | |
| 253 | + id: `environment:${environment.environmentId}`, | |
| 254 | + title: environment.label, | |
| 255 | + state: | |
| 256 | + options.selectedEnvironmentId === environment.environmentId | |
| 257 | + ? ("on" as const) | |
| 258 | + : ("off" as const), | |
| 259 | + })), | |
| 260 | + ], | |
| 261 | + }, | |
| 262 | + { | |
| 263 | + id: "project-sort", | |
| 264 | + title: "Sort projects", | |
| 265 | + subactions: PROJECT_SORT_OPTIONS.map((option) => ({ | |
| 266 | + id: `project-sort:${option.value}`, | |
| 267 | + title: option.label, | |
| 268 | + state: options.projectSortOrder === option.value ? "on" : "off", | |
| 269 | + })), | |
| 270 | + }, | |
| 271 | + { | |
| 272 | + id: "thread-sort", | |
| 273 | + title: "Sort threads", | |
| 274 | + subactions: THREAD_SORT_OPTIONS.map((option) => ({ | |
| 275 | + id: `thread-sort:${option.value}`, | |
| 276 | + title: option.label, | |
| 277 | + state: options.threadSortOrder === option.value ? "on" : "off", | |
| 278 | + })), | |
| 279 | + }, | |
| 280 | + { | |
| 281 | + id: "project-grouping", | |
| 282 | + title: "Group projects", | |
| 283 | + subactions: PROJECT_GROUPING_OPTIONS.map((option) => ({ | |
| 284 | + id: `project-grouping:${option.value}`, | |
| 285 | + title: option.label, | |
| 286 | + subtitle: option.subtitle, | |
| 287 | + state: options.projectGroupingMode === option.value ? "on" : "off", | |
| 288 | + })), | |
| 289 | + }, | |
| 290 | + ], | |
| 291 | + [environments, options], | |
| 292 | + ); | |
| 293 | + const handleListMenuAction = useCallback( | |
| 294 | + ({ nativeEvent }: { readonly nativeEvent: { readonly event: string } }) => { | |
| 295 | + const event = nativeEvent.event; | |
| 296 | + if (event === "environment:all") { | |
| 297 | + setSelectedEnvironmentId(null); | |
| 298 | + return; | |
| 299 | + } | |
| 300 | + if (event.startsWith("environment:")) { | |
| 301 | + const environment = environments.find( | |
| 302 | + (candidate) => String(candidate.environmentId) === event.slice("environment:".length), | |
| 303 | + ); | |
| 304 | + if (environment) setSelectedEnvironmentId(environment.environmentId); | |
| 305 | + return; | |
| 306 | + } | |
| 307 | + const projectSort = PROJECT_SORT_OPTIONS.find( | |
| 308 | + (option) => `project-sort:${option.value}` === event, | |
| 309 | + ); | |
| 310 | + if (projectSort) { | |
| 311 | + setProjectSortOrder(projectSort.value); | |
| 312 | + return; | |
| 313 | + } | |
| 314 | + const threadSort = THREAD_SORT_OPTIONS.find( | |
| 315 | + (option) => `thread-sort:${option.value}` === event, | |
| 316 | + ); | |
| 317 | + if (threadSort) { | |
| 318 | + setThreadSortOrder(threadSort.value); | |
| 319 | + return; | |
| 320 | + } | |
| 321 | + const grouping = PROJECT_GROUPING_OPTIONS.find( | |
| 322 | + (option) => `project-grouping:${option.value}` === event, | |
| 323 | + ); | |
| 324 | + if (grouping) setProjectGroupingMode(grouping.value); | |
| 325 | + }, | |
| 326 | + [ | |
| 327 | + environments, | |
| 328 | + setProjectGroupingMode, | |
| 329 | + setProjectSortOrder, | |
| 330 | + setSelectedEnvironmentId, | |
| 331 | + setThreadSortOrder, | |
| 332 | + ], | |
| 333 | + ); | |
| 334 | + | |
| 335 | + const backgroundColor = useThemeColor("--color-drawer"); | |
| 336 | + const borderColor = useThemeColor("--color-border"); | |
| 337 | + const foregroundColor = useThemeColor("--color-foreground"); | |
| 338 | + const mutedColor = useThemeColor("--color-foreground-muted"); | |
| 339 | + const placeholderColor = useThemeColor("--color-placeholder"); | |
| 340 | + const searchBackgroundColor = | |
| 341 | + colorScheme === "dark" ? IOS_SEARCH_FILL_DARK : IOS_SEARCH_FILL_LIGHT; | |
| 342 | + const headerFadeColor = String(backgroundColor); | |
| 343 | + const headerWashOpacity = SIDEBAR_HEADER_WASH_OPACITY[colorScheme]; | |
| 344 | + const [measuredHeaderHeight, setMeasuredHeaderHeight] = useState<number | null>(null); | |
| 345 | + // The sticky header (title row, search field, optional connection status) | |
| 346 | + // is measured so the list inset always matches its real height — no | |
| 347 | + // hardcoded per-variant constants. | |
| 348 | + const stickyHeaderHeight = measuredHeaderHeight ?? insets.top + SIDEBAR_STICKY_HEADER_HEIGHT; | |
| 349 | + const topListInset = stickyHeaderHeight + 6; | |
| 350 | + const handleStickyHeaderLayout = useCallback((event: LayoutChangeEvent) => { | |
| 351 | + const nextHeight = Math.round(event.nativeEvent.layout.height); | |
| 352 | + setMeasuredHeaderHeight((current) => (current === nextHeight ? current : nextHeight)); | |
| 353 | + }, []); | |
| 354 | + const handleSwipeableWillOpen = useCallback((methods: SwipeableMethods) => { | |
| 355 | + if (openSwipeableRef.current !== methods) { | |
| 356 | + openSwipeableRef.current?.close(); | |
| 357 | + openSwipeableRef.current = methods; | |
| 358 | + } | |
| 359 | + }, []); | |
| 360 | + const handleSwipeableClose = useCallback((methods: SwipeableMethods) => { | |
| 361 | + if (openSwipeableRef.current === methods) { | |
| 362 | + openSwipeableRef.current = null; | |
| 363 | + } | |
| 364 | + }, []); | |
| 365 | + const handleSelectThread = useCallback( | |
| 366 | + (thread: EnvironmentThreadShell) => { | |
| 367 | + props.onSelectThread(thread); | |
| 368 | + openSwipeableRef.current?.close(); | |
| 369 | + }, | |
| 370 | + [props.onSelectThread], | |
| 371 | + ); | |
| 372 | + const handleScroll = useCallback((event: NativeSyntheticEvent<NativeScrollEvent>) => { | |
| 373 | + const next = event.nativeEvent.contentOffset.y > 6; | |
| 374 | + if (headerIsOverContentRef.current === next) { | |
| 375 | + return; | |
| 376 | + } | |
| 377 | + headerIsOverContentRef.current = next; | |
| 378 | + setHeaderIsOverContent(next); | |
| 379 | + }, []); | |
| 380 | + const handleScrollBeginDrag = useCallback(() => { | |
| 381 | + openSwipeableRef.current?.close(); | |
| 382 | + }, []); | |
| 383 | + const { swipeEnabled, scrollGateHandlers } = useSwipeableScrollGate({ | |
| 384 | + onScroll: handleScroll, | |
| 385 | + onScrollBeginDrag: handleScrollBeginDrag, | |
| 386 | + }); | |
| 387 | + const listExtraData = props.selectedThreadKey ?? ""; | |
| 388 | + const focusSearch = useCallback(() => { | |
| 389 | + const focus = () => { | |
| 390 | + if (props.nativeChrome) { | |
| 391 | + searchBarRef.current?.focus(); | |
| 392 | + return; | |
| 393 | + } | |
| 394 | + searchInputRef.current?.focus(); | |
| 395 | + }; | |
| 396 | + if (!props.visible) { | |
| 397 | + props.onRequestVisibility(); | |
| 398 | + setTimeout(focus, 240); | |
| 399 | + } else { | |
| 400 | + focus(); | |
| 401 | + } | |
| 402 | + return true; | |
| 403 | + }, [props.nativeChrome, props.onRequestVisibility, props.visible]); | |
| 404 | + useHardwareKeyboardCommand("focusSearch", focusSearch); | |
| 405 | + const renderListItem = useCallback( | |
| 406 | + ({ item }: { readonly item: HomeListItem }) => { | |
| 407 | + switch (item.type) { | |
| 408 | + case "header": | |
| 409 | + return ( | |
| 410 | + <ThreadListGroupHeader | |
| 411 | + variant="sidebar" | |
| 412 | + collapsed={item.collapsed} | |
| 413 | + isFirst={item.isFirst} | |
| 414 | + groupKey={item.group.key} | |
| 415 | + onGroupAction={updateGroupDisplay} | |
| 416 | + project={item.group.representative} | |
| 417 | + threadCount={item.group.threads.length + item.group.pendingTasks.length} | |
| 418 | + title={item.group.title} | |
| 419 | + /> | |
| 420 | + ); | |
| 421 | + case "pending-task": | |
| 422 | + return ( | |
| 423 | + <PendingTaskListRow | |
| 424 | + variant="sidebar" | |
| 425 | + pendingTask={item.pendingTask} | |
| 426 | + environmentLabel={ | |
| 427 | + savedConnectionsById[item.pendingTask.message.environmentId]?.environmentLabel ?? | |
| 428 | + null | |
| 429 | + } | |
| 430 | + isLast={item.isLast} | |
| 431 | + onSelectPendingTask={openPendingTask} | |
| 432 | + onDeletePendingTask={confirmDeletePendingTask} | |
| 433 | + /> | |
| 434 | + ); | |
| 435 | + case "thread": { | |
| 436 | + const thread = item.thread; | |
| 437 | + return ( | |
| 438 | + <ThreadListRow | |
| 439 | + variant="sidebar" | |
| 440 | + thread={thread} | |
| 441 | + environmentLabel={ | |
| 442 | + savedConnectionsById[thread.environmentId]?.environmentLabel ?? null | |
| 443 | + } | |
| 444 | + projectCwd={ | |
| 445 | + projectCwdByKey.get(scopedProjectKey(thread.environmentId, thread.projectId)) ?? | |
| 446 | + null | |
| 447 | + } | |
| 448 | + isLast={item.isLast} | |
| 449 | + selected={ | |
| 450 | + scopedThreadKey(thread.environmentId, thread.id) === props.selectedThreadKey | |
| 451 | + } | |
| 452 | + fullSwipeWidth={props.width - 20} | |
| 453 | + onArchiveThread={archiveThread} | |
| 454 | + onDeleteThread={confirmDeleteThread} | |
| 455 | + onSelectThread={handleSelectThread} | |
| 456 | + onSwipeableClose={handleSwipeableClose} | |
| 457 | + onSwipeableWillOpen={handleSwipeableWillOpen} | |
| 458 | + simultaneousSwipeGesture={sidebarScrollGesture} | |
| 459 | + /> | |
| 460 | + ); | |
| 461 | + } | |
| 462 | + case "show-more": | |
| 463 | + return ( | |
| 464 | + <ThreadListShowMoreRow | |
| 465 | + variant="sidebar" | |
| 466 | + hiddenCount={item.hiddenCount} | |
| 467 | + canShowLess={item.canShowLess} | |
| 468 | + groupKey={item.groupKey} | |
| 469 | + onGroupAction={updateGroupDisplay} | |
| 470 | + /> | |
| 471 | + ); | |
| 472 | + } | |
| 473 | + }, | |
| 474 | + [ | |
| 475 | + archiveThread, | |
| 476 | + confirmDeletePendingTask, | |
| 477 | + confirmDeleteThread, | |
| 478 | + handleSelectThread, | |
| 479 | + handleSwipeableClose, | |
| 480 | + handleSwipeableWillOpen, | |
| 481 | + openPendingTask, | |
| 482 | + projectCwdByKey, | |
| 483 | + props.selectedThreadKey, | |
| 484 | + props.width, | |
| 485 | + savedConnectionsById, | |
| 486 | + sidebarScrollGesture, | |
| 487 | + updateGroupDisplay, | |
| 488 | + ], | |
| 489 | + ); | |
| 490 | + const filterIcon = hasCustomHomeListOptions(options) | |
| 491 | + ? "line.3.horizontal.decrease.circle.fill" | |
| 492 | + : "line.3.horizontal.decrease.circle"; | |
| 493 | + const filterMenu = useMemo( | |
| 494 | + () => | |
| 495 | + buildHomeListFilterMenu({ | |
| 496 | + environments, | |
| 497 | + selectedEnvironmentId: options.selectedEnvironmentId, | |
| 498 | + projectSortOrder: options.projectSortOrder, | |
| 499 | + threadSortOrder: options.threadSortOrder, | |
| 500 | + projectGroupingMode: options.projectGroupingMode, | |
| 501 | + onEnvironmentChange: setSelectedEnvironmentId, | |
| 502 | + onProjectSortOrderChange: setProjectSortOrder, | |
| 503 | + onThreadSortOrderChange: setThreadSortOrder, | |
| 504 | + onProjectGroupingModeChange: setProjectGroupingMode, | |
| 505 | + }), | |
| 506 | + [ | |
| 507 | + environments, | |
| 508 | + options, | |
| 509 | + setProjectGroupingMode, | |
| 510 | + setProjectSortOrder, | |
| 511 | + setSelectedEnvironmentId, | |
| 512 | + setThreadSortOrder, | |
| 513 | + ], | |
| 514 | + ); | |
| 515 | + const nativeHeaderItems = useMemo( | |
| 516 | + () => | |
| 517 | + createSidebarHeaderItems({ | |
| 518 | + filterIcon, | |
| 519 | + filterMenu, | |
| 520 | + onOpenSettings: props.onOpenSettings, | |
| 521 | + }), | |
| 522 | + [filterIcon, filterMenu, props.onOpenSettings], | |
| 523 | + ); | |
| 524 | + const listEmpty = ( | |
| 525 | + <Text className="px-2 py-4 text-sm" style={{ color: mutedColor }}> | |
| 526 | + {catalogState.isLoadingConnections | |
| 527 | + ? "Loading threads…" | |
| 528 | + : props.searchQuery.trim().length > 0 | |
| 529 | + ? "No matching threads" | |
| 530 | + : "No threads yet"} | |
| 531 | + </Text> | |
| 532 | + ); | |
| 533 | + | |
| 534 | + if (props.nativeChrome) { | |
| 535 | + return ( | |
| 536 | + <> | |
| 537 | + <NativeStackScreenOptions | |
| 538 | + options={{ | |
| 539 | + headerSearchBarOptions: { | |
| 540 | + ref: searchBarRef, | |
| 541 | + autoCapitalize: "none", | |
| 542 | + hideNavigationBar: false, | |
| 543 | + // Keep the search bar pinned under the title — UIKit's default | |
| 544 | + // hidesSearchBarWhenScrolling collapses it on scroll. | |
| 545 | + hideWhenScrolling: false, | |
| 546 | + obscureBackground: false, | |
| 547 | + placeholder: "Search", | |
| 548 | + placement: "stacked", | |
| 549 | + onCancelButtonPress: () => { | |
| 550 | + props.onSearchQueryChange(""); | |
| 551 | + }, | |
| 552 | + onChangeText: (event) => { | |
| 553 | + props.onSearchQueryChange(event.nativeEvent.text); | |
| 554 | + }, | |
| 555 | + }, | |
| 556 | + unstable_headerRightItems: () => nativeHeaderItems, | |
| 557 | + }} | |
| 558 | + /> | |
| 559 | + <View style={styles.container}> | |
| 560 | + <SwipeableScrollGateProvider enabled={swipeEnabled}> | |
| 561 | + <GestureDetector gesture={sidebarScrollGesture}> | |
| 562 | + <LegendList | |
| 563 | + data={listLayout.items} | |
| 564 | + drawDistance={500} | |
| 565 | + estimatedItemSize={64} | |
| 566 | + extraData={listExtraData} | |
| 567 | + getItemType={(item) => item.type} | |
| 568 | + itemsAreEqual={homeListItemsAreEqual} | |
| 569 | + keyExtractor={(item) => item.key} | |
| 570 | + renderItem={renderListItem} | |
| 571 | + automaticallyAdjustsScrollIndicatorInsets | |
| 572 | + contentInsetAdjustmentBehavior="automatic" | |
| 573 | + contentContainerStyle={[ | |
| 574 | + styles.threadListContent, | |
| 575 | + { | |
| 576 | + paddingBottom: Math.max(insets.bottom, 16) + 16, | |
| 577 | + paddingTop: 6, | |
| 578 | + }, | |
| 579 | + ]} | |
| 580 | + keyboardDismissMode="on-drag" | |
| 581 | + keyboardShouldPersistTaps="handled" | |
| 582 | + {...scrollGateHandlers} | |
| 583 | + recycleItems | |
| 584 | + scrollEventThrottle={16} | |
| 585 | + showsVerticalScrollIndicator={false} | |
| 586 | + style={styles.threadList} | |
| 587 | + ListHeaderComponent={ | |
| 588 | + showsConnectionStatus ? ( | |
| 589 | + <View style={styles.connectionStatusNative}> | |
| 590 | + <WorkspaceConnectionStatus | |
| 591 | + onPress={props.onOpenEnvironmentSettings} | |
| 592 | + state={catalogState} | |
| 593 | + variant="sidebar" | |
| 594 | + /> | |
| 595 | + </View> | |
| 596 | + ) : null | |
| 597 | + } | |
| 598 | + ListEmptyComponent={listEmpty} | |
| 599 | + /> | |
| 600 | + </GestureDetector> | |
| 601 | + </SwipeableScrollGateProvider> | |
| 602 | + </View> | |
| 603 | + </> | |
| 604 | + ); | |
| 605 | + } | |
| 606 | + | |
| 607 | + return ( | |
| 608 | + <View | |
| 609 | + testID="thread-navigation-sidebar" | |
| 610 | + style={[ | |
| 611 | + styles.container, | |
| 612 | + { | |
| 613 | + width: props.width, | |
| 614 | + backgroundColor, | |
| 615 | + borderRightColor: borderColor, | |
| 616 | + borderRightWidth: StyleSheet.hairlineWidth, | |
| 617 | + }, | |
| 618 | + ]} | |
| 619 | + > | |
| 620 | + <View style={{ flex: 1, paddingBottom: insets.bottom }}> | |
| 621 | + <SwipeableScrollGateProvider enabled={swipeEnabled}> | |
| 622 | + <GestureDetector gesture={sidebarScrollGesture}> | |
| 623 | + <LegendList | |
| 624 | + data={listLayout.items} | |
| 625 | + drawDistance={500} | |
| 626 | + estimatedItemSize={64} | |
| 627 | + extraData={listExtraData} | |
| 628 | + getItemType={(item) => item.type} | |
| 629 | + itemsAreEqual={homeListItemsAreEqual} | |
| 630 | + keyExtractor={(item) => item.key} | |
| 631 | + renderItem={renderListItem} | |
| 632 | + contentContainerStyle={[ | |
| 633 | + styles.threadListContent, | |
| 634 | + { | |
| 635 | + paddingBottom: 16 + insets.bottom, | |
| 636 | + paddingTop: topListInset, | |
| 637 | + }, | |
| 638 | + ]} | |
| 639 | + keyboardDismissMode="on-drag" | |
| 640 | + keyboardShouldPersistTaps="handled" | |
| 641 | + {...scrollGateHandlers} | |
| 642 | + recycleItems | |
| 643 | + scrollEventThrottle={16} | |
| 644 | + showsVerticalScrollIndicator={false} | |
| 645 | + style={styles.threadList} | |
| 646 | + ListEmptyComponent={listEmpty} | |
| 647 | + /> | |
| 648 | + </GestureDetector> | |
| 649 | + </SwipeableScrollGateProvider> | |
| 650 | + </View> | |
| 651 | + | |
| 652 | + <View | |
| 653 | + onLayout={handleStickyHeaderLayout} | |
| 654 | + pointerEvents="box-none" | |
| 655 | + style={[ | |
| 656 | + styles.stickyHeader, | |
| 657 | + { | |
| 658 | + paddingTop: insets.top, | |
| 659 | + }, | |
| 660 | + ]} | |
| 661 | + > | |
| 662 | + <View | |
| 663 | + pointerEvents="none" | |
| 664 | + accessibilityElementsHidden | |
| 665 | + importantForAccessibility="no-hide-descendants" | |
| 666 | + style={[ | |
| 667 | + styles.stickyHeaderWash, | |
| 668 | + { | |
| 669 | + height: stickyHeaderHeight + SIDEBAR_STICKY_HEADER_FADE_HEIGHT, | |
| 670 | + }, | |
| 671 | + ]} | |
| 672 | + > | |
| 673 | + <Svg width="100%" height="100%"> | |
| 674 | + <Defs> | |
| 675 | + <LinearGradient id="sidebar-header-wash" x1="0%" x2="0%" y1="0%" y2="100%"> | |
| 676 | + <Stop | |
| 677 | + offset="0%" | |
| 678 | + stopColor={headerFadeColor} | |
| 679 | + stopOpacity={headerIsOverContent ? headerWashOpacity[0] : 0} | |
| 680 | + /> | |
| 681 | + <Stop | |
| 682 | + offset="58%" | |
| 683 | + stopColor={headerFadeColor} | |
| 684 | + stopOpacity={headerIsOverContent ? headerWashOpacity[1] : 0} | |
| 685 | + /> | |
| 686 | + <Stop | |
| 687 | + offset="88%" | |
| 688 | + stopColor={headerFadeColor} | |
| 689 | + stopOpacity={headerIsOverContent ? headerWashOpacity[2] : 0} | |
| 690 | + /> | |
| 691 | + <Stop offset="100%" stopColor={headerFadeColor} stopOpacity={0} /> | |
| 692 | + </LinearGradient> | |
| 693 | + </Defs> | |
| 694 | + <Rect width="100%" height="100%" fill="url(#sidebar-header-wash)" /> | |
| 695 | + </Svg> | |
| 696 | + </View> | |
| 697 | + <View style={styles.header}> | |
| 698 | + <Text | |
| 699 | + className="flex-1 text-[34px] font-t3-bold" | |
| 700 | + numberOfLines={1} | |
| 701 | + style={{ color: foregroundColor }} | |
| 702 | + > | |
| 703 | + Threads | |
| 704 | + </Text> | |
| 705 | + <SidebarHeaderButtonGroup colorScheme={colorScheme}> | |
| 706 | + <ControlPillMenu actions={listMenuActions} onPressAction={handleListMenuAction}> | |
| 707 | + <SidebarFilterButton | |
| 708 | + grouped | |
| 709 | + accessibilityLabel="Filter and sort threads" | |
| 710 | + icon={filterIcon} | |
| 711 | + /> | |
| 712 | + </ControlPillMenu> | |
| 713 | + <SidebarHeaderActions grouped onOpenSettings={props.onOpenSettings} /> | |
| 714 | + </SidebarHeaderButtonGroup> | |
| 715 | + </View> | |
| 716 | + | |
| 717 | + <View | |
| 718 | + style={[ | |
| 719 | + styles.searchField, | |
| 720 | + { | |
| 721 | + backgroundColor: searchBackgroundColor, | |
| 722 | + }, | |
| 723 | + ]} | |
| 724 | + > | |
| 725 | + <SymbolView name="magnifyingglass" size={15} tintColor={mutedColor} type="monochrome" /> | |
| 726 | + <TextInput | |
| 727 | + ref={searchInputRef} | |
| 728 | + accessibilityLabel="Search threads" | |
| 729 | + autoCapitalize="none" | |
| 730 | + autoCorrect={false} | |
| 731 | + clearButtonMode="while-editing" | |
| 732 | + onChangeText={props.onSearchQueryChange} | |
| 733 | + placeholder="Search" | |
| 734 | + placeholderTextColor={placeholderColor} | |
| 735 | + returnKeyType="search" | |
| 736 | + className="text-base" | |
| 737 | + style={[styles.searchInput, { color: foregroundColor }]} | |
| 738 | + value={props.searchQuery} | |
| 739 | + /> | |
| 740 | + </View> | |
| 741 | + | |
| 742 | + {showsConnectionStatus ? ( | |
| 743 | + <View style={styles.connectionStatus}> | |
| 744 | + <WorkspaceConnectionStatus | |
| 745 | + onPress={props.onOpenEnvironmentSettings} | |
| 746 | + state={catalogState} | |
| 747 | + variant="sidebar" | |
| 748 | + /> | |
| 749 | + </View> | |
| 750 | + ) : null} | |
| 751 | + </View> | |
| 752 | + </View> | |
| 753 | + ); | |
| 754 | +} | |
| 755 | + | |
| 756 | +const styles = StyleSheet.create({ | |
| 757 | + container: { | |
| 758 | + flex: 1, | |
| 759 | + }, | |
| 760 | + stickyHeader: { | |
| 761 | + left: 0, | |
| 762 | + position: "absolute", | |
| 763 | + right: 0, | |
| 764 | + top: 0, | |
| 765 | + zIndex: 4, | |
| 766 | + }, | |
| 767 | + stickyHeaderWash: { | |
| 768 | + left: 0, | |
| 769 | + position: "absolute", | |
| 770 | + right: 0, | |
| 771 | + top: 0, | |
| 772 | + }, | |
| 773 | + header: { | |
| 774 | + height: 50, | |
| 775 | + paddingLeft: 20, | |
| 776 | + paddingRight: 8, | |
| 777 | + flexDirection: "row", | |
| 778 | + alignItems: "flex-end", | |
| 779 | + gap: 2, | |
| 780 | + }, | |
| 781 | + headerButtonGroup: { | |
| 782 | + alignItems: "center", | |
| 783 | + borderRadius: 22, | |
| 784 | + flexDirection: "row", | |
| 785 | + overflow: "hidden", | |
| 786 | + }, | |
| 787 | + connectionStatus: { | |
| 788 | + paddingTop: 10, | |
| 789 | + paddingHorizontal: 14, | |
| 790 | + }, | |
| 791 | + connectionStatusNative: { | |
| 792 | + paddingBottom: 8, | |
| 793 | + paddingHorizontal: 6, | |
| 794 | + paddingTop: 2, | |
| 795 | + }, | |
| 796 | + searchField: { | |
| 797 | + height: 38, | |
| 798 | + marginTop: 9, | |
| 799 | + marginHorizontal: 16, | |
| 800 | + paddingLeft: 11, | |
| 801 | + paddingRight: 10, | |
| 802 | + borderRadius: 12, | |
| 803 | + flexDirection: "row", | |
| 804 | + alignItems: "center", | |
| 805 | + gap: 6, | |
| 806 | + }, | |
| 807 | + searchInput: { | |
| 808 | + flex: 1, | |
| 809 | + height: 34, | |
| 810 | + paddingVertical: 0, | |
| 811 | + paddingHorizontal: 0, | |
| 812 | + fontFamily: "DMSans_400Regular", | |
| 813 | + }, | |
| 814 | + threadList: { | |
| 815 | + flex: 1, | |
| 816 | + }, | |
| 817 | + threadListContent: { | |
| 818 | + paddingHorizontal: 8, | |
| 819 | + }, | |
| 820 | +}); | |
| @@ -1,11 +1,17 @@ | ||
| 1 | −import { Stack, useLocalSearchParams, useRouter } from "expo-router"; | |
| 2 | −import { useCallback, useMemo, useState } from "react"; | |
| 1 | +import { NativeStackScreenOptions } from "../../native/StackHeader"; | |
| 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 | 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 | 11 | import { projectScriptCwd, projectScriptRuntimeEnv } from "@t3tools/shared/projectScripts"; |
| 6 | 12 | import * as Haptics from "expo-haptics"; |
| 7 | −import { SymbolView } from "expo-symbols"; | |
| 8 | −import { Pressable, ScrollView, Text as RNText, View } from "react-native"; | |
| 13 | +import { Platform, Pressable, ScrollView, Text as RNText, View } from "react-native"; | |
| 14 | +import { useSafeAreaInsets } from "react-native-safe-area-context"; | |
| 9 | 15 | import { useWorkspaceState } from "../../state/workspace"; |
| 10 | 16 | import { useThemeColor } from "../../lib/useThemeColor"; |
| 11 | 17 | import { useEnvironmentQuery } from "../../state/query"; |
| @@ -14,7 +20,6 @@ import { vcsEnvironment } from "../../state/vcs"; · 2 unmodified lines | ||
| 14 | 20 | |
| 15 | 21 | import { EmptyState } from "../../components/EmptyState"; |
| 16 | 22 | import { LoadingScreen } from "../../components/LoadingScreen"; |
| 17 | −import { buildThreadRoutePath, buildThreadTerminalNavigation } from "../../lib/routes"; | |
| 18 | 23 | import { scopedThreadKey } from "../../lib/scopedEntities"; |
| 19 | 24 | import { MOBILE_TYPOGRAPHY } from "../../lib/typography"; |
| 20 | 25 | import { connectionTone } from "../connection/connectionTone"; |
| @@ -39,7 +44,12 @@ import { · 18 unmodified lines | ||
| 39 | 44 | } from "../terminal/terminalLaunchContext"; |
| 40 | 45 | import { terminalDebugLog } from "../terminal/terminalDebugLog"; |
| 41 | 46 | import { ThreadDetailScreen } from "./ThreadDetailScreen"; |
| 42 | −import { ThreadGitControls } from "./ThreadGitControls"; | |
| 47 | +import { | |
| 48 | + ThreadGitControls, | |
| 49 | + useThreadGitCenterHeaderItems, | |
| 50 | + useThreadGitRightHeaderItems, | |
| 51 | +} from "./ThreadGitControls"; | |
| 52 | +import { GitOverviewSheet } from "./git/GitOverviewSheet"; | |
| 43 | 53 | import { ThreadNavigationDrawer } from "./ThreadNavigationDrawer"; |
| 44 | 54 | import { useAtomCommand } from "../../state/use-atom-command"; |
| 45 | 55 | import { useSelectedThreadGitActions } from "../../state/use-selected-thread-git-actions"; |
| @@ -49,6 +59,30 @@ import { useSelectedThreadWorktree } from "../../state/use-selected-thread-workt · 3 unmodified lines | ||
| 49 | 59 | import { useThreadComposerState } from "../../state/use-thread-composer-state"; |
| 50 | 60 | import { threadEnvironment } from "../../state/threads"; |
| 51 | 61 | import { projectThreadContentPresentation } from "./threadContentPresentation"; |
| 62 | +import { | |
| 63 | + useAdaptiveWorkspaceLayout, | |
| 64 | + useAdaptiveWorkspacePaneRole, | |
| 65 | + useRegisterWorkspaceInspector, | |
| 66 | +} from "../layout/AdaptiveWorkspaceLayout"; | |
| 67 | +import { withNativeGlassHeaderItem } from "../layout/native-glass-header-items"; | |
| 68 | +import { ThreadFileNavigatorPane } from "../files/thread-file-navigator-pane"; | |
| 69 | +import { | |
| 70 | + ThreadInspectorContentStack, | |
| 71 | + type ThreadInspectorMode, | |
| 72 | +} from "./thread-inspector-content-stack"; | |
| 73 | +import { useHardwareKeyboardCommand } from "../keyboard/hardwareKeyboardCommands"; | |
| 74 | + | |
| 75 | +interface ThreadInspectorSelection { | |
| 76 | + readonly routeThreadIdentity: string | null; | |
| 77 | + readonly mode: ThreadInspectorMode; | |
| 78 | +} | |
| 79 | + | |
| 80 | +type NativeHeaderItems = ReadonlyArray<Record<string, unknown>>; | |
| 81 | + | |
| 82 | +function InspectorPaneRoleActivation() { | |
| 83 | + useAdaptiveWorkspacePaneRole("inspector"); | |
| 84 | + return null; | |
| 85 | +} | |
| 52 | 86 | |
| 53 | 87 | function firstRouteParam(value: string | string[] | undefined): string | null { |
| 54 | 88 | if (Array.isArray(value)) { |
| @@ -62,13 +96,99 @@ function OpeningThreadLoadingScreen() { · 7 unmodified lines | ||
| 62 | 96 | return <LoadingScreen message="Opening thread…" messagePlacement="above-spinner" />; |
| 63 | 97 | } |
| 64 | 98 | |
| 65 | −export function ThreadRouteScreen() { | |
| 99 | +type ThreadRouteScreenRouteProps = StaticScreenProps<{ | |
| 100 | + readonly environmentId: string; | |
| 101 | + readonly threadId: string; | |
| 102 | +}>; | |
| 103 | + | |
| 104 | +interface ThreadRouteScreenProps extends ThreadRouteScreenRouteProps { | |
| 105 | + readonly onReturnToThread?: () => void; | |
| 106 | + readonly renderInspector?: (headerInset: number) => ReactNode; | |
| 107 | +} | |
| 108 | + | |
| 109 | +function ThreadUnavailableScreen() { | |
| 110 | + return ( | |
| 111 | + <ScrollView | |
| 112 | + contentInsetAdjustmentBehavior="automatic" | |
| 113 | + contentContainerStyle={{ | |
| 114 | + flexGrow: 1, | |
| 115 | + justifyContent: "center", | |
| 116 | + paddingHorizontal: 24, | |
| 117 | + paddingVertical: 32, | |
| 118 | + }} | |
| 119 | + className="bg-screen flex-1" | |
| 120 | + > | |
| 121 | + <EmptyState | |
| 122 | + title="Thread unavailable" | |
| 123 | + detail="This thread is not available in the current mobile snapshot." | |
| 124 | + /> | |
| 125 | + </ScrollView> | |
| 126 | + ); | |
| 127 | +} | |
| 128 | + | |
| 129 | +export function ThreadRouteScreen(props: ThreadRouteScreenProps) { | |
| 66 | 130 | const { state: workspaceState } = useWorkspaceState(); |
| 67 | 131 | const { connectionState } = useRemoteConnectionStatus(); |
| 132 | + const { selectedThread } = useThreadSelection(); | |
| 133 | + const params = props.route.params; | |
| 134 | + const environmentIdRaw = firstRouteParam(params.environmentId); | |
| 135 | + const threadIdRaw = firstRouteParam(params.threadId); | |
| 136 | + const environmentId = environmentIdRaw ? EnvironmentId.make(environmentIdRaw) : null; | |
| 137 | + const routeEnvironmentRuntime = useRemoteEnvironmentRuntime(environmentId); | |
| 138 | + const routeConnectionState = | |
| 139 | + routeEnvironmentRuntime?.connectionState ?? (environmentId ? "available" : connectionState); | |
| 140 | + const routeThreadKey = | |
| 141 | + environmentId !== null && threadIdRaw !== null | |
| 142 | + ? scopedThreadKey(environmentId, ThreadId.make(threadIdRaw)) | |
| 143 | + : null; | |
| 144 | + const selectedThreadKey = | |
| 145 | + selectedThread === null | |
| 146 | + ? null | |
| 147 | + : scopedThreadKey(selectedThread.environmentId, selectedThread.id); | |
| 148 | + const selectedThreadDetailState = useSelectedThreadDetailState(); | |
| 149 | + | |
| 150 | + if (environmentId === null || threadIdRaw === null) { | |
| 151 | + return <OpeningThreadLoadingScreen />; | |
| 152 | + } | |
| 153 | + | |
| 154 | + // Render the full thread chrome (header, feed, composer) as soon as the | |
| 155 | + // thread SHELL is known — no blocking on message detail. The feed shows a | |
| 156 | + // loading placeholder while messages fetch, and the composer's connection | |
| 157 | + // pill reports connecting/reconnecting/syncing status. | |
| 158 | + if (selectedThread !== null && selectedThreadKey === routeThreadKey) { | |
| 159 | + return <ThreadRouteContent {...props} selectedThreadDetailState={selectedThreadDetailState} />; | |
| 160 | + } | |
| 161 | + | |
| 162 | + const stillHydrating = | |
| 163 | + workspaceState.isLoadingConnections || | |
| 164 | + routeConnectionState === "connecting" || | |
| 165 | + routeConnectionState === "reconnecting"; | |
| 166 | + | |
| 167 | + if (stillHydrating) { | |
| 168 | + return <OpeningThreadLoadingScreen />; | |
| 169 | + } | |
| 170 | + | |
| 171 | + return <ThreadUnavailableScreen />; | |
| 172 | +} | |
| 173 | + | |
| 174 | +function ThreadRouteContent( | |
| 175 | + props: ThreadRouteScreenProps & { | |
| 176 | + readonly selectedThreadDetailState: ReturnType<typeof useSelectedThreadDetailState>; | |
| 177 | + }, | |
| 178 | +) { | |
| 179 | + const { | |
| 180 | + fileInspector, | |
| 181 | + layout, | |
| 182 | + panes, | |
| 183 | + showAuxiliaryPane, | |
| 184 | + toggleAuxiliaryPane, | |
| 185 | + togglePrimarySidebar, | |
| 186 | + } = useAdaptiveWorkspaceLayout(); | |
| 187 | + const { connectionState } = useRemoteConnectionStatus(); | |
| 68 | 188 | const { onReconnectEnvironment } = useRemoteConnections(); |
| 69 | 189 | const { selectedThread, selectedThreadProject, selectedEnvironmentConnection } = |
| 70 | 190 | useThreadSelection(); |
| 71 | − const selectedThreadDetailState = useSelectedThreadDetailState(); | |
| 191 | + const selectedThreadDetailState = props.selectedThreadDetailState; | |
| 72 | 192 | const selectedThreadDetail = Option.getOrNull(selectedThreadDetailState.data); |
| 73 | 193 | const { selectedThreadCwd } = useSelectedThreadWorktree(); |
| 74 | 194 | const composer = useThreadComposerState(); |
| @@ -76,15 +196,72 @@ export function ThreadRouteScreen() { · 1 unmodified line | ||
| 76 | 196 | const gitActions = useSelectedThreadGitActions(); |
| 77 | 197 | const requests = useSelectedThreadRequests(); |
| 78 | 198 | const interruptThreadTurn = useAtomCommand(threadEnvironment.interruptTurn, "thread interrupt"); |
| 79 | − const router = useRouter(); | |
| 80 | − const params = useLocalSearchParams<{ | |
| 81 | − environmentId?: string | string[]; | |
| 82 | − threadId?: string | string[]; | |
| 83 | − }>(); | |
| 199 | + const navigation = useNavigation(); | |
| 200 | + const params = props.route.params; | |
| 84 | 201 | const [drawerVisible, setDrawerVisible] = useState(false); |
| 85 | 202 | const environmentIdRaw = firstRouteParam(params.environmentId); |
| 86 | 203 | const environmentId = environmentIdRaw ? EnvironmentId.make(environmentIdRaw) : null; |
| 87 | 204 | const threadId = firstRouteParam(params.threadId); |
| 205 | + const routeThreadIdentity = | |
| 206 | + environmentIdRaw !== null && threadId !== null ? `${environmentIdRaw}:${threadId}` : null; | |
| 207 | + const [inspectorSelection, setInspectorSelection] = useState<ThreadInspectorSelection | null>( | |
| 208 | + () => (props.renderInspector ? { routeThreadIdentity, mode: "route" } : null), | |
| 209 | + ); | |
| 210 | + const inspectorMode = (() => { | |
| 211 | + if (inspectorSelection?.routeThreadIdentity === routeThreadIdentity) { | |
| 212 | + if (inspectorSelection.mode === "files" && selectedThreadCwd === null) { | |
| 213 | + return null; | |
| 214 | + } | |
| 215 | + return inspectorSelection.mode; | |
| 216 | + } | |
| 217 | + return null; | |
| 218 | + })(); | |
| 219 | + useEffect(() => { | |
| 220 | + if ( | |
| 221 | + fileInspector.supported && | |
| 222 | + selectedThreadCwd === null && | |
| 223 | + inspectorMode === null && | |
| 224 | + panes.auxiliaryPaneVisible | |
| 225 | + ) { | |
| 226 | + toggleAuxiliaryPane(); | |
| 227 | + } | |
| 228 | + }, [ | |
| 229 | + fileInspector.supported, | |
| 230 | + inspectorMode, | |
| 231 | + panes.auxiliaryPaneVisible, | |
| 232 | + selectedThreadCwd, | |
| 233 | + toggleAuxiliaryPane, | |
| 234 | + ]); | |
| 235 | + | |
| 236 | + useEffect(() => { | |
| 237 | + setInspectorSelection((current) => { | |
| 238 | + if (props.renderInspector === undefined) { | |
| 239 | + if (current === null || current.mode === "route") { | |
| 240 | + return null; | |
| 241 | + } | |
| 242 | + return { ...current, routeThreadIdentity }; | |
| 243 | + } | |
| 244 | + | |
| 245 | + if (current === null || current.mode === "route") { | |
| 246 | + return { routeThreadIdentity, mode: "route" }; | |
| 247 | + } | |
| 248 | + | |
| 249 | + return { ...current, routeThreadIdentity }; | |
| 250 | + }); | |
| 251 | + }, [props.renderInspector, routeThreadIdentity]); | |
| 252 | + | |
| 253 | + useFocusEffect( | |
| 254 | + useCallback(() => { | |
| 255 | + return () => { | |
| 256 | + if (props.renderInspector === undefined) { | |
| 257 | + // Inspectors are contextual to this chat destination. Clear the | |
| 258 | + // hidden chat copy after a native push so returning from Files, | |
| 259 | + // Review, or Terminal cannot reserve an empty trailing pane. | |
| 260 | + setInspectorSelection(null); | |
| 261 | + } | |
| 262 | + }; | |
| 263 | + }, [props.renderInspector]), | |
| 264 | + ); | |
| 88 | 265 | const routeEnvironmentRuntime = useRemoteEnvironmentRuntime(environmentId); |
| 89 | 266 | const routeConnectionState = |
| 90 | 267 | routeEnvironmentRuntime?.connectionState ?? (environmentId ? "available" : connectionState); |
| @@ -103,10 +280,14 @@ export function ThreadRouteScreen() { · 12 unmodified lines | ||
| 103 | 280 | ); |
| 104 | 281 | |
| 105 | 282 | /* ─── Native header theming ──────────────────────────────────────── */ |
| 106 | − const iconColor = String(useThemeColor("--color-icon")); | |
| 107 | 283 | const foregroundColor = String(useThemeColor("--color-foreground")); |
| 108 | − const secondaryFg = String(useThemeColor("--color-foreground-secondary")); | |
| 109 | − | |
| 284 | + const usesNativeHeaderGlass = Platform.OS === "ios"; | |
| 285 | + const headerSubtitle = [ | |
| 286 | + selectedThreadProject?.title ?? null, | |
| 287 | + selectedEnvironmentConnection?.environmentLabel ?? null, | |
| 288 | + ] | |
| 289 | + .filter(Boolean) | |
| 290 | + .join(" · "); | |
| 110 | 291 | /* ─── Git status for native header trigger ───────────────────────── */ |
| 111 | 292 | const gitStatus = useEnvironmentQuery( |
| 112 | 293 | selectedThread !== null && selectedThreadCwd !== null |
| @@ -147,13 +328,135 @@ export function ThreadRouteScreen() { · 34 unmodified lines | ||
| 147 | 328 | const gitActionProgress = useGitActionProgress(gitActionProgressTarget); |
| 148 | 329 | |
| 149 | 330 | const handleOpenDrawer = useCallback(() => { |
| 150 | − void Haptics.selectionAsync(); | |
| 151 | − setDrawerVisible(true); | |
| 331 | + if (!layout.usesSplitView) { | |
| 332 | + void Haptics.selectionAsync(); | |
| 333 | + setDrawerVisible(true); | |
| 334 | + } | |
| 335 | + }, [layout.usesSplitView]); | |
| 336 | + | |
| 337 | + useEffect(() => { | |
| 338 | + if (layout.usesSplitView) { | |
| 339 | + setDrawerVisible(false); | |
| 340 | + } | |
| 341 | + }, [layout.usesSplitView]); | |
| 342 | + | |
| 343 | + const handleOpenGitInspector = useCallback(() => { | |
| 344 | + setInspectorSelection({ routeThreadIdentity, mode: "git" }); | |
| 345 | + showAuxiliaryPane("inspector"); | |
| 346 | + }, [routeThreadIdentity, showAuxiliaryPane]); | |
| 347 | + const handleOpenFilesInspector = useCallback(() => { | |
| 348 | + if (!fileInspector.supported || selectedThread === null || selectedThreadCwd === null) { | |
| 349 | + return; | |
| 350 | + } | |
| 351 | + setInspectorSelection({ | |
| 352 | + routeThreadIdentity, | |
| 353 | + mode: props.renderInspector === undefined ? "files" : "route", | |
| 354 | + }); | |
| 355 | + showAuxiliaryPane("inspector"); | |
| 356 | + }, [ | |
| 357 | + fileInspector.supported, | |
| 358 | + props.renderInspector, | |
| 359 | + routeThreadIdentity, | |
| 360 | + selectedThread, | |
| 361 | + selectedThreadCwd, | |
| 362 | + showAuxiliaryPane, | |
| 363 | + ]); | |
| 364 | + const inspectorToggleActionRef = useRef({ | |
| 365 | + inspectorMode, | |
| 366 | + openFilesInspector: handleOpenFilesInspector, | |
| 367 | + toggleAuxiliaryPane, | |
| 368 | + }); | |
| 369 | + inspectorToggleActionRef.current = { | |
| 370 | + inspectorMode, | |
| 371 | + openFilesInspector: handleOpenFilesInspector, | |
| 372 | + toggleAuxiliaryPane, | |
| 373 | + }; | |
| 374 | + const handleToggleInspector = useCallback(() => { | |
| 375 | + const action = inspectorToggleActionRef.current; | |
| 376 | + if (action.inspectorMode === null) { | |
| 377 | + action.openFilesInspector(); | |
| 378 | + return; | |
| 379 | + } | |
| 380 | + action.toggleAuxiliaryPane(); | |
| 152 | 381 | }, []); |
| 382 | + const handleSelectInspectorFile = useCallback( | |
| 383 | + (path: string) => { | |
| 384 | + if (selectedThread === null) { | |
| 385 | + return; | |
| 386 | + } | |
| 387 | + const params = { | |
| 388 | + environmentId: String(selectedThread.environmentId), | |
| 389 | + threadId: String(selectedThread.id), | |
| 390 | + path: path.split("/").filter((segment) => segment.length > 0), | |
| 391 | + }; | |
| 392 | + if (fileInspector.supported) { | |
| 393 | + navigation.navigate("ThreadFile", params); | |
| 394 | + return; | |
| 395 | + } | |
| 396 | + navigation.navigate("ThreadFile", params); | |
| 397 | + }, | |
| 398 | + [fileInspector.supported, navigation, selectedThread], | |
| 399 | + ); | |
| 400 | + // The workspace inspector column spans the full window height. On iOS the | |
| 401 | + // panes bring their own nested native headers (which underlap the status | |
| 402 | + // bar); elsewhere the pane content pads itself below the top inset. | |
| 403 | + const safeAreaInsets = useSafeAreaInsets(); | |
| 404 | + const inspectorHeaderInset = Platform.OS === "ios" ? 0 : safeAreaInsets.top; | |
| 405 | + const GitInspector = useCallback( | |
| 406 | + () => ( | |
| 407 | + <GitOverviewSheet | |
| 408 | + headerInset={inspectorHeaderInset} | |
| 409 | + presentation="inspector" | |
| 410 | + route={{ params: props.route.params }} | |
| 411 | + /> | |
| 412 | + ), | |
| 413 | + [inspectorHeaderInset, props.route.params], | |
| 414 | + ); | |
| 415 | + const FilesInspector = useCallback( | |
| 416 | + () => | |
| 417 | + selectedThread !== null && selectedThreadCwd !== null ? ( | |
| 418 | + <ThreadFileNavigatorPane | |
| 419 | + cwd={selectedThreadCwd} | |
| 420 | + environmentId={selectedThread.environmentId} | |
| 421 | + headerInset={inspectorHeaderInset} | |
| 422 | + projectName={selectedThreadProject?.title ?? "Files"} | |
| 423 | + selectedPath={null} | |
| 424 | + onSelectFile={handleSelectInspectorFile} | |
| 425 | + /> | |
| 426 | + ) : null, | |
| 427 | + [ | |
| 428 | + handleSelectInspectorFile, | |
| 429 | + inspectorHeaderInset, | |
| 430 | + selectedThread, | |
| 431 | + selectedThreadCwd, | |
| 432 | + selectedThreadProject?.title, | |
| 433 | + ], | |
| 434 | + ); | |
| 435 | + const RouteInspector = useCallback( | |
| 436 | + () => props.renderInspector?.(inspectorHeaderInset), | |
| 437 | + [inspectorHeaderInset, props.renderInspector], | |
| 438 | + ); | |
| 439 | + const renderInspectorStack = useCallback( | |
| 440 | + () => | |
| 441 | + inspectorMode === null ? null : ( | |
| 442 | + <ThreadInspectorContentStack | |
| 443 | + Files={FilesInspector} | |
| 444 | + Git={GitInspector} | |
| 445 | + mode={inspectorMode} | |
| 446 | + Route={props.renderInspector ? RouteInspector : undefined} | |
| 447 | + /> | |
| 448 | + ), | |
| 449 | + [FilesInspector, GitInspector, RouteInspector, inspectorMode, props.renderInspector], | |
| 450 | + ); | |
| 451 | + const activeInspectorRenderer = inspectorMode === null ? undefined : renderInspectorStack; | |
| 452 | + // Hand the inspector to the workspace so it renders beside the navigator, | |
| 453 | + // outside this screen's native header — the terminal/git/files toolbar | |
| 454 | + // stays anchored to the chat pane instead of floating above the inspector. | |
| 455 | + useRegisterWorkspaceInspector(activeInspectorRenderer); | |
| 153 | 456 | |
| 154 | 457 | const handleOpenConnectionEditor = useCallback(() => { |
| 155 | − void router.push("/connections"); | |
| 156 | − }, [router]); | |
| 458 | + void navigation.navigate("Connections"); | |
| 459 | + }, [navigation]); | |
| 157 | 460 | const handleStopThread = useCallback(() => { |
| 158 | 461 | if ( |
| 159 | 462 | !selectedThread || |
| @@ -185,9 +488,13 @@ export function ThreadRouteScreen() { · 25 unmodified lines | ||
| 185 | 488 | return; |
| 186 | 489 | } |
| 187 | 490 | |
| 188 | − void router.push(buildThreadTerminalNavigation(selectedThread, nextTerminalId)); | |
| 491 | + void navigation.navigate("ThreadTerminal", { | |
| 492 | + environmentId: String(selectedThread.environmentId), | |
| 493 | + threadId: String(selectedThread.id), | |
| 494 | + ...(nextTerminalId ? { terminalId: nextTerminalId } : {}), | |
| 495 | + }); | |
| 189 | 496 | }, |
| 190 | − [router, selectedThread, selectedThreadProject?.workspaceRoot], | |
| 497 | + [navigation, selectedThread, selectedThreadProject?.workspaceRoot], | |
| 191 | 498 | ); |
| 192 | 499 | |
| 193 | 500 | const handleOpenNewTerminal = useCallback(() => { |
| @@ -204,8 +511,12 @@ export function ThreadRouteScreen() { · 10 unmodified lines | ||
| 204 | 511 | const nextId = nextOpenTerminalId({ |
| 205 | 512 | listedTerminalIds: terminalMenuSessions.map((session) => session.terminalId), |
| 206 | 513 | }); |
| 207 | − void router.push(buildThreadTerminalNavigation(selectedThread, nextId)); | |
| 208 | − }, [router, selectedThread, selectedThreadProject?.workspaceRoot, terminalMenuSessions]); | |
| 514 | + void navigation.navigate("ThreadTerminal", { | |
| 515 | + environmentId: String(selectedThread.environmentId), | |
| 516 | + threadId: String(selectedThread.id), | |
| 517 | + terminalId: nextId, | |
| 518 | + }); | |
| 519 | + }, [navigation, selectedThread, selectedThreadProject?.workspaceRoot, terminalMenuSessions]); | |
| 209 | 520 | |
| 210 | 521 | const handleRunProjectScript = useCallback( |
| 211 | 522 | async (script: ProjectScript) => { |
| @@ -262,134 +573,97 @@ export function ThreadRouteScreen() { · 50 unmodified lines | ||
| 262 | 573 | worktreePath: preferredWorktreePath, |
| 263 | 574 | }); |
| 264 | 575 | |
| 265 | − void router.push(buildThreadTerminalNavigation(selectedThread, targetTerminalId)); | |
| 576 | + void navigation.navigate("ThreadTerminal", { | |
| 577 | + environmentId: String(selectedThread.environmentId), | |
| 578 | + threadId: String(selectedThread.id), | |
| 579 | + terminalId: targetTerminalId, | |
| 580 | + }); | |
| 266 | 581 | }, |
| 267 | 582 | [ |
| 268 | − router, | |
| 583 | + navigation, | |
| 269 | 584 | selectedThread, |
| 270 | 585 | selectedThreadDetailWorktreePath, |
| 271 | 586 | selectedThreadProject, |
| 272 | 587 | terminalMenuSessions, |
| 273 | 588 | ], |
| 274 | 589 | ); |
| 275 | − | |
| 276 | − const headerSubtitle = [ | |
| 277 | − selectedThreadProject?.title ?? null, | |
| 278 | − selectedEnvironmentConnection?.environmentLabel ?? null, | |
| 279 | − ] | |
| 280 | − .filter(Boolean) | |
| 281 | − .join(" · "); | |
| 282 | − | |
| 283 | − // Rendered in every state (loading / unavailable / ready) so the back button and | |
| 284 | − // title stay consistent instead of flickering as the thread hydrates. Tapping the | |
| 285 | − // title opens the thread switcher drawer. | |
| 286 | − const headerScreen = ( | |
| 287 | − <Stack.Screen | |
| 288 | − options={{ | |
| 289 | − headerShown: true, | |
| 290 | − headerTransparent: true, | |
| 291 | − headerStyle: { backgroundColor: "transparent" }, | |
| 292 | − headerShadowVisible: false, | |
| 293 | − headerTintColor: iconColor, | |
| 294 | − headerBackTitle: "", | |
| 295 | − headerBackVisible: true, | |
| 296 | − headerBackButtonDisplayMode: "minimal", | |
| 297 | − headerTitle: () => ( | |
| 298 | − <Pressable | |
| 299 | − accessibilityRole="button" | |
| 300 | − accessibilityLabel="Switch thread" | |
| 301 | − accessibilityHint="Opens the list of threads" | |
| 302 | − disabled={selectedThread === null} | |
| 303 | − hitSlop={12} | |
| 304 | − onPress={handleOpenDrawer} | |
| 305 | − style={{ alignItems: "center", maxWidth: 240 }} | |
| 306 | − > | |
| 307 | − <View style={{ flexDirection: "row", alignItems: "center", gap: 4, maxWidth: 240 }}> | |
| 308 | − <RNText | |
| 309 | − numberOfLines={1} | |
| 310 | − style={{ | |
| 311 | − fontFamily: "DMSans_700Bold", | |
| 312 | − fontSize: MOBILE_TYPOGRAPHY.headline.fontSize, | |
| 313 | − fontWeight: "900", | |
| 314 | − color: foregroundColor, | |
| 315 | − letterSpacing: -0.4, | |
| 316 | − }} | |
| 317 | − > | |
| 318 | − {selectedThread?.title ?? "Opening thread…"} | |
| 319 | − </RNText> | |
| 320 | − {selectedThread ? ( | |
| 321 | − <SymbolView | |
| 322 | − name="chevron.down" | |
| 323 | − size={11} | |
| 324 | − tintColor={secondaryFg} | |
| 325 | − type="monochrome" | |
| 326 | − /> | |
| 327 | − ) : null} | |
| 328 | − </View> | |
| 329 | − {headerSubtitle ? ( | |
| 330 | − <RNText | |
| 331 | − numberOfLines={1} | |
| 332 | − style={{ | |
| 333 | − fontFamily: "DMSans_700Bold", | |
| 334 | − fontSize: MOBILE_TYPOGRAPHY.label.fontSize, | |
| 335 | − fontWeight: "700", | |
| 336 | − color: secondaryFg, | |
| 337 | − letterSpacing: 0.3, | |
| 338 | − }} | |
| 339 | − > | |
| 340 | − {headerSubtitle} | |
| 341 | − </RNText> | |
| 342 | − ) : null} | |
| 343 | − </Pressable> | |
| 344 | − ), | |
| 345 | − }} | |
| 346 | − /> | |
| 590 | + const threadGitControlProps = { | |
| 591 | + environmentId: environmentIdRaw ?? "", | |
| 592 | + threadId: threadId ?? "", | |
| 593 | + auxiliaryPaneControl: | |
| 594 | + !layout.usesSplitView && fileInspector.supported && selectedThreadCwd !== null | |
| 595 | + ? { | |
| 596 | + accessibilityLabel: "Toggle inspector", | |
| 597 | + onPress: handleToggleInspector, | |
| 598 | + } | |
| 599 | + : undefined, | |
| 600 | + onOpenFilesInspector: | |
| 601 | + fileInspector.supported && selectedThreadCwd !== null ? handleOpenFilesInspector : undefined, | |
| 602 | + onOpenGitInspector: fileInspector.supported ? handleOpenGitInspector : undefined, | |
| 603 | + currentBranch: selectedThread?.branch ?? null, | |
| 604 | + gitStatus: gitStatus.data, | |
| 605 | + gitOperationLabel: gitState.gitOperationLabel, | |
| 606 | + canOpenTerminal: Boolean(selectedThreadProject?.workspaceRoot), | |
| 607 | + canOpenFiles: Boolean(selectedThreadProject?.workspaceRoot), | |
| 608 | + projectScripts: selectedThreadProject?.scripts ?? [], | |
| 609 | + terminalSessions: terminalMenuSessions, | |
| 610 | + showDirectFileControl: layout.usesSplitView, | |
| 611 | + onOpenTerminal: handleOpenTerminal, | |
| 612 | + onOpenNewTerminal: handleOpenNewTerminal, | |
| 613 | + onRunProjectScript: handleRunProjectScript, | |
| 614 | + onPull: gitActions.onPullSelectedThreadBranch, | |
| 615 | + onRunAction: gitActions.onRunSelectedThreadGitAction, | |
| 616 | + }; | |
| 617 | + const threadCenterHeaderItems = useThreadGitCenterHeaderItems(threadGitControlProps); | |
| 618 | + const compactRightHeaderItems = useThreadGitRightHeaderItems(threadGitControlProps); | |
| 619 | + const splitLeftHeaderItems = useMemo<NativeHeaderItems>( | |
| 620 | + () => [ | |
| 621 | + { | |
| 622 | + // Match Mail's split-view detail toolbar: the first detail action sits | |
| 623 | + // inside the content pane, not flush against the sidebar divider. | |
| 624 | + spacing: 18, | |
| 625 | + type: "spacing" as const, | |
| 626 | + }, | |
| 627 | + ...(props.onReturnToThread | |
| 628 | + ? [ | |
| 629 | + withNativeGlassHeaderItem({ | |
| 630 | + accessibilityLabel: "Return to chat", | |
| 631 | + icon: { name: "chevron.left", type: "sfSymbol" as const }, | |
| 632 | + identifier: "thread-left-return", | |
| 633 | + onPress: props.onReturnToThread, | |
| 634 | + type: "button" as const, | |
| 635 | + }), | |
| 636 | + ] | |
| 637 | + : []), | |
| 638 | + withNativeGlassHeaderItem({ | |
| 639 | + accessibilityLabel: panes.primarySidebarVisible | |
| 640 | + ? "Maximize content" | |
| 641 | + : "Show thread sidebar", | |
| 642 | + icon: { | |
| 643 | + name: panes.primarySidebarVisible ? "arrow.up.left.and.arrow.down.right" : "sidebar.left", | |
| 644 | + type: "sfSymbol" as const, | |
| 645 | + }, | |
| 646 | + identifier: "thread-left-sidebar", | |
| 647 | + onPress: togglePrimarySidebar, | |
| 648 | + type: "button" as const, | |
| 649 | + }), | |
| 650 | + withNativeGlassHeaderItem({ | |
| 651 | + accessibilityLabel: "New task", | |
| 652 | + icon: { name: "square.and.pencil", type: "sfSymbol" as const }, | |
| 653 | + identifier: "thread-left-new-task", | |
| 654 | + onPress: () => navigation.navigate("NewTaskSheet", { screen: "NewTask" }), | |
| 655 | + type: "button" as const, | |
| 656 | + }), | |
| 657 | + ], | |
| 658 | + [panes.primarySidebarVisible, props.onReturnToThread, navigation, togglePrimarySidebar], | |
| 347 | 659 | ); |
| 348 | 660 | |
| 349 | 661 | if (!environmentId || !threadId) { |
| 350 | − return ( | |
| 351 | − <> | |
| 352 | − {headerScreen} | |
| 353 | − <OpeningThreadLoadingScreen /> | |
| 354 | − </> | |
| 355 | − ); | |
| 662 | + return <OpeningThreadLoadingScreen />; | |
| 356 | 663 | } |
| 357 | 664 | |
| 358 | 665 | if (!selectedThread) { |
| 359 | − const stillHydrating = | |
| 360 | − workspaceState.isLoadingConnections || | |
| 361 | − routeConnectionState === "connecting" || | |
| 362 | − routeConnectionState === "reconnecting"; | |
| 363 | − | |
| 364 | − if (stillHydrating) { | |
| 365 | − return ( | |
| 366 | − <> | |
| 367 | − {headerScreen} | |
| 368 | − <OpeningThreadLoadingScreen /> | |
| 369 | − </> | |
| 370 | − ); | |
| 371 | − } | |
| 372 | − | |
| 373 | − return ( | |
| 374 | − <> | |
| 375 | − {headerScreen} | |
| 376 | − <ScrollView | |
| 377 | − contentInsetAdjustmentBehavior="automatic" | |
| 378 | − contentContainerStyle={{ | |
| 379 | − flexGrow: 1, | |
| 380 | − justifyContent: "center", | |
| 381 | − paddingHorizontal: 24, | |
| 382 | − paddingVertical: 32, | |
| 383 | − }} | |
| 384 | − className="bg-screen flex-1" | |
| 385 | − > | |
| 386 | − <EmptyState | |
| 387 | − title="Thread unavailable" | |
| 388 | − detail="This thread is not available in the current mobile snapshot." | |
| 389 | − /> | |
| 390 | − </ScrollView> | |
| 391 | − </> | |
| 392 | − ); | |
| 666 | + return <OpeningThreadLoadingScreen />; | |
| 393 | 667 | } |
| 394 | 668 | |
| 395 | 669 | const selectedThreadKey = scopedThreadKey(selectedThread.environmentId, selectedThread.id); |
| @@ -400,25 +674,9 @@ export function ThreadRouteScreen() { · 4 unmodified lines | ||
| 400 | 674 | connectionState: routeConnectionState, |
| 401 | 675 | }); |
| 402 | 676 | const serverConfig = routeEnvironmentRuntime?.serverConfig ?? null; |
| 403 | − | |
| 404 | − return ( | |
| 677 | + const renderThreadRouteBody = (showActionControls: boolean) => ( | |
| 405 | 678 | <> |
| 406 | − {headerScreen} | |
| 407 | − | |
| 408 | − <ThreadGitControls | |
| 409 | − currentBranch={selectedThread.branch} | |
| 410 | − gitStatus={gitStatus.data} | |
| 411 | − gitOperationLabel={gitState.gitOperationLabel} | |
| 412 | − canOpenTerminal={Boolean(selectedThreadProject?.workspaceRoot)} | |
| 413 | − canOpenFiles={Boolean(selectedThreadProject?.workspaceRoot)} | |
| 414 | − projectScripts={selectedThreadProject?.scripts ?? []} | |
| 415 | − terminalSessions={terminalMenuSessions} | |
| 416 | − onOpenTerminal={handleOpenTerminal} | |
| 417 | − onOpenNewTerminal={handleOpenNewTerminal} | |
| 418 | − onRunProjectScript={handleRunProjectScript} | |
| 419 | − onPull={gitActions.onPullSelectedThreadBranch} | |
| 420 | − onRunAction={gitActions.onRunSelectedThreadGitAction} | |
| 421 | − /> | |
| 679 | + <ThreadGitControls {...threadGitControlProps} showActionControls={showActionControls} /> | |
| 422 | 680 | |
| 423 | 681 | <GitActionProgressOverlay progress={gitActionProgress} onDismiss={dismissGitActionResult} /> |
| 424 | 682 | |
| @@ -440,11 +698,15 @@ export function ThreadRouteScreen() { · 15 unmodified lines | ||
| 440 | 698 | draftMessage={composer.draftMessage} |
| 441 | 699 | draftAttachments={composer.draftAttachments} |
| 442 | 700 | connectionStateLabel={routeConnectionState} |
| 701 | + threadSyncStatus={selectedThreadDetailState.status} | |
| 443 | 702 | activeThreadBusy={composer.activeThreadBusy} |
| 444 | 703 | environmentId={selectedThread.environmentId} |
| 445 | 704 | projectWorkspaceRoot={selectedThreadProject?.workspaceRoot ?? null} |
| 446 | 705 | threadCwd={selectedThreadCwd} |
| 447 | 706 | selectedThreadQueueCount={composer.selectedThreadQueueCount} |
| 707 | + layoutVariant={layout.variant} | |
| 708 | + usesAutomaticContentInsets={usesNativeHeaderGlass} | |
| 709 | + onOpenDrawer={handleOpenDrawer} | |
| 448 | 710 | onOpenConnectionEditor={handleOpenConnectionEditor} |
| 449 | 711 | onChangeDraftMessage={composer.onChangeDraftMessage} |
| 450 | 712 | onPickDraftImages={composer.onPickDraftImages} |
| @@ -463,16 +725,57 @@ export function ThreadRouteScreen() { · 12 unmodified lines | ||
| 463 | 725 | onSubmitUserInput={requests.onSubmitUserInput} |
| 464 | 726 | /> |
| 465 | 727 | |
| 466 | − <ThreadNavigationDrawer | |
| 467 | − visible={drawerVisible} | |
| 468 | − selectedThreadKey={selectedThreadKey} | |
| 469 | − onClose={() => setDrawerVisible(false)} | |
| 470 | − onSelectThread={(thread) => { | |
| 471 | − router.replace(buildThreadRoutePath(thread)); | |
| 472 | − }} | |
| 473 | − onStartNewTask={() => router.push("/new")} | |
| 474 | − /> | |
| 728 | + {layout.usesSplitView ? null : ( | |
| 729 | + <ThreadNavigationDrawer | |
| 730 | + visible={drawerVisible} | |
| 731 | + selectedThreadKey={selectedThreadKey} | |
| 732 | + onClose={() => setDrawerVisible(false)} | |
| 733 | + onSelectThread={(thread) => { | |
| 734 | + navigation.dispatch( | |
| 735 | + StackActions.replace("Thread", { | |
| 736 | + environmentId: String(thread.environmentId), | |
| 737 | + threadId: String(thread.id), | |
| 738 | + }), | |
| 739 | + ); | |
| 740 | + }} | |
| 741 | + onStartNewTask={() => navigation.navigate("NewTaskSheet", { screen: "NewTask" })} | |
| 742 | + /> | |
| 743 | + )} | |
| 475 | 744 | </View> |
| 476 | 745 | </> |
| 477 | 746 | ); |
| 747 | + | |
| 748 | + return ( | |
| 749 | + <> | |
| 750 | + {activeInspectorRenderer ? <InspectorPaneRoleActivation /> : null} | |
| 751 | + <NativeStackScreenOptions | |
| 752 | + options={{ | |
| 753 | + headerTitle: selectedThread.title, | |
| 754 | + headerTitleStyle: usesNativeHeaderGlass | |
| 755 | + ? { | |
| 756 | + fontSize: 17, | |
| 757 | + fontWeight: "800", | |
| 758 | + } | |
| 759 | + : undefined, | |
| 760 | + title: selectedThread.title, | |
| 761 | + headerBackVisible: !layout.usesSplitView, | |
| 762 | + // Compact uses the NATIVE back button (Thread lives flat in the root | |
| 763 | + // stack now, so a real previous route exists); only split view needs | |
| 764 | + // custom left items. | |
| 765 | + unstable_headerLeftItems: | |
| 766 | + Platform.OS === "ios" && layout.usesSplitView ? () => splitLeftHeaderItems : undefined, | |
| 767 | + // Search lives in the persistent sidebar, so the split header keeps | |
| 768 | + // the git controls on the RIGHT (no center items — center space is | |
| 769 | + // reserved for future breadcrumbs/status). | |
| 770 | + unstable_headerRightItems: | |
| 771 | + Platform.OS === "ios" | |
| 772 | + ? () => (layout.usesSplitView ? threadCenterHeaderItems : compactRightHeaderItems) | |
| 773 | + : undefined, | |
| 774 | + unstable_headerSubtitle: usesNativeHeaderGlass ? headerSubtitle : undefined, | |
| 775 | + }} | |
| 776 | + /> | |
| 777 | + | |
| 778 | + {renderThreadRouteBody(!layout.usesSplitView && !usesNativeHeaderGlass)} | |
| 779 | + </> | |
| 780 | + ); | |
| 478 | 781 | } |
| @@ -1,5 +1,5 @@ | ||
| 1 | 1 | import { sanitizeFeatureBranchName } from "@t3tools/shared/git"; |
| 2 | −import { useRouter } from "expo-router"; | |
| 2 | +import { useNavigation, type StaticScreenProps } from "@react-navigation/native"; | |
| 3 | 3 | import { useState } from "react"; |
| 4 | 4 | import { Pressable, ScrollView, View } from "react-native"; |
| 5 | 5 | import { useSafeAreaInsets } from "react-native-safe-area-context"; |
| @@ -14,8 +14,13 @@ import { useSelectedThreadWorktree } from "../../../state/use-selected-thread-wo · 8 unmodified lines | ||
| 14 | 14 | import { vcsEnvironment } from "../../../state/vcs"; |
| 15 | 15 | import { SheetActionButton } from "./gitSheetComponents"; |
| 16 | 16 | |
| 17 | −export function GitBranchesSheet() { | |
| 18 | − const router = useRouter(); | |
| 17 | +type GitBranchesSheetProps = StaticScreenProps<{ | |
| 18 | + readonly environmentId: string; | |
| 19 | + readonly threadId: string; | |
| 20 | +}>; | |
| 21 | + | |
| 22 | +export function GitBranchesSheet(_props: GitBranchesSheetProps) { | |
| 23 | + const navigation = useNavigation(); | |
| 19 | 24 | const insets = useSafeAreaInsets(); |
| 20 | 25 | const { selectedThread } = useThreadSelection(); |
| 21 | 26 | const { selectedThreadCwd, selectedThreadWorktreePath } = useSelectedThreadWorktree(); |
| @@ -96,7 +101,7 @@ export function GitBranchesSheet() { · 74 unmodified lines | ||
| 96 | 101 | if (branch.length === 0) return; |
| 97 | 102 | void gitActions.onCreateSelectedThreadBranch(branch).then(() => { |
| 98 | 103 | setNewBranchName(""); |
| 99 | − router.dismiss(); | |
| 104 | + navigation.goBack(); | |
| 100 | 105 | }); |
| 101 | 106 | }} |
| 102 | 107 | /> |
| @@ -146,7 +151,7 @@ export function GitBranchesSheet() { · 43 unmodified lines | ||
| 146 | 151 | if (baseBranch.length === 0 || newBranch.length === 0) return; |
| 147 | 152 | void gitActions.onCreateSelectedThreadWorktree({ baseBranch, newBranch }).then(() => { |
| 148 | 153 | setWorktreeBranchName(""); |
| 149 | − router.dismiss(); | |
| 154 | + navigation.goBack(); | |
| 150 | 155 | }); |
| 151 | 156 | }} |
| 152 | 157 | /> |
| @@ -188,7 +193,7 @@ export function GitBranchesSheet() { · 35 unmodified lines | ||
| 188 | 193 | }} |
| 189 | 194 | onPress={() => { |
| 190 | 195 | void gitActions.onCheckoutSelectedThreadBranch(branch.name).then(() => { |
| 191 | − router.dismiss(); | |
| 196 | + navigation.goBack(); | |
| 192 | 197 | }); |
| 193 | 198 | }} |
| 194 | 199 | > |
| @@ -1,4 +1,4 @@ | ||
| 1 | −import { useRouter } from "expo-router"; | |
| 1 | +import { useNavigation, type StaticScreenProps } from "@react-navigation/native"; | |
| 2 | 2 | import { useCallback, useState } from "react"; |
| 3 | 3 | import { Pressable, ScrollView, View, useColorScheme } from "react-native"; |
| 4 | 4 | import { useSafeAreaInsets } from "react-native-safe-area-context"; |
| @@ -13,8 +13,13 @@ import { useSelectedThreadWorktree } from "../../../state/use-selected-thread-wo · 8 unmodified lines | ||
| 13 | 13 | import { vcsEnvironment } from "../../../state/vcs"; |
| 14 | 14 | import { SheetActionButton } from "./gitSheetComponents"; |
| 15 | 15 | |
| 16 | −export function GitCommitSheet() { | |
| 17 | − const router = useRouter(); | |
| 16 | +type GitCommitSheetProps = StaticScreenProps<{ | |
| 17 | + readonly environmentId: string; | |
| 18 | + readonly threadId: string; | |
| 19 | +}>; | |
| 20 | + | |
| 21 | +export function GitCommitSheet(_props: GitCommitSheetProps) { | |
| 22 | + const navigation = useNavigation(); | |
| 18 | 23 | const insets = useSafeAreaInsets(); |
| 19 | 24 | const isDarkMode = useColorScheme() === "dark"; |
| 20 | 25 | const { selectedThread } = useThreadSelection(); |
| @@ -55,7 +60,7 @@ export function GitCommitSheet() { · 34 unmodified lines | ||
| 55 | 60 | const runCommitAction = useCallback( |
| 56 | 61 | async (featureBranch: boolean) => { |
| 57 | 62 | const commitMessage = dialogCommitMessage.trim(); |
| 58 | − router.dismiss(); | |
| 63 | + navigation.goBack(); | |
| 59 | 64 | await gitActions.onRunSelectedThreadGitAction({ |
| 60 | 65 | action: "commit", |
| 61 | 66 | featureBranch, |
| @@ -63,7 +68,7 @@ export function GitCommitSheet() { · 1 unmodified line | ||
| 63 | 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 | 74 | return ( |
| @@ -86,7 +91,7 @@ export function GitCommitSheet() { · 16 unmodified lines | ||
| 86 | 91 | </View> |
| 87 | 92 | {isDefaultRef ? ( |
| 88 | 93 | <Text |
| 89 | − className="text-xs leading-[18px]" | |
| 94 | + className="text-xs leading-normal" | |
| 90 | 95 | style={{ color: isDarkMode ? "#fbbf24" : "#b45309" }} |
| 91 | 96 | > |
| 92 | 97 | Warning: this is the default branch. |
| @@ -98,7 +103,7 @@ export function GitCommitSheet() { · 5 unmodified lines | ||
| 98 | 103 | <View className="flex-row items-center justify-between gap-3"> |
| 99 | 104 | <View className="gap-1"> |
| 100 | 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 | 107 | {selectedFiles.length} selected · +{selectedInsertions} / -{selectedDeletions} |
| 103 | 108 | </Text> |
| 104 | 109 | </View> |
| @@ -123,7 +128,7 @@ export function GitCommitSheet() { · 18 unmodified lines | ||
| 123 | 128 | </View> |
| 124 | 129 | |
| 125 | 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 | 132 | No changed files are available to commit. |
| 128 | 133 | </Text> |
| 129 | 134 | ) : !isEditingFiles ? ( |
| @@ -142,7 +147,7 @@ export function GitCommitSheet() { · 12 unmodified lines | ||
| 142 | 147 | </View> |
| 143 | 148 | ))} |
| 144 | 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 | 151 | +{selectedFiles.length - selectedFilePreview.length} more files |
| 147 | 152 | </Text> |
| 148 | 153 | ) : null} |
| @@ -182,7 +187,7 @@ export function GitCommitSheet() { · 33 unmodified lines | ||
| 182 | 187 | {file.path} |
| 183 | 188 | </Text> |
| 184 | 189 | {!included ? ( |
| 185 | − <Text className="text-foreground-muted text-2xs leading-[16px]"> | |
| 190 | + <Text className="text-foreground-muted text-2xs leading-normal"> | |
| 186 | 191 | Excluded from this commit |
| 187 | 192 | </Text> |
| 188 | 193 | ) : null} |
| @@ -2,7 +2,7 @@ import { resolveDefaultBranchActionDialogCopy } from "@t3tools/client-runtime/st · 1 unmodified line | ||
| 2 | 2 | import { resolveAutoFeatureBranchName } from "@t3tools/shared/git"; |
| 3 | 3 | import * as Arr from "effect/Array"; |
| 4 | 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 | 6 | import { useCallback, useMemo } from "react"; |
| 7 | 7 | import { View } from "react-native"; |
| 8 | 8 | import { useSafeAreaInsets } from "react-native-safe-area-context"; |
| @@ -12,19 +12,23 @@ import { useSelectedThreadGitActions } from "../../../state/use-selected-thread- · 3 unmodified lines | ||
| 12 | 12 | import { useSelectedThreadGitState } from "../../../state/use-selected-thread-git-state"; |
| 13 | 13 | import { SheetActionButton } from "./gitSheetComponents"; |
| 14 | 14 | |
| 15 | −export function GitConfirmSheet() { | |
| 16 | − const router = useRouter(); | |
| 15 | +type GitConfirmSheetProps = StaticScreenProps<{ | |
| 16 | + readonly environmentId: string; | |
| 17 | + readonly threadId: string; | |
| 18 | + readonly confirmAction?: string; | |
| 19 | + readonly branchName?: string; | |
| 20 | + readonly includesCommit?: string; | |
| 21 | + readonly commitMessage?: string; | |
| 22 | + readonly filePaths?: string; | |
| 23 | +}>; | |
| 24 | + | |
| 25 | +export function GitConfirmSheet(props: GitConfirmSheetProps) { | |
| 26 | + const navigation = useNavigation(); | |
| 17 | 27 | const insets = useSafeAreaInsets(); |
| 18 | 28 | const gitState = useSelectedThreadGitState(); |
| 19 | 29 | const gitActions = useSelectedThreadGitActions(); |
| 20 | 30 | |
| 21 | − const params = useLocalSearchParams<{ | |
| 22 | − confirmAction?: string; | |
| 23 | − branchName?: string; | |
| 24 | − includesCommit?: string; | |
| 25 | − commitMessage?: string; | |
| 26 | − filePaths?: string; | |
| 27 | − }>(); | |
| 31 | + const params = props.route.params; | |
| 28 | 32 | |
| 29 | 33 | const confirmAction = params.confirmAction as |
| 30 | 34 | | "push" |
| @@ -34,6 +38,8 @@ export function GitConfirmSheet() { · 3 unmodified lines | ||
| 34 | 38 | | undefined; |
| 35 | 39 | const branchName = params.branchName ?? ""; |
| 36 | 40 | const includesCommit = params.includesCommit === "true"; |
| 41 | + const environmentId = params.environmentId ?? ""; | |
| 42 | + const threadId = params.threadId ?? ""; | |
| 37 | 43 | |
| 38 | 44 | const copy = useMemo( |
| 39 | 45 | () => |
| @@ -49,17 +55,17 @@ export function GitConfirmSheet() { · 9 unmodified lines | ||
| 49 | 55 | |
| 50 | 56 | const continuePendingAction = useCallback(async () => { |
| 51 | 57 | if (!confirmAction) return; |
| 52 | − router.dismissAll(); | |
| 58 | + navigation.dispatch(StackActions.replace("Thread", { environmentId, threadId })); | |
| 53 | 59 | await gitActions.onRunSelectedThreadGitAction({ |
| 54 | 60 | action: confirmAction, |
| 55 | 61 | ...(params.commitMessage ? { commitMessage: params.commitMessage } : {}), |
| 56 | 62 | ...(params.filePaths ? { filePaths: params.filePaths.split(",") } : {}), |
| 57 | 63 | }); |
| 58 | − }, [confirmAction, gitActions, params, router]); | |
| 64 | + }, [confirmAction, environmentId, gitActions, params, navigation, threadId]); | |
| 59 | 65 | |
| 60 | 66 | const movePendingActionToFeatureBranch = useCallback(async () => { |
| 61 | 67 | if (!confirmAction) return; |
| 62 | − router.dismissAll(); | |
| 68 | + navigation.dispatch(StackActions.replace("Thread", { environmentId, threadId })); | |
| 63 | 69 | |
| 64 | 70 | if (includesCommit) { |
| 65 | 71 | await gitActions.onRunSelectedThreadGitAction({ |
| @@ -82,7 +88,16 @@ export function GitConfirmSheet() { · 16 unmodified lines | ||
| 82 | 88 | ); |
| 83 | 89 | await gitActions.onCreateSelectedThreadBranch(newBranchName); |
| 84 | 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 | 102 | return ( |
| 88 | 103 | <View collapsable={false} className="flex-1 bg-sheet"> |
| @@ -98,7 +113,7 @@ export function GitConfirmSheet() { · 9 unmodified lines | ||
| 98 | 113 | <Text className="text-center text-3xl font-t3-bold"> |
| 99 | 114 | {copy?.title ?? "Run action on default branch?"} |
| 100 | 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 | 117 | {copy?.description ?? "Choose how to continue."} |
| 103 | 118 | </Text> |
| 104 | 119 | </View> |
| @@ -4,17 +4,18 @@ import { · 3 unmodified lines | ||
| 4 | 4 | getGitActionDisabledReason, |
| 5 | 5 | requiresDefaultBranchConfirmation, |
| 6 | 6 | } from "@t3tools/client-runtime/state/vcs"; |
| 7 | −import type { EnvironmentId, ThreadId } from "@t3tools/contracts"; | |
| 8 | −import { useLocalSearchParams, useRouter } from "expo-router"; | |
| 7 | +import { EnvironmentId, ThreadId } from "@t3tools/contracts"; | |
| 8 | +import { useNavigation, type StaticScreenProps } from "@react-navigation/native"; | |
| 9 | 9 | import { SymbolView } from "expo-symbols"; |
| 10 | −import { useCallback, useEffect, useMemo } from "react"; | |
| 11 | −import { Alert, Pressable, ScrollView, View } from "react-native"; | |
| 10 | +import { useCallback, useEffect, useMemo, useState } from "react"; | |
| 11 | +import { Alert, Platform, Pressable, RefreshControl, ScrollView, View } from "react-native"; | |
| 12 | +import { Screen, ScreenStack, ScreenStackHeaderConfig } from "react-native-screens"; | |
| 12 | 13 | import { useSafeAreaInsets } from "react-native-safe-area-context"; |
| 13 | 14 | import { useThemeColor } from "../../../lib/useThemeColor"; |
| 14 | 15 | |
| 15 | 16 | import { AppText as Text } from "../../../components/AppText"; |
| 17 | +import { nativeHeaderScrollEdgeEffects } from "../../../native/StackHeader"; | |
| 16 | 18 | import { tryOpenExternalUrl } from "../../../lib/openExternalUrl"; |
| 17 | −import { buildThreadReviewRoutePath } from "../../../lib/routes"; | |
| 18 | 19 | import { useEnvironmentQuery } from "../../../state/query"; |
| 19 | 20 | import { useThreadSelection } from "../../../state/use-thread-selection"; |
| 20 | 21 | import { useSelectedThreadGitActions } from "../../../state/use-selected-thread-git-actions"; |
| @@ -23,13 +24,23 @@ import { useSelectedThreadWorktree } from "../../../state/use-selected-thread-wo · 2 unmodified lines | ||
| 23 | 24 | import { vcsEnvironment } from "../../../state/vcs"; |
| 24 | 25 | import { MetaCard, SheetListRow, menuItemIconName, statusSummary } from "./gitSheetComponents"; |
| 25 | 26 | |
| 26 | −export function GitOverviewSheet() { | |
| 27 | − const router = useRouter(); | |
| 27 | +const HEADER_SCROLL_EDGE_EFFECTS = nativeHeaderScrollEdgeEffects(Platform.OS, Platform.Version); | |
| 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 | 39 | const insets = useSafeAreaInsets(); |
| 29 | − const { environmentId, threadId } = useLocalSearchParams<{ | |
| 30 | − environmentId: EnvironmentId; | |
| 31 | − threadId: ThreadId; | |
| 32 | − }>(); | |
| 40 | + const presentation = props.presentation ?? "sheet"; | |
| 41 | + const isInspector = presentation === "inspector"; | |
| 42 | + const environmentId = EnvironmentId.make(props.route.params.environmentId); | |
| 43 | + const threadId = ThreadId.make(props.route.params.threadId); | |
| 33 | 44 | const { selectedThread } = useThreadSelection(); |
| 34 | 45 | const { selectedThreadCwd, selectedThreadWorktreePath } = useSelectedThreadWorktree(); |
| 35 | 46 | const gitState = useSelectedThreadGitState(); |
| @@ -37,6 +48,8 @@ export function GitOverviewSheet() { · 1 unmodified line | ||
| 37 | 48 | |
| 38 | 49 | const iconColor = useThemeColor("--color-icon"); |
| 39 | 50 | const borderColor = useThemeColor("--color-border"); |
| 51 | + const foregroundColor = String(useThemeColor("--color-foreground")); | |
| 52 | + const sheetColor = String(useThemeColor("--color-sheet")); | |
| 40 | 53 | |
| 41 | 54 | const gitStatus = useEnvironmentQuery( |
| 42 | 55 | selectedThread !== null && selectedThreadCwd !== null |
| @@ -48,6 +61,7 @@ export function GitOverviewSheet() { · 5 unmodified lines | ||
| 48 | 61 | ); |
| 49 | 62 | |
| 50 | 63 | const currentBranchLabel = gitStatus.data?.refName ?? selectedThread?.branch ?? "Detached HEAD"; |
| 64 | + const currentStatusSummary = statusSummary(gitStatus.data); | |
| 51 | 65 | const currentWorktreePath = selectedThreadWorktreePath; |
| 52 | 66 | const gitOperationLabel = gitState.gitOperationLabel; |
| 53 | 67 | const busy = gitOperationLabel !== null; |
| @@ -105,25 +119,24 @@ export function GitOverviewSheet() { · 51 unmodified lines | ||
| 105 | 119 | !input.featureBranch && |
| 106 | 120 | requiresDefaultBranchConfirmation(input.action, isDefaultRef) |
| 107 | 121 | ) { |
| 108 | − router.push({ | |
| 109 | − pathname: "/threads/[environmentId]/[threadId]/git-confirm", | |
| 110 | − params: { | |
| 111 | − environmentId, | |
| 112 | − threadId, | |
| 113 | − confirmAction: confirmableAction, | |
| 114 | − branchName, | |
| 115 | − includesCommit: String( | |
| 116 | − input.action === "commit_push" || input.action === "commit_push_pr", | |
| 117 | − ), | |
| 118 | − }, | |
| 122 | + navigation.navigate("GitConfirm", { | |
| 123 | + environmentId: String(environmentId), | |
| 124 | + threadId: String(threadId), | |
| 125 | + confirmAction: confirmableAction, | |
| 126 | + branchName, | |
| 127 | + includesCommit: String( | |
| 128 | + input.action === "commit_push" || input.action === "commit_push_pr", | |
| 129 | + ), | |
| 119 | 130 | }); |
| 120 | 131 | return; |
| 121 | 132 | } |
| 122 | 133 | |
| 123 | − router.dismiss(); | |
| 134 | + if (!isInspector) { | |
| 135 | + navigation.goBack(); | |
| 136 | + } | |
| 124 | 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 | 142 | const onPressMenuItem = useCallback( |
| @@ -134,9 +147,9 @@ export function GitOverviewSheet() { · 4 unmodified lines | ||
| 134 | 147 | return; |
| 135 | 148 | } |
| 136 | 149 | if (item.dialogAction === "commit") { |
| 137 | − router.push({ | |
| 138 | − pathname: "/threads/[environmentId]/[threadId]/git/commit", | |
| 139 | − params: { environmentId, threadId }, | |
| 150 | + navigation.navigate("GitCommit", { | |
| 151 | + environmentId: String(environmentId), | |
| 152 | + threadId: String(threadId), | |
| 140 | 153 | }); |
| 141 | 154 | return; |
| 142 | 155 | } |
| @@ -148,14 +161,209 @@ export function GitOverviewSheet() { · 5 unmodified lines | ||
| 148 | 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 | 348 | return ( |
| 155 | − <View collapsable={false} className="flex-1 bg-sheet"> | |
| 156 | − <View style={{ minHeight: 16, paddingTop: 8 }} /> | |
| 349 | + <View | |
| 350 | + collapsable={false} | |
| 351 | + className={isInspector ? "flex-1 border-l border-border bg-sheet" : "flex-1 bg-sheet"} | |
| 352 | + > | |
| 353 | + <View | |
| 354 | + style={{ | |
| 355 | + minHeight: isInspector ? (props.headerInset ?? 0) : 16, | |
| 356 | + paddingTop: isInspector ? (props.headerInset ?? 0) : 8, | |
| 357 | + }} | |
| 358 | + /> | |
| 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 | 367 | <Pressable |
| 160 | 368 | className="absolute right-3 top-4 h-9 w-9 items-center justify-center rounded-full bg-subtle" |
| 161 | 369 | style={{ zIndex: 1, opacity: busy ? 0.45 : 1 }} |
| @@ -174,76 +382,17 @@ export function GitOverviewSheet() { · 12 unmodified lines | ||
| 174 | 382 | className="text-xs font-t3-bold uppercase text-foreground-muted" |
| 175 | 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 | 389 | </Text> |
| 179 | − <Text className="text-3xl font-t3-bold">{currentBranchLabel}</Text> | |
| 180 | − <Text className="text-foreground-secondary text-sm font-medium leading-[19px]"> | |
| 181 | − {statusSummary(gitStatus.data)} | |
| 390 | + <Text className="text-foreground-secondary text-sm font-medium leading-normal"> | |
| 391 | + {currentStatusSummary} | |
| 182 | 392 | </Text> |
| 183 | 393 | </View> |
| 184 | 394 | |
| 185 | − <ScrollView | |
| 186 | − showsVerticalScrollIndicator={false} | |
| 187 | − style={{ flex: 1 }} | |
| 188 | − contentInset={{ bottom: Math.max(insets.bottom, 18) + 18 }} | |
| 189 | − contentContainerStyle={{ | |
| 190 | − paddingHorizontal: 20, | |
| 191 | − paddingTop: 8, | |
| 192 | − gap: 14, | |
| 193 | − }} | |
| 194 | − > | |
| 195 | − <View className="overflow-hidden rounded-[22px] border border-border bg-card px-4 py-1"> | |
| 196 | − {sheetMenuItems.map(({ item, disabledReason }, index) => ( | |
| 197 | − <View key={`${item.id}-${item.label}`}> | |
| 198 | − {index > 0 ? ( | |
| 199 | − <View className="ml-12 h-px" style={{ backgroundColor: borderColor }} /> | |
| 200 | − ) : null} | |
| 201 | − <SheetListRow | |
| 202 | − icon={menuItemIconName(item.icon)} | |
| 203 | − title={item.label} | |
| 204 | − subtitle={disabledReason} | |
| 205 | − disabled={item.disabled} | |
| 206 | − onPress={() => void onPressMenuItem(item)} | |
| 207 | − /> | |
| 208 | − </View> | |
| 209 | − ))} | |
| 210 | − {(gitStatus.data?.behindCount ?? 0) > 0 ? ( | |
| 211 | − <> | |
| 212 | − <View className="ml-12 h-px" style={{ backgroundColor: borderColor }} /> | |
| 213 | − <SheetListRow | |
| 214 | − icon="arrow.down.circle" | |
| 215 | − title="Pull latest" | |
| 216 | − subtitle="Sync this branch with upstream" | |
| 217 | − disabled={busy || !isRepo} | |
| 218 | − onPress={() => void gitActions.onPullSelectedThreadBranch()} | |
| 219 | − /> | |
| 220 | − </> | |
| 221 | − ) : null} | |
| 222 | − <View className="ml-12 h-px" style={{ backgroundColor: borderColor }} /> | |
| 223 | − <SheetListRow | |
| 224 | − icon="text.bubble" | |
| 225 | − title="Review changes" | |
| 226 | − subtitle="Inspect turn diffs, worktree changes, and base branch diff" | |
| 227 | − disabled={busy || !isRepo} | |
| 228 | − onPress={() => router.push(buildThreadReviewRoutePath({ environmentId, threadId }))} | |
| 229 | − /> | |
| 230 | − <View className="ml-12 h-px" style={{ backgroundColor: borderColor }} /> | |
| 231 | − <SheetListRow | |
| 232 | − icon="point.topleft.down.curvedto.point.bottomright.up" | |
| 233 | − title="Branches & worktrees" | |
| 234 | − subtitle="Switch branch, create branch, or move to a worktree" | |
| 235 | − disabled={busy || !isRepo} | |
| 236 | − onPress={() => | |
| 237 | − router.push({ | |
| 238 | − pathname: "/threads/[environmentId]/[threadId]/git/branches", | |
| 239 | − params: { environmentId, threadId }, | |
| 240 | − }) | |
| 241 | − } | |
| 242 | − /> | |
| 243 | − </View> | |
| 244 | − | |
| 245 | − {currentWorktreePath ? <MetaCard label="Worktree" value={currentWorktreePath} /> : null} | |
| 246 | − </ScrollView> | |
| 395 | + {content} | |
| 247 | 396 | </View> |
| 248 | 397 | ); |
| 249 | 398 | } |
| @@ -104,7 +104,7 @@ export function SheetListRow(props: { · 103 unmodified lines | ||
| 104 | 104 | <View className="flex-1 gap-0.5"> |
| 105 | 105 | <Text className="text-foreground text-base font-t3-bold">{props.title}</Text> |
| 106 | 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 | 108 | ) : null} |
| 109 | 109 | </View> |
| 110 | 110 | <SymbolView name="chevron.right" size={13} tintColor={iconSubtleColor} type="monochrome" /> |
| @@ -1,4 +1,4 @@ | ||
| 1 | −import React, { useCallback, useEffect, useMemo, useState } from "react"; | |
| 1 | +import React, { useCallback, useEffect, useMemo, useRef, useState } from "react"; | |
| 2 | 2 | |
| 3 | 3 | import type { |
| 4 | 4 | EnvironmentId, |
| @@ -8,18 +8,29 @@ import type { · 3 unmodified lines | ||
| 8 | 8 | RuntimeMode, |
| 9 | 9 | ServerProviderSkill, |
| 10 | 10 | } from "@t3tools/contracts"; |
| 11 | −import { DEFAULT_PROVIDER_INTERACTION_MODE, DEFAULT_RUNTIME_MODE } from "@t3tools/contracts"; | |
| 11 | +import { | |
| 12 | + CommandId, | |
| 13 | + DEFAULT_PROVIDER_INTERACTION_MODE, | |
| 14 | + DEFAULT_RUNTIME_MODE, | |
| 15 | + MessageId, | |
| 16 | + ThreadId, | |
| 17 | +} from "@t3tools/contracts"; | |
| 12 | 18 | import * as Arr from "effect/Array"; |
| 13 | 19 | import { pipe } from "effect/Function"; |
| 14 | 20 | |
| 15 | 21 | import { useEnvironmentServerConfig, useProjects, useThreadShells } from "../../state/entities"; |
| 22 | +import type { TurnCommandMetadata } from "../../lib/commandMetadata"; | |
| 16 | 23 | import type { DraftComposerImageAttachment } from "../../lib/composerImages"; |
| 17 | 24 | import type { ModelOption, ProviderGroup } from "../../lib/modelOptions"; |
| 18 | 25 | import { buildModelOptions, groupByProvider } from "../../lib/modelOptions"; |
| 19 | 26 | import { groupProjectsByRepository } from "../../lib/repositoryGroups"; |
| 20 | 27 | import { scopedProjectKey } from "../../lib/scopedEntities"; |
| 28 | +import { appAtomRegistry } from "../../state/atom-registry"; | |
| 21 | 29 | import { |
| 22 | 30 | appendComposerDraftAttachments, |
| 31 | + clearComposerDraft, | |
| 32 | + getComposerDraftSnapshot, | |
| 33 | + isComposerDraftEmpty, | |
| 23 | 34 | removeComposerDraftAttachment, |
| 24 | 35 | replaceComposerDraftAttachments, |
| 25 | 36 | setComposerDraftText, |
| @@ -27,6 +38,17 @@ import { · 1 unmodified line | ||
| 27 | 38 | useComposerDraft, |
| 28 | 39 | } from "../../state/use-composer-drafts"; |
| 29 | 40 | import { useBranches } from "../../state/queries"; |
| 41 | +import { | |
| 42 | + flattenQueuedThreadMessages, | |
| 43 | + threadOutboxManager, | |
| 44 | + updateThreadOutboxMessage, | |
| 45 | + type QueuedThreadMessage, | |
| 46 | +} from "../../state/thread-outbox"; | |
| 47 | +import { | |
| 48 | + holdEditingQueuedMessage, | |
| 49 | + releaseEditingQueuedMessage, | |
| 50 | + useThreadOutboxMessages, | |
| 51 | +} from "../../state/use-thread-outbox"; | |
| 30 | 52 | import { |
| 31 | 53 | setPendingConnectionError, |
| 32 | 54 | useSavedRemoteConnections, |
| @@ -36,6 +58,23 @@ import { type VcsRef } from "@t3tools/client-runtime/state/vcs"; · 3 unmodified lines | ||
| 36 | 58 | |
| 37 | 59 | type WorkspaceMode = "local" | "worktree"; |
| 38 | 60 | |
| 61 | +function pendingTaskDraftKey(messageId: string): string { | |
| 62 | + return `pending-task:${messageId}`; | |
| 63 | +} | |
| 64 | + | |
| 65 | +// The message id owned by the currently active editing session, tracked | |
| 66 | +// across provider instances. An in-flight flush from a dismissed session | |
| 67 | +// consults it so it never drops the draft or releases the drain lock out from | |
| 68 | +// under a newer session editing the same task. | |
| 69 | +let activeEditingMessageId: string | null = null; | |
| 70 | + | |
| 71 | +function findQueuedPendingTask(messageId: string): QueuedThreadMessage | null { | |
| 72 | + const message = flattenQueuedThreadMessages( | |
| 73 | + appAtomRegistry.get(threadOutboxManager.queuedMessagesByThreadKeyAtom), | |
| 74 | + ).find((candidate) => candidate.messageId === messageId); | |
| 75 | + return message?.creation !== undefined ? message : null; | |
| 76 | +} | |
| 77 | + | |
| 39 | 78 | function normalizeSelectedWorktreePath(project: EnvironmentProject, branch: VcsRef): string | null { |
| 40 | 79 | if (!branch.worktreePath) { |
| 41 | 80 | return null; |
| @@ -74,6 +113,9 @@ type NewTaskFlowContextValue = { · 32 unmodified lines | ||
| 74 | 113 | readonly workspaceMode: WorkspaceMode; |
| 75 | 114 | readonly selectedBranchName: string | null; |
| 76 | 115 | readonly selectedWorktreePath: string | null; |
| 116 | + readonly startFromOrigin: boolean; | |
| 117 | + readonly draftKey: string | null; | |
| 118 | + readonly editingPendingTask: QueuedThreadMessage | null; | |
| 77 | 119 | readonly prompt: string; |
| 78 | 120 | readonly attachments: ReadonlyArray<DraftComposerImageAttachment>; |
| 79 | 121 | readonly submitting: boolean; |
| @@ -100,6 +142,11 @@ type NewTaskFlowContextValue = { · 20 unmodified lines | ||
| 100 | 142 | readonly setSelectedModelKey: (key: string | null) => void; |
| 101 | 143 | readonly setWorkspaceMode: (mode: WorkspaceMode) => void; |
| 102 | 144 | readonly selectBranch: (branch: VcsRef) => void; |
| 145 | + readonly setStartFromOrigin: (value: boolean) => void; | |
| 146 | + readonly beginEditingPendingTask: (messageId: string) => boolean; | |
| 147 | + readonly finishEditingPendingTask: () => void; | |
| 148 | + readonly cancelEditingPendingTask: () => void; | |
| 149 | + readonly buildPendingTaskMessage: (metadata: TurnCommandMetadata) => QueuedThreadMessage | null; | |
| 103 | 150 | readonly setPrompt: (value: string) => void; |
| 104 | 151 | readonly replaceAttachments: (attachments: ReadonlyArray<DraftComposerImageAttachment>) => void; |
| 105 | 152 | readonly appendAttachments: (attachments: ReadonlyArray<DraftComposerImageAttachment>) => void; |
| @@ -162,6 +209,10 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) { · 56 unmodified lines | ||
| 162 | 209 | const [submitting, setSubmitting] = useState(false); |
| 163 | 210 | const [branchQuery, setBranchQuery] = useState(""); |
| 164 | 211 | const [expandedProvider, setExpandedProvider] = useState<string | null>(null); |
| 212 | + const [editingPendingTask, setEditingPendingTask] = useState<QueuedThreadMessage | null>(null); | |
| 213 | + // Mirrors `editingPendingTask` synchronously so the unmount flush cannot act | |
| 214 | + // on a task whose editing session already ended this render. | |
| 215 | + const editingPendingTaskRef = useRef<QueuedThreadMessage | null>(null); | |
| 165 | 216 | |
| 166 | 217 | const reset = useCallback(() => { |
| 167 | 218 | setSelectedEnvironmentId(null); |
| @@ -169,6 +220,15 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) { · 1 unmodified line | ||
| 169 | 220 | setSubmitting(false); |
| 170 | 221 | setBranchQuery(""); |
| 171 | 222 | setExpandedProvider(null); |
| 223 | + const editing = editingPendingTaskRef.current; | |
| 224 | + editingPendingTaskRef.current = null; | |
| 225 | + setEditingPendingTask(null); | |
| 226 | + if (editing) { | |
| 227 | + if (activeEditingMessageId === editing.messageId) { | |
| 228 | + activeEditingMessageId = null; | |
| 229 | + } | |
| 230 | + releaseEditingQueuedMessage(editing.messageId); | |
| 231 | + } | |
| 172 | 232 | }, []); |
| 173 | 233 | |
| 174 | 234 | const projectsForEnvironment = useMemo( |
| @@ -180,12 +240,42 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) { · 5 unmodified lines | ||
| 180 | 240 | [projects, selectedEnvironmentId], |
| 181 | 241 | ); |
| 182 | 242 | |
| 243 | + // Stand-in for the edited task's project while its shell is not loaded | |
| 244 | + // (environment offline / still synchronizing), built from the metadata | |
| 245 | + // snapshotted at enqueue time. | |
| 246 | + const editingPendingProject = useMemo<EnvironmentProject | null>(() => { | |
| 247 | + const creation = editingPendingTask?.creation; | |
| 248 | + if (!editingPendingTask || !creation) { | |
| 249 | + return null; | |
| 250 | + } | |
| 251 | + return { | |
| 252 | + environmentId: editingPendingTask.environmentId, | |
| 253 | + id: creation.projectId, | |
| 254 | + title: creation.projectTitle ?? "Unknown project", | |
| 255 | + // Deliberately empty when the snapshot has no cwd — downstream consumers | |
| 256 | + // (branch queries, worktree bootstrap) must skip it, not receive a | |
| 257 | + // fabricated path. | |
| 258 | + workspaceRoot: creation.projectCwd ?? "", | |
| 259 | + repositoryIdentity: null, | |
| 260 | + defaultModelSelection: editingPendingTask.modelSelection ?? null, | |
| 261 | + scripts: [], | |
| 262 | + createdAt: editingPendingTask.createdAt, | |
| 263 | + updatedAt: editingPendingTask.createdAt, | |
| 264 | + }; | |
| 265 | + }, [editingPendingTask]); | |
| 266 | + | |
| 183 | 267 | const selectedProject = |
| 184 | 268 | projectsForEnvironment.find( |
| 185 | 269 | (project) => scopedProjectKey(project.environmentId, project.id) === selectedProjectKey, |
| 186 | 270 | ) ?? |
| 187 | − projectsForEnvironment[0] ?? | |
| 188 | − null; | |
| 271 | + // While editing a queued task whose project shell is absent, keep the task | |
| 272 | + // pinned to its own project — falling through to an arbitrary first | |
| 273 | + // project would silently retarget it (and its reused turn identifiers). | |
| 274 | + (editingPendingProject !== null && | |
| 275 | + selectedProjectKey === | |
| 276 | + scopedProjectKey(editingPendingProject.environmentId, editingPendingProject.id) | |
| 277 | + ? editingPendingProject | |
| 278 | + : (projectsForEnvironment[0] ?? null)); | |
| 189 | 279 | |
| 190 | 280 | // Only offer machines that actually host the currently selected repository, so |
| 191 | 281 | // switching computers moves the same repo across machines instead of jumping to |
| @@ -223,15 +313,20 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) { · 31 unmodified lines | ||
| 223 | 313 | const selectedEnvironmentServerConfig = useEnvironmentServerConfig( |
| 224 | 314 | selectedProject?.environmentId ?? null, |
| 225 | 315 | ); |
| 226 | − const selectedProjectDraftKey = selectedProject | |
| 227 | − ? `new-task:${scopedProjectKey(selectedProject.environmentId, selectedProject.id)}` | |
| 228 | − : null; | |
| 316 | + // While a queued pending task is being edited its draft lives under a key | |
| 317 | + // scoped to the queued message, so per-project new-task drafts stay intact. | |
| 318 | + const selectedProjectDraftKey = editingPendingTask | |
| 319 | + ? pendingTaskDraftKey(editingPendingTask.messageId) | |
| 320 | + : selectedProject | |
| 321 | + ? `new-task:${scopedProjectKey(selectedProject.environmentId, selectedProject.id)}` | |
| 322 | + : null; | |
| 229 | 323 | const selectedProjectDraft = useComposerDraft(selectedProjectDraftKey); |
| 230 | 324 | const prompt = selectedProjectDraft.text; |
| 231 | 325 | const attachments = selectedProjectDraft.attachments; |
| 232 | 326 | const workspaceMode = selectedProjectDraft.workspaceSelection?.mode ?? "local"; |
| 233 | 327 | const selectedBranchName = selectedProjectDraft.workspaceSelection?.branch ?? null; |
| 234 | 328 | const selectedWorktreePath = selectedProjectDraft.workspaceSelection?.worktreePath ?? null; |
| 329 | + const startFromOrigin = selectedProjectDraft.workspaceSelection?.startFromOrigin ?? false; | |
| 235 | 330 | const runtimeMode = selectedProjectDraft.runtimeMode ?? DEFAULT_RUNTIME_MODE; |
| 236 | 331 | const interactionMode = selectedProjectDraft.interactionMode ?? DEFAULT_PROVIDER_INTERACTION_MODE; |
| 237 | 332 | |
| @@ -350,7 +445,8 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) { · 112 unmodified lines | ||
| 350 | 445 | const branchTarget = useMemo( |
| 351 | 446 | () => ({ |
| 352 | 447 | environmentId: selectedProject?.environmentId ?? null, |
| 353 | − cwd: selectedProject?.workspaceRoot ?? null, | |
| 448 | + // `|| null` also skips the stand-in project's empty workspaceRoot. | |
| 449 | + cwd: selectedProject?.workspaceRoot || null, | |
| 354 | 450 | query: null, |
| 355 | 451 | }), |
| 356 | 452 | [selectedProject?.environmentId, selectedProject?.workspaceRoot], |
| @@ -411,10 +507,11 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) { · 54 unmodified lines | ||
| 411 | 507 | mode, |
| 412 | 508 | branch: selectedBranchName, |
| 413 | 509 | worktreePath: selectedWorktreePath, |
| 510 | + startFromOrigin, | |
| 414 | 511 | }, |
| 415 | 512 | }); |
| 416 | 513 | }, |
| 417 | − [selectedBranchName, selectedProjectDraftKey, selectedWorktreePath], | |
| 514 | + [selectedBranchName, selectedProjectDraftKey, selectedWorktreePath, startFromOrigin], | |
| 418 | 515 | ); |
| 419 | 516 | |
| 420 | 517 | const selectBranch = useCallback( |
| @@ -427,10 +524,28 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) { · 6 unmodified lines | ||
| 427 | 524 | mode: workspaceMode, |
| 428 | 525 | branch: branch.name, |
| 429 | 526 | worktreePath: normalizeSelectedWorktreePath(selectedProject, branch), |
| 527 | + startFromOrigin, | |
| 430 | 528 | }, |
| 431 | 529 | }); |
| 432 | 530 | }, |
| 433 | − [selectedProject, selectedProjectDraftKey, workspaceMode], | |
| 531 | + [selectedProject, selectedProjectDraftKey, startFromOrigin, workspaceMode], | |
| 532 | + ); | |
| 533 | + | |
| 534 | + const setStartFromOrigin = useCallback( | |
| 535 | + (value: boolean) => { | |
| 536 | + if (!selectedProjectDraftKey) { | |
| 537 | + return; | |
| 538 | + } | |
| 539 | + updateComposerDraftSettings(selectedProjectDraftKey, { | |
| 540 | + workspaceSelection: { | |
| 541 | + mode: workspaceMode, | |
| 542 | + branch: selectedBranchName, | |
| 543 | + worktreePath: selectedWorktreePath, | |
| 544 | + startFromOrigin: value, | |
| 545 | + }, | |
| 546 | + }); | |
| 547 | + }, | |
| 548 | + [selectedBranchName, selectedProjectDraftKey, selectedWorktreePath, workspaceMode], | |
| 434 | 549 | ); |
| 435 | 550 | |
| 436 | 551 | const refreshBranches = branchState.refresh; |
| @@ -472,6 +587,183 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) { · 35 unmodified lines | ||
| 472 | 587 | [selectedProjectDraftKey], |
| 473 | 588 | ); |
| 474 | 589 | |
| 590 | + const beginEditingPendingTask = useCallback((messageId: string): boolean => { | |
| 591 | + const message = findQueuedPendingTask(messageId); | |
| 592 | + if (!message?.creation) { | |
| 593 | + return false; | |
| 594 | + } | |
| 595 | + const draftKey = pendingTaskDraftKey(message.messageId); | |
| 596 | + // Only hydrate a fresh editing draft; reopening mid-edit keeps newer edits. | |
| 597 | + if (isComposerDraftEmpty(getComposerDraftSnapshot(draftKey))) { | |
| 598 | + setComposerDraftText(draftKey, message.text); | |
| 599 | + replaceComposerDraftAttachments(draftKey, message.attachments); | |
| 600 | + updateComposerDraftSettings(draftKey, { | |
| 601 | + modelSelection: message.modelSelection, | |
| 602 | + runtimeMode: message.runtimeMode, | |
| 603 | + interactionMode: message.interactionMode, | |
| 604 | + workspaceSelection: { | |
| 605 | + mode: message.creation.workspaceMode, | |
| 606 | + branch: message.creation.branch, | |
| 607 | + worktreePath: message.creation.worktreePath, | |
| 608 | + startFromOrigin: message.creation.startFromOrigin ?? false, | |
| 609 | + }, | |
| 610 | + }); | |
| 611 | + } | |
| 612 | + setSelectedEnvironmentId(message.environmentId); | |
| 613 | + setSelectedProjectKey(scopedProjectKey(message.environmentId, message.creation.projectId)); | |
| 614 | + activeEditingMessageId = message.messageId; | |
| 615 | + editingPendingTaskRef.current = message; | |
| 616 | + setEditingPendingTask(message); | |
| 617 | + // Hold the outbox drain off this task while it is open in the editor. | |
| 618 | + holdEditingQueuedMessage(message.messageId); | |
| 619 | + return true; | |
| 620 | + }, []); | |
| 621 | + | |
| 622 | + const buildPendingTaskMessage = useCallback( | |
| 623 | + (metadata: TurnCommandMetadata): QueuedThreadMessage | null => { | |
| 624 | + if (!selectedProject || !selectedProjectDraftKey) { | |
| 625 | + return null; | |
| 626 | + } | |
| 627 | + const draft = getComposerDraftSnapshot(selectedProjectDraftKey); | |
| 628 | + const text = draft.text.trim(); | |
| 629 | + const draftModelSelection = draft.modelSelection ?? selectedModel; | |
| 630 | + if (text.length === 0 || !draftModelSelection) { | |
| 631 | + return null; | |
| 632 | + } | |
| 633 | + const workspaceSelection = draft.workspaceSelection; | |
| 634 | + const mode = workspaceSelection?.mode ?? "local"; | |
| 635 | + // When the selection is the stand-in built from the queued snapshot, | |
| 636 | + // persist the original (possibly absent) snapshot values — the | |
| 637 | + // stand-in's placeholder title/workspaceRoot must never be written back | |
| 638 | + // as if they were real project metadata. | |
| 639 | + const usingPendingSnapshot = selectedProject === editingPendingProject; | |
| 640 | + const projectTitle = usingPendingSnapshot | |
| 641 | + ? editingPendingTask?.creation?.projectTitle | |
| 642 | + : selectedProject.title; | |
| 643 | + const projectCwd = usingPendingSnapshot | |
| 644 | + ? editingPendingTask?.creation?.projectCwd | |
| 645 | + : selectedProject.workspaceRoot; | |
| 646 | + return { | |
| 647 | + environmentId: selectedProject.environmentId, | |
| 648 | + threadId: ThreadId.make(metadata.threadId), | |
| 649 | + messageId: MessageId.make(metadata.messageId), | |
| 650 | + commandId: CommandId.make(metadata.commandId), | |
| 651 | + text, | |
| 652 | + attachments: draft.attachments, | |
| 653 | + modelSelection: draftModelSelection, | |
| 654 | + runtimeMode: draft.runtimeMode ?? DEFAULT_RUNTIME_MODE, | |
| 655 | + interactionMode: draft.interactionMode ?? DEFAULT_PROVIDER_INTERACTION_MODE, | |
| 656 | + creation: { | |
| 657 | + projectId: selectedProject.id, | |
| 658 | + ...(projectTitle !== undefined ? { projectTitle } : {}), | |
| 659 | + ...(projectCwd !== undefined ? { projectCwd } : {}), | |
| 660 | + workspaceMode: mode, | |
| 661 | + branch: workspaceSelection?.branch ?? null, | |
| 662 | + worktreePath: mode === "worktree" ? null : (workspaceSelection?.worktreePath ?? null), | |
| 663 | + ...(workspaceSelection?.startFromOrigin ? { startFromOrigin: true } : {}), | |
| 664 | + }, | |
| 665 | + createdAt: metadata.createdAt, | |
| 666 | + }; | |
| 667 | + }, | |
| 668 | + [ | |
| 669 | + editingPendingProject, | |
| 670 | + editingPendingTask, | |
| 671 | + selectedModel, | |
| 672 | + selectedProject, | |
| 673 | + selectedProjectDraftKey, | |
| 674 | + ], | |
| 675 | + ); | |
| 676 | + | |
| 677 | + const finishEditingPendingTask = useCallback(() => { | |
| 678 | + const editing = editingPendingTaskRef.current; | |
| 679 | + editingPendingTaskRef.current = null; | |
| 680 | + if (editing) { | |
| 681 | + if (activeEditingMessageId === editing.messageId) { | |
| 682 | + activeEditingMessageId = null; | |
| 683 | + } | |
| 684 | + clearComposerDraft(pendingTaskDraftKey(editing.messageId)); | |
| 685 | + releaseEditingQueuedMessage(editing.messageId); | |
| 686 | + } | |
| 687 | + setEditingPendingTask(null); | |
| 688 | + }, []); | |
| 689 | + | |
| 690 | + // If the queued task disappears mid-edit (deleted from the list, or | |
| 691 | + // delivered), end the editing session immediately without saving — a later | |
| 692 | + // flush must not resurrect it, and the composer should fall back to the | |
| 693 | + // regular per-project draft. | |
| 694 | + const queuedMessagesByThreadKey = useThreadOutboxMessages(); | |
| 695 | + useEffect(() => { | |
| 696 | + const editing = editingPendingTaskRef.current; | |
| 697 | + if (!editing) { | |
| 698 | + return; | |
| 699 | + } | |
| 700 | + const stillQueued = flattenQueuedThreadMessages(queuedMessagesByThreadKey).some( | |
| 701 | + (candidate) => candidate.messageId === editing.messageId, | |
| 702 | + ); | |
| 703 | + if (!stillQueued) { | |
| 704 | + finishEditingPendingTask(); | |
| 705 | + } | |
| 706 | + }, [finishEditingPendingTask, queuedMessagesByThreadKey]); | |
| 707 | + | |
| 708 | + // Leaving the flow mid-edit (sheet dismissed or draft screen popped) saves | |
| 709 | + // the current edits back into the queued task so nothing typed here is lost. | |
| 710 | + const editingFlushRef = useRef<(() => void) | null>(null); | |
| 711 | + useEffect(() => { | |
| 712 | + editingFlushRef.current = () => { | |
| 713 | + const editing = editingPendingTaskRef.current; | |
| 714 | + if (!editing) { | |
| 715 | + return; | |
| 716 | + } | |
| 717 | + editingPendingTaskRef.current = null; | |
| 718 | + setEditingPendingTask(null); | |
| 719 | + if (activeEditingMessageId === editing.messageId) { | |
| 720 | + activeEditingMessageId = null; | |
| 721 | + } | |
| 722 | + | |
| 723 | + const message = buildPendingTaskMessage({ | |
| 724 | + threadId: editing.threadId, | |
| 725 | + commandId: editing.commandId, | |
| 726 | + messageId: editing.messageId, | |
| 727 | + createdAt: editing.createdAt, | |
| 728 | + }); | |
| 729 | + | |
| 730 | + if (!message) { | |
| 731 | + // The edits are currently unsendable (e.g. the prompt was cleared). | |
| 732 | + // Keep both the draft and the drain lock: the stale queued payload | |
| 733 | + // must not auto-send content the user just removed, and reopening the | |
| 734 | + // task resumes from the saved draft. | |
| 735 | + return; | |
| 736 | + } | |
| 737 | + | |
| 738 | + // update() rewrites the task only if it is still queued — a concurrent | |
| 739 | + // delete or delivery wins, so the flush cannot resurrect it. | |
| 740 | + void updateThreadOutboxMessage(message) | |
| 741 | + .then(() => { | |
| 742 | + // If this task was reopened (possibly in a fresh provider) while | |
| 743 | + // the save was in flight, that session owns the draft and the lock. | |
| 744 | + if (activeEditingMessageId === editing.messageId) { | |
| 745 | + return; | |
| 746 | + } | |
| 747 | + clearComposerDraft(pendingTaskDraftKey(editing.messageId)); | |
| 748 | + releaseEditingQueuedMessage(editing.messageId); | |
| 749 | + }) | |
| 750 | + .catch((error) => { | |
| 751 | + // Keep the drain lock and the draft: delivering the stale payload | |
| 752 | + // would silently drop the newer edits. Reopening the task retries. | |
| 753 | + console.warn("[new-task] failed to save edited pending task", error); | |
| 754 | + }); | |
| 755 | + }; | |
| 756 | + }, [buildPendingTaskMessage]); | |
| 757 | + const cancelEditingPendingTask = useCallback(() => { | |
| 758 | + editingFlushRef.current?.(); | |
| 759 | + }, []); | |
| 760 | + useEffect( | |
| 761 | + () => () => { | |
| 762 | + editingFlushRef.current?.(); | |
| 763 | + }, | |
| 764 | + [], | |
| 765 | + ); | |
| 766 | + | |
| 475 | 767 | const value = useMemo<NewTaskFlowContextValue>( |
| 476 | 768 | () => ({ |
| 477 | 769 | logicalProjects, |
| @@ -481,6 +773,9 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) { · 3 unmodified lines | ||
| 481 | 773 | workspaceMode, |
| 482 | 774 | selectedBranchName, |
| 483 | 775 | selectedWorktreePath, |
| 776 | + startFromOrigin, | |
| 777 | + draftKey: selectedProjectDraftKey, | |
| 778 | + editingPendingTask, | |
| 484 | 779 | prompt, |
| 485 | 780 | attachments, |
| 486 | 781 | submitting, |
| @@ -504,6 +799,11 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) { · 17 unmodified lines | ||
| 504 | 799 | setSelectedModelKey, |
| 505 | 800 | setWorkspaceMode, |
| 506 | 801 | selectBranch, |
| 802 | + setStartFromOrigin, | |
| 803 | + beginEditingPendingTask, | |
| 804 | + finishEditingPendingTask, | |
| 805 | + cancelEditingPendingTask, | |
| 806 | + buildPendingTaskMessage, | |
| 507 | 807 | setPrompt, |
| 508 | 808 | replaceAttachments, |
| 509 | 809 | appendAttachments, |
| @@ -520,11 +820,16 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) { · 10 unmodified lines | ||
| 520 | 820 | [ |
| 521 | 821 | attachments, |
| 522 | 822 | availableBranches, |
| 823 | + beginEditingPendingTask, | |
| 523 | 824 | branchQuery, |
| 524 | 825 | branchesLoading, |
| 826 | + buildPendingTaskMessage, | |
| 827 | + cancelEditingPendingTask, | |
| 828 | + editingPendingTask, | |
| 525 | 829 | environments, |
| 526 | 830 | expandedProvider, |
| 527 | 831 | filteredBranches, |
| 832 | + finishEditingPendingTask, | |
| 528 | 833 | interactionMode, |
| 529 | 834 | loadBranches, |
| 530 | 835 | logicalProjects, |
| @@ -539,6 +844,7 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) { · 8 unmodified lines | ||
| 539 | 844 | selectedModel, |
| 540 | 845 | selectedModelKey, |
| 541 | 846 | selectedModelOption, |
| 847 | + selectedProjectDraftKey, | |
| 542 | 848 | selectedProviderSkills, |
| 543 | 849 | setSelectedModelOptions, |
| 544 | 850 | selectedProject, |
| @@ -551,7 +857,9 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) { · 6 unmodified lines | ||
| 551 | 857 | setPrompt, |
| 552 | 858 | setRuntimeMode, |
| 553 | 859 | setSelectedModelKey, |
| 860 | + setStartFromOrigin, | |
| 554 | 861 | setWorkspaceMode, |
| 862 | + startFromOrigin, | |
| 555 | 863 | submitting, |
| 556 | 864 | workspaceMode, |
| 557 | 865 | appendAttachments, |
| @@ -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 | +}); | |
| @@ -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 | +}); | |
| @@ -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 | +} | |
| @@ -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 | +} | |
| @@ -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 | +} | |
| @@ -0,0 +1,632 @@ | ||
| 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 type { PendingNewTask } from "../../state/use-pending-new-tasks"; | |
| 18 | +import { useThreadPr } from "../../state/use-thread-pr"; | |
| 19 | +import type { HomeGroupDisplayAction } from "../home/homeListItems"; | |
| 20 | +import { ThreadSwipeable } from "../home/thread-swipe-actions"; | |
| 21 | +import { resolveThreadStatus } from "./threadPresentation"; | |
| 22 | + | |
| 23 | +/** | |
| 24 | + * Shared presentation for the thread lists: the compact (phone) Home list and | |
| 25 | + * the iPad sidebar render the SAME items — group headers with collapse, | |
| 26 | + * thread rows with status/PR/subtitle, and show-more rows — differing only in | |
| 27 | + * metrics and chrome via `variant`. | |
| 28 | + */ | |
| 29 | +export type ThreadListVariant = "compact" | "sidebar"; | |
| 30 | + | |
| 31 | +/** Left inset that aligns compact secondary rows with the title column. */ | |
| 32 | +export const THREAD_LIST_COMPACT_INSET = 20; | |
| 33 | +const SIDEBAR_ROW_RADIUS = 12; | |
| 34 | + | |
| 35 | +/* ─── Project group header ───────────────────────────────────────────── */ | |
| 36 | + | |
| 37 | +export const ThreadListGroupHeader = memo(function ThreadListGroupHeader(props: { | |
| 38 | + readonly variant: ThreadListVariant; | |
| 39 | + readonly project: EnvironmentProject; | |
| 40 | + readonly title: string; | |
| 41 | + readonly threadCount: number; | |
| 42 | + readonly collapsed: boolean; | |
| 43 | + readonly isFirst: boolean; | |
| 44 | + readonly groupKey: string; | |
| 45 | + readonly onGroupAction: (key: string, action: HomeGroupDisplayAction) => void; | |
| 46 | + readonly onNewThread?: (project: EnvironmentProject) => void; | |
| 47 | +}) { | |
| 48 | + const iconSubtleColor = useThemeColor("--color-icon-subtle"); | |
| 49 | + const iconMutedColor = useThemeColor("--color-icon-muted"); | |
| 50 | + const { groupKey, onGroupAction, onNewThread, project } = props; | |
| 51 | + const compact = props.variant === "compact"; | |
| 52 | + const handleToggle = useCallback( | |
| 53 | + () => onGroupAction(groupKey, "toggle-collapsed"), | |
| 54 | + [groupKey, onGroupAction], | |
| 55 | + ); | |
| 56 | + const handleNewThread = useCallback(() => onNewThread?.(project), [onNewThread, project]); | |
| 57 | + | |
| 58 | + return ( | |
| 59 | + <Pressable | |
| 60 | + accessibilityRole="button" | |
| 61 | + accessibilityState={{ expanded: !props.collapsed }} | |
| 62 | + accessibilityLabel={`${props.title}, ${props.threadCount} threads`} | |
| 63 | + accessibilityHint={props.collapsed ? "Expands the project" : "Collapses the project"} | |
| 64 | + className={compact ? "bg-screen" : undefined} | |
| 65 | + onPress={handleToggle} | |
| 66 | + > | |
| 67 | + <View | |
| 68 | + className={ | |
| 69 | + compact | |
| 70 | + ? `flex-row items-center gap-2.5 px-5 pb-3 ${props.isFirst ? "pt-2" : "pt-6"}` | |
| 71 | + : `flex-row items-center gap-2 px-3 pb-2 ${props.isFirst ? "pt-1" : "pt-5"}` | |
| 72 | + } | |
| 73 | + style={{ minHeight: compact ? 44 : 36 }} | |
| 74 | + > | |
| 75 | + <ProjectFavicon | |
| 76 | + environmentId={props.project.environmentId} | |
| 77 | + size={compact ? 22 : 18} | |
| 78 | + projectTitle={props.project.title} | |
| 79 | + workspaceRoot={props.project.workspaceRoot} | |
| 80 | + /> | |
| 81 | + <Text | |
| 82 | + className={ | |
| 83 | + compact | |
| 84 | + ? "flex-shrink text-base font-t3-bold text-foreground-muted" | |
| 85 | + : "flex-shrink text-sm font-t3-bold text-foreground-muted" | |
| 86 | + } | |
| 87 | + style={{ letterSpacing: 0.2 }} | |
| 88 | + numberOfLines={1} | |
| 89 | + > | |
| 90 | + {props.title} | |
| 91 | + </Text> | |
| 92 | + <Text | |
| 93 | + className={ | |
| 94 | + compact | |
| 95 | + ? "flex-1 text-sm font-t3-medium text-foreground-tertiary" | |
| 96 | + : "flex-1 text-xs font-t3-medium text-foreground-tertiary" | |
| 97 | + } | |
| 98 | + > | |
| 99 | + {props.threadCount} | |
| 100 | + </Text> | |
| 101 | + {onNewThread ? ( | |
| 102 | + <Pressable | |
| 103 | + accessibilityLabel={`Create new thread in ${props.title}`} | |
| 104 | + accessibilityRole="button" | |
| 105 | + hitSlop={8} | |
| 106 | + onPress={handleNewThread} | |
| 107 | + style={({ pressed }) => ({ opacity: pressed ? 0.5 : 1, marginRight: compact ? 10 : 8 })} | |
| 108 | + > | |
| 109 | + <SymbolView | |
| 110 | + name="square.and.pencil" | |
| 111 | + size={compact ? 16 : 14} | |
| 112 | + tintColor={iconMutedColor} | |
| 113 | + type="monochrome" | |
| 114 | + weight="medium" | |
| 115 | + /> | |
| 116 | + </Pressable> | |
| 117 | + ) : null} | |
| 118 | + <SymbolView | |
| 119 | + name={props.collapsed ? "chevron.right" : "chevron.down"} | |
| 120 | + size={compact ? 13 : 11} | |
| 121 | + tintColor={iconSubtleColor} | |
| 122 | + type="monochrome" | |
| 123 | + weight="semibold" | |
| 124 | + /> | |
| 125 | + </View> | |
| 126 | + </Pressable> | |
| 127 | + ); | |
| 128 | +}); | |
| 129 | + | |
| 130 | +/* ─── Show more / show less row ──────────────────────────────────────── */ | |
| 131 | + | |
| 132 | +export const ThreadListShowMoreRow = memo(function ThreadListShowMoreRow(props: { | |
| 133 | + readonly variant: ThreadListVariant; | |
| 134 | + readonly hiddenCount: number; | |
| 135 | + readonly canShowLess: boolean; | |
| 136 | + readonly groupKey: string; | |
| 137 | + readonly onGroupAction: (key: string, action: HomeGroupDisplayAction) => void; | |
| 138 | +}) { | |
| 139 | + const iconSubtleColor = useThemeColor("--color-icon-subtle"); | |
| 140 | + const showsMore = props.hiddenCount > 0; | |
| 141 | + const compact = props.variant === "compact"; | |
| 142 | + const { groupKey, onGroupAction } = props; | |
| 143 | + const handleShowMore = useCallback( | |
| 144 | + () => onGroupAction(groupKey, "show-more"), | |
| 145 | + [groupKey, onGroupAction], | |
| 146 | + ); | |
| 147 | + const handleShowLess = useCallback( | |
| 148 | + () => onGroupAction(groupKey, "show-less"), | |
| 149 | + [groupKey, onGroupAction], | |
| 150 | + ); | |
| 151 | + | |
| 152 | + const button = (label: string, icon: "chevron.down" | "chevron.up", onPress: () => void) => ( | |
| 153 | + <Pressable | |
| 154 | + accessibilityRole="button" | |
| 155 | + accessibilityLabel={label === "Show more" ? "Show more threads" : "Show fewer threads"} | |
| 156 | + className="rounded-full bg-subtle" | |
| 157 | + hitSlop={6} | |
| 158 | + onPress={onPress} | |
| 159 | + style={({ pressed }) => ({ | |
| 160 | + opacity: pressed ? 0.6 : 1, | |
| 161 | + paddingHorizontal: compact ? 14 : 12, | |
| 162 | + paddingVertical: compact ? 7 : 6, | |
| 163 | + borderCurve: "continuous", | |
| 164 | + })} | |
| 165 | + > | |
| 166 | + <View className="flex-row items-center gap-1.5"> | |
| 167 | + <SymbolView | |
| 168 | + name={icon} | |
| 169 | + size={10} | |
| 170 | + tintColor={iconSubtleColor} | |
| 171 | + type="monochrome" | |
| 172 | + weight="semibold" | |
| 173 | + /> | |
| 174 | + <Text | |
| 175 | + className={ | |
| 176 | + compact | |
| 177 | + ? "text-sm font-t3-medium text-foreground-muted" | |
| 178 | + : "text-xs font-t3-medium text-foreground-muted" | |
| 179 | + } | |
| 180 | + > | |
| 181 | + {label} | |
| 182 | + </Text> | |
| 183 | + </View> | |
| 184 | + </Pressable> | |
| 185 | + ); | |
| 186 | + | |
| 187 | + return ( | |
| 188 | + <View | |
| 189 | + className={ | |
| 190 | + compact ? "flex-row items-center gap-2.5 bg-screen" : "flex-row items-center gap-2" | |
| 191 | + } | |
| 192 | + style={{ | |
| 193 | + paddingLeft: compact ? THREAD_LIST_COMPACT_INSET : 12, | |
| 194 | + paddingRight: compact ? 18 : 12, | |
| 195 | + paddingVertical: compact ? 12 : 8, | |
| 196 | + }} | |
| 197 | + > | |
| 198 | + {showsMore ? button("Show more", "chevron.down", handleShowMore) : null} | |
| 199 | + {props.canShowLess ? button("Show less", "chevron.up", handleShowLess) : null} | |
| 200 | + </View> | |
| 201 | + ); | |
| 202 | +}); | |
| 203 | + | |
| 204 | +/* ─── Pending task row ───────────────────────────────────────────────── */ | |
| 205 | + | |
| 206 | +const PENDING_TASK_MENU_ACTIONS: MenuAction[] = [ | |
| 207 | + { id: "delete", title: "Delete", image: "trash", attributes: { destructive: true } }, | |
| 208 | +]; | |
| 209 | + | |
| 210 | +/** | |
| 211 | + * A queued new task waiting in the outbox for its environment to reconnect. | |
| 212 | + * Tapping reopens the new-task composer with everything prefilled; the row | |
| 213 | + * disappears once the task is delivered and the real thread arrives. | |
| 214 | + */ | |
| 215 | +export const PendingTaskListRow = memo(function PendingTaskListRow(props: { | |
| 216 | + readonly variant: ThreadListVariant; | |
| 217 | + readonly pendingTask: PendingNewTask; | |
| 218 | + readonly environmentLabel: string | null; | |
| 219 | + readonly isLast: boolean; | |
| 220 | + readonly onSelectPendingTask: (pendingTask: PendingNewTask) => void; | |
| 221 | + readonly onDeletePendingTask: (pendingTask: PendingNewTask) => void; | |
| 222 | +}) { | |
| 223 | + const compact = props.variant === "compact"; | |
| 224 | + const separatorColor = useThemeColor("--color-separator"); | |
| 225 | + const iconSubtleColor = useThemeColor("--color-icon-subtle"); | |
| 226 | + const foregroundColor = useThemeColor("--color-foreground"); | |
406 more lines aren’t shown here.
This diff is large: its 112 lines aren’t shown here.
This diff is large: its 65 lines aren’t shown here.
This diff is large: its 21 lines aren’t shown here.
This diff is large: its 15 lines aren’t shown here.
This diff is large: its 148 lines aren’t shown here.
This diff is large: its 117 lines aren’t shown here.
This diff is large: its 69 lines aren’t shown here.
This diff is large: its 36 lines aren’t shown here.
This diff is large: its 143 lines aren’t shown here.
This diff is large: its 246 lines aren’t shown here.
This diff is large: its 344 lines aren’t shown here.
This diff is large: its 252 lines aren’t shown here.
This diff is large: its 90 lines aren’t shown here.
This diff is large: its 46 lines aren’t shown here.
This diff is large: its 135 lines aren’t shown here.
This diff is large: its 50 lines aren’t shown here.
This diff is large: its 26 lines aren’t shown here.
This diff is large: its 30 lines aren’t shown here.
This diff is large: its 10 lines aren’t shown here.
This diff is large: its 360 lines aren’t shown here.
This diff is large: its 57 lines aren’t shown here.
This diff is large: its 27 lines aren’t shown here.
This diff is large: its 7 lines aren’t shown here.
This diff is large: its 32 lines aren’t shown here.
This diff is large: its 14 lines aren’t shown here.
This diff is large: its 30 lines aren’t shown here.
This diff is large: its 38 lines aren’t shown here.
This diff is large: its 52 lines aren’t shown here.
This diff is large: its 117 lines aren’t shown here.
This diff is large: its 166 lines aren’t shown here.
This diff is large: its 12 lines aren’t shown here.
This diff is large: its 27 lines aren’t shown here.
This diff is large: its 36 lines aren’t shown here.
This diff is large: its 334 lines aren’t shown here.
This diff is large: its 47 lines aren’t shown here.
This diff is large: its 68 lines aren’t shown here.
This diff is large: its 150 lines aren’t shown here.
This diff is large: its 8 lines aren’t shown here.
This diff is large: its 51 lines aren’t shown here.
This diff is large: its 8 lines aren’t shown here.
This diff is large: its 29 lines aren’t shown here.
This diff is large: its 9 lines aren’t shown here.
This diff is large: its 36 lines aren’t shown here.
This diff is large: its 23 lines aren’t shown here.
This diff is large: its 286 lines aren’t shown here.
This diff is large: its 387 lines aren’t shown here.
This diff is large: its 11 lines aren’t shown here.
This diff is large: its 9 lines aren’t shown here.
This diff is large: its 9 lines aren’t shown here.
This diff is large: its 17 lines aren’t shown here.
This diff is large: its 31 lines aren’t shown here.
This diff is large: its 8 lines aren’t shown here.
This diff is large: its 21 lines aren’t shown here.
This diff is large: its 67 lines aren’t shown here.
This diff is large: its 110 lines aren’t shown here.
File renamed without changes.
This diff is large: its 47 lines aren’t shown here.
This diff is large: its 101 lines aren’t shown here.
This diff is large: its 22 lines aren’t shown here.
This diff is large: its 125 lines aren’t shown here.
This diff is large: its 1543 lines aren’t shown here.
This diff is large: its 3465 lines aren’t shown here.
This diff is large: its 96 lines aren’t shown here.
This diff is large: its 11 lines aren’t shown here.