History

Merge origin/main into integration/test-all, re-porting mobile changes onto the split-view restructure

Theo Browne committed 4 months ago

Commit aa38fd22 parents efee0c9 4ac094f238 files · +18573 −5199Browse files

Unified · Split

238 files
  1. .vite-hooks/pre-commit +1 −0
  2. apps/desktop/package.json +1 −1
  3. apps/desktop/scripts/electron-launcher.mjs +13 −6
  4. apps/desktop/scripts/electron-launcher.test.mjs +21 −1
  5. apps/desktop/src/backend/DesktopBackendConfiguration.ts +1 −0
  6. apps/mobile/.swiftlint.yml +1 −0
  7. apps/mobile/app.config.ts +14 −1
  8. apps/mobile/global.css +18 −18
  9. apps/mobile/index.ts +10 −1
  10. apps/mobile/modules/t3-composer-editor/ios/T3ComposerEditorModule.swift +1 −0
  11. apps/mobile/modules/t3-composer-editor/ios/T3ComposerEditorView.swift +22 −0
  12. apps/mobile/modules/t3-markdown-text/src/NativeMarkdownBlock.ios.tsx +14 −5
  13. apps/mobile/modules/t3-markdown-text/src/NativeMarkdownSelectableText.ios.tsx +21 −4
  14. apps/mobile/modules/t3-markdown-text/src/SelectableMarkdownText.types.ts +1 −0
  15. apps/mobile/modules/t3-native-controls/expo-module.config.json +6 −0
  16. apps/mobile/modules/t3-native-controls/ios/T3HeaderButtonView.swift +62 −0
  17. apps/mobile/modules/t3-native-controls/ios/T3KeyboardCommandsModule.swift +131 −0
  18. apps/mobile/modules/t3-native-controls/ios/T3NativeControls.podspec +19 −0
  19. apps/mobile/modules/t3-native-controls/ios/T3NativeControlsModule.swift +18 −0
  20. apps/mobile/modules/t3-review-diff/ios/T3ReviewDiffModule.swift +40 −13
  21. apps/mobile/modules/t3-review-diff/ios/T3ReviewDiffView.swift +270 −60
  22. apps/mobile/modules/t3-terminal/android/src/main/java/expo/modules/t3terminal/T3TerminalModule.kt +10 −0
  23. apps/mobile/modules/t3-terminal/android/src/main/java/expo/modules/t3terminal/T3TerminalView.kt +19 −2
  24. apps/mobile/modules/t3-terminal/ios/T3TerminalModule.swift +10 −0
  25. apps/mobile/modules/t3-terminal/ios/T3TerminalView.swift +128 −0
  26. apps/mobile/package.json +31 −20
  27. apps/mobile/plugins/withIosCocoaPodsUuidCache.cjs +52 −0
  28. apps/mobile/plugins/withIosSceneLifecycle.cjs +99 −0
  29. apps/mobile/src/App.tsx +72 −0
  30. apps/mobile/src/Stack.tsx +463 −0
  31. apps/mobile/src/app/+not-found.tsx +0 −43
  32. apps/mobile/src/app/_layout.tsx +0 −149
  33. apps/mobile/src/app/connections/_layout.tsx +0 −29
  34. apps/mobile/src/app/index.tsx +0 −130
  35. apps/mobile/src/app/new/_layout.tsx +0 −50
  36. apps/mobile/src/app/new/add-project/destination.tsx +0 −5
  37. apps/mobile/src/app/new/add-project/index.tsx +0 −5
  38. apps/mobile/src/app/new/add-project/local.tsx +0 −5
  39. apps/mobile/src/app/new/add-project/repository.tsx +0 −23
  40. apps/mobile/src/app/new/draft.tsx +0 −29
  41. apps/mobile/src/app/settings/_layout.tsx +0 −62
  42. apps/mobile/src/app/settings/archive.tsx +0 −3
  43. apps/mobile/src/app/settings/auth.tsx +0 −33
  44. apps/mobile/src/app/settings/environment-new.tsx +0 −1
  45. apps/mobile/src/app/threads/[environmentId]/[threadId]/_layout.tsx +0 −109
  46. apps/mobile/src/app/threads/[environmentId]/[threadId]/files/[...path].tsx +0 −5
  47. apps/mobile/src/app/threads/[environmentId]/[threadId]/files/index.tsx +0 −5
  48. apps/mobile/src/app/threads/[environmentId]/[threadId]/git-confirm.tsx +0 −5
  49. apps/mobile/src/app/threads/[environmentId]/[threadId]/git/_layout.tsx +0 −58
  50. apps/mobile/src/app/threads/[environmentId]/[threadId]/git/branches.tsx +0 −5
  51. apps/mobile/src/app/threads/[environmentId]/[threadId]/git/commit.tsx +0 −5
  52. apps/mobile/src/app/threads/[environmentId]/[threadId]/git/index.tsx +0 −5
  53. apps/mobile/src/app/threads/[environmentId]/[threadId]/git/review.tsx +0 −18
  54. apps/mobile/src/app/threads/[environmentId]/[threadId]/index.tsx +0 −5
  55. apps/mobile/src/app/threads/[environmentId]/[threadId]/review-comment.tsx +0 −5
  56. apps/mobile/src/app/threads/[environmentId]/[threadId]/review.tsx +0 −10
  57. apps/mobile/src/app/threads/[environmentId]/[threadId]/terminal.tsx +0 −5
  58. apps/mobile/src/components/AppText.tsx +2 −3
  59. apps/mobile/src/components/ControlPill.tsx +1 −1
  60. apps/mobile/src/components/EmptyState.tsx +23 −1
  61. apps/mobile/src/components/ErrorBanner.tsx +1 −1
  62. apps/mobile/src/features/agent-awareness/notificationNavigation.ts +4 −4
  63. apps/mobile/src/features/archive/ArchivedThreadsRouteScreen.tsx +1 −1
  64. apps/mobile/src/features/archive/ArchivedThreadsScreen.tsx +361 −245
  65. apps/mobile/src/features/cloud/CloudWaitlistEnrollment.tsx +11 −43
  66. apps/mobile/src/features/cloud/dpop.ts +41 −9
  67. apps/mobile/src/features/connection/ConnectionEnvironmentRow.tsx +4 −4
  68. apps/mobile/src/features/connection/ConnectionsNewRouteScreen.tsx +27 −15
  69. apps/mobile/src/features/connection/ConnectionsRouteScreen.tsx +10 −14
  70. apps/mobile/src/features/connection/EnvironmentConnectionNotice.tsx +2 −2
  71. apps/mobile/src/features/diffs/nativeReviewDiffSurface.test.ts +22 −2
  72. apps/mobile/src/features/diffs/nativeReviewDiffSurface.ts +133 −6
  73. apps/mobile/src/features/files/FileMarkdownPreview.tsx +51 −7
  74. apps/mobile/src/features/files/FileTreeBrowser.tsx +149 −55
  75. apps/mobile/src/features/files/SourceFileSurface.tsx +98 −65
  76. apps/mobile/src/features/files/ThreadFilesRouteScreen.tsx +327 −308
  77. apps/mobile/src/features/files/WorkspaceFileWebPreview.tsx +1 −1
  78. apps/mobile/src/features/files/nativeSourceFileAdapter.ts +26 −14
  79. apps/mobile/src/features/files/preload-workspace-file.ts +74 −0
  80. apps/mobile/src/features/files/source-file-document.test.ts +16 −0
  81. apps/mobile/src/features/files/source-file-document.ts +54 −0
  82. apps/mobile/src/features/files/sourceHighlightingState.test.ts +1 −1
  83. apps/mobile/src/features/files/thread-file-navigator-pane.tsx +175 −0
  84. apps/mobile/src/features/home/HomeHeader.tsx +169 −203
  85. apps/mobile/src/features/home/HomeRouteScreen.tsx +149 −0
  86. apps/mobile/src/features/home/HomeScreen.tsx +275 −466
  87. apps/mobile/src/features/home/WorkspaceConnectionStatus.test.ts +70 −0
  88. apps/mobile/src/features/home/WorkspaceConnectionStatus.tsx +53 −0
  89. apps/mobile/src/features/home/home-list-filter-menu.ts +120 −0
  90. apps/mobile/src/features/home/home-list-options.test.ts +31 −0
  91. apps/mobile/src/features/home/home-list-options.ts +144 −0
  92. apps/mobile/src/features/home/homeListItems.test.ts +197 −0
  93. apps/mobile/src/features/home/homeListItems.ts +204 −0
  94. apps/mobile/src/features/home/homeThreadList.ts +57 −4
  95. apps/mobile/src/features/home/thread-swipe-actions.tsx +247 −9
  96. apps/mobile/src/features/home/usePendingTaskListActions.ts +57 −0
  97. apps/mobile/src/features/home/workspace-connection-status.ts +22 −0
  98. apps/mobile/src/features/keyboard/HardwareKeyboardCommandProvider.tsx +73 −0
  99. apps/mobile/src/features/keyboard/hardwareKeyboardCommands.test.ts +28 −0
  100. apps/mobile/src/features/keyboard/hardwareKeyboardCommands.ts +78 −0
  101. apps/mobile/src/features/layout/AdaptiveWorkspaceLayout.tsx +507 −0
  102. apps/mobile/src/features/layout/WorkspaceEmptyDetail.tsx +30 −0
  103. apps/mobile/src/features/layout/native-glass-header-items.ts +33 −0
  104. apps/mobile/src/features/layout/native-mail-search-toolbar.ts +25 −0
  105. apps/mobile/src/features/layout/workspace-inspector-pane.tsx +145 −0
  106. apps/mobile/src/features/layout/workspace-pane-animation.ts +16 −0
  107. apps/mobile/src/features/layout/workspace-pane-divider.tsx +116 −0
  108. apps/mobile/src/features/layout/workspace-sidebar-toolbar.tsx +31 −0
  109. apps/mobile/src/features/projects/AddProjectDestinationRoute.tsx +15 −0
  110. apps/mobile/src/features/projects/AddProjectLocalRoute.tsx +12 −0
  111. apps/mobile/src/features/projects/AddProjectRepositoryRoute.tsx +31 −0
  112. apps/mobile/src/features/projects/AddProjectScreen.tsx +56 −58
  113. apps/mobile/src/features/projects/AddProjectSourceRoute.tsx +5 −0
  114. apps/mobile/src/features/review/ReviewCommentComposerSheet.tsx +21 −16
  115. apps/mobile/src/features/review/ReviewSheet.tsx +478 −182
  116. apps/mobile/src/features/review/nativeReviewDiffAdapter.test.ts +56 −0
  117. apps/mobile/src/features/review/nativeReviewDiffAdapter.ts +98 −36
  118. apps/mobile/src/features/review/review-section-menu.test.ts +40 −0
  119. apps/mobile/src/features/review/review-section-menu.ts +21 −0
  120. apps/mobile/src/features/review/reviewDiffBridgeKeys.test.ts +1 −1
  121. apps/mobile/src/features/review/reviewDiffBridgeKeys.ts +35 −0
  122. apps/mobile/src/features/review/reviewDiffRendering.tsx +16 −9
  123. apps/mobile/src/features/review/reviewPaneSelection.test.ts +35 −0
  124. apps/mobile/src/features/review/reviewPaneSelection.ts +16 −0
  125. apps/mobile/src/features/review/useNativeReviewDiffBridge.ts +6 −36
  126. apps/mobile/src/features/review/useReviewCommentSelectionController.ts +6 −7
  127. apps/mobile/src/features/review/useReviewDiffData.ts +11 −9
  128. apps/mobile/src/features/review/useReviewDiffPrewarming.ts +101 −0
  129. apps/mobile/src/features/settings/SettingsAppearanceRouteScreen.tsx +30 −0
  130. apps/mobile/src/features/settings/SettingsAuthRouteScreen.tsx +39 −0
  131. apps/mobile/src/features/settings/SettingsEnvironmentsRouteScreen.tsx +24 −28
  132. apps/mobile/src/features/settings/SettingsRouteScreen.tsx +61 −128
  133. apps/mobile/src/features/settings/SettingsWaitlistRouteScreen.tsx +19 −14
  134. apps/mobile/src/features/settings/appearance/AppearancePreferencesProvider.tsx +158 −0
  135. apps/mobile/src/features/settings/appearance/components/AppearancePreviews.tsx +177 −0
  136. apps/mobile/src/features/settings/appearance/components/FontSizeSliderRow.tsx +214 −0
  137. apps/mobile/src/features/settings/appearance/sections/CodeAppearanceSection.tsx +64 −0
  138. apps/mobile/src/features/settings/appearance/sections/TerminalAppearanceSection.tsx +54 −0
  139. apps/mobile/src/features/settings/appearance/sections/TextAppearanceSection.tsx +34 −0
  140. apps/mobile/src/features/settings/appearance/useAppearanceCodeSurface.ts +34 −0
  141. apps/mobile/src/features/settings/appearance/useScaledTextRole.ts +36 −0
  142. apps/mobile/src/features/settings/components/SettingsRow.tsx +76 −0
  143. apps/mobile/src/features/settings/components/SettingsSection.tsx +18 −0
  144. apps/mobile/src/features/settings/components/SettingsSwitchRow.tsx +37 −0
  145. apps/mobile/src/features/settings/components/settings-sheet-targets.ts +1 −0
  146. apps/mobile/src/features/terminal/NativeTerminalSurface.tsx +17 −44
  147. apps/mobile/src/features/terminal/ThreadTerminalRouteScreen.tsx +98 −165
  148. apps/mobile/src/features/terminal/nativeTerminalModule.ts +21 −1
  149. apps/mobile/src/features/terminal/terminalPreferences.ts +1 −1
  150. apps/mobile/src/features/threads/ComposerCommandPopover.tsx +1 −3
  151. apps/mobile/src/features/threads/NewTaskDraftRouteScreen.tsx +45 −0
  152. apps/mobile/src/features/threads/NewTaskDraftScreen.tsx +178 −34
  153. apps/mobile/src/features/threads/NewTaskRouteScreen.tsx +61 −52
  154. apps/mobile/src/features/threads/PendingApprovalCard.tsx +1 −1
  155. apps/mobile/src/features/threads/PendingUserInputCard.tsx +1 −1
  156. apps/mobile/src/features/threads/ThreadComposer.tsx +87 −39
  157. apps/mobile/src/features/threads/ThreadDetailScreen.tsx +158 −97
  158. apps/mobile/src/features/threads/ThreadFeed.tsx +180 −111
  159. apps/mobile/src/features/threads/ThreadGitControls.tsx +382 −122
  160. apps/mobile/src/features/threads/ThreadNavigationDrawer.tsx +7 −34
  161. apps/mobile/src/features/threads/ThreadNavigationSidebar.tsx +820 −0
  162. apps/mobile/src/features/threads/ThreadRouteScreen.tsx +469 −166
  163. apps/mobile/src/features/threads/git/GitBranchesSheet.tsx +11 −6
  164. apps/mobile/src/features/threads/git/GitCommitSheet.tsx +15 −10
  165. apps/mobile/src/features/threads/git/GitConfirmSheet.tsx +30 −15
  166. apps/mobile/src/features/threads/git/GitOverviewSheet.tsx +246 −97
  167. apps/mobile/src/features/threads/git/gitSheetComponents.tsx +1 −1
  168. apps/mobile/src/features/threads/new-task-flow-provider.tsx +318 −10
  169. apps/mobile/src/features/threads/sidebar-filter-button.tsx +55 −0
  170. apps/mobile/src/features/threads/sidebar-header-actions.tsx +74 −0
  171. apps/mobile/src/features/threads/sidebar-native-header-items.ts +63 −0
  172. apps/mobile/src/features/threads/sidebar-navigation-shell.tsx +72 −0
  173. apps/mobile/src/features/threads/thread-inspector-content-stack.tsx +101 −0
  174. apps/mobile/src/features/threads/thread-list-items.tsx +632 −0
  175. apps/mobile/src/features/threads/thread-navigation-groups.test.ts +111 −0
  176. apps/mobile/src/features/threads/thread-navigation-groups.ts +64 −0
  177. apps/mobile/src/features/threads/thread-work-log.tsx +2 −5
  178. apps/mobile/src/features/threads/threadContentPresentation.ts +7 −1
  179. apps/mobile/src/features/threads/threadPresentation.ts +103 −18
  180. apps/mobile/src/features/threads/use-project-actions.ts +22 −51
  181. apps/mobile/src/lib/adaptive-navigation.test.ts +68 −0
  182. apps/mobile/src/lib/adaptive-navigation.ts +35 −0
  183. apps/mobile/src/lib/appearancePreferences.test.ts +142 −0
  184. apps/mobile/src/lib/appearancePreferences.ts +245 −0
  185. apps/mobile/src/lib/layout.test.ts +343 −0
  186. apps/mobile/src/lib/layout.ts +226 −4
  187. apps/mobile/src/lib/projectThreadStartTurn.ts +89 −0
  188. apps/mobile/src/lib/routes.test.ts +0 −45
  189. apps/mobile/src/lib/routes.ts +0 −134
  190. apps/mobile/src/lib/storage.ts +26 −3
  191. apps/mobile/src/lib/typography.test.ts +4 −7
  192. apps/mobile/src/lib/typography.ts +10 −11
  193. apps/mobile/src/lib/uuid.ts +6 −0
  194. apps/mobile/src/native/StackHeader.tsx +359 −0
  195. apps/mobile/src/native/T3ComposerEditor.ios.tsx +7 −3
  196. apps/mobile/src/native/T3ComposerEditor.tsx +3 −2
  197. apps/mobile/src/native/T3ComposerEditor.types.ts +2 −0
  198. apps/mobile/src/native/T3KeyboardCommands.ios.tsx +31 −0
  199. apps/mobile/src/native/T3KeyboardCommands.tsx +13 −0
  200. apps/mobile/src/native/scrollEdgeEffects.test.ts +29 −0
  201. apps/mobile/src/native/scrollEdgeEffects.ts +37 −0
  202. apps/mobile/src/state/thread-outbox-manager.ts +31 −0
  203. apps/mobile/src/state/thread-outbox-model.ts +56 −2
  204. apps/mobile/src/state/thread-outbox.test.ts +121 −0
  205. apps/mobile/src/state/thread-outbox.ts +5 −0
  206. apps/mobile/src/state/use-composer-drafts.ts +6 −0
  207. apps/mobile/src/state/use-pending-new-tasks.ts +35 −0
  208. apps/mobile/src/state/use-thread-outbox-drain.ts +174 −62
  209. apps/mobile/src/state/use-thread-outbox.ts +31 −1
  210. apps/mobile/src/state/use-thread-pr.ts +67 −0
  211. apps/mobile/src/state/use-thread-selection.ts +101 −20
  212. apps/server/package.json +1 −1
  213. apps/server/src/provider/Layers/ClaudeProvider.ts +36 −1
  214. apps/web/package.json +1 −1
  215. apps/web/src/components/RightPanelTabs.tsx +15 −0
  216. apps/web/src/components/settings/ProviderModelsSection.tsx +1 −1
  217. apps/web/src/index.css +23 −0
  218. apps/web/vite.config.ts +9 −0
  219. experiments/messages-glass-lab/MessagesGlassLab.xcodeproj/project.pbxproj +285 −0
  220. experiments/messages-glass-lab/MessagesGlassLab/ContentView.swift +386 −0
  221. experiments/messages-glass-lab/MessagesGlassLab/MessagesGlassLabApp.swift +10 −0
  222. package.json +1 −1
  223. packages/client-runtime/src/state/threadDetail.ts +1 −1
  224. packages/client-runtime/src/state/threadState.ts +16 −0
  225. packages/client-runtime/src/state/threads.ts +6 −14
  226. packages/contracts/package.json +1 −1
  227. packages/contracts/src/model.ts +5 −2
  228. packages/shared/src/dpop.ts +9 −22
  229. packages/shared/src/model.test.ts +1 −88
  230. patches/@expo%2Fmetro-config@56.0.14.patch +0 −0
  231. patches/@react-native-menu__menu@2.0.0.patch +46 −0
  232. patches/@react-navigation%2Fnative-stack@7.17.6.patch +100 −0
  233. patches/expo-modules-jsi@56.0.10.patch +21 −0
  234. patches/react-native-gesture-handler@2.31.2.patch +124 −0
  235. patches/react-native-screens@4.25.2.patch +1542 −0
  236. pnpm-lock.yaml +952 −719
  237. pnpm-workspace.yaml +24 −21
  238. vite.config.ts +4 −0
.vite-hooks/pre-commit+1−0
@@ -0,0 +1 @@
1vp staged
apps/desktop/package.json+1−1
@@ -1,6 +1,6 @@
1{1{
2 "name": "@t3tools/desktop",2 "name": "@t3tools/desktop",
3 "version": "0.0.27",3 "version": "0.0.28",
4 "private": true,4 "private": true,
5 "type": "module",5 "type": "module",
6 "main": "dist-electron/main.cjs",6 "main": "dist-electron/main.cjs",
apps/desktop/scripts/electron-launcher.mjs+13−6
@@ -358,10 +358,7 @@ function resolveLinuxSandboxArgs(electronBinaryPath) { · 357 unmodified lines
358}358}
359 359
360export function resolveElectronPath() {360export function resolveElectronPath() {
361 ensureElectronRuntime();361 const electronBinaryPath = resolveElectronBinaryPath();
362
363 const require = NodeModule.createRequire(import.meta.url);
364 const electronBinaryPath = require("electron");
365 362
366 if (hostPlatform !== "darwin") {363 if (hostPlatform !== "darwin") {
367 return electronBinaryPath;364 return electronBinaryPath;
@@ -378,13 +375,23 @@ export function resolveElectronLaunchCommand(args = []) { · 10 unmodified lines
378 };375 };
379}376}
380 377
378export 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
381export function resolveDevProtocolClient() {389export function resolveDevProtocolClient() {
382 if (hostPlatform !== "darwin" || !isDevelopment) {390 if (hostPlatform !== "darwin" || !isDevelopment) {
383 return null;391 return null;
384 }392 }
385 393
386 const require = NodeModule.createRequire(import.meta.url);394 const electronBinaryPath = resolveElectronBinaryPath();
387 const electronBinaryPath = require("electron");
388 const launcherBinaryPath = buildMacLauncher(electronBinaryPath);395 const launcherBinaryPath = buildMacLauncher(electronBinaryPath);
389 return {396 return {
390 appBundlePath: NodePath.resolve(launcherBinaryPath, "..", "..", ".."),397 appBundlePath: NodePath.resolve(launcherBinaryPath, "..", "..", ".."),
apps/desktop/scripts/electron-launcher.test.mjs+21−1
@@ -1,6 +1,6 @@
1import { assert, describe, it } from "vite-plus/test";1import { assert, describe, it } from "vite-plus/test";
2 2
3import { makeDevelopmentLauncherScript } from "./electron-launcher.mjs";3import { makeDevelopmentLauncherScript, resolveElectronBinaryPath } from "./electron-launcher.mjs";
4 4
5describe("electron development launcher", () => {5describe("electron development launcher", () => {
6 it("uses captured values only as fallbacks for a live runner environment", () => {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 "exec '/repo/node_modules/electron/Electron' --t3code-dev-root='/repo/apps/desktop' '/repo/apps/desktop/dist-electron/main.cjs' \"$@\"",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});
apps/desktop/src/backend/DesktopBackendConfiguration.ts+1−0
@@ -607,6 +607,7 @@ export const make = Effect.gen(function* () { · 606 unmodified lines
607 const backendExposure = yield* serverExposure.backendConfig;607 const backendExposure = yield* serverExposure.backendConfig;
608 const persistedSettings = yield* settings.get;608 const persistedSettings = yield* settings.get;
609 const shared = yield* sharedInputs;609 const shared = yield* sharedInputs;
610 yield* wslEnvironment.preWarm(persistedSettings.wslDistro);
610 return yield* resolveWslStartConfig({611 return yield* resolveWslStartConfig({
611 ...shared,612 ...shared,
612 port: backendExposure.port,613 port: backendExposure.port,
apps/mobile/.swiftlint.yml+1−0
@@ -1,6 +1,7 @@
1included:1included:
2 - ios/T3Code2 - ios/T3Code
3 - modules/t3-composer-editor/ios3 - modules/t3-composer-editor/ios
4 - modules/t3-native-controls/ios
4 - modules/t3-terminal/ios5 - modules/t3-terminal/ios
5 - modules/t3-review-diff/ios6 - modules/t3-review-diff/ios
6 7
apps/mobile/app.config.ts+14−1
@@ -17,6 +17,7 @@ const VARIANT_CONFIG: Record< · 16 unmodified lines
17 readonly iosIcon: string;17 readonly iosIcon: string;
18 readonly iosBundleIdentifier: string;18 readonly iosBundleIdentifier: string;
19 readonly androidPackage: string;19 readonly androidPackage: string;
20 readonly relyingParty?: string;
20 }21 }
21> = {22> = {
22 development: {23 development: {
@@ -25,6 +26,7 @@ const VARIANT_CONFIG: Record< · 2 unmodified lines
25 iosIcon: "./assets/icon-composer-dev.icon",26 iosIcon: "./assets/icon-composer-dev.icon",
26 iosBundleIdentifier: "com.t3tools.t3code.dev",27 iosBundleIdentifier: "com.t3tools.t3code.dev",
27 androidPackage: "com.t3tools.t3code.dev",28 androidPackage: "com.t3tools.t3code.dev",
29 relyingParty: "clerk.t3.codes",
28 },30 },
29 preview: {31 preview: {
30 appName: "T3 Code Preview",32 appName: "T3 Code Preview",
@@ -32,6 +34,7 @@ const VARIANT_CONFIG: Record< · 1 unmodified line
32 iosIcon: "./assets/icon-composer-prod.icon",34 iosIcon: "./assets/icon-composer-prod.icon",
33 iosBundleIdentifier: "com.t3tools.t3code.preview",35 iosBundleIdentifier: "com.t3tools.t3code.preview",
34 androidPackage: "com.t3tools.t3code.preview",36 androidPackage: "com.t3tools.t3code.preview",
37 relyingParty: "clerk.t3.codes",
35 },38 },
36 production: {39 production: {
37 appName: "T3 Code",40 appName: "T3 Code",
@@ -39,6 +42,7 @@ const VARIANT_CONFIG: Record< · 1 unmodified line
39 iosIcon: "./assets/icon-composer-prod.icon",42 iosIcon: "./assets/icon-composer-prod.icon",
40 iosBundleIdentifier: "com.t3tools.t3code",43 iosBundleIdentifier: "com.t3tools.t3code",
41 androidPackage: "com.t3tools.t3code",44 androidPackage: "com.t3tools.t3code",
45 relyingParty: "clerk.t3.codes",
42 },46 },
43};47};
44 48
@@ -77,6 +81,14 @@ const config: ExpoConfig = { · 32 unmodified lines
77 icon: variant.iosIcon,81 icon: variant.iosIcon,
78 supportsTablet: true,82 supportsTablet: true,
79 bundleIdentifier: variant.iosBundleIdentifier,83 bundleIdentifier: variant.iosBundleIdentifier,
84 // Pin code signing to the T3 Tools team so non-interactive `expo run:ios`
85 // does not fall back to a personal team (which cannot sign app groups,
86 // Sign in with Apple, or push notification entitlements).
87 appleTeamId: "ARK85ZXQ4Z",
88 associatedDomains: [
89 `applinks:${variant.relyingParty}`,
90 `webcredentials:${variant.relyingParty}`,
91 ],
80 infoPlist: {92 infoPlist: {
81 NSAppTransportSecurity: {93 NSAppTransportSecurity: {
82 NSAllowsArbitraryLoads: true,94 NSAllowsArbitraryLoads: true,
@@ -101,7 +113,6 @@ const config: ExpoConfig = { · 18 unmodified lines
101 favicon: "./assets/favicon.png",113 favicon: "./assets/favicon.png",
102 },114 },
103 plugins: [115 plugins: [
104 "expo-router",
105 "expo-font",116 "expo-font",
106 "expo-secure-store",117 "expo-secure-store",
107 ["@clerk/expo", { theme: "./clerk-theme.json" }],118 ["@clerk/expo", { theme: "./clerk-theme.json" }],
@@ -139,6 +150,7 @@ const config: ExpoConfig = { · 31 unmodified lines
139 },150 },
140 },151 },
141 ],152 ],
153 "./plugins/withIosCocoaPodsUuidCache.cjs",
142 [154 [
143 "expo-widgets",155 "expo-widgets",
144 {156 {
@@ -155,6 +167,7 @@ const config: ExpoConfig = { · 10 unmodified lines
155 ],167 ],
156 },168 },
157 ],169 ],
170 "./plugins/withIosSceneLifecycle.cjs",
158 "./plugins/withAndroidCleartextTraffic.cjs",171 "./plugins/withAndroidCleartextTraffic.cjs",
159 ],172 ],
160 extra: {173 extra: {
apps/mobile/global.css+18−18
@@ -199,24 +199,24 @@ · 198 unmodified lines
199 --font-bold: "DMSans_700Bold";199 --font-bold: "DMSans_700Bold";
200 200
201 /* Keep this scale aligned with src/lib/typography.ts for native style props. */201 /* Keep this scale aligned with src/lib/typography.ts for native style props. */
202 --text-3xs: 10px;202 --text-3xs: 11px;
203 --text-3xs--line-height: 13px;203 --text-3xs--line-height: 14px;
204 --text-2xs: 11px;204 --text-2xs: 12px;
205 --text-2xs--line-height: 15px;205 --text-2xs--line-height: 16px;
206 --text-xs: 12px;206 --text-xs: 13px;
207 --text-xs--line-height: 16px;207 --text-xs--line-height: 17px;
208 --text-sm: 13px;208 --text-sm: 14px;
209 --text-sm--line-height: 18px;209 --text-sm--line-height: 19px;
210 --text-base: 15px;210 --text-base: 16px;
211 --text-base--line-height: 22px;211 --text-base--line-height: 23px;
212 --text-lg: 17px;212 --text-lg: 18px;
213 --text-lg--line-height: 22px;213 --text-lg--line-height: 23px;
214 --text-xl: 20px;214 --text-xl: 21px;
215 --text-xl--line-height: 26px;215 --text-xl--line-height: 28px;
216 --text-2xl: 24px;216 --text-2xl: 26px;
217 --text-2xl--line-height: 30px;217 --text-2xl--line-height: 32px;
218 --text-3xl: 28px;218 --text-3xl: 30px;
219 --text-3xl--line-height: 34px;219 --text-3xl--line-height: 36px;
220}220}
221 221
222/* ─── Custom utilities ──────────────────────────────────────────────── */222/* ─── Custom utilities ──────────────────────────────────────────────── */
apps/mobile/index.ts+10−1
@@ -1,2 +1,11 @@
1import { registerRootComponent } from "expo";
1import "react-native-gesture-handler";2import "react-native-gesture-handler";
2import "expo-router/entry";3import { featureFlags } from "react-native-screens";
4
5import App from "./src/App";
6
7// Required for react-native-screens' iOS FormSheet sizing fix when a nested
8// native stack is rendered inside a non-fitToContents formSheet.
9featureFlags.experiment.synchronousScreenUpdatesEnabled = true;
10
11registerRootComponent(App);
apps/mobile/modules/t3-composer-editor/ios/T3ComposerEditorModule.swift+1−0
@@ -47,6 +47,7 @@ public class T3ComposerEditorModule: Module { · 46 unmodified lines
47 "onComposerSelectionChange",47 "onComposerSelectionChange",
48 "onComposerFocus",48 "onComposerFocus",
49 "onComposerBlur",49 "onComposerBlur",
50 "onComposerSubmit",
50 "onComposerPasteImages",51 "onComposerPasteImages",
51 "onComposerContentSizeChange"52 "onComposerContentSizeChange"
52 )53 )
apps/mobile/modules/t3-composer-editor/ios/T3ComposerEditorView.swift+22−0
@@ -63,6 +63,24 @@ private final class ComposerTextView: UITextView { · 62 unmodified lines
63 63
64 var onPasteImages: (([String]) -> Void)?64 var onPasteImages: (([String]) -> Void)?
65 var onAttributedMutation: (() -> Void)?65 var onAttributedMutation: (() -> Void)?
66 var onSubmit: (() -> Void)?
67
68 override var keyCommands: [UIKeyCommand]? {
69 var commands = super.keyCommands ?? []
70 let submit = UIKeyCommand(
71 input: "\r",
72 modifierFlags: .command,
73 action: #selector(submitMessage(_:))
74 )
75 submit.discoverabilityTitle = "Send Message"
76 submit.wantsPriorityOverSystemBehavior = true
77 commands.append(submit)
78 return commands
79 }
80
81 @objc private func submitMessage(_ sender: UIKeyCommand) {
82 onSubmit?()
83 }
66 84
67 override func canPerformAction(_ action: Selector, withSender sender: Any?) -> Bool {85 override func canPerformAction(_ action: Selector, withSender sender: Any?) -> Bool {
68 if action == #selector(paste(_:)) {86 if action == #selector(paste(_:)) {
@@ -299,6 +317,7 @@ public final class T3ComposerEditorView: ExpoView, UITextViewDelegate { · 230 unmodified lines
299 let onComposerSelectionChange = EventDispatcher()317 let onComposerSelectionChange = EventDispatcher()
300 let onComposerFocus = EventDispatcher()318 let onComposerFocus = EventDispatcher()
301 let onComposerBlur = EventDispatcher()319 let onComposerBlur = EventDispatcher()
320 let onComposerSubmit = EventDispatcher()
302 let onComposerPasteImages = EventDispatcher()321 let onComposerPasteImages = EventDispatcher()
303 let onComposerContentSizeChange = EventDispatcher()322 let onComposerContentSizeChange = EventDispatcher()
304 323
@@ -320,6 +339,9 @@ public final class T3ComposerEditorView: ExpoView, UITextViewDelegate { · 15 unmodified lines
320 textView.onAttributedMutation = { [weak self] in339 textView.onAttributedMutation = { [weak self] in
321 self?.emitTextChange()340 self?.emitTextChange()
322 }341 }
342 textView.onSubmit = { [weak self] in
343 self?.onComposerSubmit([:])
344 }
323 addSubview(textView)345 addSubview(textView)
324 346
325 placeholderLabel.numberOfLines = 0347 placeholderLabel.numberOfLines = 0
apps/mobile/modules/t3-markdown-text/src/NativeMarkdownBlock.ios.tsx+14−5
@@ -26,6 +26,15 @@ function nodeKey(node: MarkdownNode, index: number): string { · 25 unmodified lines
26 return `${node.type}:${node.beg ?? index}:${node.end ?? index}`;26 return `${node.type}:${node.beg ?? index}:${node.end ?? index}`;
27}27}
28 28
29/** Code inside markdown scales with the base text size (12pt at the default 15pt body). */
30function codeBlockFontSize(textStyle: NativeMarkdownTextStyle): number {
31 return Math.max(10, Math.round(textStyle.fontSize * 0.8));
32}
33
34function codeBlockLineHeight(textStyle: NativeMarkdownTextStyle): number {
35 return codeBlockFontSize(textStyle) + 6;
36}
37
29function nodeText(node: MarkdownNode): string {38function nodeText(node: MarkdownNode): string {
30 if (node.content !== undefined) {39 if (node.content !== undefined) {
31 return node.content;40 return node.content;
@@ -161,8 +170,8 @@ function HighlightedCodeText(props: { · 129 unmodified lines
161 style={{170 style={{
162 color: props.textStyle.codeColor,171 color: props.textStyle.codeColor,
163 fontFamily: "ui-monospace",172 fontFamily: "ui-monospace",
164 fontSize: 12,173 fontSize: codeBlockFontSize(props.textStyle),
165 lineHeight: 18,174 lineHeight: codeBlockLineHeight(props.textStyle),
166 }}175 }}
167 >176 >
168 {props.content}177 {props.content}
@@ -196,8 +205,8 @@ function HighlightedCodeText(props: { · 27 unmodified lines
196 style={{205 style={{
197 color: props.textStyle.codeColor,206 color: props.textStyle.codeColor,
198 fontFamily: "ui-monospace",207 fontFamily: "ui-monospace",
199 fontSize: 12,208 fontSize: codeBlockFontSize(props.textStyle),
200 lineHeight: 18,209 lineHeight: codeBlockLineHeight(props.textStyle),
201 }}210 }}
202 >211 >
203 {keyedLines.map((line, lineIndex) => (212 {keyedLines.map((line, lineIndex) => (
@@ -264,7 +273,7 @@ function NativeCodeBlock(props: { · 60 unmodified lines
264 flex: 1,273 flex: 1,
265 color: props.textStyle.mutedColor,274 color: props.textStyle.mutedColor,
266 fontFamily: "ui-monospace",275 fontFamily: "ui-monospace",
267 fontSize: 12,276 fontSize: codeBlockFontSize(props.textStyle),
268 }}277 }}
269 >278 >
270 {languageLabel}279 {languageLabel}
apps/mobile/modules/t3-markdown-text/src/NativeMarkdownSelectableText.ios.tsx+21−4
@@ -32,11 +32,25 @@ function runKeySignature(run: NativeMarkdownTextRun): string { · 31 unmodified lines
32 ].join(":");32 ].join(":");
33}33}
34 34
35const DEFAULT_BODY_FONT_SIZE = 15;
36const DEFAULT_HEADING_FONT_SIZES = [22, 19, 17, 16, 15, 15] as const;
37
38function resolveHeadingFontSize(textStyle: NativeMarkdownTextStyle, headingLevel: number): number {
39 const index = Math.max(0, Math.min(5, headingLevel - 1));
40 const configured = textStyle.headingFontSizes?.[index];
41 if (typeof configured === "number" && Number.isFinite(configured)) {
42 return configured;
43 }
44
45 const scale = textStyle.fontSize / DEFAULT_BODY_FONT_SIZE;
46 return Math.max(12, Math.round(DEFAULT_HEADING_FONT_SIZES[index] * scale));
47}
48
35function runStyle(run: NativeMarkdownTextRun, textStyle: NativeMarkdownTextStyle): TextStyle {49function runStyle(run: NativeMarkdownTextRun, textStyle: NativeMarkdownTextStyle): TextStyle {
36 const isFile = run.fileIcon != null;50 const isFile = run.fileIcon != null;
37 const isSkill = run.skillName != null;51 const isSkill = run.skillName != null;
38 const headingLevel = Math.max(1, Math.min(6, run.headingLevel ?? 1));52 const headingLevel = Math.max(1, Math.min(6, run.headingLevel ?? 1));
39 const headingFontSize = [22, 19, 17, 16, 15, 15][headingLevel - 1] ?? 15;53 const headingFontSize = resolveHeadingFontSize(textStyle, headingLevel);
40 const isHeading = run.role === "heading";54 const isHeading = run.role === "heading";
41 const isCodeBlock = run.role === "code-block" || run.role === "code-language";55 const isCodeBlock = run.role === "code-block" || run.role === "code-language";
42 const hasParagraphStyle = run.headIndent !== undefined;56 const hasParagraphStyle = run.headIndent !== undefined;
@@ -88,7 +102,7 @@ function runStyle(run: NativeMarkdownTextRun, textStyle: NativeMarkdownTextStyle · 45 unmodified lines
88 : isHeading102 : isHeading
89 ? headingFontSize103 ? headingFontSize
90 : run.role === "code-language"104 : run.role === "code-language"
91 ? 11105 ? Math.max(10, Math.round(textStyle.fontSize * 0.73))
92 : run.code || isCodeBlock106 : run.code || isCodeBlock
93 ? Math.max(12, textStyle.fontSize - 2)107 ? Math.max(12, textStyle.fontSize - 2)
94 : textStyle.fontSize,108 : textStyle.fontSize,
@@ -98,9 +112,9 @@ function runStyle(run: NativeMarkdownTextRun, textStyle: NativeMarkdownTextStyle · 3 unmodified lines
98 : run.role === "list-break"112 : run.role === "list-break"
99 ? textStyle.lineHeight + (run.spacing ?? 0)113 ? textStyle.lineHeight + (run.spacing ?? 0)
100 : isHeading114 : isHeading
101 ? Math.max(headingFontSize + 6, 20)115 ? Math.max(headingFontSize + 6, textStyle.lineHeight + 2)
102 : isCodeBlock116 : isCodeBlock
103 ? 18117 ? Math.max(16, textStyle.lineHeight - 2)
104 : textStyle.lineHeight,118 : textStyle.lineHeight,
105 fontStyle: run.italic ? "italic" : "normal",119 fontStyle: run.italic ? "italic" : "normal",
106 fontWeight: isHeading || run.bold || isFile || isSkill ? "700" : "400",120 fontWeight: isHeading || run.bold || isFile || isSkill ? "700" : "400",
@@ -148,6 +162,9 @@ export function NativeMarkdownSelectableText(props: { · 41 unmodified lines
148 // color-only child update can otherwise leave the previous appearance cached.162 // color-only child update can otherwise leave the previous appearance cached.
149 const appearanceKey = [163 const appearanceKey = [
150 colorScheme ?? "unspecified",164 colorScheme ?? "unspecified",
165 props.textStyle.fontSize,
166 props.textStyle.lineHeight,
167 props.textStyle.headingFontSizes?.join(","),
151 props.textStyle.color,168 props.textStyle.color,
152 props.textStyle.strongColor,169 props.textStyle.strongColor,
153 props.textStyle.mutedColor,170 props.textStyle.mutedColor,
apps/mobile/modules/t3-markdown-text/src/SelectableMarkdownText.types.ts+1−0
@@ -16,6 +16,7 @@ export interface NativeMarkdownTextStyle { · 15 unmodified lines
16 readonly fontFamily: string;16 readonly fontFamily: string;
17 readonly headingFontFamily: string;17 readonly headingFontFamily: string;
18 readonly boldFontFamily: string;18 readonly boldFontFamily: string;
19 readonly headingFontSizes?: ReadonlyArray<number>;
19}20}
20 21
21export interface MarkdownHighlightedToken {22export interface MarkdownHighlightedToken {
apps/mobile/modules/t3-native-controls/expo-module.config.json+6−0
@@ -0,0 +1,6 @@
1{
2 "platforms": ["apple"],
3 "apple": {
4 "modules": ["T3NativeControlsModule", "T3KeyboardCommandsModule"]
5 }
6}
apps/mobile/modules/t3-native-controls/ios/T3HeaderButtonView.swift+62−0
@@ -0,0 +1,62 @@
1import ExpoModulesCore
2import UIKit
3
4public final class T3HeaderButtonView: ExpoView {
5 private static let size: CGFloat = 44
6 private static let symbolSize: CGFloat = 18
7
8 private let button = UIButton(type: .system)
9 private var systemImage = "circle"
10
11 let onTriggered = EventDispatcher()
12
13 public required init(appContext: AppContext? = nil) {
14 super.init(appContext: appContext)
15
16 isAccessibilityElement = false
17 button.frame = bounds
18 button.autoresizingMask = [.flexibleWidth, .flexibleHeight]
19 button.addTarget(self, action: #selector(handlePress), for: .primaryActionTriggered)
20 addSubview(button)
21 applyConfiguration()
22 }
23
24 public override var intrinsicContentSize: CGSize {
25 CGSize(width: Self.size, height: Self.size)
26 }
27
28 public func setLabel(_ label: String) {
29 button.accessibilityLabel = label
30 }
31
32 public func setSystemImage(_ systemImage: String) {
33 guard self.systemImage != systemImage else {
34 return
35 }
36 self.systemImage = systemImage
37 applyConfiguration()
38 }
39
40 private func applyConfiguration() {
41 var configuration: UIButton.Configuration
42 if #available(iOS 26.0, *) {
43 configuration = .glass()
44 configuration.cornerStyle = .capsule
45 } else {
46 configuration = .plain()
47 }
48
49 configuration.baseForegroundColor = .label
50 configuration.contentInsets = .zero
51 configuration.image = UIImage(systemName: systemImage)
52 configuration.preferredSymbolConfigurationForImage = UIImage.SymbolConfiguration(
53 pointSize: Self.symbolSize,
54 weight: .regular
55 )
56 button.configuration = configuration
57 }
58
59 @objc private func handlePress() {
60 onTriggered()
61 }
62}
apps/mobile/modules/t3-native-controls/ios/T3KeyboardCommandsModule.swift+131−0
@@ -0,0 +1,131 @@
1import ExpoModulesCore
2import UIKit
3
4public final class T3KeyboardCommandsModule: Module {
5 public func definition() -> ModuleDefinition {
6 Name("T3KeyboardCommands")
7
8 View(T3KeyboardCommandsView.self) {
9 Prop("enabledCommands") { (view: T3KeyboardCommandsView, commands: [String]) in
10 view.setEnabledCommands(commands)
11 }
12 Events("onCommand")
13 }
14 }
15}
16
17public final class T3KeyboardCommandsView: ExpoView {
18 let onCommand = EventDispatcher()
19 private var enabledCommands = Set<String>()
20
21 public override var canBecomeFirstResponder: Bool { true }
22
23 public override var keyCommands: [UIKeyCommand]? {
24 [
25 enabledCommand("newTask", input: "n", modifiers: .command, action: #selector(newTask), title: "New Task"),
26 enabledCommand("focusSearch", input: "f", modifiers: .command, action: #selector(focusSearch), title: "Find"),
27 enabledCommand("focusSearch", input: "k", modifiers: .command, action: #selector(focusSearch), title: "Focus Search"),
28 enabledCommand("back", input: "[", modifiers: .command, action: #selector(goBack), title: "Back"),
29 enabledCommand("files", input: "f", modifiers: [.command, .shift], action: #selector(openFiles), title: "Open Files"),
30 enabledCommand("terminal", input: "t", modifiers: [.command, .shift], action: #selector(openTerminal), title: "Open Terminal"),
31 enabledCommand("review", input: "r", modifiers: [.command, .shift], action: #selector(openReview), title: "Open Review"),
32 enabledCommand("toggleSidebar", input: "\\", modifiers: .command, action: #selector(handleToggleSidebar), title: "Toggle Sidebar"),
33 ].compactMap { $0 }
34 }
35
36 func setEnabledCommands(_ commands: [String]) {
37 enabledCommands = Set(commands)
38 if isFirstResponder {
39 resignFirstResponder()
40 }
41 reclaimFirstResponderIfAvailable()
42 }
43
44 private func enabledCommand(
45 _ identifier: String,
46 input: String,
47 modifiers: UIKeyModifierFlags,
48 action: Selector,
49 title: String
50 ) -> UIKeyCommand? {
51 guard enabledCommands.contains(identifier) else { return nil }
52 return command(input, modifiers: modifiers, action: action, title: title)
53 }
54
55 public required init(appContext: AppContext? = nil) {
56 super.init(appContext: appContext)
57 NotificationCenter.default.addObserver(
58 self,
59 selector: #selector(reclaimFirstResponderIfAvailable),
60 name: UITextView.textDidEndEditingNotification,
61 object: nil
62 )
63 NotificationCenter.default.addObserver(
64 self,
65 selector: #selector(reclaimFirstResponderIfAvailable),
66 name: UITextField.textDidEndEditingNotification,
67 object: nil
68 )
69 NotificationCenter.default.addObserver(
70 self,
71 selector: #selector(reclaimFirstResponderIfAvailable),
72 name: UIApplication.didBecomeActiveNotification,
73 object: nil
74 )
75 }
76
77 deinit {
78 NotificationCenter.default.removeObserver(self)
79 }
80
81 public override func didMoveToWindow() {
82 super.didMoveToWindow()
83 reclaimFirstResponderIfAvailable()
84 }
85
86 public override func layoutSubviews() {
87 super.layoutSubviews()
88 reclaimFirstResponderIfAvailable()
89 }
90
91 private func command(
92 _ input: String,
93 modifiers: UIKeyModifierFlags,
94 action: Selector,
95 title: String
96 ) -> UIKeyCommand {
97 let command = UIKeyCommand(input: input, modifierFlags: modifiers, action: action)
98 command.discoverabilityTitle = title
99 command.wantsPriorityOverSystemBehavior = true
100 return command
101 }
102
103 @objc private func newTask() { emit("newTask") }
104 @objc private func focusSearch() { emit("focusSearch") }
105 @objc private func goBack() { emit("back") }
106 @objc private func openFiles() { emit("files") }
107 @objc private func openTerminal() { emit("terminal") }
108 @objc private func openReview() { emit("review") }
109 @objc private func handleToggleSidebar() { emit("toggleSidebar") }
110
111 private func emit(_ command: String) {
112 onCommand(["command": command])
113 }
114
115 @objc private func reclaimFirstResponderIfAvailable() {
116 DispatchQueue.main.async { [weak self] in
117 guard let self, self.window?.t3FirstResponder == nil else { return }
118 self.becomeFirstResponder()
119 }
120 }
121}
122
123private extension UIView {
124 var t3FirstResponder: UIResponder? {
125 if isFirstResponder { return self }
126 for subview in subviews {
127 if let responder = subview.t3FirstResponder { return responder }
128 }
129 return nil
130 }
131}
apps/mobile/modules/t3-native-controls/ios/T3NativeControls.podspec+19−0
@@ -0,0 +1,19 @@
1Pod::Spec.new do |s|
2 s.name = 'T3NativeControls'
3 s.version = '1.0.0'
4 s.summary = 'Native UIKit controls for T3 Code mobile.'
5 s.description = 'UIKit-backed controls that match native iOS navigation chrome.'
6 s.author = 'T3 Tools'
7 s.homepage = 'https://t3tools.com'
8 s.platforms = {
9 :ios => '18.0',
10 }
11 s.source = { :path => '.' }
12 s.static_framework = true
13
14 s.dependency 'ExpoModulesCore'
15 s.pod_target_xcconfig = {
16 'DEFINES_MODULE' => 'YES',
17 }
18 s.source_files = '**/*.{h,m,mm,swift,hpp,cpp}'
19end
apps/mobile/modules/t3-native-controls/ios/T3NativeControlsModule.swift+18−0
@@ -0,0 +1,18 @@
1import ExpoModulesCore
2
3public final class T3NativeControlsModule: Module {
4 public func definition() -> ModuleDefinition {
5 Name("T3NativeControls")
6
7 View(T3HeaderButtonView.self) {
8 Prop("label") { (view: T3HeaderButtonView, label: String) in
9 view.setLabel(label)
10 }
11 Prop("systemImage") { (view: T3HeaderButtonView, systemImage: String) in
12 view.setSystemImage(systemImage)
13 }
14
15 Events("onTriggered")
16 }
17 }
18}
apps/mobile/modules/t3-review-diff/ios/T3ReviewDiffModule.swift+40−13
@@ -5,22 +5,14 @@ public class T3ReviewDiffModule: Module { · 4 unmodified lines
5 Name("T3ReviewDiffSurface")5 Name("T3ReviewDiffSurface")
6 6
7 View(T3ReviewDiffView.self) {7 View(T3ReviewDiffView.self) {
8 Prop("rowsJson") { (view: T3ReviewDiffView, rowsJson: String) in
9 view.setRowsJson(rowsJson)
10 }
11
12 Prop("tokensJson") { (view: T3ReviewDiffView, tokensJson: String) in
13 view.setTokensJson(tokensJson)
14 }
15
16 Prop("tokensPatchJson") { (view: T3ReviewDiffView, tokensPatchJson: String) in
17 view.setTokensPatchJson(tokensPatchJson)
18 }
19
20 Prop("tokensResetKey") { (view: T3ReviewDiffView, tokensResetKey: String) in8 Prop("tokensResetKey") { (view: T3ReviewDiffView, tokensResetKey: String) in
21 view.setTokensResetKey(tokensResetKey)9 view.setTokensResetKey(tokensResetKey)
22 }10 }
23 11
12 Prop("contentResetKey") { (view: T3ReviewDiffView, contentResetKey: String) in
13 view.setContentResetKey(contentResetKey)
14 }
15
24 Prop("collapsedFileIdsJson") { (view: T3ReviewDiffView, collapsedFileIdsJson: String) in16 Prop("collapsedFileIdsJson") { (view: T3ReviewDiffView, collapsedFileIdsJson: String) in
25 view.setCollapsedFileIdsJson(collapsedFileIdsJson)17 view.setCollapsedFileIdsJson(collapsedFileIdsJson)
26 }18 }
@@ -61,7 +53,42 @@ public class T3ReviewDiffModule: Module { · 34 unmodified lines
61 view.setInitialRowIndex(initialRowIndex)53 view.setInitialRowIndex(initialRowIndex)
62 }54 }
63 55
64 Events("onDebug", "onToggleFile", "onToggleViewedFile", "onPressLine", "onToggleComment")56 Prop("refreshing") { (view: T3ReviewDiffView, refreshing: Bool) in
57 view.setRefreshing(refreshing)
58 }
59
60 Events(
61 "onDebug",
62 "onVisibleFileChange",
63 "onToggleFile",
64 "onToggleViewedFile",
65 "onPressLine",
66 "onToggleComment",
67 "onPullToRefresh"
68 )
69
70 AsyncFunction("scrollToFile") { (view: T3ReviewDiffView, fileId: String, animated: Bool) in
71 view.scrollToFile(fileId, animated: animated)
72 }
73
74 AsyncFunction("scrollToTop") { (view: T3ReviewDiffView, animated: Bool) in
75 view.scrollToTop(animated: animated)
76 }
77
78 // Large, frequently changing JSON values cannot be regular Fabric props. Expo's
79 // prop adapter compares strings on the main thread before invoking a setter, which
80 // makes a syntax-token patch capable of blocking a frame by itself.
81 AsyncFunction("setRowsJson") { (view: T3ReviewDiffView, rowsJson: String) in
82 view.setRowsJson(rowsJson)
83 }
84
85 AsyncFunction("setTokensJson") { (view: T3ReviewDiffView, tokensJson: String) in
86 view.setTokensJson(tokensJson)
87 }
88
89 AsyncFunction("setTokensPatchJson") { (view: T3ReviewDiffView, tokensPatchJson: String) in
90 view.setTokensPatchJson(tokensPatchJson)
91 }
65 }92 }
66 }93 }
67}94}
apps/mobile/modules/t3-review-diff/ios/T3ReviewDiffView.swift+270−60
@@ -1,7 +1,7 @@
1import ExpoModulesCore1import ExpoModulesCore
2import UIKit2import UIKit
3 3
4private struct ReviewDiffNativeRow: Decodable {4private struct ReviewDiffNativeRow: Decodable, Sendable {
5 let kind: String5 let kind: String
6 let id: String6 let id: String
7 let fileId: String?7 let fileId: String?
@@ -21,18 +21,18 @@ private struct ReviewDiffNativeRow: Decodable { · 13 unmodified lines
21 let commentSectionTitle: String?21 let commentSectionTitle: String?
22}22}
23 23
24private struct ReviewDiffNativeWordDiffRange: Decodable {24private struct ReviewDiffNativeWordDiffRange: Decodable, Sendable {
25 let start: Int25 let start: Int
26 let end: Int26 let end: Int
27}27}
28 28
29private struct ReviewDiffNativeToken: Decodable {29private struct ReviewDiffNativeToken: Decodable, Sendable {
30 let content: String30 let content: String
31 let color: String?31 let color: String?
32 let fontStyle: Int?32 let fontStyle: Int?
33}33}
34 34
35private struct ReviewDiffNativeTokenPatch: Decodable {35private struct ReviewDiffNativeTokenPatch: Decodable, Sendable {
36 let resetKey: String?36 let resetKey: String?
37 let chunkIndex: Int?37 let chunkIndex: Int?
38 let tokensByRowId: [String: [ReviewDiffNativeToken]]?38 let tokensByRowId: [String: [ReviewDiffNativeToken]]?
@@ -312,6 +312,10 @@ private struct ReviewDiffNativeStyle { · 273 unmodified lines
312}312}
313 313
314public final class T3ReviewDiffView: ExpoView, UIScrollViewDelegate {314public final class T3ReviewDiffView: ExpoView, UIScrollViewDelegate {
315 private let payloadDecodeQueue = DispatchQueue(
316 label: "com.t3tools.review-diff.payload-decode",
317 qos: .userInitiated
318 )
315 private let scrollView = UIScrollView()319 private let scrollView = UIScrollView()
316 private let contentView = ReviewDiffContentView()320 private let contentView = ReviewDiffContentView()
317 private var rows: [ReviewDiffNativeRow] = []321 private var rows: [ReviewDiffNativeRow] = []
@@ -323,14 +327,25 @@ public final class T3ReviewDiffView: ExpoView, UIScrollViewDelegate { · 5 unmodified lines
323 private var lastMetricsDebugKey = ""327 private var lastMetricsDebugKey = ""
324 private var lastVisibleRangeDebugKey = ""328 private var lastVisibleRangeDebugKey = ""
325 private var tokensResetKey = ""329 private var tokensResetKey = ""
330 private var contentResetKey = ""
326 private var initialRowIndex: Int?331 private var initialRowIndex: Int?
327 private var hasAppliedInitialRowIndex = false332 private var hasAppliedInitialRowIndex = false
333 private var lastVisibleFileId: String?
334 private var pendingScrollFileId: String?
335 private var pendingScrollAnimated = false
336 private var isProgrammaticScrollActive = false
337 private var rowsDecodeGeneration = 0
338 private var tokensDecodeGeneration = 0
328 339
329 let onDebug = EventDispatcher()340 let onDebug = EventDispatcher()
341 let onVisibleFileChange = EventDispatcher()
330 let onToggleFile = EventDispatcher()342 let onToggleFile = EventDispatcher()
331 let onToggleViewedFile = EventDispatcher()343 let onToggleViewedFile = EventDispatcher()
332 let onPressLine = EventDispatcher()344 let onPressLine = EventDispatcher()
333 let onToggleComment = EventDispatcher()345 let onToggleComment = EventDispatcher()
346 let onPullToRefresh = EventDispatcher()
347
348 private let pullRefreshControl = UIRefreshControl()
334 349
335 public required init(appContext: AppContext? = nil) {350 public required init(appContext: AppContext? = nil) {
336 super.init(appContext: appContext)351 super.init(appContext: appContext)
@@ -345,6 +360,8 @@ public final class T3ReviewDiffView: ExpoView, UIScrollViewDelegate { · 8 unmodified lines
345 scrollView.showsVerticalScrollIndicator = true360 scrollView.showsVerticalScrollIndicator = true
346 scrollView.showsHorizontalScrollIndicator = false361 scrollView.showsHorizontalScrollIndicator = false
347 scrollView.backgroundColor = contentView.theme.background362 scrollView.backgroundColor = contentView.theme.background
363 pullRefreshControl.addTarget(self, action: #selector(handlePullToRefresh), for: .valueChanged)
364 scrollView.refreshControl = pullRefreshControl
348 addSubview(scrollView)365 addSubview(scrollView)
349 366
350 contentView.backgroundColor = contentView.theme.background367 contentView.backgroundColor = contentView.theme.background
@@ -379,6 +396,13 @@ public final class T3ReviewDiffView: ExpoView, UIScrollViewDelegate { · 28 unmodified lines
379 updateViewportFrame()396 updateViewportFrame()
380 }397 }
381 398
399 public func scrollViewWillBeginDragging(_ scrollView: UIScrollView) {
400 // A direct gesture takes ownership from any interrupted programmatic jump.
401 // Resume visible-file events immediately so the inspector follows the finger.
402 isProgrammaticScrollActive = false
403 contentView.isVerticalScrollActive = true
404 }
405
382 public func scrollViewDidEndDragging(_ scrollView: UIScrollView, willDecelerate decelerate: Bool) {406 public func scrollViewDidEndDragging(_ scrollView: UIScrollView, willDecelerate decelerate: Bool) {
383 guard !decelerate else {407 guard !decelerate else {
384 return408 return
@@ -396,77 +420,125 @@ public final class T3ReviewDiffView: ExpoView, UIScrollViewDelegate { · 11 unmodified lines
396 }420 }
397 421
398 func setRowsJson(_ rowsJson: String) {422 func setRowsJson(_ rowsJson: String) {
399 guard let data = rowsJson.data(using: .utf8) else {423 rowsDecodeGeneration += 1
400 return424 let generation = rowsDecodeGeneration
401 }
402 425
403 do {426 payloadDecodeQueue.async { [weak self] in
404 rows = try JSONDecoder().decode([ReviewDiffNativeRow].self, from: data)427 guard let data = rowsJson.data(using: .utf8) else {
405 contentView.rows = rows428 return
406 hasAppliedInitialRowIndex = false429 }
407 emitDebug("rows-decoded", [430
408 "rows": rows.count,431 do {
409 "firstKind": rows.first?.kind ?? "none",432 let decodedRows = try JSONDecoder().decode([ReviewDiffNativeRow].self, from: data)
410 ])433 DispatchQueue.main.async { [weak self] in
411 updateContentMetrics()434 guard let self, generation == self.rowsDecodeGeneration else {
412 } catch {435 return
413 rows = []436 }
414 contentView.rows = []437 self.rows = decodedRows
415 hasAppliedInitialRowIndex = false438 self.contentView.rows = decodedRows
416 updateContentMetrics()439 self.hasAppliedInitialRowIndex = false
417 emitDebug("rows-decode-failed", [440 self.lastVisibleFileId = nil
418 "error": error.localizedDescription,441 self.emitDebug("rows-decoded", [
419 ])442 "rows": decodedRows.count,
443 "firstKind": decodedRows.first?.kind ?? "none",
444 ])
445 self.updateContentMetrics()
446 self.applyPendingScrollIfNeeded()
447 }
448 } catch {
449 let message = error.localizedDescription
450 DispatchQueue.main.async { [weak self] in
451 guard let self, generation == self.rowsDecodeGeneration else {
452 return
453 }
454 self.rows = []
455 self.contentView.rows = []
456 self.hasAppliedInitialRowIndex = false
457 self.lastVisibleFileId = nil
458 self.pendingScrollFileId = nil
459 self.updateContentMetrics()
460 self.emitDebug("rows-decode-failed", ["error": message])
461 }
462 }
420 }463 }
421 }464 }
422 465
423 func setTokensJson(_ tokensJson: String) {466 func setTokensJson(_ tokensJson: String) {
424 guard let data = tokensJson.data(using: .utf8) else {467 tokensDecodeGeneration += 1
425 return468 let generation = tokensDecodeGeneration
426 }
427 469
428 do {470 payloadDecodeQueue.async { [weak self] in
429 contentView.tokensByRowId = try JSONDecoder().decode(471 guard let data = tokensJson.data(using: .utf8) else {
430 [String: [ReviewDiffNativeToken]].self,472 return
431 from: data473 }
432 )474
433 } catch {475 do {
434 contentView.tokensByRowId = [:]476 let decodedTokens = try JSONDecoder().decode(
435 emitDebug("tokens-decode-failed", [477 [String: [ReviewDiffNativeToken]].self,
436 "error": error.localizedDescription,478 from: data
437 ])479 )
480 DispatchQueue.main.async { [weak self] in
481 guard let self, generation == self.tokensDecodeGeneration else {
482 return
483 }
484 self.contentView.tokensByRowId = decodedTokens
485 }
486 } catch {
487 let message = error.localizedDescription
488 DispatchQueue.main.async { [weak self] in
489 guard let self, generation == self.tokensDecodeGeneration else {
490 return
491 }
492 self.contentView.tokensByRowId = [:]
493 self.emitDebug("tokens-decode-failed", ["error": message])
494 }
495 }
438 }496 }
439 }497 }
440 498
441 func setTokensPatchJson(_ tokensPatchJson: String) {499 func setTokensPatchJson(_ tokensPatchJson: String) {
442 guard let data = tokensPatchJson.data(using: .utf8) else {500 let contentResetKey = self.contentResetKey
443 return
444 }
445 501
446 do {502 payloadDecodeQueue.async { [weak self] in
447 let patch = try JSONDecoder().decode(ReviewDiffNativeTokenPatch.self, from: data)503 guard let data = tokensPatchJson.data(using: .utf8) else {
448 if let resetKey = patch.resetKey, resetKey != tokensResetKey {
449 tokensResetKey = resetKey
450 contentView.tokensByRowId = [:]
451 }
452
453 let tokensByRowId = patch.tokensByRowId ?? [:]
454 if tokensByRowId.isEmpty {
455 return504 return
456 }505 }
457 506
458 contentView.mergeTokensByRowId(tokensByRowId)507 do {
459 if let chunkIndex = patch.chunkIndex, chunkIndex < 5 || chunkIndex.isMultiple(of: 10) {508 let patch = try JSONDecoder().decode(ReviewDiffNativeTokenPatch.self, from: data)
460 emitDebug("tokens-patch-decoded", [509 DispatchQueue.main.async { [weak self] in
461 "chunkIndex": chunkIndex,510 guard let self else {
462 "rows": tokensByRowId.count,511 return
463 "totalRows": contentView.tokensByRowId.count,512 }
464 ])513 guard contentResetKey == self.contentResetKey else {
514 return
515 }
516 // A highlighter request from the previous file can finish after the view has
517 // already reset. Never let that stale patch roll the native token state back.
518 if let resetKey = patch.resetKey, resetKey != self.tokensResetKey {
519 return
520 }
521
522 let tokensByRowId = patch.tokensByRowId ?? [:]
523 if tokensByRowId.isEmpty {
524 return
525 }
526
527 self.contentView.mergeTokensByRowId(tokensByRowId)
528 if let chunkIndex = patch.chunkIndex, chunkIndex < 5 || chunkIndex.isMultiple(of: 10) {
529 self.emitDebug("tokens-patch-decoded", [
530 "chunkIndex": chunkIndex,
531 "rows": tokensByRowId.count,
532 "totalRows": self.contentView.tokensByRowId.count,
533 ])
534 }
535 }
536 } catch {
537 let message = error.localizedDescription
538 DispatchQueue.main.async { [weak self] in
539 self?.emitDebug("tokens-patch-decode-failed", ["error": message])
540 }
465 }541 }
466 } catch {
467 emitDebug("tokens-patch-decode-failed", [
468 "error": error.localizedDescription,
469 ])
470 }542 }
471 }543 }
472 544
@@ -482,6 +554,27 @@ public final class T3ReviewDiffView: ExpoView, UIScrollViewDelegate { · 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 func setCollapsedFileIdsJson(_ collapsedFileIdsJson: String) {578 func setCollapsedFileIdsJson(_ collapsedFileIdsJson: String) {
486 let nextCollapsedFileIds = decodeFileIdSet(collapsedFileIdsJson)579 let nextCollapsedFileIds = decodeFileIdSet(collapsedFileIdsJson)
487 let changedFileIds = contentView.collapsedFileIds.symmetricDifference(nextCollapsedFileIds)580 let changedFileIds = contentView.collapsedFileIds.symmetricDifference(nextCollapsedFileIds)
@@ -573,6 +666,8 @@ public final class T3ReviewDiffView: ExpoView, UIScrollViewDelegate { · 85 unmodified lines
573 contentView.invalidateVisibleViewport()666 contentView.invalidateVisibleViewport()
574 contentView.setNeedsDisplay()667 contentView.setNeedsDisplay()
575 applyInitialRowIndexIfNeeded()668 applyInitialRowIndexIfNeeded()
669 applyPendingScrollIfNeeded()
670 emitVisibleFileIfNeeded()
576 671
577 let debugKey = "\(rows.count):\(Int(bounds.width)):\(Int(bounds.height)):\(Int(height))"672 let debugKey = "\(rows.count):\(Int(bounds.width)):\(Int(bounds.height)):\(Int(height))"
578 if debugKey != lastMetricsDebugKey {673 if debugKey != lastMetricsDebugKey {
@@ -596,11 +691,41 @@ public final class T3ReviewDiffView: ExpoView, UIScrollViewDelegate { · 17 unmodified lines
596 }691 }
597 692
598 private func finishVerticalScroll() {693 private func finishVerticalScroll() {
694 isProgrammaticScrollActive = false
599 contentView.isVerticalScrollActive = false695 contentView.isVerticalScrollActive = false
600 updateViewportFrame()696 updateViewportFrame()
601 emitVisibleRange(reason: "scroll-end")697 emitVisibleRange(reason: "scroll-end")
602 }698 }
603 699
700 private func emitVisibleFileIfNeeded() {
701 // Keep the explicit destination selected while UIKit animates through the files
702 // between the old and new offsets. Emitting every intermediate header forces a
703 // React render per crossing and makes the navigator visibly flash.
704 guard !isProgrammaticScrollActive else {
705 return
706 }
707
708 // The top of the combined diff is the explicit "All files" destination.
709 // Treat it as a first-class selection instead of immediately resolving the
710 // first file header and undoing the navigator's optimistic selection.
711 if scrollView.contentOffset.y <= 0.5 {
712 guard lastVisibleFileId != nil else {
713 return
714 }
715 lastVisibleFileId = nil
716 onVisibleFileChange(["fileId": NSNull()])
717 return
718 }
719
720 guard let fileId = contentView.visibleFileId(atVerticalOffset: scrollView.contentOffset.y),
721 fileId != lastVisibleFileId else {
722 return
723 }
724
725 lastVisibleFileId = fileId
726 onVisibleFileChange(["fileId": fileId])
727 }
728
604 private func emitVisibleRange(reason: String) {729 private func emitVisibleRange(reason: String) {
605 guard let range = contentView.currentVisibleRowRange() else {730 guard let range = contentView.currentVisibleRowRange() else {
606 return731 return
@@ -670,6 +795,33 @@ public final class T3ReviewDiffView: ExpoView, UIScrollViewDelegate { · 63 unmodified lines
670 applyInitialRowIndexIfNeeded()795 applyInitialRowIndexIfNeeded()
671 }796 }
672 797
798 func scrollToFile(_ fileId: String, animated: Bool) {
799 pendingScrollFileId = fileId
800 pendingScrollAnimated = animated
801 applyPendingScrollIfNeeded()
802 }
803
804 func scrollToTop(animated: Bool) {
805 pendingScrollFileId = nil
806 pendingScrollAnimated = false
807 setVerticalContentOffset(0, animated: animated)
808 }
809
810 @objc private func handlePullToRefresh() {
811 onPullToRefresh([:])
812 }
813
814 /// Driven from JS: set to false once the reload completes to dismiss the spinner.
815 func setRefreshing(_ refreshing: Bool) {
816 if refreshing {
817 if !pullRefreshControl.isRefreshing {
818 pullRefreshControl.beginRefreshing()
819 }
820 } else if pullRefreshControl.isRefreshing {
821 pullRefreshControl.endRefreshing()
822 }
823 }
824
673 private func applyStyle() {825 private func applyStyle() {
674 contentView.style = ReviewDiffNativeStyle826 contentView.style = ReviewDiffNativeStyle
675 .resolve(stylePayload)827 .resolve(stylePayload)
@@ -686,6 +838,38 @@ public final class T3ReviewDiffView: ExpoView, UIScrollViewDelegate { · 10 unmodified lines
686 )838 )
687 contentView.verticalOffset = scrollView.contentOffset.y839 contentView.verticalOffset = scrollView.contentOffset.y
688 contentView.invalidateVisibleViewport()840 contentView.invalidateVisibleViewport()
841 emitVisibleFileIfNeeded()
842 }
843
844 private func applyPendingScrollIfNeeded() {
845 guard let fileId = pendingScrollFileId,
846 bounds.height > 0 else {
847 return
848 }
849
850 guard let headerOffset = contentView.fileHeaderOffset(forFileId: fileId) else {
851 if !rows.isEmpty {
852 pendingScrollFileId = nil
853 }
854 return
855 }
856
857 let animated = pendingScrollAnimated
858 pendingScrollFileId = nil
859 pendingScrollAnimated = false
860 setVerticalContentOffset(headerOffset, animated: animated)
861 }
862
863 private func setVerticalContentOffset(_ targetOffset: CGFloat, animated: Bool) {
864 let maxOffset = max(scrollView.contentSize.height - scrollView.bounds.height, 0)
865 let clampedOffset = min(max(targetOffset, 0), maxOffset)
866 let shouldAnimate = animated && abs(scrollView.contentOffset.y - clampedOffset) > 0.5
867 isProgrammaticScrollActive = shouldAnimate
868 contentView.isVerticalScrollActive = shouldAnimate
869 scrollView.setContentOffset(CGPoint(x: 0, y: clampedOffset), animated: shouldAnimate)
870 if !shouldAnimate {
871 updateViewportFrame()
872 }
689 }873 }
690 874
691 private func applyInitialRowIndexIfNeeded() {875 private func applyInitialRowIndexIfNeeded() {
@@ -1255,6 +1439,32 @@ private final class ReviewDiffContentView: UIView, UIGestureRecognizerDelegate { · 563 unmodified lines
1255 return rowOffsets[rowIndex]1439 return rowOffsets[rowIndex]
1256 }1440 }
1257 1441
1442 func visibleFileId(atVerticalOffset verticalOffset: CGFloat) -> String? {
1443 guard let firstHeaderRowIndex = fileHeaderRowIndices.first else {
1444 return nil
1445 }
1446
1447 var lowerBound = 0
1448 var upperBound = fileHeaderRowIndices.count
1449 while lowerBound < upperBound {
1450 let midpoint = (lowerBound + upperBound) / 2
1451 let rowIndex = fileHeaderRowIndices[midpoint]
1452 if rowOffsets[rowIndex] <= verticalOffset + 0.5 {
1453 lowerBound = midpoint + 1
1454 } else {
1455 upperBound = midpoint
1456 }
1457 }
1458
1459 let rowIndex = lowerBound > 0
1460 ? fileHeaderRowIndices[lowerBound - 1]
1461 : firstHeaderRowIndex
1462 guard rows.indices.contains(rowIndex) else {
1463 return nil
1464 }
1465 return resolvedFileId(for: rows[rowIndex])
1466 }
1467
1258 private func fileHeaderRowIndex(forFileId fileId: String) -> Int? {1468 private func fileHeaderRowIndex(forFileId fileId: String) -> Int? {
1259 fileHeaderRowIndices.first { rowIndex in1469 fileHeaderRowIndices.first { rowIndex in
1260 rows.indices.contains(rowIndex) && resolvedFileId(for: rows[rowIndex]) == fileId1470 rows.indices.contains(rowIndex) && resolvedFileId(for: rows[rowIndex]) == fileId
apps/mobile/modules/t3-terminal/android/src/main/java/expo/modules/t3terminal/T3TerminalModule.kt+10−0
@@ -7,6 +7,12 @@ class T3TerminalModule : Module() { · 6 unmodified lines
7 override fun definition() = ModuleDefinition {7 override fun definition() = ModuleDefinition {
8 Name("T3TerminalSurface")8 Name("T3TerminalSurface")
9 9
10 // Bumped when native hardware-keyboard handling changes; surfaced in the JS debug
11 // logs so a stale native binary is distinguishable from a broken key pipeline.
12 Constants(
13 "hardwareKeyRevision" to 2,
14 )
15
10 View(T3TerminalView::class) {16 View(T3TerminalView::class) {
11 Prop("terminalKey") { view: T3TerminalView, terminalKey: String ->17 Prop("terminalKey") { view: T3TerminalView, terminalKey: String ->
12 view.terminalKey = terminalKey18 view.terminalKey = terminalKey
@@ -20,6 +26,10 @@ class T3TerminalModule : Module() { · 7 unmodified lines
20 view.fontSize = fontSize.toFloat()26 view.fontSize = fontSize.toFloat()
21 }27 }
22 28
29 Prop("focusRequest") { view: T3TerminalView, focusRequest: Double ->
30 view.focusRequest = focusRequest
31 }
32
23 Prop("appearanceScheme") { view: T3TerminalView, appearanceScheme: String ->33 Prop("appearanceScheme") { view: T3TerminalView, appearanceScheme: String ->
24 view.appearanceScheme = appearanceScheme34 view.appearanceScheme = appearanceScheme
25 }35 }
apps/mobile/modules/t3-terminal/android/src/main/java/expo/modules/t3terminal/T3TerminalView.kt+19−2
@@ -62,6 +62,15 @@ class T3TerminalView(context: Context, appContext: AppContext) : ExpoView(contex · 61 unmodified lines
62 62
63 var themeConfig: String = ""63 var themeConfig: String = ""
64 64
65 var focusRequest: Double = 0.0
66 set(value) {
67 val previous = field
68 field = value
69 if (value != previous && value > 0) {
70 requestKeyboardFocus()
71 }
72 }
73
65 var backgroundColorHex: String = "#24292E"74 var backgroundColorHex: String = "#24292E"
66 set(value) {75 set(value) {
67 field = value76 field = value
@@ -113,11 +122,19 @@ class T3TerminalView(context: Context, appContext: AppContext) : ExpoView(contex · 45 unmodified lines
113 }122 }
114 inputView.setOnKeyListener { _, keyCode, event ->123 inputView.setOnKeyListener { _, keyCode, event ->
115 if (event.action != android.view.KeyEvent.ACTION_DOWN) return@setOnKeyListener false124 if (event.action != android.view.KeyEvent.ACTION_DOWN) return@setOnKeyListener false
116 when (keyCode) {125 when {
117 android.view.KeyEvent.KEYCODE_DEL -> {126 keyCode == android.view.KeyEvent.KEYCODE_DEL -> {
118 onInput(mapOf("data" to "\u007F"))127 onInput(mapOf("data" to "\u007F"))
119 true128 true
120 }129 }
130 // Hardware keyboard Ctrl+A..Z -> control bytes 0x01..0x1A (Ctrl+C, Ctrl+Z, ...).
131 event.isCtrlPressed &&
132 keyCode in android.view.KeyEvent.KEYCODE_A..android.view.KeyEvent.KEYCODE_Z -> {
133 onInput(
134 mapOf("data" to (keyCode - android.view.KeyEvent.KEYCODE_A + 1).toChar().toString()),
135 )
136 true
137 }
121 else -> false138 else -> false
122 }139 }
123 }140 }
apps/mobile/modules/t3-terminal/ios/T3TerminalModule.swift+10−0
@@ -4,6 +4,12 @@ public class T3TerminalModule: Module { · 3 unmodified lines
4 public func definition() -> ModuleDefinition {4 public func definition() -> ModuleDefinition {
5 Name("T3TerminalSurface")5 Name("T3TerminalSurface")
6 6
7 // Bumped when native hardware-keyboard handling changes; surfaced in the JS debug
8 // logs so a stale native binary is distinguishable from a broken key pipeline.
9 Constants([
10 "hardwareKeyRevision": 2,
11 ])
12
7 View(T3TerminalView.self) {13 View(T3TerminalView.self) {
8 Prop("terminalKey") { (view: T3TerminalView, terminalKey: String) in14 Prop("terminalKey") { (view: T3TerminalView, terminalKey: String) in
9 view.terminalKey = terminalKey15 view.terminalKey = terminalKey
@@ -17,6 +23,10 @@ public class T3TerminalModule: Module { · 7 unmodified lines
17 view.fontSize = CGFloat(fontSize)23 view.fontSize = CGFloat(fontSize)
18 }24 }
19 25
26 Prop("focusRequest") { (view: T3TerminalView, focusRequest: Double) in
27 view.focusRequest = focusRequest
28 }
29
20 Prop("appearanceScheme") { (view: T3TerminalView, appearanceScheme: String) in30 Prop("appearanceScheme") { (view: T3TerminalView, appearanceScheme: String) in
21 view.appearanceScheme = appearanceScheme31 view.appearanceScheme = appearanceScheme
22 }32 }
apps/mobile/modules/t3-terminal/ios/T3TerminalView.swift+128−0
@@ -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.
34private enum TerminalHardwareKeyEncoder {
35 /// Characters that produce a control byte when combined with Ctrl.
36 private static let controlInputs = "abcdefghijklmnopqrstuvwxyz@[\\]^_-? "
37
38 static func makeKeyCommands(action: Selector) -> [UIKeyCommand] {
39 var commands: [UIKeyCommand] = []
40
41 let specialInputs = [
42 UIKeyCommand.inputEscape,
43 UIKeyCommand.inputUpArrow,
44 UIKeyCommand.inputDownArrow,
45 UIKeyCommand.inputLeftArrow,
46 UIKeyCommand.inputRightArrow,
47 "\t",
48 ]
49 for input in specialInputs {
50 commands.append(makeCommand(input: input, modifierFlags: [], action: action))
51 }
52 commands.append(makeCommand(input: "\t", modifierFlags: .shift, action: action))
53
54 for character in controlInputs {
55 commands.append(makeCommand(input: String(character), modifierFlags: .control, action: action))
56 commands.append(
57 makeCommand(input: String(character), modifierFlags: [.control, .shift], action: action)
58 )
59 }
60
61 return commands
62 }
63
64 private static func makeCommand(
65 input: String,
66 modifierFlags: UIKeyModifierFlags,
67 action: Selector
68 ) -> UIKeyCommand {
69 let command = UIKeyCommand(input: input, modifierFlags: modifierFlags, action: action)
70 command.wantsPriorityOverSystemBehavior = true
71 return command
72 }
73
74 static func sequence(input: String, modifiers: UIKeyModifierFlags) -> String? {
75 switch input {
76 case UIKeyCommand.inputEscape:
77 return "\u{1B}"
78 case UIKeyCommand.inputUpArrow:
79 return "\u{1B}[A"
80 case UIKeyCommand.inputDownArrow:
81 return "\u{1B}[B"
82 case UIKeyCommand.inputRightArrow:
83 return "\u{1B}[C"
84 case UIKeyCommand.inputLeftArrow:
85 return "\u{1B}[D"
86 case "\t":
87 return modifiers.contains(.shift) ? "\u{1B}[Z" : "\t"
88 default:
89 break
90 }
91
92 guard modifiers.contains(.control) else { return nil }
93 guard let scalar = input.lowercased().unicodeScalars.first else { return nil }
94 return controlSequence(for: scalar)
95 }
96
97 private static func controlSequence(for scalar: Unicode.Scalar) -> String? {
98 switch scalar {
99 case "a"..."z":
100 // Ctrl+A..Z -> 0x01..0x1A (Ctrl+C = ETX, Ctrl+Z = SUB, ...).
101 return UnicodeScalar(scalar.value - 96).map(String.init)
102 case " ", "@":
103 return "\u{00}"
104 case "[":
105 return "\u{1B}"
106 case "\\":
107 return "\u{1C}"
108 case "]":
109 return "\u{1D}"
110 case "^":
111 return "\u{1E}"
112 case "_", "-":
113 return "\u{1F}"
114 case "?":
115 return "\u{7F}"
116 default:
117 return nil
118 }
119 }
120}
121
25private final class TerminalInputField: UITextField {122private final class TerminalInputField: UITextField {
26 var onDeleteBackward: (() -> Void)?123 var onDeleteBackward: (() -> Void)?
124 var onInsert: ((String) -> Void)?
125
126 private static let hardwareKeyCommands = TerminalHardwareKeyEncoder.makeKeyCommands(
127 action: #selector(handleHardwareKeyCommand(_:))
128 )
129
130 override var keyCommands: [UIKeyCommand]? {
131 Self.hardwareKeyCommands
132 }
27 133
28 override func deleteBackward() {134 override func deleteBackward() {
29 onDeleteBackward?()135 onDeleteBackward?()
30 super.deleteBackward()136 super.deleteBackward()
31 }137 }
138
139 @objc
140 private func handleHardwareKeyCommand(_ command: UIKeyCommand) {
141 guard let input = command.input else { return }
142 guard let sequence = TerminalHardwareKeyEncoder.sequence(
143 input: input,
144 modifiers: command.modifierFlags
145 ) else { return }
146 onInsert?(sequence)
147 }
32}148}
33 149
34private enum TerminalAppearanceScheme: String {150private 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 var appearanceScheme: String = TerminalAppearanceScheme.dark.rawValue {236 var appearanceScheme: String = TerminalAppearanceScheme.dark.rawValue {
112 didSet {237 didSet {
113 guard oldValue != appearanceScheme else { return }238 guard oldValue != appearanceScheme else { return }
@@ -167,6 +292,9 @@ public final class T3TerminalView: ExpoView, UITextFieldDelegate { · 53 unmodified lines
167 inputField.onDeleteBackward = { [weak self] in292 inputField.onDeleteBackward = { [weak self] in
168 self?.emitInput("\u{7F}")293 self?.emitInput("\u{7F}")
169 }294 }
295 inputField.onInsert = { [weak self] data in
296 self?.emitInput(data)
297 }
170 298
171 focusTapGesture.addTarget(self, action: #selector(handleViewportTap))299 focusTapGesture.addTarget(self, action: #selector(handleViewportTap))
172 terminalViewport.addGestureRecognizer(focusTapGesture)300 terminalViewport.addGestureRecognizer(focusTapGesture)
apps/mobile/package.json+31−20
@@ -5,7 +5,7 @@ · 4 unmodified lines
5 "main": "index.ts",5 "main": "index.ts",
6 "scripts": {6 "scripts": {
7 "dev": "expo start --clear",7 "dev": "expo start --clear",
8 "dev:client": "APP_VARIANT=development expo start --dev-client --clear",8 "dev:client": "APP_VARIANT=development expo start --dev-client --scheme t3code-dev --clear",
9 "start": "expo start",9 "start": "expo start",
10 "start:dev": "APP_VARIANT=development expo start",10 "start:dev": "APP_VARIANT=development expo start",
11 "start:preview": "APP_VARIANT=preview expo start",11 "start:preview": "APP_VARIANT=preview expo start",
@@ -43,12 +43,16 @@ · 31 unmodified lines
43 "@clerk/expo": "catalog:",43 "@clerk/expo": "catalog:",
44 "@effect/atom-react": "catalog:",44 "@effect/atom-react": "catalog:",
45 "@expo-google-fonts/dm-sans": "^0.4.2",45 "@expo-google-fonts/dm-sans": "^0.4.2",
46 "@expo/ui": "~56.0.8",46 "@expo/metro-runtime": "~56.0.15",
47 "@expo/ui": "~56.0.18",
47 "@legendapp/list": "3.2.0",48 "@legendapp/list": "3.2.0",
48 "@noble/curves": "catalog:",49 "@noble/curves": "catalog:",
49 "@noble/hashes": "catalog:",50 "@noble/hashes": "catalog:",
50 "@pierre/diffs": "catalog:",51 "@pierre/diffs": "catalog:",
51 "@react-native-menu/menu": "^2.0.0",52 "@react-native-menu/menu": "^2.0.0",
53 "@react-navigation/elements": "2.9.26",
54 "@react-navigation/native": "7.3.4",
55 "@react-navigation/native-stack": "7.17.6",
52 "@shikijs/core": "4.2.0",56 "@shikijs/core": "4.2.0",
53 "@shikijs/engine-javascript": "4.2.0",57 "@shikijs/engine-javascript": "4.2.0",
54 "@shikijs/langs": "4.2.0",58 "@shikijs/langs": "4.2.0",
@@ -62,38 +66,37 @@ · 7 unmodified lines
62 "clsx": "^2.1.1",66 "clsx": "^2.1.1",
63 "diff": "8.0.3",67 "diff": "8.0.3",
64 "effect": "catalog:",68 "effect": "catalog:",
65 "expo": "^56.0.0",69 "expo": "~56.0.12",
66 "expo-asset": "~56.0.15",70 "expo-asset": "~56.0.17",
67 "expo-auth-session": "~56.0.12",71 "expo-auth-session": "~56.0.14",
68 "expo-build-properties": "~56.0.15",72 "expo-build-properties": "~56.0.19",
69 "expo-camera": "~56.0.7",73 "expo-camera": "~56.0.8",
70 "expo-clipboard": "~56.0.3",74 "expo-clipboard": "~56.0.4",
71 "expo-constants": "~56.0.16",75 "expo-constants": "~56.0.18",
72 "expo-crypto": "~56.0.4",76 "expo-crypto": "~56.0.4",
73 "expo-dev-client": "~56.0.16",77 "expo-dev-client": "~56.0.20",
74 "expo-file-system": "~56.0.7",78 "expo-file-system": "~56.0.8",
75 "expo-font": "~56.0.5",79 "expo-font": "~56.0.7",
76 "expo-glass-effect": "~56.0.4",80 "expo-glass-effect": "~56.0.4",
77 "expo-haptics": "~56.0.3",81 "expo-haptics": "~56.0.3",
78 "expo-image-picker": "~56.0.14",82 "expo-image-picker": "~56.0.18",
79 "expo-linking": "~56.0.12",83 "expo-linking": "~56.0.14",
80 "expo-network": "~56.0.5",84 "expo-network": "~56.0.5",
81 "expo-notifications": "~56.0.14",85 "expo-notifications": "~56.0.18",
82 "expo-paste-input": "^0.1.15",86 "expo-paste-input": "^0.1.15",
83 "expo-router": "~56.2.7",
84 "expo-secure-store": "~56.0.4",87 "expo-secure-store": "~56.0.4",
85 "expo-splash-screen": "~56.0.10",88 "expo-splash-screen": "~56.0.10",
86 "expo-symbols": "~56.0.5",89 "expo-symbols": "~56.0.6",
87 "expo-updates": "~56.0.17",90 "expo-updates": "~56.0.19",
88 "expo-web-browser": "~56.0.5",91 "expo-web-browser": "~56.0.5",
89 "expo-widgets": "~56.0.15",92 "expo-widgets": "~56.0.19",
90 "punycode": "^2.3.1",93 "punycode": "^2.3.1",
91 "react": "19.2.3",94 "react": "19.2.3",
92 "react-dom": "19.2.3",95 "react-dom": "19.2.3",
93 "react-native": "0.85.3",96 "react-native": "0.85.3",
94 "react-native-gesture-handler": "~2.31.1",97 "react-native-gesture-handler": "~2.31.1",
95 "react-native-image-viewing": "^0.2.2",98 "react-native-image-viewing": "^0.2.2",
96 "react-native-keyboard-controller": "1.21.7",99 "react-native-keyboard-controller": "1.21.6",
97 "react-native-nitro-markdown": "^0.5.0",100 "react-native-nitro-markdown": "^0.5.0",
98 "react-native-nitro-modules": "0.35.9",101 "react-native-nitro-modules": "0.35.9",
99 "react-native-reanimated": "4.3.1",102 "react-native-reanimated": "4.3.1",
@@ -117,5 +120,13 @@ · 17 unmodified lines
117 },120 },
118 "overrides": {121 "overrides": {
119 "react-native-nitro-markdown": "file:deps/react-native-nitro-markdown-0.5.0.tgz"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}
apps/mobile/plugins/withIosCocoaPodsUuidCache.cjs+52−0
@@ -0,0 +1,52 @@
1const fs = require("node:fs");
2const path = require("node:path");
3
4const { withDangerousMod } = require("expo/config-plugins");
5
6const MARKER = "# t3code: repair cached CocoaPods UUID allocation before SPM integration";
7const UUID_REPAIR = `${MARKER}
8 pods_project = installer.pods_project
9 existing_uuids = pods_project.objects.map(&:uuid)
10 uuid_prefix = pods_project.instance_variable_get(:@uuid_prefix)[0, 6]
11 sequential_uuid = /\\A#{Regexp.escape(uuid_prefix)}([0-9A-F]{7})0\\z/
12 highest_index = existing_uuids.filter_map do |uuid|
13 match = sequential_uuid.match(uuid)
14 match && match[1].to_i(16)
15 end.max || -1
16
17 # Pod::Project generates sequential UUIDs without collision checks because CocoaPods
18 # normally creates the project in this process. EAS can restore Pods.xcodeproj from
19 # cache, leaving the allocator behind the loaded objects. React Native's SPM support
20 # then reuses an existing UUID and silently corrupts the project graph.
21 next_index = highest_index + 1
22 pods_project.instance_variable_set(:@generated_uuids, Array.new(next_index))
23 pods_project.instance_variable_set(:@available_uuids, [])
24 pods_project.generate_available_uuid_list(1_000)
25 Pod::UI.puts "T3Code: reset CocoaPods UUID allocator at #{next_index} (#{existing_uuids.length} existing objects)"
26`;
27
28module.exports = function withIosCocoaPodsUuidCache(config) {
29 return withDangerousMod(config, [
30 "ios",
31 (nextConfig) => {
32 const podfilePath = path.join(nextConfig.modRequest.platformProjectRoot, "Podfile");
33 const podfile = fs.readFileSync(podfilePath, "utf8");
34
35 if (podfile.includes(MARKER)) {
36 return nextConfig;
37 }
38
39 const postInstallStart = "post_install do |installer|\n";
40 if (!podfile.includes(postInstallStart)) {
41 throw new Error("Unable to repair CocoaPods UUID allocation: post_install is missing.");
42 }
43
44 fs.writeFileSync(
45 podfilePath,
46 podfile.replace(postInstallStart, `${postInstallStart}${UUID_REPAIR}`),
47 "utf8",
48 );
49 return nextConfig;
50 },
51 ]);
52};
apps/mobile/plugins/withIosSceneLifecycle.cjs+99−0
@@ -0,0 +1,99 @@
1const { withAppDelegate, withInfoPlist } = require("expo/config-plugins");
2
3const SCENE_DELEGATE = `
4
5class SceneDelegate: UIResponder, UIWindowSceneDelegate {
6 var window: UIWindow?
7
8 func scene(
9 _ scene: UIScene,
10 willConnectTo session: UISceneSession,
11 options connectionOptions: UIScene.ConnectionOptions
12 ) {
13 guard
14 let windowScene = scene as? UIWindowScene,
15 let appDelegate = UIApplication.shared.delegate as? AppDelegate
16 else {
17 return
18 }
19
20 let appWindow: UIWindow
21 if let existingWindow = appDelegate.window {
22 appWindow = existingWindow
23 } else {
24 appWindow = UIWindow(windowScene: windowScene)
25 appDelegate.window = appWindow
26 appDelegate.reactNativeFactory?.startReactNative(
27 withModuleName: "main",
28 in: appWindow,
29 launchOptions: nil)
30 }
31
32 window = appWindow
33 appWindow.windowScene = windowScene
34 appWindow.makeKeyAndVisible()
35
36 if let url = connectionOptions.urlContexts.first?.url {
37 _ = appDelegate.application(UIApplication.shared, open: url, options: [:])
38 }
39
40 if let userActivity = connectionOptions.userActivities.first {
41 _ = appDelegate.application(
42 UIApplication.shared,
43 continue: userActivity,
44 restorationHandler: { _ in })
45 }
46 }
47
48 func scene(_ scene: UIScene, openURLContexts URLContexts: Set<UIOpenURLContext>) {
49 guard
50 let url = URLContexts.first?.url,
51 let appDelegate = UIApplication.shared.delegate as? AppDelegate
52 else {
53 return
54 }
55
56 _ = appDelegate.application(UIApplication.shared, open: url, options: [:])
57 }
58
59 func scene(_ scene: UIScene, continue userActivity: NSUserActivity) {
60 guard let appDelegate = UIApplication.shared.delegate as? AppDelegate else {
61 return
62 }
63
64 _ = appDelegate.application(
65 UIApplication.shared,
66 continue: userActivity,
67 restorationHandler: { _ in })
68 }
69}`;
70
71module.exports = function withIosSceneLifecycle(config) {
72 config = withInfoPlist(config, (nextConfig) => {
73 nextConfig.modResults.UIApplicationSceneManifest = {
74 UIApplicationSupportsMultipleScenes: false,
75 UISceneConfigurations: {
76 UIWindowSceneSessionRoleApplication: [
77 {
78 UISceneConfigurationName: "Default Configuration",
79 UISceneDelegateClassName: "$(PRODUCT_MODULE_NAME).SceneDelegate",
80 },
81 ],
82 },
83 };
84
85 return nextConfig;
86 });
87
88 return withAppDelegate(config, (nextConfig) => {
89 if (nextConfig.modResults.language !== "swift") {
90 throw new Error("The iOS scene lifecycle plugin requires a Swift AppDelegate.");
91 }
92
93 if (!nextConfig.modResults.contents.includes("class SceneDelegate:")) {
94 nextConfig.modResults.contents += SCENE_DELEGATE;
95 }
96
97 return nextConfig;
98 });
99};
apps/mobile/src/App.tsx+72−0
@@ -0,0 +1,72 @@
1import {
2 DMSans_400Regular,
3 DMSans_500Medium,
4 DMSans_700Bold,
5 useFonts,
6} from "@expo-google-fonts/dm-sans";
7import * as Linking from "expo-linking";
8import * as SplashScreen from "expo-splash-screen";
9import { StatusBar, useColorScheme } from "react-native";
10import { GestureHandlerRootView } from "react-native-gesture-handler";
11import { KeyboardProvider } from "react-native-keyboard-controller";
12import { SafeAreaProvider } from "react-native-safe-area-context";
13import { createStaticNavigation, DarkTheme, DefaultTheme } from "@react-navigation/native";
14
15import { RegistryContext } from "@effect/atom-react";
16import { useEffect } from "react";
17import { CloudAuthProvider } from "./features/cloud/CloudAuthProvider";
18import { AppearancePreferencesProvider } from "./features/settings/appearance/AppearancePreferencesProvider";
19import { RootStack } from "./Stack";
20import { appAtomRegistry } from "./state/atom-registry";
21import { useThemeColor } from "./lib/useThemeColor";
22
23import "../global.css";
24
25const appLinking = {
26 prefixes: [Linking.createURL("/"), "t3code://", "t3code-dev://", "t3code-preview://"],
27};
28
29const Navigation = createStaticNavigation(RootStack);
30
31export default function App() {
32 const [fontsLoaded] = useFonts({
33 DMSans_400Regular,
34 DMSans_500Medium,
35 DMSans_700Bold,
36 });
37 const colorScheme = useColorScheme();
38 const statusBarBg = useThemeColor("--color-status-bar");
39
40 useEffect(() => {
41 if (fontsLoaded) SplashScreen.hide();
42 }, [fontsLoaded]);
43
44 return (
45 <RegistryContext.Provider value={appAtomRegistry}>
46 <CloudAuthProvider>
47 <AppearancePreferencesProvider>
48 <GestureHandlerRootView style={{ flex: 1 }}>
49 <KeyboardProvider statusBarTranslucent>
50 <SafeAreaProvider>
51 <StatusBar
52 barStyle={colorScheme === "dark" ? "light-content" : "dark-content"}
53 backgroundColor={statusBarBg}
54 translucent
55 />
56 {/* The navigation theme drives the NATIVE header appearance: native-stack
57 forwards `dark` as the nav bar's overrideUserInterfaceStyle. Without
58 this, React Navigation defaults to its light theme and every native
59 header (glass buttons, title, materials) is forced light even when
60 the system is in dark mode. */}
61 <Navigation
62 linking={appLinking}
63 theme={colorScheme === "dark" ? DarkTheme : DefaultTheme}
64 />
65 </SafeAreaProvider>
66 </KeyboardProvider>
67 </GestureHandlerRootView>
68 </AppearancePreferencesProvider>
69 </CloudAuthProvider>
70 </RegistryContext.Provider>
71 );
72}
apps/mobile/src/Stack.tsx+463−0
@@ -0,0 +1,463 @@
1import {
2 createPathConfigForStaticNavigation,
3 getPathFromState,
4 NavigationState,
5 StackActions,
6 useNavigation,
7} from "@react-navigation/native";
8import {
9 createNativeStackNavigator,
10 createNativeStackScreen,
11 type NativeStackNavigationOptions,
12} from "@react-navigation/native-stack";
13import { DynamicColorIOS, Platform, Pressable, ScrollView, StyleSheet } from "react-native";
14import { useResolveClassNames } from "uniwind";
15
16import { AppText as Text } from "./components/AppText";
17import { ArchivedThreadsRouteScreen } from "./features/archive/ArchivedThreadsRouteScreen";
18import { useAgentNotificationNavigation } from "./features/agent-awareness/notificationNavigation";
19import { ClerkSettingsSheetDetentProvider } from "./features/cloud/ClerkSettingsSheetDetent";
20import { ThreadFilesTreeScreen, ThreadFileScreen } from "./features/files/ThreadFilesRouteScreen";
21import { AdaptiveWorkspaceLayout } from "./features/layout/AdaptiveWorkspaceLayout";
22import { HardwareKeyboardCommandProvider } from "./features/keyboard/HardwareKeyboardCommandProvider";
23import { ReviewCommentComposerSheet } from "./features/review/ReviewCommentComposerSheet";
24import { ReviewSheet } from "./features/review/ReviewSheet";
25import { ThreadTerminalRouteScreen } from "./features/terminal/ThreadTerminalRouteScreen";
26import { GitBranchesSheet } from "./features/threads/git/GitBranchesSheet";
27import { GitCommitSheet } from "./features/threads/git/GitCommitSheet";
28import { GitConfirmSheet } from "./features/threads/git/GitConfirmSheet";
29import { GitOverviewSheet } from "./features/threads/git/GitOverviewSheet";
30import { ThreadRouteScreen } from "./features/threads/ThreadRouteScreen";
31import { ConnectionsRouteScreen } from "./features/connection/ConnectionsRouteScreen";
32import { ConnectionsNewRouteScreen } from "./features/connection/ConnectionsNewRouteScreen";
33import { HomeRouteScreen } from "./features/home/HomeRouteScreen";
34import { AddProjectDestinationRoute } from "./features/projects/AddProjectDestinationRoute";
35import { AddProjectLocalRoute } from "./features/projects/AddProjectLocalRoute";
36import { AddProjectRepositoryRoute } from "./features/projects/AddProjectRepositoryRoute";
37import { AddProjectSourceRoute } from "./features/projects/AddProjectSourceRoute";
38import { NewTaskDraftRouteScreen } from "./features/threads/NewTaskDraftRouteScreen";
39import { NewTaskFlowProvider } from "./features/threads/new-task-flow-provider";
40import { NewTaskRouteScreen } from "./features/threads/NewTaskRouteScreen";
41import { SettingsAppearanceRouteScreen } from "./features/settings/SettingsAppearanceRouteScreen";
42import { SettingsAuthRouteScreen } from "./features/settings/SettingsAuthRouteScreen";
43import { SettingsEnvironmentsRouteScreen } from "./features/settings/SettingsEnvironmentsRouteScreen";
44import { SettingsRouteScreen } from "./features/settings/SettingsRouteScreen";
45import { SettingsWaitlistRouteScreen } from "./features/settings/SettingsWaitlistRouteScreen";
46import { nativeHeaderScrollEdgeEffects } from "./native/StackHeader";
47import { useThreadOutboxDrain } from "./state/use-thread-outbox-drain";
48
49const HEADER_SCROLL_EDGE_EFFECTS = nativeHeaderScrollEdgeEffects(Platform.OS, Platform.Version);
50
51// Matches --color-sheet in global.css (light/dark). DynamicColorIOS lets the header
52// background stay STATIC config while still adapting to appearance changes.
53const SHEET_BACKGROUND_COLOR =
54 Platform.OS === "ios"
55 ? DynamicColorIOS({ light: "rgba(242, 242, 247, 0.98)", dark: "rgba(14, 14, 14, 0.98)" })
56 : undefined;
57
58type AppScreenOptions = NativeStackNavigationOptions & {
59 readonly unstable_navigationItemStyle?: "editor";
60};
61
62// Shared header presets. Screens only override genuinely dynamic values (titles,
63// subtitles, toolbar items, search callbacks) via NativeStackScreenOptions.
64//
65// GLASS: transparent header over the screen's primary scroll view, with the iOS 26
66// scroll-edge blur sampling the content (Home, Thread, Files tree, settings sheet).
67const GLASS_HEADER_OPTIONS: AppScreenOptions = {
68 headerBackButtonDisplayMode: "minimal",
69 headerBackTitle: "",
70 headerLargeTitle: false,
71 headerShadowVisible: false,
72 headerShown: true,
73 headerStyle: Platform.OS === "ios" ? { backgroundColor: "transparent" } : undefined,
74 headerTitleStyle: { fontSize: 18, fontWeight: "800" },
75 headerTransparent: Platform.OS === "ios",
76 scrollEdgeEffects: Platform.OS === "ios" ? HEADER_SCROLL_EDGE_EFFECTS : undefined,
77 unstable_navigationItemStyle: Platform.OS === "ios" ? "editor" : undefined,
78};
79
80// SOLID: opaque sheet-colored header for surfaces whose content scrolls internally
81// (file viewer, terminal, review) — there is nothing for glass to sample there.
82const SOLID_HEADER_OPTIONS: AppScreenOptions = {
83 headerBackButtonDisplayMode: "minimal",
84 headerBackTitle: "",
85 headerLargeTitle: false,
86 headerShadowVisible: false,
87 headerShown: true,
88 headerStyle:
89 SHEET_BACKGROUND_COLOR !== undefined
90 ? // native-stack types this as `string`, but the native side accepts any
91 // ColorValue including DynamicColorIOS.
92 { backgroundColor: SHEET_BACKGROUND_COLOR as unknown as string }
93 : undefined,
94 headerTitleStyle: { fontSize: 18, fontWeight: "800" },
95 headerTransparent: false,
96 unstable_navigationItemStyle: Platform.OS === "ios" ? "editor" : undefined,
97};
98
99// Solid header variant for screens inside sheets (centered title, no editor style).
100const SHEET_SOLID_HEADER_OPTIONS: AppScreenOptions = {
101 ...SOLID_HEADER_OPTIONS,
102 unstable_navigationItemStyle: undefined,
103};
104
105const SettingsSheetStack = createNativeStackNavigator({
106 initialRouteName: "Settings",
107 screenOptions: {
108 ...GLASS_HEADER_OPTIONS,
109 // Sheets read better with the iOS-default centered title (no editor style).
110 unstable_navigationItemStyle: undefined,
111 },
112 screens: {
113 Settings: createNativeStackScreen({
114 screen: SettingsRouteScreen,
115 linking: "",
116 options: {
117 title: "Settings",
118 },
119 }),
120 SettingsEnvironments: createNativeStackScreen({
121 screen: SettingsEnvironmentsRouteScreen,
122 linking: "environments",
123 options: {
124 title: "Environments",
125 },
126 }),
127 SettingsEnvironmentNew: createNativeStackScreen({
128 screen: ConnectionsNewRouteScreen,
129 linking: "environment-new",
130 options: {
131 title: "Add Environment",
132 },
133 }),
134 SettingsArchive: createNativeStackScreen({
135 screen: ArchivedThreadsRouteScreen,
136 linking: "archive",
137 options: {
138 title: "Archived Threads",
139 },
140 }),
141 SettingsAppearance: createNativeStackScreen({
142 screen: SettingsAppearanceRouteScreen,
143 linking: "appearance",
144 options: {
145 title: "Appearance",
146 },
147 }),
148 SettingsAuth: createNativeStackScreen({
149 screen: SettingsAuthRouteScreen,
150 linking: "auth",
151 options: {
152 title: "Sign in",
153 },
154 }),
155 SettingsWaitlist: createNativeStackScreen({
156 screen: SettingsWaitlistRouteScreen,
157 linking: "waitlist",
158 options: {
159 title: "Join the waitlist",
160 },
161 }),
162 },
163});
164
165// Thread routes live FLAT in the root stack (not in a nested navigator). A nested
166// stack means a second UINavigationController with its own UINavigationBar, which
167// breaks iOS 26's shared-header morphing between Home and Thread (each pair inside
168// one bar morphs; across two bars the whole screen slides). Flat linking paths keep
169// the same deep-link URLs the nested config produced.
170const THREAD_LINKING_PREFIX = "threads/:environmentId/:threadId";
171
172// New-task / add-project flow: nested navigator inside the formSheet (Settings-sheet
173// pattern — a plain formSheet screen cannot render a stack header; the header and
174// in-sheet pushes come from this nested stack).
175const NewTaskSheetStack = createNativeStackNavigator({
176 initialRouteName: "NewTask",
177 screenOptions: {
178 ...GLASS_HEADER_OPTIONS,
179 // Sheets read better with the iOS-default centered title (no editor style).
180 unstable_navigationItemStyle: undefined,
181 },
182 screens: {
183 NewTask: createNativeStackScreen({
184 screen: NewTaskRouteScreen,
185 linking: "",
186 options: {
187 title: "Choose project",
188 },
189 }),
190 NewTaskDraft: createNativeStackScreen({
191 screen: NewTaskDraftRouteScreen,
192 linking: "draft",
193 // The draft composer has no scroll view for glass to sample; a solid
194 // header also lays the content out below the bar (no manual inset).
195 options: SHEET_SOLID_HEADER_OPTIONS,
196 }),
197 AddProject: createNativeStackScreen({
198 screen: AddProjectSourceRoute,
199 linking: "add-project",
200 options: {
201 title: "Add Project",
202 },
203 }),
204 AddProjectRepository: createNativeStackScreen({
205 screen: AddProjectRepositoryRoute,
206 linking: "add-project/repository",
207 }),
208 AddProjectDestination: createNativeStackScreen({
209 screen: AddProjectDestinationRoute,
210 linking: "add-project/destination",
211 }),
212 AddProjectLocal: createNativeStackScreen({
213 screen: AddProjectLocalRoute,
214 linking: "add-project/local",
215 }),
216 },
217});
218
219// Routes presented as sheets/overlays ON TOP of the workspace. They must not
220// influence the adaptive workspace layout: opening Settings over Home should
221// not flip the sidebar in or change the active thread.
222const WORKSPACE_OVERLAY_ROUTES = new Set([
223 "Connections",
224 "ConnectionsNew",
225 "GitBranches",
226 "GitCommit",
227 "GitConfirm",
228 "GitOverview",
229 "NewTaskSheet",
230 "SettingsSheet",
231 "ThreadReviewComment",
232]);
233
234/**
235 * Pathname of the topmost NON-overlay route — the screen the workspace is
236 * actually "on", regardless of any sheets floating above it.
237 */
238function workspacePathFromState(state: NavigationState): string {
239 const routes = state.routes.filter((route) => !WORKSPACE_OVERLAY_ROUTES.has(route.name));
240 const effectiveState =
241 routes.length > 0 && routes.length !== state.routes.length
242 ? ({ ...state, routes, index: routes.length - 1 } as NavigationState)
243 : state;
244 const path = getPathFromState(effectiveState, navigationPathConfig);
245 return path.startsWith("/") ? path : `/${path}`;
246}
247
248function RootStackLayout(props: {
249 readonly children: React.ReactNode;
250 readonly state: NavigationState;
251}) {
252 useAgentNotificationNavigation();
253 useThreadOutboxDrain();
254 // Full pathname (sheets included) for keyboard-command scoping; the
255 // workspace layout only reacts to the underlying non-overlay route.
256 const path = getPathFromState(props.state, navigationPathConfig);
257 const pathname = path.startsWith("/") ? path : `/${path}`;
258 const workspacePathname = workspacePathFromState(props.state);
259
260 return (
261 <HardwareKeyboardCommandProvider pathname={pathname}>
262 <ClerkSettingsSheetDetentProvider initiallyExpanded={false}>
263 <AdaptiveWorkspaceLayout pathname={workspacePathname}>
264 {props.children}
265 </AdaptiveWorkspaceLayout>
266 </ClerkSettingsSheetDetentProvider>
267 </HardwareKeyboardCommandProvider>
268 );
269}
270
271function NotFoundScreen() {
272 const navigation = useNavigation();
273 const screenBgStyle = StyleSheet.flatten(useResolveClassNames("bg-screen"));
274 const primaryBgStyle = StyleSheet.flatten(useResolveClassNames("bg-primary"));
275 const returnHomeButtonStyle = StyleSheet.flatten([
276 {
277 borderRadius: 999,
278 paddingHorizontal: 20,
279 paddingVertical: 14,
280 },
281 primaryBgStyle,
282 ]);
283
284 return (
285 <ScrollView
286 contentInsetAdjustmentBehavior="automatic"
287 contentContainerStyle={{
288 flexGrow: 1,
289 alignItems: "center",
290 justifyContent: "center",
291 gap: 16,
292 paddingHorizontal: 24,
293 paddingVertical: 32,
294 }}
295 style={[{ flex: 1 }, screenBgStyle]}
296 >
297 <Text className="text-3xl font-t3-bold text-foreground" selectable>
298 Route not found
299 </Text>
300 <Pressable
301 style={returnHomeButtonStyle}
302 onPress={() => navigation.dispatch(StackActions.replace("Home"))}
303 >
304 <Text className="text-base font-t3-bold text-primary-foreground">Return home</Text>
305 </Pressable>
306 </ScrollView>
307 );
308}
309
310export const RootStack = createNativeStackNavigator({
311 initialRouteName: "Home",
312 layout: RootStackLayout,
313 screenOptions: {
314 headerShown: false,
315 },
316 screens: {
317 Home: createNativeStackScreen({
318 screen: HomeRouteScreen,
319 linking: "",
320 options: {
321 ...GLASS_HEADER_OPTIONS,
322 contentStyle: { backgroundColor: "transparent" },
323 headerBackVisible: false,
324 title: "Threads",
325 },
326 }),
327 Thread: createNativeStackScreen({
328 screen: ThreadRouteScreen,
329 linking: THREAD_LINKING_PREFIX,
330 options: GLASS_HEADER_OPTIONS,
331 }),
332 ThreadTerminal: createNativeStackScreen({
333 screen: ThreadTerminalRouteScreen,
334 linking: `${THREAD_LINKING_PREFIX}/terminal`,
335 options: SOLID_HEADER_OPTIONS,
336 }),
337 ThreadReview: createNativeStackScreen({
338 screen: ReviewSheet,
339 linking: `${THREAD_LINKING_PREFIX}/review`,
340 options: SOLID_HEADER_OPTIONS,
341 }),
342 ThreadReviewComment: createNativeStackScreen({
343 screen: ReviewCommentComposerSheet,
344 linking: `${THREAD_LINKING_PREFIX}/review-comment`,
345 options: {
346 presentation: "formSheet",
347 sheetAllowedDetents: [0.55, 0.92],
348 sheetGrabberVisible: true,
349 },
350 }),
351 ThreadFiles: createNativeStackScreen({
352 screen: ThreadFilesTreeScreen,
353 linking: `${THREAD_LINKING_PREFIX}/files`,
354 options: {
355 ...GLASS_HEADER_OPTIONS,
356 contentStyle:
357 SHEET_BACKGROUND_COLOR !== undefined
358 ? { backgroundColor: SHEET_BACKGROUND_COLOR }
359 : undefined,
360 title: "Files",
361 },
362 }),
363 ThreadFile: createNativeStackScreen({
364 screen: ThreadFileScreen,
365 linking: `${THREAD_LINKING_PREFIX}/files/:path*`,
366 options: SOLID_HEADER_OPTIONS,
367 }),
368 GitOverview: createNativeStackScreen({
369 screen: GitOverviewSheet,
370 linking: `${THREAD_LINKING_PREFIX}/git`,
371 options: {
372 presentation: "formSheet",
373 sheetAllowedDetents: [0.55, 0.92],
374 sheetGrabberVisible: true,
375 },
376 }),
377 GitCommit: createNativeStackScreen({
378 screen: GitCommitSheet,
379 linking: `${THREAD_LINKING_PREFIX}/git/commit`,
380 options: {
381 presentation: "formSheet",
382 sheetAllowedDetents: [0.55, 0.92],
383 sheetGrabberVisible: true,
384 },
385 }),
386 GitBranches: createNativeStackScreen({
387 screen: GitBranchesSheet,
388 linking: `${THREAD_LINKING_PREFIX}/git/branches`,
389 options: {
390 presentation: "formSheet",
391 sheetAllowedDetents: [0.55, 0.92],
392 sheetGrabberVisible: true,
393 },
394 }),
395 GitConfirm: createNativeStackScreen({
396 screen: GitConfirmSheet,
397 linking: `${THREAD_LINKING_PREFIX}/git-confirm`,
398 options: {
399 presentation: "formSheet",
400 sheetAllowedDetents: [0.45, 0.7],
401 sheetGrabberVisible: true,
402 },
403 }),
404 SettingsSheet: createNativeStackScreen({
405 screen: SettingsSheetStack,
406 linking: "settings",
407 options: {
408 gestureEnabled: true,
409 headerShown: false,
410 presentation: "formSheet",
411 sheetAllowedDetents: [0.7, 0.92],
412 sheetGrabberVisible: true,
413 },
414 }),
415 Connections: createNativeStackScreen({
416 screen: ConnectionsRouteScreen,
417 linking: "connections",
418 options: {
419 title: "Environments",
420 presentation: "formSheet",
421 sheetAllowedDetents: [0.55, 0.7],
422 sheetGrabberVisible: true,
423 },
424 }),
425 ConnectionsNew: createNativeStackScreen({
426 screen: ConnectionsNewRouteScreen,
427 linking: "connections/new",
428 options: {
429 presentation: "formSheet",
430 sheetAllowedDetents: [0.55, 0.7],
431 sheetGrabberVisible: true,
432 },
433 }),
434 NewTaskSheet: createNativeStackScreen({
435 screen: NewTaskSheetStack,
436 linking: "new",
437 // The whole new-task flow (choose project → draft → add project) shares
438 // draft state via NewTaskFlowProvider. The expo-router era mounted it in
439 // app/new/_layout.tsx; this layout wrapper is the native-stack equivalent.
440 layout: ({ children }) => <NewTaskFlowProvider>{children}</NewTaskFlowProvider>,
441 options: {
442 gestureEnabled: true,
443 headerShown: false,
444 presentation: "formSheet",
445 sheetAllowedDetents: [0.92],
446 sheetGrabberVisible: true,
447 },
448 }),
449 NotFound: createNativeStackScreen({
450 screen: NotFoundScreen,
451 linking: "*",
452 }),
453 },
454});
455type RootStackType = typeof RootStack;
456
457const navigationPathConfig = {
458 screens: createPathConfigForStaticNavigation(RootStack) ?? {},
459};
460
461declare module "@react-navigation/native" {
462 interface RootNavigator extends RootStackType {}
463}
apps/mobile/src/app/+not-found.tsx+0−43
@@ -1,43 +0,0 @@
1import { Link } from "expo-router";
2import { Pressable, ScrollView, StyleSheet } from "react-native";
3import { useResolveClassNames } from "uniwind";
4
5import { AppText as Text } from "../components/AppText";
6
7export default function NotFoundRoute() {
8 const screenBgStyle = StyleSheet.flatten(useResolveClassNames("bg-screen"));
9 const primaryBgStyle = StyleSheet.flatten(useResolveClassNames("bg-primary"));
10
11 return (
12 <ScrollView
13 contentInsetAdjustmentBehavior="automatic"
14 contentContainerStyle={{
15 flexGrow: 1,
16 alignItems: "center",
17 justifyContent: "center",
18 gap: 16,
19 paddingHorizontal: 24,
20 paddingVertical: 32,
21 }}
22 style={[{ flex: 1 }, screenBgStyle]}
23 >
24 <Text className="text-3xl font-t3-bold text-foreground" selectable>
25 Route not found
26 </Text>
27 <Link href="/" asChild>
28 <Pressable
29 style={[
30 {
31 borderRadius: 999,
32 paddingHorizontal: 20,
33 paddingVertical: 14,
34 },
35 primaryBgStyle,
36 ]}
37 >
38 <Text className="text-base font-t3-bold text-primary-foreground">Return home</Text>
39 </Pressable>
40 </Link>
41 </ScrollView>
42 );
43}
apps/mobile/src/app/_layout.tsx+0−149
@@ -1,149 +0,0 @@
1import "../../global.css";
2import {
3 DMSans_400Regular,
4 DMSans_500Medium,
5 DMSans_700Bold,
6 useFonts,
7} from "@expo-google-fonts/dm-sans";
8import { usePathname } from "expo-router";
9import Stack from "expo-router/stack";
10import { useCallback } from "react";
11import { StatusBar, useColorScheme } from "react-native";
12import { GestureHandlerRootView } from "react-native-gesture-handler";
13import { KeyboardProvider } from "react-native-keyboard-controller";
14import { SafeAreaProvider } from "react-native-safe-area-context";
15import { useResolveClassNames } from "uniwind";
16
17import { LoadingScreen } from "../components/LoadingScreen";
18
19import { useWorkspaceState } from "../state/workspace";
20import { useThreadOutboxDrain } from "../state/use-thread-outbox-drain";
21import { RegistryContext } from "@effect/atom-react";
22import { appAtomRegistry } from "../state/atom-registry";
23import { CloudAuthProvider } from "../features/cloud/CloudAuthProvider";
24import {
25 ClerkSettingsSheetDetentProvider,
26 useClerkSettingsSheetDetent,
27} from "../features/cloud/ClerkSettingsSheetDetent";
28import { useAgentNotificationNavigation } from "../features/agent-awareness/notificationNavigation";
29import { useThemeColor } from "../lib/useThemeColor";
30
31function AppNavigator() {
32 const pathname = usePathname();
33 const expandedSettingsRouteIsActive =
34 pathname === "/settings/archive" || pathname === "/settings/auth";
35
36 return (
37 <ClerkSettingsSheetDetentProvider initiallyExpanded={expandedSettingsRouteIsActive}>
38 <AppNavigatorContent />
39 </ClerkSettingsSheetDetentProvider>
40 );
41}
42
43function AppNavigatorContent() {
44 const { state } = useWorkspaceState();
45 const { collapse, isExpanded } = useClerkSettingsSheetDetent();
46 const colorScheme = useColorScheme();
47 const statusBarBg = useThemeColor("--color-status-bar");
48 const sheetStyle = useResolveClassNames("bg-sheet");
49 useAgentNotificationNavigation();
50 useThreadOutboxDrain();
51
52 const handleSettingsTransitionEnd = useCallback(
53 (event: { data: { closing: boolean } }) => {
54 if (event.data.closing) {
55 collapse();
56 }
57 },
58 [collapse],
59 );
60
61 const newTaskScreenOptions = {
62 contentStyle: sheetStyle,
63 gestureEnabled: true,
64 headerShown: false,
65 presentation: "formSheet" as const,
66 sheetAllowedDetents: [0.92],
67 sheetGrabberVisible: true,
68 };
69
70 const connectionSheetScreenOptions = {
71 contentStyle: sheetStyle,
72 gestureEnabled: true,
73 headerShown: false,
74 presentation: "formSheet" as const,
75 sheetAllowedDetents: [0.55, 0.7],
76 sheetGrabberVisible: true,
77 };
78
79 const settingsSheetScreenOptions = {
80 ...connectionSheetScreenOptions,
81 sheetAllowedDetents: isExpanded ? [0.92] : [0.7],
82 };
83
84 if (state.isLoadingConnections) {
85 return <LoadingScreen message="Loading remote workspace…" />;
86 }
87
88 return (
89 <>
90 <StatusBar
91 barStyle={colorScheme === "dark" ? "light-content" : "dark-content"}
92 backgroundColor={String(statusBarBg)}
93 translucent
94 />
95 <Stack screenOptions={{ headerShown: false }}>
96 <Stack.Screen
97 name="index"
98 options={{
99 contentStyle: { backgroundColor: "transparent" },
100 headerShown: true,
101 headerTransparent: true,
102 headerShadowVisible: false,
103 }}
104 />
105 <Stack.Screen
106 name="settings"
107 listeners={{ transitionEnd: handleSettingsTransitionEnd }}
108 options={settingsSheetScreenOptions}
109 />
110 <Stack.Screen name="connections" options={connectionSheetScreenOptions} />
111 <Stack.Screen name="new" options={newTaskScreenOptions} />
112 <Stack.Screen
113 name="threads/[environmentId]/[threadId]"
114 options={{
115 animation: "slide_from_right",
116 contentStyle: { backgroundColor: "transparent" },
117 gestureEnabled: true,
118 headerShown: false,
119 }}
120 />
121 </Stack>
122 </>
123 );
124}
125
126export default function RootLayout() {
127 const [fontsLoaded] = useFonts({
128 DMSans_400Regular,
129 DMSans_500Medium,
130 DMSans_700Bold,
131 });
132 return (
133 <RegistryContext.Provider value={appAtomRegistry}>
134 <CloudAuthProvider>
135 <GestureHandlerRootView style={{ flex: 1 }}>
136 <KeyboardProvider statusBarTranslucent>
137 <SafeAreaProvider>
138 {fontsLoaded ? (
139 <AppNavigator />
140 ) : (
141 <LoadingScreen message="Loading remote workspace…" />
142 )}
143 </SafeAreaProvider>
144 </KeyboardProvider>
145 </GestureHandlerRootView>
146 </CloudAuthProvider>
147 </RegistryContext.Provider>
148 );
149}
apps/mobile/src/app/connections/_layout.tsx+0−29
@@ -1,29 +0,0 @@
1import Stack from "expo-router/stack";
2import { useResolveClassNames } from "uniwind";
3import { useThemeColor } from "../../lib/useThemeColor";
4
5export const unstable_settings = {
6 anchor: "index",
7};
8
9export default function ConnectionsLayout() {
10 const contentStyle = useResolveClassNames("bg-sheet");
11 const connSheetBg = useThemeColor("--color-sheet");
12 const headerTint = useThemeColor("--color-foreground");
13
14 return (
15 <Stack
16 screenOptions={{
17 contentStyle,
18 headerStyle: { backgroundColor: connSheetBg },
19 headerTintColor: headerTint,
20 headerTitleStyle: { fontFamily: "DMSans_700Bold" },
21 headerShadowVisible: false,
22 headerBackButtonDisplayMode: "minimal",
23 }}
24 >
25 <Stack.Screen name="index" options={{ animation: "none" }} />
26 <Stack.Screen name="new" options={{ animation: "slide_from_right" }} />
27 </Stack>
28 );
29}
apps/mobile/src/app/index.tsx+0−130
@@ -1,130 +0,0 @@
1import type {
2 EnvironmentId,
3 SidebarProjectGroupingMode,
4 SidebarThreadSortOrder,
5} from "@t3tools/contracts";
6import {
7 DEFAULT_SIDEBAR_PROJECT_GROUPING_MODE,
8 DEFAULT_SIDEBAR_PROJECT_SORT_ORDER,
9 DEFAULT_SIDEBAR_THREAD_SORT_ORDER,
10} from "@t3tools/contracts";
11import * as Arr from "effect/Array";
12import * as Order from "effect/Order";
13import { useRouter } from "expo-router";
14import { useCallback, useMemo, useState } from "react";
15
16import { useProjects, useThreadShells } from "../state/entities";
17import { useWorkspaceState } from "../state/workspace";
18import { buildThreadRoutePath } from "../lib/routes";
19import { useSavedRemoteConnections } from "../state/use-remote-environment-registry";
20import { HomeScreen } from "../features/home/HomeScreen";
21import { HomeHeader } from "../features/home/HomeHeader";
22import type { HomeProjectSortOrder } from "../features/home/homeThreadList";
23import { useThreadListActions } from "../features/home/useThreadListActions";
24
25interface HomeListOptions {
26 readonly selectedEnvironmentId: EnvironmentId | null;
27 readonly projectSortOrder: HomeProjectSortOrder;
28 readonly threadSortOrder: SidebarThreadSortOrder;
29 readonly projectGroupingMode: SidebarProjectGroupingMode;
30}
31
32/* ─── Route screen ───────────────────────────────────────────────────── */
33
34export default function HomeRouteScreen() {
35 const projects = useProjects();
36 const threads = useThreadShells();
37 const { state: catalogState } = useWorkspaceState();
38 const { savedConnectionsById } = useSavedRemoteConnections();
39 const router = useRouter();
40 const [searchQuery, setSearchQuery] = useState("");
41 const [listOptions, setListOptions] = useState<HomeListOptions>({
42 selectedEnvironmentId: null,
43 projectSortOrder:
44 DEFAULT_SIDEBAR_PROJECT_SORT_ORDER === "manual"
45 ? "updated_at"
46 : DEFAULT_SIDEBAR_PROJECT_SORT_ORDER,
47 threadSortOrder: DEFAULT_SIDEBAR_THREAD_SORT_ORDER,
48 projectGroupingMode: DEFAULT_SIDEBAR_PROJECT_GROUPING_MODE,
49 });
50 const { archiveThread, confirmDeleteThread } = useThreadListActions();
51 const environments = useMemo(
52 () =>
53 Arr.sort(
54 Object.values(savedConnectionsById).map((connection) => ({
55 environmentId: connection.environmentId,
56 label: connection.environmentLabel,
57 })),
58 Order.mapInput(
59 Order.String,
60 (environment: { readonly label: string }) => environment.label,
61 ),
62 ),
63 [savedConnectionsById],
64 );
65 const selectedEnvironmentId = environments.some(
66 (environment) => environment.environmentId === listOptions.selectedEnvironmentId,
67 )
68 ? listOptions.selectedEnvironmentId
69 : null;
70 const setSelectedEnvironmentId = useCallback((environmentId: EnvironmentId | null) => {
71 setListOptions((current) => ({ ...current, selectedEnvironmentId: environmentId }));
72 }, []);
73 const setProjectSortOrder = useCallback((projectSortOrder: HomeProjectSortOrder) => {
74 setListOptions((current) => ({ ...current, projectSortOrder }));
75 }, []);
76 const setThreadSortOrder = useCallback((threadSortOrder: SidebarThreadSortOrder) => {
77 setListOptions((current) => ({ ...current, threadSortOrder }));
78 }, []);
79 const setProjectGroupingMode = useCallback((projectGroupingMode: SidebarProjectGroupingMode) => {
80 setListOptions((current) => ({ ...current, projectGroupingMode }));
81 }, []);
82
83 return (
84 <>
85 <HomeHeader
86 environments={environments}
87 selectedEnvironmentId={selectedEnvironmentId}
88 projectSortOrder={listOptions.projectSortOrder}
89 threadSortOrder={listOptions.threadSortOrder}
90 projectGroupingMode={listOptions.projectGroupingMode}
91 onEnvironmentChange={setSelectedEnvironmentId}
92 onOpenSettings={() => router.push("/settings")}
93 onProjectGroupingModeChange={setProjectGroupingMode}
94 onProjectSortOrderChange={setProjectSortOrder}
95 onSearchQueryChange={setSearchQuery}
96 onStartNewTask={() => router.push("/new")}
97 onThreadSortOrderChange={setThreadSortOrder}
98 />
99
100 <HomeScreen
101 catalogState={catalogState}
102 onAddConnection={() => router.push("/connections/new")}
103 onArchiveThread={archiveThread}
104 onDeleteThread={confirmDeleteThread}
105 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}
apps/mobile/src/app/new/_layout.tsx+0−50
@@ -1,50 +0,0 @@
1import Stack from "expo-router/stack";
2import { useResolveClassNames } from "uniwind";
3
4import { NewTaskFlowProvider } from "../../features/threads/new-task-flow-provider";
5import { useThemeColor } from "../../lib/useThemeColor";
6
7export const unstable_settings = {
8 anchor: "index",
9};
10
11export default function NewTaskLayout() {
12 const sheetStyle = useResolveClassNames("bg-sheet");
13 const sheetBg = useThemeColor("--color-sheet");
14 const headerTint = useThemeColor("--color-foreground");
15
16 return (
17 <NewTaskFlowProvider>
18 <Stack
19 screenOptions={{
20 contentStyle: sheetStyle,
21 headerBackButtonDisplayMode: "minimal",
22 headerLargeTitle: false,
23 headerShadowVisible: false,
24 headerStyle: { backgroundColor: sheetBg },
25 headerTintColor: headerTint,
26 headerTitleStyle: { fontFamily: "DMSans_700Bold" },
27 }}
28 >
29 <Stack.Screen name="index" options={{ animation: "none", title: "Choose project" }} />
30 <Stack.Screen
31 name="add-project/index"
32 options={{ animation: "slide_from_right", title: "New project" }}
33 />
34 <Stack.Screen
35 name="add-project/repository"
36 options={{ animation: "slide_from_right", title: "Repository" }}
37 />
38 <Stack.Screen
39 name="add-project/destination"
40 options={{ animation: "slide_from_right", title: "Clone destination" }}
41 />
42 <Stack.Screen
43 name="add-project/local"
44 options={{ animation: "slide_from_right", title: "Local folder" }}
45 />
46 <Stack.Screen name="draft" options={{ animation: "slide_from_right", title: "New task" }} />
47 </Stack>
48 </NewTaskFlowProvider>
49 );
50}
apps/mobile/src/app/new/add-project/destination.tsx+0−5
@@ -1,5 +0,0 @@
1import { AddProjectDestinationScreen } from "../../../features/projects/AddProjectScreen";
2
3export default function AddProjectDestinationRoute() {
4 return <AddProjectDestinationScreen />;
5}
apps/mobile/src/app/new/add-project/index.tsx+0−5
@@ -1,5 +0,0 @@
1import { AddProjectSourceScreen } from "../../../features/projects/AddProjectScreen";
2
3export default function AddProjectRoute() {
4 return <AddProjectSourceScreen />;
5}
apps/mobile/src/app/new/add-project/local.tsx+0−5
@@ -1,5 +0,0 @@
1import { AddProjectLocalFolderScreen } from "../../../features/projects/AddProjectScreen";
2
3export default function AddProjectLocalRoute() {
4 return <AddProjectLocalFolderScreen />;
5}
apps/mobile/src/app/new/add-project/repository.tsx+0−23
@@ -1,23 +0,0 @@
1import { Stack, useLocalSearchParams } from "expo-router";
2import { addProjectRemoteSourceLabel } from "@t3tools/client-runtime/operations/projects";
3
4import { AddProjectRepositoryScreen } from "../../../features/projects/AddProjectScreen";
5
6export default function AddProjectRepositoryRoute() {
7 const params = useLocalSearchParams<{ source?: string | string[] }>();
8 const source = Array.isArray(params.source) ? params.source[0] : params.source;
9 const title =
10 source === "github" ||
11 source === "gitlab" ||
12 source === "bitbucket" ||
13 source === "azure-devops"
14 ? addProjectRemoteSourceLabel(source)
15 : "Git URL";
16
17 return (
18 <>
19 <Stack.Screen options={{ title }} />
20 <AddProjectRepositoryScreen />
21 </>
22 );
23}
apps/mobile/src/app/new/draft.tsx+0−29
@@ -1,29 +0,0 @@
1import { Stack, useLocalSearchParams } from "expo-router";
2
3import { NewTaskDraftScreen } from "../../features/threads/NewTaskDraftScreen";
4
5export default function NewTaskDraftRoute() {
6 const params = useLocalSearchParams<{
7 environmentId?: string | string[];
8 projectId?: string | string[];
9 title?: string | string[];
10 }>();
11
12 return (
13 <>
14 <Stack.Screen
15 options={{
16 title: Array.isArray(params.title) ? params.title[0] : (params.title ?? "New task"),
17 }}
18 />
19 <NewTaskDraftScreen
20 initialProjectRef={{
21 environmentId: Array.isArray(params.environmentId)
22 ? params.environmentId[0]
23 : params.environmentId,
24 projectId: Array.isArray(params.projectId) ? params.projectId[0] : params.projectId,
25 }}
26 />
27 </>
28 );
29}
apps/mobile/src/app/settings/_layout.tsx+0−62
@@ -1,62 +0,0 @@
1import Stack from "expo-router/stack";
2import { useCallback } from "react";
3import { useResolveClassNames } from "uniwind";
4
5import { useClerkSettingsSheetDetent } from "../../features/cloud/ClerkSettingsSheetDetent";
6import { useThemeColor } from "../../lib/useThemeColor";
7
8export const unstable_settings = {
9 anchor: "index",
10};
11
12export default function SettingsLayout() {
13 const { collapse } = useClerkSettingsSheetDetent();
14 const contentStyle = useResolveClassNames("bg-sheet");
15 const sheetBg = useThemeColor("--color-sheet");
16 const headerTint = useThemeColor("--color-foreground");
17 const handleExpandedRouteTransitionEnd = useCallback(
18 (event: { data: { closing: boolean } }) => {
19 if (event.data.closing) {
20 collapse();
21 }
22 },
23 [collapse],
24 );
25
26 return (
27 <Stack
28 screenOptions={{
29 contentStyle,
30 headerBackButtonDisplayMode: "minimal",
31 headerShadowVisible: false,
32 headerStyle: { backgroundColor: sheetBg },
33 headerTintColor: headerTint,
34 headerTitleStyle: { fontFamily: "DMSans_700Bold" },
35 }}
36 >
37 <Stack.Screen name="index" options={{ animation: "none", title: "Settings" }} />
38 <Stack.Screen
39 name="environments"
40 options={{ animation: "slide_from_right", title: "Environments" }}
41 />
42 <Stack.Screen
43 name="environment-new"
44 options={{ animation: "slide_from_right", title: "Add Environment" }}
45 />
46 <Stack.Screen
47 name="waitlist"
48 options={{ animation: "slide_from_right", title: "Join the waitlist" }}
49 />
50 <Stack.Screen
51 name="archive"
52 listeners={{ transitionEnd: handleExpandedRouteTransitionEnd }}
53 options={{ animation: "slide_from_right", title: "Archived Threads" }}
54 />
55 <Stack.Screen
56 name="auth"
57 listeners={{ transitionEnd: handleExpandedRouteTransitionEnd }}
58 options={{ animation: "slide_from_right", title: "Sign in" }}
59 />
60 </Stack>
61 );
62}
apps/mobile/src/app/settings/archive.tsx+0−3
@@ -1,3 +0,0 @@
1import { ArchivedThreadsRouteScreen } from "../../features/archive/ArchivedThreadsRouteScreen";
2
3export default ArchivedThreadsRouteScreen;
apps/mobile/src/app/settings/auth.tsx+0−33
@@ -1,33 +0,0 @@
1import { useAuth } from "@clerk/expo";
2import { AuthView, UserProfileView } from "@clerk/expo/native";
3import { Redirect, Stack } from "expo-router";
4import { View } from "react-native";
5
6import { hasCloudPublicConfig } from "../../features/cloud/publicConfig";
7
8export default function SettingsAuthRouteScreen() {
9 return hasCloudPublicConfig() ? (
10 <ConfiguredSettingsAuthRouteScreen />
11 ) : (
12 <Redirect href="/settings" />
13 );
14}
15
16function ConfiguredSettingsAuthRouteScreen() {
17 const { isLoaded, isSignedIn } = useAuth({ treatPendingAsSignedOut: false });
18
19 return (
20 <>
21 <Stack.Screen options={{ title: isSignedIn ? "Account" : "Sign in" }} />
22 <View collapsable={false} className="flex-1 overflow-hidden bg-sheet">
23 {isLoaded ? (
24 isSignedIn ? (
25 <UserProfileView isDismissible={false} />
26 ) : (
27 <AuthView isDismissible={false} />
28 )
29 ) : null}
30 </View>
31 </>
32 );
33}
apps/mobile/src/app/settings/environment-new.tsx+0−1
@@ -1 +0,0 @@
1export { default } from "../connections/new";
apps/mobile/src/app/threads/[environmentId]/[threadId]/_layout.tsx+0−109
@@ -1,109 +0,0 @@
1import Stack from "expo-router/stack";
2import { StyleSheet } from "react-native";
3import { useResolveClassNames } from "uniwind";
4
5export default function ThreadLayout() {
6 const sheetStyle = StyleSheet.flatten(useResolveClassNames("bg-sheet"));
7 const headerBg = {
8 backgroundColor: (sheetStyle as { backgroundColor?: string })?.backgroundColor,
9 };
10
11 return (
12 <Stack screenOptions={{ headerShown: false }}>
13 <Stack.Screen
14 name="index"
15 options={{
16 contentStyle: { backgroundColor: "transparent" },
17 headerShown: true,
18 headerTransparent: true,
19 headerShadowVisible: false,
20 headerTitle: "",
21 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}
apps/mobile/src/app/threads/[environmentId]/[threadId]/files/[...path].tsx+0−5
@@ -1,5 +0,0 @@
1import { ThreadFileScreen } from "../../../../../features/files/ThreadFilesRouteScreen";
2
3export default function ThreadFileRoute() {
4 return <ThreadFileScreen />;
5}
apps/mobile/src/app/threads/[environmentId]/[threadId]/files/index.tsx+0−5
@@ -1,5 +0,0 @@
1import { ThreadFilesTreeScreen } from "../../../../../features/files/ThreadFilesRouteScreen";
2
3export default function ThreadFilesIndexRoute() {
4 return <ThreadFilesTreeScreen />;
5}
apps/mobile/src/app/threads/[environmentId]/[threadId]/git-confirm.tsx+0−5
@@ -1,5 +0,0 @@
1import { GitConfirmSheet } from "../../../../features/threads/git/GitConfirmSheet";
2
3export default function GitConfirmRoute() {
4 return <GitConfirmSheet />;
5}
apps/mobile/src/app/threads/[environmentId]/[threadId]/git/_layout.tsx+0−58
@@ -1,58 +0,0 @@
1import Stack from "expo-router/stack";
2import { StyleSheet } from "react-native";
3import { useResolveClassNames } from "uniwind";
4
5export const unstable_settings = {
6 anchor: "index",
7};
8
9export default function GitSheetLayout() {
10 const sheetStyle = StyleSheet.flatten(useResolveClassNames("bg-sheet"));
11 const headerBg = {
12 backgroundColor: (sheetStyle as { backgroundColor?: string })?.backgroundColor,
13 };
14
15 return (
16 <Stack
17 screenOptions={{
18 contentStyle: sheetStyle,
19 headerShown: false,
20 }}
21 >
22 <Stack.Screen name="index" options={{ animation: "none" }} />
23 <Stack.Screen
24 name="commit"
25 options={{
26 animation: "slide_from_right",
27 headerShown: true,
28 headerTitle: "Commit changes",
29 headerBackTitle: "",
30 headerShadowVisible: false,
31 headerStyle: headerBg,
32 }}
33 />
34 <Stack.Screen
35 name="branches"
36 options={{
37 animation: "slide_from_right",
38 headerShown: true,
39 headerTitle: "Branches & worktrees",
40 headerBackTitle: "",
41 headerShadowVisible: false,
42 headerStyle: headerBg,
43 }}
44 />
45 <Stack.Screen
46 name="review"
47 options={{
48 animation: "slide_from_right",
49 headerShown: true,
50 headerTitle: "Review changes",
51 headerBackTitle: "",
52 headerShadowVisible: false,
53 headerStyle: headerBg,
54 }}
55 />
56 </Stack>
57 );
58}
apps/mobile/src/app/threads/[environmentId]/[threadId]/git/branches.tsx+0−5
@@ -1,5 +0,0 @@
1import { GitBranchesSheet } from "../../../../../features/threads/git/GitBranchesSheet";
2
3export default function GitBranchesRoute() {
4 return <GitBranchesSheet />;
5}
apps/mobile/src/app/threads/[environmentId]/[threadId]/git/commit.tsx+0−5
@@ -1,5 +0,0 @@
1import { GitCommitSheet } from "../../../../../features/threads/git/GitCommitSheet";
2
3export default function GitCommitRoute() {
4 return <GitCommitSheet />;
5}
apps/mobile/src/app/threads/[environmentId]/[threadId]/git/index.tsx+0−5
@@ -1,5 +0,0 @@
1import { GitOverviewSheet } from "../../../../../features/threads/git/GitOverviewSheet";
2
3export default function GitRoute() {
4 return <GitOverviewSheet />;
5}
apps/mobile/src/app/threads/[environmentId]/[threadId]/git/review.tsx+0−18
@@ -1,18 +0,0 @@
1import { Redirect, useLocalSearchParams } from "expo-router";
2import type { EnvironmentId, ThreadId } from "@t3tools/contracts";
3
4export default function ReviewRoute() {
5 const { environmentId, threadId } = useLocalSearchParams<{
6 environmentId: EnvironmentId;
7 threadId: ThreadId;
8 }>();
9
10 return (
11 <Redirect
12 href={{
13 pathname: "/threads/[environmentId]/[threadId]/review",
14 params: { environmentId, threadId },
15 }}
16 />
17 );
18}
apps/mobile/src/app/threads/[environmentId]/[threadId]/index.tsx+0−5
@@ -1,5 +0,0 @@
1import { ThreadRouteScreen } from "../../../../features/threads/ThreadRouteScreen";
2
3export default function ThreadRoute() {
4 return <ThreadRouteScreen />;
5}
apps/mobile/src/app/threads/[environmentId]/[threadId]/review-comment.tsx+0−5
@@ -1,5 +0,0 @@
1import { ReviewCommentComposerSheet } from "../../../../features/review/ReviewCommentComposerSheet";
2
3export default function ReviewCommentRoute() {
4 return <ReviewCommentComposerSheet />;
5}
apps/mobile/src/app/threads/[environmentId]/[threadId]/review.tsx+0−10
@@ -1,10 +0,0 @@
1import { ReviewHighlighterProvider } from "../../../../features/review/ReviewHighlighterProvider";
2import { ReviewSheet } from "../../../../features/review/ReviewSheet";
3
4export default function ReviewRoute() {
5 return (
6 <ReviewHighlighterProvider>
7 <ReviewSheet />
8 </ReviewHighlighterProvider>
9 );
10}
apps/mobile/src/app/threads/[environmentId]/[threadId]/terminal.tsx+0−5
@@ -1,5 +0,0 @@
1import { ThreadTerminalRouteScreen } from "../../../../features/terminal/ThreadTerminalRouteScreen";
2
3export default function ThreadTerminalRoute() {
4 return <ThreadTerminalRouteScreen />;
5}
apps/mobile/src/components/AppText.tsx+2−3
@@ -1,4 +1,3 @@
1import type { Ref } from "react";
2import {1import {
3 Text as RNText,2 Text as RNText,
4 TextInput as RNTextInput,3 TextInput as RNTextInput,
@@ -21,7 +20,7 @@ export function AppText({ className, ...props }: AppTextProps) { · 16 unmodified lines
21 20
22export type AppTextInputProps = RNTextInputProps & {21export type AppTextInputProps = RNTextInputProps & {
23 readonly className?: string;22 readonly className?: string;
24 readonly ref?: Ref<RNTextInput>;23 readonly ref?: React.Ref<RNTextInput>;
25};24};
26 25
27/**26/**
@@ -40,7 +39,7 @@ export function AppTextInput({ · 12 unmodified lines
40 <RNTextInput39 <RNTextInput
41 ref={ref}40 ref={ref}
42 className={cn(41 className={cn(
43 "min-h-[54px] rounded-2xl border border-input-border bg-input px-3.5 py-3 font-sans text-base text-foreground",42 "min-h-13.5 rounded-2xl border border-input-border bg-input px-3.5 py-3 font-sans text-base text-foreground",
44 className,43 className,
45 )}44 )}
46 placeholderTextColor={placeholderTextColor ?? placeholderColor}45 placeholderTextColor={placeholderTextColor ?? placeholderColor}
apps/mobile/src/components/ControlPill.tsx+1−1
@@ -75,7 +75,7 @@ export function ControlPill(props: { · 74 unmodified lines
75}75}
76 76
77export function ControlPillMenu(77export function ControlPillMenu(
78 props: Omit<ComponentProps<typeof MenuView>, "children" | "style" | "themeVariant"> & {78 props: Omit<ComponentProps<typeof MenuView>, "children" | "themeVariant"> & {
79 readonly children: ReactNode;79 readonly children: ReactNode;
80 },80 },
81) {81) {
apps/mobile/src/components/EmptyState.tsx+23−1
@@ -7,11 +7,33 @@ export function EmptyState(props: { · 6 unmodified lines
7 readonly detail: string;7 readonly detail: string;
8 readonly actionLabel?: string;8 readonly actionLabel?: string;
9 readonly onAction?: () => void;9 readonly onAction?: () => void;
10 readonly variant?: "card" | "plain";
10}) {11}) {
12 if (props.variant === "plain") {
13 return (
14 <View className="items-center px-8 py-8">
15 <Text className="text-center text-xl font-t3-bold text-foreground">{props.title}</Text>
16 <Text className="mt-2 text-center font-sans text-base leading-normal text-foreground-muted">
17 {props.detail}
18 </Text>
19 {props.actionLabel && props.onAction ? (
20 <Pressable
21 className="mt-5 rounded-full bg-primary px-5 py-3 active:opacity-70"
22 onPress={props.onAction}
23 >
24 <Text className="text-sm font-t3-bold text-primary-foreground">
25 {props.actionLabel}
26 </Text>
27 </Pressable>
28 ) : null}
29 </View>
30 );
31 }
32
11 return (33 return (
12 <View className="rounded-[22px] border border-border bg-card p-5">34 <View className="rounded-[22px] border border-border bg-card p-5">
13 <Text className="font-t3-bold text-lg text-foreground">{props.title}</Text>35 <Text className="font-t3-bold text-lg text-foreground">{props.title}</Text>
14 <Text className="mt-2 font-sans text-sm leading-[21px] text-foreground-muted">36 <Text className="mt-2 font-sans text-sm leading-relaxed text-foreground-muted">
15 {props.detail}37 {props.detail}
16 </Text>38 </Text>
17 {props.actionLabel && props.onAction ? (39 {props.actionLabel && props.onAction ? (
apps/mobile/src/components/ErrorBanner.tsx+1−1
@@ -4,7 +4,7 @@ import { AppText as Text } from "./AppText"; · 3 unmodified lines
4export function ErrorBanner(props: { readonly message: string }) {4export function ErrorBanner(props: { readonly message: string }) {
5 return (5 return (
6 <View className="rounded-2xl border border-rose-300/70 bg-rose-100/80 px-3.5 py-3 dark:border-rose-400/28 dark:bg-rose-500/12">6 <View className="rounded-2xl border border-rose-300/70 bg-rose-100/80 px-3.5 py-3 dark:border-rose-400/28 dark:bg-rose-500/12">
7 <Text className="font-t3-medium text-sm leading-[18px] text-rose-700 dark:text-rose-300">7 <Text className="font-t3-medium text-sm text-rose-700 dark:text-rose-300">
8 {props.message}8 {props.message}
9 </Text>9 </Text>
10 </View>10 </View>
apps/mobile/src/features/agent-awareness/notificationNavigation.ts+4−4
@@ -1,12 +1,12 @@
1import { useEffect, useRef } from "react";1import { useEffect, useRef } from "react";
2import * as Notifications from "expo-notifications";2import * as Notifications from "expo-notifications";
3import { useRouter } from "expo-router";3import { useLinkTo } from "@react-navigation/native";
4 4
5import { routeAgentNotificationResponseOnce } from "./notificationPayload";5import { routeAgentNotificationResponseOnce } from "./notificationPayload";
6import { consumeLastAgentNotificationResponse } from "./notificationResponseConsumer";6import { consumeLastAgentNotificationResponse } from "./notificationResponseConsumer";
7 7
8export function useAgentNotificationNavigation(): void {8export function useAgentNotificationNavigation(): void {
9 const router = useRouter();9 const linkTo = useLinkTo();
10 const handledResponseIds = useRef(new Set<string>());10 const handledResponseIds = useRef(new Set<string>());
11 11
12 useEffect(() => {12 useEffect(() => {
@@ -14,7 +14,7 @@ export function useAgentNotificationNavigation(): void { · 1 unmodified line
14 routeAgentNotificationResponseOnce({14 routeAgentNotificationResponseOnce({
15 handledResponseIds: handledResponseIds.current,15 handledResponseIds: handledResponseIds.current,
16 response,16 response,
17 navigate: (deepLink) => router.push(deepLink as never),17 navigate: linkTo,
18 });18 });
19 };19 };
20 20
@@ -28,5 +28,5 @@ export function useAgentNotificationNavigation(): void { · 7 unmodified lines
28 return () => {28 return () => {
29 subscription.remove();29 subscription.remove();
30 };30 };
31 }, [router]);31 }, [linkTo]);
32}32}
apps/mobile/src/features/archive/ArchivedThreadsRouteScreen.tsx+1−1
@@ -1,7 +1,7 @@
1import type { EnvironmentId } from "@t3tools/contracts";1import type { EnvironmentId } from "@t3tools/contracts";
2import * as Arr from "effect/Array";2import * as Arr from "effect/Array";
3import * as Order from "effect/Order";3import * as Order from "effect/Order";
4import { useFocusEffect } from "expo-router";4import { useFocusEffect } from "@react-navigation/native";
5import { useCallback, useMemo, useState } from "react";5import { useCallback, useMemo, useState } from "react";
6 6
7import { useSavedRemoteConnections } from "../../state/use-remote-environment-registry";7import { useSavedRemoteConnections } from "../../state/use-remote-environment-registry";
apps/mobile/src/features/archive/ArchivedThreadsScreen.tsx+361−245
@@ -2,23 +2,22 @@ import type { · 1 unmodified line
2 EnvironmentProject,2 EnvironmentProject,
3 EnvironmentThreadShell,3 EnvironmentThreadShell,
4} from "@t3tools/client-runtime/state/shell";4} from "@t3tools/client-runtime/state/shell";
5import { LegendList } from "@legendapp/list/react-native";
5import type { EnvironmentId } from "@t3tools/contracts";6import type { EnvironmentId } from "@t3tools/contracts";
6import type { MenuAction } from "@react-native-menu/menu";7import type { MenuAction } from "@react-native-menu/menu";
7import * as Haptics from "expo-haptics";8import { NativeHeaderToolbar, NativeStackScreenOptions } from "../../native/StackHeader";
8import { Stack } from "expo-router";
9import { SymbolView } from "expo-symbols";9import { SymbolView } from "expo-symbols";
10import { useCallback, useRef } from "react";10import { useCallback, useMemo, useRef, type ComponentProps } from "react";
11import {11import {
12 ActivityIndicator,12 ActivityIndicator,
13 Platform,
13 Pressable,14 Pressable,
14 RefreshControl,15 RefreshControl,
15 ScrollView,
16 useWindowDimensions,16 useWindowDimensions,
17 View,17 View,
18} from "react-native";18} from "react-native";
19import ReanimatedSwipeable, {19import { Gesture, GestureDetector } from "react-native-gesture-handler";
20 type SwipeableMethods,20import type { SwipeableMethods } from "react-native-gesture-handler/ReanimatedSwipeable";
21} from "react-native-gesture-handler/ReanimatedSwipeable";
22 21
23import { AppText as Text } from "../../components/AppText";22import { AppText as Text } from "../../components/AppText";
24import { ControlPillMenu } from "../../components/ControlPill";23import { ControlPillMenu } from "../../components/ControlPill";
@@ -26,11 +25,8 @@ import { EmptyState } from "../../components/EmptyState"; · 1 unmodified line
26import { ProjectFavicon } from "../../components/ProjectFavicon";25import { ProjectFavicon } from "../../components/ProjectFavicon";
27import { relativeTime } from "../../lib/time";26import { relativeTime } from "../../lib/time";
28import { useThemeColor } from "../../lib/useThemeColor";27import { useThemeColor } from "../../lib/useThemeColor";
29import {28import { ThreadSwipeable } from "../home/thread-swipe-actions";
30 THREAD_SWIPE_ACTIONS_WIDTH,29import { createNativeMailSearchToolbarItem } from "../layout/native-mail-search-toolbar";
31 THREAD_SWIPE_SPRING,
32 ThreadSwipeActions,
33} from "../home/thread-swipe-actions";
34import type { ArchivedThreadGroup, ArchivedThreadSortOrder } from "./archivedThreadList";30import type { ArchivedThreadGroup, ArchivedThreadSortOrder } from "./archivedThreadList";
35 31
36export interface ArchivedThreadsHeaderEnvironment {32export interface ArchivedThreadsHeaderEnvironment {
@@ -52,84 +48,185 @@ const THREAD_ACTIONS: MenuAction[] = [ · 15 unmodified lines
52 },48 },
53];49];
54 50
51type ArchivedThreadListItem =
52 | {
53 readonly kind: "project";
54 readonly key: string;
55 readonly environmentLabel: string | null;
56 readonly project: EnvironmentProject;
57 }
58 | {
59 readonly kind: "thread";
60 readonly key: string;
61 readonly environmentLabel: string | null;
62 readonly isFirst: boolean;
63 readonly isLast: boolean;
64 readonly thread: EnvironmentThreadShell;
65 };
66
55function ArchivedThreadsHeader(props: {67function ArchivedThreadsHeader(props: {
56 readonly environments: ReadonlyArray<ArchivedThreadsHeaderEnvironment>;68 readonly environments: ReadonlyArray<ArchivedThreadsHeaderEnvironment>;
57 readonly selectedEnvironmentId: EnvironmentId | null;69 readonly selectedEnvironmentId: EnvironmentId | null;
58 readonly sortOrder: ArchivedThreadSortOrder;70 readonly sortOrder: ArchivedThreadSortOrder;
59 readonly onEnvironmentChange: (environmentId: EnvironmentId | null) => void;71 readonly onEnvironmentChange: (environmentId: EnvironmentId | null) => void;
72 readonly onRefresh: () => void;
60 readonly onSearchQueryChange: (query: string) => void;73 readonly onSearchQueryChange: (query: string) => void;
61 readonly onSortOrderChange: (sortOrder: ArchivedThreadSortOrder) => void;74 readonly onSortOrderChange: (sortOrder: ArchivedThreadSortOrder) => void;
62}) {75}) {
76 const { width } = useWindowDimensions();
63 const hasCustomFilter = props.selectedEnvironmentId !== null || props.sortOrder !== "newest";77 const hasCustomFilter = props.selectedEnvironmentId !== null || props.sortOrder !== "newest";
78 const usesNativeChrome = Platform.OS === "ios";
79 const usesCompactMailToolbar = Platform.OS === "ios" && width < 700;
80 const archiveFilterMenu = {
81 title: "Archived thread options",
82 items: [
83 {
84 type: "submenu" as const,
85 title: "Environment",
86 items: [
87 {
88 type: "action" as const,
89 title: "All environments",
90 state: props.selectedEnvironmentId === null ? ("on" as const) : ("off" as const),
91 onPress: () => props.onEnvironmentChange(null),
92 },
93 ...props.environments.map((environment) => ({
94 type: "action" as const,
95 title: environment.label,
96 state:
97 props.selectedEnvironmentId === environment.environmentId
98 ? ("on" as const)
99 : ("off" as const),
100 onPress: () => props.onEnvironmentChange(environment.environmentId),
101 })),
102 ],
103 },
104 {
105 type: "submenu" as const,
106 title: "Sort by archived date",
107 items: [
108 {
109 type: "action" as const,
110 title: "Newest first",
111 state: props.sortOrder === "newest" ? ("on" as const) : ("off" as const),
112 onPress: () => props.onSortOrderChange("newest"),
113 },
114 {
115 type: "action" as const,
116 title: "Oldest first",
117 state: props.sortOrder === "oldest" ? ("on" as const) : ("off" as const),
118 onPress: () => props.onSortOrderChange("oldest"),
119 },
120 ],
121 },
122 ],
123 };
64 124
65 return (125 return (
66 <>126 <>
67 <Stack.Screen127 {/* Static header config (glass preset + title) lives in Stack.tsx; only
128 dynamic toolbar/search wiring is set here. */}
129 <NativeStackScreenOptions
68 options={{130 options={{
69 title: "Archived Threads",131 unstable_headerToolbarItems: usesCompactMailToolbar
70 headerSearchBarOptions: {132 ? () => [
71 autoCapitalize: "none",133 createNativeMailSearchToolbarItem({
72 hideNavigationBar: false,134 composeButtonId: "archived-refresh",
73 obscureBackground: false,135 composeSystemImageName: "arrow.clockwise",
74 placeholder: "Search archived threads",136 filterMenu: archiveFilterMenu,
75 placement: "stacked",137 filterButtonId: "archived-filter",
76 onChangeText: (event) => {138 filterSystemImageName: hasCustomFilter
77 props.onSearchQueryChange(event.nativeEvent.text);139 ? "line.3.horizontal.decrease.circle.fill"
78 },140 : "line.3.horizontal.decrease",
79 onCancelButtonPress: () => {141 onComposePress: props.onRefresh,
80 props.onSearchQueryChange("");142 onSearchTextChange: props.onSearchQueryChange,
81 },143 placeholder: "Search",
82 },144 searchTextChangeId: "archived-search-text",
145 }),
146 ]
147 : undefined,
148 headerSearchBarOptions: usesCompactMailToolbar
149 ? undefined
150 : {
151 ...(usesNativeChrome
152 ? {
153 allowToolbarIntegration: true,
154 placement: "integratedButton" as const,
155 }
156 : {
157 placement: "stacked" as const,
158 }),
159 autoCapitalize: "none",
160 hideNavigationBar: false,
161 obscureBackground: false,
162 placeholder: "Search archived threads",
163 onChangeText: (event) => {
164 props.onSearchQueryChange(event.nativeEvent.text);
165 },
166 onCancelButtonPress: () => {
167 props.onSearchQueryChange("");
168 },
169 },
83 }}170 }}
84 />171 />
85 172
86 <Stack.Toolbar placement="right">173 {usesCompactMailToolbar ? null : (
87 <Stack.Toolbar.Menu174 <NativeHeaderToolbar placement="right">
88 accessibilityLabel="Filter and sort archived threads"175 {usesNativeChrome ? (
89 icon={176 <NativeHeaderToolbar.Button
90 hasCustomFilter177 accessibilityLabel="Refresh archived threads"
91 ? "line.3.horizontal.decrease.circle.fill"178 icon="arrow.clockwise"
92 : "line.3.horizontal.decrease.circle"179 onPress={props.onRefresh}
93 }180 separateBackground
94 separateBackground181 />
95 title="Archived thread options"182 ) : null}
96 >183 <NativeHeaderToolbar.Menu
97 <Stack.Toolbar.Menu title="Environment">184 accessibilityLabel="Filter and sort archived threads"
98 <Stack.Toolbar.Label>Environment</Stack.Toolbar.Label>185 icon={
99 <Stack.Toolbar.MenuAction186 hasCustomFilter
100 isOn={props.selectedEnvironmentId === null}187 ? "line.3.horizontal.decrease.circle.fill"
101 onPress={() => props.onEnvironmentChange(null)}188 : "line.3.horizontal.decrease.circle"
102 >189 }
103 <Stack.Toolbar.Label>All environments</Stack.Toolbar.Label>190 separateBackground
104 </Stack.Toolbar.MenuAction>191 title="Archived thread options"
105 {props.environments.map((environment) => (192 >
106 <Stack.Toolbar.MenuAction193 <NativeHeaderToolbar.Menu title="Environment">
107 key={environment.environmentId}194 <NativeHeaderToolbar.Label>Environment</NativeHeaderToolbar.Label>
108 isOn={props.selectedEnvironmentId === environment.environmentId}195 <NativeHeaderToolbar.MenuAction
109 onPress={() => props.onEnvironmentChange(environment.environmentId)}196 isOn={props.selectedEnvironmentId === null}
197 onPress={() => props.onEnvironmentChange(null)}
110 >198 >
111 <Stack.Toolbar.Label>{environment.label}</Stack.Toolbar.Label>199 <NativeHeaderToolbar.Label>All environments</NativeHeaderToolbar.Label>
112 </Stack.Toolbar.MenuAction>200 </NativeHeaderToolbar.MenuAction>
113 ))}201 {props.environments.map((environment) => (
114 </Stack.Toolbar.Menu>202 <NativeHeaderToolbar.MenuAction
203 key={environment.environmentId}
204 isOn={props.selectedEnvironmentId === environment.environmentId}
205 onPress={() => props.onEnvironmentChange(environment.environmentId)}
206 >
207 <NativeHeaderToolbar.Label>{environment.label}</NativeHeaderToolbar.Label>
208 </NativeHeaderToolbar.MenuAction>
209 ))}
210 </NativeHeaderToolbar.Menu>
115 211
116 <Stack.Toolbar.Menu title="Sort by archived date">212 <NativeHeaderToolbar.Menu title="Sort by archived date">
117 <Stack.Toolbar.Label>Sort by archived date</Stack.Toolbar.Label>213 <NativeHeaderToolbar.Label>Sort by archived date</NativeHeaderToolbar.Label>
118 <Stack.Toolbar.MenuAction214 <NativeHeaderToolbar.MenuAction
119 isOn={props.sortOrder === "newest"}215 isOn={props.sortOrder === "newest"}
120 onPress={() => props.onSortOrderChange("newest")}216 onPress={() => props.onSortOrderChange("newest")}
121 >217 >
122 <Stack.Toolbar.Label>Newest first</Stack.Toolbar.Label>218 <NativeHeaderToolbar.Label>Newest first</NativeHeaderToolbar.Label>
123 </Stack.Toolbar.MenuAction>219 </NativeHeaderToolbar.MenuAction>
124 <Stack.Toolbar.MenuAction220 <NativeHeaderToolbar.MenuAction
125 isOn={props.sortOrder === "oldest"}221 isOn={props.sortOrder === "oldest"}
126 onPress={() => props.onSortOrderChange("oldest")}222 onPress={() => props.onSortOrderChange("oldest")}
127 >223 >
128 <Stack.Toolbar.Label>Oldest first</Stack.Toolbar.Label>224 <NativeHeaderToolbar.Label>Oldest first</NativeHeaderToolbar.Label>
129 </Stack.Toolbar.MenuAction>225 </NativeHeaderToolbar.MenuAction>
130 </Stack.Toolbar.Menu>226 </NativeHeaderToolbar.Menu>
131 </Stack.Toolbar.Menu>227 </NativeHeaderToolbar.Menu>
132 </Stack.Toolbar>228 </NativeHeaderToolbar>
229 )}
133 </>230 </>
134 );231 );
135}232}
@@ -164,30 +261,25 @@ function ProjectGroupLabel(props: { · 28 unmodified lines
164 261
165function ArchivedThreadRow(props: {262function ArchivedThreadRow(props: {
166 readonly environmentLabel: string | null;263 readonly environmentLabel: string | null;
264 readonly isFirst: boolean;
167 readonly isLast: boolean;265 readonly isLast: boolean;
168 readonly onDelete: () => void;266 readonly onDelete: () => void;
169 readonly onSwipeableClose: (methods: SwipeableMethods) => void;267 readonly onSwipeableClose: (methods: SwipeableMethods) => void;
170 readonly onSwipeableWillOpen: (methods: SwipeableMethods) => void;268 readonly onSwipeableWillOpen: (methods: SwipeableMethods) => void;
269 readonly simultaneousSwipeGesture?: ComponentProps<
270 typeof ThreadSwipeable
271 >["simultaneousWithExternalGesture"];
171 readonly onUnarchive: () => void;272 readonly onUnarchive: () => void;
172 readonly thread: EnvironmentThreadShell;273 readonly thread: EnvironmentThreadShell;
173}) {274}) {
174 const swipeableRef = useRef<SwipeableMethods | null>(null);
175 const fullSwipeArmedRef = useRef(false);
176 const { width: windowWidth } = useWindowDimensions();275 const { width: windowWidth } = useWindowDimensions();
177 const cardColor = useThemeColor("--color-card");276 const cardColor = useThemeColor("--color-card");
178 const iconColor = useThemeColor("--color-icon-subtle");277 const iconColor = useThemeColor("--color-icon-subtle");
179 const separatorColor = useThemeColor("--color-separator");278 const separatorColor = useThemeColor("--color-separator");
180 const fullSwipeThreshold = Math.max(THREAD_SWIPE_ACTIONS_WIDTH + 44, (windowWidth - 32) * 0.58);
181 const timestamp = relativeTime(props.thread.archivedAt ?? props.thread.updatedAt);279 const timestamp = relativeTime(props.thread.archivedAt ?? props.thread.updatedAt);
182 const subtitle = [props.environmentLabel, props.thread.branch].filter((part): part is string =>280 const subtitle = [props.environmentLabel, props.thread.branch].filter((part): part is string =>
183 Boolean(part),281 Boolean(part),
184 );282 );
185 const handleFullSwipeArmedChange = useCallback((armed: boolean) => {
186 if (armed && !fullSwipeArmedRef.current && process.env.EXPO_OS === "ios") {
187 void Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Medium);
188 }
189 fullSwipeArmedRef.current = armed;
190 }, []);
191 const handleMenuAction = useCallback(283 const handleMenuAction = useCallback(
192 (event: { nativeEvent: { event: string } }) => {284 (event: { nativeEvent: { event: string } }) => {
193 if (event.nativeEvent.event === "unarchive") {285 if (event.nativeEvent.event === "unarchive") {
@@ -200,114 +292,84 @@ function ArchivedThreadRow(props: { · 6 unmodified lines
200 );292 );
201 293
202 return (294 return (
203 <ReanimatedSwipeable295 <ThreadSwipeable
204 ref={swipeableRef}296 backgroundColor={cardColor}
205 animationOptions={THREAD_SWIPE_SPRING}297 fullSwipeWidth={windowWidth - 32}
206 childrenContainerStyle={{ backgroundColor: cardColor }}298 onDelete={props.onDelete}
207 containerStyle={{ backgroundColor: cardColor }}299 onSwipeableClose={props.onSwipeableClose}
208 dragOffsetFromRightEdge={8}300 onSwipeableWillOpen={props.onSwipeableWillOpen}
209 enableTrackpadTwoFingerGesture301 primaryAction={{
210 friction={1}302 accessibilityLabel: `Unarchive ${props.thread.title}`,
211 onSwipeableClose={() => {303 icon: "arrow.uturn.backward",
212 fullSwipeArmedRef.current = false;304 label: "Unarchive",
213 if (swipeableRef.current) {305 onPress: props.onUnarchive,
214 props.onSwipeableClose(swipeableRef.current);
215 }
216 }}
217 onSwipeableOpenStartDrag={() => {
218 if (swipeableRef.current) {
219 props.onSwipeableWillOpen(swipeableRef.current);
220 }
221 }}306 }}
222 onSwipeableWillOpen={() => {307 simultaneousWithExternalGesture={props.simultaneousSwipeGesture}
223 const methods = swipeableRef.current;308 threadTitle={props.thread.title}
224 if (!methods) return;
225
226 props.onSwipeableWillOpen(methods);
227 if (fullSwipeArmedRef.current) {
228 fullSwipeArmedRef.current = false;
229 methods.close();
230 props.onDelete();
231 }
232 }}
233 overshootFriction={1}
234 overshootRight
235 renderRightActions={(_progress, translation, methods) => (
236 <ThreadSwipeActions
237 backgroundColor={cardColor}
238 fullSwipeThreshold={fullSwipeThreshold}
239 onDelete={props.onDelete}
240 onFullSwipeArmedChange={handleFullSwipeArmedChange}
241 primaryAction={{
242 accessibilityLabel: `Unarchive ${props.thread.title}`,
243 icon: "arrow.uturn.backward",
244 label: "Unarchive",
245 onPress: props.onUnarchive,
246 }}
247 swipeableMethods={methods}
248 threadTitle={props.thread.title}
249 translation={translation}
250 />
251 )}
252 rightThreshold={THREAD_SWIPE_ACTIONS_WIDTH * 0.42}
253 >309 >
254 <View310 {() => (
255 className="flex-row items-center gap-3 bg-card px-4 py-3"311 <View
256 style={{312 className="flex-row items-center gap-3 bg-card px-4 py-3"
257 borderBottomColor: separatorColor,313 style={{
258 borderBottomWidth: props.isLast ? 0 : 1,314 borderBottomColor: separatorColor,
259 }}315 borderBottomWidth: props.isLast ? 0 : 1,
260 >316 borderTopLeftRadius: props.isFirst ? 20 : 0,
261 <View className="h-[34px] w-[34px] items-center justify-center rounded-[11px] bg-subtle">317 borderTopRightRadius: props.isFirst ? 20 : 0,
262 <SymbolView name="archivebox.fill" size={15} tintColor={iconColor} type="monochrome" />318 borderBottomLeftRadius: props.isLast ? 20 : 0,
263 </View>319 borderBottomRightRadius: props.isLast ? 20 : 0,
264 320 }}
265 <View className="min-w-0 flex-1 gap-1">321 >
266 <View className="flex-row items-center gap-2">322 <View className="h-[34px] w-[34px] items-center justify-center rounded-[11px] bg-subtle">
267 <Text323 <SymbolView name="archivebox.fill" size={15} tintColor={iconColor} type="monochrome" />
268 className="min-w-0 flex-1 text-base font-t3-bold leading-[20px] text-foreground"
269 numberOfLines={1}
270 >
271 {props.thread.title}
272 </Text>
273 <Text
274 className="text-xs text-foreground-tertiary"
275 style={{ fontVariant: ["tabular-nums"], minWidth: 30, textAlign: "right" }}
276 >
277 {timestamp}
278 </Text>
279 </View>324 </View>
280 {subtitle.length > 0 ? (325
281 <View className="flex-row items-center gap-1.5">326 <View className="min-w-0 flex-1 gap-1">
282 <SymbolView327 <View className="flex-row items-center gap-2">
283 name="arrow.triangle.branch"
284 size={10}
285 tintColor={iconColor}
286 type="monochrome"
287 />
288 <Text328 <Text
289 className="min-w-0 flex-1 text-2xs text-foreground-tertiary"329 className="min-w-0 flex-1 text-base font-t3-bold leading-snug text-foreground"
290 numberOfLines={1}330 numberOfLines={1}
291 style={{ fontFamily: "monospace" }}
292 >331 >
293 {subtitle.join(" · ")}332 {props.thread.title}
333 </Text>
334 <Text
335 className="text-xs text-foreground-tertiary"
336 style={{ fontVariant: ["tabular-nums"], minWidth: 30, textAlign: "right" }}
337 >
338 {timestamp}
294 </Text>339 </Text>
295 </View>340 </View>
296 ) : null}341 {subtitle.length > 0 ? (
297 </View>342 <View className="flex-row items-center gap-1.5">
343 <SymbolView
344 name="arrow.triangle.branch"
345 size={10}
346 tintColor={iconColor}
347 type="monochrome"
348 />
349 <Text
350 className="min-w-0 flex-1 text-2xs text-foreground-tertiary"
351 numberOfLines={1}
352 style={{ fontFamily: "monospace" }}
353 >
354 {subtitle.join(" · ")}
355 </Text>
356 </View>
357 ) : null}
358 </View>
298 359
299 <ControlPillMenu actions={THREAD_ACTIONS} onPressAction={handleMenuAction}>360 <ControlPillMenu actions={THREAD_ACTIONS} onPressAction={handleMenuAction}>
300 <Pressable361 <Pressable
301 accessibilityLabel={`Actions for ${props.thread.title}`}362 accessibilityLabel={`Actions for ${props.thread.title}`}
302 accessibilityRole="button"363 accessibilityRole="button"
303 className="h-8 w-8 items-center justify-center rounded-full active:bg-subtle"364 className="h-8 w-8 items-center justify-center rounded-full active:bg-subtle"
304 hitSlop={6}365 hitSlop={6}
305 >366 >
306 <SymbolView name="ellipsis" size={16} tintColor={iconColor} type="monochrome" />367 <SymbolView name="ellipsis" size={16} tintColor={iconColor} type="monochrome" />
307 </Pressable>368 </Pressable>
308 </ControlPillMenu>369 </ControlPillMenu>
309 </View>370 </View>
310 </ReanimatedSwipeable>371 )}
372 </ThreadSwipeable>
311 );373 );
312}374}
313 375
@@ -317,7 +379,7 @@ function ArchiveError(props: { readonly message: string; readonly onRetry: () => · 3 unmodified lines
317 <Text className="text-base font-t3-bold text-danger-foreground">379 <Text className="text-base font-t3-bold text-danger-foreground">
318 Could not load every archive380 Could not load every archive
319 </Text>381 </Text>
320 <Text className="mt-1 text-sm leading-[18px] text-foreground-muted">{props.message}</Text>382 <Text className="mt-1 text-sm text-foreground-muted">{props.message}</Text>
321 <Pressable className="mt-3 self-start active:opacity-60" onPress={props.onRetry}>383 <Pressable className="mt-3 self-start active:opacity-60" onPress={props.onRetry}>
322 <Text className="text-sm font-t3-bold text-danger-foreground">Try again</Text>384 <Text className="text-sm font-t3-bold text-danger-foreground">Try again</Text>
323 </Pressable>385 </Pressable>
@@ -340,8 +402,41 @@ export function ArchivedThreadsScreen(props: { · 16 unmodified lines
340 readonly onSortOrderChange: (sortOrder: ArchivedThreadSortOrder) => void;402 readonly onSortOrderChange: (sortOrder: ArchivedThreadSortOrder) => void;
341 readonly onUnarchiveThread: (thread: EnvironmentThreadShell) => void;403 readonly onUnarchiveThread: (thread: EnvironmentThreadShell) => void;
342}) {404}) {
405 const { onDeleteThread, onUnarchiveThread } = props;
343 const openSwipeableRef = useRef<SwipeableMethods | null>(null);406 const openSwipeableRef = useRef<SwipeableMethods | null>(null);
407 const archiveScrollGesture = useMemo(() => Gesture.Native(), []);
344 const refreshTint = useThemeColor("--color-icon");408 const refreshTint = useThemeColor("--color-icon");
409 const environmentLabelsById = useMemo(
410 () =>
411 new Map(
412 props.environments.map((environment) => [environment.environmentId, environment.label]),
413 ),
414 [props.environments],
415 );
416 const listItems = useMemo<ReadonlyArray<ArchivedThreadListItem>>(() => {
417 const items: ArchivedThreadListItem[] = [];
418 for (const group of props.groups) {
419 const environmentLabel = environmentLabelsById.get(group.project.environmentId) ?? null;
420 items.push({
421 kind: "project",
422 key: `${group.key}:project`,
423 environmentLabel,
424 project: group.project,
425 });
426
427 group.threads.forEach((thread, index) => {
428 items.push({
429 kind: "thread",
430 key: `${thread.environmentId}:${thread.id}`,
431 environmentLabel,
432 isFirst: index === 0,
433 isLast: index === group.threads.length - 1,
434 thread,
435 });
436 });
437 }
438 return items;
439 }, [environmentLabelsById, props.groups]);
345 const handleSwipeableWillOpen = useCallback((methods: SwipeableMethods) => {440 const handleSwipeableWillOpen = useCallback((methods: SwipeableMethods) => {
346 if (openSwipeableRef.current && openSwipeableRef.current !== methods) {441 if (openSwipeableRef.current && openSwipeableRef.current !== methods) {
347 openSwipeableRef.current.close();442 openSwipeableRef.current.close();
@@ -355,82 +450,103 @@ export function ArchivedThreadsScreen(props: { · 7 unmodified lines
355 }, []);450 }, []);
356 const isInitialLoad = props.isLoading && props.groups.length === 0 && props.error === null;451 const isInitialLoad = props.isLoading && props.groups.length === 0 && props.error === null;
357 const isFiltered = props.searchQuery.trim().length > 0 || props.selectedEnvironmentId !== null;452 const isFiltered = props.searchQuery.trim().length > 0 || props.selectedEnvironmentId !== null;
453 const renderListItem = useCallback(
454 ({ item }: { item: ArchivedThreadListItem }) => {
455 if (item.kind === "project") {
456 return (
457 <View className="pt-4">
458 <ProjectGroupLabel environmentLabel={item.environmentLabel} project={item.project} />
459 </View>
460 );
461 }
462
463 return (
464 <ArchivedThreadRow
465 environmentLabel={item.environmentLabel}
466 isFirst={item.isFirst}
467 isLast={item.isLast}
468 onDelete={() => onDeleteThread(item.thread)}
469 onSwipeableClose={handleSwipeableClose}
470 onSwipeableWillOpen={handleSwipeableWillOpen}
471 onUnarchive={() => onUnarchiveThread(item.thread)}
472 simultaneousSwipeGesture={archiveScrollGesture}
473 thread={item.thread}
474 />
475 );
476 },
477 [
478 archiveScrollGesture,
479 handleSwipeableClose,
480 handleSwipeableWillOpen,
481 onDeleteThread,
482 onUnarchiveThread,
483 ],
484 );
485 const listEmptyComponent = useMemo(() => {
486 if (isInitialLoad) {
487 return (
488 <View className="items-center py-16">
489 <ActivityIndicator color={refreshTint} />
490 <Text className="mt-3 text-sm text-foreground-muted">Loading archive...</Text>
491 </View>
492 );
493 }
494
495 return (
496 <EmptyState
497 detail={
498 isFiltered
499 ? "Try another search or environment."
500 : "Threads you archive will appear here."
501 }
502 title={isFiltered ? "No matching threads" : "No archived threads"}
503 />
504 );
505 }, [isFiltered, isInitialLoad, refreshTint]);
358 506
359 return (507 return (
360 <View className="flex-1 bg-sheet">508 <View className="flex-1 bg-sheet">
361 <ArchivedThreadsHeader509 <ArchivedThreadsHeader
362 environments={props.environments}510 environments={props.environments}
363 onEnvironmentChange={props.onEnvironmentChange}511 onEnvironmentChange={props.onEnvironmentChange}
512 onRefresh={props.onRefresh}
364 onSearchQueryChange={props.onSearchQueryChange}513 onSearchQueryChange={props.onSearchQueryChange}
365 onSortOrderChange={props.onSortOrderChange}514 onSortOrderChange={props.onSortOrderChange}
366 selectedEnvironmentId={props.selectedEnvironmentId}515 selectedEnvironmentId={props.selectedEnvironmentId}
367 sortOrder={props.sortOrder}516 sortOrder={props.sortOrder}
368 />517 />
369 518
370 <ScrollView519 <GestureDetector gesture={archiveScrollGesture}>
371 className="flex-1"520 <LegendList
372 contentContainerStyle={{ gap: 20, paddingBottom: 32, paddingHorizontal: 16, paddingTop: 8 }}521 className="flex-1"
373 contentInsetAdjustmentBehavior="automatic"522 contentContainerStyle={{
374 keyboardDismissMode="on-drag"523 paddingBottom: 32,
375 keyboardShouldPersistTaps="handled"524 paddingHorizontal: 16,
376 onScrollBeginDrag={() => openSwipeableRef.current?.close()}525 paddingTop: 4,
377 refreshControl={526 }}
378 <RefreshControl527 contentInsetAdjustmentBehavior="automatic"
379 onRefresh={props.onRefresh}528 data={listItems}
380 refreshing={props.isLoading && !isInitialLoad}529 estimatedItemSize={62}
381 tintColor={String(refreshTint)}530 getItemType={(item) => item.kind}
382 />531 keyboardDismissMode="on-drag"
383 }532 keyboardShouldPersistTaps="handled"
384 showsVerticalScrollIndicator={false}533 keyExtractor={(item) => item.key}
385 >534 ListEmptyComponent={listEmptyComponent}
386 {props.error ? <ArchiveError message={props.error} onRetry={props.onRefresh} /> : null}535 ListHeaderComponent={
387 536 props.error ? <ArchiveError message={props.error} onRetry={props.onRefresh} /> : null
388 {isInitialLoad ? (537 }
389 <View className="items-center py-16">538 onScrollBeginDrag={() => openSwipeableRef.current?.close()}
390 <ActivityIndicator color={refreshTint} />539 refreshControl={
391 <Text className="mt-3 text-sm text-foreground-muted">Loading archive…</Text>540 <RefreshControl
392 </View>541 onRefresh={props.onRefresh}
393 ) : props.groups.length === 0 ? (542 refreshing={props.isLoading && !isInitialLoad}
394 <EmptyState543 tintColor={String(refreshTint)}
395 detail={544 />
396 isFiltered545 }
397 ? "Try another search or environment."546 renderItem={renderListItem}
398 : "Threads you archive will appear here."547 showsVerticalScrollIndicator={false}
399 }548 />
400 title={isFiltered ? "No matching threads" : "No archived threads"}549 </GestureDetector>
401 />
402 ) : (
403 props.groups.map((group) => {
404 const environmentLabel =
405 props.environments.find(
406 (environment) => environment.environmentId === group.project.environmentId,
407 )?.label ?? null;
408
409 return (
410 <View key={group.key} collapsable={false}>
411 <ProjectGroupLabel environmentLabel={environmentLabel} project={group.project} />
412 <View
413 className="overflow-hidden rounded-[20px] bg-card"
414 style={{ borderCurve: "continuous" }}
415 >
416 {group.threads.map((thread, index) => (
417 <ArchivedThreadRow
418 key={`${thread.environmentId}:${thread.id}`}
419 environmentLabel={environmentLabel}
420 isLast={index === group.threads.length - 1}
421 onDelete={() => props.onDeleteThread(thread)}
422 onSwipeableClose={handleSwipeableClose}
423 onSwipeableWillOpen={handleSwipeableWillOpen}
424 onUnarchive={() => props.onUnarchiveThread(thread)}
425 thread={thread}
426 />
427 ))}
428 </View>
429 </View>
430 );
431 })
432 )}
433 </ScrollView>
434 </View>550 </View>
435 );551 );
436}552}
apps/mobile/src/features/cloud/CloudWaitlistEnrollment.tsx+11−43
@@ -2,7 +2,6 @@ import { useWaitlist } from "@clerk/expo"; · 1 unmodified line
2import { ActivityIndicator, Pressable, StyleSheet, Text, TextInput, View } from "react-native";2import { ActivityIndicator, Pressable, StyleSheet, Text, TextInput, View } from "react-native";
3import { useState } from "react";3import { useState } from "react";
4 4
5import { MOBILE_TYPOGRAPHY } from "../../lib/typography";
6import { useThemeColor } from "../../lib/useThemeColor";5import { useThemeColor } from "../../lib/useThemeColor";
7import { CloudWaitlistJoinRejectedError, joinCloudWaitlist } from "./cloudWaitlistJoin";6import { CloudWaitlistJoinRejectedError, joinCloudWaitlist } from "./cloudWaitlistJoin";
8 7
@@ -36,8 +35,10 @@ export function CloudWaitlistEnrollment(props: { readonly onSignIn: () => void } · 27 unmodified lines
36 if (waitlist.id) {35 if (waitlist.id) {
37 return (36 return (
38 <View style={styles.content}>37 <View style={styles.content}>
39 <Text style={[styles.title, { color: colors.foreground }]}>You are on the waitlist</Text>38 <Text className="text-center font-t3-bold text-xl text-foreground">
40 <Text style={[styles.body, styles.confirmationBody, { color: colors.secondaryForeground }]}>39 You are on the waitlist
40 </Text>
41 <Text className="text-center font-sans text-base text-foreground-secondary">
41 We will email you when your T3 Cloud access is ready.42 We will email you when your T3 Cloud access is ready.
42 </Text>43 </Text>
43 <SignInAction onPress={props.onSignIn} />44 <SignInAction onPress={props.onSignIn} />
@@ -47,17 +48,18 @@ export function CloudWaitlistEnrollment(props: { readonly onSignIn: () => void } · 3 unmodified lines
47 48
48 return (49 return (
49 <View style={styles.content}>50 <View style={styles.content}>
50 <Text style={[styles.body, { color: colors.secondaryForeground }]}>51 <Text className="font-sans text-base text-foreground-secondary">
51 Enter your email and we will let you know when access is ready.52 Enter your email and we will let you know when access is ready.
52 </Text>53 </Text>
53 54
54 <View style={styles.field}>55 <View style={styles.field}>
55 <Text style={[styles.label, { color: colors.secondaryForeground }]}>Email address</Text>56 <Text className="font-t3-bold text-sm text-foreground-secondary">Email address</Text>
56 <TextInput57 <TextInput
57 accessibilityLabel="Email address"58 accessibilityLabel="Email address"
58 autoCapitalize="none"59 autoCapitalize="none"
59 autoComplete="email"60 autoComplete="email"
60 autoCorrect={false}61 autoCorrect={false}
62 className="font-sans text-lg text-foreground"
61 keyboardType="email-address"63 keyboardType="email-address"
62 onChangeText={(value) => {64 onChangeText={(value) => {
63 setEmailAddress(value);65 setEmailAddress(value);
@@ -73,7 +75,6 @@ export function CloudWaitlistEnrollment(props: { readonly onSignIn: () => void } · 9 unmodified lines
73 backgroundColor: colors.input,75 backgroundColor: colors.input,
74 borderColor:76 borderColor:
75 fieldError || requestError ? colors.dangerForeground : colors.inputBorder,77 fieldError || requestError ? colors.dangerForeground : colors.inputBorder,
76 color: colors.foreground,
77 },78 },
78 ]}79 ]}
79 textContentType="emailAddress"80 textContentType="emailAddress"
@@ -82,7 +83,7 @@ export function CloudWaitlistEnrollment(props: { readonly onSignIn: () => void } · 2 unmodified lines
82 {fieldError || requestError ? (83 {fieldError || requestError ? (
83 <Text84 <Text
84 accessibilityLiveRegion="polite"85 accessibilityLiveRegion="polite"
85 style={[styles.error, { color: colors.dangerForeground }]}86 className="font-sans text-sm text-danger-foreground"
86 selectable87 selectable
87 >88 >
88 {fieldError ?? requestError}89 {fieldError ?? requestError}
@@ -107,7 +108,7 @@ export function CloudWaitlistEnrollment(props: { readonly onSignIn: () => void } · 18 unmodified lines
107 ]}108 ]}
108 >109 >
109 {isSubmitting ? <ActivityIndicator color={colors.primaryForeground} size="small" /> : null}110 {isSubmitting ? <ActivityIndicator color={colors.primaryForeground} size="small" /> : null}
110 <Text style={[styles.buttonText, { color: colors.primaryForeground }]}>111 <Text className="font-t3-bold text-base text-primary-foreground">
111 {isSubmitting ? "Joining" : "Join the waitlist"}112 {isSubmitting ? "Joining" : "Join the waitlist"}
112 </Text>113 </Text>
113 </Pressable>114 </Pressable>
@@ -118,12 +119,11 @@ export function CloudWaitlistEnrollment(props: { readonly onSignIn: () => void } · 4 unmodified lines
118}119}
119 120
120function SignInAction(props: { readonly onPress: () => void }) {121function SignInAction(props: { readonly onPress: () => void }) {
121 const colors = useCloudWaitlistColors();
122 return (122 return (
123 <View style={styles.signInRow}>123 <View style={styles.signInRow}>
124 <Text style={[styles.body, { color: colors.secondaryForeground }]}>Already have access?</Text>124 <Text className="font-sans text-base text-foreground-secondary">Already have access?</Text>
125 <Pressable accessibilityRole="button" hitSlop={8} onPress={props.onPress}>125 <Pressable accessibilityRole="button" hitSlop={8} onPress={props.onPress}>
126 <Text style={[styles.signInText, { color: colors.foreground }]}>Sign in</Text>126 <Text className="font-t3-bold text-base text-foreground">Sign in</Text>
127 </Pressable>127 </Pressable>
128 </View>128 </View>
129 );129 );
@@ -132,35 +132,18 @@ function SignInAction(props: { readonly onPress: () => void }) { · 2 unmodified lines
132function useCloudWaitlistColors() {132function useCloudWaitlistColors() {
133 return {133 return {
134 dangerForeground: String(useThemeColor("--color-danger-foreground")),134 dangerForeground: String(useThemeColor("--color-danger-foreground")),
135 foreground: String(useThemeColor("--color-foreground")),
136 input: String(useThemeColor("--color-input")),135 input: String(useThemeColor("--color-input")),
137 inputBorder: String(useThemeColor("--color-input-border")),136 inputBorder: String(useThemeColor("--color-input-border")),
138 placeholder: String(useThemeColor("--color-placeholder")),137 placeholder: String(useThemeColor("--color-placeholder")),
139 primary: String(useThemeColor("--color-primary")),138 primary: String(useThemeColor("--color-primary")),
140 primaryForeground: String(useThemeColor("--color-primary-foreground")),139 primaryForeground: String(useThemeColor("--color-primary-foreground")),
141 secondaryForeground: String(useThemeColor("--color-foreground-secondary")),
142 };140 };
143}141}
144 142
145const styles = StyleSheet.create({143const styles = StyleSheet.create({
146 body: {
147 fontFamily: "DMSans_400Regular",
148 ...MOBILE_TYPOGRAPHY.body,
149 },
150 buttonText: {
151 fontFamily: "DMSans_700Bold",
152 fontSize: MOBILE_TYPOGRAPHY.body.fontSize,
153 },
154 content: {144 content: {
155 gap: 18,145 gap: 18,
156 },146 },
157 confirmationBody: {
158 textAlign: "center",
159 },
160 error: {
161 fontFamily: "DMSans_400Regular",
162 ...MOBILE_TYPOGRAPHY.footnote,
163 },
164 field: {147 field: {
165 gap: 8,148 gap: 8,
166 },149 },
@@ -168,16 +151,10 @@ const styles = StyleSheet.create({ · 1 unmodified line
168 borderCurve: "continuous",151 borderCurve: "continuous",
169 borderRadius: 16,152 borderRadius: 16,
170 borderWidth: 1,153 borderWidth: 1,
171 fontFamily: "DMSans_400Regular",
172 fontSize: MOBILE_TYPOGRAPHY.headline.fontSize,
173 minHeight: 54,154 minHeight: 54,
174 paddingHorizontal: 16,155 paddingHorizontal: 16,
175 paddingVertical: 14,156 paddingVertical: 14,
176 },157 },
177 label: {
178 fontFamily: "DMSans_700Bold",
179 ...MOBILE_TYPOGRAPHY.footnote,
180 },
181 primaryButton: {158 primaryButton: {
182 alignItems: "center",159 alignItems: "center",
183 borderRadius: 999,160 borderRadius: 999,
@@ -195,13 +172,4 @@ const styles = StyleSheet.create({ · 11 unmodified lines
195 justifyContent: "center",172 justifyContent: "center",
196 paddingTop: 4,173 paddingTop: 4,
197 },174 },
198 signInText: {
199 fontFamily: "DMSans_700Bold",
200 ...MOBILE_TYPOGRAPHY.body,
201 },
202 title: {
203 fontFamily: "DMSans_700Bold",
204 ...MOBILE_TYPOGRAPHY.title,
205 textAlign: "center",
206 },
207});175});
apps/mobile/src/features/cloud/dpop.ts+41−9
@@ -8,12 +8,7 @@ import * as Schema from "effect/Schema"; · 7 unmodified lines
8import * as ExpoCrypto from "expo-crypto";8import * as ExpoCrypto from "expo-crypto";
9import * as SecureStore from "expo-secure-store";9import * as SecureStore from "expo-secure-store";
10import { p256 } from "@noble/curves/nist";10import { p256 } from "@noble/curves/nist";
11import {11import { DpopPublicJwk, normalizeDpopHtu } from "@t3tools/shared/dpopCommon";
12 computeDpopAccessTokenHash,
13 computeDpopJwkThumbprint,
14 DpopPublicJwk,
15 normalizeDpopHtu,
16} from "@t3tools/shared/dpop";
17import * as Layer from "effect/Layer";12import * as Layer from "effect/Layer";
18 13
19export class CloudDpopError extends Data.TaggedError("CloudDpopError")<{14export class CloudDpopError extends Data.TaggedError("CloudDpopError")<{
@@ -107,6 +102,40 @@ function sha256Digest( · 87 unmodified lines
107 );102 );
108}103}
109 104
105function base64UrlSha256(
106 data: Uint8Array,
107 message: string,
108): Effect.Effect<string, CloudDpopError, Crypto.Crypto> {
109 return sha256Digest(data, message).pipe(Effect.map(Encoding.encodeBase64Url));
110}
111
112function dpopThumbprintInput(jwk: DpopPublicJwk): string {
113 return JSON.stringify({
114 crv: jwk.crv,
115 kty: jwk.kty,
116 x: jwk.x,
117 y: jwk.y,
118 });
119}
120
121function computeDpopJwkThumbprintEffect(
122 jwk: DpopPublicJwk,
123): Effect.Effect<string, CloudDpopError, Crypto.Crypto> {
124 return base64UrlSha256(
125 new TextEncoder().encode(dpopThumbprintInput(jwk)),
126 "Could not hash the DPoP public key thumbprint.",
127 );
128}
129
130function computeDpopAccessTokenHashEffect(
131 accessToken: string,
132): Effect.Effect<string, CloudDpopError, Crypto.Crypto> {
133 return base64UrlSha256(
134 new TextEncoder().encode(accessToken),
135 "Could not hash the DPoP access token.",
136 );
137}
138
110function secureRandomBytes(139function secureRandomBytes(
111 byteCount: number,140 byteCount: number,
112 message: string,141 message: string,
@@ -153,7 +182,7 @@ export function generateDpopProofKeyPair(): Effect.Effect< · 40 unmodified lines
153 try: () => publicJwkFromUncompressedPublicKey(p256.getPublicKey(privateKey, false)),182 try: () => publicJwkFromUncompressedPublicKey(p256.getPublicKey(privateKey, false)),
154 catch: cloudDpopError("Generated DPoP public key is invalid."),183 catch: cloudDpopError("Generated DPoP public key is invalid."),
155 });184 });
156 const thumbprint = computeDpopJwkThumbprint(publicJwk);185 const thumbprint = yield* computeDpopJwkThumbprintEffect(publicJwk);
157 return {186 return {
158 privateJwk: privateJwkFromPrivateKey(privateKey, publicJwk),187 privateJwk: privateJwkFromPrivateKey(privateKey, publicJwk),
159 publicJwk,188 publicJwk,
@@ -189,9 +218,10 @@ export function loadOrCreateDpopProofKeyPair(): Effect.Effect< · 29 unmodified lines
189 },218 },
190 catch: cloudDpopError("Stored DPoP proof key is invalid."),219 catch: cloudDpopError("Stored DPoP proof key is invalid."),
191 });220 });
221 const thumbprint = yield* computeDpopJwkThumbprintEffect(restored.publicJwk);
192 return {222 return {
193 ...restored,223 ...restored,
194 thumbprint: computeDpopJwkThumbprint(restored.publicJwk),224 thumbprint,
195 };225 };
196 }226 }
197 const generated = yield* generateDpopProofKeyPair();227 const generated = yield* generateDpopProofKeyPair();
@@ -243,7 +273,9 @@ export function createDpopProof(input: { · 45 unmodified lines
243 Effect.map(Encoding.encodeBase64Url),273 Effect.map(Encoding.encodeBase64Url),
244 Effect.mapError(cloudDpopError("Could not encode DPoP proof header.")),274 Effect.mapError(cloudDpopError("Could not encode DPoP proof header.")),
245 );275 );
246 const ath = input.accessToken ? computeDpopAccessTokenHash(input.accessToken) : null;276 const ath = input.accessToken
277 ? yield* computeDpopAccessTokenHashEffect(input.accessToken)
278 : null;
247 const payload = yield* encodeDpopJwtPayloadJson({279 const payload = yield* encodeDpopJwtPayloadJson({
248 htm: input.method.toUpperCase(),280 htm: input.method.toUpperCase(),
249 htu,281 htu,
apps/mobile/src/features/connection/ConnectionEnvironmentRow.tsx+4−4
@@ -76,16 +76,16 @@ export function ConnectionEnvironmentRow(props: { · 75 unmodified lines
76 />76 />
77 77
78 <View className="flex-1 gap-0.5">78 <View className="flex-1 gap-0.5">
79 <Text className="text-base font-t3-bold leading-[21px] text-foreground" numberOfLines={1}>79 <Text className="text-base font-t3-bold leading-snug text-foreground" numberOfLines={1}>
80 {props.environment.environmentLabel}80 {props.environment.environmentLabel}
81 </Text>81 </Text>
82 <Text className="text-xs leading-[16px] text-foreground-muted" numberOfLines={1}>82 <Text className="text-xs text-foreground-muted" numberOfLines={1}>
83 {props.environment.displayUrl}83 {props.environment.displayUrl}
84 </Text>84 </Text>
85 {statusLabel ? (85 {statusLabel ? (
86 <Text86 <Text
87 className={cn(87 className={cn(
88 "text-xs leading-[16px]",88 "text-xs",
89 hasConnectionFailure ? "text-rose-500 dark:text-rose-400" : "text-foreground-muted",89 hasConnectionFailure ? "text-rose-500 dark:text-rose-400" : "text-foreground-muted",
90 )}90 )}
91 numberOfLines={props.expanded ? undefined : 1}91 numberOfLines={props.expanded ? undefined : 1}
@@ -134,7 +134,7 @@ export function ConnectionEnvironmentRow(props: { · 42 unmodified lines
134 className="gap-3 px-4 pb-4"134 className="gap-3 px-4 pb-4"
135 >135 >
136 {props.environment.isRelayManaged ? (136 {props.environment.isRelayManaged ? (
137 <Text className="text-sm leading-[18px] text-foreground-muted">137 <Text className="text-sm text-foreground-muted">
138 Managed by T3 Cloud. Tunnel details update automatically.138 Managed by T3 Cloud. Tunnel details update automatically.
139 </Text>139 </Text>
140 ) : (140 ) : (
apps/mobile/src/features/connection/ConnectionsNewRouteScreen.tsxrenamed from apps/mobile/src/app/connections/new.tsx+27−15
@@ -1,5 +1,6 @@
1import { CameraView, useCameraPermissions } from "expo-camera";1import { CameraView, useCameraPermissions } from "expo-camera";
2import { Stack, useLocalSearchParams, useRouter } from "expo-router";2import { NativeHeaderToolbar, NativeStackScreenOptions } from "../../native/StackHeader";
3import { StackActions, useNavigation, type StaticScreenProps } from "@react-navigation/native";
3import { AsyncResult } from "effect/unstable/reactivity";4import { AsyncResult } from "effect/unstable/reactivity";
4import { useCallback, useEffect, useState } from "react";5import { useCallback, useEffect, useState } from "react";
5import { Alert, ScrollView, View } from "react-native";6import { Alert, ScrollView, View } from "react-native";
@@ -8,21 +9,26 @@ import { useThemeColor } from "../../lib/useThemeColor"; · 2 unmodified lines
8 9
9import { AppText as Text, AppTextInput as TextInput } from "../../components/AppText";10import { AppText as Text, AppTextInput as TextInput } from "../../components/AppText";
10import { ErrorBanner } from "../../components/ErrorBanner";11import { ErrorBanner } from "../../components/ErrorBanner";
11import { dismissRoute } from "../../lib/routes";12import { ConnectionSheetButton } from "./ConnectionSheetButton";
12import { ConnectionSheetButton } from "../../features/connection/ConnectionSheetButton";13import { extractPairingUrlFromQrPayload } from "./pairing";
13import { extractPairingUrlFromQrPayload } from "../../features/connection/pairing";
14import { useRemoteConnections } from "../../state/use-remote-environment-registry";14import { useRemoteConnections } from "../../state/use-remote-environment-registry";
15import { buildPairingUrl, parsePairingUrl } from "../../features/connection/pairing";15import { buildPairingUrl, parsePairingUrl } from "./pairing";
16 16
17export default function ConnectionsNewRouteScreen() {17type ConnectionsNewRouteParams = {
18 readonly mode?: string;
19};
20
21export function ConnectionsNewRouteScreen({
22 route,
23}: StaticScreenProps<ConnectionsNewRouteParams | undefined>) {
18 const {24 const {
19 connectionPairingUrl,25 connectionPairingUrl,
20 onChangeConnectionPairingUrl,26 onChangeConnectionPairingUrl,
21 onConnectPress,27 onConnectPress,
22 pairingConnectionError,28 pairingConnectionError,
23 } = useRemoteConnections();29 } = useRemoteConnections();
24 const router = useRouter();30 const navigation = useNavigation();
25 const params = useLocalSearchParams<{ mode?: string }>();31 const params = route.params ?? {};
26 const insets = useSafeAreaInsets();32 const insets = useSafeAreaInsets();
27 const [hostInput, setHostInput] = useState("");33 const [hostInput, setHostInput] = useState("");
28 const [codeInput, setCodeInput] = useState("");34 const [codeInput, setCodeInput] = useState("");
@@ -31,6 +37,7 @@ export default function ConnectionsNewRouteScreen() { · 2 unmodified lines
31 const [cameraPermission, requestCameraPermission] = useCameraPermissions();37 const [cameraPermission, requestCameraPermission] = useCameraPermissions();
32 const [scannerLocked, setScannerLocked] = useState(false);38 const [scannerLocked, setScannerLocked] = useState(false);
33 39
40 const headerIconColor = useThemeColor("--color-icon");
34 const placeholderColor = useThemeColor("--color-placeholder");41 const placeholderColor = useThemeColor("--color-placeholder");
35 42
36 const connectDisabled = isSubmitting || hostInput.trim().length === 0;43 const connectDisabled = isSubmitting || hostInput.trim().length === 0;
@@ -116,21 +123,25 @@ export default function ConnectionsNewRouteScreen() { · 79 unmodified lines
116 onChangeConnectionPairingUrl(pairingUrl);123 onChangeConnectionPairingUrl(pairingUrl);
117 const result = await onConnectPress(pairingUrl);124 const result = await onConnectPress(pairingUrl);
118 if (AsyncResult.isSuccess(result)) {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 } else {131 } else {
121 setIsSubmitting(false);132 setIsSubmitting(false);
122 }133 }
123 }, [codeInput, hostInput, onChangeConnectionPairingUrl, onConnectPress, router]);134 }, [codeInput, hostInput, onChangeConnectionPairingUrl, onConnectPress, navigation]);
124 135
125 return (136 return (
126 <View collapsable={false} className="flex-1 bg-sheet">137 <View collapsable={false} className="flex-1 bg-sheet">
127 <Stack.Screen138 <NativeStackScreenOptions
128 options={{139 options={{
129 title: showScanner ? "Scan QR Code" : "Add Environment",140 title: showScanner ? "Scan QR Code" : "Add Environment",
130 }}141 }}
131 />142 />
132 <Stack.Toolbar placement="right">143 <NativeHeaderToolbar placement="right">
133 <Stack.Toolbar.Button144 <NativeHeaderToolbar.Button
134 icon={showScanner ? "xmark" : "qrcode.viewfinder"}145 icon={showScanner ? "xmark" : "qrcode.viewfinder"}
135 onPress={() => {146 onPress={() => {
136 if (showScanner) {147 if (showScanner) {
@@ -140,8 +151,9 @@ export default function ConnectionsNewRouteScreen() { · 3 unmodified lines
140 }151 }
141 }}152 }}
142 separateBackground153 separateBackground
154 tintColor={headerIconColor}
143 />155 />
144 </Stack.Toolbar>156 </NativeHeaderToolbar>
145 157
146 <ScrollView158 <ScrollView
147 contentInsetAdjustmentBehavior="automatic"159 contentInsetAdjustmentBehavior="automatic"
@@ -171,7 +183,7 @@ export default function ConnectionsNewRouteScreen() { · 23 unmodified lines
171 className="items-center gap-3 rounded-[24px] bg-card px-5 py-8"183 className="items-center gap-3 rounded-[24px] bg-card px-5 py-8"
172 style={{ borderCurve: "continuous" }}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 Camera permission is required to scan a QR code.187 Camera permission is required to scan a QR code.
176 </Text>188 </Text>
177 <ConnectionSheetButton189 <ConnectionSheetButton
apps/mobile/src/features/connection/ConnectionsRouteScreen.tsxrenamed from apps/mobile/src/app/connections/index.tsx+10−14
@@ -1,4 +1,5 @@
1import { Stack, useRouter } from "expo-router";1import { NativeHeaderToolbar } from "../../native/StackHeader";
2import { useNavigation } from "@react-navigation/native";
2import { SymbolView } from "expo-symbols";3import { SymbolView } from "expo-symbols";
3import type { EnvironmentId } from "@t3tools/contracts";4import type { EnvironmentId } from "@t3tools/contracts";
4import { useCallback, useState } from "react";5import { useCallback, useState } from "react";
@@ -9,16 +10,16 @@ import { useThemeColor } from "../../lib/useThemeColor"; · 4 unmodified lines
9import { AppText as Text } from "../../components/AppText";10import { AppText as Text } from "../../components/AppText";
10import { cn } from "../../lib/cn";11import { cn } from "../../lib/cn";
11import { useRemoteConnections } from "../../state/use-remote-environment-registry";12import { useRemoteConnections } from "../../state/use-remote-environment-registry";
12import { ConnectionEnvironmentRow } from "../../features/connection/ConnectionEnvironmentRow";13import { ConnectionEnvironmentRow } from "./ConnectionEnvironmentRow";
13 14
14export default function ConnectionsRouteScreen() {15export function ConnectionsRouteScreen() {
15 const {16 const {
16 connectedEnvironments,17 connectedEnvironments,
17 onReconnectEnvironment,18 onReconnectEnvironment,
18 onRemoveEnvironmentPress,19 onRemoveEnvironmentPress,
19 onUpdateEnvironment,20 onUpdateEnvironment,
20 } = useRemoteConnections();21 } = useRemoteConnections();
21 const router = useRouter();22 const navigation = useNavigation();
22 const insets = useSafeAreaInsets();23 const insets = useSafeAreaInsets();
23 const hasEnvironments = connectedEnvironments.length > 0;24 const hasEnvironments = connectedEnvironments.length > 0;
24 const [expandedId, setExpandedId] = useState<EnvironmentId | null>(null);25 const [expandedId, setExpandedId] = useState<EnvironmentId | null>(null);
@@ -31,18 +32,13 @@ export default function ConnectionsRouteScreen() { · 6 unmodified lines
31 32
32 return (33 return (
33 <View collapsable={false} className="flex-1 bg-sheet">34 <View collapsable={false} className="flex-1 bg-sheet">
34 <Stack.Screen35 <NativeHeaderToolbar placement="right">
35 options={{36 <NativeHeaderToolbar.Button
36 title: "Environments",
37 }}
38 />
39 <Stack.Toolbar placement="right">
40 <Stack.Toolbar.Button
41 icon="plus"37 icon="plus"
42 onPress={() => router.push("/connections/new")}38 onPress={() => navigation.navigate("ConnectionsNew")}
43 separateBackground39 separateBackground
44 />40 />
45 </Stack.Toolbar>41 </NativeHeaderToolbar>
46 <ScrollView42 <ScrollView
47 contentInsetAdjustmentBehavior="automatic"43 contentInsetAdjustmentBehavior="automatic"
48 showsVerticalScrollIndicator={false}44 showsVerticalScrollIndicator={false}
@@ -85,7 +81,7 @@ export default function ConnectionsRouteScreen() { · 36 unmodified lines
85 type="monochrome"81 type="monochrome"
86 />82 />
87 </View>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 No environments connected yet.{"\n"}Tap{" "}85 No environments connected yet.{"\n"}Tap{" "}
90 <Text className="font-t3-bold text-foreground">+</Text> to add one.86 <Text className="font-t3-bold text-foreground">+</Text> to add one.
91 </Text>87 </Text>
apps/mobile/src/features/connection/EnvironmentConnectionNotice.tsx+2−2
@@ -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 {noticeTitle(props.connection.phase, props.environmentLabel)}77 {noticeTitle(props.connection.phase, props.environmentLabel)}
78 </Text>78 </Text>
79 <Text className="text-center text-sm leading-[20px] text-foreground-muted">79 <Text className="text-center text-sm leading-normal text-foreground-muted">
80 {noticeDetail(props.connection.phase, props.resourceName, props.connection.error)}80 {noticeDetail(props.connection.phase, props.resourceName, props.connection.error)}
81 {props.connection.traceId ? (81 {props.connection.traceId ? (
82 <>82 <>
apps/mobile/src/features/diffs/nativeReviewDiffSurface.test.ts+22−2
@@ -33,11 +33,14 @@ describe("resolveNativeReviewDiffView", () => { · 32 unmodified lines
33 expect(expoMocks.requireNativeView).not.toHaveBeenCalled();33 expect(expoMocks.requireNativeView).not.toHaveBeenCalled();
34 });34 });
35 35
36 it("returns the native review diff view when the view config is installed", async () => {36 it("returns the payload bridge when the native review diff view is installed", async () => {
37 setExpoViewConfigAvailable();37 setExpoViewConfigAvailable();
38 expoMocks.requireNativeView.mockReturnValue(nativeView);38 expoMocks.requireNativeView.mockReturnValue(nativeView);
39 const { resolveNativeReviewDiffView } = await import("./nativeReviewDiffSurface");39 const { resolveNativeReviewDiffView } = await import("./nativeReviewDiffSurface");
40 expect(resolveNativeReviewDiffView()).toBe(nativeView);40 const resolvedView = resolveNativeReviewDiffView();
41 expect(resolvedView).not.toBeNull();
42 expect(resolvedView).not.toBe(nativeView);
43 expect(resolveNativeReviewDiffView()).toBe(resolvedView);
41 expect(expoMocks.requireNativeView).toHaveBeenCalledWith("T3ReviewDiffSurface");44 expect(expoMocks.requireNativeView).toHaveBeenCalledWith("T3ReviewDiffSurface");
42 });45 });
43 46
@@ -78,3 +81,20 @@ describe("resolveNativeReviewDiffView", () => { · 34 unmodified lines
78 expect(consoleError).toHaveBeenCalledTimes(1);81 expect(consoleError).toHaveBeenCalledTimes(1);
79 });82 });
80});83});
84
85describe("isPendingNativeViewRegistration", () => {
86 it("recognizes registration races for the installed native view name", async () => {
87 const { isPendingNativeViewRegistration } = await import("./nativeReviewDiffSurface");
88
89 expect(
90 isPendingNativeViewRegistration(
91 new Error("Unable to find the 'T3ReviewDiffSurface' view for this native tag"),
92 ),
93 ).toBe(true);
94 expect(
95 isPendingNativeViewRegistration(
96 new Error("Unable to find the 'T3ReviewDiffView' view for this native tag"),
97 ),
98 ).toBe(false);
99 });
100});
apps/mobile/src/features/diffs/nativeReviewDiffSurface.ts+133−6
@@ -1,10 +1,18 @@
1import type { ComponentType } from "react";1import {
2 createElement,
3 useEffect,
4 useImperativeHandle,
5 useRef,
6 type ComponentType,
7 type Ref,
8} from "react";
2import type { NativeSyntheticEvent, ViewProps } from "react-native";9import type { NativeSyntheticEvent, ViewProps } from "react-native";
3import { requireNativeView } from "expo";10import { requireNativeView } from "expo";
4 11
5import { NativeViewResolutionError } from "../../native/nativeViewResolutionError";12import { NativeViewResolutionError } from "../../native/nativeViewResolutionError";
6 13
7const NATIVE_REVIEW_DIFF_MODULE_NAME = "T3ReviewDiffSurface";14const NATIVE_REVIEW_DIFF_MODULE_NAME = "T3ReviewDiffSurface";
15const NATIVE_REVIEW_DIFF_PAYLOAD_RETRY_FRAMES = 60;
8 16
9interface ExpoGlobalWithViewConfig {17interface ExpoGlobalWithViewConfig {
10 readonly expo?: {18 readonly expo?: {
@@ -103,6 +111,7 @@ export interface NativeReviewDiffViewProps extends ViewProps { · 92 unmodified lines
103 readonly tokensJson?: string;111 readonly tokensJson?: string;
104 readonly tokensPatchJson?: string;112 readonly tokensPatchJson?: string;
105 readonly tokensResetKey?: string;113 readonly tokensResetKey?: string;
114 readonly contentResetKey?: string;
106 readonly collapsedFileIdsJson?: string;115 readonly collapsedFileIdsJson?: string;
107 readonly viewedFileIdsJson?: string;116 readonly viewedFileIdsJson?: string;
108 readonly selectedRowIdsJson?: string;117 readonly selectedRowIdsJson?: string;
@@ -113,7 +122,13 @@ export interface NativeReviewDiffViewProps extends ViewProps { · 4 unmodified lines
113 readonly rowHeight: number;122 readonly rowHeight: number;
114 readonly contentWidth: number;123 readonly contentWidth: number;
115 readonly initialRowIndex?: number;124 readonly initialRowIndex?: number;
125 readonly refreshing?: boolean;
126 readonly nativeViewRef?: Ref<NativeReviewDiffViewHandle>;
127 readonly onPullToRefresh?: (event: NativeSyntheticEvent<Record<string, never>>) => void;
116 readonly onDebug?: (event: NativeSyntheticEvent<Record<string, unknown>>) => void;128 readonly onDebug?: (event: NativeSyntheticEvent<Record<string, unknown>>) => void;
129 readonly onVisibleFileChange?: (
130 event: NativeSyntheticEvent<{ readonly fileId?: string | null }>,
131 ) => void;
117 readonly onToggleFile?: (event: NativeSyntheticEvent<{ readonly fileId?: string }>) => void;132 readonly onToggleFile?: (event: NativeSyntheticEvent<{ readonly fileId?: string }>) => void;
118 readonly onToggleViewedFile?: (event: NativeSyntheticEvent<{ readonly fileId?: string }>) => void;133 readonly onToggleViewedFile?: (event: NativeSyntheticEvent<{ readonly fileId?: string }>) => void;
119 readonly onPressLine?: (134 readonly onPressLine?: (
@@ -129,18 +144,130 @@ export interface NativeReviewDiffViewProps extends ViewProps { · 9 unmodified lines
129 readonly onToggleComment?: (event: NativeSyntheticEvent<{ readonly commentId?: string }>) => void;144 readonly onToggleComment?: (event: NativeSyntheticEvent<{ readonly commentId?: string }>) => void;
130}145}
131 146
132let cachedNativeReviewDiffView: ComponentType<NativeReviewDiffViewProps> | undefined;147export interface NativeReviewDiffViewHandle {
148 readonly scrollToFile: (fileId: string, animated?: boolean) => Promise<void>;
149 readonly scrollToTop: (animated?: boolean) => Promise<void>;
150}
151
152interface NativeReviewDiffViewRef {
153 readonly setRowsJson: (rowsJson: string) => Promise<void>;
154 readonly setTokensJson: (tokensJson: string) => Promise<void>;
155 readonly setTokensPatchJson: (tokensPatchJson: string) => Promise<void>;
156 readonly scrollToFile: (fileId: string, animated: boolean) => Promise<void>;
157 readonly scrollToTop: (animated: boolean) => Promise<void>;
158}
159
160type NativeReviewDiffRawViewProps = Omit<
161 NativeReviewDiffViewProps,
162 "nativeViewRef" | "rowsJson" | "tokensJson" | "tokensPatchJson"
163> & {
164 readonly ref?: Ref<NativeReviewDiffViewRef>;
165};
166
167let cachedNativeReviewDiffRawView: ComponentType<NativeReviewDiffRawViewProps> | undefined;
133let nativeReviewDiffViewResolutionFailed = false;168let nativeReviewDiffViewResolutionFailed = false;
134 169
170type NativeReviewDiffPayloadMethod = "setRowsJson" | "setTokensJson" | "setTokensPatchJson";
171
172export function isPendingNativeViewRegistration(error: unknown): boolean {
173 return (
174 error instanceof Error &&
175 error.message.includes(`Unable to find the '${NATIVE_REVIEW_DIFF_MODULE_NAME}' view`)
176 );
177}
178
179function useNativeReviewDiffPayload(
180 nativeRef: React.RefObject<NativeReviewDiffViewRef | null>,
181 method: NativeReviewDiffPayloadMethod,
182 payload: string | undefined,
183) {
184 useEffect(() => {
185 if (payload === undefined) {
186 return;
187 }
188
189 let cancelled = false;
190 let frame: number | null = null;
191 let attempts = 0;
192
193 const dispatch = () => {
194 if (cancelled) {
195 return;
196 }
197
198 const view = nativeRef.current;
199 const command = view?.[method];
200 if (!view || !command) {
201 if (attempts < NATIVE_REVIEW_DIFF_PAYLOAD_RETRY_FRAMES) {
202 attempts += 1;
203 frame = requestAnimationFrame(dispatch);
204 }
205 return;
206 }
207
208 void command.call(view, payload).catch((error: unknown) => {
209 if (
210 !cancelled &&
211 attempts < NATIVE_REVIEW_DIFF_PAYLOAD_RETRY_FRAMES &&
212 isPendingNativeViewRegistration(error)
213 ) {
214 attempts += 1;
215 frame = requestAnimationFrame(dispatch);
216 return;
217 }
218 console.error(`[native-review-diff] ${method} failed`, error);
219 });
220 };
221
222 // Fabric attaches the React ref before Expo registers the native tag used by
223 // view functions. Starting on the next frame avoids racing that registration.
224 frame = requestAnimationFrame(dispatch);
225
226 return () => {
227 cancelled = true;
228 if (frame !== null) {
229 cancelAnimationFrame(frame);
230 }
231 };
232 }, [method, nativeRef, payload]);
233}
234
135function getExpoViewConfig(moduleName: string) {235function getExpoViewConfig(moduleName: string) {
136 return (globalThis as typeof globalThis & ExpoGlobalWithViewConfig).expo?.getViewConfig?.(236 return (globalThis as typeof globalThis & ExpoGlobalWithViewConfig).expo?.getViewConfig?.(
137 moduleName,237 moduleName,
138 );238 );
139}239}
140 240
241function NativeReviewDiffView(props: NativeReviewDiffViewProps) {
242 const { nativeViewRef, rowsJson, tokensJson, tokensPatchJson, ...nativeProps } = props;
243 const nativeRef = useRef<NativeReviewDiffViewRef>(null);
244 useNativeReviewDiffPayload(nativeRef, "setRowsJson", rowsJson);
245 useNativeReviewDiffPayload(nativeRef, "setTokensJson", tokensJson);
246 useNativeReviewDiffPayload(nativeRef, "setTokensPatchJson", tokensPatchJson);
247 useImperativeHandle(
248 nativeViewRef,
249 () => ({
250 scrollToFile: async (fileId, animated = true) => {
251 await nativeRef.current?.scrollToFile(fileId, animated);
252 },
253 scrollToTop: async (animated = true) => {
254 await nativeRef.current?.scrollToTop(animated);
255 },
256 }),
257 [],
258 );
259
260 const RawNativeView = cachedNativeReviewDiffRawView;
261 if (!RawNativeView) {
262 return null;
263 }
264
265 return createElement(RawNativeView, { ...nativeProps, ref: nativeRef });
266}
267
141export function resolveNativeReviewDiffView(): ComponentType<NativeReviewDiffViewProps> | null {268export function resolveNativeReviewDiffView(): ComponentType<NativeReviewDiffViewProps> | null {
142 if (cachedNativeReviewDiffView) {269 if (cachedNativeReviewDiffRawView) {
143 return cachedNativeReviewDiffView;270 return NativeReviewDiffView;
144 }271 }
145 272
146 if (nativeReviewDiffViewResolutionFailed) {273 if (nativeReviewDiffViewResolutionFailed) {
@@ -152,7 +279,7 @@ export function resolveNativeReviewDiffView(): ComponentType<NativeReviewDiffVie · 5 unmodified lines
152 }279 }
153 280
154 try {281 try {
155 cachedNativeReviewDiffView = requireNativeView<NativeReviewDiffViewProps>(282 cachedNativeReviewDiffRawView = requireNativeView<NativeReviewDiffRawViewProps>(
156 NATIVE_REVIEW_DIFF_MODULE_NAME,283 NATIVE_REVIEW_DIFF_MODULE_NAME,
157 );284 );
158 } catch (cause) {285 } catch (cause) {
@@ -166,5 +293,5 @@ export function resolveNativeReviewDiffView(): ComponentType<NativeReviewDiffVie · 7 unmodified lines
166 return null;293 return null;
167 }294 }
168 295
169 return cachedNativeReviewDiffView ?? null;296 return cachedNativeReviewDiffRawView ? NativeReviewDiffView : null;
170}297}
apps/mobile/src/features/files/FileMarkdownPreview.tsx+51−7
@@ -1,15 +1,19 @@
1import { useCallback, useMemo } from "react";1import { useCallback, useMemo, useState } from "react";
2import {2import {
3 Markdown,3 Markdown,
4 type CustomRenderers,4 type CustomRenderers,
5 type NodeStyleOverrides,5 type NodeStyleOverrides,
6 type PartialMarkdownTheme,6 type PartialMarkdownTheme,
7} from "react-native-nitro-markdown";7} from "react-native-nitro-markdown";
8import { ScrollView, Text as NativeText, View } from "react-native";8import { RefreshControl, ScrollView, Text as NativeText, View } from "react-native";
9 9
10import { tryOpenExternalUrl } from "../../lib/openExternalUrl";10import { tryOpenExternalUrl } from "../../lib/openExternalUrl";
11import { MOBILE_TYPOGRAPHY } from "../../lib/typography";11import {
12 resolveMarkdownFontSizes,
13 resolveNativeMarkdownTypography,
14} from "../../lib/appearancePreferences";
12import { useThemeColor } from "../../lib/useThemeColor";15import { useThemeColor } from "../../lib/useThemeColor";
16import { useAppearancePreferences } from "../settings/appearance/AppearancePreferencesProvider";
13import {17import {
14 hasNativeSelectableMarkdownText,18 hasNativeSelectableMarkdownText,
15 SelectableMarkdownText,19 SelectableMarkdownText,
@@ -24,6 +28,15 @@ interface MarkdownPreviewStyles { · 8 unmodified lines
24}28}
25 29
26function useMarkdownPreviewStyles(): MarkdownPreviewStyles {30function useMarkdownPreviewStyles(): MarkdownPreviewStyles {
31 const { appearance } = useAppearancePreferences();
32 const markdownFontSizes = useMemo(
33 () => resolveMarkdownFontSizes(appearance.baseFontSize),
34 [appearance.baseFontSize],
35 );
36 const nativeMarkdownTypography = useMemo(
37 () => resolveNativeMarkdownTypography(appearance.baseFontSize),
38 [appearance.baseFontSize],
39 );
27 const body = String(useThemeColor("--color-md-body"));40 const body = String(useThemeColor("--color-md-body"));
28 const strong = String(useThemeColor("--color-md-strong"));41 const strong = String(useThemeColor("--color-md-strong"));
29 const link = String(useThemeColor("--color-md-link"));42 const link = String(useThemeColor("--color-md-link"));
@@ -74,7 +87,8 @@ function useMarkdownPreviewStyles(): MarkdownPreviewStyles { · 44 unmodified lines
74 text: {87 text: {
75 color: body,88 color: body,
76 fontFamily: "DMSans_400Regular",89 fontFamily: "DMSans_400Regular",
77 ...MOBILE_TYPOGRAPHY.body,90 fontSize: markdownFontSizes.m,
91 lineHeight: markdownFontSizes.bodyLineHeight,
78 },92 },
79 heading: {93 heading: {
80 color: strong,94 color: strong,
@@ -124,7 +138,9 @@ function useMarkdownPreviewStyles(): MarkdownPreviewStyles { · 43 unmodified lines
124 skillTextColor: codeText,138 skillTextColor: codeText,
125 quoteMarkerColor: blockquoteBorder,139 quoteMarkerColor: blockquoteBorder,
126 dividerColor: horizontalRule,140 dividerColor: horizontalRule,
127 ...MOBILE_TYPOGRAPHY.body,141 fontSize: nativeMarkdownTypography.fontSize,
142 lineHeight: nativeMarkdownTypography.lineHeight,
143 headingFontSizes: nativeMarkdownTypography.headingFontSizes,
128 fontFamily: "DMSans_400Regular",144 fontFamily: "DMSans_400Regular",
129 headingFontFamily: "DMSans_700Bold",145 headingFontFamily: "DMSans_700Bold",
130 boldFontFamily: "DMSans_700Bold",146 boldFontFamily: "DMSans_700Bold",
@@ -138,18 +154,46 @@ function useMarkdownPreviewStyles(): MarkdownPreviewStyles { · 7 unmodified lines
138 codeText,154 codeText,
139 horizontalRule,155 horizontalRule,
140 link,156 link,
157 markdownFontSizes,
158 nativeMarkdownTypography,
141 strong,159 strong,
142 ]);160 ]);
143}161}
144 162
145export function FileMarkdownPreview(props: { readonly markdown: string }) {163export function FileMarkdownPreview(props: {
164 readonly markdown: string;
165 readonly onRefresh?: () => Promise<void> | void;
166}) {
167 const [isPullRefreshing, setIsPullRefreshing] = useState(false);
168 const handlePullToRefresh = useCallback(async () => {
169 if (!props.onRefresh) {
170 return;
171 }
172 setIsPullRefreshing(true);
173 try {
174 await props.onRefresh();
175 } finally {
176 setIsPullRefreshing(false);
177 }
178 }, [props.onRefresh]);
146 const styles = useMarkdownPreviewStyles();179 const styles = useMarkdownPreviewStyles();
147 const onLinkPress = useCallback((href: string) => {180 const onLinkPress = useCallback((href: string) => {
148 void tryOpenExternalUrl(href, "markdown-link");181 void tryOpenExternalUrl(href, "markdown-link");
149 }, []);182 }, []);
150 183
151 return (184 return (
152 <ScrollView className="flex-1 bg-card" contentContainerStyle={{ padding: 18 }}>185 <ScrollView
186 className="flex-1 bg-sheet"
187 contentContainerStyle={{ padding: 18 }}
188 refreshControl={
189 props.onRefresh ? (
190 <RefreshControl
191 refreshing={isPullRefreshing}
192 onRefresh={() => void handlePullToRefresh()}
193 />
194 ) : undefined
195 }
196 >
153 <View className="mx-auto w-full max-w-[760px]">197 <View className="mx-auto w-full max-w-[760px]">
154 {hasNativeSelectableMarkdownText() ? (198 {hasNativeSelectableMarkdownText() ? (
155 <SelectableMarkdownText199 <SelectableMarkdownText
apps/mobile/src/features/files/FileTreeBrowser.tsx+149−55
@@ -1,7 +1,15 @@
1import type { ProjectEntry } from "@t3tools/contracts";1import type { ProjectEntry } from "@t3tools/contracts";
2import { SymbolView } from "expo-symbols";2import { SymbolView } from "expo-symbols";
3import { memo, useCallback, useEffect, useMemo, useState } from "react";3import { memo, useCallback, useEffect, useMemo, useRef, useState } from "react";
4import { ActivityIndicator, FlatList, Pressable, RefreshControl, View } from "react-native";4import {
5 ActivityIndicator,
6 FlatList,
7 Platform,
8 Pressable,
9 RefreshControl,
10 View,
11} from "react-native";
12import { useSafeAreaInsets } from "react-native-safe-area-context";
5 13
6import { AppText as Text } from "../../components/AppText";14import { AppText as Text } from "../../components/AppText";
7import { PierreEntryIcon } from "../../components/PierreEntryIcon";15import { PierreEntryIcon } from "../../components/PierreEntryIcon";
@@ -11,9 +19,25 @@ import { · 3 unmodified lines
11 buildFileTree,19 buildFileTree,
12 defaultExpandedTreePaths,20 defaultExpandedTreePaths,
13 flattenFileTree,21 flattenFileTree,
22 type FileTreeNode,
14 type VisibleFileTreeNode,23 type VisibleFileTreeNode,
15} from "./fileTree";24} from "./fileTree";
16 25
26const fileTreeCache = new WeakMap<ReadonlyArray<ProjectEntry>, ReadonlyArray<FileTreeNode>>();
27const FILE_TREE_INITIAL_RENDER_COUNT = 20;
28const FILE_TREE_RENDER_BATCH_SIZE = 12;
29const OPTIMISTIC_SELECTION_TIMEOUT_MS = 1_000;
30
31function cachedFileTree(entries: ReadonlyArray<ProjectEntry>): ReadonlyArray<FileTreeNode> {
32 const cached = fileTreeCache.get(entries);
33 if (cached !== undefined) {
34 return cached;
35 }
36 const tree = buildFileTree(entries);
37 fileTreeCache.set(entries, tree);
38 return tree;
39}
40
17function ancestorPaths(path: string): ReadonlyArray<string> {41function ancestorPaths(path: string): ReadonlyArray<string> {
18 const parts = path.split("/").filter(Boolean);42 const parts = path.split("/").filter(Boolean);
19 const ancestors: string[] = [];43 const ancestors: string[] = [];
@@ -25,19 +49,24 @@ function ancestorPaths(path: string): ReadonlyArray<string> { · 5 unmodified lines
25 49
26const FileTreeRow = memo(function FileTreeRow(props: {50const FileTreeRow = memo(function FileTreeRow(props: {
27 readonly item: VisibleFileTreeNode;51 readonly item: VisibleFileTreeNode;
28 readonly selectedPath: string | null;52 readonly selected: boolean;
29 readonly expanded: boolean;53 readonly expanded: boolean;
30 readonly iconColor: string;54 readonly iconColor: string;
31 readonly onPressDirectory: (path: string) => void;55 readonly onPressDirectory: (path: string) => void;
56 readonly onPreviewFile?: (path: string) => void;
32 readonly onPressFile: (path: string) => void;57 readonly onPressFile: (path: string) => void;
33}) {58}) {
34 const { node, depth } = props.item;59 const { node, depth } = props.item;
35 const selected = node.kind === "file" && node.path === props.selectedPath;
36 60
37 return (61 return (
38 <Pressable62 <Pressable
39 accessibilityRole="button"63 accessibilityRole="button"
40 accessibilityLabel={node.path}64 accessibilityLabel={node.path}
65 onPressIn={() => {
66 if (node.kind === "file") {
67 props.onPreviewFile?.(node.path);
68 }
69 }}
41 onPress={() => {70 onPress={() => {
42 if (node.kind === "directory") {71 if (node.kind === "directory") {
43 props.onPressDirectory(node.path);72 props.onPressDirectory(node.path);
@@ -47,7 +76,7 @@ const FileTreeRow = memo(function FileTreeRow(props: { · 3 unmodified lines
47 }}76 }}
48 className={cn(77 className={cn(
49 "mx-2 min-h-[42px] flex-row items-center gap-2 rounded-[12px] px-2 active:bg-subtle",78 "mx-2 min-h-[42px] flex-row items-center gap-2 rounded-[12px] px-2 active:bg-subtle",
50 selected && "bg-subtle-strong",79 props.selected && "bg-subtle-strong",
51 )}80 )}
52 style={{ paddingLeft: 8 + depth * 18 }}81 style={{ paddingLeft: 8 + depth * 18 }}
53 >82 >
@@ -64,8 +93,10 @@ const FileTreeRow = memo(function FileTreeRow(props: { · 10 unmodified lines
64 <PierreEntryIcon path={node.path} kind={node.kind} size={17} />93 <PierreEntryIcon path={node.path} kind={node.kind} size={17} />
65 <Text94 <Text
66 className={cn(95 className={cn(
67 "min-w-0 flex-1 text-sm leading-[19px]",96 "min-w-0 flex-1 text-sm leading-normal",
68 selected ? "font-t3-bold text-foreground" : "font-t3-medium text-foreground-secondary",97 props.selected
98 ? "font-t3-bold text-foreground"
99 : "font-t3-medium text-foreground-secondary",
69 )}100 )}
70 numberOfLines={1}101 numberOfLines={1}
71 >102 >
@@ -86,13 +117,30 @@ export function FileTreeBrowser(props: { · 14 unmodified lines
86 readonly isPending: boolean;117 readonly isPending: boolean;
87 readonly searchQuery: string;118 readonly searchQuery: string;
88 readonly selectedPath: string | null;119 readonly selectedPath: string | null;
120 readonly onPreviewFile?: (path: string) => void;
89 readonly onRefresh: () => void;121 readonly onRefresh: () => void;
90 readonly onSelectFile: (path: string) => void;122 readonly onSelectFile: (path: string) => void;
91}) {123}) {
92 const [expandedPaths, setExpandedPaths] = useState<ReadonlySet<string>>(() => new Set());124 const [expandedPaths, setExpandedPaths] = useState<ReadonlySet<string>>(() => new Set());
125 const [pendingSelection, setPendingSelection] = useState<{
126 readonly path: string;
127 readonly selectedPathAtPress: string | null;
128 } | null>(null);
129 const insets = useSafeAreaInsets();
130 // Native transparent-header height ≈ safe-area top + nav bar (~44). Matches the
131 // observed adjustedContentInset bottom (~102) seen in the native trace.
132 const headerInset = Platform.OS === "ios" ? insets.top + 44 : 0;
93 const iconColor = String(useThemeColor("--color-icon-muted"));133 const iconColor = String(useThemeColor("--color-icon-muted"));
134 const { onPreviewFile, onSelectFile, selectedPath: controlledSelectedPath } = props;
135 const controlledSelectedPathRef = useRef(controlledSelectedPath);
136 const pendingSelectionTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
137 controlledSelectedPathRef.current = controlledSelectedPath;
94 138
95 const tree = useMemo(() => buildFileTree(props.entries), [props.entries]);139 const selectedPath =
140 pendingSelection?.selectedPathAtPress === controlledSelectedPath
141 ? pendingSelection.path
142 : controlledSelectedPath;
143 const tree = useMemo(() => cachedFileTree(props.entries), [props.entries]);
96 const defaultExpanded = useMemo(() => defaultExpandedTreePaths(tree), [tree]);144 const defaultExpanded = useMemo(() => defaultExpandedTreePaths(tree), [tree]);
97 const visibleNodes = useMemo(145 const visibleNodes = useMemo(
98 () =>146 () =>
@@ -114,17 +162,30 @@ export function FileTreeBrowser(props: { · 15 unmodified lines
114 }, [defaultExpanded]);162 }, [defaultExpanded]);
115 163
116 useEffect(() => {164 useEffect(() => {
117 if (!props.selectedPath) {165 if (!controlledSelectedPath) {
118 return;166 return;
119 }167 }
120 setExpandedPaths((current) => {168 setExpandedPaths((current) => {
169 const ancestors = ancestorPaths(controlledSelectedPath);
170 if (ancestors.every((ancestor) => current.has(ancestor))) {
171 return current;
172 }
121 const next = new Set(current);173 const next = new Set(current);
122 for (const ancestor of ancestorPaths(props.selectedPath ?? "")) {174 for (const ancestor of ancestors) {
123 next.add(ancestor);175 next.add(ancestor);
124 }176 }
125 return next;177 return next;
126 });178 });
127 }, [props.selectedPath]);179 }, [controlledSelectedPath]);
180
181 useEffect(
182 () => () => {
183 if (pendingSelectionTimeoutRef.current !== null) {
184 clearTimeout(pendingSelectionTimeoutRef.current);
185 }
186 },
187 [],
188 );
128 189
129 const toggleDirectory = useCallback((path: string) => {190 const toggleDirectory = useCallback((path: string) => {
130 setExpandedPaths((current) => {191 setExpandedPaths((current) => {
@@ -137,53 +198,86 @@ export function FileTreeBrowser(props: { · 6 unmodified lines
137 return next;198 return next;
138 });199 });
139 }, []);200 }, []);
201 const handleSelectFile = useCallback(
202 (path: string) => {
203 if (pendingSelectionTimeoutRef.current !== null) {
204 clearTimeout(pendingSelectionTimeoutRef.current);
205 }
206 setPendingSelection({
207 path,
208 selectedPathAtPress: controlledSelectedPathRef.current,
209 });
210 pendingSelectionTimeoutRef.current = setTimeout(() => {
211 pendingSelectionTimeoutRef.current = null;
212 setPendingSelection((current) => (current?.path === path ? null : current));
213 }, OPTIMISTIC_SELECTION_TIMEOUT_MS);
214 onSelectFile(path);
215 },
216 [onSelectFile],
217 );
218 const renderItem = useCallback(
219 ({ item }: { readonly item: VisibleFileTreeNode }) => (
220 <FileTreeRow
221 item={item}
222 selected={item.node.kind === "file" && item.node.path === selectedPath}
223 expanded={expandedPaths.has(item.node.path)}
224 iconColor={iconColor}
225 onPressDirectory={toggleDirectory}
226 onPreviewFile={onPreviewFile}
227 onPressFile={handleSelectFile}
228 />
229 ),
230 [expandedPaths, handleSelectFile, iconColor, onPreviewFile, selectedPath, toggleDirectory],
231 );
140 232
233 if (props.error && props.entries.length === 0) {
234 return (
235 <View className="flex-1 bg-sheet px-4 py-5">
236 <Text className="text-sm font-t3-bold text-foreground">Files unavailable</Text>
237 <Text className="mt-1 text-xs leading-normal text-foreground-muted">{props.error}</Text>
238 </View>
239 );
240 }
241
242 // SPIKE: render the FlatList as the screen's DIRECT content (no wrapping View), and
243 // mirror the Home ScrollView exactly — `contentInsetAdjustmentBehavior: "automatic"`
244 // with NO manual contentInset. iOS only applies the nav-bar top inset + scroll-edge
245 // blur to a scroll view in the screen's primary position; a scroll view buried in
246 // flex-1 Views is ignored, which is why the tree rendered under the header with no blur.
141 return (247 return (
142 <View className="flex-1 bg-sheet">248 <FlatList
143 {props.error && props.entries.length === 0 ? (249 style={{ flex: 1 }}
250 data={visibleNodes}
251 keyExtractor={(item) => item.node.path}
252 contentInsetAdjustmentBehavior={Platform.OS === "ios" ? "automatic" : "never"}
253 scrollIndicatorInsets={
254 Platform.OS === "ios" ? { top: headerInset, left: 0, right: 0, bottom: 0 } : undefined
255 }
256 keyboardDismissMode="on-drag"
257 keyboardShouldPersistTaps="handled"
258 initialNumToRender={FILE_TREE_INITIAL_RENDER_COUNT}
259 maxToRenderPerBatch={FILE_TREE_RENDER_BATCH_SIZE}
260 updateCellsBatchingPeriod={16}
261 windowSize={5}
262 contentContainerStyle={{ paddingTop: 8, paddingBottom: 8 }}
263 refreshControl={<RefreshControl refreshing={props.isPending} onRefresh={props.onRefresh} />}
264 renderItem={renderItem}
265 ListEmptyComponent={
144 <View className="px-4 py-5">266 <View className="px-4 py-5">
145 <Text className="text-sm font-t3-bold text-foreground">Files unavailable</Text>267 {props.isPending ? (
146 <Text className="mt-1 text-xs leading-[18px] text-foreground-muted">{props.error}</Text>268 <ActivityIndicator size="small" />
147 </View>269 ) : (
148 ) : (270 <>
149 <FlatList271 <Text className="text-sm font-t3-bold text-foreground">No files found</Text>
150 data={visibleNodes}272 <Text className="mt-1 text-xs leading-normal text-foreground-muted">
151 keyExtractor={(item) => item.node.path}273 {props.searchQuery.trim().length > 0
152 contentInsetAdjustmentBehavior="automatic"274 ? "Try a different search."
153 keyboardDismissMode="on-drag"275 : "The workspace file index is empty."}
154 keyboardShouldPersistTaps="handled"276 </Text>
155 contentContainerStyle={{ paddingVertical: 8 }}277 </>
156 refreshControl={
157 <RefreshControl refreshing={props.isPending} onRefresh={props.onRefresh} />
158 }
159 renderItem={({ item }) => (
160 <FileTreeRow
161 item={item}
162 selectedPath={props.selectedPath}
163 expanded={expandedPaths.has(item.node.path)}
164 iconColor={iconColor}
165 onPressDirectory={toggleDirectory}
166 onPressFile={props.onSelectFile}
167 />
168 )}278 )}
169 ListEmptyComponent={279 </View>
170 <View className="px-4 py-5">280 }
171 {props.isPending ? (281 />
172 <ActivityIndicator size="small" />
173 ) : (
174 <>
175 <Text className="text-sm font-t3-bold text-foreground">No files found</Text>
176 <Text className="mt-1 text-xs leading-[18px] text-foreground-muted">
177 {props.searchQuery.trim().length > 0
178 ? "Try a different search."
179 : "The workspace file index is empty."}
180 </Text>
181 </>
182 )}
183 </View>
184 }
185 />
186 )}
187 </View>
188 );282 );
189}283}
apps/mobile/src/features/files/SourceFileSurface.tsx+98−65
@@ -1,8 +1,15 @@
1import { useAtomValue } from "@effect/atom-react";1import { useAtomValue } from "@effect/atom-react";
2import { AsyncResult } from "effect/unstable/reactivity";2import { AsyncResult } from "effect/unstable/reactivity";
3import type { ComponentType } from "react";3import type { ComponentType } from "react";
4import { memo, useCallback, useEffect, useMemo, useRef } from "react";4import { memo, useCallback, useEffect, useMemo, useRef, useState } from "react";
5import { FlatList, ScrollView, Text as NativeText, useColorScheme, View } from "react-native";5import {
6 FlatList,
7 ScrollView,
8 Text as NativeText,
9 useColorScheme,
10 useWindowDimensions,
11 View,
12} from "react-native";
6 13
7import { AppText as Text } from "../../components/AppText";14import { AppText as Text } from "../../components/AppText";
8import { LoadingStrip } from "../../components/LoadingStrip";15import { LoadingStrip } from "../../components/LoadingStrip";
@@ -11,71 +18,62 @@ import { · 2 unmodified lines
11 resolveNativeReviewDiffView,18 resolveNativeReviewDiffView,
12} from "../diffs/nativeReviewDiffSurface";19} from "../diffs/nativeReviewDiffSurface";
13import { createNativeReviewDiffTheme } from "../review/nativeReviewDiffAdapter";20import { createNativeReviewDiffTheme } from "../review/nativeReviewDiffAdapter";
14import {21import { REVIEW_MONO_FONT_FAMILY, renderVisibleWhitespace } from "../review/reviewDiffRendering";
15 REVIEW_DIFF_LINE_HEIGHT,
16 REVIEW_MONO_FONT_FAMILY,
17 renderVisibleWhitespace,
18} from "../review/reviewDiffRendering";
19import type { ReviewHighlightedToken } from "../review/shikiReviewHighlighter";22import type { ReviewHighlightedToken } from "../review/shikiReviewHighlighter";
20import { cn } from "../../lib/cn";23import { cn } from "../../lib/cn";
21import { MOBILE_CODE_SURFACE } from "../../lib/typography";24import type { ResolvedMobileCodeSurface } from "../../lib/appearancePreferences";
25import { useAppearanceCodeSurface } from "../settings/appearance/useAppearanceCodeSurface";
22import {26import {
23 buildNativeSourceRows,
24 buildNativeSourceTokens,27 buildNativeSourceTokens,
25 NATIVE_SOURCE_CONTENT_WIDTH,28 NATIVE_SOURCE_CONTENT_WIDTH,
26 NATIVE_SOURCE_ROW_HEIGHT,
27 NATIVE_SOURCE_STYLE,
28 nativeSourceRowId,29 nativeSourceRowId,
29} from "./nativeSourceFileAdapter";30} from "./nativeSourceFileAdapter";
31import { prepareSourceFileDocument } from "./source-file-document";
30import { sourceHighlightAtom } from "./sourceHighlightingState";32import { sourceHighlightAtom } from "./sourceHighlightingState";
31 33
32const SOURCE_LINE_HEIGHT = MOBILE_CODE_SURFACE.rowHeight;
33const SOURCE_LINE_NUMBER_WIDTH = MOBILE_CODE_SURFACE.gutterWidth;
34const NATIVE_SOURCE_STYLE_JSON = JSON.stringify(NATIVE_SOURCE_STYLE);
35
36interface SourceFileSurfaceProps {34interface SourceFileSurfaceProps {
37 readonly contents: string;35 readonly contents: string;
38 readonly path: string;36 readonly path: string;
39 readonly initialLine?: number | null;37 readonly initialLine?: number | null;
38 /** Enables native pull-to-refresh on the source surface. */
39 readonly onRefresh?: () => Promise<void> | void;
40}40}
41 41
42type SourceHighlightStatus = "highlighting" | "ready" | "error";42type SourceHighlightStatus = "highlighting" | "ready" | "error";
43 43
44function splitSourceLines(contents: string): ReadonlyArray<string> {
45 return contents.replace(/\r\n?/g, "\n").split("\n");
46}
47
48const HighlightedSourceLine = memo(function HighlightedSourceLine(props: {44const HighlightedSourceLine = memo(function HighlightedSourceLine(props: {
45 readonly codeSurface: ResolvedMobileCodeSurface;
49 readonly index: number;46 readonly index: number;
50 readonly line: string;47 readonly line: string;
51 readonly tokens: ReadonlyArray<ReviewHighlightedToken> | null;48 readonly tokens: ReadonlyArray<ReviewHighlightedToken> | null;
52 readonly highlighted: boolean;49 readonly highlighted: boolean;
50 readonly wordBreak: boolean;
53}) {51}) {
54 return (52 return (
55 <View53 <View
56 className={cn("flex-row", props.highlighted && "bg-primary/10")}54 className={cn("flex-row", props.highlighted && "bg-primary/10")}
57 style={{ minHeight: SOURCE_LINE_HEIGHT }}55 style={{ minHeight: props.codeSurface.rowHeight }}
58 >56 >
59 <NativeText57 <NativeText
60 className="select-none pr-3 text-right text-foreground-tertiary"58 className="select-none pr-3 text-right text-foreground-tertiary"
61 style={{59 style={{
62 width: SOURCE_LINE_NUMBER_WIDTH,60 width: props.codeSurface.gutterWidth,
63 fontFamily: REVIEW_MONO_FONT_FAMILY,61 fontFamily: REVIEW_MONO_FONT_FAMILY,
64 fontSize: MOBILE_CODE_SURFACE.lineNumberFontSize,62 fontSize: props.codeSurface.lineNumberFontSize,
65 lineHeight: MOBILE_CODE_SURFACE.rowHeight,63 lineHeight: props.codeSurface.rowHeight,
66 }}64 }}
67 >65 >
68 {props.index + 1}66 {props.index + 1}
69 </NativeText>67 </NativeText>
70 <NativeText68 <NativeText
71 selectable69 selectable
72 numberOfLines={1}70 numberOfLines={props.wordBreak ? undefined : 1}
73 className="font-normal text-foreground"71 className="flex-1 font-normal text-foreground"
74 style={{72 style={{
75 fontFamily: REVIEW_MONO_FONT_FAMILY,73 fontFamily: REVIEW_MONO_FONT_FAMILY,
76 fontSize: MOBILE_CODE_SURFACE.fontSize,74 fontSize: props.codeSurface.fontSize,
77 lineHeight: MOBILE_CODE_SURFACE.rowHeight,75 lineHeight: props.codeSurface.rowHeight,
78 minWidth: 320,76 minWidth: props.wordBreak ? undefined : 320,
79 }}77 }}
80 >78 >
81 {props.tokens && props.tokens.length > 079 {props.tokens && props.tokens.length > 0
@@ -119,11 +117,8 @@ const HighlightedSourceLine = memo(function HighlightedSourceLine(props: { · 37 unmodified lines
119function useSourceFileModel(props: SourceFileSurfaceProps) {117function useSourceFileModel(props: SourceFileSurfaceProps) {
120 const colorScheme = useColorScheme();118 const colorScheme = useColorScheme();
121 const theme: "dark" | "light" = colorScheme === "dark" ? "dark" : "light";119 const theme: "dark" | "light" = colorScheme === "dark" ? "dark" : "light";
122 const normalizedContents = useMemo(120 const document = useMemo(() => prepareSourceFileDocument(props.contents), [props.contents]);
123 () => props.contents.replace(/\r\n?/g, "\n"),121 const { contents: normalizedContents, lines, rowsJson } = document;
124 [props.contents],
125 );
126 const lines = useMemo(() => splitSourceLines(normalizedContents), [normalizedContents]);
127 const targetIndex =122 const targetIndex =
128 props.initialLine !== null && props.initialLine !== undefined && props.initialLine > 0123 props.initialLine !== null && props.initialLine !== undefined && props.initialLine > 0
129 ? Math.min(Math.floor(props.initialLine) - 1, Math.max(0, lines.length - 1))124 ? Math.min(Math.floor(props.initialLine) - 1, Math.max(0, lines.length - 1))
@@ -140,7 +135,7 @@ function useSourceFileModel(props: SourceFileSurfaceProps) { · 10 unmodified lines
140 ? "ready"135 ? "ready"
141 : "highlighting";136 : "highlighting";
142 137
143 return { lines, status, targetIndex, theme, tokens };138 return { lines, rowsJson, status, targetIndex, theme, tokens };
144}139}
145 140
146function SourceHighlightStatusView(props: { readonly status: SourceHighlightStatus }) {141function SourceHighlightStatusView(props: { readonly status: SourceHighlightStatus }) {
@@ -162,39 +157,63 @@ function NativeSourceFileSurface( · 15 unmodified lines
162 readonly NativeView: ComponentType<NativeReviewDiffViewProps>;157 readonly NativeView: ComponentType<NativeReviewDiffViewProps>;
163 },158 },
164) {159) {
165 const { NativeView } = props;160 const { NativeView, onRefresh } = props;
166 const { lines, status, targetIndex, theme, tokens } = useSourceFileModel(props);161 const { codeSurface, codeWordBreak, nativeSourceStyle } = useAppearanceCodeSurface();
167 const rowsJson = useMemo(() => JSON.stringify(buildNativeSourceRows(lines)), [lines]);162 const { width: viewportWidth } = useWindowDimensions();
163 const { rowsJson, status, targetIndex, theme, tokens } = useSourceFileModel(props);
164 const [isPullRefreshing, setIsPullRefreshing] = useState(false);
165 const handlePullToRefresh = useCallback(async () => {
166 if (!onRefresh) {
167 return;
168 }
169 setIsPullRefreshing(true);
170 try {
171 await onRefresh();
172 } finally {
173 setIsPullRefreshing(false);
174 }
175 }, [onRefresh]);
168 const tokensJson = useMemo(() => JSON.stringify(buildNativeSourceTokens(tokens)), [tokens]);176 const tokensJson = useMemo(() => JSON.stringify(buildNativeSourceTokens(tokens)), [tokens]);
169 const selectedRowIdsJson = useMemo(177 const selectedRowIdsJson = useMemo(
170 () => JSON.stringify(targetIndex === null ? [] : [nativeSourceRowId(targetIndex)]),178 () => JSON.stringify(targetIndex === null ? [] : [nativeSourceRowId(targetIndex)]),
171 [targetIndex],179 [targetIndex],
172 );180 );
173 const themeJson = useMemo(() => JSON.stringify(createNativeReviewDiffTheme(theme)), [theme]);181 const themeJson = useMemo(() => JSON.stringify(createNativeReviewDiffTheme(theme)), [theme]);
182 const styleJson = useMemo(() => JSON.stringify(nativeSourceStyle), [nativeSourceStyle]);
183 const contentWidth = codeWordBreak
184 ? Math.max(240, viewportWidth - codeSurface.gutterWidth - 24)
185 : NATIVE_SOURCE_CONTENT_WIDTH;
174 186
175 return (187 return (
176 <View className="relative flex-1 bg-card">188 <View className="relative flex-1 bg-sheet">
177 <SourceHighlightStatusView status={status} />189 <SourceHighlightStatusView status={status} />
178 <NativeView190 <NativeView
179 key={props.path}
180 collapsable={false}191 collapsable={false}
181 testID="source-native-code-view"192 testID="source-native-code-view"
182 style={{ flex: 1 }}193 style={{ flex: 1 }}
183 appearanceScheme={theme}194 appearanceScheme={theme}
184 contentWidth={NATIVE_SOURCE_CONTENT_WIDTH}195 contentResetKey={props.path}
196 contentWidth={contentWidth}
185 initialRowIndex={targetIndex ?? -1}197 initialRowIndex={targetIndex ?? -1}
186 rowHeight={NATIVE_SOURCE_ROW_HEIGHT}198 rowHeight={nativeSourceStyle.rowHeight ?? codeSurface.rowHeight}
187 rowsJson={rowsJson}199 rowsJson={rowsJson}
188 selectedRowIdsJson={selectedRowIdsJson}200 selectedRowIdsJson={selectedRowIdsJson}
189 styleJson={NATIVE_SOURCE_STYLE_JSON}201 styleJson={styleJson}
190 themeJson={themeJson}202 themeJson={themeJson}
191 tokensJson={tokensJson}203 tokensJson={tokensJson}
204 {...(onRefresh
205 ? {
206 refreshing: isPullRefreshing,
207 onPullToRefresh: () => void handlePullToRefresh(),
208 }
209 : {})}
192 />210 />
193 </View>211 </View>
194 );212 );
195}213}
196 214
197function JavaScriptSourceFileSurface(props: SourceFileSurfaceProps) {215function JavaScriptSourceFileSurface(props: SourceFileSurfaceProps) {
216 const { codeSurface, codeWordBreak } = useAppearanceCodeSurface();
198 const { lines, status, targetIndex, tokens } = useSourceFileModel(props);217 const { lines, status, targetIndex, tokens } = useSourceFileModel(props);
199 const listRef = useRef<FlatList<string>>(null);218 const listRef = useRef<FlatList<string>>(null);
200 219
@@ -211,39 +230,53 @@ function JavaScriptSourceFileSurface(props: SourceFileSurfaceProps) { · 10 unmodified lines
211 const renderLine = useCallback(230 const renderLine = useCallback(
212 ({ item, index }: { item: string; index: number }) => (231 ({ item, index }: { item: string; index: number }) => (
213 <HighlightedSourceLine232 <HighlightedSourceLine
233 codeSurface={codeSurface}
214 index={index}234 index={index}
215 line={item}235 line={item}
216 tokens={tokens?.[index] ?? null}236 tokens={tokens?.[index] ?? null}
217 highlighted={index === targetIndex}237 highlighted={index === targetIndex}
238 wordBreak={codeWordBreak}
218 />239 />
219 ),240 ),
220 [targetIndex, tokens],241 [codeSurface, codeWordBreak, targetIndex, tokens],
242 );
243
244 const list = (
245 <FlatList
246 ref={listRef}
247 data={lines}
248 keyExtractor={(_line, index) => String(index)}
249 initialNumToRender={80}
250 maxToRenderPerBatch={80}
251 windowSize={12}
252 {...(codeWordBreak
253 ? {}
254 : {
255 getItemLayout: (_data, index) => ({
256 length: codeSurface.rowHeight,
257 offset: codeSurface.rowHeight * index,
258 index,
259 }),
260 })}
261 contentContainerStyle={{
262 minWidth: codeWordBreak ? undefined : "100%",
263 paddingBottom: codeSurface.rowHeight,
264 paddingTop: 8,
265 }}
266 renderItem={renderLine}
267 />
221 );268 );
222 269
223 return (270 return (
224 <View className="relative flex-1 bg-card">271 <View className="relative flex-1 bg-sheet">
225 <SourceHighlightStatusView status={status} />272 <SourceHighlightStatusView status={status} />
226 <ScrollView horizontal bounces={false} className="flex-1">273 {codeWordBreak ? (
227 <FlatList274 list
228 ref={listRef}275 ) : (
229 data={lines}276 <ScrollView horizontal bounces={false} className="flex-1">
230 keyExtractor={(_line, index) => String(index)}277 {list}
231 initialNumToRender={80}278 </ScrollView>
232 maxToRenderPerBatch={80}279 )}
233 windowSize={12}
234 getItemLayout={(_data, index) => ({
235 length: SOURCE_LINE_HEIGHT,
236 offset: SOURCE_LINE_HEIGHT * index,
237 index,
238 })}
239 contentContainerStyle={{
240 minWidth: "100%",
241 paddingBottom: REVIEW_DIFF_LINE_HEIGHT,
242 paddingTop: 8,
243 }}
244 renderItem={renderLine}
245 />
246 </ScrollView>
247 </View>280 </View>
248 );281 );
249}282}
apps/mobile/src/features/files/ThreadFilesRouteScreen.tsx+327−308
@@ -1,8 +1,15 @@
1import Stack from "expo-router/stack";1import { NativeHeaderToolbar, NativeStackScreenOptions } from "../../native/StackHeader";
2import { SymbolView } from "expo-symbols";2import { StackActions, useNavigation, type StaticScreenProps } from "@react-navigation/native";
3import { useLocalSearchParams, useRouter } from "expo-router";3import { useCallback, useEffect, useMemo, useRef, useState } from "react";
4import { useCallback, useMemo, useRef, useState } from "react";4import {
5import { ActivityIndicator, Pressable, ScrollView, Text as RNText, View } from "react-native";5 ActivityIndicator,
6 Platform,
7 Pressable,
8 ScrollView,
9 useColorScheme,
10 View,
11} from "react-native";
12import { useSafeAreaInsets } from "react-native-safe-area-context";
6import Svg, { Defs, LinearGradient, Rect, Stop } from "react-native-svg";13import Svg, { Defs, LinearGradient, Rect, Stop } from "react-native-svg";
7import {14import {
8 EnvironmentId,15 EnvironmentId,
@@ -12,22 +19,31 @@ import { · 3 unmodified lines
12} from "@t3tools/contracts";19} from "@t3tools/contracts";
13 20
14import { AppText as Text } from "../../components/AppText";21import { AppText as Text } from "../../components/AppText";
15import { CopyTextButton } from "../../components/CopyTextButton";
16import { EmptyState } from "../../components/EmptyState";22import { EmptyState } from "../../components/EmptyState";
17import { LoadingScreen } from "../../components/LoadingScreen";23import { LoadingScreen } from "../../components/LoadingScreen";
18import { cn } from "../../lib/cn";24import { cn } from "../../lib/cn";
25import { resolveFileSelectionNavigationAction } from "../../lib/adaptive-navigation";
26import { copyTextWithHaptic } from "../../lib/copyTextWithHaptic";
19import { tryOpenExternalUrl } from "../../lib/openExternalUrl";27import { tryOpenExternalUrl } from "../../lib/openExternalUrl";
20import { buildThreadFilesNavigation } from "../../lib/routes";
21import { MOBILE_TYPOGRAPHY } from "../../lib/typography";
22import { useThemeColor } from "../../lib/useThemeColor";28import { useThemeColor } from "../../lib/useThemeColor";
23import { useThreadSelection } from "../../state/use-thread-selection";29import { useThreadSelection } from "../../state/use-thread-selection";
24import { useSelectedThreadWorktree } from "../../state/use-selected-thread-worktree";30import { useSelectedThreadWorktree } from "../../state/use-selected-thread-worktree";
25import { useEnvironmentQuery } from "../../state/query";31import { useEnvironmentQuery } from "../../state/query";
26import { projectEnvironment } from "../../state/projects";32import { projectEnvironment } from "../../state/projects";
33import {
34 useAdaptiveWorkspaceLayout,
35 useAdaptiveWorkspacePaneRole,
36 useRegisterWorkspaceInspector,
37} from "../layout/AdaptiveWorkspaceLayout";
38import { createNativeMailSearchToolbarItem } from "../layout/native-mail-search-toolbar";
39import { WorkspaceSidebarToolbar } from "../layout/workspace-sidebar-toolbar";
27import { ReviewHighlighterProvider } from "../review/ReviewHighlighterProvider";40import { ReviewHighlighterProvider } from "../review/ReviewHighlighterProvider";
41import { ThreadRouteScreen } from "../threads/ThreadRouteScreen";
28import { FileMarkdownPreview } from "./FileMarkdownPreview";42import { FileMarkdownPreview } from "./FileMarkdownPreview";
29import { FileTreeBrowser } from "./FileTreeBrowser";43import { FileTreeBrowser } from "./FileTreeBrowser";
44import { preloadWorkspaceFileContents } from "./preload-workspace-file";
30import { SourceFileSurface } from "./SourceFileSurface";45import { SourceFileSurface } from "./SourceFileSurface";
46import { ThreadFileNavigatorPane } from "./thread-file-navigator-pane";
31import { WorkspaceFileImagePreview } from "./WorkspaceFileImagePreview";47import { WorkspaceFileImagePreview } from "./WorkspaceFileImagePreview";
32import { WorkspaceFileWebPreview } from "./WorkspaceFileWebPreview";48import { WorkspaceFileWebPreview } from "./WorkspaceFileWebPreview";
33import {49import {
@@ -72,196 +88,6 @@ function defaultViewMode(path: string | null): FileViewMode { · 38 unmodified lines
72 : "source";88 : "source";
73}89}
74 90
75function ModeButton(props: {
76 readonly active: boolean;
77 readonly icon: "doc.text" | "eye";
78 readonly label: string;
79 readonly onPress: () => void;
80}) {
81 const iconColor = String(
82 useThemeColor(props.active ? "--color-primary-foreground" : "--color-icon-muted"),
83 );
84
85 return (
86 <Pressable
87 accessibilityRole="button"
88 accessibilityState={{ selected: props.active }}
89 className={cn(
90 "h-8 flex-row items-center justify-center gap-1.5 rounded-full px-3 active:opacity-70",
91 props.active ? "bg-primary" : "bg-subtle",
92 )}
93 onPress={props.onPress}
94 >
95 <SymbolView name={props.icon} size={13} tintColor={iconColor} type="monochrome" />
96 <Text
97 className={cn(
98 "text-xs font-t3-bold",
99 props.active ? "text-primary-foreground" : "text-foreground-muted",
100 )}
101 >
102 {props.label}
103 </Text>
104 </Pressable>
105 );
106}
107
108function BreadcrumbFade(props: { readonly color: string; readonly side: "left" | "right" }) {
109 const gradientId = `file-breadcrumb-${props.side}-fade`;
110 const isLeft = props.side === "left";
111
112 return (
113 <View
114 pointerEvents="none"
115 className={cn("absolute inset-y-0 w-7", isLeft ? "left-0" : "right-0")}
116 >
117 <Svg width="100%" height="100%">
118 <Defs>
119 <LinearGradient id={gradientId} x1="0%" x2="100%" y1="0%" y2="0%">
120 <Stop offset="0%" stopColor={props.color} stopOpacity={isLeft ? 1 : 0} />
121 <Stop offset="100%" stopColor={props.color} stopOpacity={isLeft ? 0 : 1} />
122 </LinearGradient>
123 </Defs>
124 <Rect width="100%" height="100%" fill={`url(#${gradientId})`} />
125 </Svg>
126 </View>
127 );
128}
129
130function FileBreadcrumbs(props: { readonly projectName: string; readonly relativePath: string }) {
131 const iconColor = String(useThemeColor("--color-icon-muted"));
132 const cardColor = String(useThemeColor("--color-card"));
133 const scrollMetrics = useRef({ contentWidth: 0, offsetX: 0, viewportWidth: 0 });
134 const [fadeVisibility, setFadeVisibility] = useState({ left: false, right: false });
135 const breadcrumbs = useMemo(
136 () => fileBreadcrumbs(props.projectName, props.relativePath),
137 [props.projectName, props.relativePath],
138 );
139 const updateFadeVisibility = useCallback(
140 (metrics: Partial<(typeof scrollMetrics)["current"]>) => {
141 Object.assign(scrollMetrics.current, metrics);
142 const { contentWidth, offsetX, viewportWidth } = scrollMetrics.current;
143 const maxOffset = Math.max(0, contentWidth - viewportWidth);
144 const next = {
145 left: maxOffset > 1 && offsetX > 1,
146 right: maxOffset > 1 && offsetX < maxOffset - 1,
147 };
148
149 setFadeVisibility((current) =>
150 current.left === next.left && current.right === next.right ? current : next,
151 );
152 },
153 [],
154 );
155
156 return (
157 <View className="min-w-0 flex-1">
158 <ScrollView
159 horizontal
160 showsHorizontalScrollIndicator={false}
161 onContentSizeChange={(contentWidth) => {
162 updateFadeVisibility({ contentWidth });
163 }}
164 onLayout={(event) => {
165 updateFadeVisibility({ viewportWidth: event.nativeEvent.layout.width });
166 }}
167 onScroll={(event) => {
168 updateFadeVisibility({ offsetX: event.nativeEvent.contentOffset.x });
169 }}
170 scrollEventThrottle={16}
171 >
172 <View className="h-8 flex-row items-center">
173 {breadcrumbs.map((crumb, index) => (
174 <View key={crumb.path || "project"} className="flex-row items-center">
175 {index > 0 ? (
176 <SymbolView
177 name="chevron.right"
178 size={10}
179 tintColor={iconColor}
180 type="monochrome"
181 />
182 ) : null}
183 <Text
184 className={cn(
185 "max-w-[180px] px-1 text-xs",
186 crumb.kind === "file"
187 ? "font-t3-bold text-foreground"
188 : "font-t3-medium text-foreground-muted",
189 )}
190 numberOfLines={1}
191 >
192 {crumb.label}
193 </Text>
194 </View>
195 ))}
196 </View>
197 </ScrollView>
198 {fadeVisibility.left ? <BreadcrumbFade color={cardColor} side="left" /> : null}
199 {fadeVisibility.right ? <BreadcrumbFade color={cardColor} side="right" /> : null}
200 </View>
201 );
202}
203
204function FilePreviewHeader(props: {
205 readonly activeMode: FileViewMode;
206 readonly showModeSelector: boolean;
207 readonly externalPreviewUri?: string | null;
208 readonly projectName: string;
209 readonly relativePath: string;
210 readonly onSetMode: (mode: FileViewMode) => void;
211}) {
212 const iconColor = String(useThemeColor("--color-icon-muted"));
213
214 return (
215 <View className="border-b border-border bg-card px-3 py-2">
216 <View className="flex-row items-center gap-2">
217 <FileBreadcrumbs projectName={props.projectName} relativePath={props.relativePath} />
218 <CopyTextButton
219 accessibilityLabel="Copy file path"
220 text={props.relativePath}
221 tintColor={iconColor}
222 buttonSize={32}
223 iconSize={13}
224 />
225 </View>
226 {props.showModeSelector ? (
227 <View className="mt-2 flex-row items-center gap-2">
228 <ModeButton
229 active={props.activeMode === "preview"}
230 icon="eye"
231 label="Preview"
232 onPress={() => props.onSetMode("preview")}
233 />
234 <ModeButton
235 active={props.activeMode === "source"}
236 icon="doc.text"
237 label="Source"
238 onPress={() => props.onSetMode("source")}
239 />
240 {props.externalPreviewUri !== undefined ? (
241 <Pressable
242 accessibilityRole="button"
243 accessibilityLabel="Open preview in Safari"
244 disabled={props.externalPreviewUri === null}
245 hitSlop={8}
246 className={cn(
247 "ml-auto h-8 w-8 items-center justify-center rounded-full bg-subtle active:opacity-70",
248 props.externalPreviewUri === null && "opacity-40",
249 )}
250 onPress={() => {
251 if (typeof props.externalPreviewUri === "string") {
252 void tryOpenExternalUrl(props.externalPreviewUri, "file-preview");
253 }
254 }}
255 >
256 <SymbolView name="safari" size={15} tintColor={iconColor} type="monochrome" />
257 </Pressable>
258 ) : null}
259 </View>
260 ) : null}
261 </View>
262 );
263}
264
265function FileContent(props: {91function FileContent(props: {
266 readonly activeMode: FileViewMode;92 readonly activeMode: FileViewMode;
267 readonly previewUri: string | null;93 readonly previewUri: string | null;
@@ -270,6 +96,7 @@ function FileContent(props: { · 2 unmodified lines
270 readonly relativePath: string;96 readonly relativePath: string;
271 readonly initialLine: number | null;97 readonly initialLine: number | null;
272 readonly truncated: boolean;98 readonly truncated: boolean;
99 readonly onRefresh?: () => Promise<void> | void;
273}) {100}) {
274 const isMarkdown = isMarkdownPreviewFile(props.relativePath);101 const isMarkdown = isMarkdownPreviewFile(props.relativePath);
275 const isBrowserFile = isBrowserPreviewFile(props.relativePath);102 const isBrowserFile = isBrowserPreviewFile(props.relativePath);
@@ -293,7 +120,7 @@ function FileContent(props: { · 17 unmodified lines
293 120
294 if (props.fileError && props.fileContents === null) {121 if (props.fileError && props.fileContents === null) {
295 return (122 return (
296 <View className="flex-1 items-center justify-center bg-card px-6">123 <View className="flex-1 items-center justify-center bg-sheet px-6">
297 <EmptyState title="File unavailable" detail={props.fileError} />124 <EmptyState title="File unavailable" detail={props.fileError} />
298 </View>125 </View>
299 );126 );
@@ -301,7 +128,7 @@ function FileContent(props: { · 1 unmodified line
301 128
302 if (props.fileContents === null) {129 if (props.fileContents === null) {
303 return (130 return (
304 <View className="flex-1 items-center justify-center gap-3 bg-card px-6">131 <View className="flex-1 items-center justify-center gap-3 bg-sheet px-6">
305 <ActivityIndicator />132 <ActivityIndicator />
306 <Text className="text-center text-sm text-foreground-muted">Loading file...</Text>133 <Text className="text-center text-sm text-foreground-muted">Loading file...</Text>
307 </View>134 </View>
@@ -309,35 +136,47 @@ function FileContent(props: { · 1 unmodified line
309 }136 }
310 137
311 return (138 return (
312 <View className="flex-1 bg-card">139 <View className="flex-1 bg-sheet">
313 {props.truncated ? (140 {props.truncated ? (
314 <View className="border-b border-amber-200 bg-amber-50 px-4 py-2 dark:border-amber-900/60 dark:bg-amber-950/40">141 <View className="border-b border-amber-200 bg-amber-50 px-4 py-2 dark:border-amber-900/60 dark:bg-amber-950/40">
315 <Text className="text-2xs font-t3-bold uppercase text-amber-700 dark:text-amber-300">142 <Text className="text-2xs font-t3-bold uppercase text-amber-700 dark:text-amber-300">
316 Partial file143 Partial file
317 </Text>144 </Text>
318 <Text className="text-xs leading-[17px] text-amber-800 dark:text-amber-200">145 <Text className="text-xs leading-snug text-amber-800 dark:text-amber-200">
319 Preview limited to the first 1 MB of a truncated file.146 Preview limited to the first 1 MB of a truncated file.
320 </Text>147 </Text>
321 </View>148 </View>
322 ) : null}149 ) : null}
323 {props.activeMode === "preview" && isMarkdown ? (150 {props.activeMode === "preview" && isMarkdown ? (
324 <FileMarkdownPreview markdown={props.fileContents} />151 <FileMarkdownPreview markdown={props.fileContents} onRefresh={props.onRefresh} />
325 ) : (152 ) : (
326 <SourceFileSurface153 <SourceFileSurface
327 contents={props.fileContents}154 contents={props.fileContents}
328 path={props.relativePath}155 path={props.relativePath}
329 initialLine={props.initialLine}156 initialLine={props.initialLine}
157 onRefresh={props.onRefresh}
330 />158 />
331 )}159 )}
332 </View>160 </View>
333 );161 );
334}162}
335 163
336function useThreadFilesWorkspace() {164type ThreadFilesRouteScreenProps = StaticScreenProps<{
337 const params = useLocalSearchParams<{165 readonly environmentId: string;
338 environmentId?: string | string[];166 readonly threadId: string;
339 threadId?: string | string[];167}>;
340 }>();168
169type ThreadFileRouteScreenProps = StaticScreenProps<{
170 readonly environmentId: string;
171 readonly threadId: string;
172 readonly path: string[];
173 readonly line?: string;
174}>;
175
176function useThreadFilesWorkspace(params: {
177 readonly environmentId?: string | string[];
178 readonly threadId?: string | string[];
179}) {
341 const routeEnvironmentId = firstRouteParam(params.environmentId);180 const routeEnvironmentId = firstRouteParam(params.environmentId);
342 const routeThreadId = firstRouteParam(params.threadId);181 const routeThreadId = firstRouteParam(params.threadId);
343 const { selectedThread, selectedThreadProject } = useThreadSelection();182 const { selectedThread, selectedThreadProject } = useThreadSelection();
@@ -364,7 +203,7 @@ function useThreadFilesWorkspace() { · 20 unmodified lines
364function FilesUnavailable() {203function FilesUnavailable() {
365 return (204 return (
366 <View className="flex-1 items-center justify-center bg-sheet px-6">205 <View className="flex-1 items-center justify-center bg-sheet px-6">
367 <Stack.Screen options={{ title: "Files" }} />206 <NativeStackScreenOptions options={{ title: "Files" }} />
368 <EmptyState207 <EmptyState
369 title="Files unavailable"208 title="Files unavailable"
370 detail="This thread does not have an active workspace path."209 detail="This thread does not have an active workspace path."
@@ -373,40 +212,6 @@ function FilesUnavailable() { · 2 unmodified lines
373 );212 );
374}213}
375 214
376function FilesHeaderTitle(props: { readonly projectName: string }) {
377 const foregroundColor = String(useThemeColor("--color-foreground"));
378 const secondaryForegroundColor = String(useThemeColor("--color-foreground-secondary"));
379
380 return (
381 <View style={{ alignItems: "center", maxWidth: 220 }}>
382 <RNText
383 numberOfLines={1}
384 style={{
385 color: foregroundColor,
386 fontFamily: "DMSans_700Bold",
387 fontSize: MOBILE_TYPOGRAPHY.headline.fontSize,
388 fontWeight: "900",
389 letterSpacing: -0.4,
390 }}
391 >
392 Files
393 </RNText>
394 <RNText
395 numberOfLines={1}
396 style={{
397 color: secondaryForegroundColor,
398 fontFamily: "DMSans_500Medium",
399 fontSize: MOBILE_TYPOGRAPHY.label.fontSize,
400 fontWeight: "500",
401 letterSpacing: 0.2,
402 }}
403 >
404 {props.projectName}
405 </RNText>
406 </View>
407 );
408}
409
410function FilesToolbarBottomFade() {215function FilesToolbarBottomFade() {
411 const sheetColor = String(useThemeColor("--color-sheet"));216 const sheetColor = String(useThemeColor("--color-sheet"));
412 217
@@ -442,12 +247,20 @@ function FilesToolbarBottomFade() { · 29 unmodified lines
442 );247 );
443}248}
444 249
445export function ThreadFilesTreeScreen() {250export function ThreadFilesTreeScreen(props: ThreadFilesRouteScreenProps) {
446 const router = useRouter();251 useAdaptiveWorkspacePaneRole("inspector");
252 const navigation = useNavigation();
253 const { fileInspector, layout, panes, showAuxiliaryPane, togglePrimarySidebar } =
254 useAdaptiveWorkspaceLayout();
447 const [searchQuery, setSearchQuery] = useState("");255 const [searchQuery, setSearchQuery] = useState("");
448 const { cwd, environmentId, projectName, selectedThread, threadId } = useThreadFilesWorkspace();256 const colorScheme = useColorScheme();
257 const highlightTheme = colorScheme === "dark" ? "dark" : "light";
258 const { cwd, environmentId, projectName, selectedThread, threadId } = useThreadFilesWorkspace(
259 props.route.params,
260 );
261 const revealedInspectorRef = useRef(false);
449 const entriesQuery = useEnvironmentQuery(262 const entriesQuery = useEnvironmentQuery(
450 environmentId !== null && cwd !== null263 environmentId !== null && cwd !== null && !fileInspector.supported
451 ? projectEnvironment.listEntries({264 ? projectEnvironment.listEntries({
452 environmentId,265 environmentId,
453 input: { cwd },266 input: { cwd },
@@ -455,18 +268,87 @@ export function ThreadFilesTreeScreen() { · 1 unmodified line
455 : null,268 : null,
456 );269 );
457 const entriesData = entriesQuery.data as ProjectListEntriesResult | null;270 const entriesData = entriesQuery.data as ProjectListEntriesResult | null;
271 const handleReturnToThread = useCallback(() => {
272 if (navigation.canGoBack()) {
273 navigation.goBack();
274 return;
275 }
276 if (environmentId !== null && threadId !== null) {
277 navigation.dispatch(
278 StackActions.replace("Thread", {
279 environmentId: String(environmentId),
280 threadId: String(threadId),
281 }),
282 );
283 }
284 }, [environmentId, navigation, threadId]);
458 285
459 const handleSelectFile = useCallback(286 const handleSelectFile = useCallback(
460 (path: string) => {287 (path: string) => {
461 if (environmentId === null || threadId === null) {288 if (environmentId === null || threadId === null) {
462 return;289 return;
463 }290 }
464 router.push(buildThreadFilesNavigation({ environmentId, threadId }, path));291 const params = {
292 environmentId: String(environmentId),
293 threadId: String(threadId),
294 path: path.split("/").filter((segment) => segment.length > 0),
295 };
296 const navigationAction = resolveFileSelectionNavigationAction({
297 hasPersistentFileInspector: fileInspector.supported,
298 });
299 if (navigationAction === "replace") {
300 navigation.dispatch(StackActions.replace("ThreadFile", params));
301 return;
302 }
303 navigation.navigate("ThreadFile", params);
465 },304 },
466 [environmentId, router, threadId],305 [environmentId, fileInspector.supported, navigation, threadId],
467 );306 );
307 const renderInspector = useCallback(
308 (headerInset: number) =>
309 environmentId !== null && cwd !== null ? (
310 <ThreadFileNavigatorPane
311 cwd={cwd}
312 environmentId={environmentId}
313 headerInset={headerInset}
314 projectName={projectName}
315 selectedPath={null}
316 onSelectFile={handleSelectFile}
317 />
318 ) : null,
319 [cwd, environmentId, handleSelectFile, projectName],
320 );
321 const handlePreviewFile = useCallback(
322 (relativePath: string) => {
323 if (environmentId === null || cwd === null) {
324 return;
325 }
326 preloadWorkspaceFileContents({
327 cwd,
328 environmentId,
329 relativePath,
330 theme: highlightTheme,
331 });
332 },
333 [cwd, environmentId, highlightTheme],
334 );
335 useEffect(() => {
336 if (fileInspector.supported && cwd !== null && !revealedInspectorRef.current) {
337 revealedInspectorRef.current = true;
338 showAuxiliaryPane("inspector");
339 }
340 }, [cwd, fileInspector.supported, showAuxiliaryPane]);
468 341
469 if (selectedThread === null || environmentId === null || threadId === null) {342 if (selectedThread === null || environmentId === null || threadId === null) {
343 if (fileInspector.supported) {
344 return (
345 <ThreadRouteScreen
346 onReturnToThread={handleReturnToThread}
347 renderInspector={renderInspector}
348 route={props.route}
349 />
350 );
351 }
470 return <LoadingScreen message="Opening files..." messagePlacement="above-spinner" />;352 return <LoadingScreen message="Opening files..." messagePlacement="above-spinner" />;
471 }353 }
472 354
@@ -474,62 +356,95 @@ export function ThreadFilesTreeScreen() { · 1 unmodified line
474 return <FilesUnavailable />;356 return <FilesUnavailable />;
475 }357 }
476 358
359 if (fileInspector.supported) {
360 return (
361 <ThreadRouteScreen
362 onReturnToThread={handleReturnToThread}
363 renderInspector={renderInspector}
364 route={props.route}
365 />
366 );
367 }
368
369 const usesCompactMailToolbar = Platform.OS === "ios" && !layout.usesSplitView;
370
477 return (371 return (
478 <View className="flex-1 bg-sheet">372 <>
479 <Stack.Screen373 {/* Static header config (glass preset, title, contentStyle) lives in Stack.tsx.
374 Only genuinely dynamic options are set here. */}
375 <NativeStackScreenOptions
480 options={{376 options={{
481 title: "Files",377 unstable_headerSubtitle:
482 headerShown: true,378 Platform.OS === "ios" && projectName.length > 0 ? projectName : undefined,
483 headerTransparent: true,379 // No refresh button: the list already supports pull-to-refresh.
484 headerStyle: { backgroundColor: "transparent" },380 unstable_headerToolbarItems: usesCompactMailToolbar
485 headerShadowVisible: false,381 ? () => [
486 headerTitle: () => <FilesHeaderTitle projectName={projectName} />,382 createNativeMailSearchToolbarItem({
487 headerSearchBarOptions: {383 onSearchTextChange: setSearchQuery,
488 allowToolbarIntegration: true,384 placeholder: "Search files",
489 autoCapitalize: "none",385 searchTextChangeId: "files-search-text",
490 hideNavigationBar: false,386 }),
491 placeholder: "Search files",387 ]
492 onChangeText: (event) => {388 : undefined,
493 setSearchQuery(event.nativeEvent.text);389 headerSearchBarOptions: usesCompactMailToolbar
494 },390 ? undefined
495 onCancelButtonPress: () => {391 : {
496 setSearchQuery("");392 allowToolbarIntegration: true,
497 },393 autoCapitalize: "none",
498 },394 hideNavigationBar: false,
395 placeholder: "Search files",
396 onChangeText: (event) => {
397 setSearchQuery(event.nativeEvent.text);
398 },
399 onCancelButtonPress: () => {
400 setSearchQuery("");
401 },
402 },
499 }}403 }}
500 />404 />
501 <Stack.Toolbar placement="right">405 {layout.usesSplitView ? (
502 <Stack.Toolbar.Button406 <NativeHeaderToolbar placement="left">
503 accessibilityLabel="Refresh files"407 <NativeHeaderToolbar.Button
504 icon="arrow.clockwise"408 accessibilityLabel={panes.primarySidebarVisible ? "Maximize files" : "Show threads"}
505 onPress={entriesQuery.refresh}409 icon={
506 />410 panes.primarySidebarVisible ? "arrow.up.left.and.arrow.down.right" : "sidebar.left"
507 </Stack.Toolbar>411 }
508 <Stack.Toolbar placement="bottom">412 onPress={togglePrimarySidebar}
509 <Stack.Toolbar.SearchBarSlot />413 separateBackground
510 </Stack.Toolbar>414 />
415 </NativeHeaderToolbar>
416 ) : null}
417 {usesCompactMailToolbar ? null : (
418 <NativeHeaderToolbar placement="bottom">
419 <NativeHeaderToolbar.SearchBarSlot />
420 </NativeHeaderToolbar>
421 )}
511 <FileTreeBrowser422 <FileTreeBrowser
512 entries={entriesData?.entries ?? []}423 entries={entriesData?.entries ?? []}
513 error={entriesQuery.error}424 error={entriesQuery.error}
514 isPending={entriesQuery.isPending}425 isPending={entriesQuery.isPending}
515 searchQuery={searchQuery}426 searchQuery={searchQuery}
516 selectedPath={null}427 selectedPath={null}
428 onPreviewFile={handlePreviewFile}
517 onRefresh={entriesQuery.refresh}429 onRefresh={entriesQuery.refresh}
518 onSelectFile={handleSelectFile}430 onSelectFile={handleSelectFile}
519 />431 />
520 <FilesToolbarBottomFade />432 <FilesToolbarBottomFade />
521 </View>433 </>
522 );434 );
523}435}
524 436
525export function ThreadFileScreen() {437export function ThreadFileScreen(props: ThreadFileRouteScreenProps) {
526 const params = useLocalSearchParams<{438 useAdaptiveWorkspacePaneRole("inspector");
527 line?: string | string[];439 const navigation = useNavigation();
528 path?: string | string[];440 const { fileInspector, panes, toggleAuxiliaryPane } = useAdaptiveWorkspaceLayout();
529 }>();441 const iconColor = useThemeColor("--color-icon");
442 const params = props.route.params;
530 const relativePath = normalizeRoutePath(params.path);443 const relativePath = normalizeRoutePath(params.path);
531 const targetLine = normalizeRouteLine(firstRouteParam(params.line));444 const targetLine = normalizeRouteLine(firstRouteParam(params.line));
532 const { cwd, environmentId, projectName, selectedThread, threadId } = useThreadFilesWorkspace();445 const { cwd, environmentId, projectName, selectedThread, threadId } = useThreadFilesWorkspace(
446 props.route.params,
447 );
533 const [modeOverride, setModeOverride] = useState<{448 const [modeOverride, setModeOverride] = useState<{
534 readonly path: string;449 readonly path: string;
535 readonly mode: FileViewMode;450 readonly mode: FileViewMode;
@@ -568,6 +483,43 @@ export function ThreadFileScreen() { · 32 unmodified lines
568 );483 );
569 const fileData = fileQuery.data as ProjectReadFileResult | null;484 const fileData = fileQuery.data as ProjectReadFileResult | null;
570 485
486 const handleSelectFile = useCallback(
487 (path: string) => {
488 navigation.navigate("ThreadFile", {
489 environmentId: String(environmentId),
490 threadId: String(threadId),
491 path: path.split("/").filter(Boolean),
492 });
493 },
494 [environmentId, navigation, threadId],
495 );
496 const renderInspector = useCallback(
497 (headerInset: number) =>
498 fileInspector.supported && environmentId !== null && cwd !== null ? (
499 <ThreadFileNavigatorPane
500 cwd={cwd}
501 environmentId={environmentId}
502 headerInset={headerInset}
503 projectName={projectName}
504 selectedPath={relativePath}
505 onSelectFile={handleSelectFile}
506 />
507 ) : undefined,
508 [cwd, environmentId, fileInspector.supported, handleSelectFile, projectName, relativePath],
509 );
510 // The workspace inspector column spans the full window height. On iOS the
511 // pane brings its own nested native header; elsewhere it pads itself below
512 // the top inset.
513 const safeAreaInsets = useSafeAreaInsets();
514 const inspectorHeaderInset = Platform.OS === "ios" ? 0 : safeAreaInsets.top;
515 // Hand the file navigator to the workspace so it renders beside the
516 // navigator, outside this screen's native header.
517 const renderWorkspaceInspector = useCallback(
518 () => renderInspector(inspectorHeaderInset),
519 [inspectorHeaderInset, renderInspector],
520 );
521 useRegisterWorkspaceInspector(fileInspector.supported ? renderWorkspaceInspector : undefined);
522
571 if (selectedThread === null || environmentId === null || threadId === null) {523 if (selectedThread === null || environmentId === null || threadId === null) {
572 return <LoadingScreen message="Opening file..." messagePlacement="above-spinner" />;524 return <LoadingScreen message="Opening file..." messagePlacement="above-spinner" />;
573 }525 }
@@ -579,38 +531,104 @@ export function ThreadFileScreen() { · 5 unmodified lines
579 if (relativePath === null) {531 if (relativePath === null) {
580 return (532 return (
581 <View className="flex-1 items-center justify-center bg-sheet px-6">533 <View className="flex-1 items-center justify-center bg-sheet px-6">
582 <Stack.Screen options={{ title: "Files" }} />534 <NativeStackScreenOptions options={{ title: "Files" }} />
583 <EmptyState title="File unavailable" detail="This file path is invalid." />535 <EmptyState title="File unavailable" detail="This file path is invalid." />
584 </View>536 </View>
585 );537 );
586 }538 }
587 539
540 const parentDir = relativePath.split("/").slice(0, -1).join("/");
541 const headerSubtitle = [projectName, parentDir].filter(Boolean).join(" · ");
542
588 return (543 return (
589 <ReviewHighlighterProvider>544 <ReviewHighlighterProvider>
590 <View className="flex-1 bg-sheet">545 <View className="flex-1 bg-sheet">
591 <Stack.Screen options={{ title: basename(relativePath) }} />546 <NativeStackScreenOptions
592 <Stack.Toolbar placement="right">547 options={{
593 <Stack.Toolbar.Button548 // Static header config lives in Stack.tsx (SOLID_HEADER_OPTIONS: solid
594 icon="arrow.clockwise"549 // sheet-colored header — this route's content scrolls internally, so
595 onPress={() => {550 // there is nothing for glass to sample). Only dynamic values here.
596 if (resolvedActiveMode === "preview" && (isBrowserFile || isImageFile)) {551 headerTintColor: iconColor,
597 setPreviewRevision((current) => current + 1);552 headerTitle: basename(relativePath),
598 return;553 title: basename(relativePath),
599 }554 unstable_headerSubtitle:
600 fileQuery.refresh();555 Platform.OS === "ios" && headerSubtitle.length > 0 ? headerSubtitle : undefined,
601 }}
602 />
603 </Stack.Toolbar>
604 <FilePreviewHeader
605 activeMode={resolvedActiveMode}
606 showModeSelector={canPreview && !isImageFile}
607 externalPreviewUri={isBrowserFile ? assetPreviewUri : undefined}
608 projectName={projectName}
609 relativePath={relativePath}
610 onSetMode={(mode) => {
611 setModeOverride({ path: relativePath, mode });
612 }}556 }}
613 />557 />
558 <WorkspaceSidebarToolbar>
559 {fileInspector.supported ? (
560 <NativeHeaderToolbar.Button
561 accessibilityLabel="Return to chat"
562 icon="chevron.left"
563 onPress={() => {
564 navigation.dispatch(
565 StackActions.replace("Thread", {
566 environmentId: String(environmentId),
567 threadId: String(threadId),
568 }),
569 );
570 }}
571 />
572 ) : null}
573 </WorkspaceSidebarToolbar>
574 <NativeHeaderToolbar placement="right">
575 {fileInspector.supported ? (
576 <NativeHeaderToolbar.Button
577 accessibilityLabel={
578 panes.auxiliaryPaneVisible ? "Hide file navigator" : "Show file navigator"
579 }
580 icon="sidebar.right"
581 onPress={toggleAuxiliaryPane}
582 separateBackground
583 />
584 ) : null}
585 <NativeHeaderToolbar.Menu accessibilityLabel="File actions" icon="ellipsis">
586 {canPreview && !isImageFile ? (
587 <NativeHeaderToolbar.Menu inline>
588 <NativeHeaderToolbar.MenuAction
589 icon="eye"
590 isOn={resolvedActiveMode === "preview"}
591 onPress={() => setModeOverride({ path: relativePath, mode: "preview" })}
592 >
593 Preview
594 </NativeHeaderToolbar.MenuAction>
595 <NativeHeaderToolbar.MenuAction
596 icon="doc.text"
597 isOn={resolvedActiveMode === "source"}
598 onPress={() => setModeOverride({ path: relativePath, mode: "source" })}
599 >
600 Source
601 </NativeHeaderToolbar.MenuAction>
602 </NativeHeaderToolbar.Menu>
603 ) : null}
604 <NativeHeaderToolbar.MenuAction
605 icon="doc.on.doc"
606 onPress={() => copyTextWithHaptic(relativePath)}
607 >
608 Copy path
609 </NativeHeaderToolbar.MenuAction>
610 {isBrowserFile && typeof assetPreviewUri === "string" ? (
611 <NativeHeaderToolbar.MenuAction
612 icon="safari"
613 onPress={() => {
614 void tryOpenExternalUrl(assetPreviewUri, "file-preview");
615 }}
616 >
617 Open in Safari
618 </NativeHeaderToolbar.MenuAction>
619 ) : null}
620 {resolvedActiveMode === "preview" && (isBrowserFile || isImageFile) ? (
621 <NativeHeaderToolbar.MenuAction
622 icon="arrow.clockwise"
623 onPress={() => {
624 setPreviewRevision((current) => current + 1);
625 }}
626 >
627 Refresh
628 </NativeHeaderToolbar.MenuAction>
629 ) : null}
630 </NativeHeaderToolbar.Menu>
631 </NativeHeaderToolbar>
614 <FileContent632 <FileContent
615 activeMode={resolvedActiveMode}633 activeMode={resolvedActiveMode}
616 previewUri={previewUri}634 previewUri={previewUri}
@@ -619,6 +637,7 @@ export function ThreadFileScreen() { · 2 unmodified lines
619 initialLine={targetLine}637 initialLine={targetLine}
620 relativePath={relativePath}638 relativePath={relativePath}
621 truncated={fileData?.truncated ?? false}639 truncated={fileData?.truncated ?? false}
640 onRefresh={() => fileQuery.refresh()}
622 />641 />
623 </View>642 </View>
624 </ReviewHighlighterProvider>643 </ReviewHighlighterProvider>
apps/mobile/src/features/files/WorkspaceFileWebPreview.tsx+1−1
@@ -24,7 +24,7 @@ export function WorkspaceFileWebPreview(props: { readonly uri: string | null }) · 23 unmodified lines
24 {loadError ? (24 {loadError ? (
25 <View className="border-b border-border bg-card px-4 py-2">25 <View className="border-b border-border bg-card px-4 py-2">
26 <Text className="text-xs font-t3-bold text-foreground">Preview failed</Text>26 <Text className="text-xs font-t3-bold text-foreground">Preview failed</Text>
27 <Text className="mt-0.5 text-xs leading-[17px] text-foreground-muted">{loadError}</Text>27 <Text className="mt-0.5 text-xs leading-snug text-foreground-muted">{loadError}</Text>
28 </View>28 </View>
29 ) : null}29 ) : null}
30 <WebView30 <WebView
apps/mobile/src/features/files/nativeSourceFileAdapter.ts+26−14
@@ -3,26 +3,38 @@ import type { · 2 unmodified lines
3 NativeReviewDiffStyle,3 NativeReviewDiffStyle,
4 NativeReviewDiffToken,4 NativeReviewDiffToken,
5} from "../diffs/nativeReviewDiffSurface";5} from "../diffs/nativeReviewDiffSurface";
6import type { ResolvedMobileCodeSurface } from "../../lib/appearancePreferences";
7import { resolveMobileCodeSurface } from "../../lib/appearancePreferences";
6import { MOBILE_CODE_SURFACE, MOBILE_TYPOGRAPHY } from "../../lib/typography";8import { MOBILE_CODE_SURFACE, MOBILE_TYPOGRAPHY } from "../../lib/typography";
7import type { SourceHighlightTokens } from "./sourceHighlightingState";9import type { SourceHighlightTokens } from "./sourceHighlightingState";
8 10
9export const NATIVE_SOURCE_ROW_HEIGHT = MOBILE_CODE_SURFACE.rowHeight;11export const NATIVE_SOURCE_ROW_HEIGHT = MOBILE_CODE_SURFACE.rowHeight;
10export const NATIVE_SOURCE_CONTENT_WIDTH = 32_000;12export const NATIVE_SOURCE_CONTENT_WIDTH = 32_000;
11 13
12export const NATIVE_SOURCE_STYLE: NativeReviewDiffStyle = {14export const NATIVE_SOURCE_STYLE: NativeReviewDiffStyle = createNativeSourceStyle(
13 rowHeight: NATIVE_SOURCE_ROW_HEIGHT,15 resolveMobileCodeSurface(MOBILE_CODE_SURFACE.fontSize),
14 contentWidth: NATIVE_SOURCE_CONTENT_WIDTH,16);
15 changeBarWidth: 0,17
16 gutterWidth: MOBILE_CODE_SURFACE.gutterWidth,18export function createNativeSourceStyle(
17 codePadding: MOBILE_CODE_SURFACE.codePadding,19 codeSurface: ResolvedMobileCodeSurface,
18 textVerticalInset: MOBILE_CODE_SURFACE.textVerticalInset,20): NativeReviewDiffStyle {
19 codeFontSize: MOBILE_CODE_SURFACE.fontSize,21 return {
20 codeFontWeight: "regular",22 rowHeight: codeSurface.rowHeight,
21 lineNumberFontSize: MOBILE_CODE_SURFACE.lineNumberFontSize,23 contentWidth: NATIVE_SOURCE_CONTENT_WIDTH,
22 lineNumberFontWeight: "regular",24 changeBarWidth: 0,
23 emptyStateFontSize: MOBILE_TYPOGRAPHY.label.fontSize,25 gutterWidth: codeSurface.gutterWidth,
24 emptyStateFontWeight: "medium",26 codePadding: codeSurface.codePadding,
25};27 textVerticalInset: codeSurface.textVerticalInset,
28 codeFontSize: codeSurface.fontSize,
29 codeFontWeight: "regular",
30 lineNumberFontSize: codeSurface.lineNumberFontSize,
31 lineNumberFontWeight: "regular",
32 emptyStateFontSize: Math.round(
33 MOBILE_TYPOGRAPHY.label.fontSize * (codeSurface.fontSize / MOBILE_CODE_SURFACE.fontSize),
34 ),
35 emptyStateFontWeight: "medium",
36 };
37}
26 38
27const SOURCE_FILE_ID = "source-file";39const SOURCE_FILE_ID = "source-file";
28 40
apps/mobile/src/features/files/preload-workspace-file.ts+74−0
@@ -0,0 +1,74 @@
1import { executeAtomQuery } from "@t3tools/client-runtime/state/runtime";
2import type { EnvironmentId } from "@t3tools/contracts";
3
4import { appAtomRegistry } from "../../state/atom-registry";
5import { projectEnvironment } from "../../state/projects";
6import { isBrowserPreviewFile, isImagePreviewFile } from "./filePath";
7import { prepareSourceFileDocument } from "./source-file-document";
8import { sourceHighlightAtom } from "./sourceHighlightingState";
9import type { ReviewDiffTheme } from "../review/shikiReviewHighlighter";
10
11const inFlightPreloads = new Map<string, Promise<void>>();
12const MAX_HIGHLIGHT_PRELOAD_CHARACTERS = 256 * 1024;
13
14function preloadKey(input: {
15 readonly cwd: string;
16 readonly environmentId: EnvironmentId;
17 readonly relativePath: string;
18}): string {
19 return JSON.stringify([input.environmentId, input.cwd, input.relativePath]);
20}
21
22export function preloadWorkspaceFileContents(input: {
23 readonly cwd: string;
24 readonly environmentId: EnvironmentId;
25 readonly relativePath: string;
26 readonly theme: ReviewDiffTheme;
27}): void {
28 if (isBrowserPreviewFile(input.relativePath) || isImagePreviewFile(input.relativePath)) {
29 return;
30 }
31
32 const key = preloadKey(input);
33 if (inFlightPreloads.has(key)) {
34 return;
35 }
36
37 const preload = executeAtomQuery(
38 appAtomRegistry,
39 projectEnvironment.readFile({
40 environmentId: input.environmentId,
41 input: { cwd: input.cwd, relativePath: input.relativePath },
42 }),
43 {
44 label: "workspace file preload",
45 reportDefect: false,
46 reportFailure: false,
47 },
48 )
49 .then(async (result) => {
50 if (result._tag === "Success") {
51 const document = prepareSourceFileDocument(result.value.contents);
52 if (document.contents.length <= MAX_HIGHLIGHT_PRELOAD_CHARACTERS) {
53 await executeAtomQuery(
54 appAtomRegistry,
55 sourceHighlightAtom({
56 path: input.relativePath,
57 contents: document.contents,
58 theme: input.theme,
59 }),
60 {
61 label: "workspace source highlight preload",
62 reportDefect: false,
63 reportFailure: false,
64 },
65 );
66 }
67 }
68 })
69 .finally(() => {
70 inFlightPreloads.delete(key);
71 });
72
73 inFlightPreloads.set(key, preload);
74}
apps/mobile/src/features/files/source-file-document.test.ts+16−0
@@ -0,0 +1,16 @@
1import { describe, expect, it } from "vite-plus/test";
2
3import { prepareSourceFileDocument } from "./source-file-document";
4
5describe("prepareSourceFileDocument", () => {
6 it("normalizes and serializes source rows once for repeated consumers", () => {
7 const first = prepareSourceFileDocument("const value = 1;\r\n\tvalue;\r");
8 const second = prepareSourceFileDocument("const value = 1;\r\n\tvalue;\r");
9 const rows = JSON.parse(first.rowsJson) as ReadonlyArray<{ readonly content: string }>;
10
11 expect(first.contents).toBe("const value = 1;\n\tvalue;\n");
12 expect(first.lines).toEqual(["const value = 1;", "\tvalue;", ""]);
13 expect(rows.map((row) => row.content)).toEqual(["const value = 1;", " value;", ""]);
14 expect(second).toBe(first);
15 });
16});
apps/mobile/src/features/files/source-file-document.ts+54−0
@@ -0,0 +1,54 @@
1import { buildNativeSourceRows } from "./nativeSourceFileAdapter";
2
3const MAX_CACHED_DOCUMENTS = 8;
4const MAX_CACHED_CHARACTERS = 4 * 1024 * 1024;
5
6export interface SourceFileDocument {
7 readonly contents: string;
8 readonly lines: ReadonlyArray<string>;
9 readonly rowsJson: string;
10}
11
12const documentCache = new Map<string, SourceFileDocument>();
13let cachedCharacterCount = 0;
14
15function removeOldestCachedDocument(): void {
16 const oldestKey = documentCache.keys().next().value;
17 if (typeof oldestKey !== "string") {
18 return;
19 }
20 const document = documentCache.get(oldestKey);
21 documentCache.delete(oldestKey);
22 cachedCharacterCount -= (document?.contents.length ?? 0) + (document?.rowsJson.length ?? 0);
23}
24
25export function prepareSourceFileDocument(contents: string): SourceFileDocument {
26 const cached = documentCache.get(contents);
27 if (cached !== undefined) {
28 documentCache.delete(contents);
29 documentCache.set(contents, cached);
30 return cached;
31 }
32
33 const normalizedContents = contents.replace(/\r\n?/g, "\n");
34 const lines = normalizedContents.split("\n");
35 const document = {
36 contents: normalizedContents,
37 lines,
38 rowsJson: JSON.stringify(buildNativeSourceRows(lines)),
39 } satisfies SourceFileDocument;
40 const characterCount = document.contents.length + document.rowsJson.length;
41
42 if (characterCount <= MAX_CACHED_CHARACTERS) {
43 while (
44 documentCache.size >= MAX_CACHED_DOCUMENTS ||
45 cachedCharacterCount + characterCount > MAX_CACHED_CHARACTERS
46 ) {
47 removeOldestCachedDocument();
48 }
49 documentCache.set(contents, document);
50 cachedCharacterCount += characterCount;
51 }
52
53 return document;
54}
apps/mobile/src/features/files/sourceHighlightingState.test.ts+1−1
@@ -88,7 +88,7 @@ describe("sourceHighlightingState", () => { · 87 unmodified lines
88 expect(AsyncResult.isSuccess(registry.get(atom))).toBe(true);88 expect(AsyncResult.isSuccess(registry.get(atom))).toBe(true);
89 });89 });
90 firstUnmount();90 firstUnmount();
91 await new Promise((resolve) => setTimeout(resolve, 25));91 await new Promise((resolve) => setTimeout(resolve, 100));
92 92
93 const secondUnmount = registry.mount(atom);93 const secondUnmount = registry.mount(atom);
94 await vi.waitFor(() => {94 await vi.waitFor(() => {
apps/mobile/src/features/files/thread-file-navigator-pane.tsx+175−0
@@ -0,0 +1,175 @@
1import type { EnvironmentId, ProjectListEntriesResult } from "@t3tools/contracts";
2import { SymbolView } from "expo-symbols";
3import { useCallback, useMemo, useState, type ComponentProps } from "react";
4import { Platform, Pressable, useColorScheme, View, type NativeSyntheticEvent } from "react-native";
5import {
6 Screen,
7 ScreenStack,
8 ScreenStackHeaderConfig,
9 ScreenStackHeaderSearchBarView,
10 SearchBar,
11} from "react-native-screens";
12
13import { AppText as Text, AppTextInput as TextInput } from "../../components/AppText";
14import { nativeHeaderScrollEdgeEffects } from "../../native/StackHeader";
15import { useThemeColor } from "../../lib/useThemeColor";
16import { projectEnvironment } from "../../state/projects";
17import { useEnvironmentQuery } from "../../state/query";
18import { FileTreeBrowser } from "./FileTreeBrowser";
19import { preloadWorkspaceFileContents } from "./preload-workspace-file";
20
21export function ThreadFileNavigatorPane(props: {
22 readonly cwd: string;
23 readonly environmentId: EnvironmentId;
24 readonly headerInset: number;
25 readonly projectName: string;
26 readonly selectedPath: string | null;
27 readonly onSelectFile: (path: string) => void;
28}) {
29 const [searchQuery, setSearchQuery] = useState("");
30 const colorScheme = useColorScheme();
31 const highlightTheme = colorScheme === "dark" ? "dark" : "light";
32 const iconColor = String(useThemeColor("--color-icon-muted"));
33 const foregroundColor = String(useThemeColor("--color-foreground"));
34 const sheetColor = String(useThemeColor("--color-sheet"));
35 const headerScrollEdgeEffects = nativeHeaderScrollEdgeEffects(Platform.OS, Platform.Version);
36 const entriesQuery = useEnvironmentQuery(
37 projectEnvironment.listEntries({
38 environmentId: props.environmentId,
39 input: { cwd: props.cwd },
40 }),
41 );
42 const entriesData = entriesQuery.data as ProjectListEntriesResult | null;
43 const handlePreviewFile = useCallback(
44 (relativePath: string) => {
45 preloadWorkspaceFileContents({
46 cwd: props.cwd,
47 environmentId: props.environmentId,
48 relativePath,
49 theme: highlightTheme,
50 });
51 },
52 [highlightTheme, props.cwd, props.environmentId],
53 );
54 const nativeHeaderRightBarButtonItems = useMemo(
55 () =>
56 [
57 {
58 accessibilityLabel: "Refresh files",
59 icon: { name: "arrow.clockwise", type: "sfSymbol" as const },
60 identifier: "thread-file-navigator-refresh",
61 onPress: entriesQuery.refresh,
62 sharesBackground: false,
63 tintColor: foregroundColor,
64 type: "button" as const,
65 width: 44,
66 },
67 ] as ComponentProps<typeof ScreenStackHeaderConfig>["headerRightBarButtonItems"],
68 [entriesQuery.refresh, foregroundColor],
69 );
70
71 const fileTree = (
72 <FileTreeBrowser
73 entries={entriesData?.entries ?? []}
74 error={entriesQuery.error}
75 isPending={entriesQuery.isPending}
76 searchQuery={searchQuery}
77 selectedPath={props.selectedPath}
78 onPreviewFile={handlePreviewFile}
79 onRefresh={entriesQuery.refresh}
80 onSelectFile={props.onSelectFile}
81 />
82 );
83
84 if (Platform.OS === "ios") {
85 return (
86 <View className="flex-1 border-l border-border bg-sheet">
87 <ScreenStack style={{ flex: 1 }}>
88 <Screen
89 activityState={2}
90 enabled
91 isNativeStack
92 screenId="thread-file-navigator-native"
93 scrollEdgeEffects={headerScrollEdgeEffects}
94 style={{ backgroundColor: sheetColor, flex: 1 }}
95 >
96 {fileTree}
97 <ScreenStackHeaderConfig
98 backgroundColor="rgba(0,0,0,0)"
99 color={foregroundColor}
100 headerRightBarButtonItems={nativeHeaderRightBarButtonItems}
101 hideBackButton
102 hideShadow={false}
103 navigationItemStyle="editor"
104 subtitle={props.projectName}
105 title="Files"
106 titleColor={foregroundColor}
107 titleFontSize={17}
108 titleFontWeight="700"
109 translucent
110 >
111 <ScreenStackHeaderSearchBarView>
112 <SearchBar
113 allowToolbarIntegration
114 autoCapitalize="none"
115 barTintColor={sheetColor}
116 hideNavigationBar={false}
117 hideWhenScrolling={false}
118 obscureBackground={false}
119 onCancelButtonPress={() => {
120 setSearchQuery("");
121 }}
122 onChangeText={(event: NativeSyntheticEvent<{ readonly text?: string }>) => {
123 setSearchQuery(event.nativeEvent.text ?? "");
124 }}
125 placement="integratedButton"
126 placeholder="Search files"
127 textColor={foregroundColor}
128 tintColor={foregroundColor}
129 />
130 </ScreenStackHeaderSearchBarView>
131 </ScreenStackHeaderConfig>
132 </Screen>
133 </ScreenStack>
134 </View>
135 );
136 }
137
138 return (
139 <View className="flex-1 border-l border-border bg-sheet">
140 <View className="border-b border-border" style={{ paddingTop: props.headerInset }}>
141 <View className="h-12 flex-row items-center gap-2 px-3">
142 <View className="min-w-0 flex-1">
143 <Text className="text-sm font-t3-bold text-foreground">Files</Text>
144 <Text className="text-xs text-foreground-muted" numberOfLines={1}>
145 {props.projectName}
146 </Text>
147 </View>
148 <Pressable
149 accessibilityRole="button"
150 accessibilityLabel="Refresh files"
151 hitSlop={8}
152 className="h-8 w-8 items-center justify-center rounded-full active:bg-subtle"
153 onPress={entriesQuery.refresh}
154 >
155 <SymbolView name="arrow.clockwise" size={14} tintColor={iconColor} type="monochrome" />
156 </Pressable>
157 </View>
158 <View className="flex-row items-center gap-2 border-t border-border px-3 py-2">
159 <SymbolView name="magnifyingglass" size={15} tintColor={iconColor} type="monochrome" />
160 <TextInput
161 accessibilityLabel="Search files"
162 autoCapitalize="none"
163 autoCorrect={false}
164 clearButtonMode="while-editing"
165 className="min-h-10 flex-1 rounded-xl py-2 text-sm"
166 placeholder="Search files"
167 value={searchQuery}
168 onChangeText={setSearchQuery}
169 />
170 </View>
171 </View>
172 {fileTree}
173 </View>
174 );
175}
apps/mobile/src/features/home/HomeHeader.tsx+169−203
@@ -3,60 +3,30 @@ import type { · 2 unmodified lines
3 SidebarProjectGroupingMode,3 SidebarProjectGroupingMode,
4 SidebarThreadSortOrder,4 SidebarThreadSortOrder,
5} from "@t3tools/contracts";5} from "@t3tools/contracts";
6import {6import { NativeHeaderToolbar, NativeStackScreenOptions } from "../../native/StackHeader";
7 DEFAULT_SIDEBAR_PROJECT_GROUPING_MODE,7import { useCallback, useRef } from "react";
8 DEFAULT_SIDEBAR_PROJECT_SORT_ORDER,8import { Platform } from "react-native";
9 DEFAULT_SIDEBAR_THREAD_SORT_ORDER,9import type { SearchBarCommands } from "react-native-screens";
10} from "@t3tools/contracts";
11import { Stack } from "expo-router";
12import { Text as RNText, View } from "react-native";
13 10
11import { nativeHeaderScrollEdgeEffects } from "../../native/StackHeader";
14import { useThemeColor } from "../../lib/useThemeColor";12import { useThemeColor } from "../../lib/useThemeColor";
15import { MOBILE_TYPOGRAPHY } from "../../lib/typography";13import { useHardwareKeyboardCommand } from "../keyboard/hardwareKeyboardCommands";
14import { withNativeGlassHeaderItem } from "../layout/native-glass-header-items";
15import { createNativeMailSearchToolbarItem } from "../layout/native-mail-search-toolbar";
16import type { HomeProjectSortOrder } from "./homeThreadList";16import type { HomeProjectSortOrder } from "./homeThreadList";
17import {
18 buildHomeListFilterMenu,
19 type HomeListFilterMenuEnvironment,
20} from "./home-list-filter-menu";
21import {
22 hasCustomHomeListOptions,
23 PROJECT_GROUPING_OPTIONS,
24 PROJECT_SORT_OPTIONS,
25 THREAD_SORT_OPTIONS,
26} from "./home-list-options";
17 27
18export interface HomeHeaderEnvironment {28export type HomeHeaderEnvironment = HomeListFilterMenuEnvironment;
19 readonly environmentId: EnvironmentId;29const HEADER_SCROLL_EDGE_EFFECTS = nativeHeaderScrollEdgeEffects(Platform.OS, Platform.Version);
20 readonly label: string;
21}
22
23const PROJECT_SORT_OPTIONS: ReadonlyArray<{
24 readonly value: HomeProjectSortOrder;
25 readonly label: string;
26}> = [
27 { value: "updated_at", label: "Last user message" },
28 { value: "created_at", label: "Created at" },
29];
30
31const THREAD_SORT_OPTIONS: ReadonlyArray<{
32 readonly value: SidebarThreadSortOrder;
33 readonly label: string;
34}> = [
35 { value: "updated_at", label: "Last user message" },
36 { value: "created_at", label: "Created at" },
37];
38
39const PROJECT_GROUPING_OPTIONS: ReadonlyArray<{
40 readonly value: SidebarProjectGroupingMode;
41 readonly label: string;
42 readonly subtitle: string;
43}> = [
44 {
45 value: "repository",
46 label: "Group by repository",
47 subtitle: "Combine matching repositories across environments",
48 },
49 {
50 value: "repository_path",
51 label: "Group by repository path",
52 subtitle: "Combine only matching paths within a repository",
53 },
54 {
55 value: "separate",
56 label: "Keep separate",
57 subtitle: "Show every project path separately",
58 },
59];
60 30
61export function HomeHeader(props: {31export function HomeHeader(props: {
62 readonly environments: ReadonlyArray<HomeHeaderEnvironment>;32 readonly environments: ReadonlyArray<HomeHeaderEnvironment>;
@@ -72,174 +42,170 @@ export function HomeHeader(props: { · 9 unmodified lines
72 readonly onOpenSettings: () => void;42 readonly onOpenSettings: () => void;
73 readonly onStartNewTask: () => void;43 readonly onStartNewTask: () => void;
74}) {44}) {
45 const searchBarRef = useRef<SearchBarCommands>(null);
75 const iconColor = useThemeColor("--color-icon");46 const iconColor = useThemeColor("--color-icon");
76 const mutedColor = useThemeColor("--color-foreground-muted");47 const hasCustomListOptions = hasCustomHomeListOptions(props);
77 const subtleColor = useThemeColor("--color-subtle");48 const focusSearch = useCallback(() => {
78 const hasCustomListOptions =49 searchBarRef.current?.focus();
79 props.selectedEnvironmentId !== null ||50 return searchBarRef.current !== null;
80 props.projectSortOrder !== DEFAULT_SIDEBAR_PROJECT_SORT_ORDER ||51 }, []);
81 props.threadSortOrder !== DEFAULT_SIDEBAR_THREAD_SORT_ORDER ||52 useHardwareKeyboardCommand("focusSearch", focusSearch);
82 props.projectGroupingMode !== DEFAULT_SIDEBAR_PROJECT_GROUPING_MODE;53 const filterMenu = buildHomeListFilterMenu(props);
83 54
84 return (55 return (
85 <>56 <>
86 <Stack.Screen57 <NativeStackScreenOptions
87 options={{58 options={{
88 headerShown: true,59 // Static header config (glass, title, fonts) lives in Stack.tsx
89 headerTransparent: true,60 // (GLASS_HEADER_OPTIONS). Only dynamic values are set here.
90 headerStyle: { backgroundColor: "transparent" },
91 headerShadowVisible: false,
92 headerTintColor: iconColor,61 headerTintColor: iconColor,
93 headerTitle: "",62 unstable_headerRightItems:
94 headerSearchBarOptions: {63 Platform.OS === "ios"
95 placeholder: "Search threads",64 ? () => [
96 hideNavigationBar: false,65 withNativeGlassHeaderItem({
97 onChangeText: (event) => {66 accessibilityLabel: "Open settings",
98 props.onSearchQueryChange(event.nativeEvent.text);67 icon: { name: "ellipsis", type: "sfSymbol" } as const,
99 },68 identifier: "home-settings",
100 onCancelButtonPress: () => {69 label: "",
101 props.onSearchQueryChange("");70 onPress: props.onOpenSettings,
102 },71 type: "button",
103 allowToolbarIntegration: true,72 }),
104 },73 ]
74 : undefined,
75 unstable_headerToolbarItems:
76 Platform.OS === "ios"
77 ? () => [
78 createNativeMailSearchToolbarItem({
79 composeButtonId: "home-new-task",
80 composeSystemImageName: "square.and.pencil",
81 filterMenu,
82 filterButtonId: "home-filter",
83 filterSystemImageName: hasCustomListOptions
84 ? "line.3.horizontal.decrease.circle.fill"
85 : "line.3.horizontal.decrease",
86 onComposePress: props.onStartNewTask,
87 onSearchTextChange: props.onSearchQueryChange,
88 placeholder: "Search",
89 searchTextChangeId: "home-search-text",
90 }),
91 ]
92 : undefined,
93 headerSearchBarOptions:
94 Platform.OS === "ios"
95 ? undefined
96 : {
97 ref: searchBarRef,
98 allowToolbarIntegration: true,
99 hideNavigationBar: false,
100 placeholder: "Search",
101 onCancelButtonPress: () => {
102 props.onSearchQueryChange("");
103 },
104 onChangeText: (event) => {
105 props.onSearchQueryChange(event.nativeEvent.text);
106 },
107 },
105 }}108 }}
106 />109 />
107 110
108 <Stack.Toolbar placement="left">111 {Platform.OS === "ios" ? null : (
109 <Stack.Toolbar.View hidesSharedBackground>112 <NativeHeaderToolbar placement="right">
110 <View113 <NativeHeaderToolbar.Button
111 style={{114 accessibilityLabel="Open settings"
112 width: 128,115 icon="gearshape"
113 height: 32,116 onPress={props.onOpenSettings}
114 flexDirection: "row",117 separateBackground
115 alignItems: "center",118 />
116 gap: 8,119 </NativeHeaderToolbar>
117 }}120 )}
118 >
119 <RNText
120 style={{
121 fontFamily: "DMSans_700Bold",
122 fontSize: MOBILE_TYPOGRAPHY.headline.fontSize,
123 color: iconColor,
124 letterSpacing: -0.4,
125 }}
126 >
127 T3 Code
128 </RNText>
129 <View
130 style={{
131 backgroundColor: subtleColor,
132 borderRadius: 99,
133 paddingHorizontal: 8,
134 paddingVertical: 3,
135 }}
136 >
137 <RNText
138 style={{
139 fontFamily: "DMSans_700Bold",
140 fontSize: MOBILE_TYPOGRAPHY.micro.fontSize,
141 color: mutedColor,
142 letterSpacing: 1.1,
143 textTransform: "uppercase",
144 }}
145 >
146 Alpha
147 </RNText>
148 </View>
149 </View>
150 </Stack.Toolbar.View>
151 </Stack.Toolbar>
152
153 <Stack.Toolbar placement="right">
154 <Stack.Toolbar.Menu
155 accessibilityLabel="Filter and sort threads"
156 icon={
157 hasCustomListOptions
158 ? "line.3.horizontal.decrease.circle.fill"
159 : "line.3.horizontal.decrease.circle"
160 }
161 separateBackground
162 title="Thread list options"
163 >
164 <Stack.Toolbar.Menu title="Environment">
165 <Stack.Toolbar.Label>Environment</Stack.Toolbar.Label>
166 <Stack.Toolbar.MenuAction
167 isOn={props.selectedEnvironmentId === null}
168 onPress={() => props.onEnvironmentChange(null)}
169 subtitle="Show threads from every environment"
170 >
171 <Stack.Toolbar.Label>All environments</Stack.Toolbar.Label>
172 </Stack.Toolbar.MenuAction>
173 {props.environments.map((environment) => (
174 <Stack.Toolbar.MenuAction
175 key={environment.environmentId}
176 isOn={props.selectedEnvironmentId === environment.environmentId}
177 onPress={() => props.onEnvironmentChange(environment.environmentId)}
178 >
179 <Stack.Toolbar.Label>{environment.label}</Stack.Toolbar.Label>
180 </Stack.Toolbar.MenuAction>
181 ))}
182 </Stack.Toolbar.Menu>
183 121
184 <Stack.Toolbar.Menu title="Sort projects">122 {Platform.OS === "ios" ? null : (
185 <Stack.Toolbar.Label>Sort projects</Stack.Toolbar.Label>123 <NativeHeaderToolbar placement="bottom">
186 {PROJECT_SORT_OPTIONS.map((option) => (124 <NativeHeaderToolbar.Menu
187 <Stack.Toolbar.MenuAction125 accessibilityLabel="Filter and sort threads"
188 key={option.value}126 icon={
189 isOn={props.projectSortOrder === option.value}127 hasCustomListOptions
190 onPress={() => props.onProjectSortOrderChange(option.value)}128 ? "line.3.horizontal.decrease.circle.fill"
191 >129 : "line.3.horizontal.decrease.circle"
192 <Stack.Toolbar.Label>{option.label}</Stack.Toolbar.Label>130 }
193 </Stack.Toolbar.MenuAction>131 title="Thread list options"
194 ))}132 separateBackground
195 </Stack.Toolbar.Menu>133 >
134 <NativeHeaderToolbar.MenuAction onPress={props.onOpenSettings}>
135 <NativeHeaderToolbar.Label>Settings</NativeHeaderToolbar.Label>
136 </NativeHeaderToolbar.MenuAction>
196 137
197 <Stack.Toolbar.Menu title="Sort threads">138 <NativeHeaderToolbar.Menu title="Environment">
198 <Stack.Toolbar.Label>Sort threads</Stack.Toolbar.Label>139 <NativeHeaderToolbar.Label>Environment</NativeHeaderToolbar.Label>
199 {THREAD_SORT_OPTIONS.map((option) => (140 <NativeHeaderToolbar.MenuAction
200 <Stack.Toolbar.MenuAction141 isOn={props.selectedEnvironmentId === null}
201 key={option.value}142 onPress={() => props.onEnvironmentChange(null)}
202 isOn={props.threadSortOrder === option.value}143 subtitle="Show threads from every environment"
203 onPress={() => props.onThreadSortOrderChange(option.value)}
204 >144 >
205 <Stack.Toolbar.Label>{option.label}</Stack.Toolbar.Label>145 <NativeHeaderToolbar.Label>All environments</NativeHeaderToolbar.Label>
206 </Stack.Toolbar.MenuAction>146 </NativeHeaderToolbar.MenuAction>
207 ))}147 {props.environments.map((environment) => (
208 </Stack.Toolbar.Menu>148 <NativeHeaderToolbar.MenuAction
149 key={environment.environmentId}
150 isOn={props.selectedEnvironmentId === environment.environmentId}
151 onPress={() => props.onEnvironmentChange(environment.environmentId)}
152 >
153 <NativeHeaderToolbar.Label>{environment.label}</NativeHeaderToolbar.Label>
154 </NativeHeaderToolbar.MenuAction>
155 ))}
156 </NativeHeaderToolbar.Menu>
209 157
210 <Stack.Toolbar.Menu title="Group projects">158 <NativeHeaderToolbar.Menu title="Sort projects">
211 <Stack.Toolbar.Label>Group projects</Stack.Toolbar.Label>159 <NativeHeaderToolbar.Label>Sort projects</NativeHeaderToolbar.Label>
212 {PROJECT_GROUPING_OPTIONS.map((option) => (160 {PROJECT_SORT_OPTIONS.map((option) => (
213 <Stack.Toolbar.MenuAction161 <NativeHeaderToolbar.MenuAction
214 key={option.value}162 key={option.value}
215 isOn={props.projectGroupingMode === option.value}163 isOn={props.projectSortOrder === option.value}
216 onPress={() => props.onProjectGroupingModeChange(option.value)}164 onPress={() => props.onProjectSortOrderChange(option.value)}
217 subtitle={option.subtitle}165 >
218 >166 <NativeHeaderToolbar.Label>{option.label}</NativeHeaderToolbar.Label>
219 <Stack.Toolbar.Label>{option.label}</Stack.Toolbar.Label>167 </NativeHeaderToolbar.MenuAction>
220 </Stack.Toolbar.MenuAction>168 ))}
221 ))}169 </NativeHeaderToolbar.Menu>
222 </Stack.Toolbar.Menu>
223 </Stack.Toolbar.Menu>
224 170
225 <Stack.Toolbar.Button171 <NativeHeaderToolbar.Menu title="Sort threads">
226 accessibilityLabel="Open settings"172 <NativeHeaderToolbar.Label>Sort threads</NativeHeaderToolbar.Label>
227 icon="gearshape"173 {THREAD_SORT_OPTIONS.map((option) => (
228 onPress={props.onOpenSettings}174 <NativeHeaderToolbar.MenuAction
229 separateBackground175 key={option.value}
230 />176 isOn={props.threadSortOrder === option.value}
231 </Stack.Toolbar>177 onPress={() => props.onThreadSortOrderChange(option.value)}
178 >
179 <NativeHeaderToolbar.Label>{option.label}</NativeHeaderToolbar.Label>
180 </NativeHeaderToolbar.MenuAction>
181 ))}
182 </NativeHeaderToolbar.Menu>
232 183
233 <Stack.Toolbar placement="bottom">184 <NativeHeaderToolbar.Menu title="Group projects">
234 <Stack.Toolbar.SearchBarSlot />185 <NativeHeaderToolbar.Label>Group projects</NativeHeaderToolbar.Label>
235 <Stack.Toolbar.Spacer width={8} sharesBackground={false} />186 {PROJECT_GROUPING_OPTIONS.map((option) => (
236 <Stack.Toolbar.Button187 <NativeHeaderToolbar.MenuAction
237 accessibilityLabel="New task"188 key={option.value}
238 icon="square.and.pencil"189 isOn={props.projectGroupingMode === option.value}
239 onPress={props.onStartNewTask}190 onPress={() => props.onProjectGroupingModeChange(option.value)}
240 separateBackground191 subtitle={option.subtitle}
241 />192 >
242 </Stack.Toolbar>193 <NativeHeaderToolbar.Label>{option.label}</NativeHeaderToolbar.Label>
194 </NativeHeaderToolbar.MenuAction>
195 ))}
196 </NativeHeaderToolbar.Menu>
197 </NativeHeaderToolbar.Menu>
198 <NativeHeaderToolbar.Spacer width={8} sharesBackground={false} />
199 <NativeHeaderToolbar.SearchBarSlot />
200 <NativeHeaderToolbar.Spacer width={8} sharesBackground={false} />
201 <NativeHeaderToolbar.Button
202 accessibilityLabel="New task"
203 icon="square.and.pencil"
204 onPress={props.onStartNewTask}
205 separateBackground
206 />
207 </NativeHeaderToolbar>
208 )}
243 </>209 </>
244 );210 );
245}211}
apps/mobile/src/features/home/HomeRouteScreen.tsx+149−0
@@ -0,0 +1,149 @@
1import * as Arr from "effect/Array";
2import * as Order from "effect/Order";
3import { useNavigation } from "@react-navigation/native";
4import { useMemo, useState } from "react";
5
6import { NativeHeaderToolbar, NativeStackScreenOptions } from "../../native/StackHeader";
7import { useProjects, useThreadShells } from "../../state/entities";
8import { usePendingNewTasks } from "../../state/use-pending-new-tasks";
9import { useWorkspaceState } from "../../state/workspace";
10import { useSavedRemoteConnections } from "../../state/use-remote-environment-registry";
11import { useAdaptiveWorkspaceLayout } from "../layout/AdaptiveWorkspaceLayout";
12import { WorkspaceEmptyDetail } from "../layout/WorkspaceEmptyDetail";
13import { WorkspaceSidebarToolbar } from "../layout/workspace-sidebar-toolbar";
14import { HomeScreen } from "./HomeScreen";
15import { HomeHeader } from "./HomeHeader";
16import { useHomeListOptions } from "./home-list-options";
17import { usePendingTaskListActions } from "./usePendingTaskListActions";
18import { useThreadListActions } from "./useThreadListActions";
19
20/* ─── Route screen ───────────────────────────────────────────────────── */
21
22export 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}
apps/mobile/src/features/home/HomeScreen.tsx+275−466
@@ -1,3 +1,8 @@
1import {
2 LegendList,
3 type LegendListRef,
4 type LegendListRenderItemProps,
5} from "@legendapp/list/react-native";
1import {6import {
2 type EnvironmentProject,7 type EnvironmentProject,
3 type EnvironmentThreadShell,8 type EnvironmentThreadShell,
@@ -7,102 +12,74 @@ import type { · 3 unmodified lines
7 SidebarProjectGroupingMode,12 SidebarProjectGroupingMode,
8 SidebarThreadSortOrder,13 SidebarThreadSortOrder,
9} from "@t3tools/contracts";14} from "@t3tools/contracts";
10import * as Haptics from "expo-haptics";
11import { SymbolView } from "expo-symbols";
12import { useCallback, useMemo, useRef, useState } from "react";15import { useCallback, useMemo, useRef, useState } from "react";
13import { ActivityIndicator, Pressable, ScrollView, useWindowDimensions, View } from "react-native";16import { ActivityIndicator, Platform, View } from "react-native";
14import ReanimatedSwipeable, {17import type { SwipeableMethods } from "react-native-gesture-handler/ReanimatedSwipeable";
15 type SwipeableMethods,
16} from "react-native-gesture-handler/ReanimatedSwipeable";
17import Animated, {
18 Easing,
19 LinearTransition,
20 type ExitAnimationsValues,
21 withDelay,
22 withTiming,
23} from "react-native-reanimated";
24import { useSafeAreaInsets } from "react-native-safe-area-context";18import { useSafeAreaInsets } from "react-native-safe-area-context";
25import { useThemeColor } from "../../lib/useThemeColor";19import { useThemeColor } from "../../lib/useThemeColor";
26 20
27import { AppText as Text } from "../../components/AppText";
28import { EmptyState } from "../../components/EmptyState";21import { EmptyState } from "../../components/EmptyState";
29import { ProjectFavicon } from "../../components/ProjectFavicon";
30import type { WorkspaceState } from "../../state/workspaceModel";22import type { WorkspaceState } from "../../state/workspaceModel";
31import type { SavedRemoteConnection } from "../../lib/connection";23import type { SavedRemoteConnection } from "../../lib/connection";
32import { relativeTime } from "../../lib/time";24import { scopedProjectKey } from "../../lib/scopedEntities";
33import { threadStatusTone } from "../threads/threadPresentation";25import type { PendingNewTask } from "../../state/use-pending-new-tasks";
34import { buildHomeThreadGroups, type HomeProjectSortOrder } from "./homeThreadList";
35import {26import {
36 THREAD_SWIPE_ACTIONS_WIDTH,27 PendingTaskListRow,
37 THREAD_SWIPE_SPRING,28 ThreadListGroupHeader,
38 ThreadSwipeActions,29 ThreadListRow,
39} from "./thread-swipe-actions";30 ThreadListShowMoreRow,
31} from "../threads/thread-list-items";
32import type { HomeListFilterMenuEnvironment } from "./home-list-filter-menu";
33import {
34 buildHomeListLayout,
35 DEFAULT_GROUP_DISPLAY_STATE,
36 homeListItemsAreEqual,
37 nextGroupDisplayState,
38 type HomeGroupDisplayAction,
39 type HomeGroupDisplayState,
40 type HomeListItem,
41} from "./homeListItems";
42import { buildHomeThreadGroups, type HomeProjectSortOrder } from "./homeThreadList";
43import { SwipeableScrollGateProvider, useSwipeableScrollGate } from "./thread-swipe-actions";
44import { WorkspaceConnectionStatus } from "./WorkspaceConnectionStatus";
45import { shouldShowWorkspaceConnectionStatus } from "./workspace-connection-status";
40 46
41/* ─── Types ──────────────────────────────────────────────────────────── */47/* ─── Types ──────────────────────────────────────────────────────────── */
42 48
43interface HomeScreenProps {49interface HomeScreenProps {
44 readonly projects: ReadonlyArray<EnvironmentProject>;50 readonly projects: ReadonlyArray<EnvironmentProject>;
45 readonly threads: ReadonlyArray<EnvironmentThreadShell>;51 readonly threads: ReadonlyArray<EnvironmentThreadShell>;
52 readonly pendingTasks: ReadonlyArray<PendingNewTask>;
46 readonly catalogState: WorkspaceState;53 readonly catalogState: WorkspaceState;
47 readonly savedConnectionsById: Readonly<Record<string, SavedRemoteConnection>>;54 readonly savedConnectionsById: Readonly<Record<string, SavedRemoteConnection>>;
55 readonly environments: ReadonlyArray<HomeListFilterMenuEnvironment>;
48 readonly searchQuery: string;56 readonly searchQuery: string;
49 readonly selectedEnvironmentId: EnvironmentId | null;57 readonly selectedEnvironmentId: EnvironmentId | null;
50 readonly projectSortOrder: HomeProjectSortOrder;58 readonly projectSortOrder: HomeProjectSortOrder;
51 readonly threadSortOrder: SidebarThreadSortOrder;59 readonly threadSortOrder: SidebarThreadSortOrder;
52 readonly projectGroupingMode: SidebarProjectGroupingMode;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 readonly onAddConnection: () => void;66 readonly onAddConnection: () => void;
54 readonly onOpenEnvironments: () => void;67 readonly onOpenEnvironments: () => void;
68 readonly onOpenSettings: () => void;
69 readonly onStartNewTask: () => void;
55 readonly onSelectThread: (thread: EnvironmentThreadShell) => void;70 readonly onSelectThread: (thread: EnvironmentThreadShell) => void;
56 readonly onArchiveThread: (thread: EnvironmentThreadShell) => void;71 readonly onArchiveThread: (thread: EnvironmentThreadShell) => void;
57 readonly onDeleteThread: (thread: EnvironmentThreadShell) => void;72 readonly onDeleteThread: (thread: EnvironmentThreadShell) => void;
73 readonly onSelectPendingTask: (pendingTask: PendingNewTask) => void;
74 readonly onDeletePendingTask: (pendingTask: PendingNewTask) => void;
58 readonly onNewThreadInProject: (project: EnvironmentProject) => void;75 readonly onNewThreadInProject: (project: EnvironmentProject) => void;
59}76}
60 77
61/* ─── Status indicator colors ────────────────────────────────────────── */78/* ─── Layout constants ───────────────────────────────────────────────── */
62
63function 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
78const COLLAPSED_THREAD_LIMIT = 6;
79const THREAD_LAYOUT_TRANSITION = LinearTransition.duration(220).easing(Easing.out(Easing.cubic));
80 79
81function threadRowExit(values: ExitAnimationsValues) {80const ESTIMATED_THREAD_ROW_HEIGHT = 72;
82 "worklet";81/** Height of the floating custom header on non-iOS platforms. */
83 82const CUSTOM_HEADER_HEIGHT = 78;
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}
106 83
107function deriveEmptyState(props: {84function deriveEmptyState(props: {
108 readonly catalogState: WorkspaceState;85 readonly catalogState: WorkspaceState;
@@ -167,304 +144,28 @@ function deriveEmptyState(props: { · 58 unmodified lines
167 };144 };
168}145}
169 146
170/* ─── Project group header ───────────────────────────────────────────── */147function HomeTopContentSpacer(props: { readonly topInset: number }) {
171 148 return <View style={{ height: props.topInset + CUSTOM_HEADER_HEIGHT }} />;
172function 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
230function 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 );
401}149}
402 150
403/* ─── Main screen ────────────────────────────────────────────────────── */151/* ─── Main screen ────────────────────────────────────────────────────── */
404 152
405function 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
419function 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
457export function HomeScreen(props: HomeScreenProps) {153export 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 const openSwipeableRef = useRef<SwipeableMethods | null>(null);157 const openSwipeableRef = useRef<SwipeableMethods | null>(null);
158 const listRef = useRef<LegendListRef | null>(null);
460 const insets = useSafeAreaInsets();159 const insets = useSafeAreaInsets();
461 const accentColor = useThemeColor("--color-icon-muted");160 const accentColor = useThemeColor("--color-icon-muted");
462 161
463 const toggleExpanded = useCallback((key: string) => {162 const updateGroupDisplay = useCallback((key: string, action: HomeGroupDisplayAction) => {
464 setExpandedProjects((prev) => {163 setGroupDisplayStates((previous) => {
465 const next = new Set(prev);164 const next = new Map(previous);
466 if (next.has(key)) next.delete(key);165 next.set(
467 else next.add(key);166 key,
167 nextGroupDisplayState(previous.get(key) ?? DEFAULT_GROUP_DISPLAY_STATE, action),
168 );
468 return next;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 const projectGroups = useMemo(193 const projectGroups = useMemo(
486 () =>194 () =>
487 buildHomeThreadGroups({195 buildHomeThreadGroups({
488 projects: props.projects,196 projects: props.projects,
489 threads: props.threads,197 threads: props.threads,
198 pendingTasks: props.pendingTasks,
490 environmentId: props.selectedEnvironmentId,199 environmentId: props.selectedEnvironmentId,
491 searchQuery: props.searchQuery,200 searchQuery: props.searchQuery,
492 projectSortOrder: props.projectSortOrder,201 projectSortOrder: props.projectSortOrder,
@@ -494,6 +203,7 @@ export function HomeScreen(props: HomeScreenProps) { · 1 unmodified line
494 projectGroupingMode: props.projectGroupingMode,203 projectGroupingMode: props.projectGroupingMode,
495 }),204 }),
496 [205 [
206 props.pendingTasks,
497 props.projectGroupingMode,207 props.projectGroupingMode,
498 props.projects,208 props.projects,
499 props.projectSortOrder,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 /* Empty states */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 const hasResults = projectGroups.length > 0;326 const hasResults = projectGroups.length > 0;
510 const selectedEnvironmentLabel =327 const selectedEnvironmentLabel =
511 props.selectedEnvironmentId === null328 props.selectedEnvironmentId === null
512 ? null329 ? null
513 : (props.savedConnectionsById[props.selectedEnvironmentId]?.environmentLabel ??330 : (props.savedConnectionsById[props.selectedEnvironmentId]?.environmentLabel ??
514 "this environment");331 "this environment");
515 const hasSearchQuery = props.searchQuery.trim().length > 0;332 const shouldShowConnectionStatus = shouldShowWorkspaceConnectionStatus(props.catalogState);
516 const shouldShowConnectionStatus =
517 props.catalogState.networkStatus === "offline" ||
518 props.catalogState.hasConnectingEnvironment ||
519 (props.catalogState.hasLoadedShellSnapshot && !props.catalogState.hasReadyEnvironment);
520 const emptyState = deriveEmptyState({333 const emptyState = deriveEmptyState({
521 catalogState: props.catalogState,334 catalogState: props.catalogState,
522 projectCount: props.projects.length,335 projectCount: props.projects.length,
523 });336 });
524 337 const connectionStatus =
525 return (338 shouldShowConnectionStatus && Platform.OS !== "ios" ? (
526 <View className="flex-1 bg-screen">339 <View
527 <ScrollView340 className="absolute left-0 right-0 items-center"
528 contentInsetAdjustmentBehavior="automatic"341 style={{ bottom: Math.max(insets.bottom, 18) + 76 }}
529 showsVerticalScrollIndicator={false}342 >
530 keyboardDismissMode="on-drag"343 <WorkspaceConnectionStatus state={props.catalogState} onPress={props.onOpenEnvironments} />
531 keyboardShouldPersistTaps="handled"344 </View>
532 onScrollBeginDrag={() => openSwipeableRef.current?.close()}345 ) : null;
533 className="flex-1"346
534 contentContainerStyle={{347 if (!hasAnyThreads) {
535 paddingHorizontal: 16,348 return (
536 paddingTop: 8,349 <View
537 paddingBottom: 24,350 className="flex-1 items-center justify-center bg-screen px-8"
538 gap: 20,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 ? (356 <View className="w-full max-w-[430px]">
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 ? (
563 <EmptyState357 <EmptyState
564 title="No threads yet"358 title={emptyState.title}
565 detail="Create a task to start a new coding session."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 ) : (364 {emptyState.loading ? (
568 projectGroups.map((group) => {365 <View className="mt-4 items-center">
569 const isExpanded = expandedProjects.has(group.key);366 <ActivityIndicator color={accentColor} />
570 // Collapsed shows recent threads only; expanding reveals full history.367 </View>
571 const collapsedThreads = group.recentThreads.slice(0, COLLAPSED_THREAD_LIMIT);368 ) : null}
572 const visibleThreads = isExpanded ? group.threads : collapsedThreads;369 </View>
573 const hiddenCount = group.threads.length - collapsedThreads.length;370 {connectionStatus}
371 </View>
372 );
373 }
574 374
575 return (375 const listHeader = (
576 <Animated.View376 <>
577 key={group.key}377 {Platform.OS === "ios" ? null : <HomeTopContentSpacer topInset={insets.top} />}
578 collapsable={false}378
579 exiting={threadRowExit}379 {shouldShowConnectionStatus && Platform.OS === "ios" ? (
580 layout={THREAD_LAYOUT_TRANSITION}380 <View style={{ paddingBottom: 16 }}>
581 style={{ overflow: "hidden" }}381 <WorkspaceConnectionStatus
582 >382 state={props.catalogState}
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}
634 onPress={props.onOpenEnvironments}383 onPress={props.onOpenEnvironments}
384 variant="sidebar"
635 />385 />
636 </View>386 </View>
637 ) : null}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 </View>447 </View>
639 );448 );
640}449}
apps/mobile/src/features/home/WorkspaceConnectionStatus.test.ts+70−0
@@ -0,0 +1,70 @@
1import { describe, expect, it } from "vite-plus/test";
2
3import type { WorkspaceState } from "../../state/workspaceModel";
4import {
5 shouldShowWorkspaceConnectionStatus,
6 workspaceConnectionStatusLabel,
7} from "./workspace-connection-status";
8
9function workspaceState(overrides: Partial<WorkspaceState> = {}): WorkspaceState {
10 return {
11 isLoadingConnections: false,
12 hasConnections: true,
13 hasLoadedShellSnapshot: true,
14 hasPendingShellSnapshot: false,
15 hasReadyEnvironment: true,
16 hasConnectingEnvironment: false,
17 connectingEnvironments: [],
18 connectionState: "connected",
19 connectionError: null,
20 shellSnapshotError: null,
21 latestCachedSnapshotReceivedAt: null,
22 networkStatus: "online",
23 ...overrides,
24 };
25}
26
27describe("workspace connection status", () => {
28 it("stays hidden while a ready environment is connected", () => {
29 expect(shouldShowWorkspaceConnectionStatus(workspaceState())).toBe(false);
30 });
31
32 it("surfaces offline snapshots", () => {
33 const state = workspaceState({ networkStatus: "offline", hasReadyEnvironment: false });
34
35 expect(shouldShowWorkspaceConnectionStatus(state)).toBe(true);
36 expect(workspaceConnectionStatusLabel(state)).toBe("You are offline");
37 });
38
39 it("names the environment while reconnecting", () => {
40 const state = workspaceState({
41 hasConnectingEnvironment: true,
42 hasReadyEnvironment: false,
43 connectingEnvironments: [
44 {
45 environmentId: "environment-1" as never,
46 environmentLabel: "Julius’s Mac mini",
47 displayUrl: "",
48 isRelayManaged: false,
49 connectionState: "reconnecting",
50 connectionError: null,
51 connectionErrorTraceId: null,
52 },
53 ],
54 });
55
56 expect(shouldShowWorkspaceConnectionStatus(state)).toBe(true);
57 expect(workspaceConnectionStatusLabel(state)).toBe("Reconnecting to Julius’s Mac mini");
58 });
59
60 it("surfaces connection errors before the generic disconnected fallback", () => {
61 const state = workspaceState({
62 connectionError: "Could not reach Julius’s Mac mini",
63 hasLoadedShellSnapshot: false,
64 hasReadyEnvironment: false,
65 });
66
67 expect(shouldShowWorkspaceConnectionStatus(state)).toBe(true);
68 expect(workspaceConnectionStatusLabel(state)).toBe("Could not reach Julius’s Mac mini");
69 });
70});
apps/mobile/src/features/home/WorkspaceConnectionStatus.tsx+53−0
@@ -0,0 +1,53 @@
1import { SymbolView } from "expo-symbols";
2import { ActivityIndicator, Pressable } from "react-native";
3
4import { AppText as Text } from "../../components/AppText";
5import { useThemeColor } from "../../lib/useThemeColor";
6import type { WorkspaceState } from "../../state/workspaceModel";
7import { workspaceConnectionStatusLabel } from "./workspace-connection-status";
8
9export function WorkspaceConnectionStatus(props: {
10 readonly state: WorkspaceState;
11 readonly onPress: () => void;
12 readonly variant?: "floating" | "sidebar";
13}) {
14 const iconColor = useThemeColor("--color-icon-muted");
15 const isReconnecting = props.state.connectingEnvironments.length > 0;
16 const variant = props.variant ?? "floating";
17
18 return (
19 <Pressable
20 accessibilityHint="Opens environment settings"
21 accessibilityLabel={workspaceConnectionStatusLabel(props.state)}
22 accessibilityRole="button"
23 onPress={props.onPress}
24 className={
25 variant === "sidebar"
26 ? "mx-3 flex-row items-center gap-2 rounded-xl bg-subtle px-3 py-2.5"
27 : "flex-row items-center gap-2 rounded-full bg-card px-4 py-2.5"
28 }
29 style={
30 variant === "floating"
31 ? {
32 shadowColor: "#000",
33 shadowOffset: { width: 0, height: 10 },
34 shadowOpacity: 0.12,
35 shadowRadius: 24,
36 }
37 : undefined
38 }
39 >
40 {isReconnecting ? (
41 <ActivityIndicator color={iconColor} size="small" />
42 ) : (
43 <SymbolView name="wifi.slash" size={15} tintColor={iconColor} type="monochrome" />
44 )}
45 <Text className="min-w-0 flex-1 text-sm font-t3-bold text-foreground" numberOfLines={1}>
46 {workspaceConnectionStatusLabel(props.state)}
47 </Text>
48 {variant === "sidebar" ? (
49 <SymbolView name="chevron.right" size={11} tintColor={iconColor} type="monochrome" />
50 ) : null}
51 </Pressable>
52 );
53}
apps/mobile/src/features/home/home-list-filter-menu.ts+120−0
@@ -0,0 +1,120 @@
1import type {
2 EnvironmentId,
3 SidebarProjectGroupingMode,
4 SidebarThreadSortOrder,
5} from "@t3tools/contracts";
6
7import type { HomeProjectSortOrder } from "./homeThreadList";
8import {
9 PROJECT_GROUPING_OPTIONS,
10 PROJECT_SORT_OPTIONS,
11 THREAD_SORT_OPTIONS,
12} from "./home-list-options";
13
14export interface HomeListFilterMenuEnvironment {
15 readonly environmentId: EnvironmentId;
16 readonly label: string;
17}
18
19type HomeListFilterMenuAction = {
20 readonly type: "action";
21 readonly title: string;
22 readonly subtitle?: string;
23 readonly state?: "on" | "off";
24 readonly onPress: () => void;
25};
26
27type HomeListFilterMenuSubmenu = {
28 readonly type: "submenu";
29 readonly title: string;
30 readonly items: HomeListFilterMenuAction[];
31};
32
33export interface HomeListFilterMenu {
34 readonly title: string;
35 readonly items: Array<HomeListFilterMenuAction | HomeListFilterMenuSubmenu>;
36}
37
38export function buildHomeListFilterMenu(props: {
39 readonly environments: ReadonlyArray<HomeListFilterMenuEnvironment>;
40 readonly selectedEnvironmentId: EnvironmentId | null;
41 readonly projectSortOrder: HomeProjectSortOrder;
42 readonly threadSortOrder: SidebarThreadSortOrder;
43 readonly projectGroupingMode: SidebarProjectGroupingMode;
44 readonly onEnvironmentChange: (environmentId: EnvironmentId | null) => void;
45 readonly onProjectSortOrderChange: (sortOrder: HomeProjectSortOrder) => void;
46 readonly onThreadSortOrderChange: (sortOrder: SidebarThreadSortOrder) => void;
47 readonly onProjectGroupingModeChange: (mode: SidebarProjectGroupingMode) => void;
48 readonly onOpenSettings?: () => void;
49}): HomeListFilterMenu {
50 const items: Array<HomeListFilterMenuAction | HomeListFilterMenuSubmenu> = [];
51
52 if (props.onOpenSettings) {
53 items.push({
54 type: "action",
55 title: "Settings",
56 onPress: props.onOpenSettings,
57 });
58 }
59
60 items.push(
61 {
62 type: "submenu",
63 title: "Environment",
64 items: [
65 {
66 type: "action",
67 title: "All environments",
68 subtitle: "Show threads from every environment",
69 state: props.selectedEnvironmentId === null ? "on" : "off",
70 onPress: () => props.onEnvironmentChange(null),
71 },
72 ...props.environments.map((environment) => ({
73 type: "action" as const,
74 title: environment.label,
75 state:
76 props.selectedEnvironmentId === environment.environmentId
77 ? ("on" as const)
78 : ("off" as const),
79 onPress: () => props.onEnvironmentChange(environment.environmentId),
80 })),
81 ],
82 },
83 {
84 type: "submenu",
85 title: "Sort projects",
86 items: PROJECT_SORT_OPTIONS.map((option) => ({
87 type: "action",
88 title: option.label,
89 state: props.projectSortOrder === option.value ? "on" : "off",
90 onPress: () => props.onProjectSortOrderChange(option.value),
91 })),
92 },
93 {
94 type: "submenu",
95 title: "Sort threads",
96 items: THREAD_SORT_OPTIONS.map((option) => ({
97 type: "action",
98 title: option.label,
99 state: props.threadSortOrder === option.value ? "on" : "off",
100 onPress: () => props.onThreadSortOrderChange(option.value),
101 })),
102 },
103 {
104 type: "submenu",
105 title: "Group projects",
106 items: PROJECT_GROUPING_OPTIONS.map((option) => ({
107 type: "action",
108 title: option.label,
109 subtitle: option.subtitle,
110 state: props.projectGroupingMode === option.value ? "on" : "off",
111 onPress: () => props.onProjectGroupingModeChange(option.value),
112 })),
113 },
114 );
115
116 return {
117 title: "Thread list options",
118 items,
119 };
120}
apps/mobile/src/features/home/home-list-options.test.ts+31−0
@@ -0,0 +1,31 @@
1import {
2 DEFAULT_SIDEBAR_PROJECT_GROUPING_MODE,
3 DEFAULT_SIDEBAR_PROJECT_SORT_ORDER,
4 DEFAULT_SIDEBAR_THREAD_SORT_ORDER,
5} from "@t3tools/contracts";
6import { describe, expect, it } from "vite-plus/test";
7
8import { hasCustomHomeListOptions, type HomeListOptions } from "./home-list-options";
9
10const defaults: HomeListOptions = {
11 selectedEnvironmentId: null,
12 projectSortOrder:
13 DEFAULT_SIDEBAR_PROJECT_SORT_ORDER === "manual"
14 ? "updated_at"
15 : DEFAULT_SIDEBAR_PROJECT_SORT_ORDER,
16 threadSortOrder: DEFAULT_SIDEBAR_THREAD_SORT_ORDER,
17 projectGroupingMode: DEFAULT_SIDEBAR_PROJECT_GROUPING_MODE,
18};
19
20describe("home list options", () => {
21 it("recognizes default options", () => {
22 expect(hasCustomHomeListOptions(defaults)).toBe(false);
23 });
24
25 it("marks environment filters and grouping changes as customized", () => {
26 expect(
27 hasCustomHomeListOptions({ ...defaults, selectedEnvironmentId: "environment-1" as never }),
28 ).toBe(true);
29 expect(hasCustomHomeListOptions({ ...defaults, projectGroupingMode: "separate" })).toBe(true);
30 });
31});
apps/mobile/src/features/home/home-list-options.ts+144−0
@@ -0,0 +1,144 @@
1import type {
2 EnvironmentId,
3 SidebarProjectGroupingMode,
4 SidebarThreadSortOrder,
5} from "@t3tools/contracts";
6import {
7 DEFAULT_SIDEBAR_PROJECT_GROUPING_MODE,
8 DEFAULT_SIDEBAR_PROJECT_SORT_ORDER,
9 DEFAULT_SIDEBAR_THREAD_SORT_ORDER,
10} from "@t3tools/contracts";
11import {
12 createContext,
13 createElement,
14 useCallback,
15 useContext,
16 useMemo,
17 useState,
18 type PropsWithChildren,
19 type Dispatch,
20 type SetStateAction,
21} from "react";
22
23import type { HomeProjectSortOrder } from "./homeThreadList";
24
25export interface HomeListOptions {
26 readonly selectedEnvironmentId: EnvironmentId | null;
27 readonly projectSortOrder: HomeProjectSortOrder;
28 readonly threadSortOrder: SidebarThreadSortOrder;
29 readonly projectGroupingMode: SidebarProjectGroupingMode;
30}
31
32export const PROJECT_SORT_OPTIONS: ReadonlyArray<{
33 readonly value: HomeProjectSortOrder;
34 readonly label: string;
35}> = [
36 { value: "updated_at", label: "Last user message" },
37 { value: "created_at", label: "Created at" },
38];
39
40export const THREAD_SORT_OPTIONS: ReadonlyArray<{
41 readonly value: SidebarThreadSortOrder;
42 readonly label: string;
43}> = [
44 { value: "updated_at", label: "Last user message" },
45 { value: "created_at", label: "Created at" },
46];
47
48export const PROJECT_GROUPING_OPTIONS: ReadonlyArray<{
49 readonly value: SidebarProjectGroupingMode;
50 readonly label: string;
51 readonly subtitle: string;
52}> = [
53 {
54 value: "repository",
55 label: "Group by repository",
56 subtitle: "Combine matching repositories across environments",
57 },
58 {
59 value: "repository_path",
60 label: "Group by repository path",
61 subtitle: "Combine only matching paths within a repository",
62 },
63 {
64 value: "separate",
65 label: "Keep separate",
66 subtitle: "Show every project path separately",
67 },
68];
69
70function defaultHomeListOptions(): HomeListOptions {
71 return {
72 selectedEnvironmentId: null,
73 projectSortOrder:
74 DEFAULT_SIDEBAR_PROJECT_SORT_ORDER === "manual"
75 ? "updated_at"
76 : DEFAULT_SIDEBAR_PROJECT_SORT_ORDER,
77 threadSortOrder: DEFAULT_SIDEBAR_THREAD_SORT_ORDER,
78 projectGroupingMode: DEFAULT_SIDEBAR_PROJECT_GROUPING_MODE,
79 };
80}
81
82interface HomeListOptionsContextValue {
83 readonly options: HomeListOptions;
84 readonly setOptions: Dispatch<SetStateAction<HomeListOptions>>;
85}
86
87const HomeListOptionsContext = createContext<HomeListOptionsContextValue | null>(null);
88
89/** Keeps list preferences stable while the app moves between compact and split shells. */
90export function HomeListOptionsProvider({ children }: PropsWithChildren) {
91 const [options, setOptions] = useState<HomeListOptions>(defaultHomeListOptions);
92 const value = useMemo(() => ({ options, setOptions }), [options]);
93 return createElement(HomeListOptionsContext, { value }, children);
94}
95
96export function hasCustomHomeListOptions(options: HomeListOptions): boolean {
97 const defaultProjectSortOrder =
98 DEFAULT_SIDEBAR_PROJECT_SORT_ORDER === "manual"
99 ? "updated_at"
100 : DEFAULT_SIDEBAR_PROJECT_SORT_ORDER;
101 return (
102 options.selectedEnvironmentId !== null ||
103 options.projectSortOrder !== defaultProjectSortOrder ||
104 options.threadSortOrder !== DEFAULT_SIDEBAR_THREAD_SORT_ORDER ||
105 options.projectGroupingMode !== DEFAULT_SIDEBAR_PROJECT_GROUPING_MODE
106 );
107}
108
109export function useHomeListOptions(availableEnvironmentIds: ReadonlySet<EnvironmentId>) {
110 const shared = useContext(HomeListOptionsContext);
111 const [localOptions, setLocalOptions] = useState<HomeListOptions>(defaultHomeListOptions);
112 const options = shared?.options ?? localOptions;
113 const setOptions = shared?.setOptions ?? setLocalOptions;
114 const selectedEnvironmentId =
115 options.selectedEnvironmentId !== null &&
116 availableEnvironmentIds.has(options.selectedEnvironmentId)
117 ? options.selectedEnvironmentId
118 : null;
119 const resolvedOptions =
120 selectedEnvironmentId === options.selectedEnvironmentId
121 ? options
122 : { ...options, selectedEnvironmentId };
123
124 const setSelectedEnvironmentId = useCallback((value: EnvironmentId | null) => {
125 setOptions((current) => ({ ...current, selectedEnvironmentId: value }));
126 }, []);
127 const setProjectSortOrder = useCallback((value: HomeProjectSortOrder) => {
128 setOptions((current) => ({ ...current, projectSortOrder: value }));
129 }, []);
130 const setThreadSortOrder = useCallback((value: SidebarThreadSortOrder) => {
131 setOptions((current) => ({ ...current, threadSortOrder: value }));
132 }, []);
133 const setProjectGroupingMode = useCallback((value: SidebarProjectGroupingMode) => {
134 setOptions((current) => ({ ...current, projectGroupingMode: value }));
135 }, []);
136
137 return {
138 options: resolvedOptions,
139 setSelectedEnvironmentId,
140 setProjectSortOrder,
141 setThreadSortOrder,
142 setProjectGroupingMode,
143 } as const;
144}
apps/mobile/src/features/home/homeListItems.test.ts+197−0
@@ -0,0 +1,197 @@
1import type {
2 EnvironmentProject,
3 EnvironmentThreadShell,
4} from "@t3tools/client-runtime/state/shell";
5import { EnvironmentId, ProjectId, ProviderInstanceId, ThreadId } from "@t3tools/contracts";
6import { describe, expect, it } from "vite-plus/test";
7
8import {
9 buildHomeListLayout,
10 DEFAULT_GROUP_DISPLAY_STATE,
11 HOME_INITIAL_VISIBLE_THREADS,
12 HOME_SHOW_MORE_STEP,
13 nextGroupDisplayState,
14 type HomeGroupDisplayState,
15 type HomeListItem,
16} from "./homeListItems";
17import type { HomeThreadGroup } from "./homeThreadList";
18
19const environmentId = EnvironmentId.make("environment-1");
20
21function makeProject(id: string, title: string): EnvironmentProject {
22 return {
23 environmentId,
24 id: ProjectId.make(id),
25 title,
26 workspaceRoot: `/workspaces/${id}`,
27 repositoryIdentity: null,
28 defaultModelSelection: null,
29 scripts: [],
30 createdAt: "2026-06-01T00:00:00.000Z",
31 updatedAt: "2026-06-01T00:00:00.000Z",
32 };
33}
34
35function makeThread(id: string, projectId: ProjectId): EnvironmentThreadShell {
36 return {
37 environmentId,
38 id: ThreadId.make(id),
39 projectId,
40 title: `Thread ${id}`,
41 modelSelection: { instanceId: ProviderInstanceId.make("codex"), model: "gpt-5.4" },
42 runtimeMode: "full-access",
43 interactionMode: "default",
44 branch: null,
45 worktreePath: null,
46 latestTurn: null,
47 createdAt: "2026-06-01T00:00:00.000Z",
48 updatedAt: "2026-06-01T00:00:00.000Z",
49 archivedAt: null,
50 session: null,
51 latestUserMessageAt: null,
52 hasPendingApprovals: false,
53 hasPendingUserInput: false,
54 hasActionableProposedPlan: false,
55 };
56}
57
58function makeGroup(key: string, threadCount: number): HomeThreadGroup {
59 const project = makeProject(key, key);
60 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
76function itemTypes(items: ReadonlyArray<HomeListItem>): string[] {
77 return items.map((item) => item.type);
78}
79
80function displayStates(
81 entries: Record<string, HomeGroupDisplayState>,
82): ReadonlyMap<string, HomeGroupDisplayState> {
83 return new Map(Object.entries(entries));
84}
85
86describe("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});
apps/mobile/src/features/home/homeListItems.ts+204−0
@@ -0,0 +1,204 @@
1import type { EnvironmentThreadShell } from "@t3tools/client-runtime/state/shell";
2
3import type { PendingNewTask } from "../../state/use-pending-new-tasks";
4import type { HomeThreadGroup } from "./homeThreadList";
5
6/** Threads shown per project before the "Show more" affordance appears. */
7export const HOME_INITIAL_VISIBLE_THREADS = 6;
8/** Additional threads revealed per "Show more" tap. */
9export const HOME_SHOW_MORE_STEP = 10;
10
11export interface HomeGroupDisplayState {
12 readonly collapsed: boolean;
13 /** How many threads are currently revealed (clamped to the group size). */
14 readonly visibleCount: number;
15}
16
17export const DEFAULT_GROUP_DISPLAY_STATE: HomeGroupDisplayState = {
18 collapsed: false,
19 visibleCount: HOME_INITIAL_VISIBLE_THREADS,
20};
21
22export interface HomeHeaderListItem {
23 readonly type: "header";
24 readonly key: string;
25 readonly group: HomeThreadGroup;
26 readonly collapsed: boolean;
27 readonly isFirst: boolean;
28}
29
30export interface HomeThreadListItem {
31 readonly type: "thread";
32 readonly key: string;
33 readonly thread: EnvironmentThreadShell;
34 readonly isLast: boolean;
35}
36
37export interface HomePendingTaskListItem {
38 readonly type: "pending-task";
39 readonly key: string;
40 readonly pendingTask: PendingNewTask;
41 readonly isLast: boolean;
42}
43
44export 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
54export type HomeListItem =
55 | HomeHeaderListItem
56 | HomePendingTaskListItem
57 | HomeThreadListItem
58 | HomeShowMoreListItem;
59
60export interface HomeListLayout {
61 readonly items: ReadonlyArray<HomeListItem>;
62 readonly stickyHeaderIndices: ReadonlyArray<number>;
63}
64
65export type HomeGroupDisplayAction = "toggle-collapsed" | "show-more" | "show-less";
66
67export 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 */
88export 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
119export 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}
apps/mobile/src/features/home/homeThreadList.ts+57−4
@@ -17,6 +17,7 @@ import * as Arr from "effect/Array"; · 16 unmodified lines
17import * as Order from "effect/Order";17import * as Order from "effect/Order";
18 18
19import { scopedProjectKey } from "../../lib/scopedEntities";19import { scopedProjectKey } from "../../lib/scopedEntities";
20import type { PendingNewTask } from "../../state/use-pending-new-tasks";
20 21
21export type HomeProjectSortOrder = Exclude<SidebarProjectSortOrder, "manual">;22export type HomeProjectSortOrder = Exclude<SidebarProjectSortOrder, "manual">;
22 23
@@ -33,6 +34,7 @@ export interface HomeThreadGroup { · 10 unmodified lines
33 readonly title: string;34 readonly title: string;
34 readonly representative: EnvironmentProject;35 readonly representative: EnvironmentProject;
35 readonly projects: ReadonlyArray<EnvironmentProject>;36 readonly projects: ReadonlyArray<EnvironmentProject>;
37 readonly pendingTasks: ReadonlyArray<PendingNewTask>;
36 /** Full sorted thread history for the group (revealed when expanded / searching). */38 /** Full sorted thread history for the group (revealed when expanded / searching). */
37 readonly threads: ReadonlyArray<EnvironmentThreadShell>;39 readonly threads: ReadonlyArray<EnvironmentThreadShell>;
38 /** Subset shown by default: threads from the last few days, or the most recent few. */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
42interface MutableHomeThreadGroup {44interface MutableHomeThreadGroup {
43 readonly key: string;45 readonly key: string;
44 readonly projects: EnvironmentProject[];46 readonly projects: EnvironmentProject[];
47 readonly pendingTasks: PendingNewTask[];
45 readonly threads: EnvironmentThreadShell[];48 readonly threads: EnvironmentThreadShell[];
46}49}
47 50
48function groupSortTimestamp(group: HomeThreadGroup, sortOrder: HomeProjectSortOrder): number {51function groupSortTimestamp(group: HomeThreadGroup, sortOrder: HomeProjectSortOrder): number {
49 return group.threads.reduce(52 const latestThread = group.threads.reduce(
50 (latest, thread) => Math.max(latest, getThreadSortTimestamp(thread, sortOrder)),53 (latest, thread) => Math.max(latest, getThreadSortTimestamp(thread, sortOrder)),
51 Number.NEGATIVE_INFINITY,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
73export function buildHomeThreadGroups(input: {80export function buildHomeThreadGroups(input: {
74 readonly projects: ReadonlyArray<EnvironmentProject>;81 readonly projects: ReadonlyArray<EnvironmentProject>;
75 readonly threads: ReadonlyArray<EnvironmentThreadShell>;82 readonly threads: ReadonlyArray<EnvironmentThreadShell>;
83 readonly pendingTasks?: ReadonlyArray<PendingNewTask>;
76 readonly environmentId: EnvironmentId | null;84 readonly environmentId: EnvironmentId | null;
77 readonly searchQuery: string;85 readonly searchQuery: string;
78 readonly projectSortOrder: HomeProjectSortOrder;86 readonly projectSortOrder: HomeProjectSortOrder;
@@ -100,10 +108,49 @@ export function buildHomeThreadGroups(input: { · 21 unmodified lines
100 if (existing) {108 if (existing) {
101 existing.projects.push(project);109 existing.projects.push(project);
102 } else {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 for (const thread of input.threads) {154 for (const thread of input.threads) {
108 if (thread.archivedAt !== null) {155 if (thread.archivedAt !== null) {
109 continue;156 continue;
@@ -125,7 +172,7 @@ export function buildHomeThreadGroups(input: { · 15 unmodified lines
125 172
126 for (const group of groups.values()) {173 for (const group of groups.values()) {
127 const representative = group.projects[0];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 continue;176 continue;
130 }177 }
131 178
@@ -140,8 +187,13 @@ export function buildHomeThreadGroups(input: { · 8 unmodified lines
140 const matchingThreads = groupMatches187 const matchingThreads = groupMatches
141 ? group.threads188 ? group.threads
142 : group.threads.filter((thread) => thread.title.toLocaleLowerCase().includes(query));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 continue;197 continue;
146 }198 }
147 199
@@ -158,6 +210,7 @@ export function buildHomeThreadGroups(input: { · 10 unmodified lines
158 title,210 title,
159 representative,211 representative,
160 projects: group.projects,212 projects: group.projects,
213 pendingTasks: matchingPendingTasks,
161 threads: sortedThreads,214 threads: sortedThreads,
162 recentThreads,215 recentThreads,
163 });216 });
apps/mobile/src/features/home/thread-swipe-actions.tsx+247−9
@@ -1,8 +1,26 @@
1import { SymbolView } from "expo-symbols";1import { SymbolView } from "expo-symbols";
2import type { ComponentProps } from "react";2import * as Haptics from "expo-haptics";
3import type { ColorValue } from "react-native";3import {
4 createContext,
5 use,
6 useCallback,
7 useEffect,
8 useRef,
9 useState,
10 type ComponentProps,
11 type ReactNode,
12} from "react";
13import type {
14 ColorValue,
15 NativeScrollEvent,
16 NativeSyntheticEvent,
17 StyleProp,
18 ViewStyle,
19} from "react-native";
4import { Pressable, View } from "react-native";20import { Pressable, View } from "react-native";
5import type { SwipeableMethods } from "react-native-gesture-handler/ReanimatedSwipeable";21import ReanimatedSwipeable, {
22 type SwipeableMethods,
23} from "react-native-gesture-handler/ReanimatedSwipeable";
6import Animated, {24import Animated, {
7 Extrapolation,25 Extrapolation,
8 interpolate,26 interpolate,
@@ -26,6 +44,231 @@ export const THREAD_SWIPE_SPRING = { · 17 unmodified lines
26 stiffness: 330,44 stiffness: 330,
27};45};
28 46
47interface ThreadSwipePrimaryAction {
48 readonly accessibilityLabel: string;
49 readonly icon: ComponentProps<typeof SymbolView>["name"];
50 readonly label: string;
51 readonly onPress: () => void;
52}
53
54/**
55 * Delivers the scroll gate to swipeables via context so that flipping it does
56 * NOT re-render whole rows: putting the flag in list extraData/renderItem deps
57 * re-rendered every visible row (hooks, subscriptions and all) exactly at
58 * scroll start — peak frame pressure. As a context value only the
59 * ThreadSwipeable consumers re-render.
60 */
61const SwipeableScrollGateContext = createContext(true);
62
63export function SwipeableScrollGateProvider(props: {
64 readonly enabled: boolean;
65 readonly children: ReactNode;
66}) {
67 return (
68 <SwipeableScrollGateContext.Provider value={props.enabled}>
69 {props.children}
70 </SwipeableScrollGateContext.Provider>
71 );
72}
73
74/**
75 * Gates row swipes on list scroll activity, mirroring UIKit's own swipe
76 * actions (`!isDragging && !isDecelerating`). failOffsetY on the swipe pan
77 * covers the first pan of a scroll, but trackpad scroll sessions spawn fresh
78 * gesture sessions (momentum catch, direction changes) whose reset
79 * translation can re-activate a swipe mid-scroll — so while the list has
80 * moved vertically during an active drag/momentum phase, row swipes are
81 * disabled entirely.
82 *
83 * Spread the returned handlers onto the list and pass `swipeEnabled` to rows.
84 */
85export function useSwipeableScrollGate(options?: {
86 readonly onScroll?: (event: NativeSyntheticEvent<NativeScrollEvent>) => void;
87 readonly onScrollBeginDrag?: (event: NativeSyntheticEvent<NativeScrollEvent>) => void;
88}) {
89 const [gateActive, setGateActive] = useState(false);
90 const gateActiveRef = useRef(false);
91 const draggingRef = useRef(false);
92 const dragStartYRef = useRef(0);
93 const settleTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
94 const externalOnScroll = options?.onScroll;
95 const externalOnScrollBeginDrag = options?.onScrollBeginDrag;
96
97 const update = useCallback((next: boolean) => {
98 if (gateActiveRef.current !== next) {
99 gateActiveRef.current = next;
100 setGateActive(next);
101 }
102 }, []);
103 const clearSettle = useCallback(() => {
104 if (settleTimerRef.current !== null) {
105 clearTimeout(settleTimerRef.current);
106 settleTimerRef.current = null;
107 }
108 }, []);
109 useEffect(() => clearSettle, [clearSettle]);
110
111 const onScrollBeginDrag = useCallback(
112 (event: NativeSyntheticEvent<NativeScrollEvent>) => {
113 draggingRef.current = true;
114 dragStartYRef.current = event.nativeEvent.contentOffset.y;
115 clearSettle();
116 externalOnScrollBeginDrag?.(event);
117 },
118 [clearSettle, externalOnScrollBeginDrag],
119 );
120 const onScroll = useCallback(
121 (event: NativeSyntheticEvent<NativeScrollEvent>) => {
122 // Only vertical movement during a user drag arms the gate — a purely
123 // horizontal row swipe never moves contentOffset.y, and inset-driven
124 // offset changes at mount happen outside a drag.
125 if (
126 draggingRef.current &&
127 !gateActiveRef.current &&
128 Math.abs(event.nativeEvent.contentOffset.y - dragStartYRef.current) > 4
129 ) {
130 update(true);
131 }
132 externalOnScroll?.(event);
133 },
134 [externalOnScroll, update],
135 );
136 const onScrollEndDrag = useCallback(() => {
137 draggingRef.current = false;
138 clearSettle();
139 // If momentum follows, onMomentumScrollBegin cancels this and the gate
140 // stays armed until the deceleration finishes.
141 settleTimerRef.current = setTimeout(() => update(false), 160);
142 }, [clearSettle, update]);
143 const onMomentumScrollBegin = useCallback(() => {
144 clearSettle();
145 }, [clearSettle]);
146 const onMomentumScrollEnd = useCallback(() => {
147 update(false);
148 }, [update]);
149
150 return {
151 swipeEnabled: !gateActive,
152 scrollGateHandlers: {
153 onScroll,
154 onScrollBeginDrag,
155 onScrollEndDrag,
156 onMomentumScrollBegin,
157 onMomentumScrollEnd,
158 },
159 };
160}
161
162export function ThreadSwipeable(props: {
163 readonly backgroundColor: ColorValue;
164 readonly children: (close: () => void) => ReactNode;
165 readonly containerStyle?: StyleProp<ViewStyle>;
166 /** Disables NEW swipe activations (e.g. while the list scrolls). */
167 readonly enabled?: boolean;
168 readonly enableTrackpadSwipe?: boolean;
169 readonly fullSwipeWidth: number;
170 readonly onDelete: () => void;
171 readonly onSwipeableClose?: (methods: SwipeableMethods) => void;
172 readonly onSwipeableWillOpen?: (methods: SwipeableMethods) => void;
173 readonly primaryAction: ThreadSwipePrimaryAction;
174 /**
175 * Identity of the content being wrapped. When a recycled list reuses this
176 * component for a different item, the swipeable snaps back to closed so an
177 * open/mid-drag state can't leak onto another row.
178 */
179 readonly resetKey?: string;
180 readonly simultaneousWithExternalGesture?: ComponentProps<
181 typeof ReanimatedSwipeable
182 >["simultaneousWithExternalGesture"];
183 readonly threadTitle: string;
184}) {
185 const swipeableRef = useRef<SwipeableMethods | null>(null);
186 const fullSwipeArmedRef = useRef(false);
187 const fullSwipeThreshold = Math.max(THREAD_SWIPE_ACTIONS_WIDTH + 44, props.fullSwipeWidth * 0.58);
188 const close = useCallback(() => swipeableRef.current?.close(), []);
189 const gateEnabled = use(SwipeableScrollGateContext);
190 const resetKey = props.resetKey;
191 useEffect(() => {
192 if (resetKey === undefined) {
193 return;
194 }
195 fullSwipeArmedRef.current = false;
196 swipeableRef.current?.reset();
197 }, [resetKey]);
198 const handleFullSwipeArmedChange = useCallback((armed: boolean) => {
199 if (armed && !fullSwipeArmedRef.current && process.env.EXPO_OS === "ios") {
200 void Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Medium);
201 }
202 fullSwipeArmedRef.current = armed;
203 }, []);
204
205 return (
206 <ReanimatedSwipeable
207 ref={swipeableRef}
208 animationOptions={THREAD_SWIPE_SPRING}
209 childrenContainerStyle={{ backgroundColor: props.backgroundColor }}
210 containerStyle={[{ backgroundColor: props.backgroundColor }, props.containerStyle]}
211 dragOffsetFromRightEdge={8}
212 enabled={props.enabled !== false && gateEnabled}
213 enableTrackpadTwoFingerGesture={props.enableTrackpadSwipe ?? true}
214 // Fail the swipe once the pan is vertically dominant (patched-in RNGH
215 // prop) — otherwise trackpad scrolls with ~8px of horizontal drift
216 // start opening rows because the swipe pan runs simultaneously with
217 // the list scroll gesture and never gets disqualified by Y movement.
218 failOffsetY={[-10, 10]}
219 friction={1}
220 onSwipeableClose={() => {
221 fullSwipeArmedRef.current = false;
222 if (swipeableRef.current) {
223 props.onSwipeableClose?.(swipeableRef.current);
224 }
225 }}
226 onSwipeableOpenStartDrag={() => {
227 if (swipeableRef.current) {
228 props.onSwipeableWillOpen?.(swipeableRef.current);
229 }
230 }}
231 onSwipeableWillOpen={() => {
232 const methods = swipeableRef.current;
233 if (!methods) {
234 return;
235 }
236
237 props.onSwipeableWillOpen?.(methods);
238 if (fullSwipeArmedRef.current) {
239 fullSwipeArmedRef.current = false;
240 methods.close();
241 props.onDelete();
242 }
243 }}
244 overshootFriction={1}
245 overshootRight
246 renderRightActions={(_progress, translation, methods) => (
247 <ThreadSwipeActions
248 backgroundColor={props.backgroundColor}
249 fullSwipeThreshold={fullSwipeThreshold}
250 onDelete={props.onDelete}
251 onFullSwipeArmedChange={handleFullSwipeArmedChange}
252 primaryAction={{
253 ...props.primaryAction,
254 onPress: () => {
255 methods.close();
256 props.primaryAction.onPress();
257 },
258 }}
259 swipeableMethods={methods}
260 threadTitle={props.threadTitle}
261 translation={translation}
262 />
263 )}
264 rightThreshold={THREAD_SWIPE_ACTIONS_WIDTH * 0.42}
265 simultaneousWithExternalGesture={props.simultaneousWithExternalGesture}
266 >
267 {props.children(close)}
268 </ReanimatedSwipeable>
269 );
270}
271
29function SwipeActionButton(props: {272function SwipeActionButton(props: {
30 readonly accessibilityLabel: string;273 readonly accessibilityLabel: string;
31 readonly backgroundColor: string;274 readonly backgroundColor: string;
@@ -179,12 +422,7 @@ export function ThreadSwipeActions(props: { · 147 unmodified lines
179 readonly fullSwipeThreshold: number;422 readonly fullSwipeThreshold: number;
180 readonly onDelete: () => void;423 readonly onDelete: () => void;
181 readonly onFullSwipeArmedChange: (armed: boolean) => void;424 readonly onFullSwipeArmedChange: (armed: boolean) => void;
182 readonly primaryAction: {425 readonly primaryAction: ThreadSwipePrimaryAction;
183 readonly accessibilityLabel: string;
184 readonly icon: ComponentProps<typeof SymbolView>["name"];
185 readonly label: string;
186 readonly onPress: () => void;
187 };
188 readonly swipeableMethods: SwipeableMethods;426 readonly swipeableMethods: SwipeableMethods;
189 readonly threadTitle: string;427 readonly threadTitle: string;
190 readonly translation: SharedValue<number>;428 readonly translation: SharedValue<number>;
apps/mobile/src/features/home/usePendingTaskListActions.ts+57−0
@@ -0,0 +1,57 @@
1import { useNavigation } from "@react-navigation/native";
2import { useCallback } from "react";
3import { Alert } from "react-native";
4
5import { removeThreadOutboxMessage } from "../../state/thread-outbox";
6import type { PendingNewTask } from "../../state/use-pending-new-tasks";
7import { releaseEditingQueuedMessage } from "../../state/use-thread-outbox";
8
9export 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}
apps/mobile/src/features/home/workspace-connection-status.ts+22−0
@@ -0,0 +1,22 @@
1import type { WorkspaceState } from "../../state/workspaceModel";
2
3export function shouldShowWorkspaceConnectionStatus(state: WorkspaceState): boolean {
4 return (
5 state.networkStatus === "offline" ||
6 state.connectionError !== null ||
7 state.hasConnectingEnvironment ||
8 (state.hasLoadedShellSnapshot && !state.hasReadyEnvironment)
9 );
10}
11
12export function workspaceConnectionStatusLabel(state: WorkspaceState): string {
13 if (state.networkStatus === "offline") return "You are offline";
14 if (state.connectingEnvironments.length === 1) {
15 return `Reconnecting to ${state.connectingEnvironments[0]!.environmentLabel}`;
16 }
17 if (state.connectingEnvironments.length > 1) {
18 return `Reconnecting ${state.connectingEnvironments.length} environments`;
19 }
20 if (state.connectionError !== null) return state.connectionError;
21 return "Not connected";
22}
apps/mobile/src/features/keyboard/HardwareKeyboardCommandProvider.tsx+73−0
@@ -0,0 +1,73 @@
1import { StackActions, useNavigation } from "@react-navigation/native";
2import { useCallback, useMemo, useSyncExternalStore, type PropsWithChildren } from "react";
3
4import { T3KeyboardCommands } from "../../native/T3KeyboardCommands";
5import {
6 dispatchHardwareKeyboardCommand,
7 getHardwareKeyboardCommandRegistrationVersion,
8 getRegisteredHardwareKeyboardCommands,
9 parseActiveThreadPath,
10 subscribeToHardwareKeyboardCommandRegistrations,
11 type HardwareKeyboardCommand,
12} from "./hardwareKeyboardCommands";
13
14export function HardwareKeyboardCommandProvider({
15 children,
16 pathname,
17}: PropsWithChildren<{ readonly pathname: string }>) {
18 const navigation = useNavigation();
19 const registrationVersion = useSyncExternalStore(
20 subscribeToHardwareKeyboardCommandRegistrations,
21 getHardwareKeyboardCommandRegistrationVersion,
22 getHardwareKeyboardCommandRegistrationVersion,
23 );
24 const enabledCommands = useMemo(() => {
25 const commands = new Set<HardwareKeyboardCommand>(getRegisteredHardwareKeyboardCommands());
26 commands.add("newTask");
27 if (pathname !== "/" || navigation.canGoBack()) commands.add("back");
28 if (parseActiveThreadPath(pathname)) {
29 commands.add("files");
30 commands.add("terminal");
31 commands.add("review");
32 }
33 return [...commands];
34 }, [pathname, registrationVersion, navigation]);
35
36 const onCommand = useCallback(
37 (command: HardwareKeyboardCommand) => {
38 if (dispatchHardwareKeyboardCommand(command)) return;
39
40 if (command === "newTask") {
41 navigation.navigate("NewTaskSheet", { screen: "NewTask" });
42 return;
43 }
44 if (command === "back") {
45 if (navigation.canGoBack()) {
46 navigation.goBack();
47 } else {
48 navigation.dispatch(StackActions.replace("Home"));
49 }
50 return;
51 }
52
53 const thread = parseActiveThreadPath(pathname);
54 if (!thread) return;
55 if (command === "files" && !/\/files(?:\/|$)/.test(pathname)) {
56 navigation.navigate("ThreadFiles", thread);
57 }
58 if (command === "terminal" && !/\/terminal(?:\/|$)/.test(pathname)) {
59 navigation.navigate("ThreadTerminal", thread);
60 }
61 if (command === "review" && !/\/review(?:\/|$)/.test(pathname)) {
62 navigation.navigate("ThreadReview", thread);
63 }
64 },
65 [pathname, navigation],
66 );
67
68 return (
69 <T3KeyboardCommands enabledCommands={enabledCommands} onCommand={onCommand}>
70 {children}
71 </T3KeyboardCommands>
72 );
73}
apps/mobile/src/features/keyboard/hardwareKeyboardCommands.test.ts+28−0
@@ -0,0 +1,28 @@
1import { describe, expect, it } from "vite-plus/test";
2
3import { parseActiveThreadPath } from "./hardwareKeyboardCommands";
4
5describe("parseActiveThreadPath", () => {
6 it("extracts the active thread from thread subroutes", () => {
7 expect(parseActiveThreadPath("/threads/environment-1/thread-1/files/src/index.ts")).toEqual({
8 environmentId: "environment-1",
9 threadId: "thread-1",
10 });
11 });
12
13 it("decodes route components", () => {
14 expect(parseActiveThreadPath("/threads/local%20machine/thread%2Fone/review")).toEqual({
15 environmentId: "local machine",
16 threadId: "thread/one",
17 });
18 });
19
20 it("ignores non-thread routes", () => {
21 expect(parseActiveThreadPath("/settings")).toBeNull();
22 expect(parseActiveThreadPath("/threads/environment-only")).toBeNull();
23 });
24
25 it("ignores malformed encoded route components", () => {
26 expect(parseActiveThreadPath("/threads/%E0%A4%A/thread-1")).toBeNull();
27 });
28});
apps/mobile/src/features/keyboard/hardwareKeyboardCommands.ts+78−0
@@ -0,0 +1,78 @@
1import { EnvironmentId, ThreadId } from "@t3tools/contracts";
2import { useEffect } from "react";
3
4export type HardwareKeyboardCommand =
5 | "newTask"
6 | "focusSearch"
7 | "back"
8 | "files"
9 | "terminal"
10 | "review"
11 | "toggleSidebar";
12
13type CommandHandler = () => boolean | void;
14
15const handlers = new Map<HardwareKeyboardCommand, Set<CommandHandler>>();
16const registrationListeners = new Set<() => void>();
17let registrationVersion = 0;
18
19/**
20 * Registers a context-specific hardware-keyboard action. The most recently mounted handler gets
21 * the first chance to consume the command, allowing focused screens to override app defaults.
22 */
23export function useHardwareKeyboardCommand(
24 command: HardwareKeyboardCommand,
25 handler: CommandHandler,
26): void {
27 useEffect(() => {
28 const commandHandlers = handlers.get(command) ?? new Set<CommandHandler>();
29 commandHandlers.add(handler);
30 handlers.set(command, commandHandlers);
31 registrationVersion += 1;
32 registrationListeners.forEach((listener) => listener());
33 return () => {
34 commandHandlers.delete(handler);
35 if (commandHandlers.size === 0) handlers.delete(command);
36 registrationVersion += 1;
37 registrationListeners.forEach((listener) => listener());
38 };
39 }, [command, handler]);
40}
41
42export function getRegisteredHardwareKeyboardCommands(): ReadonlySet<HardwareKeyboardCommand> {
43 return new Set(handlers.keys());
44}
45
46export function getHardwareKeyboardCommandRegistrationVersion(): number {
47 return registrationVersion;
48}
49
50export function subscribeToHardwareKeyboardCommandRegistrations(listener: () => void): () => void {
51 registrationListeners.add(listener);
52 return () => registrationListeners.delete(listener);
53}
54
55export function dispatchHardwareKeyboardCommand(command: HardwareKeyboardCommand): boolean {
56 const commandHandlers = handlers.get(command);
57 if (!commandHandlers) return false;
58 for (const handler of [...commandHandlers].toReversed()) {
59 if (handler() !== false) return true;
60 }
61 return false;
62}
63
64export function parseActiveThreadPath(pathname: string): {
65 readonly environmentId: EnvironmentId;
66 readonly threadId: ThreadId;
67} | null {
68 const match = /^\/threads\/([^/]+)\/([^/]+)(?:\/|$)/.exec(pathname);
69 if (!match?.[1] || !match[2]) return null;
70 try {
71 return {
72 environmentId: EnvironmentId.make(decodeURIComponent(match[1])),
73 threadId: ThreadId.make(decodeURIComponent(match[2])),
74 };
75 } catch {
76 return null;
77 }
78}
apps/mobile/src/features/layout/AdaptiveWorkspaceLayout.tsx+507−0
@@ -0,0 +1,507 @@
1import type { EnvironmentThreadShell } from "@t3tools/client-runtime/state/shell";
2import { EnvironmentId, ThreadId } from "@t3tools/contracts";
3import { useFocusEffect } from "@react-navigation/native";
4import {
5 NavigationContext,
6 NavigationRouteContext,
7 StackActions,
8 useNavigation,
9} from "@react-navigation/native";
10import {
11 createContext,
12 use,
13 useCallback,
14 useEffect,
15 useMemo,
16 useRef,
17 useState,
18 type ReactNode,
19} from "react";
20import { useWindowDimensions, View } from "react-native";
21import Animated, { useAnimatedStyle, useSharedValue, withTiming } from "react-native-reanimated";
22
23import {
24 deriveFileInspectorPaneLayout,
25 deriveLayout,
26 deriveWorkspacePaneLayout,
27 type FileInspectorPaneLayout,
28 type Layout,
29 type WorkspaceAuxiliaryPaneRole,
30 type WorkspacePaneLayout,
31} from "../../lib/layout";
32import { resolveThreadSelectionNavigationAction } from "../../lib/adaptive-navigation";
33import { scopedThreadKey } from "../../lib/scopedEntities";
34import {
35 parseActiveThreadPath,
36 useHardwareKeyboardCommand,
37} from "../keyboard/hardwareKeyboardCommands";
38import { HomeListOptionsProvider } from "../home/home-list-options";
39import { ThreadNavigationSidebar } from "../threads/ThreadNavigationSidebar";
40import { WORKSPACE_PANE_TIMING } from "./workspace-pane-animation";
41import { WorkspaceInspectorPane } from "./workspace-inspector-pane";
42
43interface AdaptiveWorkspaceContextValue {
44 readonly layout: Layout;
45 readonly panes: WorkspacePaneLayout;
46 readonly fileInspector: FileInspectorPaneLayout;
47 readonly primarySidebarSearchQuery: string;
48 readonly activateAuxiliaryPaneRole: (role: WorkspaceAuxiliaryPaneRole) => () => void;
49 /**
50 * Route screens hand their inspector pane content to the workspace so it
51 * renders BESIDE the navigator (outside the native stack header) instead of
52 * inside the route. Returns a deactivate callback: the pane animates closed
53 * (content kept mounted for the exit transition) unless a newer
54 * registration already took over — stale deactivates never clobber it.
55 * Prefer useRegisterWorkspaceInspector over calling this directly.
56 */
57 readonly registerWorkspaceInspector: (render: () => ReactNode) => () => void;
58 readonly setPrimarySidebarSearchQuery: (query: string) => void;
59 readonly showAuxiliaryPane: (role: WorkspaceAuxiliaryPaneRole) => void;
60 readonly toggleAuxiliaryPane: () => void;
61 readonly togglePrimarySidebar: () => void;
62 readonly setAuxiliaryPaneWidth: (width: number) => void;
63}
64
65const compactLayout = deriveLayout({ width: 0, height: 0 });
66const compactPanes = deriveWorkspacePaneLayout({
67 layout: compactLayout,
68 viewportWidth: 0,
69 primarySidebarPreferredVisible: true,
70 auxiliaryPanePreferredVisible: true,
71});
72const compactFileInspector = deriveFileInspectorPaneLayout({
73 layout: compactLayout,
74 viewportWidth: 0,
75});
76const AdaptiveWorkspaceContext = createContext<AdaptiveWorkspaceContextValue>({
77 layout: compactLayout,
78 panes: compactPanes,
79 fileInspector: compactFileInspector,
80 primarySidebarSearchQuery: "",
81 activateAuxiliaryPaneRole: () => () => undefined,
82 registerWorkspaceInspector: () => () => undefined,
83 setPrimarySidebarSearchQuery: () => undefined,
84 showAuxiliaryPane: () => undefined,
85 toggleAuxiliaryPane: () => undefined,
86 togglePrimarySidebar: () => undefined,
87 setAuxiliaryPaneWidth: () => undefined,
88});
89
90export function useAdaptiveWorkspaceLayout(): AdaptiveWorkspaceContextValue {
91 return use(AdaptiveWorkspaceContext);
92}
93
94export function useAdaptiveWorkspacePaneRole(role: WorkspaceAuxiliaryPaneRole) {
95 const { activateAuxiliaryPaneRole } = useAdaptiveWorkspaceLayout();
96
97 useFocusEffect(
98 useCallback(() => activateAuxiliaryPaneRole(role), [activateAuxiliaryPaneRole, role]),
99 );
100}
101
102/**
103 * Register this screen's inspector pane content with the workspace column.
104 *
105 * The column renders BESIDE the navigator — outside any screen — so the
106 * registering screen's navigation and route contexts are captured here and
107 * re-provided around the portal content. Without them, useNavigation/useRoute
108 * inside the pane (e.g. GitOverviewSheet via useThreadSelection) throw
109 * "Couldn't find a route object".
110 *
111 * Registration is FOCUS-scoped, driven by navigation events rather than the
112 * screen's own render cycle: react-native-screens freezes blurred screens, so
113 * a cleanup that depends on the blurred subtree re-rendering never runs and
114 * would leak the pane into the next route. Blur deactivates the pane (it
115 * animates closed, or is replaced seamlessly when the next route registers in
116 * the same commit); focus re-registers it.
117 */
118export function useRegisterWorkspaceInspector(render: (() => ReactNode) | undefined) {
119 const { registerWorkspaceInspector } = useAdaptiveWorkspaceLayout();
120 // Raw context values (not the useNavigation/useRoute wrappers) so the
121 // portal re-provides exactly what this screen sees.
122 const navigation = use(NavigationContext);
123 const route = use(NavigationRouteContext);
124
125 const wrappedRender = useMemo(() => {
126 if (render === undefined) {
127 return undefined;
128 }
129 return () => (
130 <NavigationContext.Provider value={navigation}>
131 <NavigationRouteContext.Provider value={route}>{render()}</NavigationRouteContext.Provider>
132 </NavigationContext.Provider>
133 );
134 }, [navigation, render, route]);
135
136 const wrappedRenderRef = useRef(wrappedRender);
137 wrappedRenderRef.current = wrappedRender;
138 const focusedRef = useRef(false);
139 const deactivateRef = useRef<(() => void) | null>(null);
140
141 const syncRegistration = useCallback(() => {
142 if (!focusedRef.current || wrappedRenderRef.current === undefined) {
143 deactivateRef.current?.();
144 return;
145 }
146 deactivateRef.current = registerWorkspaceInspector(wrappedRenderRef.current);
147 }, [registerWorkspaceInspector]);
148
149 // Focus lifecycle. Blur/focus events fire even when the blurred subtree is
150 // frozen (events are navigation-driven, renders are not).
151 useFocusEffect(
152 useCallback(() => {
153 focusedRef.current = true;
154 syncRegistration();
155 return () => {
156 focusedRef.current = false;
157 syncRegistration();
158 };
159 }, [syncRegistration]),
160 );
161
162 // Content changes while focused re-register in place.
163 useEffect(() => {
164 if (focusedRef.current) {
165 syncRegistration();
166 }
167 }, [syncRegistration, wrappedRender]);
168
169 // Unmount: hand the pane back (owner-guarded, so a route that already
170 // took over is unaffected).
171 useEffect(
172 () => () => {
173 deactivateRef.current?.();
174 deactivateRef.current = null;
175 },
176 [],
177 );
178}
179
180export function AdaptiveWorkspaceLayout(props: {
181 readonly children: ReactNode;
182 readonly pathname: string;
183}) {
184 const { width, height } = useWindowDimensions();
185 const pathname = props.pathname;
186 const navigation = useNavigation();
187 const activeRoleOwner = useRef<symbol | null>(null);
188 const [primarySidebarPreferredVisible, setPrimarySidebarPreferredVisible] = useState(true);
189 const [supplementaryPanePreferredVisible, setSupplementaryPanePreferredVisible] = useState(true);
190 const [supplementaryPanePreferredWidth, setSupplementaryPanePreferredWidth] = useState<
191 number | null
192 >(null);
193 const [fileInspectorPreferredVisible, setFileInspectorPreferredVisible] = useState(true);
194 const [fileInspectorPreferredWidth, setFileInspectorPreferredWidth] = useState<number | null>(
195 null,
196 );
197 const [primarySidebarSearchQuery, setPrimarySidebarSearchQuery] = useState("");
198 const [focusedAuxiliaryPaneRole, setFocusedAuxiliaryPaneRole] =
199 useState<WorkspaceAuxiliaryPaneRole | null>(null);
200 const baseLayout = useMemo(() => deriveLayout({ width, height }), [height, width]);
201 const layout = baseLayout;
202 // In split layouts the sidebar IS the thread list — it renders on every
203 // route, including Home (which shows an empty-detail pane instead of the
204 // compact list).
205 const shouldRenderPrimarySidebar = layout.usesSplitView;
206 const fileInspector = useMemo(
207 () =>
208 deriveFileInspectorPaneLayout({
209 layout,
210 viewportWidth: width,
211 preferredWidth: fileInspectorPreferredWidth ?? undefined,
212 reservedLeadingWidth:
213 shouldRenderPrimarySidebar && primarySidebarPreferredVisible
214 ? (layout.listPaneWidth ?? 0)
215 : 0,
216 }),
217 [
218 fileInspectorPreferredWidth,
219 layout,
220 primarySidebarPreferredVisible,
221 shouldRenderPrimarySidebar,
222 width,
223 ],
224 );
225 const auxiliaryPaneRole: WorkspaceAuxiliaryPaneRole =
226 focusedAuxiliaryPaneRole ?? (/\/files(?:\/|$)/.test(pathname) ? "inspector" : "supplementary");
227 const auxiliaryPanePreferredVisible =
228 auxiliaryPaneRole === "inspector"
229 ? fileInspectorPreferredVisible
230 : supplementaryPanePreferredVisible;
231 const auxiliaryPanePreferredWidth =
232 auxiliaryPaneRole === "inspector"
233 ? fileInspectorPreferredWidth
234 : supplementaryPanePreferredWidth;
235 const panes = useMemo(
236 () =>
237 deriveWorkspacePaneLayout({
238 layout,
239 viewportWidth: width,
240 primarySidebarPreferredVisible,
241 auxiliaryPanePreferredVisible,
242 auxiliaryPaneRole,
243 auxiliaryPanePreferredWidth: auxiliaryPanePreferredWidth ?? undefined,
244 }),
245 [
246 auxiliaryPanePreferredVisible,
247 auxiliaryPaneRole,
248 auxiliaryPanePreferredWidth,
249 layout,
250 primarySidebarPreferredVisible,
251 width,
252 ],
253 );
254 const activeThread = parseActiveThreadPath(pathname);
255 const environmentId = activeThread?.environmentId ?? null;
256 const threadId = activeThread?.threadId ?? null;
257 const selectedThreadKey = useMemo(() => {
258 if (environmentId === null || threadId === null) {
259 return null;
260 }
261 try {
262 return scopedThreadKey(EnvironmentId.make(environmentId), ThreadId.make(threadId));
263 } catch {
264 return null;
265 }
266 }, [environmentId, threadId]);
267 // Wrapped in an object: bare functions in useState would be treated as
268 // lazy initializers/updaters. `active: false` keeps the outgoing route's
269 // content mounted so the pane can animate closed (or be replaced
270 // seamlessly by the next route's registration in the same commit).
271 const [workspaceInspector, setWorkspaceInspector] = useState<{
272 readonly render: () => ReactNode;
273 readonly active: boolean;
274 } | null>(null);
275 const workspaceInspectorOwner = useRef<symbol | null>(null);
276 const registerWorkspaceInspector = useCallback((render: () => ReactNode) => {
277 const owner = Symbol("workspace-inspector");
278 workspaceInspectorOwner.current = owner;
279 setWorkspaceInspector({ render, active: true });
280
281 return () => {
282 // During a push/replace the outgoing screen deactivates AFTER the
283 // incoming screen registered — only the current owner may deactivate.
284 if (workspaceInspectorOwner.current !== owner) {
285 return;
286 }
287 setWorkspaceInspector((current) => (current === null ? null : { ...current, active: false }));
288 };
289 }, []);
290 // Once the close animation settles, drop the stale content entirely.
291 const handleWorkspaceInspectorClosed = useCallback(() => {
292 setWorkspaceInspector((current) => (current !== null && !current.active ? null : current));
293 }, []);
294 const activateAuxiliaryPaneRole = useCallback((role: WorkspaceAuxiliaryPaneRole) => {
295 const owner = Symbol(role);
296 activeRoleOwner.current = owner;
297 setFocusedAuxiliaryPaneRole(role);
298
299 return () => {
300 if (activeRoleOwner.current !== owner) {
301 return;
302 }
303 activeRoleOwner.current = null;
304 setFocusedAuxiliaryPaneRole(null);
305 };
306 }, []);
307 const togglePrimarySidebar = useCallback(() => {
308 if (!panes.primarySidebarVisible && panes.primarySidebarSuppressedByAuxiliary) {
309 setFileInspectorPreferredVisible(false);
310 setPrimarySidebarPreferredVisible(true);
311 return;
312 }
313 setPrimarySidebarPreferredVisible((current) => !current);
314 }, [panes.primarySidebarSuppressedByAuxiliary, panes.primarySidebarVisible]);
315 const revealPrimarySidebar = useCallback(() => {
316 if (panes.primarySidebarSuppressedByAuxiliary) {
317 setFileInspectorPreferredVisible(false);
318 }
319 setPrimarySidebarPreferredVisible(true);
320 }, [panes.primarySidebarSuppressedByAuxiliary]);
321 const handleToggleSidebarCommand = useCallback(() => {
322 togglePrimarySidebar();
323 return true;
324 }, [togglePrimarySidebar]);
325 useHardwareKeyboardCommand("toggleSidebar", handleToggleSidebarCommand);
326 const showAuxiliaryPane = useCallback((role: WorkspaceAuxiliaryPaneRole) => {
327 if (role === "inspector") {
328 setFocusedAuxiliaryPaneRole("inspector");
329 setFileInspectorPreferredVisible(true);
330 return;
331 }
332 setFocusedAuxiliaryPaneRole("supplementary");
333 setSupplementaryPanePreferredVisible(true);
334 }, []);
335 const handleOpenFilesCommand = useCallback(() => {
336 const activeThread = parseActiveThreadPath(pathname);
337 if (!layout.usesSplitView || !fileInspector.supported || activeThread === null) {
338 return false;
339 }
340 showAuxiliaryPane("inspector");
341 if (/\/files(?:\/|$)/.test(pathname)) {
342 return true;
343 }
344 navigation.navigate("ThreadFiles", activeThread);
345 return true;
346 }, [fileInspector.supported, layout.usesSplitView, pathname, navigation, showAuxiliaryPane]);
347 useHardwareKeyboardCommand("files", handleOpenFilesCommand);
348 const toggleAuxiliaryPane = useCallback(() => {
349 if (auxiliaryPaneRole === "inspector") {
350 setFileInspectorPreferredVisible((current) => !current);
351 return;
352 }
353 setSupplementaryPanePreferredVisible((current) => !current);
354 }, [auxiliaryPaneRole]);
355 const setAuxiliaryPaneWidth = useCallback(
356 (nextWidth: number) => {
357 if (auxiliaryPaneRole === "inspector") {
358 setFileInspectorPreferredWidth(nextWidth);
359 return;
360 }
361 setSupplementaryPanePreferredWidth(nextWidth);
362 },
363 [auxiliaryPaneRole],
364 );
365 const contextValue = useMemo(
366 () => ({
367 layout,
368 panes,
369 fileInspector,
370 primarySidebarSearchQuery,
371 activateAuxiliaryPaneRole,
372 registerWorkspaceInspector,
373 setPrimarySidebarSearchQuery,
374 showAuxiliaryPane,
375 toggleAuxiliaryPane,
376 togglePrimarySidebar,
377 setAuxiliaryPaneWidth,
378 }),
379 [
380 activateAuxiliaryPaneRole,
381 fileInspector,
382 layout,
383 panes,
384 primarySidebarSearchQuery,
385 registerWorkspaceInspector,
386 showAuxiliaryPane,
387 setPrimarySidebarSearchQuery,
388 setAuxiliaryPaneWidth,
389 toggleAuxiliaryPane,
390 togglePrimarySidebar,
391 ],
392 );
393
394 const handleOpenSettings = useCallback(() => {
395 navigation.navigate("SettingsSheet", { screen: "Settings" });
396 }, [navigation]);
397
398 // Minted here (root stack navigation) so the sidebar pane stays free of
399 // navigation hooks — on iOS it renders inside an independent nav tree.
400 const handleOpenEnvironmentSettings = useCallback(() => {
401 navigation.navigate("SettingsSheet", { screen: "SettingsEnvironments" });
402 }, [navigation]);
403
404 const renderedSidebarWidth = useSharedValue(
405 panes.primarySidebarVisible ? (layout.listPaneWidth ?? 0) : 0,
406 );
407 useEffect(() => {
408 const targetWidth = panes.primarySidebarVisible ? (layout.listPaneWidth ?? 0) : 0;
409 renderedSidebarWidth.value = withTiming(targetWidth, WORKSPACE_PANE_TIMING);
410 }, [layout.listPaneWidth, panes.primarySidebarVisible, renderedSidebarWidth]);
411 const sidebarAnimatedStyle = useAnimatedStyle(() => ({
412 opacity: Math.min(1, renderedSidebarWidth.value / 80),
413 width: renderedSidebarWidth.value,
414 }));
415
416 // Freeze the content pane at its SETTLED width while the side panes
417 // animate. The navigator (native header + markdown feed) lays out ONCE per
418 // pane toggle instead of re-measuring on every animation frame — the
419 // animating columns merely clip/reveal it over a matching background.
420 // Continuously re-wrapping the chat feed was the main source of dropped
421 // frames during sidebar/inspector transitions.
422 const inspectorColumnTargetWidth =
423 workspaceInspector !== null && workspaceInspector.active && panes.auxiliaryPaneVisible
424 ? (panes.auxiliaryPaneWidth ?? 0)
425 : 0;
426 const contentSettledWidth = layout.usesSplitView
427 ? Math.max(0, panes.contentPaneWidth - inspectorColumnTargetWidth)
428 : null;
429
430 const handleSelectThread = useCallback(
431 (thread: EnvironmentThreadShell) => {
432 const params = {
433 environmentId: String(thread.environmentId),
434 threadId: String(thread.id),
435 };
436 const navigationAction = resolveThreadSelectionNavigationAction({
437 usesSplitView: layout.usesSplitView,
438 pathname,
439 });
440 if (navigationAction === "set-params") {
441 const nextThreadKey = scopedThreadKey(thread.environmentId, thread.id);
442 if (nextThreadKey === selectedThreadKey) {
443 return;
444 }
445 setFileInspectorPreferredVisible(false);
446 navigation.navigate("Thread", params);
447 return;
448 }
449 if (navigationAction === "replace") {
450 setFileInspectorPreferredVisible(false);
451 navigation.dispatch(StackActions.replace("Thread", params));
452 return;
453 }
454 navigation.navigate("Thread", params);
455 },
456 [layout.usesSplitView, pathname, navigation, selectedThreadKey],
457 );
458
459 return (
460 <HomeListOptionsProvider>
461 <AdaptiveWorkspaceContext.Provider value={contextValue}>
462 <View testID="adaptive-workspace-layout" style={{ flex: 1, flexDirection: "row" }}>
463 {shouldRenderPrimarySidebar && layout.listPaneWidth !== null ? (
464 <Animated.View
465 accessibilityElementsHidden={!panes.primarySidebarVisible}
466 collapsable={false}
467 importantForAccessibility={
468 panes.primarySidebarVisible ? "auto" : "no-hide-descendants"
469 }
470 pointerEvents={panes.primarySidebarVisible ? "auto" : "none"}
471 style={[{ alignSelf: "stretch", overflow: "hidden" }, sidebarAnimatedStyle]}
472 >
473 <ThreadNavigationSidebar
474 width={layout.listPaneWidth}
475 visible={panes.primarySidebarVisible}
476 onRequestVisibility={revealPrimarySidebar}
477 selectedThreadKey={selectedThreadKey}
478 onOpenSettings={handleOpenSettings}
479 onOpenEnvironmentSettings={handleOpenEnvironmentSettings}
480 onSelectThread={handleSelectThread}
481 onSearchQueryChange={setPrimarySidebarSearchQuery}
482 searchQuery={primarySidebarSearchQuery}
483 />
484 </Animated.View>
485 ) : null}
486 <View className="bg-screen" collapsable={false} style={{ flex: 1, overflow: "hidden" }}>
487 <View
488 collapsable={false}
489 style={
490 contentSettledWidth !== null ? { flex: 1, width: contentSettledWidth } : { flex: 1 }
491 }
492 >
493 {props.children}
494 </View>
495 </View>
496 <WorkspaceInspectorPane
497 active={workspaceInspector?.active ?? false}
498 panes={panes}
499 renderInspector={workspaceInspector?.render}
500 setAuxiliaryPaneWidth={setAuxiliaryPaneWidth}
501 onClosed={handleWorkspaceInspectorClosed}
502 />
503 </View>
504 </AdaptiveWorkspaceContext.Provider>
505 </HomeListOptionsProvider>
506 );
507}
apps/mobile/src/features/layout/WorkspaceEmptyDetail.tsx+30−0
@@ -0,0 +1,30 @@
1import { SymbolView } from "expo-symbols";
2import { Pressable, View } from "react-native";
3
4import { AppText as Text } from "../../components/AppText";
5import { useThemeColor } from "../../lib/useThemeColor";
6
7export function WorkspaceEmptyDetail(props: { readonly onStartNewTask?: () => void }) {
8 const iconColor = useThemeColor("--color-icon-subtle");
9
10 return (
11 <View className="flex-1 items-center justify-center bg-screen px-10">
12 <View className="max-w-[360px] items-center gap-3">
13 <SymbolView name="sidebar.left" size={34} tintColor={iconColor} type="hierarchical" />
14 <Text className="text-center text-xl font-t3-bold">Select a thread</Text>
15 <Text className="text-center text-base text-foreground-muted">
16 Choose a thread from the sidebar or start a new task.
17 </Text>
18 {props.onStartNewTask ? (
19 <Pressable
20 accessibilityRole="button"
21 className="mt-2 flex-row items-center gap-2 rounded-full bg-primary px-5 py-3 active:opacity-70"
22 onPress={props.onStartNewTask}
23 >
24 <Text className="text-base font-t3-bold text-primary-foreground">New Task</Text>
25 </Pressable>
26 ) : null}
27 </View>
28 </View>
29 );
30}
apps/mobile/src/features/layout/native-glass-header-items.ts+33−0
@@ -0,0 +1,33 @@
1type NativeGlassHeaderItem = {
2 readonly type: "button" | "menu";
3 readonly glassEffect?: boolean;
4 readonly hidesSharedBackground?: boolean;
5 readonly sharesBackground?: boolean;
6 readonly variant?: "plain" | "done" | "prominent";
7 readonly width?: number;
8};
9
10/**
11 * iOS 26/27 Mail-style header controls need the native glass button
12 * shared background configuration when they are not part of a larger toolbar.
13 * Do not enable `glassEffect` for normal bar-button items: react-native-screens
14 * renders that as a custom UIButton, which creates a second skinny capsule.
15 */
16export function withNativeGlassHeaderItem<T extends NativeGlassHeaderItem>(
17 item: T,
18 options: {
19 readonly hidesSharedBackground?: boolean;
20 readonly sharesBackground?: boolean;
21 readonly width?: number;
22 } = {},
23): T {
24 const sharesBackground = options.sharesBackground ?? item.sharesBackground ?? true;
25 return {
26 ...item,
27 glassEffect: item.glassEffect ?? false,
28 hidesSharedBackground: options.hidesSharedBackground ?? item.hidesSharedBackground ?? false,
29 sharesBackground,
30 variant: item.variant ?? "plain",
31 width: options.width ?? item.width,
32 } as T;
33}
apps/mobile/src/features/layout/native-mail-search-toolbar.ts+25−0
@@ -0,0 +1,25 @@
1import type { HeaderBarButtonMailSearchToolbarItem } from "react-native-screens";
2
3type NativeMailSearchToolbarInput = Omit<
4 HeaderBarButtonMailSearchToolbarItem,
5 "type" | "useFallbackSearchField"
6>;
7
8/**
9 * Builds the patched react-native-screens Mail-style bottom search toolbar.
10 *
11 * Keeping this behind an app-level helper makes the iOS-only RNS patch an
12 * explicit layout primitive instead of a per-screen object literal. Android can
13 * keep using platform-specific header/search primitives without depending on
14 * this helper.
15 */
16export function createNativeMailSearchToolbarItem(
17 input: NativeMailSearchToolbarInput,
18): HeaderBarButtonMailSearchToolbarItem {
19 return {
20 placeholder: "Search",
21 ...input,
22 type: "mailSearchToolbar",
23 useFallbackSearchField: true,
24 };
25}
apps/mobile/src/features/layout/workspace-inspector-pane.tsx+145−0
@@ -0,0 +1,145 @@
1import { useCallback, useEffect, useRef, useState, type ReactNode } from "react";
2import Animated, {
3 runOnJS,
4 useAnimatedStyle,
5 useSharedValue,
6 withTiming,
7} from "react-native-reanimated";
8
9import { constrainAuxiliaryPaneWidth, type WorkspacePaneLayout } from "../../lib/layout";
10import { WORKSPACE_PANE_TIMING } from "./workspace-pane-animation";
11import { WorkspacePaneDivider } from "./workspace-pane-divider";
12
13/**
14 * The trailing inspector column: resize divider + animated reveal.
15 *
16 * Rendered by AdaptiveWorkspaceLayout as a SIBLING of the navigator so the
17 * native stack header (and its trailing toolbar items) spans only the content
18 * pane — the inspector owns its own full-height column, mirroring how each
19 * column of a UISplitViewController has its own chrome.
20 *
21 * Receives the pane layout via props (not the workspace context hook) so this
22 * module stays import-cycle-free with AdaptiveWorkspaceLayout.
23 */
24export function WorkspaceInspectorPane(props: {
25 /**
26 * When false the pane animates closed but keeps its content mounted for the
27 * exit transition (a route that lost focus). `onClosed` fires once the
28 * close animation settles so the owner can drop the stale content.
29 */
30 readonly active?: boolean;
31 readonly onClosed?: () => void;
32 readonly panes: WorkspacePaneLayout;
33 readonly renderInspector?: () => ReactNode;
34 readonly setAuxiliaryPaneWidth: (width: number) => void;
35}) {
36 const { panes, setAuxiliaryPaneWidth } = props;
37 const inspectorWidth = panes.auxiliaryPaneWidth;
38 const inspectorSupported = props.renderInspector !== undefined && inspectorWidth !== null;
39 const inspectorVisible =
40 inspectorSupported && panes.auxiliaryPaneVisible && (props.active ?? true);
41 const resizeStartWidth = useRef(0);
42 const [resizing, setResizing] = useState(false);
43
44 // A file-to-file replace remounts the route. Initialize an already-visible
45 // inspector at its final position so route replacement never replays an
46 // entering transition. Only visibility and explicit resizing change it.
47 const inspectorProgress = useSharedValue(inspectorVisible ? 1 : 0);
48 const renderedInspectorWidth = useSharedValue(inspectorVisible ? (inspectorWidth ?? 0) : 0);
49 // The content keeps its own width so the reveal (outer width) clips a
50 // fully-laid-out pane instead of reflowing text every frame. When the OPEN
51 // pane's target width changes (e.g. the sidebar toggles and reserves
52 // space), animate the content width in lockstep rather than snapping.
53 const renderedContentWidth = useSharedValue(inspectorWidth ?? 0);
54
55 const onClosed = props.onClosed;
56 useEffect(() => {
57 inspectorProgress.value = withTiming(
58 inspectorVisible ? 1 : 0,
59 WORKSPACE_PANE_TIMING,
60 (finished) => {
61 if (finished === true && !inspectorVisible && onClosed !== undefined) {
62 runOnJS(onClosed)();
63 }
64 },
65 );
66 const targetWidth = inspectorVisible ? (inspectorWidth ?? 0) : 0;
67 renderedInspectorWidth.value = resizing
68 ? targetWidth
69 : withTiming(targetWidth, WORKSPACE_PANE_TIMING);
70 }, [
71 inspectorProgress,
72 inspectorVisible,
73 inspectorWidth,
74 onClosed,
75 renderedInspectorWidth,
76 resizing,
77 ]);
78
79 useEffect(() => {
80 const targetWidth = inspectorWidth ?? 0;
81 if (!inspectorVisible || resizing) {
82 // Hidden panes re-measure silently; during a divider drag the content
83 // tracks the finger directly.
84 renderedContentWidth.value = targetWidth;
85 return;
86 }
87 renderedContentWidth.value = withTiming(targetWidth, WORKSPACE_PANE_TIMING);
88 }, [inspectorVisible, inspectorWidth, renderedContentWidth, resizing]);
89
90 const inspectorStyle = useAnimatedStyle(
91 () => ({
92 opacity: inspectorProgress.value,
93 transform: [{ translateX: (1 - inspectorProgress.value) * 24 }],
94 width: renderedInspectorWidth.value,
95 }),
96 [],
97 );
98 const inspectorContentStyle = useAnimatedStyle(() => ({ width: renderedContentWidth.value }), []);
99 const beginResize = useCallback(() => {
100 resizeStartWidth.current = inspectorWidth ?? 0;
101 setResizing(true);
102 }, [inspectorWidth]);
103 const resizeBy = useCallback(
104 (delta: number) => {
105 setAuxiliaryPaneWidth(
106 constrainAuxiliaryPaneWidth({
107 preferredWidth: resizeStartWidth.current + delta,
108 availableWidth: panes.contentPaneWidth,
109 }),
110 );
111 },
112 [panes.contentPaneWidth, setAuxiliaryPaneWidth],
113 );
114 const endResize = useCallback(() => {
115 setResizing(false);
116 }, []);
117
118 return (
119 <>
120 {inspectorVisible ? (
121 <WorkspacePaneDivider
122 accessibilityLabel="Resize detail pane"
123 currentWidth={inspectorWidth ?? 0}
124 resizeDirection={-1}
125 onResizeStart={beginResize}
126 onResizeBy={resizeBy}
127 onResizeEnd={endResize}
128 />
129 ) : null}
130 {inspectorSupported ? (
131 <Animated.View
132 accessibilityElementsHidden={!inspectorVisible}
133 collapsable={false}
134 importantForAccessibility={inspectorVisible ? "auto" : "no-hide-descendants"}
135 pointerEvents={inspectorVisible ? "auto" : "none"}
136 style={[{ flexShrink: 0, overflow: "hidden" }, inspectorStyle]}
137 >
138 <Animated.View style={[{ flex: 1 }, inspectorContentStyle]}>
139 {props.renderInspector?.()}
140 </Animated.View>
141 </Animated.View>
142 ) : null}
143 </>
144 );
145}
apps/mobile/src/features/layout/workspace-pane-animation.ts+16−0
@@ -0,0 +1,16 @@
1import { Easing, ReduceMotion } from "react-native-reanimated";
2
3/**
4 * One timing curve for every workspace pane (primary sidebar + inspector).
5 *
6 * Panes frequently animate together — opening the sidebar can auto-close the
7 * inspector when both no longer fit — so identical duration and easing on
8 * every pane keeps the center content pane from wobbling while both edges
9 * move. Asymmetric open/close timings (the previous 220ms out-cubic open vs
10 * 160ms in-cubic close) read as jank during those simultaneous swaps.
11 */
12export const WORKSPACE_PANE_TIMING = {
13 duration: 260,
14 easing: Easing.inOut(Easing.cubic),
15 reduceMotion: ReduceMotion.System,
16} as const;
apps/mobile/src/features/layout/workspace-pane-divider.tsx+116−0
@@ -0,0 +1,116 @@
1import { useCallback, useMemo, useRef, useState } from "react";
2import {
3 Platform,
4 PlatformColor,
5 Pressable,
6 StyleSheet,
7 View,
8 type AccessibilityActionEvent,
9} from "react-native";
10import { Gesture, GestureDetector } from "react-native-gesture-handler";
11import { runOnJS } from "react-native-reanimated";
12
13const ACCESSIBILITY_RESIZE_STEP = 24;
14
15interface WorkspacePaneDividerProps {
16 readonly accessibilityLabel: string;
17 readonly currentWidth: number;
18 /** 1 when dragging right grows the pane, -1 when dragging left grows it. */
19 readonly resizeDirection: 1 | -1;
20 readonly onResizeStart?: () => void;
21 readonly onResizeBy: (delta: number) => void;
22 readonly onResizeEnd?: () => void;
23}
24
25/** A forgiving divider target for touch, pointer, and VoiceOver users. */
26export function WorkspacePaneDivider(props: WorkspacePaneDividerProps) {
27 const latestProps = useRef(props);
28 latestProps.current = props;
29 const [hovered, setHovered] = useState(false);
30 const [dragging, setDragging] = useState(false);
31 const handleResizeStart = useCallback(() => {
32 setDragging(true);
33 latestProps.current.onResizeStart?.();
34 }, []);
35 const handleResize = useCallback((translationX: number) => {
36 latestProps.current.onResizeBy(translationX * latestProps.current.resizeDirection);
37 }, []);
38 const handleResizeEnd = useCallback(() => {
39 setDragging(false);
40 latestProps.current.onResizeEnd?.();
41 }, []);
42 const resizeGesture = useMemo(
43 () =>
44 Gesture.Pan()
45 .activeOffsetX([-4, 4])
46 .failOffsetY([-24, 24])
47 .onStart(() => {
48 runOnJS(handleResizeStart)();
49 })
50 .onUpdate((event) => {
51 runOnJS(handleResize)(event.translationX);
52 })
53 .onFinalize(() => {
54 runOnJS(handleResizeEnd)();
55 }),
56 [handleResize, handleResizeEnd, handleResizeStart],
57 );
58
59 const handleAccessibilityAction = (event: AccessibilityActionEvent) => {
60 props.onResizeStart?.();
61 if (event.nativeEvent.actionName === "increment") {
62 props.onResizeBy(ACCESSIBILITY_RESIZE_STEP);
63 } else if (event.nativeEvent.actionName === "decrement") {
64 props.onResizeBy(-ACCESSIBILITY_RESIZE_STEP);
65 }
66 props.onResizeEnd?.();
67 };
68
69 return (
70 <GestureDetector gesture={resizeGesture}>
71 <Pressable
72 accessibilityActions={[
73 { name: "increment", label: "Make pane wider" },
74 { name: "decrement", label: "Make pane narrower" },
75 ]}
76 accessibilityLabel={props.accessibilityLabel}
77 accessibilityRole="adjustable"
78 accessibilityValue={{
79 now: Math.round(props.currentWidth),
80 text: `${Math.round(props.currentWidth)} points wide`,
81 }}
82 onAccessibilityAction={handleAccessibilityAction}
83 onHoverIn={() => setHovered(true)}
84 onHoverOut={() => setHovered(false)}
85 style={styles.hitTarget}
86 >
87 <View style={[styles.line, (hovered || dragging) && styles.activeLine]} />
88 </Pressable>
89 </GestureDetector>
90 );
91}
92
93const styles = StyleSheet.create({
94 hitTarget: {
95 alignSelf: "stretch",
96 cursor: "pointer",
97 justifyContent: "center",
98 marginHorizontal: -22,
99 position: "relative",
100 width: 44,
101 zIndex: 100,
102 },
103 line: {
104 alignSelf: "center",
105 backgroundColor:
106 Platform.OS === "ios" ? PlatformColor("separator") : "rgba(120, 120, 128, 0.28)",
107 height: "100%",
108 opacity: 0.7,
109 width: StyleSheet.hairlineWidth,
110 },
111 activeLine: {
112 backgroundColor: Platform.OS === "ios" ? PlatformColor("systemBlueColor") : "#0a84ff",
113 opacity: 1,
114 width: 2,
115 },
116});
apps/mobile/src/features/layout/workspace-sidebar-toolbar.tsx+31−0
@@ -0,0 +1,31 @@
1import { NativeHeaderToolbar } from "../../native/StackHeader";
2import type { ReactNode } from "react";
3
4import { useAdaptiveWorkspaceLayout } from "./AdaptiveWorkspaceLayout";
5
6export function WorkspaceSidebarToolbar(
7 props: {
8 readonly children?: ReactNode;
9 readonly afterSidebarButton?: ReactNode;
10 } = {},
11) {
12 const { layout, panes, togglePrimarySidebar } = useAdaptiveWorkspaceLayout();
13
14 if (!layout.usesSplitView) {
15 return null;
16 }
17
18 return (
19 <NativeHeaderToolbar placement="left">
20 {props.children}
21 <NativeHeaderToolbar.Button
22 accessibilityLabel={
23 panes.primarySidebarVisible ? "Maximize content" : "Show thread sidebar"
24 }
25 icon={panes.primarySidebarVisible ? "arrow.up.left.and.arrow.down.right" : "sidebar.left"}
26 onPress={togglePrimarySidebar}
27 />
28 {props.afterSidebarButton}
29 </NativeHeaderToolbar>
30 );
31}
apps/mobile/src/features/projects/AddProjectDestinationRoute.tsx+15−0
@@ -0,0 +1,15 @@
1import type { StaticScreenProps } from "@react-navigation/native";
2import { AddProjectDestinationScreen } from "./AddProjectScreen";
3
4type AddProjectDestinationRouteParams = {
5 readonly environmentId?: string | string[];
6 readonly source?: string | string[];
7 readonly remoteUrl?: string | string[];
8 readonly repositoryTitle?: string | string[];
9};
10
11export function AddProjectDestinationRoute({
12 route,
13}: StaticScreenProps<AddProjectDestinationRouteParams | undefined>) {
14 return <AddProjectDestinationScreen {...(route.params ?? {})} />;
15}
apps/mobile/src/features/projects/AddProjectLocalRoute.tsx+12−0
@@ -0,0 +1,12 @@
1import type { StaticScreenProps } from "@react-navigation/native";
2import { AddProjectLocalFolderScreen } from "./AddProjectScreen";
3
4type AddProjectLocalRouteParams = {
5 readonly environmentId?: string | string[];
6};
7
8export function AddProjectLocalRoute({
9 route,
10}: StaticScreenProps<AddProjectLocalRouteParams | undefined>) {
11 return <AddProjectLocalFolderScreen {...(route.params ?? {})} />;
12}
apps/mobile/src/features/projects/AddProjectRepositoryRoute.tsx+31−0
@@ -0,0 +1,31 @@
1import type { StaticScreenProps } from "@react-navigation/native";
2import { NativeStackScreenOptions } from "../../native/StackHeader";
3import { addProjectRemoteSourceLabel } from "@t3tools/client-runtime/operations/projects";
4
5import { AddProjectRepositoryScreen } from "./AddProjectScreen";
6
7type AddProjectRepositoryRouteParams = {
8 readonly environmentId?: string | string[];
9 readonly source?: string | string[];
10};
11
12export function AddProjectRepositoryRoute({
13 route,
14}: StaticScreenProps<AddProjectRepositoryRouteParams>) {
15 const params = route.params ?? {};
16 const source = Array.isArray(params.source) ? params.source[0] : params.source;
17 const title =
18 source === "github" ||
19 source === "gitlab" ||
20 source === "bitbucket" ||
21 source === "azure-devops"
22 ? addProjectRemoteSourceLabel(source)
23 : "Git URL";
24
25 return (
26 <>
27 <NativeStackScreenOptions options={{ title }} />
28 <AddProjectRepositoryScreen {...params} />
29 </>
30 );
31}
apps/mobile/src/features/projects/AddProjectScreen.tsx+56−58
@@ -22,7 +22,7 @@ import { · 21 unmodified lines
22 isFilesystemBrowseQuery,22 isFilesystemBrowseQuery,
23} from "@t3tools/client-runtime/state/projects";23} from "@t3tools/client-runtime/state/projects";
24import { CommandId, type EnvironmentId, ProjectId } from "@t3tools/contracts";24import { CommandId, type EnvironmentId, ProjectId } from "@t3tools/contracts";
25import { useLocalSearchParams, useRouter } from "expo-router";25import { StackActions, useNavigation } from "@react-navigation/native";
26import { SymbolView } from "expo-symbols";26import { SymbolView } from "expo-symbols";
27import { useCallback, useEffect, useMemo, useState, type ReactNode } from "react";27import { useCallback, useEffect, useMemo, useState, type ReactNode } from "react";
28import { ActivityIndicator, Alert, Pressable, ScrollView, View } from "react-native";28import { ActivityIndicator, Alert, Pressable, ScrollView, View } from "react-native";
@@ -168,9 +168,9 @@ function ListRow(props: { · 139 unmodified lines
168 {props.icon}168 {props.icon}
169 </View>169 </View>
170 <View className="flex-1 gap-0.5">170 <View className="flex-1 gap-0.5">
171 <Text className="text-base leading-[21px] font-t3-bold">{props.title}</Text>171 <Text className="text-base leading-snug font-t3-bold">{props.title}</Text>
172 {props.subtitle ? (172 {props.subtitle ? (
173 <Text className="text-sm leading-[17px] text-foreground-muted" numberOfLines={2}>173 <Text className="text-sm leading-snug text-foreground-muted" numberOfLines={2}>
174 {props.subtitle}174 {props.subtitle}
175 </Text>175 </Text>
176 ) : null}176 ) : null}
@@ -215,7 +215,7 @@ function ProjectPathInput(props: { · 38 unmodified lines
215}) {215}) {
216 return (216 return (
217 <TextInput217 <TextInput
218 className="h-12 min-h-12 rounded-[24px] px-4 py-0 text-base leading-[20px]"218 className="h-12 min-h-12 rounded-[24px] px-4 py-0 text-base leading-snug"
219 value={props.value}219 value={props.value}
220 onChangeText={props.onChangeText}220 onChangeText={props.onChangeText}
221 autoCapitalize="none"221 autoCapitalize="none"
@@ -250,35 +250,31 @@ function useSelectedEnvironment(): { · 28 unmodified lines
250 readonly selectedEnvironment: EnvironmentOption | null;250 readonly selectedEnvironment: EnvironmentOption | null;
251 readonly setSelectedEnvironmentId: (environmentId: EnvironmentId) => void;251 readonly setSelectedEnvironmentId: (environmentId: EnvironmentId) => void;
252} {252} {
253 const router = useRouter();253 const [selectedEnvironmentId, setSelectedEnvironmentId] = useState<EnvironmentId | null>(null);
254 const params = useLocalSearchParams<{ environmentId?: string }>();
255 const environmentOptions = useEnvironmentOptions();254 const environmentOptions = useEnvironmentOptions();
256 const requestedEnvironmentId = stringParam(params.environmentId) as EnvironmentId | null;
257 const selectedEnvironment =255 const selectedEnvironment =
258 environmentOptions.find(256 environmentOptions.find((environment) => environment.environmentId === selectedEnvironmentId) ??
259 (environment) => environment.environmentId === requestedEnvironmentId,
260 ) ??
261 environmentOptions[0] ??257 environmentOptions[0] ??
262 null;258 null;
263 259
264 return {260 return {
265 environmentOptions,261 environmentOptions,
266 selectedEnvironment,262 selectedEnvironment,
267 setSelectedEnvironmentId: (environmentId) => router.setParams({ environmentId }),263 setSelectedEnvironmentId,
268 };264 };
269}265}
270 266
271function EmptyEnvironmentState() {267function EmptyEnvironmentState() {
272 const router = useRouter();268 const navigation = useNavigation();
273 269
274 return (270 return (
275 <View className="items-center gap-3 rounded-2xl bg-card px-5 py-8">271 <View className="items-center gap-3 rounded-2xl bg-card px-5 py-8">
276 <Text className="text-center text-lg font-t3-bold">No environments connected</Text>272 <Text className="text-center text-lg font-t3-bold">No environments connected</Text>
277 <Text className="text-center text-sm leading-[20px] text-foreground-muted">273 <Text className="text-center text-sm leading-normal text-foreground-muted">
278 Add an environment before adding a project.274 Add an environment before adding a project.
279 </Text>275 </Text>
280 <Pressable276 <Pressable
281 onPress={() => router.replace("/connections/new")}277 onPress={() => navigation.dispatch(StackActions.replace("ConnectionsNew"))}
282 className="mt-1 rounded-full bg-primary px-4 py-2.5 active:opacity-70"278 className="mt-1 rounded-full bg-primary px-4 py-2.5 active:opacity-70"
283 >279 >
284 <Text className="text-sm font-t3-bold text-primary-foreground">Add environment</Text>280 <Text className="text-sm font-t3-bold text-primary-foreground">Add environment</Text>
@@ -294,7 +290,7 @@ function SourceControlRow(props: { · 9 unmodified lines
294 readonly hint: string;290 readonly hint: string;
295 readonly isFirst: boolean;291 readonly isFirst: boolean;
296}) {292}) {
297 const router = useRouter();293 const navigation = useNavigation();
298 const iconColor = useThemeColor("--color-icon");294 const iconColor = useThemeColor("--color-icon");
299 const title =295 const title =
300 props.source === "url" ? "Git URL" : `${addProjectRemoteSourceLabel(props.source)} repository`;296 props.source === "url" ? "Git URL" : `${addProjectRemoteSourceLabel(props.source)} repository`;
@@ -322,8 +318,8 @@ function SourceControlRow(props: { · 21 unmodified lines
322 icon={icon}318 icon={icon}
323 isFirst={props.isFirst}319 isFirst={props.isFirst}
324 onPress={() =>320 onPress={() =>
325 router.push({321 navigation.navigate("NewTaskSheet", {
326 pathname: "/new/add-project/repository",322 screen: "AddProjectRepository",
327 params: {323 params: {
328 environmentId: props.selectedEnvironmentId,324 environmentId: props.selectedEnvironmentId,
329 source: props.source,325 source: props.source,
@@ -335,7 +331,7 @@ function SourceControlRow(props: { · 5 unmodified lines
335}331}
336 332
337export function AddProjectSourceScreen() {333export function AddProjectSourceScreen() {
338 const router = useRouter();334 const navigation = useNavigation();
339 const accentColor = useThemeColor("--color-icon-muted");335 const accentColor = useThemeColor("--color-icon-muted");
340 const iconColor = useThemeColor("--color-icon");336 const iconColor = useThemeColor("--color-icon");
341 const { environmentOptions, selectedEnvironment, setSelectedEnvironmentId } =337 const { environmentOptions, selectedEnvironment, setSelectedEnvironmentId } =
@@ -409,9 +405,11 @@ export function AddProjectSourceScreen() { · 67 unmodified lines
409 }405 }
410 isFirst406 isFirst
411 onPress={() =>407 onPress={() =>
412 router.push({408 navigation.navigate("NewTaskSheet", {
413 pathname: "/new/add-project/local",409 screen: "AddProjectLocal",
414 params: { environmentId: selectedEnvironment.environmentId },410 params: {
411 environmentId: selectedEnvironment.environmentId,
412 },
415 })413 })
416 }414 }
417 />415 />
@@ -440,7 +438,7 @@ export function AddProjectSourceScreen() { · 22 unmodified lines
440}438}
441 439
442function useCreateProject(environment: EnvironmentOption | null) {440function useCreateProject(environment: EnvironmentOption | null) {
443 const router = useRouter();441 const navigation = useNavigation();
444 const createProject = useAtomCommand(projectEnvironment.create, { reportFailure: false });442 const createProject = useAtomCommand(projectEnvironment.create, { reportFailure: false });
445 const projects = useProjects();443 const projects = useProjects();
446 444
@@ -455,14 +453,13 @@ function useCreateProject(environment: EnvironmentOption | null) { · 8 unmodified lines
455 });453 });
456 if (existing) {454 if (existing) {
457 Alert.alert("Project already exists", existing.title);455 Alert.alert("Project already exists", existing.title);
458 router.replace({456 navigation.dispatch(
459 pathname: "/new/draft",457 StackActions.replace("NewTaskDraft", {
460 params: {
461 environmentId: existing.environmentId,458 environmentId: existing.environmentId,
462 projectId: existing.id,459 projectId: existing.id,
463 title: existing.title,460 title: existing.title,
464 },461 }),
465 });462 );
466 return;463 return;
467 }464 }
468 465
@@ -480,24 +477,24 @@ function useCreateProject(environment: EnvironmentOption | null) { · 11 unmodified lines
480 if (AsyncResult.isFailure(result)) {477 if (AsyncResult.isFailure(result)) {
481 return result;478 return result;
482 }479 }
483 router.replace({480 navigation.dispatch(
484 pathname: "/new/draft",481 StackActions.replace("NewTaskDraft", {
485 params: {
486 environmentId: environment.environmentId,482 environmentId: environment.environmentId,
487 projectId,483 projectId,
488 title: inferProjectTitleFromPath(workspaceRoot),484 title: inferProjectTitleFromPath(workspaceRoot),
489 },485 }),
490 });486 );
491 return result;487 return result;
492 },488 },
493 [createProject, environment, projects, router],489 [createProject, environment, projects, navigation],
494 );490 );
495}491}
496 492
497function useEnvironmentFromParam(): EnvironmentOption | null {493function useEnvironmentFromParam(
498 const params = useLocalSearchParams<{ environmentId?: string }>();494 environmentIdParam: string | string[] | undefined,
495): EnvironmentOption | null {
499 const environmentOptions = useEnvironmentOptions();496 const environmentOptions = useEnvironmentOptions();
500 const environmentId = stringParam(params.environmentId) as EnvironmentId | null;497 const environmentId = stringParam(environmentIdParam) as EnvironmentId | null;
501 return (498 return (
502 environmentOptions.find((environment) => environment.environmentId === environmentId) ??499 environmentOptions.find((environment) => environment.environmentId === environmentId) ??
503 environmentOptions[0] ??500 environmentOptions[0] ??
@@ -505,14 +502,16 @@ function useEnvironmentFromParam(): EnvironmentOption | null { · 1 unmodified line
505 );502 );
506}503}
507 504
508export function AddProjectRepositoryScreen() {505export function AddProjectRepositoryScreen(props: {
506 readonly environmentId?: string | string[];
507 readonly source?: string | string[];
508}) {
509 const lookupRepositoryQuery = useAtomQueryRunner(sourceControlEnvironment.repository, {509 const lookupRepositoryQuery = useAtomQueryRunner(sourceControlEnvironment.repository, {
510 reportFailure: false,510 reportFailure: false,
511 });511 });
512 const router = useRouter();512 const navigation = useNavigation();
513 const params = useLocalSearchParams<{ environmentId?: string; source?: string }>();513 const environment = useEnvironmentFromParam(props.environmentId);
514 const environment = useEnvironmentFromParam();514 const source = sourceFromParam(props.source);
515 const source = sourceFromParam(params.source);
516 const [repositoryInput, setRepositoryInput] = useState("");515 const [repositoryInput, setRepositoryInput] = useState("");
517 const [isSubmitting, setIsSubmitting] = useState(false);516 const [isSubmitting, setIsSubmitting] = useState(false);
518 const [error, setError] = useState<string | null>(null);517 const [error, setError] = useState<string | null>(null);
@@ -524,8 +523,8 @@ export function AddProjectRepositoryScreen() { · 5 unmodified lines
524 const provider = addProjectRemoteSourceProvider(source);523 const provider = addProjectRemoteSourceProvider(source);
525 if (!provider) {524 if (!provider) {
526 const remoteUrl = repositoryInput.trim();525 const remoteUrl = repositoryInput.trim();
527 router.push({526 navigation.navigate("NewTaskSheet", {
528 pathname: "/new/add-project/destination",527 screen: "AddProjectDestination",
529 params: {528 params: {
530 environmentId: environment.environmentId,529 environmentId: environment.environmentId,
531 source,530 source,
@@ -548,8 +547,8 @@ export function AddProjectRepositoryScreen() { · 16 unmodified lines
548 setError(errorMessage(Cause.squash(result.cause)));547 setError(errorMessage(Cause.squash(result.cause)));
549 } else {548 } else {
550 const repository = result.value;549 const repository = result.value;
551 router.push({550 navigation.navigate("NewTaskSheet", {
552 pathname: "/new/add-project/destination",551 screen: "AddProjectDestination",
553 params: {552 params: {
554 environmentId: environment.environmentId,553 environmentId: environment.environmentId,
555 source,554 source,
@@ -559,13 +558,13 @@ export function AddProjectRepositoryScreen() { · 3 unmodified lines
559 });558 });
560 }559 }
561 setIsSubmitting(false);560 setIsSubmitting(false);
562 }, [environment, isSubmitting, lookupRepositoryQuery, repositoryInput, router, source]);561 }, [environment, isSubmitting, lookupRepositoryQuery, repositoryInput, navigation, source]);
563 562
564 return (563 return (
565 <AddProjectShell>564 <AddProjectShell>
566 {error ? <ErrorBanner message={error} /> : null}565 {error ? <ErrorBanner message={error} /> : null}
567 <TextInput566 <TextInput
568 className="h-12 min-h-12 rounded-[24px] px-4 py-0 text-base leading-[20px]"567 className="h-12 min-h-12 rounded-[24px] px-4 py-0 text-base leading-snug"
569 value={repositoryInput}568 value={repositoryInput}
570 onChangeText={setRepositoryInput}569 onChangeText={setRepositoryInput}
571 autoCapitalize="none"570 autoCapitalize="none"
@@ -681,8 +680,8 @@ function FolderBrowser(props: { · 109 unmodified lines
681 );680 );
682}681}
683 682
684export function AddProjectLocalFolderScreen() {683export function AddProjectLocalFolderScreen(props: { readonly environmentId?: string | string[] }) {
685 const environment = useEnvironmentFromParam();684 const environment = useEnvironmentFromParam(props.environmentId);
686 const createProject = useCreateProject(environment);685 const createProject = useCreateProject(environment);
687 const [pathInput, setPathInput] = useState(() =>686 const [pathInput, setPathInput] = useState(() =>
688 getAddProjectInitialQuery(environment?.baseDirectory),687 getAddProjectInitialQuery(environment?.baseDirectory),
@@ -745,19 +744,18 @@ export function AddProjectLocalFolderScreen() { · 56 unmodified lines
745 );744 );
746}745}
747 746
748export function AddProjectDestinationScreen() {747export function AddProjectDestinationScreen(props: {
748 readonly environmentId?: string | string[];
749 readonly remoteUrl?: string | string[];
750 readonly repositoryTitle?: string | string[];
751}) {
749 const cloneRepository = useAtomCommand(sourceControlEnvironment.cloneRepository, {752 const cloneRepository = useAtomCommand(sourceControlEnvironment.cloneRepository, {
750 reportFailure: false,753 reportFailure: false,
751 });754 });
752 const params = useLocalSearchParams<{755 const environment = useEnvironmentFromParam(props.environmentId);
753 environmentId?: string;
754 remoteUrl?: string;
755 repositoryTitle?: string;
756 }>();
757 const environment = useEnvironmentFromParam();
758 const createProject = useCreateProject(environment);756 const createProject = useCreateProject(environment);
759 const remoteUrl = stringParam(params.remoteUrl);757 const remoteUrl = stringParam(props.remoteUrl);
760 const repositoryTitle = stringParam(params.repositoryTitle);758 const repositoryTitle = stringParam(props.repositoryTitle);
761 const [pathInput, setPathInput] = useState(() =>759 const [pathInput, setPathInput] = useState(() =>
762 getAddProjectInitialQuery(environment?.baseDirectory),760 getAddProjectInitialQuery(environment?.baseDirectory),
763 );761 );
apps/mobile/src/features/projects/AddProjectSourceRoute.tsx+5−0
@@ -0,0 +1,5 @@
1import { AddProjectSourceScreen } from "./AddProjectScreen";
2
3export function AddProjectSourceRoute() {
4 return <AddProjectSourceScreen />;
5}
apps/mobile/src/features/review/ReviewCommentComposerSheet.tsx+21−16
@@ -1,4 +1,4 @@
1import { useLocalSearchParams, useRouter } from "expo-router";1import { useNavigation, type StaticScreenProps } from "@react-navigation/native";
2import { SymbolView } from "expo-symbols";2import { SymbolView } from "expo-symbols";
3import { TextInputWrapper } from "expo-paste-input";3import { TextInputWrapper } from "expo-paste-input";
4import type { EnvironmentId, ThreadId } from "@t3tools/contracts";4import type { EnvironmentId, ThreadId } from "@t3tools/contracts";
@@ -25,10 +25,10 @@ import { · 20 unmodified lines
25 getSelectedReviewCommentLines,25 getSelectedReviewCommentLines,
26 useReviewCommentTarget,26 useReviewCommentTarget,
27} from "./reviewCommentSelection";27} from "./reviewCommentSelection";
28import { useAppearanceCodeSurface } from "../settings/appearance/useAppearanceCodeSurface";
28import {29import {
29 changeTone,30 changeTone,
30 DiffTokenText,31 DiffTokenText,
31 REVIEW_DIFF_LINE_HEIGHT,
32 REVIEW_MONO_FONT_FAMILY,32 REVIEW_MONO_FONT_FAMILY,
33 ReviewChangeBar,33 ReviewChangeBar,
34} from "./reviewDiffRendering";34} from "./reviewDiffRendering";
@@ -40,17 +40,20 @@ import { · 5 unmodified lines
40 40
41const REVIEW_COMMENT_PREVIEW_MAX_LINES = 5;41const REVIEW_COMMENT_PREVIEW_MAX_LINES = 5;
42 42
43export function ReviewCommentComposerSheet() {43type ReviewCommentComposerSheetProps = StaticScreenProps<{
44 const router = useRouter();44 readonly environmentId: EnvironmentId;
45 readonly threadId: ThreadId;
46}>;
47
48export function ReviewCommentComposerSheet(props: ReviewCommentComposerSheetProps) {
49 const navigation = useNavigation();
45 const insets = useSafeAreaInsets();50 const insets = useSafeAreaInsets();
46 const { width } = useWindowDimensions();51 const { width } = useWindowDimensions();
47 const colorScheme = useColorScheme();52 const colorScheme = useColorScheme();
48 const iconTint = String(useThemeColor("--color-icon"));53 const iconTint = String(useThemeColor("--color-icon"));
49 const target = useReviewCommentTarget();54 const target = useReviewCommentTarget();
50 const { environmentId, threadId } = useLocalSearchParams<{55 const { codeSurface } = useAppearanceCodeSurface();
51 environmentId: EnvironmentId;56 const { environmentId, threadId } = props.route.params;
52 threadId: ThreadId;
53 }>();
54 const [commentText, setCommentText] = useState("");57 const [commentText, setCommentText] = useState("");
55 const [highlightedLinesById, setHighlightedLinesById] = useState<58 const [highlightedLinesById, setHighlightedLinesById] = useState<
56 Record<string, ReadonlyArray<ReviewHighlightedToken>>59 Record<string, ReadonlyArray<ReviewHighlightedToken>>
@@ -78,14 +81,14 @@ export function ReviewCommentComposerSheet() { · 21 unmodified lines
78 ? `Lines ${firstNumber}-${lastNumber}`81 ? `Lines ${firstNumber}-${lastNumber}`
79 : `${selectedLines.length} lines selected`;82 : `${selectedLines.length} lines selected`;
80 const previewHeight = Math.max(83 const previewHeight = Math.max(
81 Math.min(selectedLines.length, REVIEW_COMMENT_PREVIEW_MAX_LINES) * REVIEW_DIFF_LINE_HEIGHT,84 Math.min(selectedLines.length, REVIEW_COMMENT_PREVIEW_MAX_LINES) * codeSurface.rowHeight,
82 REVIEW_DIFF_LINE_HEIGHT,85 codeSurface.rowHeight,
83 );86 );
84 const previewViewportWidth = Math.max(width - 40, 280);87 const previewViewportWidth = Math.max(width - 40, 280);
85 const dismissComposer = useCallback(() => {88 const dismissComposer = useCallback(() => {
86 clearReviewCommentTarget();89 clearReviewCommentTarget();
87 router.dismiss();90 navigation.goBack();
88 }, [router]);91 }, [navigation]);
89 const handleNativePaste = useNativePaste((uris) => {92 const handleNativePaste = useNativePaste((uris) => {
90 void (async () => {93 void (async () => {
91 try {94 try {
@@ -167,7 +170,7 @@ export function ReviewCommentComposerSheet() { · 75 unmodified lines
167 {!target ? (170 {!target ? (
168 <View className="rounded-[22px] border border-border bg-card px-4 py-5">171 <View className="rounded-[22px] border border-border bg-card px-4 py-5">
169 <Text className="text-base font-t3-bold text-foreground">No selection</Text>172 <Text className="text-base font-t3-bold text-foreground">No selection</Text>
170 <Text className="mt-1 text-sm leading-[19px] text-foreground-muted">173 <Text className="mt-1 text-sm leading-normal text-foreground-muted">
171 Select a diff line or range first.174 Select a diff line or range first.
172 </Text>175 </Text>
173 </View>176 </View>
@@ -178,7 +181,7 @@ export function ReviewCommentComposerSheet() { · 4 unmodified lines
178 {selectionLabel}181 {selectionLabel}
179 </Text>182 </Text>
180 <Text183 <Text
181 className="font-mono text-xs leading-[17px] text-foreground-muted"184 className="font-mono text-xs leading-snug text-foreground-muted"
182 ellipsizeMode="middle"185 ellipsizeMode="middle"
183 numberOfLines={2}186 numberOfLines={2}
184 >187 >
@@ -211,9 +214,9 @@ export function ReviewCommentComposerSheet() { · 26 unmodified lines
211 <View214 <View
212 key={line.id}215 key={line.id}
213 className={cn("flex-row items-start", changeTone(line.change))}216 className={cn("flex-row items-start", changeTone(line.change))}
214 style={{ height: REVIEW_DIFF_LINE_HEIGHT }}217 style={{ height: codeSurface.rowHeight }}
215 >218 >
216 <ReviewChangeBar change={line.change} />219 <ReviewChangeBar change={line.change} height={codeSurface.rowHeight} />
217 <Text220 <Text
218 className="w-9 py-1 pr-1 text-right text-2xs font-t3-medium text-foreground-muted"221 className="w-9 py-1 pr-1 text-right text-2xs font-t3-medium text-foreground-muted"
219 style={{ fontFamily: REVIEW_MONO_FONT_FAMILY }}222 style={{ fontFamily: REVIEW_MONO_FONT_FAMILY }}
@@ -225,6 +228,8 @@ export function ReviewCommentComposerSheet() { · 5 unmodified lines
225 fallback={line.content}228 fallback={line.content}
226 tokens={highlightedLinesById[line.id] ?? null}229 tokens={highlightedLinesById[line.id] ?? null}
227 change={line.change}230 change={line.change}
231 fontSize={codeSurface.fontSize}
232 lineHeight={codeSurface.rowHeight}
228 />233 />
229 </View>234 </View>
230 </View>235 </View>
apps/mobile/src/features/review/ReviewSheet.tsx+478−182
@@ -1,14 +1,30 @@
1import type { EnvironmentId, ThreadId } from "@t3tools/contracts";1import type { EnvironmentId, ThreadId } from "@t3tools/contracts";
2import { useLocalSearchParams } from "expo-router";2import { useNavigation, type StaticScreenProps } from "@react-navigation/native";
3import Stack from "expo-router/stack";3import {
4 NativeHeaderToolbar,
5 NativeStackScreenOptions,
6 nativeHeaderScrollEdgeEffects,
7} from "../../native/StackHeader";
8import { Screen, ScreenStack, ScreenStackHeaderConfig } from "react-native-screens";
4import { SymbolView } from "expo-symbols";9import { SymbolView } from "expo-symbols";
5import { memo, type ReactElement, useCallback, useMemo } from "react";10import {
11 memo,
12 type Ref,
13 type ReactElement,
14 useCallback,
15 useEffect,
16 useImperativeHandle,
17 useMemo,
18 useRef,
19 useState,
20} from "react";
6import {21import {
7 ActivityIndicator,22 ActivityIndicator,
23 FlatList,
24 Platform,
8 Pressable,25 Pressable,
9 ScrollView,26 ScrollView,
10 type NativeSyntheticEvent,27 type NativeSyntheticEvent,
11 Text as NativeText,
12 StyleSheet,28 StyleSheet,
13 useColorScheme,29 useColorScheme,
14 View,30 View,
@@ -20,23 +36,38 @@ import { environmentCatalog } from "../../connection/catalog"; · 5 unmodified lines
20import { useEnvironmentPresentation } from "../../state/presentation";36import { useEnvironmentPresentation } from "../../state/presentation";
21import { useAtomCommand } from "../../state/use-atom-command";37import { useAtomCommand } from "../../state/use-atom-command";
22import { useThemeColor } from "../../lib/useThemeColor";38import { useThemeColor } from "../../lib/useThemeColor";
23import { MOBILE_TYPOGRAPHY } from "../../lib/typography";
24import { useThreadDraftForThread } from "../../state/use-thread-composer-state";39import { useThreadDraftForThread } from "../../state/use-thread-composer-state";
25import { EnvironmentConnectionNotice } from "../connection/EnvironmentConnectionNotice";40import { EnvironmentConnectionNotice } from "../connection/EnvironmentConnectionNotice";
41import {
42 useAdaptiveWorkspaceLayout,
43 useAdaptiveWorkspacePaneRole,
44 useRegisterWorkspaceInspector,
45} from "../layout/AdaptiveWorkspaceLayout";
46import { useEnvironmentQuery } from "../../state/query";
47import { useSelectedThreadGitActions } from "../../state/use-selected-thread-git-actions";
48import { useSelectedThreadGitState } from "../../state/use-selected-thread-git-state";
49import { useSelectedThreadWorktree } from "../../state/use-selected-thread-worktree";
50import { useThreadSelection } from "../../state/use-thread-selection";
51import { vcsEnvironment } from "../../state/vcs";
52import { WorkspaceSidebarToolbar } from "../layout/workspace-sidebar-toolbar";
53import { ThreadGitMenu } from "../threads/ThreadGitControls";
26import { useReviewCacheForThread } from "./reviewState";54import { useReviewCacheForThread } from "./reviewState";
27import { resolveNativeReviewDiffView } from "../diffs/nativeReviewDiffSurface";
28import {55import {
29 NATIVE_REVIEW_DIFF_CONTENT_WIDTH,56 type NativeReviewDiffViewHandle,
30 NATIVE_REVIEW_DIFF_ROW_HEIGHT,57 resolveNativeReviewDiffView,
31} from "./nativeReviewDiffAdapter";58} from "../diffs/nativeReviewDiffSurface";
59import { NATIVE_REVIEW_DIFF_CONTENT_WIDTH } from "./nativeReviewDiffAdapter";
60import { useAppearanceCodeSurface } from "../settings/appearance/useAppearanceCodeSurface";
32import { useReviewDiffData } from "./useReviewDiffData";61import { useReviewDiffData } from "./useReviewDiffData";
62import { useReviewDiffPrewarming } from "./useReviewDiffPrewarming";
33import { useReviewFileVisibility } from "./reviewFileVisibility";63import { useReviewFileVisibility } from "./reviewFileVisibility";
34import { useReviewSections } from "./useReviewSections";64import { useReviewSections } from "./useReviewSections";
35import { useNativeReviewDiffBridge } from "./useNativeReviewDiffBridge";65import { useNativeReviewDiffBridge } from "./useNativeReviewDiffBridge";
36import { useReviewCommentSelectionController } from "./useReviewCommentSelectionController";66import { useReviewCommentSelectionController } from "./useReviewCommentSelectionController";
37import { resolveReviewAvailability } from "./reviewAvailability";67import { resolveReviewAvailability } from "./reviewAvailability";
68import { resolveSelectedReviewFileId } from "./reviewPaneSelection";
69import { buildReviewSectionMenu } from "./review-section-menu";
38 70
39const IOS_NAV_BAR_HEIGHT = 44;
40const REVIEW_HEADER_SPACING = 0;71const REVIEW_HEADER_SPACING = 0;
41 72
42const ReviewNotice = memo(function ReviewNotice(props: { readonly notice: string }) {73const ReviewNotice = memo(function ReviewNotice(props: { readonly notice: string }) {
@@ -45,7 +76,7 @@ const ReviewNotice = memo(function ReviewNotice(props: { readonly notice: string · 2 unmodified lines
45 <Text className="text-xs font-t3-bold uppercase text-amber-700 dark:text-amber-300">76 <Text className="text-xs font-t3-bold uppercase text-amber-700 dark:text-amber-300">
46 Partial diff77 Partial diff
47 </Text>78 </Text>
48 <Text className="text-xs leading-[18px] text-amber-800 dark:text-amber-200">79 <Text className="text-xs leading-normal text-amber-800 dark:text-amber-200">
49 {props.notice}80 {props.notice}
50 </Text>81 </Text>
51 </View>82 </View>
@@ -110,36 +141,244 @@ function ReviewSelectionActionBar(props: { · 58 unmodified lines
110 );141 );
111}142}
112 143
113export function ReviewSheet() {144interface ReviewNavigatorFile {
145 readonly id: string;
146 readonly path: string;
147 readonly additions: number;
148 readonly deletions: number;
149}
150
151const ReviewFileNavigatorRow = memo(function ReviewFileNavigatorRow(props: {
152 readonly file: ReviewNavigatorFile;
153 readonly selected: boolean;
154 readonly onSelectFile: (fileId: string | null) => void;
155}) {
156 const { file, selected, onSelectFile } = props;
157 // Tapping the selected file again returns to the all-files diff.
158 const handlePress = useCallback(() => {
159 onSelectFile(selected ? null : file.id);
160 }, [file.id, onSelectFile, selected]);
161
162 return (
163 <Pressable
164 accessibilityRole="button"
165 accessibilityState={{ selected }}
166 className={
167 selected
168 ? "mt-1 min-h-12 justify-center rounded-xl bg-subtle-strong px-3 py-2"
169 : "mt-1 min-h-12 justify-center rounded-xl px-3 py-2 active:bg-subtle"
170 }
171 onPress={handlePress}
172 >
173 <Text
174 className={
175 selected
176 ? "text-xs font-t3-bold text-foreground"
177 : "text-xs font-t3-medium text-foreground-secondary"
178 }
179 numberOfLines={2}
180 >
181 {file.path}
182 </Text>
183 <View className="mt-1 flex-row gap-2">
184 <Text className="text-2xs font-t3-bold text-emerald-600">+{file.additions}</Text>
185 <Text className="text-2xs font-t3-bold text-rose-600">-{file.deletions}</Text>
186 </View>
187 </Pressable>
188 );
189});
190
191interface ReviewFileNavigatorHandle {
192 readonly setVisibleFile: (fileId: string | null) => void;
193}
194
195interface ReviewFileNavigatorProps {
196 readonly files: ReadonlyArray<ReviewNavigatorFile>;
197 readonly headerInset: number;
198 readonly sectionId: string | null;
199 readonly onSelectFile: (fileId: string | null) => void;
200 readonly ref?: Ref<ReviewFileNavigatorHandle>;
201}
202
203function ReviewFileNavigator({
204 files,
205 headerInset,
206 sectionId,
207 onSelectFile,
208 ref,
209}: ReviewFileNavigatorProps) {
210 const insets = useSafeAreaInsets();
211 const sheetColor = String(useThemeColor("--color-sheet"));
212 const foregroundColor = String(useThemeColor("--color-foreground"));
213 const headerScrollEdgeEffects = nativeHeaderScrollEdgeEffects(Platform.OS, Platform.Version);
214 const [fileSelection, setFileSelection] = useState<{
215 readonly sectionId: string | null;
216 readonly fileId: string | null;
217 }>({ sectionId: null, fileId: null });
218 const availableFileIds = useMemo(() => files.map((file) => file.id), [files]);
219 const selectedFileId = resolveSelectedReviewFileId({
220 selection: fileSelection,
221 sectionId,
222 availableFileIds,
223 });
224
225 useImperativeHandle(
226 ref,
227 () => ({
228 setVisibleFile: (fileId) => {
229 if (fileId !== null && !availableFileIds.includes(fileId)) {
230 return;
231 }
232 setFileSelection((current) => {
233 if (current.sectionId === sectionId && current.fileId === fileId) {
234 return current;
235 }
236 return { sectionId, fileId };
237 });
238 },
239 }),
240 [availableFileIds, sectionId],
241 );
242
243 const handleSelectFile = useCallback(
244 (fileId: string | null) => {
245 setFileSelection({ sectionId, fileId });
246 onSelectFile(fileId);
247 },
248 [onSelectFile, sectionId],
249 );
250
251 const renderFile = useCallback(
252 ({ item }: { readonly item: ReviewNavigatorFile }) => (
253 <ReviewFileNavigatorRow
254 file={item}
255 selected={selectedFileId === item.id}
256 onSelectFile={handleSelectFile}
257 />
258 ),
259 [handleSelectFile, selectedFileId],
260 );
261
262 const fileList = (
263 <FlatList
264 data={files}
265 extraData={selectedFileId}
266 keyExtractor={(file) => file.id}
267 contentContainerStyle={{
268 paddingHorizontal: 8,
269 paddingBottom: 8,
270 // The nested native header is translucent; start the list below it so
271 // the scroll-edge effect can sample the content (same treatment as
272 // FileTreeBrowser in the Files pane).
273 paddingTop: Platform.OS === "ios" ? insets.top + 44 + 8 : 8,
274 }}
275 scrollIndicatorInsets={Platform.OS === "ios" ? { top: insets.top + 44 } : undefined}
276 renderItem={renderFile}
277 />
278 );
279
280 if (Platform.OS === "ios") {
281 return (
282 <View className="flex-1 border-l border-border bg-sheet">
283 <ScreenStack style={{ flex: 1 }}>
284 <Screen
285 activityState={2}
286 enabled
287 isNativeStack
288 screenId="review-file-navigator-native"
289 scrollEdgeEffects={headerScrollEdgeEffects}
290 style={{ backgroundColor: sheetColor, flex: 1 }}
291 >
292 {fileList}
293 <ScreenStackHeaderConfig
294 backgroundColor="rgba(0,0,0,0)"
295 color={foregroundColor}
296 hideBackButton
297 hideShadow={false}
298 navigationItemStyle="editor"
299 subtitle={`${files.length} ${files.length === 1 ? "file" : "files"}`}
300 title="Changed files"
301 titleColor={foregroundColor}
302 titleFontSize={17}
303 titleFontWeight="700"
304 translucent
305 />
306 </Screen>
307 </ScreenStack>
308 </View>
309 );
310 }
311
312 return (
313 <View className="flex-1 border-l border-border bg-sheet">
314 <View className="border-b border-border" style={{ paddingTop: headerInset }}>
315 <View className="px-4 py-3">
316 <Text className="text-sm font-t3-bold text-foreground">Changed files</Text>
317 <Text className="text-xs text-foreground-muted">
318 {files.length} {files.length === 1 ? "file" : "files"}
319 </Text>
320 </View>
321 </View>
322 {fileList}
323 </View>
324 );
325}
326
327type ReviewSheetProps = StaticScreenProps<{
328 readonly environmentId: EnvironmentId;
329 readonly threadId: ThreadId;
330}>;
331
332export function ReviewSheet(props: ReviewSheetProps) {
333 const { nativeReviewDiffStyle } = useAppearanceCodeSurface();
334 useAdaptiveWorkspacePaneRole("inspector");
335 const { panes, showAuxiliaryPane, toggleAuxiliaryPane } = useAdaptiveWorkspaceLayout();
336 const navigation = useNavigation();
114 const insets = useSafeAreaInsets();337 const insets = useSafeAreaInsets();
115 const colorScheme = useColorScheme();338 const colorScheme = useColorScheme();
116 const headerForeground = String(useThemeColor("--color-foreground"));
117 const headerMuted = String(useThemeColor("--color-foreground-muted"));
118 const headerIcon = String(useThemeColor("--color-icon"));339 const headerIcon = String(useThemeColor("--color-icon"));
119 const { environmentId, threadId } = useLocalSearchParams<{340 const { environmentId, threadId } = props.route.params;
120 environmentId: EnvironmentId;
121 threadId: ThreadId;
122 }>();
123 const environment = useEnvironmentPresentation(environmentId);341 const environment = useEnvironmentPresentation(environmentId);
124 const retryEnvironment = useAtomCommand(environmentCatalog.retryNow, "environment retry");342 const retryEnvironment = useAtomCommand(environmentCatalog.retryNow, "environment retry");
125 const isEnvironmentReady = environment.presentation?.connection.phase === "connected";343 const isEnvironmentReady = environment.presentation?.connection.phase === "connected";
126 const { draftMessage } = useThreadDraftForThread({ environmentId, threadId });344 const { draftMessage } = useThreadDraftForThread({ environmentId, threadId });
127 const reviewCache = useReviewCacheForThread({ environmentId, threadId });345 const reviewCache = useReviewCacheForThread({ environmentId, threadId });
346 /* ─── Git actions for the toolbar menu (commit/push without leaving review) ── */
347 const { selectedThread } = useThreadSelection();
348 const { selectedThreadCwd } = useSelectedThreadWorktree();
349 const gitState = useSelectedThreadGitState();
350 const gitActions = useSelectedThreadGitActions();
351 const gitStatusQuery = useEnvironmentQuery(
352 selectedThread !== null && selectedThreadCwd !== null
353 ? vcsEnvironment.status({
354 environmentId: selectedThread.environmentId,
355 input: { cwd: selectedThreadCwd },
356 })
357 : null,
358 );
359 // The selection-based git hooks only apply when this review belongs to the
360 // selected thread (it always does when reached from the thread's toolbar).
361 const gitMenuAvailable =
362 selectedThread !== null && String(selectedThread.id) === String(threadId);
128 const selectedTheme = colorScheme === "dark" ? "dark" : "light";363 const selectedTheme = colorScheme === "dark" ? "dark" : "light";
129 const topContentInset = insets.top + IOS_NAV_BAR_HEIGHT;364 // With a solid (non-overlay) header the content lays out below the header
130 const {365 // natively, so no manual top inset is needed.
131 error,366 const topContentInset = 0;
132 loadingGitDiffs,367
133 loadingTurnIds,368 useEffect(() => {
134 reviewSections,369 showAuxiliaryPane("inspector");
135 selectedSection,370 }, [environmentId, showAuxiliaryPane, threadId]);
136 refreshSelectedSection,371 const { error, reviewSections, selectedSection, refreshSelectedSection, selectSection } =
137 selectSection,372 useReviewSections({
138 } = useReviewSections({373 enabled: isEnvironmentReady,
139 enabled: isEnvironmentReady,374 environmentId,
140 environmentId,375 threadId,
141 threadId,376 reviewCache,
142 reviewCache,377 });
378 useReviewDiffPrewarming({
379 threadKey: reviewCache.threadKey,
380 sections: reviewSections,
381 selectedSectionId: selectedSection?.id ?? null,
143 });382 });
144 const { headerDiffSummary, nativeReviewDiffData, parsedDiff, pendingReviewCommentCount } =383 const { headerDiffSummary, nativeReviewDiffData, parsedDiff, pendingReviewCommentCount } =
145 useReviewDiffData({384 useReviewDiffData({
@@ -148,6 +387,18 @@ export function ReviewSheet() { · 2 unmodified lines
148 draftMessage,387 draftMessage,
149 });388 });
150 const NativeReviewDiffView = resolveNativeReviewDiffView()!;389 const NativeReviewDiffView = resolveNativeReviewDiffView()!;
390 const nativeReviewDiffViewRef = useRef<NativeReviewDiffViewHandle>(null);
391 // Native pull-to-refresh on the diff surface (replaces the old Refresh menu item).
392 const [isPullRefreshing, setIsPullRefreshing] = useState(false);
393 const handlePullToRefresh = useCallback(async () => {
394 setIsPullRefreshing(true);
395 try {
396 await refreshSelectedSection();
397 } finally {
398 setIsPullRefreshing(false);
399 }
400 }, [refreshSelectedSection]);
401 const reviewFileNavigatorRef = useRef<ReviewFileNavigatorHandle>(null);
151 const reviewFiles = parsedDiff.kind === "files" ? parsedDiff.files : [];402 const reviewFiles = parsedDiff.kind === "files" ? parsedDiff.files : [];
152 const fileVisibility = useReviewFileVisibility({403 const fileVisibility = useReviewFileVisibility({
153 threadKey: reviewCache.threadKey,404 threadKey: reviewCache.threadKey,
@@ -179,6 +430,43 @@ export function ReviewSheet() { · 25 unmodified lines
179 canHighlight: parsedDiff.kind === "files",430 canHighlight: parsedDiff.kind === "files",
180 });431 });
181 432
433 const handleSelectFile = useCallback(
434 (fileId: string | null) => {
435 commentSelection.clearSelection();
436 if (fileId !== null && collapsedFileIds.includes(fileId)) {
437 toggleExpandedFile(fileId);
438 }
439 const navigation =
440 fileId === null
441 ? nativeReviewDiffViewRef.current?.scrollToTop(true)
442 : nativeReviewDiffViewRef.current?.scrollToFile(fileId, true);
443 void navigation?.catch((error: unknown) => {
444 console.error("[review] Failed to navigate to diff file", error);
445 });
446 },
447 [collapsedFileIds, commentSelection, toggleExpandedFile],
448 );
449 const handleVisibleFileChange = useCallback(
450 (event: NativeSyntheticEvent<{ readonly fileId?: string | null }>) => {
451 reviewFileNavigatorRef.current?.setVisibleFile(event.nativeEvent.fileId ?? null);
452 },
453 [],
454 );
455 const renderInspector = useCallback(
456 () => (
457 <ReviewFileNavigator
458 ref={reviewFileNavigatorRef}
459 files={nativeReviewDiffData.files}
460 // The workspace inspector column spans the full window height, so the
461 // pane clears the status bar itself.
462 headerInset={insets.top}
463 sectionId={selectedSection?.id ?? null}
464 onSelectFile={handleSelectFile}
465 />
466 ),
467 [handleSelectFile, insets.top, nativeReviewDiffData.files, selectedSection?.id],
468 );
469
182 const handleNativeToggleFile = useCallback(470 const handleNativeToggleFile = useCallback(
183 (event: NativeSyntheticEvent<{ readonly fileId?: string }>) => {471 (event: NativeSyntheticEvent<{ readonly fileId?: string }>) => {
184 const { fileId } = event.nativeEvent;472 const { fileId } = event.nativeEvent;
@@ -203,6 +491,7 @@ export function ReviewSheet() { · 18 unmodified lines
203 parsedDiff.kind === "files" || parsedDiff.kind === "raw" ? parsedDiff.notice : null;491 parsedDiff.kind === "files" || parsedDiff.kind === "raw" ? parsedDiff.notice : null;
204 const hasCachedSelectedDiff = selectedSection?.diff != null;492 const hasCachedSelectedDiff = selectedSection?.diff != null;
205 const hasAnyCachedDiff = reviewSections.some((section) => section.diff != null);493 const hasAnyCachedDiff = reviewSections.some((section) => section.diff != null);
494 const sectionMenu = useMemo(() => buildReviewSectionMenu(reviewSections), [reviewSections]);
206 const { showConnectionNotice, showSectionToolbar } = resolveReviewAvailability({495 const { showConnectionNotice, showSectionToolbar } = resolveReviewAvailability({
207 hasEnvironmentPresentation: environment.isReady,496 hasEnvironmentPresentation: environment.isReady,
208 isEnvironmentConnected: isEnvironmentReady,497 isEnvironmentConnected: isEnvironmentReady,
@@ -212,6 +501,22 @@ export function ReviewSheet() { · 3 unmodified lines
212 const handleRetryEnvironment = useCallback(() => {501 const handleRetryEnvironment = useCallback(() => {
213 void retryEnvironment(environmentId);502 void retryEnvironment(environmentId);
214 }, [environmentId, retryEnvironment]);503 }, [environmentId, retryEnvironment]);
504 const handleReturnToThread = useCallback(() => {
505 if (navigation.canGoBack()) {
506 navigation.goBack();
507 return;
508 }
509 navigation.navigate("Thread", {
510 environmentId: String(environmentId),
511 threadId: String(threadId),
512 });
513 }, [environmentId, navigation, threadId]);
514
515 // The changed-files navigator lives in the workspace inspector column —
516 // the single right-hand pane per route — instead of an in-screen panel.
517 const showChangedFilesPane =
518 !showConnectionNotice && selectedSection !== null && parsedDiff.kind === "files";
519 useRegisterWorkspaceInspector(showChangedFilesPane ? renderInspector : undefined);
215 520
216 const listHeader = useMemo(() => {521 const listHeader = useMemo(() => {
217 const children: ReactElement[] = [];522 const children: ReactElement[] = [];
@@ -220,7 +525,7 @@ export function ReviewSheet() { · 2 unmodified lines
220 children.push(525 children.push(
221 <View key="review-error" className="border-b border-border bg-card px-4 py-3">526 <View key="review-error" className="border-b border-border bg-card px-4 py-3">
222 <Text className="text-sm font-t3-bold text-foreground">Review unavailable</Text>527 <Text className="text-sm font-t3-bold text-foreground">Review unavailable</Text>
223 <Text className="text-xs leading-[18px] text-foreground-muted">{error}</Text>528 <Text className="text-xs leading-normal text-foreground-muted">{error}</Text>
224 </View>,529 </View>,
225 );530 );
226 }531 }
@@ -235,134 +540,117 @@ export function ReviewSheet() { · 8 unmodified lines
235 540
236 return <>{children}</>;541 return <>{children}</>;
237 }, [error, parsedDiffNotice]);542 }, [error, parsedDiffNotice]);
543 const headerSubtitle = [
544 headerDiffSummary.additions,
545 headerDiffSummary.deletions,
546 pendingReviewCommentCount > 0
547 ? `${pendingReviewCommentCount} comment${pendingReviewCommentCount === 1 ? "" : "s"}`
548 : null,
549 ]
550 .filter(Boolean)
551 .join(" · ");
552 const headerTitleText = selectedSection?.title ?? "Review changes";
238 553
239 return (554 return (
240 <>555 <>
241 <Stack.Screen556 <NativeStackScreenOptions
242 options={{557 options={{
243 headerTransparent: true,558 // Static header config lives in Stack.tsx (SOLID_HEADER_OPTIONS — the native
244 headerShadowVisible: false,559 // diff scrolls internally, nothing for glass to sample). Only dynamic values
560 // here.
245 headerTintColor: headerIcon,561 headerTintColor: headerIcon,
246 headerStyle: {562 headerTitle: headerTitleText,
247 backgroundColor: "transparent",563 title: headerTitleText,
248 },564 unstable_headerSubtitle:
249 headerTitle: () => (565 Platform.OS === "ios" && headerSubtitle.length > 0 ? headerSubtitle : undefined,
250 <View style={{ alignItems: "center" }}>
251 <NativeText
252 numberOfLines={1}
253 style={{
254 fontFamily: "DMSans_700Bold",
255 fontSize: MOBILE_TYPOGRAPHY.headline.fontSize,
256 fontWeight: "900",
257 color: headerForeground,
258 letterSpacing: -0.4,
259 }}
260 >
261 Files Changed
262 </NativeText>
263 <View
264 style={{
265 flexDirection: "row",
266 alignItems: "center",
267 justifyContent: "center",
268 gap: 6,
269 flexWrap: "wrap",
270 }}
271 >
272 {headerDiffSummary.additions && headerDiffSummary.deletions ? (
273 <>
274 <NativeText
275 style={{
276 fontFamily: "DMSans_700Bold",
277 fontSize: MOBILE_TYPOGRAPHY.label.fontSize,
278 fontWeight: "700",
279 color: "#16a34a",
280 }}
281 >
282 {headerDiffSummary.additions}
283 </NativeText>
284 <NativeText
285 style={{
286 fontFamily: "DMSans_700Bold",
287 fontSize: MOBILE_TYPOGRAPHY.label.fontSize,
288 fontWeight: "700",
289 color: "#e11d48",
290 }}
291 >
292 {headerDiffSummary.deletions}
293 </NativeText>
294 {pendingReviewCommentCount > 0 ? (
295 <NativeText
296 style={{
297 fontFamily: "DMSans_700Bold",
298 fontSize: MOBILE_TYPOGRAPHY.label.fontSize,
299 fontWeight: "700",
300 color: "#b45309",
301 }}
302 >
303 {pendingReviewCommentCount} pending
304 </NativeText>
305 ) : null}
306 </>
307 ) : (
308 <View style={{ flexDirection: "row", alignItems: "center", gap: 6 }}>
309 <NativeText
310 numberOfLines={1}
311 style={{
312 fontFamily: "DMSans_700Bold",
313 fontSize: MOBILE_TYPOGRAPHY.label.fontSize,
314 fontWeight: "700",
315 color: headerMuted,
316 }}
317 >
318 {selectedSection?.title ?? "Review changes"}
319 </NativeText>
320 {pendingReviewCommentCount > 0 ? (
321 <NativeText
322 style={{
323 fontFamily: "DMSans_700Bold",
324 fontSize: MOBILE_TYPOGRAPHY.label.fontSize,
325 fontWeight: "700",
326 color: "#b45309",
327 }}
328 >
329 {pendingReviewCommentCount} pending
330 </NativeText>
331 ) : null}
332 </View>
333 )}
334 </View>
335 </View>
336 ),
337 }}566 }}
338 />567 />
339 568
340 {showSectionToolbar ? (569 <WorkspaceSidebarToolbar>
341 <Stack.Toolbar placement="right">570 <NativeHeaderToolbar.Button
342 <Stack.Toolbar.Menu icon="ellipsis.circle" title="Select diff" separateBackground>571 accessibilityLabel="Back to chat"
343 {reviewSections.map((section) => (572 icon="chevron.left"
344 <Stack.Toolbar.MenuAction573 onPress={handleReturnToThread}
345 key={section.id}574 />
346 icon={section.id === selectedSection?.id ? "checkmark" : "circle"}575 </WorkspaceSidebarToolbar>
347 onPress={() => selectSection(section.id)}576
348 subtitle={section.subtitle ?? undefined}577 {showSectionToolbar || panes.supportsAuxiliaryPane || gitMenuAvailable ? (
349 >578 <NativeHeaderToolbar placement="right">
350 <Stack.Toolbar.Label>{section.title}</Stack.Toolbar.Label>579 {panes.supportsAuxiliaryPane ? (
351 </Stack.Toolbar.MenuAction>580 <NativeHeaderToolbar.Button
352 ))}581 accessibilityLabel={
353 <Stack.Toolbar.MenuAction582 panes.auxiliaryPaneVisible ? "Hide changed files" : "Show changed files"
354 icon="arrow.clockwise"
355 disabled={
356 loadingGitDiffs ||
357 (selectedSection?.kind === "turn" && loadingTurnIds[selectedSection.id] === true)
358 }583 }
359 onPress={() => void refreshSelectedSection()}584 icon="sidebar.right"
360 subtitle="Reload current diff"585 onPress={toggleAuxiliaryPane}
361 >586 separateBackground
362 <Stack.Toolbar.Label>Refresh</Stack.Toolbar.Label>587 />
363 </Stack.Toolbar.MenuAction>588 ) : null}
364 </Stack.Toolbar.Menu>589 {gitMenuAvailable && selectedThread !== null ? (
365 </Stack.Toolbar>590 <ThreadGitMenu
591 environmentId={environmentId}
592 threadId={threadId}
593 currentBranch={selectedThread.branch ?? null}
594 gitStatus={gitStatusQuery.data}
595 gitOperationLabel={gitState.gitOperationLabel}
596 onPull={gitActions.onPullSelectedThreadBranch}
597 onRunAction={gitActions.onRunSelectedThreadGitAction}
598 />
599 ) : null}
600 {showSectionToolbar ? (
601 <NativeHeaderToolbar.Menu icon="ellipsis" title="Select diff" separateBackground>
602 <NativeHeaderToolbar.Menu inline>
603 <NativeHeaderToolbar.MenuAction
604 disabled={sectionMenu.workingTree === null}
605 isOn={selectedSection?.id === sectionMenu.workingTree?.id}
606 onPress={() => {
607 if (sectionMenu.workingTree) {
608 selectSection(sectionMenu.workingTree.id);
609 }
610 }}
611 >
612 <NativeHeaderToolbar.Label>Working tree</NativeHeaderToolbar.Label>
613 </NativeHeaderToolbar.MenuAction>
614 <NativeHeaderToolbar.MenuAction
615 disabled={sectionMenu.branchChanges === null}
616 isOn={selectedSection?.id === sectionMenu.branchChanges?.id}
617 onPress={() => {
618 if (sectionMenu.branchChanges) {
619 selectSection(sectionMenu.branchChanges.id);
620 }
621 }}
622 >
623 <NativeHeaderToolbar.Label>Branch changes</NativeHeaderToolbar.Label>
624 </NativeHeaderToolbar.MenuAction>
625 <NativeHeaderToolbar.MenuAction
626 disabled={sectionMenu.latestTurn === null}
627 isOn={selectedSection?.id === sectionMenu.latestTurn?.id}
628 onPress={() => {
629 if (sectionMenu.latestTurn) {
630 selectSection(sectionMenu.latestTurn.id);
631 }
632 }}
633 >
634 <NativeHeaderToolbar.Label>Latest turn</NativeHeaderToolbar.Label>
635 </NativeHeaderToolbar.MenuAction>
636 {sectionMenu.turns.length > 0 ? (
637 <NativeHeaderToolbar.Menu title="Turn">
638 {sectionMenu.turns.map((section) => (
639 <NativeHeaderToolbar.MenuAction
640 key={section.id}
641 isOn={section.id === selectedSection?.id}
642 onPress={() => selectSection(section.id)}
643 subtitle={section.subtitle ?? undefined}
644 >
645 <NativeHeaderToolbar.Label>{section.title}</NativeHeaderToolbar.Label>
646 </NativeHeaderToolbar.MenuAction>
647 ))}
648 </NativeHeaderToolbar.Menu>
649 ) : null}
650 </NativeHeaderToolbar.Menu>
651 </NativeHeaderToolbar.Menu>
652 ) : null}
653 </NativeHeaderToolbar>
366 ) : null}654 ) : null}
367 655
368 <View className="flex-1 bg-sheet">656 <View className="flex-1 bg-sheet">
@@ -386,34 +674,42 @@ export function ReviewSheet() { · 17 unmodified lines
386 className="flex-1"674 className="flex-1"
387 style={{675 style={{
388 backgroundColor: nativeBridge.theme.background,676 backgroundColor: nativeBridge.theme.background,
389 paddingTop: topContentInset + REVIEW_HEADER_SPACING,
390 }}677 }}
391 >678 >
392 {listHeader}679 <View
393 <View className="flex-1" collapsable={false}>680 className="min-w-0 flex-1"
394 <NativeReviewDiffView681 style={{ paddingTop: topContentInset + REVIEW_HEADER_SPACING }}
395 key={`${reviewCache.threadKey}:${selectedSection.id}`}682 >
396 collapsable={false}683 {listHeader}
397 testID="review-native-diff-view"684 <View className="min-w-0 flex-1" collapsable={false}>
398 style={StyleSheet.absoluteFill}685 <NativeReviewDiffView
399 appearanceScheme={selectedTheme}686 collapsable={false}
400 collapsedFileIdsJson={nativeBridge.collapsedFileIdsJson}687 testID="review-native-diff-view"
401 collapsedCommentIdsJson={nativeBridge.collapsedCommentIdsJson}688 refreshing={isPullRefreshing}
402 contentWidth={NATIVE_REVIEW_DIFF_CONTENT_WIDTH}689 onPullToRefresh={() => void handlePullToRefresh()}
403 rowHeight={NATIVE_REVIEW_DIFF_ROW_HEIGHT}690 style={StyleSheet.absoluteFill}
404 rowsJson={nativeBridge.rowsJson}691 appearanceScheme={selectedTheme}
405 selectedRowIdsJson={nativeBridge.selectedRowIdsJson}692 collapsedFileIdsJson={nativeBridge.collapsedFileIdsJson}
406 styleJson={nativeBridge.styleJson}693 collapsedCommentIdsJson={nativeBridge.collapsedCommentIdsJson}
407 themeJson={nativeBridge.themeJson}694 contentResetKey={`${reviewCache.threadKey}:${selectedSection.id}`}
408 tokensPatchJson={nativeBridge.tokensPatchJson}695 contentWidth={NATIVE_REVIEW_DIFF_CONTENT_WIDTH}
409 tokensResetKey={nativeBridge.tokensResetKey}696 nativeViewRef={nativeReviewDiffViewRef}
410 viewedFileIdsJson={nativeBridge.viewedFileIdsJson}697 rowHeight={nativeReviewDiffStyle.rowHeight}
411 onDebug={nativeBridge.onDebug}698 rowsJson={nativeBridge.rowsJson}
412 onPressLine={commentSelection.onPressLine}699 selectedRowIdsJson={nativeBridge.selectedRowIdsJson}
413 onToggleComment={nativeBridge.onToggleComment}700 styleJson={nativeBridge.styleJson}
414 onToggleFile={handleNativeToggleFile}701 themeJson={nativeBridge.themeJson}
415 onToggleViewedFile={handleNativeToggleViewedFile}702 tokensPatchJson={nativeBridge.tokensPatchJson}
416 />703 tokensResetKey={nativeBridge.tokensResetKey}
704 viewedFileIdsJson={nativeBridge.viewedFileIdsJson}
705 onDebug={nativeBridge.onDebug}
706 onPressLine={commentSelection.onPressLine}
707 onVisibleFileChange={handleVisibleFileChange}
708 onToggleComment={nativeBridge.onToggleComment}
709 onToggleFile={handleNativeToggleFile}
710 onToggleViewedFile={handleNativeToggleViewedFile}
711 />
712 </View>
417 </View>713 </View>
418 </View>714 </View>
419 ) : (715 ) : (
@@ -432,7 +728,7 @@ export function ReviewSheet() { · 12 unmodified lines
432 {!selectedSection ? (728 {!selectedSection ? (
433 <View className="border-b border-border bg-card px-4 py-5">729 <View className="border-b border-border bg-card px-4 py-5">
434 <Text className="text-sm font-t3-bold text-foreground">No review diffs</Text>730 <Text className="text-sm font-t3-bold text-foreground">No review diffs</Text>
435 <Text className="text-xs leading-[18px] text-foreground-muted">731 <Text className="text-xs leading-normal text-foreground-muted">
436 This thread has no ready turn diffs and the worktree diff is empty.732 This thread has no ready turn diffs and the worktree diff is empty.
437 </Text>733 </Text>
438 </View>734 </View>
@@ -444,17 +740,17 @@ export function ReviewSheet() { · 5 unmodified lines
444 ) : parsedDiff.kind === "empty" ? (740 ) : parsedDiff.kind === "empty" ? (
445 <View className="border-b border-border bg-card px-4 py-5">741 <View className="border-b border-border bg-card px-4 py-5">
446 <Text className="text-sm font-t3-bold text-foreground">No changes</Text>742 <Text className="text-sm font-t3-bold text-foreground">No changes</Text>
447 <Text className="text-xs leading-[18px] text-foreground-muted">743 <Text className="text-xs leading-normal text-foreground-muted">
448 {selectedSection.subtitle ?? "This diff is empty."}744 {selectedSection.subtitle ?? "This diff is empty."}
449 </Text>745 </Text>
450 </View>746 </View>
451 ) : parsedDiff.kind === "raw" ? (747 ) : parsedDiff.kind === "raw" ? (
452 <View className="gap-3 border-b border-border bg-card px-4 py-4">748 <View className="gap-3 border-b border-border bg-card px-4 py-4">
453 <Text className="text-xs leading-[18px] text-foreground-muted">749 <Text className="text-xs leading-normal text-foreground-muted">
454 {parsedDiff.reason}750 {parsedDiff.reason}
455 </Text>751 </Text>
456 <ScrollView horizontal showsHorizontalScrollIndicator={false} bounces={false}>752 <ScrollView horizontal showsHorizontalScrollIndicator={false} bounces={false}>
457 <Text selectable className="font-mono text-xs leading-[19px] text-foreground">753 <Text selectable className="font-mono text-xs leading-relaxed text-foreground">
458 {parsedDiff.text}754 {parsedDiff.text}
459 </Text>755 </Text>
460 </ScrollView>756 </ScrollView>
apps/mobile/src/features/review/nativeReviewDiffAdapter.test.ts+56−0
@@ -0,0 +1,56 @@
1import { describe, expect, it } from "vite-plus/test";
2
3import {
4 getCachedNativeReviewDiffData,
5 type BuildNativeReviewDiffDataInput,
6} from "./nativeReviewDiffAdapter";
7import type { ReviewInlineComment } from "./reviewCommentSelection";
8import { buildReviewParsedDiff } from "./reviewModel";
9
10const parsedDiff = buildReviewParsedDiff(
11 [
12 "diff --git a/example.ts b/example.ts",
13 "--- a/example.ts",
14 "+++ b/example.ts",
15 "@@ -1 +1 @@",
16 "-const before = 1;",
17 "+const after = 2;",
18 ].join("\n"),
19 "native-review-cache-test",
20);
21
22function makeComment(text: string): ReviewInlineComment {
23 return {
24 id: "comment-1",
25 sectionId: "git:working-tree",
26 sectionTitle: "Dirty worktree",
27 filePath: "example.ts",
28 startIndex: 0,
29 endIndex: 0,
30 rangeLabel: "-1",
31 text,
32 diff: "@@ -1,1 +1,0 @@\n-const before = 1;",
33 };
34}
35
36function buildInput(comments: BuildNativeReviewDiffDataInput["comments"]) {
37 return { parsedDiff, comments } satisfies BuildNativeReviewDiffDataInput;
38}
39
40describe("getCachedNativeReviewDiffData", () => {
41 it("reuses the row model for equivalent empty comment arrays", () => {
42 const first = getCachedNativeReviewDiffData(buildInput([]));
43 const second = getCachedNativeReviewDiffData(buildInput([]));
44
45 expect(second).toBe(first);
46 });
47
48 it("reuses equivalent comment contents and invalidates changed comments", () => {
49 const first = getCachedNativeReviewDiffData(buildInput([makeComment("First")]));
50 const equivalent = getCachedNativeReviewDiffData(buildInput([makeComment("First")]));
51 const changed = getCachedNativeReviewDiffData(buildInput([makeComment("Changed")]));
52
53 expect(equivalent).toBe(first);
54 expect(changed).not.toBe(first);
55 });
56});
apps/mobile/src/features/review/nativeReviewDiffAdapter.ts+98−36
@@ -5,6 +5,8 @@ import type { · 4 unmodified lines
5} from "../diffs/nativeReviewDiffTypes";5} from "../diffs/nativeReviewDiffTypes";
6import * as Arr from "effect/Array";6import * as Arr from "effect/Array";
7import { pipe } from "effect/Function";7import { pipe } from "effect/Function";
8import type { ResolvedMobileCodeSurface } from "../../lib/appearancePreferences";
9import { resolveMobileCodeSurface } from "../../lib/appearancePreferences";
8import { MOBILE_CODE_SURFACE } from "../../lib/typography";10import { MOBILE_CODE_SURFACE } from "../../lib/typography";
9import { getPierreTerminalTheme, type TerminalAppearanceScheme } from "../terminal/terminalTheme";11import { getPierreTerminalTheme, type TerminalAppearanceScheme } from "../terminal/terminalTheme";
10import { computeWordAltDiffRanges } from "./reviewWordDiffs";12import { computeWordAltDiffRanges } from "./reviewWordDiffs";
@@ -22,38 +24,44 @@ const NATIVE_REVIEW_MAX_WORD_DIFF_COVERAGE = 0.45; · 11 unmodified lines
22export const NATIVE_REVIEW_DIFF_ROW_HEIGHT = MOBILE_CODE_SURFACE.rowHeight;24export const NATIVE_REVIEW_DIFF_ROW_HEIGHT = MOBILE_CODE_SURFACE.rowHeight;
23export const NATIVE_REVIEW_DIFF_CONTENT_WIDTH = 2_800;25export const NATIVE_REVIEW_DIFF_CONTENT_WIDTH = 2_800;
24 26
25export const NATIVE_REVIEW_DIFF_STYLE = {27export const NATIVE_REVIEW_DIFF_STYLE = createNativeReviewDiffStyle(
26 rowHeight: NATIVE_REVIEW_DIFF_ROW_HEIGHT,28 resolveMobileCodeSurface(MOBILE_CODE_SURFACE.fontSize),
27 contentWidth: NATIVE_REVIEW_DIFF_CONTENT_WIDTH,29);
28 changeBarWidth: 4,30
29 gutterWidth: MOBILE_CODE_SURFACE.gutterWidth,31export function createNativeReviewDiffStyle(codeSurface: ResolvedMobileCodeSurface) {
30 codePadding: MOBILE_CODE_SURFACE.codePadding,32 return {
31 textVerticalInset: MOBILE_CODE_SURFACE.textVerticalInset,33 rowHeight: codeSurface.rowHeight,
32 fileHeaderHeight: 56,34 contentWidth: NATIVE_REVIEW_DIFF_CONTENT_WIDTH,
33 fileHeaderHorizontalMargin: 8,35 changeBarWidth: 4,
34 fileHeaderVerticalMargin: 6,36 gutterWidth: codeSurface.gutterWidth,
35 fileHeaderCornerRadius: 10,37 codePadding: codeSurface.codePadding,
36 fileHeaderHorizontalPadding: 10,38 textVerticalInset: codeSurface.textVerticalInset,
37 fileHeaderPathRightPadding: 118,39 fileHeaderHeight: 56,
38 fileHeaderCountColumnWidth: 38,40 fileHeaderHorizontalMargin: 8,
39 fileHeaderCountGap: 5,41 fileHeaderVerticalMargin: 6,
40 codeFontSize: MOBILE_CODE_SURFACE.fontSize,42 fileHeaderCornerRadius: 10,
41 codeFontWeight: "regular",43 fileHeaderHorizontalPadding: 10,
42 lineNumberFontSize: MOBILE_CODE_SURFACE.lineNumberFontSize,44 fileHeaderPathRightPadding: 118,
43 lineNumberFontWeight: "regular",45 fileHeaderCountColumnWidth: 38,
44 hunkFontSize: 11,46 fileHeaderCountGap: 5,
45 hunkFontWeight: "medium",47 codeFontSize: codeSurface.fontSize,
46 fileHeaderFontSize: 11,48 codeFontWeight: "regular",
47 fileHeaderFontWeight: "semibold",49 lineNumberFontSize: codeSurface.lineNumberFontSize,
48 fileHeaderMetaFontSize: 10,50 lineNumberFontWeight: "regular",
49 fileHeaderMetaFontWeight: "semibold",51 hunkFontSize: 11,
50 fileHeaderSubtextFontSize: 11,52 hunkFontWeight: "medium",
51 fileHeaderSubtextFontWeight: "medium",53 fileHeaderFontSize: 11,
52 fileHeaderStatusFontSize: 9,54 fileHeaderFontWeight: "semibold",
53 fileHeaderStatusFontWeight: "bold",55 fileHeaderMetaFontSize: 10,
54 emptyStateFontSize: 12,56 fileHeaderMetaFontWeight: "semibold",
55 emptyStateFontWeight: "medium",57 fileHeaderSubtextFontSize: 11,
56} as const;58 fileHeaderSubtextFontWeight: "medium",
59 fileHeaderStatusFontSize: 9,
60 fileHeaderStatusFontWeight: "bold",
61 emptyStateFontSize: 12,
62 emptyStateFontWeight: "medium",
63 } as const;
64}
57 65
58export interface NativeReviewDiffData {66export interface NativeReviewDiffData {
59 readonly rows: ReadonlyArray<NativeReviewDiffRow>;67 readonly rows: ReadonlyArray<NativeReviewDiffRow>;
@@ -75,6 +83,33 @@ export interface BuildNativeReviewDiffDataInput { · 15 unmodified lines
75 readonly comments?: ReadonlyArray<ReviewInlineComment>;83 readonly comments?: ReadonlyArray<ReviewInlineComment>;
76}84}
77 85
86interface CachedNativeReviewDiffData {
87 readonly commentsKey: string;
88 readonly data: NativeReviewDiffData;
89}
90
91const nativeReviewDiffDataCache = new WeakMap<ReviewParsedDiff, CachedNativeReviewDiffData>();
92
93function buildReviewCommentsCacheKey(comments: ReadonlyArray<ReviewInlineComment>): string {
94 if (comments.length === 0) {
95 return "none";
96 }
97
98 return comments
99 .map((comment) =>
100 [
101 comment.id,
102 comment.sectionId,
103 comment.filePath,
104 comment.startIndex,
105 comment.endIndex,
106 comment.rangeLabel,
107 comment.text,
108 ].join("\u001f"),
109 )
110 .join("\u001e");
111}
112
78export function createNativeReviewDiffTheme(113export function createNativeReviewDiffTheme(
79 scheme: TerminalAppearanceScheme,114 scheme: TerminalAppearanceScheme,
80): NativeReviewDiffTheme {115): NativeReviewDiffTheme {
@@ -83,10 +118,12 @@ export function createNativeReviewDiffTheme( · 2 unmodified lines
83 118
84 if (scheme === "dark") {119 if (scheme === "dark") {
85 return {120 return {
86 background: terminalTheme.background,121 // Match the app surface (--color-sheet) so code views blend with the rest of
122 // the app instead of using a distinct code-editor background.
123 background: "#0e0e0e",
87 text: terminalTheme.foreground,124 text: terminalTheme.foreground,
88 mutedText: terminalTheme.mutedForeground,125 mutedText: terminalTheme.mutedForeground,
89 headerBackground: terminalTheme.background,126 headerBackground: "#0e0e0e",
90 border: terminalTheme.border,127 border: terminalTheme.border,
91 hunkBackground: "#071f28",128 hunkBackground: "#071f28",
92 hunkText: terminalBlue ?? "#009fff",129 hunkText: terminalBlue ?? "#009fff",
@@ -100,10 +137,12 @@ export function createNativeReviewDiffTheme( · 7 unmodified lines
100 }137 }
101 138
102 return {139 return {
103 background: "#ffffff",140 // Match the app surface (--color-sheet) so code views blend with the rest of the
141 // app instead of using a distinct code-editor background.
142 background: "#f2f2f7",
104 text: "#070707",143 text: "#070707",
105 mutedText: terminalTheme.mutedForeground,144 mutedText: terminalTheme.mutedForeground,
106 headerBackground: "#ffffff",145 headerBackground: "#f2f2f7",
107 border: terminalTheme.border,146 border: terminalTheme.border,
108 hunkBackground: "#e0f2ff",147 hunkBackground: "#e0f2ff",
109 hunkText: terminalBlue ?? "#009fff",148 hunkText: terminalBlue ?? "#009fff",
@@ -430,3 +469,26 @@ export function buildNativeReviewDiffData( · 320 unmodified lines
430 deletions: parsedDiff.deletions,469 deletions: parsedDiff.deletions,
431 };470 };
432}471}
472
473/**
474 * Reuses the expensive flattened native row model across React development
475 * render probes and unrelated draft updates. Only the latest comment version
476 * is retained for each parsed diff so editing a comment cannot grow the cache.
477 */
478export function getCachedNativeReviewDiffData(
479 input: BuildNativeReviewDiffDataInput,
480): NativeReviewDiffData {
481 const comments = input.comments ?? [];
482 const commentsKey = buildReviewCommentsCacheKey(comments);
483 const cached = nativeReviewDiffDataCache.get(input.parsedDiff);
484 if (cached?.commentsKey === commentsKey) {
485 return cached.data;
486 }
487
488 const data = buildNativeReviewDiffData({
489 parsedDiff: input.parsedDiff,
490 comments,
491 });
492 nativeReviewDiffDataCache.set(input.parsedDiff, { commentsKey, data });
493 return data;
494}
apps/mobile/src/features/review/review-section-menu.test.ts+40−0
@@ -0,0 +1,40 @@
1import { describe, expect, it } from "vite-plus/test";
2
3import type { ReviewSectionItem, ReviewSectionKind } from "./reviewModel";
4import { buildReviewSectionMenu } from "./review-section-menu";
5
6function section(id: string, kind: ReviewSectionKind): ReviewSectionItem {
7 return {
8 id,
9 kind,
10 title: id,
11 subtitle: null,
12 diff: null,
13 isLoading: false,
14 };
15}
16
17describe("buildReviewSectionMenu", () => {
18 it("exposes git scopes and the latest turn at the top level", () => {
19 const turn28 = section("turn:28", "turn");
20 const turn27 = section("turn:27", "turn");
21 const workingTree = section("git:working-tree", "working-tree");
22 const branchChanges = section("git:branch-range", "branch-range");
23
24 expect(buildReviewSectionMenu([turn28, turn27, workingTree, branchChanges])).toEqual({
25 workingTree,
26 branchChanges,
27 latestTurn: turn28,
28 turns: [turn28, turn27],
29 });
30 });
31
32 it("keeps unavailable scopes empty while data loads", () => {
33 expect(buildReviewSectionMenu([])).toEqual({
34 workingTree: null,
35 branchChanges: null,
36 latestTurn: null,
37 turns: [],
38 });
39 });
40});
apps/mobile/src/features/review/review-section-menu.ts+21−0
@@ -0,0 +1,21 @@
1import type { ReviewSectionItem } from "./reviewModel";
2
3export interface ReviewSectionMenu {
4 readonly workingTree: ReviewSectionItem | null;
5 readonly branchChanges: ReviewSectionItem | null;
6 readonly latestTurn: ReviewSectionItem | null;
7 readonly turns: ReadonlyArray<ReviewSectionItem>;
8}
9
10export function buildReviewSectionMenu(
11 sections: ReadonlyArray<ReviewSectionItem>,
12): ReviewSectionMenu {
13 const turns = sections.filter((section) => section.kind === "turn");
14
15 return {
16 workingTree: sections.find((section) => section.kind === "working-tree") ?? null,
17 branchChanges: sections.find((section) => section.kind === "branch-range") ?? null,
18 latestTurn: turns[0] ?? null,
19 turns,
20 };
21}
apps/mobile/src/features/review/reviewDiffBridgeKeys.test.tsrenamed from apps/mobile/src/features/review/useNativeReviewDiffBridge.test.ts+1−1
@@ -1,6 +1,6 @@
1import { describe, expect, it } from "vite-plus/test";1import { describe, expect, it } from "vite-plus/test";
2 2
3import { buildNativeReviewTokensResetKey, hashReviewDiffKey } from "./useNativeReviewDiffBridge";3import { buildNativeReviewTokensResetKey, hashReviewDiffKey } from "./reviewDiffBridgeKeys";
4 4
5describe("native review diff bridge", () => {5describe("native review diff bridge", () => {
6 it("builds stable reset keys from the rendered diff identity", () => {6 it("builds stable reset keys from the rendered diff identity", () => {
apps/mobile/src/features/review/reviewDiffBridgeKeys.ts+35−0
@@ -0,0 +1,35 @@
1import type { NativeReviewDiffHighlightScheme } from "../diffs/nativeReviewDiffHighlighter";
2
3// Pure key-derivation helpers for the native review diff bridge. Kept free of
4// react-native / hook imports so they stay unit-testable in node.
5
6export function hashReviewDiffKey(diff: string | null | undefined): string {
7 if (!diff) {
8 return "empty";
9 }
10
11 let hash = 5381;
12 for (let index = 0; index < diff.length; index += 1) {
13 hash = (hash * 33) ^ diff.charCodeAt(index);
14 }
15
16 return `${diff.length}:${(hash >>> 0).toString(36)}`;
17}
18
19export function buildNativeReviewTokensResetKey(input: {
20 readonly threadKey: string | null;
21 readonly sectionId: string | null;
22 readonly scheme: NativeReviewDiffHighlightScheme;
23 readonly diff: string | null | undefined;
24 readonly fileCount: number;
25 readonly rowCount: number;
26}): string {
27 return [
28 input.threadKey ?? "none",
29 input.sectionId ?? "none",
30 input.scheme,
31 hashReviewDiffKey(input.diff),
32 input.fileCount,
33 input.rowCount,
34 ].join(":");
35}
apps/mobile/src/features/review/reviewDiffRendering.tsx+16−9
@@ -13,7 +13,6 @@ export const REVIEW_MONO_FONT_FAMILY = Platform.select({ · 12 unmodified lines
13});13});
14 14
15export const REVIEW_DIFF_LINE_HEIGHT = MOBILE_CODE_SURFACE.rowHeight;15export const REVIEW_DIFF_LINE_HEIGHT = MOBILE_CODE_SURFACE.rowHeight;
16const REVIEW_DELETE_STRIPE_COUNT = REVIEW_DIFF_LINE_HEIGHT / 2;
17 16
18export function renderVisibleWhitespace(value: string): string {17export function renderVisibleWhitespace(value: string): string {
19 const expandedTabs = value.replace(/\t/g, " ");18 const expandedTabs = value.replace(/\t/g, " ");
@@ -38,12 +37,16 @@ function diffHighlightColor(change: ReviewRenderableLineRow["change"]): string | · 18 unmodified lines
38 return undefined;37 return undefined;
39}38}
40 39
41export function ReviewChangeBar(props: { readonly change: ReviewRenderableLineRow["change"] }) {40export function ReviewChangeBar(props: {
41 readonly change: ReviewRenderableLineRow["change"];
42 readonly height?: number;
43}) {
44 const height = props.height ?? REVIEW_DIFF_LINE_HEIGHT;
42 if (props.change === "delete") {45 if (props.change === "delete") {
43 return (46 return (
44 <View className="w-[5px] overflow-hidden" style={{ height: REVIEW_DIFF_LINE_HEIGHT }}>47 <View className="w-[5px] overflow-hidden" style={{ height }}>
45 <View>48 <View>
46 {Array.from({ length: REVIEW_DELETE_STRIPE_COUNT }, (_, index) => (49 {Array.from({ length: Math.ceil(height / 2) }, (_, index) => (
47 <View key={index}>50 <View key={index}>
48 <View className="w-[5px] bg-rose-400" style={{ height: 1 }} />51 <View className="w-[5px] bg-rose-400" style={{ height: 1 }} />
49 <View style={{ height: 1 }} />52 <View style={{ height: 1 }} />
@@ -55,7 +58,7 @@ export function ReviewChangeBar(props: { readonly change: ReviewRenderableLineRo · 5 unmodified lines
55 }58 }
56 59
57 return (60 return (
58 <View className="w-[5px] overflow-hidden" style={{ height: REVIEW_DIFF_LINE_HEIGHT }}>61 <View className="w-[5px] overflow-hidden" style={{ height }}>
59 <View className={cn("h-full w-[5px] flex-1", changeBarTone(props.change))} />62 <View className={cn("h-full w-[5px] flex-1", changeBarTone(props.change))} />
60 </View>63 </View>
61 );64 );
@@ -66,7 +69,11 @@ export function DiffTokenText(props: { · 4 unmodified lines
66 readonly fallback: string;69 readonly fallback: string;
67 readonly change?: ReviewRenderableLineRow["change"];70 readonly change?: ReviewRenderableLineRow["change"];
68 readonly className?: string;71 readonly className?: string;
72 readonly fontSize?: number;
73 readonly lineHeight?: number;
69}) {74}) {
75 const fontSize = props.fontSize ?? MOBILE_CODE_SURFACE.fontSize;
76 const lineHeight = props.lineHeight ?? MOBILE_CODE_SURFACE.rowHeight;
70 if (!props.tokens || props.tokens.length === 0) {77 if (!props.tokens || props.tokens.length === 0) {
71 return (78 return (
72 <NativeText79 <NativeText
@@ -75,8 +82,8 @@ export function DiffTokenText(props: { · 2 unmodified lines
75 className={cn("font-normal text-foreground", props.className)}82 className={cn("font-normal text-foreground", props.className)}
76 style={{83 style={{
77 fontFamily: REVIEW_MONO_FONT_FAMILY,84 fontFamily: REVIEW_MONO_FONT_FAMILY,
78 fontSize: MOBILE_CODE_SURFACE.fontSize,85 fontSize,
79 lineHeight: MOBILE_CODE_SURFACE.rowHeight,86 lineHeight,
80 }}87 }}
81 >88 >
82 {renderVisibleWhitespace(props.fallback || " ")}89 {renderVisibleWhitespace(props.fallback || " ")}
@@ -91,8 +98,8 @@ export function DiffTokenText(props: { · 8 unmodified lines
91 className={cn("font-normal text-foreground", props.className)}98 className={cn("font-normal text-foreground", props.className)}
92 style={{99 style={{
93 fontFamily: REVIEW_MONO_FONT_FAMILY,100 fontFamily: REVIEW_MONO_FONT_FAMILY,
94 fontSize: MOBILE_CODE_SURFACE.fontSize,101 fontSize,
95 lineHeight: MOBILE_CODE_SURFACE.rowHeight,102 lineHeight,
96 }}103 }}
97 >104 >
98 {(() => {105 {(() => {
apps/mobile/src/features/review/reviewPaneSelection.test.ts+35−0
@@ -0,0 +1,35 @@
1import { describe, expect, it } from "vite-plus/test";
2
3import { resolveSelectedReviewFileId } from "./reviewPaneSelection";
4
5describe("resolveSelectedReviewFileId", () => {
6 it("keeps a visible file selected within the active section", () => {
7 expect(
8 resolveSelectedReviewFileId({
9 selection: { sectionId: "worktree", fileId: "second" },
10 sectionId: "worktree",
11 availableFileIds: ["first", "second"],
12 }),
13 ).toBe("second");
14 });
15
16 it("clears selection when the review section changes", () => {
17 expect(
18 resolveSelectedReviewFileId({
19 selection: { sectionId: "turn-1", fileId: "first" },
20 sectionId: "turn-2",
21 availableFileIds: ["first"],
22 }),
23 ).toBeNull();
24 });
25
26 it("clears a file that no longer exists in the diff", () => {
27 expect(
28 resolveSelectedReviewFileId({
29 selection: { sectionId: "worktree", fileId: "removed" },
30 sectionId: "worktree",
31 availableFileIds: ["first", "second"],
32 }),
33 ).toBeNull();
34 });
35});
apps/mobile/src/features/review/reviewPaneSelection.ts+16−0
@@ -0,0 +1,16 @@
1export interface ReviewPaneFileSelection {
2 readonly sectionId: string | null;
3 readonly fileId: string | null;
4}
5
6export function resolveSelectedReviewFileId(input: {
7 readonly selection: ReviewPaneFileSelection;
8 readonly sectionId: string | null;
9 readonly availableFileIds: ReadonlyArray<string>;
10}): string | null {
11 if (input.selection.sectionId !== input.sectionId || input.selection.fileId === null) {
12 return null;
13 }
14
15 return input.availableFileIds.includes(input.selection.fileId) ? input.selection.fileId : null;
16}
apps/mobile/src/features/review/useNativeReviewDiffBridge.ts+6−36
@@ -2,43 +2,12 @@ import { useCallback, useMemo, useState } from "react"; · 1 unmodified line
2import type { NativeSyntheticEvent } from "react-native";2import type { NativeSyntheticEvent } from "react-native";
3 3
4import { type NativeReviewDiffHighlightScheme } from "../diffs/nativeReviewDiffHighlighter";4import { type NativeReviewDiffHighlightScheme } from "../diffs/nativeReviewDiffHighlighter";
5import {5import { createNativeReviewDiffTheme, type NativeReviewDiffData } from "./nativeReviewDiffAdapter";
6 createNativeReviewDiffTheme,6import { useAppearanceCodeSurface } from "../settings/appearance/useAppearanceCodeSurface";
7 NATIVE_REVIEW_DIFF_STYLE,
8 type NativeReviewDiffData,
9} from "./nativeReviewDiffAdapter";
10import { useNativeReviewDiffHighlighting } from "./useNativeReviewDiffHighlighting";7import { useNativeReviewDiffHighlighting } from "./useNativeReviewDiffHighlighting";
8import { buildNativeReviewTokensResetKey } from "./reviewDiffBridgeKeys";
11 9
12export function hashReviewDiffKey(diff: string | null | undefined): string {10export { buildNativeReviewTokensResetKey, hashReviewDiffKey } from "./reviewDiffBridgeKeys";
13 if (!diff) {
14 return "empty";
15 }
16
17 let hash = 5381;
18 for (let index = 0; index < diff.length; index += 1) {
19 hash = (hash * 33) ^ diff.charCodeAt(index);
20 }
21
22 return `${diff.length}:${(hash >>> 0).toString(36)}`;
23}
24
25export function buildNativeReviewTokensResetKey(input: {
26 readonly threadKey: string | null;
27 readonly sectionId: string | null;
28 readonly scheme: NativeReviewDiffHighlightScheme;
29 readonly diff: string | null | undefined;
30 readonly fileCount: number;
31 readonly rowCount: number;
32}): string {
33 return [
34 input.threadKey ?? "none",
35 input.sectionId ?? "none",
36 input.scheme,
37 hashReviewDiffKey(input.diff),
38 input.fileCount,
39 input.rowCount,
40 ].join(":");
41}
42 11
43export function useNativeReviewDiffBridge(input: {12export function useNativeReviewDiffBridge(input: {
44 readonly threadKey: string | null;13 readonly threadKey: string | null;
@@ -62,6 +31,7 @@ export function useNativeReviewDiffBridge(input: { · 17 unmodified lines
62 threadKey,31 threadKey,
63 viewedFileIds,32 viewedFileIds,
64 } = input;33 } = input;
34 const { nativeReviewDiffStyle } = useAppearanceCodeSurface();
65 const [collapsedCommentIds, setCollapsedCommentIds] = useState<ReadonlySet<string>>(35 const [collapsedCommentIds, setCollapsedCommentIds] = useState<ReadonlySet<string>>(
66 () => new Set(),36 () => new Set(),
67 );37 );
@@ -76,7 +46,7 @@ export function useNativeReviewDiffBridge(input: { · 8 unmodified lines
76 [collapsedCommentIds],46 [collapsedCommentIds],
77 );47 );
78 const themeJson = useMemo(() => JSON.stringify(theme), [theme]);48 const themeJson = useMemo(() => JSON.stringify(theme), [theme]);
79 const styleJson = useMemo(() => JSON.stringify(NATIVE_REVIEW_DIFF_STYLE), []);49 const styleJson = useMemo(() => JSON.stringify(nativeReviewDiffStyle), [nativeReviewDiffStyle]);
80 const tokensResetKey = useMemo(50 const tokensResetKey = useMemo(
81 () =>51 () =>
82 buildNativeReviewTokensResetKey({52 buildNativeReviewTokensResetKey({
apps/mobile/src/features/review/useReviewCommentSelectionController.ts+6−7
@@ -1,12 +1,11 @@
1import { useCallback, useEffect, useMemo, useState } from "react";1import { useCallback, useEffect, useMemo, useState } from "react";
2import type { NativeSyntheticEvent } from "react-native";2import type { NativeSyntheticEvent } from "react-native";
3import { useRouter } from "expo-router";3import { useNavigation } from "@react-navigation/native";
4import * as Arr from "effect/Array";4import * as Arr from "effect/Array";
5import { pipe } from "effect/Function";5import { pipe } from "effect/Function";
6import * as Result from "effect/Result";6import * as Result from "effect/Result";
7 7
8import type { EnvironmentId, ThreadId } from "@t3tools/contracts";8import type { EnvironmentId, ThreadId } from "@t3tools/contracts";
9
10import {9import {
11 buildReviewCommentTarget,10 buildReviewCommentTarget,
12 clearReviewCommentTarget,11 clearReviewCommentTarget,
@@ -34,7 +33,7 @@ export function useReviewCommentSelectionController(input: { · 21 unmodified lines
34 readonly nativeReviewDiffData: NativeReviewDiffData;33 readonly nativeReviewDiffData: NativeReviewDiffData;
35}) {34}) {
36 const { environmentId, nativeReviewDiffData, selectedSection, threadId } = input;35 const { environmentId, nativeReviewDiffData, selectedSection, threadId } = input;
37 const { push } = useRouter();36 const navigation = useNavigation();
38 const activeCommentTarget = useReviewCommentTarget();37 const activeCommentTarget = useReviewCommentTarget();
39 const [pendingNativeCommentSelection, setPendingNativeCommentSelection] =38 const [pendingNativeCommentSelection, setPendingNativeCommentSelection] =
40 useState<PendingNativeCommentSelection | null>(null);39 useState<PendingNativeCommentSelection | null>(null);
@@ -44,11 +43,11 @@ export function useReviewCommentSelectionController(input: { · 3 unmodified lines
44 return;43 return;
45 }44 }
46 45
47 push({46 navigation.navigate("ThreadReviewComment", {
48 pathname: "/threads/[environmentId]/[threadId]/review-comment",47 environmentId,
49 params: { environmentId, threadId },48 threadId,
50 });49 });
51 }, [environmentId, push, threadId]);50 }, [environmentId, navigation, threadId]);
52 51
53 const selectedRowIds = useMemo(() => {52 const selectedRowIds = useMemo(() => {
54 if (53 if (
apps/mobile/src/features/review/useReviewDiffData.ts+11−9
@@ -1,11 +1,13 @@
1import { useEffect, useMemo } from "react";1import { useEffect, useMemo } from "react";
2 2
3import { countReviewCommentContexts, parseReviewInlineComments } from "./reviewCommentSelection";3import { countReviewCommentContexts, parseReviewInlineComments } from "./reviewCommentSelection";
4import { buildNativeReviewDiffData } from "./nativeReviewDiffAdapter";4import { getCachedNativeReviewDiffData } from "./nativeReviewDiffAdapter";
5import { markReviewEvent, measureReviewWork } from "./reviewPerf";5import { markReviewEvent, measureReviewWork } from "./reviewPerf";
6import { getCachedReviewParsedDiff } from "./reviewState";6import { getCachedReviewParsedDiff } from "./reviewState";
7import type { ReviewParsedDiff, ReviewSectionItem } from "./reviewModel";7import type { ReviewParsedDiff, ReviewSectionItem } from "./reviewModel";
8 8
9const EMPTY_INLINE_REVIEW_COMMENTS = Object.freeze([]);
10
9function isReviewDiffDebugLoggingEnabled(): boolean {11function isReviewDiffDebugLoggingEnabled(): boolean {
10 return typeof __DEV__ !== "undefined" ? __DEV__ : false;12 return typeof __DEV__ !== "undefined" ? __DEV__ : false;
11}13}
@@ -43,6 +45,7 @@ export function useReviewDiffData(input: { · 31 unmodified lines
43 readonly draftMessage: string;45 readonly draftMessage: string;
44}) {46}) {
45 const { draftMessage, selectedSection, threadKey } = input;47 const { draftMessage, selectedSection, threadKey } = input;
48 const selectedSectionId = selectedSection?.id ?? null;
46 const parsedDiff = useMemo(49 const parsedDiff = useMemo(
47 () =>50 () =>
48 measureReviewWork("parse-diff", () =>51 measureReviewWork("parse-diff", () =>
@@ -59,17 +62,16 @@ export function useReviewDiffData(input: { · 10 unmodified lines
59 () => parseReviewInlineComments(draftMessage),62 () => parseReviewInlineComments(draftMessage),
60 [draftMessage],63 [draftMessage],
61 );64 );
62 const selectedSectionInlineComments = useMemo(65 const selectedSectionInlineComments = useMemo(() => {
63 () =>66 if (!selectedSectionId || inlineReviewComments.length === 0) {
64 selectedSection67 return EMPTY_INLINE_REVIEW_COMMENTS;
65 ? inlineReviewComments.filter((comment) => comment.sectionId === selectedSection.id)68 }
66 : [],69 return inlineReviewComments.filter((comment) => comment.sectionId === selectedSectionId);
67 [inlineReviewComments, selectedSection],70 }, [inlineReviewComments, selectedSectionId]);
68 );
69 const nativeReviewDiffData = useMemo(71 const nativeReviewDiffData = useMemo(
70 () =>72 () =>
71 measureReviewWork("build-native-diff-data", () =>73 measureReviewWork("build-native-diff-data", () =>
72 buildNativeReviewDiffData({74 getCachedNativeReviewDiffData({
73 parsedDiff,75 parsedDiff,
74 comments: selectedSectionInlineComments,76 comments: selectedSectionInlineComments,
75 }),77 }),
apps/mobile/src/features/review/useReviewDiffPrewarming.ts+101−0
@@ -0,0 +1,101 @@
1import { useEffect } from "react";
2
3import { getCachedNativeReviewDiffData } from "./nativeReviewDiffAdapter";
4import type { ReviewSectionItem } from "./reviewModel";
5import { getCachedReviewParsedDiff } from "./reviewState";
6
7interface IdleDeadlineLike {
8 readonly didTimeout: boolean;
9 timeRemaining(): number;
10}
11
12type IdleCallback = (deadline: IdleDeadlineLike) => void;
13
14function scheduleIdle(callback: IdleCallback): number {
15 if (typeof globalThis.requestIdleCallback === "function") {
16 return globalThis.requestIdleCallback(callback, { timeout: 2_000 });
17 }
18
19 return setTimeout(
20 () => callback({ didTimeout: true, timeRemaining: () => 0 }),
21 100,
22 ) as unknown as number;
23}
24
25function cancelIdle(handle: number): void {
26 if (typeof globalThis.cancelIdleCallback === "function") {
27 globalThis.cancelIdleCallback(handle);
28 return;
29 }
30 clearTimeout(handle);
31}
32
33export function prewarmReviewDiffSection(input: {
34 readonly threadKey: string;
35 readonly section: ReviewSectionItem;
36}): void {
37 const { section, threadKey } = input;
38 if (section.diff === null) {
39 return;
40 }
41
42 const parsedDiff = getCachedReviewParsedDiff({
43 threadKey,
44 sectionId: section.id,
45 diff: section.diff,
46 });
47 getCachedNativeReviewDiffData({ parsedDiff, comments: [] });
48}
49
50/** Warms one cached section per idle period, after navigation animations finish. */
51export function useReviewDiffPrewarming(input: {
52 readonly threadKey: string | null;
53 readonly sections: ReadonlyArray<ReviewSectionItem>;
54 readonly selectedSectionId: string | null;
55}): void {
56 const { sections, selectedSectionId, threadKey } = input;
57
58 useEffect(() => {
59 if (!threadKey) {
60 return;
61 }
62
63 const pendingSections = sections.filter(
64 (section) => section.id !== selectedSectionId && section.diff !== null,
65 );
66 if (pendingSections.length === 0) {
67 return;
68 }
69
70 let cancelled = false;
71 let idleHandle: number | null = null;
72 let nextSectionIndex = 0;
73
74 const scheduleNext = () => {
75 idleHandle = scheduleIdle(() => {
76 if (cancelled) {
77 return;
78 }
79
80 const section = pendingSections[nextSectionIndex];
81 if (!section) {
82 return;
83 }
84 nextSectionIndex += 1;
85 prewarmReviewDiffSection({ threadKey, section });
86
87 if (nextSectionIndex < pendingSections.length) {
88 scheduleNext();
89 }
90 });
91 };
92
93 scheduleNext();
94 return () => {
95 cancelled = true;
96 if (idleHandle !== null) {
97 cancelIdle(idleHandle);
98 }
99 };
100 }, [sections, selectedSectionId, threadKey]);
101}
apps/mobile/src/features/settings/SettingsAppearanceRouteScreen.tsx+30−0
@@ -0,0 +1,30 @@
1import { ScrollView, View } from "react-native";
2import { useSafeAreaInsets } from "react-native-safe-area-context";
3
4import { CodeAppearanceSection } from "./appearance/sections/CodeAppearanceSection";
5import { TerminalAppearanceSection } from "./appearance/sections/TerminalAppearanceSection";
6import { TextAppearanceSection } from "./appearance/sections/TextAppearanceSection";
7
8export function SettingsAppearanceRouteScreen() {
9 const insets = useSafeAreaInsets();
10
11 return (
12 <View collapsable={false} className="flex-1 bg-sheet">
13 <ScrollView
14 contentInsetAdjustmentBehavior="automatic"
15 showsVerticalScrollIndicator={false}
16 style={{ flex: 1 }}
17 contentContainerStyle={{
18 gap: 24,
19 paddingBottom: Math.max(insets.bottom, 18) + 18,
20 paddingHorizontal: 20,
21 paddingTop: 16,
22 }}
23 >
24 <TextAppearanceSection />
25 <TerminalAppearanceSection />
26 <CodeAppearanceSection />
27 </ScrollView>
28 </View>
29 );
30}
apps/mobile/src/features/settings/SettingsAuthRouteScreen.tsx+39−0
@@ -0,0 +1,39 @@
1import { useAuth } from "@clerk/expo";
2import { AuthView, UserProfileView } from "@clerk/expo/native";
3import { StackActions, useNavigation } from "@react-navigation/native";
4import { NativeStackScreenOptions } from "../../native/StackHeader";
5import { useEffect } from "react";
6import { View } from "react-native";
7
8import { hasCloudPublicConfig } from "../cloud/publicConfig";
9
10export function SettingsAuthRouteScreen() {
11 const navigation = useNavigation();
12
13 useEffect(() => {
14 if (!hasCloudPublicConfig()) {
15 navigation.dispatch(StackActions.replace("Settings"));
16 }
17 }, [navigation]);
18
19 return hasCloudPublicConfig() ? <ConfiguredSettingsAuthRouteScreen /> : null;
20}
21
22function ConfiguredSettingsAuthRouteScreen() {
23 const { isLoaded, isSignedIn } = useAuth({ treatPendingAsSignedOut: false });
24
25 return (
26 <>
27 <NativeStackScreenOptions options={{ title: isSignedIn ? "Account" : "Sign in" }} />
28 <View collapsable={false} className="flex-1 overflow-hidden bg-sheet">
29 {isLoaded ? (
30 isSignedIn ? (
31 <UserProfileView isDismissible={false} />
32 ) : (
33 <AuthView isDismissible={false} />
34 )
35 ) : null}
36 </View>
37 </>
38 );
39}
apps/mobile/src/features/settings/SettingsEnvironmentsRouteScreen.tsxrenamed from apps/mobile/src/app/settings/environments.tsx+24−28
@@ -1,5 +1,6 @@
1import { useAuth } from "@clerk/expo";1import { useAuth } from "@clerk/expo";
2import { Stack, useRouter } from "expo-router";2import { NativeHeaderToolbar } from "../../native/StackHeader";
3import { useNavigation } from "@react-navigation/native";
3import { SymbolView } from "expo-symbols";4import { SymbolView } from "expo-symbols";
4import {5import {
5 connectionStatusText,6 connectionStatusText,
@@ -22,26 +23,26 @@ import { AppText as Text } from "../../components/AppText"; · 16 unmodified lines
22import {23import {
23 type RelayEnvironmentView,24 type RelayEnvironmentView,
24 useConnectionController,25 useConnectionController,
25} from "../../features/connection/useConnectionController";26} from "../connection/useConnectionController";
26import { hasCloudPublicConfig } from "../../features/cloud/publicConfig";27import { hasCloudPublicConfig } from "../cloud/publicConfig";
27import { availableCloudEnvironmentPresentation } from "../../features/cloud/cloudEnvironmentPresentation";28import { availableCloudEnvironmentPresentation } from "../cloud/cloudEnvironmentPresentation";
28import { ConnectionEnvironmentRow } from "../../features/connection/ConnectionEnvironmentRow";29import { ConnectionEnvironmentRow } from "../connection/ConnectionEnvironmentRow";
29import { ConnectionStatusDot } from "../../features/connection/ConnectionStatusDot";30import { ConnectionStatusDot } from "../connection/ConnectionStatusDot";
30import { splitEnvironmentSections } from "../../features/connection/environmentSections";31import { splitEnvironmentSections } from "../connection/environmentSections";
31import { cn } from "../../lib/cn";32import { cn } from "../../lib/cn";
32import { copyTextWithHaptic } from "../../lib/copyTextWithHaptic";33import { copyTextWithHaptic } from "../../lib/copyTextWithHaptic";
33import { useThemeColor } from "../../lib/useThemeColor";34import { useThemeColor } from "../../lib/useThemeColor";
34import type { ConnectedEnvironmentSummary } from "../../state/remote-runtime-types";35import type { ConnectedEnvironmentSummary } from "../../state/remote-runtime-types";
35import { useRemoteConnections } from "../../state/use-remote-environment-registry";36import { useRemoteConnections } from "../../state/use-remote-environment-registry";
36 37
37export default function SettingsEnvironmentsRouteScreen() {38export function SettingsEnvironmentsRouteScreen() {
38 const {39 const {
39 connectedEnvironments,40 connectedEnvironments,
40 onReconnectEnvironment,41 onReconnectEnvironment,
41 onRemoveEnvironmentPress,42 onRemoveEnvironmentPress,
42 onUpdateEnvironment,43 onUpdateEnvironment,
43 } = useRemoteConnections();44 } = useRemoteConnections();
44 const router = useRouter();45 const navigation = useNavigation();
45 const insets = useSafeAreaInsets();46 const insets = useSafeAreaInsets();
46 const { localEnvironments, connectedCloudEnvironments } = splitEnvironmentSections({47 const { localEnvironments, connectedCloudEnvironments } = splitEnvironmentSections({
47 connectedEnvironments,48 connectedEnvironments,
@@ -50,6 +51,7 @@ export default function SettingsEnvironmentsRouteScreen() { · 2 unmodified lines
50 const hasLocalEnvironments = localEnvironments.length > 0;51 const hasLocalEnvironments = localEnvironments.length > 0;
51 const [expandedId, setExpandedId] = useState<EnvironmentId | null>(null);52 const [expandedId, setExpandedId] = useState<EnvironmentId | null>(null);
52 const accentColor = useThemeColor("--color-icon-muted");53 const accentColor = useThemeColor("--color-icon-muted");
54 const headerIconColor = useThemeColor("--color-icon");
53 55
54 const handleToggle = useCallback((environmentId: EnvironmentId) => {56 const handleToggle = useCallback((environmentId: EnvironmentId) => {
55 setExpandedId((prev) => (prev === environmentId ? null : environmentId));57 setExpandedId((prev) => (prev === environmentId ? null : environmentId));
@@ -57,18 +59,14 @@ export default function SettingsEnvironmentsRouteScreen() { · 1 unmodified line
57 59
58 return (60 return (
59 <View collapsable={false} className="flex-1 bg-sheet">61 <View collapsable={false} className="flex-1 bg-sheet">
60 <Stack.Screen62 <NativeHeaderToolbar placement="right">
61 options={{63 <NativeHeaderToolbar.Button
62 title: "Environments",
63 }}
64 />
65 <Stack.Toolbar placement="right">
66 <Stack.Toolbar.Button
67 icon="plus"64 icon="plus"
68 onPress={() => router.push("/settings/environment-new")}65 onPress={() => navigation.navigate("SettingsSheet", { screen: "SettingsEnvironmentNew" })}
69 separateBackground66 separateBackground
67 tintColor={headerIconColor}
70 />68 />
71 </Stack.Toolbar>69 </NativeHeaderToolbar>
72 <ScrollView70 <ScrollView
73 contentInsetAdjustmentBehavior="automatic"71 contentInsetAdjustmentBehavior="automatic"
74 showsVerticalScrollIndicator={false}72 showsVerticalScrollIndicator={false}
@@ -111,7 +109,7 @@ export default function SettingsEnvironmentsRouteScreen() { · 36 unmodified lines
111 type="monochrome"109 type="monochrome"
112 />110 />
113 </View>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 No environments connected yet.{"\n"}Tap{" "}113 No environments connected yet.{"\n"}Tap{" "}
116 <Text className="font-t3-bold text-foreground">+</Text> to add one.114 <Text className="font-t3-bold text-foreground">+</Text> to add one.
117 </Text>115 </Text>
@@ -204,7 +202,7 @@ function ConfiguredCloudEnvironmentRows(props: { · 86 unmodified lines
204 ) : controller.relayDiscovery.isRefreshing ? (202 ) : controller.relayDiscovery.isRefreshing ? (
205 <View collapsable={false} className="items-center gap-3 rounded-[24px] bg-card p-6">203 <View collapsable={false} className="items-center gap-3 rounded-[24px] bg-card p-6">
206 <ActivityIndicator color={iconColor} />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 Loading linked cloud environments.206 Loading linked cloud environments.
209 </Text>207 </Text>
210 </View>208 </View>
@@ -213,16 +211,14 @@ function ConfiguredCloudEnvironmentRows(props: { · 2 unmodified lines
213 <Text className="text-base font-t3-bold text-foreground">211 <Text className="text-base font-t3-bold text-foreground">
214 Could not load T3 Cloud environments212 Could not load T3 Cloud environments
215 </Text>213 </Text>
216 <Text className="text-sm leading-[18px] text-foreground-muted">214 <Text className="text-sm text-foreground-muted">{controller.relayDiscovery.error}</Text>
217 {controller.relayDiscovery.error}
218 </Text>
219 {controller.relayDiscovery.errorTraceId ? (215 {controller.relayDiscovery.errorTraceId ? (
220 <CopyTraceIdButton traceId={controller.relayDiscovery.errorTraceId} />216 <CopyTraceIdButton traceId={controller.relayDiscovery.errorTraceId} />
221 ) : null}217 ) : null}
222 </View>218 </View>
223 ) : (219 ) : (
224 <View collapsable={false} className="rounded-[24px] bg-card p-5">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 No additional linked cloud environments.222 No additional linked cloud environments.
227 </Text>223 </Text>
228 </View>224 </View>
@@ -361,7 +357,7 @@ function CloudEnvironmentRowShell(props: { · 132 unmodified lines
361 <View className="min-w-0 flex-row items-center gap-2">357 <View className="min-w-0 flex-row items-center gap-2">
362 <ConnectionStatusDot state={props.connectionState} pulse={shouldPulse} size={7} />358 <ConnectionStatusDot state={props.connectionState} pulse={shouldPulse} size={7} />
363 <Text359 <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 numberOfLines={1}361 numberOfLines={1}
366 >362 >
367 {props.label}363 {props.label}
@@ -370,7 +366,7 @@ function CloudEnvironmentRowShell(props: { · 2 unmodified lines
370 {props.connectionError ? (366 {props.connectionError ? (
371 <Text367 <Text
372 aria-hidden368 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 onTextLayout={onMeasuredErrorTextLayout}370 onTextLayout={onMeasuredErrorTextLayout}
375 style={{ opacity: 0, zIndex: -1 }}371 style={{ opacity: 0, zIndex: -1 }}
376 >372 >
@@ -384,7 +380,7 @@ function CloudEnvironmentRowShell(props: { · 7 unmodified lines
384 className="min-w-0 flex-row items-start gap-1"380 className="min-w-0 flex-row items-start gap-1"
385 >381 >
386 <Text382 <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 numberOfLines={isErrorExpanded ? undefined : 1}384 numberOfLines={isErrorExpanded ? undefined : 1}
389 >385 >
390 {statusText}386 {statusText}
@@ -394,7 +390,7 @@ function CloudEnvironmentRowShell(props: { · 3 unmodified lines
394 <Text390 <Text
395 accessibilityHint="Copies the trace ID"391 accessibilityHint="Copies the trace ID"
396 accessibilityRole="button"392 accessibilityRole="button"
397 className={cn("text-xs leading-[16px] underline", statusClassName)}393 className={cn("text-xs underline", statusClassName)}
398 onLongPress={(event) => {394 onLongPress={(event) => {
399 event.stopPropagation();395 event.stopPropagation();
400 copyTextWithHaptic(errorTraceId, { target: "connection-trace-id" });396 copyTextWithHaptic(errorTraceId, { target: "connection-trace-id" });
apps/mobile/src/features/settings/SettingsRouteScreen.tsxrenamed from apps/mobile/src/app/settings/index.tsx+61−128
@@ -1,11 +1,11 @@
1import { useAuth, useUser } from "@clerk/expo";1import { useAuth, useUser } from "@clerk/expo";
2import * as Notifications from "expo-notifications";2import * as Notifications from "expo-notifications";
3import { Link, Stack, useRouter } from "expo-router";3import { useNavigation } from "@react-navigation/native";
4import { NativeStackScreenOptions } from "../../native/StackHeader";
4import { SymbolView } from "expo-symbols";5import { SymbolView } from "expo-symbols";
5import * as Effect from "effect/Effect";6import * as Effect from "effect/Effect";
6import { useCallback, useEffect, useMemo, useState } from "react";7import { useCallback, useEffect, useMemo, useState } from "react";
7import type { ComponentProps, ReactNode } from "react";8import { Alert, Linking, Platform, ScrollView, View } from "react-native";
8import { Alert, Linking, Pressable, ScrollView, Switch, View } from "react-native";
9import { useSafeAreaInsets } from "react-native-safe-area-context";9import { useSafeAreaInsets } from "react-native-safe-area-context";
10 10
11import {11import {
@@ -16,25 +16,51 @@ import { · 4 unmodified lines
16 squashAtomCommandFailure,16 squashAtomCommandFailure,
17} from "@t3tools/client-runtime/state/runtime";17} from "@t3tools/client-runtime/state/runtime";
18import { AppText as Text } from "../../components/AppText";18import { AppText as Text } from "../../components/AppText";
19import { setLiveActivityUpdatesEnabled } from "../../features/agent-awareness/liveActivityPreferences";19import { setLiveActivityUpdatesEnabled } from "../agent-awareness/liveActivityPreferences";
20import { requestAgentNotificationPermission } from "../../features/agent-awareness/notificationPermissions";20import { requestAgentNotificationPermission } from "../agent-awareness/notificationPermissions";
21import { refreshAgentAwarenessRegistration } from "../../features/agent-awareness/remoteRegistration";21import { refreshAgentAwarenessRegistration } from "../agent-awareness/remoteRegistration";
22import { refreshManagedRelayEnvironments } from "../../features/cloud/managedRelayState";22import { refreshManagedRelayEnvironments } from "../cloud/managedRelayState";
23import { useClerkSettingsSheetDetent } from "../../features/cloud/ClerkSettingsSheetDetent";23import { useClerkSettingsSheetDetent } from "../cloud/ClerkSettingsSheetDetent";
24import {24import { hasCloudPublicConfig, resolveRelayClerkTokenOptions } from "../cloud/publicConfig";
25 hasCloudPublicConfig,25import { withNativeGlassHeaderItem } from "../layout/native-glass-header-items";
26 resolveRelayClerkTokenOptions,26import { WorkspaceSidebarToolbar } from "../layout/workspace-sidebar-toolbar";
27} from "../../features/cloud/publicConfig";
28import { runtime } from "../../lib/runtime";27import { runtime } from "../../lib/runtime";
29import { loadPreferences } from "../../lib/storage";28import { loadPreferences } from "../../lib/storage";
30import { useThemeColor } from "../../lib/useThemeColor";29import { useThemeColor } from "../../lib/useThemeColor";
31import { useSavedRemoteConnections } from "../../state/use-remote-environment-registry";30import { useSavedRemoteConnections } from "../../state/use-remote-environment-registry";
31import { SettingsRow } from "./components/SettingsRow";
32import { SettingsSection } from "./components/SettingsSection";
33import { SettingsSwitchRow } from "./components/SettingsSwitchRow";
32 34
33type NotificationStatus = "checking" | "enabled" | "disabled" | "unsupported";35type NotificationStatus = "checking" | "enabled" | "disabled" | "unsupported";
34type LiveActivityStatus = "checking" | "enabled" | "disabled" | "signed-out" | "linking";36type LiveActivityStatus = "checking" | "enabled" | "disabled" | "signed-out" | "linking";
35 37
36export default function SettingsRouteScreen() {38export function SettingsRouteScreen() {
37 return hasCloudPublicConfig() ? <ConfiguredSettingsRouteScreen /> : <LocalSettingsRouteScreen />;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
40function LocalSettingsRouteScreen() {66function LocalSettingsRouteScreen() {
@@ -44,7 +70,6 @@ function LocalSettingsRouteScreen() { · 3 unmodified lines
44 70
45 return (71 return (
46 <View collapsable={false} className="flex-1 bg-sheet">72 <View collapsable={false} className="flex-1 bg-sheet">
47 <Stack.Screen options={{ title: "Settings" }} />
48 <ScrollView73 <ScrollView
49 contentInsetAdjustmentBehavior="automatic"74 contentInsetAdjustmentBehavior="automatic"
50 showsVerticalScrollIndicator={false}75 showsVerticalScrollIndicator={false}
@@ -61,10 +86,14 @@ function LocalSettingsRouteScreen() { · 10 unmodified lines
61 icon="desktopcomputer"86 icon="desktopcomputer"
62 label="Environments"87 label="Environments"
63 value={`${environmentCount}`}88 value={`${environmentCount}`}
64 href="/settings/environments"89 target="SettingsEnvironments"
65 />90 />
66 </SettingsSection>91 </SettingsSection>
67 92
93 <SettingsSection title="Appearance">
94 <SettingsRow icon="paintbrush" label="Appearance" target="SettingsAppearance" />
95 </SettingsSection>
96
68 <ArchivedThreadsSettingsSection />97 <ArchivedThreadsSettingsSection />
69 98
70 <AppSettingsSection />99 <AppSettingsSection />
@@ -75,7 +104,7 @@ function LocalSettingsRouteScreen() { · 4 unmodified lines
75 104
76function ConfiguredSettingsRouteScreen() {105function ConfiguredSettingsRouteScreen() {
77 const insets = useSafeAreaInsets();106 const insets = useSafeAreaInsets();
78 const { push } = useRouter();107 const navigation = useNavigation();
79 const { expand: expandClerkSheet } = useClerkSettingsSheetDetent();108 const { expand: expandClerkSheet } = useClerkSettingsSheetDetent();
80 const { getToken, isLoaded, isSignedIn } = useAuth({ treatPendingAsSignedOut: false });109 const { getToken, isLoaded, isSignedIn } = useAuth({ treatPendingAsSignedOut: false });
81 const { user } = useUser();110 const { user } = useUser();
@@ -186,10 +215,13 @@ function ConfiguredSettingsRouteScreen() { · 104 unmodified lines
186 "Live Activity updates require approved T3 Cloud access so relay can deliver updates to this device.",215 "Live Activity updates require approved T3 Cloud access so relay can deliver updates to this device.",
187 [216 [
188 { text: "Cancel", style: "cancel" },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 const linkEnvironments = useCallback(async () => {226 const linkEnvironments = useCallback(async () => {
195 if (!isSignedIn) {227 if (!isSignedIn) {
@@ -316,16 +348,15 @@ function ConfiguredSettingsRouteScreen() { · 120 unmodified lines
316 const openAccount = useCallback(() => {348 const openAccount = useCallback(() => {
317 if (!isLoaded) return;349 if (!isLoaded) return;
318 if (!isSignedIn) {350 if (!isSignedIn) {
319 push("/settings/waitlist");351 navigation.navigate("SettingsSheet", { screen: "SettingsWaitlist" });
320 return;352 return;
321 }353 }
322 expandClerkSheet();354 expandClerkSheet();
323 push("/settings/auth");355 navigation.navigate("SettingsSheet", { screen: "SettingsAuth" });
324 }, [expandClerkSheet, isLoaded, isSignedIn, push]);356 }, [expandClerkSheet, isLoaded, isSignedIn, navigation]);
325 357
326 return (358 return (
327 <View collapsable={false} className="flex-1 bg-sheet">359 <View collapsable={false} className="flex-1 bg-sheet">
328 <Stack.Screen options={{ title: "Settings" }} />
329 <ScrollView360 <ScrollView
330 contentInsetAdjustmentBehavior="automatic"361 contentInsetAdjustmentBehavior="automatic"
331 showsVerticalScrollIndicator={false}362 showsVerticalScrollIndicator={false}
@@ -346,7 +377,7 @@ function ConfiguredSettingsRouteScreen() { · 14 unmodified lines
346 onPress={openAccount}377 onPress={openAccount}
347 />378 />
348 </SettingsSection>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 T3 Code works locally without signing in. Cloud features are optional.381 T3 Code works locally without signing in. Cloud features are optional.
351 </Text>382 </Text>
352 </View>383 </View>
@@ -356,7 +387,7 @@ function ConfiguredSettingsRouteScreen() { · 3 unmodified lines
356 icon="desktopcomputer"387 icon="desktopcomputer"
357 label="Environments"388 label="Environments"
358 value={`${environmentCount}`}389 value={`${environmentCount}`}
359 href="/settings/environments"390 target="SettingsEnvironments"
360 />391 />
361 <SettingsSwitchRow392 <SettingsSwitchRow
362 icon="bell.badge"393 icon="bell.badge"
@@ -376,6 +407,10 @@ function ConfiguredSettingsRouteScreen() { · 13 unmodified lines
376 />407 />
377 </SettingsSection>408 </SettingsSection>
378 409
410 <SettingsSection title="Appearance">
411 <SettingsRow icon="paintbrush" label="Appearance" target="SettingsAppearance" />
412 </SettingsSection>
413
379 <ArchivedThreadsSettingsSection />414 <ArchivedThreadsSettingsSection />
380 415
381 <AppSettingsSection />416 <AppSettingsSection />
@@ -384,22 +419,6 @@ function ConfiguredSettingsRouteScreen() { · 2 unmodified lines
384 );419 );
385}420}
386 421
387type SymbolName = ComponentProps<typeof SymbolView>["name"];
388
389function SettingsSection(props: { readonly title: string; readonly children: ReactNode }) {
390 return (
391 <View className="gap-2">
392 <Text className="px-2 text-sm font-t3-medium text-foreground-muted">{props.title}</Text>
393 <View
394 className="overflow-hidden rounded-[28px] bg-card"
395 style={{ borderCurve: "continuous" }}
396 >
397 {props.children}
398 </View>
399 </View>
400 );
401}
402
403function AppSettingsSection() {422function AppSettingsSection() {
404 const icon = useThemeColor("--color-icon");423 const icon = useThemeColor("--color-icon");
405 424
@@ -423,93 +442,7 @@ function AppSettingsSection() { · 17 unmodified lines
423function ArchivedThreadsSettingsSection() {442function ArchivedThreadsSettingsSection() {
424 return (443 return (
425 <SettingsSection title="Threads">444 <SettingsSection title="Threads">
426 <SettingsRow icon="archivebox" label="Archived Threads" href="/settings/archive" />445 <SettingsRow icon="archivebox" label="Archived Threads" target="SettingsArchive" />
427 </SettingsSection>446 </SettingsSection>
428 );447 );
429}448}
430
431function SettingsRow(props: {
432 readonly disabled?: boolean;
433 readonly icon: SymbolName;
434 readonly label: string;
435 readonly value?: string;
436 readonly href?: "/settings/archive" | "/settings/environments";
437 readonly onPress?: () => void;
438}) {
439 const icon = useThemeColor("--color-icon");
440 const chevron = useThemeColor("--color-chevron");
441 const content = (
442 <View
443 className="flex-row items-center gap-4 p-4"
444 style={{ opacity: props.disabled ? 0.45 : 1 }}
445 >
446 <SymbolView name={props.icon} size={22} tintColor={icon} type="monochrome" weight="regular" />
447 <Text className="shrink-0 text-lg text-foreground" numberOfLines={1}>
448 {props.label}
449 </Text>
450 <View className="min-w-0 flex-1 items-end">
451 {props.value ? (
452 <Text
453 className="max-w-[180px] text-right text-base text-foreground-muted"
454 ellipsizeMode="middle"
455 numberOfLines={1}
456 >
457 {props.value}
458 </Text>
459 ) : null}
460 </View>
461 <SymbolView
462 name="chevron.right"
463 size={16}
464 tintColor={chevron}
465 type="monochrome"
466 weight="semibold"
467 />
468 </View>
469 );
470
471 if (props.href) {
472 return (
473 <Link href={props.href} asChild>
474 <Pressable accessibilityLabel={props.label} accessibilityRole="button">
475 {content}
476 </Pressable>
477 </Link>
478 );
479 }
480
481 return (
482 <Pressable accessibilityRole="button" disabled={props.disabled} onPress={props.onPress}>
483 {content}
484 </Pressable>
485 );
486}
487
488function SettingsSwitchRow(props: {
489 readonly disabled?: boolean;
490 readonly icon: SymbolName;
491 readonly label: string;
492 readonly value: boolean;
493 readonly onValueChange: (value: boolean) => void;
494}) {
495 const icon = useThemeColor("--color-icon");
496 const activeTrack = String(useThemeColor("--color-switch-active"));
497 const track = String(useThemeColor("--color-secondary-border"));
498
499 return (
500 <View
501 className="flex-row items-center gap-4 p-4"
502 style={{ opacity: props.disabled ? 0.45 : 1 }}
503 >
504 <SymbolView name={props.icon} size={22} tintColor={icon} type="monochrome" weight="regular" />
505 <Text className="flex-1 text-lg text-foreground">{props.label}</Text>
506 <Switch
507 disabled={props.disabled}
508 ios_backgroundColor={track}
509 onValueChange={props.onValueChange}
510 trackColor={{ false: track, true: activeTrack }}
511 value={props.value}
512 />
513 </View>
514 );
515}
apps/mobile/src/features/settings/SettingsWaitlistRouteScreen.tsxrenamed from apps/mobile/src/app/settings/waitlist.tsx+19−14
@@ -1,36 +1,41 @@
1import { useAuth } from "@clerk/expo";1import { useAuth } from "@clerk/expo";
2import { Redirect, Stack, useFocusEffect, useRouter } from "expo-router";2import { StackActions, useFocusEffect, useNavigation } from "@react-navigation/native";
3import { useCallback } from "react";3import { useCallback } from "react";
4import { ScrollView } from "react-native";4import { ScrollView } from "react-native";
5 5
6import { CloudWaitlistEnrollment } from "../../features/cloud/CloudWaitlistEnrollment";6import { CloudWaitlistEnrollment } from "../cloud/CloudWaitlistEnrollment";
7import { useClerkSettingsSheetDetent } from "../../features/cloud/ClerkSettingsSheetDetent";7import { useClerkSettingsSheetDetent } from "../cloud/ClerkSettingsSheetDetent";
8import { hasCloudPublicConfig } from "../../features/cloud/publicConfig";8import { hasCloudPublicConfig } from "../cloud/publicConfig";
9 9
10export default function SettingsWaitlistRouteScreen() {10export function SettingsWaitlistRouteScreen() {
11 return hasCloudPublicConfig() ? (11 const navigation = useNavigation();
12 <ConfiguredSettingsWaitlistRouteScreen />12
13 ) : (13 useFocusEffect(
14 <Redirect href="/settings" />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
18function ConfiguredSettingsWaitlistRouteScreen() {24function ConfiguredSettingsWaitlistRouteScreen() {
19 const { isLoaded, isSignedIn } = useAuth({ treatPendingAsSignedOut: false });25 const { isLoaded, isSignedIn } = useAuth({ treatPendingAsSignedOut: false });
20 const { expand } = useClerkSettingsSheetDetent();26 const { expand } = useClerkSettingsSheetDetent();
21 const router = useRouter();27 const navigation = useNavigation();
22 28
23 useFocusEffect(29 useFocusEffect(
24 useCallback(() => {30 useCallback(() => {
25 if (isLoaded && isSignedIn) {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 return (37 return (
32 <>38 <>
33 <Stack.Screen options={{ title: "Join the waitlist" }} />
34 <ScrollView39 <ScrollView
35 automaticallyAdjustKeyboardInsets40 automaticallyAdjustKeyboardInsets
36 contentContainerStyle={{41 contentContainerStyle={{
@@ -46,7 +51,7 @@ function ConfiguredSettingsWaitlistRouteScreen() { · 9 unmodified lines
46 <CloudWaitlistEnrollment51 <CloudWaitlistEnrollment
47 onSignIn={() => {52 onSignIn={() => {
48 expand();53 expand();
49 router.push("/settings/auth");54 navigation.navigate("SettingsSheet", { screen: "SettingsAuth" });
50 }}55 }}
51 />56 />
52 </ScrollView>57 </ScrollView>
apps/mobile/src/features/settings/appearance/AppearancePreferencesProvider.tsx+158−0
@@ -0,0 +1,158 @@
1import {
2 createContext,
3 useCallback,
4 useContext,
5 useEffect,
6 useMemo,
7 useState,
8 type ReactNode,
9} from "react";
10
11import { Uniwind } from "uniwind";
12
13import {
14 resolveAppearance,
15 resolveAppearancePreferences,
16 resolveTextScaleVariables,
17 type AppearancePreferences,
18 type ResolvedAppearance,
19} from "../../../lib/appearancePreferences";
20import { loadPreferences, savePreferencesPatch } from "../../../lib/storage";
21import { cacheTerminalFontSize } from "../../terminal/terminalUiState";
22
23interface AppearancePreferencesContextValue {
24 /** Effective values with base-size derivation applied. Use this for rendering. */
25 readonly appearance: ResolvedAppearance;
26 readonly isReady: boolean;
27 readonly setBaseFontSize: (value: number) => void;
28 /** Pass null to clear the override and follow the base font size. */
29 readonly setTerminalFontSize: (value: number | null) => void;
30 /** Pass null to clear the override and follow the base font size. */
31 readonly setCodeFontSize: (value: number | null) => void;
32 readonly setCodeWordBreak: (value: boolean) => void;
33}
34
35const AppearancePreferencesContext = createContext<AppearancePreferencesContextValue | null>(null);
36
37/**
38 * Injects the scaled `--text-*` variables into Uniwind so every
39 * className-based text size (`text-sm`, `text-base`, ...) re-resolves live.
40 * Updates the current theme last so the active stylesheet settles correctly.
41 */
42function applyTextScaleVariables(baseFontSize: number) {
43 const variables = resolveTextScaleVariables(baseFontSize);
44 const currentTheme = Uniwind.currentTheme;
45
46 for (const theme of ["light", "dark"] as const) {
47 if (theme !== currentTheme) {
48 Uniwind.updateCSSVariables(theme, variables);
49 }
50 }
51 Uniwind.updateCSSVariables(currentTheme, variables);
52}
53
54export function AppearancePreferencesProvider(props: { readonly children: ReactNode }) {
55 const [preferences, setPreferences] = useState<AppearancePreferences>(() =>
56 resolveAppearancePreferences(null),
57 );
58 const [isReady, setIsReady] = useState(false);
59
60 useEffect(() => {
61 let cancelled = false;
62
63 void loadPreferences()
64 .then((stored) => {
65 if (cancelled) {
66 return;
67 }
68
69 const resolved = resolveAppearancePreferences(stored);
70 setPreferences(resolved);
71 cacheTerminalFontSize(resolveAppearance(resolved).terminalFontSize);
72 setIsReady(true);
73 })
74 .catch(() => {
75 if (cancelled) {
76 return;
77 }
78
79 setIsReady(true);
80 });
81
82 return () => {
83 cancelled = true;
84 };
85 }, []);
86
87 useEffect(() => {
88 applyTextScaleVariables(preferences.baseFontSize);
89 }, [preferences.baseFontSize]);
90
91 const updatePreferences = useCallback((patch: Partial<AppearancePreferences>) => {
92 setPreferences((current) => {
93 const next = resolveAppearancePreferences({ ...current, ...patch });
94 cacheTerminalFontSize(resolveAppearance(next).terminalFontSize);
95 void savePreferencesPatch({
96 baseFontSize: next.baseFontSize,
97 terminalFontSize: next.terminalFontSize,
98 codeFontSize: next.codeFontSize,
99 codeWordBreak: next.codeWordBreak,
100 }).catch(() => undefined);
101 return next;
102 });
103 }, []);
104
105 const setBaseFontSize = useCallback(
106 (value: number) => {
107 updatePreferences({ baseFontSize: value });
108 },
109 [updatePreferences],
110 );
111
112 const setTerminalFontSize = useCallback(
113 (value: number | null) => {
114 updatePreferences({ terminalFontSize: value });
115 },
116 [updatePreferences],
117 );
118
119 const setCodeFontSize = useCallback(
120 (value: number | null) => {
121 updatePreferences({ codeFontSize: value });
122 },
123 [updatePreferences],
124 );
125
126 const setCodeWordBreak = useCallback(
127 (value: boolean) => {
128 updatePreferences({ codeWordBreak: value });
129 },
130 [updatePreferences],
131 );
132
133 const value = useMemo(
134 (): AppearancePreferencesContextValue => ({
135 appearance: resolveAppearance(preferences),
136 isReady,
137 setBaseFontSize,
138 setTerminalFontSize,
139 setCodeFontSize,
140 setCodeWordBreak,
141 }),
142 [preferences, isReady, setBaseFontSize, setTerminalFontSize, setCodeFontSize, setCodeWordBreak],
143 );
144
145 return (
146 <AppearancePreferencesContext.Provider value={value}>
147 {props.children}
148 </AppearancePreferencesContext.Provider>
149 );
150}
151
152export function useAppearancePreferences(): AppearancePreferencesContextValue {
153 const context = useContext(AppearancePreferencesContext);
154 if (!context) {
155 throw new Error("useAppearancePreferences must be used within AppearancePreferencesProvider");
156 }
157 return context;
158}
apps/mobile/src/features/settings/appearance/components/AppearancePreviews.tsx+177−0
@@ -0,0 +1,177 @@
1import { Platform, ScrollView, View, useColorScheme } from "react-native";
2
3import { AppText as Text } from "../../../../components/AppText";
4import {
5 resolveMarkdownFontSizes,
6 resolveMobileCodeSurface,
7} from "../../../../lib/appearancePreferences";
8import { useThemeColor } from "../../../../lib/useThemeColor";
9import { getPierreTerminalTheme } from "../../../terminal/terminalTheme";
10
11const CODE_FONT_FAMILY = Platform.select({
12 ios: "ui-monospace",
13 android: "monospace",
14 default: "monospace",
15});
16
17/** Hairline between a section's preview surface and its control rows. */
18export function AppearancePreviewSeparator() {
19 return <View className="h-px bg-separator" />;
20}
21
22/** Live sample of body text rendered at the chosen base font size. */
23export function TextAppearancePreview(props: { readonly fontSize: number }) {
24 const sizes = resolveMarkdownFontSizes(props.fontSize);
25
26 return (
27 <View className="gap-1 p-4">
28 <Text
29 className="text-foreground"
30 style={{ fontSize: sizes.m, lineHeight: sizes.bodyLineHeight }}
31 >
32 The quick brown fox jumps over the lazy dog.
33 </Text>
34 <Text
35 className="text-foreground-muted"
36 style={{ fontSize: sizes.s, lineHeight: Math.round(sizes.s * 1.4) }}
37 >
38 Messages, labels, and headings scale with this size.
39 </Text>
40 </View>
41 );
42}
43
44/**
45 * Live terminal sample using the real terminal theme's text colors and font,
46 * on the shared card background so it reads like the other previews.
47 */
48export function TerminalAppearancePreview(props: { readonly fontSize: number }) {
49 const scheme = useColorScheme() === "light" ? "light" : "dark";
50 const theme = getPierreTerminalTheme(scheme);
51 const lineHeight = Math.round(props.fontSize * 1.6);
52 const lineStyle = {
53 fontFamily: "Menlo",
54 fontSize: props.fontSize,
55 lineHeight,
56 } as const;
57
58 return (
59 <View className="p-4">
60 <Text style={[lineStyle, { color: theme.foreground }]}>$ npm run dev</Text>
61 <Text style={[lineStyle, { color: theme.palette[2] }]}>✓ Ready in 430ms</Text>
62 <Text style={[lineStyle, { color: theme.foreground }]}>
63 Local: http://localhost:3000{" "}
64 <Text style={[lineStyle, { color: theme.cursorForeground }]}>▏</Text>
65 </Text>
66 </View>
67 );
68}
69
70interface CodePreviewToken {
71 readonly text: string;
72 readonly keyword?: boolean;
73}
74
75interface CodePreviewLine {
76 readonly id: string;
77 readonly tokens: ReadonlyArray<CodePreviewToken>;
78}
79
80const CODE_PREVIEW_LINES: ReadonlyArray<CodePreviewLine> = [
81 {
82 id: "signature",
83 tokens: [{ text: "function", keyword: true }, { text: " formatUser(user) {" }],
84 },
85 {
86 id: "body",
87 tokens: [
88 { text: " " },
89 { text: "return", keyword: true },
90 { text: " `${user.name} <${user.email}>` // demonstrates how long lines behave" },
91 ],
92 },
93 { id: "close", tokens: [{ text: "}" }] },
94];
95
96/**
97 * Live code sample matching the code & diff surface metrics. Long lines wrap
98 * when word break is on and scroll horizontally when it is off, mirroring the
99 * real code surface.
100 */
101export function CodeAppearancePreview(props: {
102 readonly fontSize: number;
103 readonly wordBreak: boolean;
104}) {
105 const surface = resolveMobileCodeSurface(props.fontSize);
106 const lineNumberColor = useThemeColor("--color-icon-subtle");
107 const keywordColor = useThemeColor("--color-md-link");
108
109 const lineNumber = (line: CodePreviewLine, index: number) => (
110 <Text
111 className="text-right"
112 key={line.id}
113 style={{
114 color: lineNumberColor,
115 fontFamily: CODE_FONT_FAMILY,
116 fontSize: surface.lineNumberFontSize,
117 lineHeight: surface.rowHeight,
118 width: 22,
119 }}
120 >
121 {index + 1}
122 </Text>
123 );
124
125 const codeLine = (line: CodePreviewLine, wrap: boolean) => (
126 <Text
127 className="text-foreground"
128 key={line.id}
129 numberOfLines={wrap ? undefined : 1}
130 style={{
131 fontFamily: CODE_FONT_FAMILY,
132 fontSize: surface.fontSize,
133 lineHeight: surface.rowHeight,
134 }}
135 >
136 {line.tokens.map((token) => (
137 <Text
138 key={token.text}
139 style={{
140 color: token.keyword ? keywordColor : undefined,
141 fontFamily: CODE_FONT_FAMILY,
142 fontSize: surface.fontSize,
143 lineHeight: surface.rowHeight,
144 }}
145 >
146 {token.text}
147 </Text>
148 ))}
149 </Text>
150 );
151
152 if (props.wordBreak) {
153 return (
154 <View className="p-4">
155 {CODE_PREVIEW_LINES.map((line, index) => (
156 <View className="flex-row" key={line.id}>
157 {lineNumber(line, index)}
158 <View className="flex-1 pl-3">{codeLine(line, true)}</View>
159 </View>
160 ))}
161 </View>
162 );
163 }
164
165 return (
166 <View className="flex-row p-4">
167 <View>{CODE_PREVIEW_LINES.map((line, index) => lineNumber(line, index))}</View>
168 <ScrollView
169 horizontal
170 contentContainerStyle={{ paddingLeft: 12 }}
171 showsHorizontalScrollIndicator={false}
172 >
173 <View>{CODE_PREVIEW_LINES.map((line) => codeLine(line, false))}</View>
174 </ScrollView>
175 </View>
176 );
177}
apps/mobile/src/features/settings/appearance/components/FontSizeSliderRow.tsx+214−0
@@ -0,0 +1,214 @@
1import * as Haptics from "expo-haptics";
2import { SymbolView } from "expo-symbols";
3import { useCallback, useEffect, useMemo, useRef } from "react";
4import { View, type AccessibilityActionEvent } from "react-native";
5import { Gesture, GestureDetector } from "react-native-gesture-handler";
6import Animated, {
7 runOnJS,
8 useAnimatedStyle,
9 useSharedValue,
10 withTiming,
11} from "react-native-reanimated";
12import type { ComponentProps } from "react";
13
14import { AppText as Text } from "../../../../components/AppText";
15import { useThemeColor } from "../../../../lib/useThemeColor";
16
17type SymbolName = ComponentProps<typeof SymbolView>["name"];
18
19const THUMB_SIZE = 26;
20const TRACK_HEIGHT = 4;
21const SNAP_ANIMATION = { duration: 120 } as const;
22
23function clampFraction(value: number): number {
24 "worklet";
25 return Math.min(1, Math.max(0, value));
26}
27
28export function FontSizeSliderRow(props: {
29 readonly disabled?: boolean;
30 readonly icon: SymbolName;
31 readonly label: string;
32 readonly valueLabel: string;
33 readonly min: number;
34 readonly max: number;
35 readonly step: number;
36 readonly value: number;
37 readonly onChange: (value: number) => void;
38}) {
39 const icon = useThemeColor("--color-icon");
40 const iconMuted = String(useThemeColor("--color-icon-muted"));
41 const trackColor = String(useThemeColor("--color-secondary-border"));
42 const fillColor = String(useThemeColor("--color-primary"));
43
44 const latest = useRef(props);
45 latest.current = props;
46
47 const { min, max, step, value, disabled } = props;
48 const fraction = (value - min) / (max - min);
49
50 const progress = useSharedValue(clampFraction(fraction));
51 const trackWidth = useSharedValue(0);
52 const dragging = useSharedValue(false);
53
54 useEffect(() => {
55 if (!dragging.value) {
56 progress.value = withTiming(clampFraction(fraction), SNAP_ANIMATION);
57 }
58 }, [dragging, fraction, progress]);
59
60 const commit = useCallback((next: number) => {
61 if (next === latest.current.value) {
62 return;
63 }
64 Haptics.selectionAsync().catch(() => undefined);
65 latest.current.onChange(next);
66 }, []);
67
68 const gesture = useMemo(() => {
69 const snapValue = (raw: number): number => {
70 "worklet";
71 const stepped = Math.round((raw - min) / step) * step + min;
72 return Math.min(max, Math.max(min, stepped));
73 };
74 const fractionAt = (x: number): number => {
75 "worklet";
76 const usable = trackWidth.value - THUMB_SIZE;
77 if (usable <= 0) {
78 return 0;
79 }
80 return clampFraction((x - THUMB_SIZE / 2) / usable);
81 };
82 const valueAtFraction = (f: number): number => {
83 "worklet";
84 return snapValue(min + f * (max - min));
85 };
86 const fractionOfValue = (v: number): number => {
87 "worklet";
88 return clampFraction((v - min) / (max - min));
89 };
90
91 const pan = Gesture.Pan()
92 .enabled(!disabled)
93 .activeOffsetX([-8, 8])
94 .failOffsetY([-12, 12])
95 .onUpdate((event) => {
96 dragging.value = true;
97 const f = fractionAt(event.x);
98 progress.value = f;
99 runOnJS(commit)(valueAtFraction(f));
100 })
101 .onFinalize(() => {
102 if (!dragging.value) {
103 return;
104 }
105 dragging.value = false;
106 progress.value = withTiming(
107 fractionOfValue(valueAtFraction(progress.value)),
108 SNAP_ANIMATION,
109 );
110 });
111
112 const tap = Gesture.Tap()
113 .enabled(!disabled)
114 .onEnd((event) => {
115 const next = valueAtFraction(fractionAt(event.x));
116 progress.value = withTiming(fractionOfValue(next), SNAP_ANIMATION);
117 runOnJS(commit)(next);
118 });
119
120 return Gesture.Race(pan, tap);
121 }, [commit, disabled, dragging, max, min, progress, step, trackWidth]);
122
123 const fillStyle = useAnimatedStyle(() => ({
124 width: THUMB_SIZE / 2 + progress.value * Math.max(0, trackWidth.value - THUMB_SIZE),
125 }));
126 const thumbStyle = useAnimatedStyle(() => ({
127 transform: [{ translateX: progress.value * Math.max(0, trackWidth.value - THUMB_SIZE) }],
128 }));
129
130 const handleAccessibilityAction = (event: AccessibilityActionEvent) => {
131 if (event.nativeEvent.actionName === "increment") {
132 commit(Math.min(max, value + step));
133 } else if (event.nativeEvent.actionName === "decrement") {
134 commit(Math.max(min, value - step));
135 }
136 };
137
138 return (
139 <View className="gap-1 p-4" style={{ opacity: disabled ? 0.45 : 1 }}>
140 <View className="flex-row items-center gap-4">
141 <SymbolView
142 name={props.icon}
143 size={22}
144 tintColor={icon}
145 type="monochrome"
146 weight="regular"
147 />
148 <Text className="flex-1 text-lg text-foreground">{props.label}</Text>
149 <Text className="text-base font-t3-medium text-foreground-muted">{props.valueLabel}</Text>
150 </View>
151 <View className="flex-row items-center gap-3">
152 <SymbolView
153 name="textformat.size.smaller"
154 size={15}
155 tintColor={iconMuted}
156 type="monochrome"
157 weight="regular"
158 />
159 <GestureDetector gesture={gesture}>
160 <View
161 accessible
162 accessibilityActions={[
163 { name: "increment", label: `Increase ${props.label}` },
164 { name: "decrement", label: `Decrease ${props.label}` },
165 ]}
166 accessibilityLabel={props.label}
167 accessibilityRole="adjustable"
168 accessibilityValue={{ min, max, now: value, text: props.valueLabel }}
169 className="h-11 flex-1 justify-center"
170 onAccessibilityAction={handleAccessibilityAction}
171 onLayout={(event) => {
172 trackWidth.value = event.nativeEvent.layout.width;
173 }}
174 >
175 <View
176 className="w-full rounded-full"
177 style={{ backgroundColor: trackColor, height: TRACK_HEIGHT }}
178 >
179 <Animated.View
180 className="absolute inset-y-0 left-0 rounded-full"
181 style={[{ backgroundColor: fillColor }, fillStyle]}
182 />
183 </View>
184 <Animated.View
185 className="absolute left-0 rounded-full bg-white"
186 style={[
187 {
188 borderColor: "rgba(0, 0, 0, 0.06)",
189 borderWidth: 1,
190 height: THUMB_SIZE,
191 marginTop: -THUMB_SIZE / 2,
192 shadowColor: "#000000",
193 shadowOffset: { height: 2, width: 0 },
194 shadowOpacity: 0.18,
195 shadowRadius: 3,
196 top: "50%",
197 width: THUMB_SIZE,
198 },
199 thumbStyle,
200 ]}
201 />
202 </View>
203 </GestureDetector>
204 <SymbolView
205 name="textformat.size.larger"
206 size={22}
207 tintColor={iconMuted}
208 type="monochrome"
209 weight="regular"
210 />
211 </View>
212 </View>
213 );
214}
apps/mobile/src/features/settings/appearance/sections/CodeAppearanceSection.tsx+64−0
@@ -0,0 +1,64 @@
1import { useCallback } from "react";
2
3import {
4 CODE_FONT_SIZE_STEP,
5 MAX_CODE_FONT_SIZE,
6 MIN_CODE_FONT_SIZE,
7} from "../../../../lib/appearancePreferences";
8import { SettingsSection } from "../../components/SettingsSection";
9import { SettingsSwitchRow } from "../../components/SettingsSwitchRow";
10import { useAppearancePreferences } from "../AppearancePreferencesProvider";
11import {
12 AppearancePreviewSeparator,
13 CodeAppearancePreview,
14} from "../components/AppearancePreviews";
15import { FontSizeSliderRow } from "../components/FontSizeSliderRow";
16
17export function CodeAppearanceSection() {
18 const { isReady, appearance, setCodeFontSize, setCodeWordBreak } = useAppearancePreferences();
19 const custom = appearance.isCodeFontSizeCustom;
20
21 const handleToggleCustom = useCallback(
22 (enabled: boolean) => {
23 setCodeFontSize(enabled ? appearance.codeFontSize : null);
24 },
25 [appearance.codeFontSize, setCodeFontSize],
26 );
27
28 return (
29 <SettingsSection title="Code & Diffs">
30 <CodeAppearancePreview
31 fontSize={appearance.codeFontSize}
32 wordBreak={appearance.codeWordBreak}
33 />
34 <AppearancePreviewSeparator />
35 <SettingsSwitchRow
36 disabled={!isReady}
37 icon="chevron.left.forwardslash.chevron.right"
38 label="Custom font size"
39 onValueChange={handleToggleCustom}
40 value={custom}
41 />
42 {custom ? (
43 <FontSizeSliderRow
44 disabled={!isReady}
45 icon="textformat.size"
46 label="Font size"
47 max={MAX_CODE_FONT_SIZE}
48 min={MIN_CODE_FONT_SIZE}
49 onChange={setCodeFontSize}
50 step={CODE_FONT_SIZE_STEP}
51 value={appearance.codeFontSize}
52 valueLabel={`${appearance.codeFontSize} pt`}
53 />
54 ) : null}
55 <SettingsSwitchRow
56 disabled={!isReady}
57 icon="text.word.spacing"
58 label="Word break"
59 onValueChange={setCodeWordBreak}
60 value={appearance.codeWordBreak}
61 />
62 </SettingsSection>
63 );
64}
apps/mobile/src/features/settings/appearance/sections/TerminalAppearanceSection.tsx+54−0
@@ -0,0 +1,54 @@
1import { useCallback } from "react";
2
3import {
4 MAX_TERMINAL_FONT_SIZE,
5 MIN_TERMINAL_FONT_SIZE,
6 TERMINAL_FONT_SIZE_STEP,
7} from "../../../../lib/appearancePreferences";
8import { SettingsSection } from "../../components/SettingsSection";
9import { SettingsSwitchRow } from "../../components/SettingsSwitchRow";
10import { useAppearancePreferences } from "../AppearancePreferencesProvider";
11import {
12 AppearancePreviewSeparator,
13 TerminalAppearancePreview,
14} from "../components/AppearancePreviews";
15import { FontSizeSliderRow } from "../components/FontSizeSliderRow";
16
17export function TerminalAppearanceSection() {
18 const { isReady, appearance, setTerminalFontSize } = useAppearancePreferences();
19 const custom = appearance.isTerminalFontSizeCustom;
20
21 const handleToggleCustom = useCallback(
22 (enabled: boolean) => {
23 setTerminalFontSize(enabled ? appearance.terminalFontSize : null);
24 },
25 [appearance.terminalFontSize, setTerminalFontSize],
26 );
27
28 return (
29 <SettingsSection title="Terminal">
30 <TerminalAppearancePreview fontSize={appearance.terminalFontSize} />
31 <AppearancePreviewSeparator />
32 <SettingsSwitchRow
33 disabled={!isReady}
34 icon="terminal"
35 label="Custom font size"
36 onValueChange={handleToggleCustom}
37 value={custom}
38 />
39 {custom ? (
40 <FontSizeSliderRow
41 disabled={!isReady}
42 icon="textformat.size"
43 label="Font size"
44 max={MAX_TERMINAL_FONT_SIZE}
45 min={MIN_TERMINAL_FONT_SIZE}
46 onChange={setTerminalFontSize}
47 step={TERMINAL_FONT_SIZE_STEP}
48 value={appearance.terminalFontSize}
49 valueLabel={`${appearance.terminalFontSize.toFixed(1)} pt`}
50 />
51 ) : null}
52 </SettingsSection>
53 );
54}
apps/mobile/src/features/settings/appearance/sections/TextAppearanceSection.tsx+34−0
@@ -0,0 +1,34 @@
1import {
2 BASE_FONT_SIZE_STEP,
3 MAX_BASE_FONT_SIZE,
4 MIN_BASE_FONT_SIZE,
5} from "../../../../lib/appearancePreferences";
6import { SettingsSection } from "../../components/SettingsSection";
7import { useAppearancePreferences } from "../AppearancePreferencesProvider";
8import {
9 AppearancePreviewSeparator,
10 TextAppearancePreview,
11} from "../components/AppearancePreviews";
12import { FontSizeSliderRow } from "../components/FontSizeSliderRow";
13
14export function TextAppearanceSection() {
15 const { isReady, appearance, setBaseFontSize } = useAppearancePreferences();
16
17 return (
18 <SettingsSection title="Text">
19 <TextAppearancePreview fontSize={appearance.baseFontSize} />
20 <AppearancePreviewSeparator />
21 <FontSizeSliderRow
22 disabled={!isReady}
23 icon="textformat.size"
24 label="Text size"
25 max={MAX_BASE_FONT_SIZE}
26 min={MIN_BASE_FONT_SIZE}
27 onChange={setBaseFontSize}
28 step={BASE_FONT_SIZE_STEP}
29 value={appearance.baseFontSize}
30 valueLabel={`${appearance.baseFontSize} pt`}
31 />
32 </SettingsSection>
33 );
34}
apps/mobile/src/features/settings/appearance/useAppearanceCodeSurface.ts+34−0
@@ -0,0 +1,34 @@
1import { useMemo } from "react";
2
3import {
4 resolveMobileCodeSurface,
5 type ResolvedMobileCodeSurface,
6} from "../../../lib/appearancePreferences";
7import { createNativeReviewDiffStyle } from "../../review/nativeReviewDiffAdapter";
8import { createNativeSourceStyle } from "../../files/nativeSourceFileAdapter";
9import { useAppearancePreferences } from "./AppearancePreferencesProvider";
10
11export function useAppearanceCodeSurface(): {
12 readonly codeSurface: ResolvedMobileCodeSurface;
13 readonly codeWordBreak: boolean;
14 readonly nativeSourceStyle: ReturnType<typeof createNativeSourceStyle>;
15 readonly nativeReviewDiffStyle: ReturnType<typeof createNativeReviewDiffStyle>;
16} {
17 const { appearance } = useAppearancePreferences();
18 const codeSurface = useMemo(
19 () => resolveMobileCodeSurface(appearance.codeFontSize),
20 [appearance.codeFontSize],
21 );
22 const nativeSourceStyle = useMemo(() => createNativeSourceStyle(codeSurface), [codeSurface]);
23 const nativeReviewDiffStyle = useMemo(
24 () => createNativeReviewDiffStyle(codeSurface),
25 [codeSurface],
26 );
27
28 return {
29 codeSurface,
30 codeWordBreak: appearance.codeWordBreak,
31 nativeSourceStyle,
32 nativeReviewDiffStyle,
33 };
34}
apps/mobile/src/features/settings/appearance/useScaledTextRole.ts+36−0
@@ -0,0 +1,36 @@
1import { useCSSVariable } from "uniwind";
2
3import { MOBILE_TYPOGRAPHY } from "../../../lib/typography";
4
5const TEXT_ROLE_VARIABLES = {
6 micro: "--text-3xs",
7 caption: "--text-2xs",
8 label: "--text-xs",
9 footnote: "--text-sm",
10 body: "--text-base",
11 headline: "--text-lg",
12 title: "--text-xl",
13 largeTitle: "--text-2xl",
14 display: "--text-3xl",
15} as const satisfies Record<keyof typeof MOBILE_TYPOGRAPHY, string>;
16
17export interface ScaledTextRole {
18 readonly fontSize: number;
19 readonly lineHeight: number;
20}
21
22/**
23 * Reads a typography role's current size from the Uniwind `--text-*` CSS
24 * variables (scaled at runtime with the base font size). Use for style-prop
25 * consumers that can't express their size as a `text-*` className. Reactive:
26 * re-renders when the appearance provider re-injects the variables.
27 */
28export function useScaledTextRole(role: keyof typeof MOBILE_TYPOGRAPHY): ScaledTextRole {
29 const variable = TEXT_ROLE_VARIABLES[role];
30 const [fontSize, lineHeight] = useCSSVariable([variable, `${variable}--line-height`]);
31
32 return {
33 fontSize: typeof fontSize === "number" ? fontSize : MOBILE_TYPOGRAPHY[role].fontSize,
34 lineHeight: typeof lineHeight === "number" ? lineHeight : MOBILE_TYPOGRAPHY[role].lineHeight,
35 };
36}
apps/mobile/src/features/settings/components/SettingsRow.tsx+76−0
@@ -0,0 +1,76 @@
1import { useNavigation } from "@react-navigation/native";
2import { SymbolView } from "expo-symbols";
3import type { ComponentProps } from "react";
4import { Pressable, View } from "react-native";
5
6import { AppText as Text } from "../../../components/AppText";
7import { useThemeColor } from "../../../lib/useThemeColor";
8import type { SettingsSheetTarget } from "./settings-sheet-targets";
9
10type SymbolName = ComponentProps<typeof SymbolView>["name"];
11
12export function SettingsRow(props: {
13 readonly disabled?: boolean;
14 readonly icon: SymbolName;
15 readonly label: string;
16 readonly value?: string;
17 readonly target?: SettingsSheetTarget;
18 readonly onPress?: () => void;
19}) {
20 const navigation = useNavigation();
21 const icon = useThemeColor("--color-icon");
22 const chevron = useThemeColor("--color-chevron");
23 const content = (
24 <View
25 className="flex-row items-center gap-4 p-4"
26 style={{ opacity: props.disabled ? 0.45 : 1 }}
27 >
28 <SymbolView name={props.icon} size={22} tintColor={icon} type="monochrome" weight="regular" />
29 <Text className="shrink-0 text-lg text-foreground" numberOfLines={1}>
30 {props.label}
31 </Text>
32 <View className="min-w-0 flex-1 items-end">
33 {props.value ? (
34 <Text
35 className="max-w-[180px] text-right text-base text-foreground-muted"
36 ellipsizeMode="middle"
37 numberOfLines={1}
38 >
39 {props.value}
40 </Text>
41 ) : null}
42 </View>
43 <SymbolView
44 name="chevron.right"
45 size={16}
46 tintColor={chevron}
47 type="monochrome"
48 weight="semibold"
49 />
50 </View>
51 );
52
53 const target = props.target;
54 if (target) {
55 return (
56 <Pressable
57 accessibilityLabel={props.label}
58 accessibilityRole="button"
59 disabled={props.disabled}
60 onPress={() =>
61 navigation.navigate("SettingsSheet", {
62 screen: target,
63 })
64 }
65 >
66 {content}
67 </Pressable>
68 );
69 }
70
71 return (
72 <Pressable accessibilityRole="button" disabled={props.disabled} onPress={props.onPress}>
73 {content}
74 </Pressable>
75 );
76}
apps/mobile/src/features/settings/components/SettingsSection.tsx+18−0
@@ -0,0 +1,18 @@
1import type { ReactNode } from "react";
2import { View } from "react-native";
3
4import { AppText as Text } from "../../../components/AppText";
5
6export function SettingsSection(props: { readonly title: string; readonly children: ReactNode }) {
7 return (
8 <View className="gap-2">
9 <Text className="px-2 text-sm font-t3-medium text-foreground-muted">{props.title}</Text>
10 <View
11 className="overflow-hidden rounded-[28px] bg-card"
12 style={{ borderCurve: "continuous" }}
13 >
14 {props.children}
15 </View>
16 </View>
17 );
18}
apps/mobile/src/features/settings/components/SettingsSwitchRow.tsx+37−0
@@ -0,0 +1,37 @@
1import { SymbolView } from "expo-symbols";
2import type { ComponentProps } from "react";
3import { Switch, View } from "react-native";
4
5import { AppText as Text } from "../../../components/AppText";
6import { useThemeColor } from "../../../lib/useThemeColor";
7
8type SymbolName = ComponentProps<typeof SymbolView>["name"];
9
10export function SettingsSwitchRow(props: {
11 readonly disabled?: boolean;
12 readonly icon: SymbolName;
13 readonly label: string;
14 readonly value: boolean;
15 readonly onValueChange: (value: boolean) => void;
16}) {
17 const icon = useThemeColor("--color-icon");
18 const activeTrack = String(useThemeColor("--color-switch-active"));
19 const track = String(useThemeColor("--color-secondary-border"));
20
21 return (
22 <View
23 className="flex-row items-center gap-4 p-4"
24 style={{ opacity: props.disabled ? 0.45 : 1 }}
25 >
26 <SymbolView name={props.icon} size={22} tintColor={icon} type="monochrome" weight="regular" />
27 <Text className="flex-1 text-lg text-foreground">{props.label}</Text>
28 <Switch
29 disabled={props.disabled}
30 ios_backgroundColor={track}
31 onValueChange={props.onValueChange}
32 trackColor={{ false: track, true: activeTrack }}
33 value={props.value}
34 />
35 </View>
36 );
37}
apps/mobile/src/features/settings/components/settings-sheet-targets.ts+1−0
@@ -0,0 +1 @@
1export type SettingsSheetTarget = "SettingsEnvironments" | "SettingsArchive" | "SettingsAppearance";
apps/mobile/src/features/terminal/NativeTerminalSurface.tsx+17−44
@@ -12,7 +12,10 @@ import { · 11 unmodified lines
12 12
13import { AppText as Text } from "../../components/AppText";13import { AppText as Text } from "../../components/AppText";
14import { MOBILE_TYPOGRAPHY } from "../../lib/typography";14import { MOBILE_TYPOGRAPHY } from "../../lib/typography";
15import { resolveNativeTerminalSurfaceView } from "./nativeTerminalModule";15import {
16 getNativeTerminalHardwareKeyRevision,
17 resolveNativeTerminalSurfaceView,
18} from "./nativeTerminalModule";
16import {19import {
17 buildGhosttyThemeConfig,20 buildGhosttyThemeConfig,
18 getPierreTerminalTheme,21 getPierreTerminalTheme,
@@ -92,9 +95,9 @@ const FallbackTerminalSurface = memo(function FallbackTerminalSurface(props: Ter · 73 unmodified lines
92 >95 >
93 <View style={{ flex: 1, paddingHorizontal: 10, paddingVertical: 8 }}>96 <View style={{ flex: 1, paddingHorizontal: 10, paddingVertical: 8 }}>
94 <Text97 <Text
98 className="text-2xs"
95 style={{99 style={{
96 color: theme.mutedForeground,100 color: theme.mutedForeground,
97 fontSize: MOBILE_TYPOGRAPHY.caption.fontSize,
98 paddingBottom: 8,101 paddingBottom: 8,
99 }}102 }}
100 >103 >
@@ -137,11 +140,11 @@ const FallbackTerminalSurface = memo(function FallbackTerminalSurface(props: Ter · 36 unmodified lines
137 placeholder="type and press return"140 placeholder="type and press return"
138 placeholderTextColor={theme.mutedForeground}141 placeholderTextColor={theme.mutedForeground}
139 returnKeyType="send"142 returnKeyType="send"
143 className="text-sm"
140 style={{144 style={{
141 color: theme.foreground,145 color: theme.foreground,
142 flex: 1,146 flex: 1,
143 fontFamily: "Menlo",147 fontFamily: "Menlo",
144 fontSize: MOBILE_TYPOGRAPHY.footnote.fontSize,
145 padding: 0,148 padding: 0,
146 }}149 }}
147 onSubmitEditing={(event) => {150 onSubmitEditing={(event) => {
@@ -163,10 +166,10 @@ const FallbackTerminalSurface = memo(function FallbackTerminalSurface(props: Ter · 15 unmodified lines
163 onPress={() => props.onInput("\u0003")}166 onPress={() => props.onInput("\u0003")}
164 >167 >
165 <Text168 <Text
169 className="text-2xs"
166 style={{170 style={{
167 color: theme.foreground,171 color: theme.foreground,
168 fontFamily: "DMSans_700Bold",172 fontFamily: "DMSans_700Bold",
169 fontSize: MOBILE_TYPOGRAPHY.caption.fontSize,
170 }}173 }}
171 >174 >
172 Ctrl-C175 Ctrl-C
@@ -179,7 +182,6 @@ const FallbackTerminalSurface = memo(function FallbackTerminalSurface(props: Ter · 6 unmodified lines
179 182
180export const TerminalSurface = memo(function TerminalSurface(props: TerminalSurfaceProps) {183export const TerminalSurface = memo(function TerminalSurface(props: TerminalSurfaceProps) {
181 const fontSize = props.fontSize ?? MOBILE_TYPOGRAPHY.label.fontSize;184 const fontSize = props.fontSize ?? MOBILE_TYPOGRAPHY.label.fontSize;
182 const keyboardInputRef = useRef<TextInput>(null);
183 const appearanceScheme = useColorScheme() === "light" ? "light" : "dark";185 const appearanceScheme = useColorScheme() === "light" ? "light" : "dark";
184 const theme = props.theme ?? getPierreTerminalTheme(appearanceScheme);186 const theme = props.theme ?? getPierreTerminalTheme(appearanceScheme);
185 const { onInput, onResize } = props;187 const { onInput, onResize } = props;
@@ -190,15 +192,23 @@ export const TerminalSurface = memo(function TerminalSurface(props: TerminalSurf · 4 unmodified lines
190 terminalDebugLog("native:surface", {192 terminalDebugLog("native:surface", {
191 terminalKey: props.terminalKey,193 terminalKey: props.terminalKey,
192 native: hasNativeSurface,194 native: hasNativeSurface,
195 // null = installed binary predates native hardware-key handling (rebuild needed).
196 hardwareKeyRevision: getNativeTerminalHardwareKeyRevision(),
193 bufferLen: props.buffer.length,197 bufferLen: props.buffer.length,
194 isRunning: props.isRunning,198 isRunning: props.isRunning,
195 });199 });
196 }, [hasNativeSurface, props.buffer.length, props.isRunning, props.terminalKey]);200 }, [hasNativeSurface, props.buffer.length, props.isRunning, props.terminalKey]);
197 const handleNativeInput = useCallback(201 const handleNativeInput = useCallback(
198 (event: NativeSyntheticEvent<TerminalInputEvent>) => {202 (event: NativeSyntheticEvent<TerminalInputEvent>) => {
203 if (!props.isRunning) {
204 return;
205 }
206 terminalDebugLog("native:onInput", {
207 codes: Array.from(event.nativeEvent.data, (char) => char.codePointAt(0)),
208 });
199 onInput(event.nativeEvent.data);209 onInput(event.nativeEvent.data);
200 },210 },
201 [onInput],211 [onInput, props.isRunning],
202 );212 );
203 const handleNativeResize = useCallback(213 const handleNativeResize = useCallback(
204 (event: NativeSyntheticEvent<TerminalResizeEvent>) => {214 (event: NativeSyntheticEvent<TerminalResizeEvent>) => {
@@ -210,33 +220,13 @@ export const TerminalSurface = memo(function TerminalSurface(props: TerminalSurf · 5 unmodified lines
210 [onResize],220 [onResize],
211 );221 );
212 222
213 // Reopen focus through React and forward input normally; this avoids a native focus-command bridge.
214 useEffect(() => {
215 if (!NativeTerminalSurfaceView || (props.keyboardFocusRequest ?? 0) <= 0) {
216 return undefined;
217 }
218
219 keyboardInputRef.current?.blur();
220 const focusFrame = requestAnimationFrame(() => keyboardInputRef.current?.focus());
221 return () => cancelAnimationFrame(focusFrame);
222 }, [NativeTerminalSurfaceView, props.keyboardFocusRequest]);
223
224 const handleKeyboardInput = useCallback(
225 (data: string) => {
226 if (data.length > 0) {
227 onInput(data);
228 keyboardInputRef.current?.clear();
229 }
230 },
231 [onInput],
232 );
233
234 if (NativeTerminalSurfaceView) {223 if (NativeTerminalSurfaceView) {
235 return (224 return (
236 <View style={props.style}>225 <View style={props.style}>
237 <NativeTerminalSurfaceView226 <NativeTerminalSurfaceView
238 appearanceScheme={appearanceScheme}227 appearanceScheme={appearanceScheme}
239 backgroundColor={theme.background}228 backgroundColor={theme.background}
229 focusRequest={props.isRunning ? (props.keyboardFocusRequest ?? 0) : 0}
240 foregroundColor={theme.foreground}230 foregroundColor={theme.foreground}
241 mutedForegroundColor={theme.mutedForeground}231 mutedForegroundColor={theme.mutedForeground}
242 terminalKey={props.terminalKey}232 terminalKey={props.terminalKey}
@@ -247,23 +237,6 @@ export const TerminalSurface = memo(function TerminalSurface(props: TerminalSurf · 4 unmodified lines
247 onInput={handleNativeInput}237 onInput={handleNativeInput}
248 onResize={handleNativeResize}238 onResize={handleNativeResize}
249 />239 />
250 <TextInput
251 ref={keyboardInputRef}
252 autoCapitalize="none"
253 autoCorrect={false}
254 blurOnSubmit={false}
255 caretHidden
256 editable={props.isRunning}
257 keyboardType="ascii-capable"
258 style={{ bottom: 0, height: 1, left: 0, opacity: 0.01, position: "absolute", width: 1 }}
259 onChangeText={handleKeyboardInput}
260 onKeyPress={(event) => {
261 if (event.nativeEvent.key === "Backspace") {
262 onInput("\u007f");
263 }
264 }}
265 onSubmitEditing={() => onInput("\n")}
266 />
267 </View>240 </View>
268 );241 );
269 }242 }
apps/mobile/src/features/terminal/ThreadTerminalRouteScreen.tsx+98−165
@@ -1,9 +1,10 @@
1import { DEFAULT_TERMINAL_ID, EnvironmentId, ThreadId } from "@t3tools/contracts";1import { DEFAULT_TERMINAL_ID, EnvironmentId, ThreadId } from "@t3tools/contracts";
2import { type KnownTerminalSession } from "@t3tools/client-runtime/state/terminal";2import { type KnownTerminalSession } from "@t3tools/client-runtime/state/terminal";
3import { SymbolView } from "expo-symbols";3import { SymbolView } from "expo-symbols";
4import { Stack, useLocalSearchParams, useRouter } from "expo-router";4import { NativeHeaderToolbar, NativeStackScreenOptions } from "../../native/StackHeader";
5import { StackActions, useNavigation, type StaticScreenProps } from "@react-navigation/native";
5import { useCallback, useEffect, useMemo, useRef, useState } from "react";6import { useCallback, useEffect, useMemo, useRef, useState } from "react";
6import { Pressable, Text as RNText, View, useColorScheme } from "react-native";7import { Platform, Pressable, View, useColorScheme } from "react-native";
7import {8import {
8 KeyboardController,9 KeyboardController,
9 KeyboardEvents,10 KeyboardEvents,
@@ -24,8 +25,13 @@ import { useEnvironmentPresentation } from "../../state/presentation"; · 14 unmodified lines
24import { terminalEnvironment } from "../../state/terminal";25import { terminalEnvironment } from "../../state/terminal";
25import { useAtomCommand } from "../../state/use-atom-command";26import { useAtomCommand } from "../../state/use-atom-command";
26import { useWorkspaceState } from "../../state/workspace";27import { useWorkspaceState } from "../../state/workspace";
27import { buildThreadTerminalNavigation } from "../../lib/routes";28import {
28import { MOBILE_TYPOGRAPHY } from "../../lib/typography";29 MAX_TERMINAL_FONT_SIZE,
30 MIN_TERMINAL_FONT_SIZE,
31 TERMINAL_FONT_SIZE_STEP,
32 stepTerminalFontSize,
33} from "../../lib/appearancePreferences";
34import { useAppearancePreferences } from "../settings/appearance/AppearancePreferencesProvider";
29import {35import {
30 useAttachedTerminalSession,36 useAttachedTerminalSession,
31 useKnownTerminalSessions,37 useKnownTerminalSessions,
@@ -33,9 +39,9 @@ import { · 1 unmodified line
33import { useThreadSelection } from "../../state/use-thread-selection";39import { useThreadSelection } from "../../state/use-thread-selection";
34import { useSelectedThreadDetail } from "../../state/use-thread-detail";40import { useSelectedThreadDetail } from "../../state/use-thread-detail";
35import { EnvironmentConnectionNotice } from "../connection/EnvironmentConnectionNotice";41import { EnvironmentConnectionNotice } from "../connection/EnvironmentConnectionNotice";
42import { useAdaptiveWorkspaceLayout } from "../layout/AdaptiveWorkspaceLayout";
36import { TerminalSurface } from "./NativeTerminalSurface";43import { TerminalSurface } from "./NativeTerminalSurface";
37import { getPierreTerminalTheme } from "./terminalTheme";44import { getPierreTerminalTheme } from "./terminalTheme";
38import { loadPreferences, savePreferencesPatch } from "../../lib/storage";
39import { terminalDebugLog } from "./terminalDebugLog";45import { terminalDebugLog } from "./terminalDebugLog";
40import {46import {
41 getTerminalBufferReplayKey,47 getTerminalBufferReplayKey,
@@ -55,19 +61,7 @@ import { · 13 unmodified lines
55 resolveTerminalSessionLabel,61 resolveTerminalSessionLabel,
56 type TerminalMenuSession,62 type TerminalMenuSession,
57} from "./terminalMenu";63} from "./terminalMenu";
58import {64import { cacheTerminalGridSize, getCachedTerminalGridSize } from "./terminalUiState";
59 DEFAULT_TERMINAL_FONT_SIZE,
60 MAX_TERMINAL_FONT_SIZE,
61 MIN_TERMINAL_FONT_SIZE,
62 TERMINAL_FONT_SIZE_STEP,
63 normalizeTerminalFontSize,
64} from "./terminalPreferences";
65import {
66 cacheTerminalFontSize,
67 cacheTerminalGridSize,
68 getCachedTerminalFontSize,
69 getCachedTerminalGridSize,
70} from "./terminalUiState";
71 65
72const DEFAULT_TERMINAL_COLS = 80;66const DEFAULT_TERMINAL_COLS = 80;
73const DEFAULT_TERMINAL_ROWS = 24;67const DEFAULT_TERMINAL_ROWS = 24;
@@ -153,19 +147,22 @@ function pickRunningTerminalSessionForBootstrap( · 79 unmodified lines
153 );147 );
154}148}
155 149
156export function ThreadTerminalRouteScreen() {150type ThreadTerminalRouteScreenProps = StaticScreenProps<{
157 const router = useRouter();151 readonly environmentId: string;
152 readonly threadId: string;
153 readonly terminalId?: string;
154}>;
155
156export function ThreadTerminalRouteScreen(props: ThreadTerminalRouteScreenProps) {
157 const navigation = useNavigation();
158 const writeTerminal = useAtomCommand(terminalEnvironment.write, "terminal write");158 const writeTerminal = useAtomCommand(terminalEnvironment.write, "terminal write");
159 const resizeTerminal = useAtomCommand(terminalEnvironment.resize, "terminal resize");159 const resizeTerminal = useAtomCommand(terminalEnvironment.resize, "terminal resize");
160 const clearTerminal = useAtomCommand(terminalEnvironment.clear, "terminal clear");160 const clearTerminal = useAtomCommand(terminalEnvironment.clear, "terminal clear");
161 const retryEnvironment = useAtomCommand(environmentCatalog.retryNow, "environment retry");161 const retryEnvironment = useAtomCommand(environmentCatalog.retryNow, "environment retry");
162 const appearanceScheme = useColorScheme() === "light" ? "light" : "dark";162 const appearanceScheme = useColorScheme() === "light" ? "light" : "dark";
163 const { state: workspaceState } = useWorkspaceState();163 const { state: workspaceState } = useWorkspaceState();
164 const params = useLocalSearchParams<{164 const { layout, panes, togglePrimarySidebar } = useAdaptiveWorkspaceLayout();
165 environmentId?: string | string[];165 const params = props.route.params;
166 threadId?: string | string[];
167 terminalId?: string | string[];
168 }>();
169 const { selectedThread, selectedThreadProject, selectedEnvironmentConnection } =166 const { selectedThread, selectedThreadProject, selectedEnvironmentConnection } =
170 useThreadSelection();167 useThreadSelection();
171 const selectedThreadDetail = useSelectedThreadDetail();168 const selectedThreadDetail = useSelectedThreadDetail();
@@ -179,7 +176,12 @@ export function ThreadTerminalRouteScreen() { · 7 unmodified lines
179 const isEnvironmentReady = environment.presentation?.connection.phase === "connected";176 const isEnvironmentReady = environment.presentation?.connection.phase === "connected";
180 const requestedTerminalId = firstRouteParam(params.terminalId);177 const requestedTerminalId = firstRouteParam(params.terminalId);
181 const terminalId = requestedTerminalId ?? DEFAULT_TERMINAL_ID;178 const terminalId = requestedTerminalId ?? DEFAULT_TERMINAL_ID;
182 const cachedFontSize = getCachedTerminalFontSize();179 const {
180 isReady: hasResolvedFontPreference,
181 appearance,
182 setTerminalFontSize,
183 } = useAppearancePreferences();
184 const fontSize = appearance.terminalFontSize;
183 const cachedRouteGridSize =185 const cachedRouteGridSize =
184 routeEnvironmentId && routeThreadId186 routeEnvironmentId && routeThreadId
185 ? getCachedTerminalGridSize({187 ? getCachedTerminalGridSize({
@@ -239,7 +241,6 @@ export function ThreadTerminalRouteScreen() { · 53 unmodified lines
239 rows: DEFAULT_TERMINAL_ROWS,241 rows: DEFAULT_TERMINAL_ROWS,
240 },242 },
241 );243 );
242 const [fontSize, setFontSize] = useState(cachedFontSize ?? DEFAULT_TERMINAL_FONT_SIZE);
243 const [keyboardFocusRequest, setKeyboardFocusRequest] = useState(0);244 const [keyboardFocusRequest, setKeyboardFocusRequest] = useState(0);
244 const [isAccessoryDismissed, setIsAccessoryDismissed] = useState(false);245 const [isAccessoryDismissed, setIsAccessoryDismissed] = useState(false);
245 const bufferReplayTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);246 const bufferReplayTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
@@ -247,9 +248,6 @@ export function ThreadTerminalRouteScreen() { · 1 unmodified line
247 const lastBufferReplayKeyRef = useRef<string | null>(null);248 const lastBufferReplayKeyRef = useRef<string | null>(null);
248 const sentInitialInputKeyRef = useRef<string | null>(null);249 const sentInitialInputKeyRef = useRef<string | null>(null);
249 const [readyBufferReplayKey, setReadyBufferReplayKey] = useState<string | null>(null);250 const [readyBufferReplayKey, setReadyBufferReplayKey] = useState<string | null>(null);
250 const [hasResolvedFontPreference, setHasResolvedFontPreference] = useState(
251 cachedFontSize !== null,
252 );
253 /** Default grid is always valid for attach; onResize refines cols/rows. Requiring a cached size blocked bootstrap for new terminal routes. */251 /** Default grid is always valid for attach; onResize refines cols/rows. Requiring a cached size blocked bootstrap for new terminal routes. */
254 const [hasMeasuredSurface, setHasMeasuredSurface] = useState(true);252 const [hasMeasuredSurface, setHasMeasuredSurface] = useState(true);
255 const [pendingModifierState, setPendingModifierState] = useState<{253 const [pendingModifierState, setPendingModifierState] = useState<{
@@ -404,24 +402,10 @@ export function ThreadTerminalRouteScreen() { · 148 unmodified lines
404 );402 );
405 403
406 const terminalTheme = getPierreTerminalTheme(appearanceScheme);404 const terminalTheme = getPierreTerminalTheme(appearanceScheme);
405 const usesNativeHeaderGlass = Platform.OS === "ios";
407 const pendingModifier =406 const pendingModifier =
408 pendingModifierState.terminalId === terminalId ? pendingModifierState.value : null;407 pendingModifierState.terminalId === terminalId ? pendingModifierState.value : null;
409 const headerTitle = useMemo(() => {408 const headerSubtitle = selectedThreadProject?.title ?? "";
410 const topLineParts = [
411 selectedEnvironmentConnection?.environmentLabel ?? null,
412 selectedThreadProject?.title ?? null,
413 ].filter((value): value is string => Boolean(value));
414
415 return {
416 topLine: topLineParts.join(" \u00b7 "),
417 bottomLine: cwd ?? selectedThreadProject?.workspaceRoot ?? "",
418 };
419 }, [
420 cwd,
421 selectedEnvironmentConnection?.environmentLabel,
422 selectedThreadProject?.title,
423 selectedThreadProject?.workspaceRoot,
424 ]);
425 const terminalToolbarActions = useMemo<ReadonlyArray<TerminalToolbarAction>>(() => {409 const terminalToolbarActions = useMemo<ReadonlyArray<TerminalToolbarAction>>(() => {
426 const modifierActions: ReadonlyArray<TerminalToolbarAction> =410 const modifierActions: ReadonlyArray<TerminalToolbarAction> =
427 hostPlatform === "mac"411 hostPlatform === "mac"
@@ -546,8 +530,14 @@ export function ThreadTerminalRouteScreen() { · 118 unmodified lines
546 if (!shouldRedirectToRunningTerminal || !selectedThread || !runningSession) {530 if (!shouldRedirectToRunningTerminal || !selectedThread || !runningSession) {
547 return;531 return;
548 }532 }
549 router.replace(buildThreadTerminalNavigation(selectedThread, runningSession.target.terminalId));533 navigation.dispatch(
550 }, [router, runningSession, selectedThread, shouldRedirectToRunningTerminal]);534 StackActions.replace("ThreadTerminal", {
535 environmentId: String(selectedThread.environmentId),
536 threadId: String(selectedThread.id),
537 terminalId: runningSession.target.terminalId,
538 }),
539 );
540 }, [navigation, runningSession, selectedThread, shouldRedirectToRunningTerminal]);
551 541
552 useEffect(() => {542 useEffect(() => {
553 const initialInput = pendingLaunch?.initialInput;543 const initialInput = pendingLaunch?.initialInput;
@@ -637,42 +627,6 @@ export function ThreadTerminalRouteScreen() { · 83 unmodified lines
637 setHasMeasuredSurface(true);627 setHasMeasuredSurface(true);
638 }, [routeEnvironmentId, routeThreadId, terminalId]);628 }, [routeEnvironmentId, routeThreadId, terminalId]);
639 629
640 useEffect(() => {
641 let cancelled = false;
642
643 void loadPreferences()
644 .then((preferences) => {
645 if (cancelled) {
646 return;
647 }
648
649 setFontSize(cacheTerminalFontSize(preferences.terminalFontSize));
650 setHasResolvedFontPreference(true);
651 })
652 .catch(() => {
653 if (cancelled) {
654 return;
655 }
656
657 setHasResolvedFontPreference(true);
658 });
659
660 return () => {
661 cancelled = true;
662 };
663 }, []);
664
665 useEffect(() => {
666 if (!hasResolvedFontPreference) {
667 return;
668 }
669
670 cacheTerminalFontSize(fontSize);
671 void savePreferencesPatch({
672 terminalFontSize: normalizeTerminalFontSize(fontSize),
673 });
674 }, [fontSize, hasResolvedFontPreference]);
675
676 const writeInput = useCallback(630 const writeInput = useCallback(
677 (data: string) => {631 (data: string) => {
678 if (!selectedThread || !isRunning) {632 if (!selectedThread || !isRunning) {
@@ -772,9 +726,15 @@ export function ThreadTerminalRouteScreen() { · 93 unmodified lines
772 return;726 return;
773 }727 }
774 728
775 router.replace(buildThreadTerminalNavigation(selectedThread, nextTerminalId));729 navigation.dispatch(
730 StackActions.replace("ThreadTerminal", {
731 environmentId: String(selectedThread.environmentId),
732 threadId: String(selectedThread.id),
733 terminalId: nextTerminalId,
734 }),
735 );
776 },736 },
777 [router, selectedThread, terminalId],737 [navigation, selectedThread, terminalId],
778 );738 );
779 739
780 const handleOpenNewTerminal = useCallback(() => {740 const handleOpenNewTerminal = useCallback(() => {
@@ -782,30 +742,25 @@ export function ThreadTerminalRouteScreen() { · 1 unmodified line
782 return;742 return;
783 }743 }
784 744
785 router.replace(745 navigation.dispatch(
786 buildThreadTerminalNavigation(746 StackActions.replace("ThreadTerminal", {
787 selectedThread,747 environmentId: String(selectedThread.environmentId),
788 nextOpenTerminalId({748 threadId: String(selectedThread.id),
749 terminalId: nextOpenTerminalId({
789 listedTerminalIds: terminalMenuSessions.map((session) => session.terminalId),750 listedTerminalIds: terminalMenuSessions.map((session) => session.terminalId),
790 activeRouteTerminalId: terminalId,751 activeRouteTerminalId: terminalId,
791 }),752 }),
792 ),753 }),
793 );754 );
794 }, [router, selectedThread, terminalId, terminalMenuSessions]);755 }, [navigation, selectedThread, terminalId, terminalMenuSessions]);
795
796 const adjustFontSize = useCallback((delta: number) => {
797 setTimeout(() => {
798 setFontSize((current) => cacheTerminalFontSize(current + delta));
799 }, 0);
800 }, []);
801 756
802 const handleDecreaseFontSize = useCallback(() => {757 const handleDecreaseFontSize = useCallback(() => {
803 adjustFontSize(-TERMINAL_FONT_SIZE_STEP);758 setTerminalFontSize(stepTerminalFontSize(fontSize, -1));
804 }, [adjustFontSize]);759 }, [fontSize, setTerminalFontSize]);
805 760
806 const handleIncreaseFontSize = useCallback(() => {761 const handleIncreaseFontSize = useCallback(() => {
807 adjustFontSize(TERMINAL_FONT_SIZE_STEP);762 setTerminalFontSize(stepTerminalFontSize(fontSize, 1));
808 }, [adjustFontSize]);763 }, [fontSize, setTerminalFontSize]);
809 764
810 const handleClearTerminal = useCallback(() => {765 const handleClearTerminal = useCallback(() => {
811 if (!selectedThread) {766 if (!selectedThread) {
@@ -898,80 +853,58 @@ export function ThreadTerminalRouteScreen() { · 86 unmodified lines
898 853
899 return (854 return (
900 <>855 <>
901 <Stack.Screen856 <NativeStackScreenOptions
902 options={{857 options={{
903 headerShown: true,858 // Static header config lives in Stack.tsx (SOLID_HEADER_OPTIONS — the pty
904 headerBackButtonDisplayMode: "minimal",859 // scrolls internally, nothing for glass to sample). Default title/subtitle
905 headerBackTitle: "",860 // styling, like every other page.
906 headerShadowVisible: false,861 title: "Terminal",
907 headerStyle: { backgroundColor: terminalTheme.background },862 unstable_headerSubtitle:
908 headerTintColor: terminalTheme.foreground,863 usesNativeHeaderGlass && headerSubtitle.length > 0 ? headerSubtitle : undefined,
909 headerTitleAlign: "center",
910 title: "",
911 headerTitle: () => (
912 <View
913 style={{
914 alignItems: "center",
915 gap: 1,
916 maxWidth: 240,
917 }}
918 >
919 <RNText
920 numberOfLines={1}
921 style={{
922 color: terminalTheme.foreground,
923 fontFamily: "DMSans_700Bold",
924 fontSize: MOBILE_TYPOGRAPHY.footnote.fontSize,
925 lineHeight: 16,
926 }}
927 >
928 {headerTitle.topLine}
929 </RNText>
930 <RNText
931 ellipsizeMode="middle"
932 numberOfLines={1}
933 style={{
934 color: terminalTheme.mutedForeground,
935 fontFamily: "Menlo",
936 fontSize: MOBILE_TYPOGRAPHY.caption.fontSize,
937 lineHeight: 14,
938 }}
939 >
940 {headerTitle.bottomLine}
941 </RNText>
942 </View>
943 ),
944 }}864 }}
945 />865 />
946 866
867 {layout.usesSplitView ? (
868 <NativeHeaderToolbar placement="left">
869 <NativeHeaderToolbar.Button
870 accessibilityLabel={panes.primarySidebarVisible ? "Maximize terminal" : "Show threads"}
871 icon={
872 panes.primarySidebarVisible ? "arrow.up.left.and.arrow.down.right" : "sidebar.left"
873 }
874 onPress={togglePrimarySidebar}
875 separateBackground
876 />
877 </NativeHeaderToolbar>
878 ) : null}
879
947 {isEnvironmentReady ? (880 {isEnvironmentReady ? (
948 <Stack.Toolbar placement="right">881 <NativeHeaderToolbar placement="right">
949 <Stack.Toolbar.Menu icon="terminal" title="Terminal options" separateBackground>882 <NativeHeaderToolbar.Menu icon="terminal" title="Terminal options" separateBackground>
950 <Stack.Toolbar.Label>883 <NativeHeaderToolbar.Label>
951 {getTerminalStatusLabel({884 {getTerminalStatusLabel({
952 status: terminal.status,885 status: terminal.status,
953 hasRunningSubprocess: terminal.hasRunningSubprocess,886 hasRunningSubprocess: terminal.hasRunningSubprocess,
954 })}887 })}
955 </Stack.Toolbar.Label>888 </NativeHeaderToolbar.Label>
956 <Stack.Toolbar.Menu icon="textformat.size" inline title="Text size">889 <NativeHeaderToolbar.Menu icon="textformat.size" inline title="Text size">
957 <Stack.Toolbar.Label>Text size</Stack.Toolbar.Label>890 <NativeHeaderToolbar.Label>Text size</NativeHeaderToolbar.Label>
958 <Stack.Toolbar.MenuAction891 <NativeHeaderToolbar.MenuAction
959 disabled={fontSize <= MIN_TERMINAL_FONT_SIZE}892 disabled={fontSize <= MIN_TERMINAL_FONT_SIZE}
960 discoverabilityLabel="Decrease terminal text size"893 discoverabilityLabel="Decrease terminal text size"
961 onPress={handleDecreaseFontSize}894 onPress={handleDecreaseFontSize}
962 >895 >
963 <Stack.Toolbar.Label>{`A- ${Math.max(MIN_TERMINAL_FONT_SIZE, fontSize - TERMINAL_FONT_SIZE_STEP).toFixed(1)} pt`}</Stack.Toolbar.Label>896 <NativeHeaderToolbar.Label>{`A- ${Math.max(MIN_TERMINAL_FONT_SIZE, fontSize - TERMINAL_FONT_SIZE_STEP).toFixed(1)} pt`}</NativeHeaderToolbar.Label>
964 </Stack.Toolbar.MenuAction>897 </NativeHeaderToolbar.MenuAction>
965 <Stack.Toolbar.MenuAction898 <NativeHeaderToolbar.MenuAction
966 disabled={fontSize >= MAX_TERMINAL_FONT_SIZE}899 disabled={fontSize >= MAX_TERMINAL_FONT_SIZE}
967 discoverabilityLabel="Increase terminal text size"900 discoverabilityLabel="Increase terminal text size"
968 onPress={handleIncreaseFontSize}901 onPress={handleIncreaseFontSize}
969 >902 >
970 <Stack.Toolbar.Label>{`A+ ${Math.min(MAX_TERMINAL_FONT_SIZE, fontSize + TERMINAL_FONT_SIZE_STEP).toFixed(1)} pt`}</Stack.Toolbar.Label>903 <NativeHeaderToolbar.Label>{`A+ ${Math.min(MAX_TERMINAL_FONT_SIZE, fontSize + TERMINAL_FONT_SIZE_STEP).toFixed(1)} pt`}</NativeHeaderToolbar.Label>
971 </Stack.Toolbar.MenuAction>904 </NativeHeaderToolbar.MenuAction>
972 </Stack.Toolbar.Menu>905 </NativeHeaderToolbar.Menu>
973 {terminalMenuSessions.map((session) => (906 {terminalMenuSessions.map((session) => (
974 <Stack.Toolbar.MenuAction907 <NativeHeaderToolbar.MenuAction
975 key={session.terminalId}908 key={session.terminalId}
976 icon={session.terminalId === terminalId ? "checkmark" : "terminal"}909 icon={session.terminalId === terminalId ? "checkmark" : "terminal"}
977 onPress={() => handleSelectTerminal(session.terminalId)}910 onPress={() => handleSelectTerminal(session.terminalId)}
@@ -982,18 +915,18 @@ export function ThreadTerminalRouteScreen() { · 4 unmodified lines
982 .filter(Boolean)915 .filter(Boolean)
983 .join(" · ")}916 .join(" · ")}
984 >917 >
985 <Stack.Toolbar.Label>{session.displayLabel}</Stack.Toolbar.Label>918 <NativeHeaderToolbar.Label>{session.displayLabel}</NativeHeaderToolbar.Label>
986 </Stack.Toolbar.MenuAction>919 </NativeHeaderToolbar.MenuAction>
987 ))}920 ))}
988 <Stack.Toolbar.MenuAction921 <NativeHeaderToolbar.MenuAction
989 icon="plus"922 icon="plus"
990 onPress={handleOpenNewTerminal}923 onPress={handleOpenNewTerminal}
991 subtitle={`Start another shell in ${basename(selectedThreadProject.workspaceRoot) ?? "this workspace"}`}924 subtitle={`Start another shell in ${basename(selectedThreadProject.workspaceRoot) ?? "this workspace"}`}
992 >925 >
993 <Stack.Toolbar.Label>Open new terminal</Stack.Toolbar.Label>926 <NativeHeaderToolbar.Label>Open new terminal</NativeHeaderToolbar.Label>
994 </Stack.Toolbar.MenuAction>927 </NativeHeaderToolbar.MenuAction>
995 </Stack.Toolbar.Menu>928 </NativeHeaderToolbar.Menu>
996 </Stack.Toolbar>929 </NativeHeaderToolbar>
997 ) : null}930 ) : null}
998 931
999 <View style={{ flex: 1, backgroundColor: terminalTheme.background }}>932 <View style={{ flex: 1, backgroundColor: terminalTheme.background }}>
apps/mobile/src/features/terminal/nativeTerminalModule.ts+21−1
@@ -1,6 +1,6 @@
1import type { ComponentType } from "react";1import type { ComponentType } from "react";
2import type { NativeSyntheticEvent, ViewProps } from "react-native";2import type { NativeSyntheticEvent, ViewProps } from "react-native";
3import { requireNativeView } from "expo";3import { requireNativeView, requireOptionalNativeModule } from "expo";
4 4
5import { NativeViewResolutionError } from "../../native/nativeViewResolutionError";5import { NativeViewResolutionError } from "../../native/nativeViewResolutionError";
6 6
@@ -23,6 +23,7 @@ interface TerminalResizeEvent { · 16 unmodified lines
23 23
24export interface NativeTerminalSurfaceProps extends ViewProps {24export interface NativeTerminalSurfaceProps extends ViewProps {
25 readonly appearanceScheme?: "light" | "dark";25 readonly appearanceScheme?: "light" | "dark";
26 readonly focusRequest?: number;
26 readonly themeConfig?: string;27 readonly themeConfig?: string;
27 readonly backgroundColor?: string;28 readonly backgroundColor?: string;
28 readonly foregroundColor?: string;29 readonly foregroundColor?: string;
@@ -74,6 +75,25 @@ export function resolveNativeTerminalSurfaceView(): ComponentType<NativeTerminal · 45 unmodified lines
74 return cachedNativeTerminalSurfaceView ?? null;75 return cachedNativeTerminalSurfaceView ?? null;
75}76}
76 77
78/**
79 * Revision of the native hardware-keyboard handling compiled into the installed binary,
80 * or `null` when the binary predates the revision constant (or the module is missing).
81 * Used in terminal debug logs to detect stale native builds.
82 */
83export function getNativeTerminalHardwareKeyRevision(): number | null {
84 try {
85 if (typeof requireOptionalNativeModule !== "function") {
86 return null;
87 }
88 const module = requireOptionalNativeModule<{ readonly hardwareKeyRevision?: number }>(
89 NATIVE_TERMINAL_MODULE_NAME,
90 );
91 return module?.hardwareKeyRevision ?? null;
92 } catch {
93 return null;
94 }
95}
96
77export function hasNativeTerminalSurface() {97export function hasNativeTerminalSurface() {
78 return resolveNativeTerminalSurfaceView() !== null;98 return resolveNativeTerminalSurfaceView() !== null;
79}99}
apps/mobile/src/features/terminal/terminalPreferences.ts+1−1
@@ -1,4 +1,4 @@
1export const DEFAULT_TERMINAL_FONT_SIZE = 10;1export const DEFAULT_TERMINAL_FONT_SIZE = 10.5;
2export const TERMINAL_FONT_SIZE_STEP = 0.5;2export const TERMINAL_FONT_SIZE_STEP = 0.5;
3export const MIN_TERMINAL_FONT_SIZE = 6;3export const MIN_TERMINAL_FONT_SIZE = 6;
4export const MAX_TERMINAL_FONT_SIZE = 14;4export const MAX_TERMINAL_FONT_SIZE = 14;
apps/mobile/src/features/threads/ComposerCommandPopover.tsx+1−3
@@ -7,8 +7,6 @@ import { Pressable, ScrollView, useColorScheme, View, type ViewStyle } from "rea · 6 unmodified lines
7 7
8import { AppText as Text } from "../../components/AppText";8import { AppText as Text } from "../../components/AppText";
9import { PierreEntryIcon } from "../../components/PierreEntryIcon";9import { PierreEntryIcon } from "../../components/PierreEntryIcon";
10import { MOBILE_TYPOGRAPHY } from "../../lib/typography";
11
12export type ComposerCommandItem =10export type ComposerCommandItem =
13 | {11 | {
14 readonly id: string;12 readonly id: string;
@@ -165,11 +163,11 @@ const CommandRow = memo(function CommandRow(props: { · 150 unmodified lines
165 </Text>163 </Text>
166 {props.item.description ? (164 {props.item.description ? (
167 <Text165 <Text
166 className="text-xs"
168 numberOfLines={1}167 numberOfLines={1}
169 style={{168 style={{
170 flex: 1,169 flex: 1,
171 minWidth: 0,170 minWidth: 0,
172 fontSize: MOBILE_TYPOGRAPHY.label.fontSize,
173 color: "#a1a1aa",171 color: "#a1a1aa",
174 }}172 }}
175 >173 >
apps/mobile/src/features/threads/NewTaskDraftRouteScreen.tsx+45−0
@@ -0,0 +1,45 @@
1import type { StaticScreenProps } from "@react-navigation/native";
2import { useMemo } from "react";
3import { NativeStackScreenOptions } from "../../native/StackHeader";
4
5import { NewTaskDraftScreen } from "./NewTaskDraftScreen";
6
7type NewTaskDraftRouteParams = {
8 readonly environmentId?: string | string[];
9 readonly projectId?: string | string[];
10 readonly title?: string | string[];
11 readonly pendingTaskId?: string | string[];
12};
13
14export 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}
apps/mobile/src/features/threads/NewTaskDraftScreen.tsx+178−34
@@ -1,4 +1,5 @@
1import { Stack, useRouter } from "expo-router";1import { NativeStackScreenOptions } from "../../native/StackHeader";
2import { StackActions, useNavigation } from "@react-navigation/native";
2import { useCallback, useEffect, useMemo, useRef } from "react";3import { useCallback, useEffect, useMemo, useRef } from "react";
3import { Alert, InteractionManager, View, useColorScheme } from "react-native";4import { Alert, InteractionManager, View, useColorScheme } from "react-native";
4import { KeyboardAvoidingView, useKeyboardState } from "react-native-keyboard-controller";5import { KeyboardAvoidingView, useKeyboardState } from "react-native-keyboard-controller";
@@ -22,6 +23,7 @@ import { ComposerAttachmentStrip } from "../../components/ComposerAttachmentStri · 17 unmodified lines
22import { ControlPillMenu } from "../../components/ControlPill";23import { ControlPillMenu } from "../../components/ControlPill";
23import { ProviderIcon } from "../../components/ProviderIcon";24import { ProviderIcon } from "../../components/ProviderIcon";
24 25
26import { makeTurnCommandMetadata } from "../../lib/commandMetadata";
25import { convertPastedImagesToAttachments, pickComposerImages } from "../../lib/composerImages";27import { convertPastedImagesToAttachments, pickComposerImages } from "../../lib/composerImages";
26import {28import {
27 applyProviderOptionMenuEvent,29 applyProviderOptionMenuEvent,
@@ -29,11 +31,11 @@ import { · 1 unmodified line
29 providerOptionsConfigurationLabel,31 providerOptionsConfigurationLabel,
30 resolveProviderOptionDescriptors,32 resolveProviderOptionDescriptors,
31} from "../../lib/providerOptions";33} from "../../lib/providerOptions";
32import { buildThreadRoutePath } from "../../lib/routes";34import { useScaledTextRole } from "../settings/appearance/useScaledTextRole";
33import { scopedProjectKey } from "../../lib/scopedEntities";
34import { MOBILE_TYPOGRAPHY } from "../../lib/typography";
35import { getComposerDraftSnapshot } from "../../state/use-composer-drafts";35import { getComposerDraftSnapshot } from "../../state/use-composer-drafts";
36import { useProjects } from "../../state/entities";36import { useProjects } from "../../state/entities";
37import { enqueueThreadOutboxMessage, removeThreadOutboxMessage } from "../../state/thread-outbox";
38import { useRemoteConnectionStatus } from "../../state/use-remote-environment-registry";
37import { branchBadgeLabel, useNewTaskFlow } from "./new-task-flow-provider";39import { branchBadgeLabel, useNewTaskFlow } from "./new-task-flow-provider";
38import { useCreateProjectThread } from "./use-project-actions";40import { useCreateProjectThread } from "./use-project-actions";
39 41
@@ -54,25 +56,79 @@ export function NewTaskDraftScreen(props: { · 14 unmodified lines
54 readonly environmentId?: string;56 readonly environmentId?: string;
55 readonly projectId?: string;57 readonly projectId?: string;
56 };58 };
59 /** Queued outbox message id when editing an existing pending task. */
60 readonly pendingTaskId?: string;
57}) {61}) {
58 const projects = useProjects();62 const projects = useProjects();
59 const createProjectThread = useCreateProjectThread();63 const createProjectThread = useCreateProjectThread();
60 const flow = useNewTaskFlow();64 const flow = useNewTaskFlow();
61 const router = useRouter();65 const navigation = useNavigation();
62 const insets = useSafeAreaInsets();66 const insets = useSafeAreaInsets();
63 const colorScheme = useColorScheme();67 const colorScheme = useColorScheme();
64 const isKeyboardVisible = useKeyboardState((state) => state.isVisible);68 const isKeyboardVisible = useKeyboardState((state) => state.isVisible);
65 const controlsBottomPadding = isKeyboardVisible ? 8 : Math.max(insets.bottom, 10);69 const controlsBottomPadding = isKeyboardVisible ? 8 : Math.max(insets.bottom, 10);
66 const { logicalProjects, selectedProject, setProject } = flow;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 const promptInputRef = useRef<ComposerEditorHandle>(null);77 const promptInputRef = useRef<ComposerEditorHandle>(null);
68 const loadedBranchesProjectKeyRef = useRef<string | null>(null);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 const borderColor = useThemeColor("--color-border");113 const borderColor = useThemeColor("--color-border");
114 const headlineText = useScaledTextRole("headline");
72 const sheetFadeOpaque = colorScheme === "dark" ? "rgba(14,14,14,0.98)" : "rgba(242,242,247,0.98)";115 const sheetFadeOpaque = colorScheme === "dark" ? "rgba(14,14,14,0.98)" : "rgba(242,242,247,0.98)";
73 const sheetFadeTransparent = colorScheme === "dark" ? "rgba(14,14,14,0)" : "rgba(242,242,247,0)";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 useEffect(() => {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 const initialEnvironmentId = props.initialProjectRef?.environmentId;132 const initialEnvironmentId = props.initialProjectRef?.environmentId;
77 const initialProjectId = props.initialProjectRef?.projectId;133 const initialProjectId = props.initialProjectRef?.projectId;
78 if (initialEnvironmentId && initialProjectId) {134 if (initialEnvironmentId && initialProjectId) {
@@ -83,19 +139,20 @@ export function NewTaskDraftScreen(props: { · 4 unmodified lines
83 ) ?? null;139 ) ?? null;
84 140
85 if (directProject) {141 if (directProject) {
86 // Honor the route's project ref only once. Re-applying it on every142 // Apply the route's project once. Re-applying on every change would
87 // selection change would immediately revert a manual computer switch,143 // instantly revert environment/project switches made in the picker.
88 // silently snapping back to the machine the draft was opened from.144 const directProjectKey = `${directProject.environmentId}:${directProject.id}`;
89 const initialKey = `${initialEnvironmentId}:${initialProjectId}`;145 if (appliedInitialProjectKeyRef.current === directProjectKey) {
90 if (appliedInitialRefKeyRef.current !== initialKey) {146 return;
91 appliedInitialRefKeyRef.current = initialKey;
92 if (
93 selectedProject?.environmentId !== directProject.environmentId ||
94 selectedProject.id !== directProject.id
95 ) {
96 setProject(directProject);
97 }
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 return;156 return;
100 }157 }
101 }158 }
@@ -109,13 +166,13 @@ export function NewTaskDraftScreen(props: { · 7 unmodified lines
109 return;166 return;
110 }167 }
111 168
112 router.replace("/new");169 navigation.dispatch(StackActions.replace("NewTask"));
113 }, [170 }, [
114 logicalProjects,171 logicalProjects,
115 projects,172 projects,
116 props.initialProjectRef?.environmentId,173 props.initialProjectRef,
117 props.initialProjectRef?.projectId,174 props.pendingTaskId,
118 router,175 navigation,
119 selectedProject,176 selectedProject,
120 setProject,177 setProject,
121 ]);178 ]);
@@ -281,12 +338,24 @@ export function NewTaskDraftScreen(props: { · 159 unmodified lines
281 subtitle: flow.selectedBranchName ?? "Choose branch",338 subtitle: flow.selectedBranchName ?? "Choose branch",
282 subactions: branchActions,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 flow.availableBranches,354 flow.availableBranches,
287 flow.branchesLoading,355 flow.branchesLoading,
288 flow.selectedBranchName,356 flow.selectedBranchName,
289 flow.selectedProject,357 flow.selectedProject,
358 flow.startFromOrigin,
290 flow.workspaceMode,359 flow.workspaceMode,
291 ]);360 ]);
292 361
@@ -351,6 +420,10 @@ export function NewTaskDraftScreen(props: { · 58 unmodified lines
351 );420 );
352 return;421 return;
353 }422 }
423 if (event === "workspace:start-from-origin") {
424 flow.setStartFromOrigin(!flow.startFromOrigin);
425 return;
426 }
354 if (event.startsWith("workspace:branch:")) {427 if (event.startsWith("workspace:branch:")) {
355 const branchName = event.slice("workspace:branch:".length);428 const branchName = event.slice("workspace:branch:".length);
356 const branch = flow.availableBranches.find((candidate) => candidate.name === branchName);429 const branch = flow.availableBranches.find((candidate) => candidate.name === branchName);
@@ -386,17 +459,17 @@ export function NewTaskDraftScreen(props: { · 29 unmodified lines
386 459
387 async function handleStart(): Promise<void> {460 async function handleStart(): Promise<void> {
388 const selectedProject = flow.selectedProject;461 const selectedProject = flow.selectedProject;
389 if (!selectedProject) {462 const draftKey = flow.draftKey;
463 if (!selectedProject || !draftKey) {
390 return;464 return;
391 }465 }
392 const draft = getComposerDraftSnapshot(466 const draft = getComposerDraftSnapshot(draftKey);
393 `new-task:${scopedProjectKey(selectedProject.environmentId, selectedProject.id)}`,
394 );
395 const modelSelection = draft.modelSelection ?? flow.selectedModel;467 const modelSelection = draft.modelSelection ?? flow.selectedModel;
396 const workspaceMode = draft.workspaceSelection?.mode ?? flow.workspaceMode;468 const workspaceMode = draft.workspaceSelection?.mode ?? flow.workspaceMode;
397 const selectedBranchName = draft.workspaceSelection?.branch ?? flow.selectedBranchName;469 const selectedBranchName = draft.workspaceSelection?.branch ?? flow.selectedBranchName;
398 const selectedWorktreePath =470 const selectedWorktreePath =
399 draft.workspaceSelection?.worktreePath ?? flow.selectedWorktreePath;471 draft.workspaceSelection?.worktreePath ?? flow.selectedWorktreePath;
472 const startFromOrigin = draft.workspaceSelection?.startFromOrigin ?? flow.startFromOrigin;
400 const runtimeMode = draft.runtimeMode ?? flow.runtimeMode;473 const runtimeMode = draft.runtimeMode ?? flow.runtimeMode;
401 const interactionMode = draft.interactionMode ?? flow.interactionMode;474 const interactionMode = draft.interactionMode ?? flow.interactionMode;
402 const initialMessageText = draft.text.trim();475 const initialMessageText = draft.text.trim();
@@ -410,6 +483,46 @@ export function NewTaskDraftScreen(props: { · 7 unmodified lines
410 return;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 flow.setSubmitting(true);526 flow.setSubmitting(true);
414 const result = await createProjectThread({527 const result = await createProjectThread({
415 project: selectedProject,528 project: selectedProject,
@@ -417,10 +530,21 @@ export function NewTaskDraftScreen(props: { · 1 unmodified line
417 envMode: workspaceMode,530 envMode: workspaceMode,
418 branch: selectedBranchName,531 branch: selectedBranchName,
419 worktreePath: workspaceMode === "worktree" ? null : selectedWorktreePath,532 worktreePath: workspaceMode === "worktree" ? null : selectedWorktreePath,
533 startFromOrigin,
420 runtimeMode,534 runtimeMode,
421 interactionMode,535 interactionMode,
422 initialMessageText,536 initialMessageText,
423 initialAttachments: draft.attachments,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 flow.setSubmitting(false);549 flow.setSubmitting(false);
426 550
@@ -435,22 +559,36 @@ export function NewTaskDraftScreen(props: { · 8 unmodified lines
435 return;559 return;
436 }560 }
437 561
438 flow.setPrompt("");562 if (editingPendingTask) {
439 flow.clearAttachments();563 try {
440 router.replace(buildThreadRoutePath(result.value));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 if (!selectedProject) {581 if (!selectedProject) {
444 return (582 return (
445 <View className="flex-1 bg-sheet">583 <View className="flex-1 bg-sheet">
446 <Stack.Screen options={{ title: "Loading task" }} />584 <NativeStackScreenOptions options={{ title: "Loading task" }} />
447 </View>585 </View>
448 );586 );
449 }587 }
450 588
451 return (589 return (
452 <View className="flex-1 bg-sheet">590 <View className="flex-1 bg-sheet">
453 <Stack.Screen options={{ title: selectedProject.title }} />591 <NativeStackScreenOptions options={{ title: selectedProject.title }} />
454 592
455 <KeyboardAvoidingView automaticOffset behavior="padding" style={{ flex: 1 }}>593 <KeyboardAvoidingView automaticOffset behavior="padding" style={{ flex: 1 }}>
456 <View style={{ flex: 1, minHeight: 0, paddingHorizontal: 20, paddingTop: 8 }}>594 <View style={{ flex: 1, minHeight: 0, paddingHorizontal: 20, paddingTop: 8 }}>
@@ -465,7 +603,7 @@ export function NewTaskDraftScreen(props: { · 8 unmodified lines
465 onPasteImages={(uris) => void handleNativePasteImages(uris)}603 onPasteImages={(uris) => void handleNativePasteImages(uris)}
466 placeholder={`Describe a coding task in ${selectedProject.title}`}604 placeholder={`Describe a coding task in ${selectedProject.title}`}
467 style={{ flex: 1, minHeight: 0 }}605 style={{ flex: 1, minHeight: 0 }}
468 textStyle={MOBILE_TYPOGRAPHY.headline}606 textStyle={headlineText}
469 />607 />
470 </View>608 </View>
471 609
@@ -540,8 +678,14 @@ export function NewTaskDraftScreen(props: { · 68 unmodified lines
540 </ControlPillMenu>678 </ControlPillMenu>
541 </ComposerToolbarScroller>679 </ComposerToolbarScroller>
542 <ComposerToolbarButton680 <ComposerToolbarButton
543 accessibilityLabel={flow.submitting ? "Starting task" : "Start task"}681 accessibilityLabel={
544 icon="arrow.up"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 onPress={() => void handleStart()}689 onPress={() => void handleStart()}
546 variant="primary"690 variant="primary"
547 showChevron={false}691 showChevron={false}
apps/mobile/src/features/threads/NewTaskRouteScreen.tsxrenamed from apps/mobile/src/app/new/index.tsx+61−52
@@ -1,4 +1,5 @@
1import { Link, Stack, useRouter } from "expo-router";1import { NativeHeaderToolbar } from "../../native/StackHeader";
2import { useNavigation } from "@react-navigation/native";
2import { SymbolView } from "expo-symbols";3import { SymbolView } from "expo-symbols";
3import type { EnvironmentId, ProjectId } from "@t3tools/contracts";4import type { EnvironmentId, ProjectId } from "@t3tools/contracts";
4import { useMemo } from "react";5import { useMemo } from "react";
@@ -12,6 +13,7 @@ import { useProjects, useThreadShells } from "../../state/entities"; · 7 unmodified lines
12import type { WorkspaceState } from "../../state/workspaceModel";13import type { WorkspaceState } from "../../state/workspaceModel";
13import { useWorkspaceState } from "../../state/workspace";14import { useWorkspaceState } from "../../state/workspace";
14import { groupProjectsByRepository } from "../../lib/repositoryGroups";15import { groupProjectsByRepository } from "../../lib/repositoryGroups";
16import { useAdaptiveWorkspaceLayout } from "../layout/AdaptiveWorkspaceLayout";
15 17
16function deriveProjectEmptyState(catalogState: WorkspaceState): {18function deriveProjectEmptyState(catalogState: WorkspaceState): {
17 readonly title: string;19 readonly title: string;
@@ -68,11 +70,12 @@ function deriveProjectEmptyState(catalogState: WorkspaceState): { · 50 unmodified lines
68 };70 };
69}71}
70 72
71export default function NewTaskRoute() {73export function NewTaskRouteScreen() {
72 const projects = useProjects();74 const projects = useProjects();
73 const threads = useThreadShells();75 const threads = useThreadShells();
74 const { state: catalogState } = useWorkspaceState();76 const { state: catalogState } = useWorkspaceState();
75 const router = useRouter();77 const navigation = useNavigation();
78 const { layout } = useAdaptiveWorkspaceLayout();
76 const insets = useSafeAreaInsets();79 const insets = useSafeAreaInsets();
77 const chevronColor = useThemeColor("--color-chevron");80 const chevronColor = useThemeColor("--color-chevron");
78 const accentColor = useThemeColor("--color-icon-muted");81 const accentColor = useThemeColor("--color-icon-muted");
@@ -108,16 +111,24 @@ export default function NewTaskRoute() { · 29 unmodified lines
108 111
109 return (112 return (
110 <View collapsable={false} className="flex-1 bg-sheet">113 <View collapsable={false} className="flex-1 bg-sheet">
111 <Stack.Screen options={{ title: "Choose project" }} />114 <NativeHeaderToolbar placement="right">
112 <Stack.Toolbar placement="right">115 {layout.usesSplitView ? (
113 <Stack.Toolbar.Button116 <NativeHeaderToolbar.Button
117 accessibilityLabel="Close new task"
118 icon="xmark"
119 onPress={() => navigation.goBack()}
120 separateBackground
121 />
122 ) : null}
123 <NativeHeaderToolbar.Button
114 icon="plus"124 icon="plus"
115 onPress={() => router.push("/new/add-project")}125 onPress={() => navigation.navigate("NewTaskSheet", { screen: "AddProject" })}
116 separateBackground126 separateBackground
117 />127 />
118 </Stack.Toolbar>128 </NativeHeaderToolbar>
119 129
120 <ScrollView130 <ScrollView
131 contentInsetAdjustmentBehavior="automatic"
121 showsVerticalScrollIndicator={false}132 showsVerticalScrollIndicator={false}
122 style={{ flex: 1 }}133 style={{ flex: 1 }}
123 contentInset={{ bottom: Math.max(insets.bottom, 18) + 18 }}134 contentInset={{ bottom: Math.max(insets.bottom, 18) + 18 }}
@@ -132,13 +143,13 @@ export default function NewTaskRoute() { · 8 unmodified lines
132 <Text className="text-center text-lg font-t3-bold text-foreground">143 <Text className="text-center text-lg font-t3-bold text-foreground">
133 {projectEmptyState.title}144 {projectEmptyState.title}
134 </Text>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 {projectEmptyState.detail}147 {projectEmptyState.detail}
137 </Text>148 </Text>
138 {!catalogState.hasReadyEnvironment ? (149 {!catalogState.hasReadyEnvironment ? (
139 <Pressable150 <Pressable
140 className="mt-1 rounded-full bg-primary px-4 py-2.5 active:opacity-70"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 <Text className="text-sm font-t3-bold text-primary-foreground">154 <Text className="text-sm font-t3-bold text-primary-foreground">
144 Add environment155 Add environment
@@ -147,7 +158,7 @@ export default function NewTaskRoute() { · 2 unmodified lines
147 ) : (158 ) : (
148 <Pressable159 <Pressable
149 className="mt-1 rounded-full bg-primary px-4 py-2.5 active:opacity-70"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 <Text className="text-sm font-t3-bold text-primary-foreground">163 <Text className="text-sm font-t3-bold text-primary-foreground">
153 Add new project164 Add new project
@@ -162,52 +173,50 @@ export default function NewTaskRoute() { · 8 unmodified lines
162 const isLast = index === items.length - 1;173 const isLast = index === items.length - 1;
163 174
164 return (175 return (
165 <Link176 <Pressable
166 key={item.key}177 key={item.key}
167 href={{178 className="bg-card"
168 pathname: "/new/draft",179 onPress={() =>
169 params: {180 navigation.navigate("NewTaskSheet", {
170 environmentId: item.environmentId,181 screen: "NewTaskDraft",
171 projectId: item.id,182 params: {
172 title: item.title,183 environmentId: item.environmentId,
173 },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 <Pressable200 <View className="flex-row items-center justify-between gap-3">
178 className="bg-card"201 <View className="h-7 w-7 items-center justify-center">
179 style={{202 <ProjectFavicon
180 paddingHorizontal: 16,203 environmentId={item.environmentId}
181 paddingVertical: 14,204 size={20}
182 borderTopWidth: isFirst ? 0 : 1,205 projectTitle={item.title}
183 borderTopColor: borderSubtleColor,206 workspaceRoot={item.workspaceRoot}
184 borderTopLeftRadius: isFirst ? 24 : 0,
185 borderTopRightRadius: isFirst ? 24 : 0,
186 borderBottomLeftRadius: isLast ? 24 : 0,
187 borderBottomRightRadius: isLast ? 24 : 0,
188 }}
189 >
190 <View className="flex-row items-center justify-between gap-3">
191 <View className="h-7 w-7 items-center justify-center">
192 <ProjectFavicon
193 environmentId={item.environmentId}
194 size={20}
195 projectTitle={item.title}
196 workspaceRoot={item.workspaceRoot}
197 />
198 </View>
199 <View className="flex-1">
200 <Text className="text-base leading-[21px] font-t3-bold">{item.title}</Text>
201 </View>
202 <SymbolView
203 name="chevron.right"
204 size={14}
205 tintColor={chevronColor}
206 type="monochrome"
207 />207 />
208 </View>208 </View>
209 </Pressable>209 <View className="flex-1">
210 </Link>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 </View>222 </View>
apps/mobile/src/features/threads/PendingApprovalCard.tsx+1−1
@@ -23,7 +23,7 @@ export function PendingApprovalCard(props: PendingApprovalCardProps) { · 22 unmodified lines
23 {props.approval.requestKind}23 {props.approval.requestKind}
24 </Text>24 </Text>
25 {props.approval.detail ? (25 {props.approval.detail ? (
26 <Text className="font-sans text-sm leading-5 text-neutral-600 dark:text-neutral-400">26 <Text className="font-sans text-sm leading-normal text-neutral-600 dark:text-neutral-400">
27 {props.approval.detail}27 {props.approval.detail}
28 </Text>28 </Text>
29 ) : null}29 ) : null}
apps/mobile/src/features/threads/PendingUserInputCard.tsx+1−1
@@ -39,7 +39,7 @@ export function PendingUserInputCard(props: PendingUserInputCardProps) { · 38 unmodified lines
39 <Text className="font-t3-bold text-xs uppercase tracking-[1px] text-neutral-500 dark:text-neutral-500">39 <Text className="font-t3-bold text-xs uppercase tracking-[1px] text-neutral-500 dark:text-neutral-500">
40 {question.header}40 {question.header}
41 </Text>41 </Text>
42 <Text className="font-sans text-base leading-[21px] text-neutral-950 dark:text-neutral-50">42 <Text className="font-sans text-base leading-snug text-neutral-950 dark:text-neutral-50">
43 {question.question}43 {question.question}
44 </Text>44 </Text>
45 <View className="flex-row flex-wrap gap-2.5">45 <View className="flex-row flex-wrap gap-2.5">
apps/mobile/src/features/threads/ThreadComposer.tsx+87−39
@@ -26,6 +26,7 @@ import { · 25 unmodified lines
26} from "react-native";26} from "react-native";
27import ImageViewing from "react-native-image-viewing";27import ImageViewing from "react-native-image-viewing";
28import { useThemeColor } from "../../lib/useThemeColor";28import { useThemeColor } from "../../lib/useThemeColor";
29import { scopedThreadKey } from "../../lib/scopedEntities";
29 30
30import { AppText as Text } from "../../components/AppText";31import { AppText as Text } from "../../components/AppText";
31import { ComposerAttachmentStrip } from "../../components/ComposerAttachmentStrip";32import { ComposerAttachmentStrip } from "../../components/ComposerAttachmentStrip";
@@ -44,7 +45,7 @@ import { ControlPill, ControlPillMenu } from "../../components/ControlPill"; · 12 unmodified lines
44import { ProviderIcon } from "../../components/ProviderIcon";45import { ProviderIcon } from "../../components/ProviderIcon";
45import type { DraftComposerImageAttachment } from "../../lib/composerImages";46import type { DraftComposerImageAttachment } from "../../lib/composerImages";
46import { buildModelOptions, groupByProvider } from "../../lib/modelOptions";47import { buildModelOptions, groupByProvider } from "../../lib/modelOptions";
47import { MOBILE_TYPOGRAPHY } from "../../lib/typography";48import { useScaledTextRole } from "../settings/appearance/useScaledTextRole";
48import type { RemoteClientConnectionState } from "../../lib/connection";49import type { RemoteClientConnectionState } from "../../lib/connection";
49import {50import {
50 insertRankedSearchResult,51 insertRankedSearchResult,
@@ -76,10 +77,17 @@ export interface ThreadComposerProps { · 25 unmodified lines
76 readonly draftMessage: string;77 readonly draftMessage: string;
77 readonly draftAttachments: ReadonlyArray<DraftComposerImageAttachment>;78 readonly draftAttachments: ReadonlyArray<DraftComposerImageAttachment>;
78 readonly placeholder: string;79 readonly placeholder: string;
80 readonly contentMaxWidth?: number;
79 readonly bottomInset?: number;81 readonly bottomInset?: number;
80 readonly connectionState: RemoteClientConnectionState;82 readonly connectionState: RemoteClientConnectionState;
81 readonly connectionError: string | null;83 readonly connectionError: string | null;
82 readonly environmentLabel: string | null;84 readonly environmentLabel: string | null;
85 /**
86 * Message sync phase for the selected thread (drives the status pill):
87 * "loading" = first fetch, nothing to show yet; "syncing" = cached messages
88 * are on screen while they reconcile with the server.
89 */
90 readonly threadSyncPhase?: "loading" | "syncing" | null;
83 readonly selectedThread: OrchestrationThreadShell;91 readonly selectedThread: OrchestrationThreadShell;
84 readonly serverConfig: T3ServerConfig | null;92 readonly serverConfig: T3ServerConfig | null;
85 readonly queueCount: number;93 readonly queueCount: number;
@@ -109,41 +117,61 @@ function ComposerSurface(props: { · 23 unmodified lines
109 readonly style: ViewStyle;117 readonly style: ViewStyle;
110 readonly isDarkMode: boolean;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 if (isLiquidGlassSupported) {131 if (isLiquidGlassSupported) {
113 return (132 return (
114 <LiquidGlassView133 <View style={shadowStyle}>
115 effect="clear"134 <LiquidGlassView
116 interactive135 effect="regular"
117 tintColor={props.isDarkMode ? "rgba(44,44,46,0.5)" : "rgba(255,255,255,0.45)"}136 interactive
118 colorScheme={props.isDarkMode ? "dark" : "light"}137 colorScheme={props.isDarkMode ? "dark" : "light"}
119 style={props.style}138 style={props.style}
120 >139 >
121 {props.children}140 {props.children}
122 </LiquidGlassView>141 </LiquidGlassView>
142 </View>
123 );143 );
124 }144 }
125 145
126 return (146 return (
127 <View147 <View style={shadowStyle}>
128 style={[148 <View
129 props.style,149 style={[
130 {150 props.style,
131 backgroundColor: props.isDarkMode ? "rgba(44,44,46,0.96)" : "rgba(255,255,255,0.96)",151 {
132 borderWidth: 1,152 backgroundColor: props.isDarkMode ? "rgba(44,44,46,0.96)" : "rgba(255,255,255,0.96)",
133 borderColor: props.isDarkMode ? "rgba(255,255,255,0.08)" : "rgba(0,0,0,0.06)",153 borderWidth: 1,
134 },154 borderColor: props.isDarkMode ? "rgba(255,255,255,0.08)" : "rgba(0,0,0,0.06)",
135 ]}155 },
136 >156 ]}
137 {props.children}157 >
158 {props.children}
159 </View>
138 </View>160 </View>
139 );161 );
140}162}
141 163
164type ComposerStatusPillState = {
165 readonly kind: "unavailable" | "reconnecting" | "syncing";
166 readonly label: string;
167};
168
142function composerConnectionStatus(input: {169function composerConnectionStatus(input: {
143 readonly connectionError: string | null;170 readonly connectionError: string | null;
144 readonly connectionState: RemoteClientConnectionState;171 readonly connectionState: RemoteClientConnectionState;
145 readonly environmentLabel: string | null;172 readonly environmentLabel: string | null;
146}): { readonly kind: "unavailable" | "reconnecting"; readonly label: string } | null {173 readonly threadSyncPhase?: "loading" | "syncing" | null;
174}): ComposerStatusPillState | null {
147 const environmentLabel = input.environmentLabel ?? "Environment";175 const environmentLabel = input.environmentLabel ?? "Environment";
148 176
149 switch (input.connectionState) {177 switch (input.connectionState) {
@@ -168,15 +196,27 @@ function composerConnectionStatus(input: { · 18 unmodified lines
168 case "available":196 case "available":
169 return { kind: "unavailable", label: `${environmentLabel} is not connected` };197 return { kind: "unavailable", label: `${environmentLabel} is not connected` };
170 case "connected":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 return null;211 return null;
172 }212 }
173}213}
174 214
175const ComposerConnectionStatusPill = memo(function ComposerConnectionStatusPill(props: {215const ComposerConnectionStatusPill = memo(function ComposerConnectionStatusPill(props: {
176 readonly onPress: () => void;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 return (221 return (
182 <View className="items-center pb-2">222 <View className="items-center pb-2">
@@ -191,7 +231,7 @@ const ComposerConnectionStatusPill = memo(function ComposerConnectionStatusPill( · 8 unmodified lines
191 <View className="h-2 w-2 rounded-full bg-red-500" />231 <View className="h-2 w-2 rounded-full bg-red-500" />
192 )}232 )}
193 <Text233 <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 numberOfLines={1}235 numberOfLines={1}
196 >236 >
197 {props.status.label}237 {props.status.label}
@@ -204,10 +244,12 @@ const ComposerConnectionStatusPill = memo(function ComposerConnectionStatusPill( · 6 unmodified lines
204export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposerProps) {244export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposerProps) {
205 const isDarkMode = useColorScheme() === "dark";245 const isDarkMode = useColorScheme() === "dark";
206 const foregroundColor = useThemeColor("--color-foreground");246 const foregroundColor = useThemeColor("--color-foreground");
247 const bodyText = useScaledTextRole("body");
207 const fallbackInputRef = useRef<ComposerEditorHandle>(null);248 const fallbackInputRef = useRef<ComposerEditorHandle>(null);
208 const inputRef = props.editorRef ?? fallbackInputRef;249 const inputRef = props.editorRef ?? fallbackInputRef;
209 const [isFocused, setIsFocused] = useState(false);250 const [isFocused, setIsFocused] = useState(false);
210 const wasExpandedBeforePreviewRef = useRef(false);251 const wasExpandedBeforePreviewRef = useRef(false);
252 const inFlightThreadIdsRef = useRef(new Set<string>());
211 const { onExpandedChange } = props;253 const { onExpandedChange } = props;
212 254
213 const [previewImageUri, setPreviewImageUri] = useState<string | null>(null);255 const [previewImageUri, setPreviewImageUri] = useState<string | null>(null);
@@ -254,6 +296,7 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer · 40 unmodified lines
254 connectionError: props.connectionError,296 connectionError: props.connectionError,
255 connectionState: props.connectionState,297 connectionState: props.connectionState,
256 environmentLabel: props.environmentLabel,298 environmentLabel: props.environmentLabel,
299 threadSyncPhase: props.threadSyncPhase,
257 });300 });
258 const toolbarFadeOpaque = isDarkMode ? "rgba(0,0,0,0.95)" : "rgba(255,255,255,0.95)";301 const toolbarFadeOpaque = isDarkMode ? "rgba(0,0,0,0.95)" : "rgba(255,255,255,0.95)";
259 const toolbarFadeTransparent = isDarkMode ? "rgba(0,0,0,0)" : "rgba(255,255,255,0)";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 // ── Handle command selection ──────────────────────────────490 // ── Handle command selection ──────────────────────────────
448 const { onChangeDraftMessage, onUpdateInteractionMode, draftMessage, onSendMessage } = props;491 const { onChangeDraftMessage, onUpdateInteractionMode, draftMessage, onSendMessage } = props;
449 492
450 const handleSend = useCallback(() => {493 const handleSend = useCallback(async () => {
451 void onSendMessage();494 const threadKey = scopedThreadKey(props.environmentId, props.selectedThread.id);
452 }, [onSendMessage]);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 const handleCommandSelect = useCallback(503 const handleCommandSelect = useCallback(
454 (item: ComposerCommandItem) => {504 (item: ComposerCommandItem) => {
455 if (!composerTrigger) return;505 if (!composerTrigger) return;
@@ -625,11 +675,14 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer · 169 unmodified lines
625 paddingTop: isExpanded ? 8 : 6,675 paddingTop: isExpanded ? 8 : 6,
626 paddingBottom: (props.bottomInset ?? 0) + (isExpanded ? 8 : 6),676 paddingBottom: (props.bottomInset ?? 0) + (isExpanded ? 8 : 6),
627 experimental_backgroundImage: isDarkMode677 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%)"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%)"
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%)",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 {composerTrigger && composerMenuItems.length > 0 ? (686 {composerTrigger && composerMenuItems.length > 0 ? (
634 <View687 <View
635 style={{688 style={{
@@ -702,6 +755,7 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer · 66 unmodified lines
702 placeholder={props.placeholder}755 placeholder={props.placeholder}
703 onFocus={handleFocus}756 onFocus={handleFocus}
704 onBlur={handleBlur}757 onBlur={handleBlur}
758 onSubmit={handleSend}
705 scrollEnabled={isExpanded}759 scrollEnabled={isExpanded}
706 contentInsetVertical={isExpanded ? 0 : 6}760 contentInsetVertical={isExpanded ? 0 : 6}
707 style={761 style={
@@ -717,7 +771,7 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer · 9 unmodified lines
717 }771 }
718 }772 }
719 textStyle={{773 textStyle={{
720 ...MOBILE_TYPOGRAPHY.composer,774 ...bodyText,
721 color: foregroundColor,775 color: foregroundColor,
722 fontFamily: "DMSans_400Regular",776 fontFamily: "DMSans_400Regular",
723 }}777 }}
@@ -827,13 +881,7 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer · 103 unmodified lines
827 881
828 {/* Queue count */}882 {/* Queue count */}
829 {props.queueCount > 0 ? (883 {props.queueCount > 0 ? (
830 <Text884 <Text className="text-xs text-foreground-muted" style={{ paddingTop: 8 }}>
831 className="text-foreground-muted"
832 style={{
833 ...MOBILE_TYPOGRAPHY.label,
834 paddingTop: 8,
835 }}
836 >
837 {props.queueCount} queued message{props.queueCount === 1 ? "" : "s"} will send885 {props.queueCount} queued message{props.queueCount === 1 ? "" : "s"} will send
838 automatically.886 automatically.
839 </Text>887 </Text>
apps/mobile/src/features/threads/ThreadDetailScreen.tsx+158−97
@@ -1,4 +1,5 @@
1import { type EnvironmentConnectionPhase } from "@t3tools/client-runtime/connection";1import { type EnvironmentConnectionPhase } from "@t3tools/client-runtime/connection";
2import type { EnvironmentThreadStatus } from "@t3tools/client-runtime/state/threads";
2import { useKeyboardChatComposerInset, useKeyboardScrollToEnd } from "@legendapp/list/keyboard";3import { useKeyboardChatComposerInset, useKeyboardScrollToEnd } from "@legendapp/list/keyboard";
3import type { LegendListRef } from "@legendapp/list/react-native";4import type { LegendListRef } from "@legendapp/list/react-native";
4import type {5import type {
@@ -15,17 +16,18 @@ import type { · 10 unmodified lines
15} from "@t3tools/contracts";16} from "@t3tools/contracts";
16import { formatElapsed } from "@t3tools/shared/orchestrationTiming";17import { formatElapsed } from "@t3tools/shared/orchestrationTiming";
17import * as Haptics from "expo-haptics";18import * as Haptics from "expo-haptics";
18import { useHeaderHeight } from "expo-router/build/react-navigation/elements";
19import { memo, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";19import { memo, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
20import { View, type GestureResponderEvent } from "react-native";20import { View, type GestureResponderEvent } from "react-native";
21import { Gesture, GestureDetector } from "react-native-gesture-handler";
21import { KeyboardController, KeyboardStickyView } from "react-native-keyboard-controller";22import { KeyboardController, KeyboardStickyView } from "react-native-keyboard-controller";
22import { useSafeAreaInsets } from "react-native-safe-area-context";23import { useSafeAreaInsets } from "react-native-safe-area-context";
24import { runOnJS } from "react-native-reanimated";
23 25
24import { AppText as Text } from "../../components/AppText";26import { AppText as Text } from "../../components/AppText";
25import type { ComposerEditorHandle } from "../../components/ComposerEditor";27import type { ComposerEditorHandle } from "../../components/ComposerEditor";
26import type { StatusTone } from "../../components/StatusPill";28import type { StatusTone } from "../../components/StatusPill";
27import type { DraftComposerImageAttachment } from "../../lib/composerImages";29import type { DraftComposerImageAttachment } from "../../lib/composerImages";
28import type { LayoutVariant } from "../../lib/layout";30import { CHAT_CONTENT_MAX_WIDTH, type LayoutVariant } from "../../lib/layout";
29import { scopedThreadKey } from "../../lib/scopedEntities";31import { scopedThreadKey } from "../../lib/scopedEntities";
30import type {32import type {
31 PendingApproval,33 PendingApproval,
@@ -60,6 +62,8 @@ export interface ThreadDetailScreenProps { · 28 unmodified lines
60 readonly draftMessage: string;62 readonly draftMessage: string;
61 readonly draftAttachments: ReadonlyArray<DraftComposerImageAttachment>;63 readonly draftAttachments: ReadonlyArray<DraftComposerImageAttachment>;
62 readonly connectionStateLabel: EnvironmentConnectionPhase;64 readonly connectionStateLabel: EnvironmentConnectionPhase;
65 /** Message sync status for the selected thread (drives the composer status pill). */
66 readonly threadSyncStatus?: EnvironmentThreadStatus;
63 readonly activeThreadBusy: boolean;67 readonly activeThreadBusy: boolean;
64 readonly environmentId: EnvironmentId;68 readonly environmentId: EnvironmentId;
65 readonly projectWorkspaceRoot: string | null;69 readonly projectWorkspaceRoot: string | null;
@@ -67,6 +71,9 @@ export interface ThreadDetailScreenProps { · 1 unmodified line
67 readonly selectedThreadQueueCount: number;71 readonly selectedThreadQueueCount: number;
68 readonly serverConfig: T3ServerConfig | null;72 readonly serverConfig: T3ServerConfig | null;
69 readonly layoutVariant?: LayoutVariant;73 readonly layoutVariant?: LayoutVariant;
74 readonly usesAutomaticContentInsets?: boolean;
75 readonly onHeaderMaterialVisibilityChange?: (visible: boolean) => void;
76 readonly onOpenDrawer: () => void;
70 readonly onOpenConnectionEditor: () => void;77 readonly onOpenConnectionEditor: () => void;
71 readonly onChangeDraftMessage: (value: string) => void;78 readonly onChangeDraftMessage: (value: string) => void;
72 readonly onPickDraftImages: () => Promise<void>;79 readonly onPickDraftImages: () => Promise<void>;
@@ -201,8 +208,9 @@ const WorkingDurationPill = memo(function WorkingDurationPill(props: { · 128 unmodified lines
201});208});
202 209
203export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: ThreadDetailScreenProps) {210export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: ThreadDetailScreenProps) {
211 const { onOpenDrawer } = props;
212
204 const insets = useSafeAreaInsets();213 const insets = useSafeAreaInsets();
205 const headerHeight = useHeaderHeight();
206 const agentLabel = `${props.selectedThread.modelSelection.instanceId} agent`;214 const agentLabel = `${props.selectedThread.modelSelection.instanceId} agent`;
207 const selectedThreadKey = scopedThreadKey(props.environmentId, props.selectedThread.id);215 const selectedThreadKey = scopedThreadKey(props.environmentId, props.selectedThread.id);
208 const composerEditorRef = useRef<ComposerEditorHandle>(null);216 const composerEditorRef = useRef<ComposerEditorHandle>(null);
@@ -215,6 +223,22 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread · 6 unmodified lines
215 const [anchorMessageId, setAnchorMessageId] = useState<MessageId | null>(null);223 const [anchorMessageId, setAnchorMessageId] = useState<MessageId | null>(null);
216 const composerBottomInset = composerExpanded ? 0 : Math.max(insets.bottom, 12);224 const composerBottomInset = composerExpanded ? 0 : Math.max(insets.bottom, 12);
217 const contentPresentationKind = props.contentPresentation.kind;225 const contentPresentationKind = props.contentPresentation.kind;
226 // The raw sync status enters "synchronizing" on every full fetch, cached or
227 // not. Whether messages are already on screen decides the pill label: no
228 // data yet → "Loading messages", cached data reconciling → "Syncing".
229 const threadSyncPhase = (() => {
230 switch (props.threadSyncStatus) {
231 case "empty":
232 case "cached":
233 case "synchronizing":
234 if (contentPresentationKind === "ready") {
235 return "syncing" as const;
236 }
237 return contentPresentationKind === "loading" ? ("loading" as const) : null;
238 default:
239 return null;
240 }
241 })();
218 const selectedThreadFeed = props.selectedThreadFeed;242 const selectedThreadFeed = props.selectedThreadFeed;
219 const composerChrome = composerExpanded ? COMPOSER_EXPANDED_CHROME : COMPOSER_COLLAPSED_CHROME;243 const composerChrome = composerExpanded ? COMPOSER_EXPANDED_CHROME : COMPOSER_COLLAPSED_CHROME;
220 const composerOverlapHeight = composerChrome + composerBottomInset;244 const composerOverlapHeight = composerChrome + composerBottomInset;
@@ -228,6 +252,8 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread · 7 unmodified lines
228 const { freeze, scrollMessageToEnd } = useKeyboardScrollToEnd({ listRef });252 const { freeze, scrollMessageToEnd } = useKeyboardScrollToEnd({ listRef });
229 const showContent = props.showContent ?? true;253 const showContent = props.showContent ?? true;
230 const layoutVariant = props.layoutVariant ?? "compact";254 const layoutVariant = props.layoutVariant ?? "compact";
255 const isSplitLayout = layoutVariant === "split";
256 const contentMaxWidth = isSplitLayout ? CHAT_CONTENT_MAX_WIDTH : undefined;
231 const selectedInstanceId = props.selectedThread.modelSelection.instanceId;257 const selectedInstanceId = props.selectedThread.modelSelection.instanceId;
232 useStreamingHaptics(props.selectedThread.id, props.selectedThreadFeed);258 useStreamingHaptics(props.selectedThread.id, props.selectedThreadFeed);
233 const selectedProviderSkills = useMemo(259 const selectedProviderSkills = useMemo(
@@ -237,6 +263,28 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread · 3 unmodified lines
237 [props.serverConfig, selectedInstanceId],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 useLayoutEffect(() => {288 useLayoutEffect(() => {
241 selectedThreadKeyRef.current = selectedThreadKey;289 selectedThreadKeyRef.current = selectedThreadKey;
242 }, [selectedThreadKey]);290 }, [selectedThreadKey]);
@@ -346,102 +394,115 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread · 103 unmodified lines
346 }, []);394 }, []);
347 395
348 return (396 return (
349 <View className="flex-1">397 <GestureDetector gesture={headerDrawerGesture}>
350 {showContent ? (398 <View className="flex-1">
351 <View399 {showContent ? (
352 style={{ flex: 1 }}400 <View
353 onTouchStart={handleFeedTouchStart}401 style={{ flex: 1 }}
354 onTouchMove={handleFeedTouchMove}402 onTouchStart={handleFeedTouchStart}
355 onTouchEnd={handleFeedTouchEnd}403 onTouchMove={handleFeedTouchMove}
356 onTouchCancel={handleFeedTouchCancel}404 onTouchEnd={handleFeedTouchEnd}
357 >405 onTouchCancel={handleFeedTouchCancel}
358 <ThreadFeed406 >
359 key={props.selectedThread.id}407 <ThreadFeed
360 environmentId={props.environmentId}408 key={props.selectedThread.id}
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}
427 environmentId={props.environmentId}409 environmentId={props.environmentId}
428 projectCwd={props.projectWorkspaceRoot}410 threadId={props.selectedThread.id}
429 bottomInset={composerBottomInset}411 workspaceRoot={props.threadCwd}
430 onChangeDraftMessage={props.onChangeDraftMessage}412 feed={props.selectedThreadFeed}
431 onPickDraftImages={props.onPickDraftImages}413 contentPresentation={props.contentPresentation}
432 onNativePasteImages={props.onNativePasteImages}414 agentLabel={agentLabel}
433 onRemoveDraftImage={props.onRemoveDraftImage}415 latestTurn={props.selectedThread.latestTurn}
434 onStopThread={props.onStopThread}416 listRef={listRef}
435 onSendMessage={handleSendMessage}417 freeze={freeze}
436 onReconnectEnvironment={props.onReconnectEnvironment}418 anchorMessageId={anchorMessageId}
437 onUpdateModelSelection={props.onUpdateThreadModelSelection}419 contentInsetEndAdjustment={contentInsetEndAdjustment}
438 onUpdateRuntimeMode={props.onUpdateThreadRuntimeMode}420 contentTopInset={0}
439 onUpdateInteractionMode={props.onUpdateThreadInteractionMode}421 contentBottomInset={estimatedOverlayHeight}
440 onExpandedChange={setComposerExpanded}422 contentMaxWidth={contentMaxWidth}
423 layoutVariant={layoutVariant}
424 usesAutomaticContentInsets={props.usesAutomaticContentInsets}
425 onHeaderMaterialVisibilityChange={props.onHeaderMaterialVisibilityChange}
426 skills={selectedProviderSkills}
441 />427 />
442 </View>428 </View>
443 </KeyboardStickyView>429 ) : (
444 ) : null}430 <View style={{ flex: 1 }} />
445 </View>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});
apps/mobile/src/features/threads/ThreadFeed.tsx+180−111
@@ -4,7 +4,7 @@ import { type LegendListRef } from "@legendapp/list/react-native"; · 3 unmodified lines
4import type { EnvironmentId, MessageId, ThreadId, TurnId } from "@t3tools/contracts";4import type { EnvironmentId, MessageId, ThreadId, TurnId } from "@t3tools/contracts";
5import { CHAT_LIST_ANCHOR_OFFSET, resolveChatListAnchoredEndSpace } from "@t3tools/shared/chatList";5import { CHAT_LIST_ANCHOR_OFFSET, resolveChatListAnchoredEndSpace } from "@t3tools/shared/chatList";
6import { SymbolView } from "expo-symbols";6import { SymbolView } from "expo-symbols";
7import { useRouter } from "expo-router";7import { useNavigation } from "@react-navigation/native";
8import {8import {
9 memo,9 memo,
10 useCallback,10 useCallback,
@@ -25,6 +25,10 @@ import { · 14 unmodified lines
25 ActivityIndicator,25 ActivityIndicator,
26 Image,26 Image,
27 Linking,27 Linking,
28 Platform,
29 type LayoutChangeEvent,
30 type NativeScrollEvent,
31 type NativeSyntheticEvent,
28 Pressable,32 Pressable,
29 ScrollView,33 ScrollView,
30 StyleSheet,34 StyleSheet,
@@ -37,7 +41,7 @@ import { · 6 unmodified lines
37import { TouchableOpacity } from "react-native-gesture-handler";41import { TouchableOpacity } from "react-native-gesture-handler";
38import ImageViewing from "react-native-image-viewing";42import ImageViewing from "react-native-image-viewing";
39import { useSafeAreaInsets } from "react-native-safe-area-context";43import { useSafeAreaInsets } from "react-native-safe-area-context";
40import type { SharedValue } from "react-native-reanimated";44import { type SharedValue } from "react-native-reanimated";
41import { useThemeColor } from "../../lib/useThemeColor";45import { useThemeColor } from "../../lib/useThemeColor";
42import { copyTextWithHaptic } from "../../lib/copyTextWithHaptic";46import { copyTextWithHaptic } from "../../lib/copyTextWithHaptic";
43import {47import {
@@ -58,14 +62,16 @@ import { · 14 unmodified lines
58 buildNativeReviewDiffData,62 buildNativeReviewDiffData,
59 createNativeReviewDiffTheme,63 createNativeReviewDiffTheme,
60 NATIVE_REVIEW_DIFF_CONTENT_WIDTH,64 NATIVE_REVIEW_DIFF_CONTENT_WIDTH,
61 NATIVE_REVIEW_DIFF_ROW_HEIGHT,
62 NATIVE_REVIEW_DIFF_STYLE,
63} from "../review/nativeReviewDiffAdapter";65} from "../review/nativeReviewDiffAdapter";
64import { buildReviewParsedDiff } from "../review/reviewModel";66import { buildReviewParsedDiff } from "../review/reviewModel";
65import { cn } from "../../lib/cn";67import { cn } from "../../lib/cn";
66import type { LayoutVariant } from "../../lib/layout";68import { deriveCenteredContentHorizontalPadding, type LayoutVariant } from "../../lib/layout";
67import { buildThreadFilesNavigation } from "../../lib/routes";69import {
68import { MOBILE_CODE_SURFACE, MOBILE_TYPOGRAPHY } from "../../lib/typography";70 resolveMarkdownFontSizes,
71 resolveNativeMarkdownTypography,
72} from "../../lib/appearancePreferences";
73import { useAppearancePreferences } from "../settings/appearance/AppearancePreferencesProvider";
74import { useAppearanceCodeSurface } from "../settings/appearance/useAppearanceCodeSurface";
69import { markdownFileIconSource } from "@t3tools/mobile-markdown-text/file-icons";75import { markdownFileIconSource } from "@t3tools/mobile-markdown-text/file-icons";
70import { resolveMarkdownLinkPresentation } from "@t3tools/mobile-markdown-text/links";76import { resolveMarkdownLinkPresentation } from "@t3tools/mobile-markdown-text/links";
71import {77import {
@@ -82,7 +88,6 @@ const MESSAGE_TIME_FORMATTER = new Intl.DateTimeFormat(undefined, { · 10 unmodified lines
82 hour: "numeric",88 hour: "numeric",
83 minute: "2-digit",89 minute: "2-digit",
84});90});
85
86function formatMessageTime(input: string): string {91function formatMessageTime(input: string): string {
87 const timestamp = Date.parse(input);92 const timestamp = Date.parse(input);
88 if (Number.isNaN(timestamp)) {93 if (Number.isNaN(timestamp)) {
@@ -105,7 +110,10 @@ export interface ThreadFeedProps { · 16 unmodified lines
105 readonly contentInsetEndAdjustment: SharedValue<number>;110 readonly contentInsetEndAdjustment: SharedValue<number>;
106 readonly contentTopInset?: number;111 readonly contentTopInset?: number;
107 readonly contentBottomInset?: number;112 readonly contentBottomInset?: number;
113 readonly contentMaxWidth?: number;
108 readonly layoutVariant?: LayoutVariant;114 readonly layoutVariant?: LayoutVariant;
115 readonly usesAutomaticContentInsets?: boolean;
116 readonly onHeaderMaterialVisibilityChange?: (visible: boolean) => void;
109 readonly skills?: ReadonlyArray<SelectableMarkdownSkill>;117 readonly skills?: ReadonlyArray<SelectableMarkdownSkill>;
110}118}
111 119
@@ -273,6 +281,15 @@ function useReviewCommentColors(): ReviewCommentColors { · 161 unmodified lines
273 281
274function useMarkdownStyles(onLinkPress: (href: string) => void): MarkdownStyleSets {282function useMarkdownStyles(onLinkPress: (href: string) => void): MarkdownStyleSets {
275 const colorScheme = useColorScheme();283 const colorScheme = useColorScheme();
284 const { appearance } = useAppearancePreferences();
285 const markdownFontSizes = useMemo(
286 () => resolveMarkdownFontSizes(appearance.baseFontSize),
287 [appearance.baseFontSize],
288 );
289 const nativeMarkdownTypography = useMemo(
290 () => resolveNativeMarkdownTypography(appearance.baseFontSize),
291 [appearance.baseFontSize],
292 );
276 const colors = MARKDOWN_COLORS[colorScheme === "dark" ? "dark" : "light"];293 const colors = MARKDOWN_COLORS[colorScheme === "dark" ? "dark" : "light"];
277 const inlineSkillForeground = String(useThemeColor("--color-inline-skill-foreground"));294 const inlineSkillForeground = String(useThemeColor("--color-inline-skill-foreground"));
278 295
@@ -316,14 +333,14 @@ function useMarkdownStyles(onLinkPress: (href: string) => void): MarkdownStyleSe · 37 unmodified lines
316 xl: 16,333 xl: 16,
317 },334 },
318 fontSizes: {335 fontSizes: {
319 s: 13,336 s: markdownFontSizes.s,
320 m: 15,337 m: markdownFontSizes.m,
321 h1: 20,338 h1: markdownFontSizes.h1,
322 h2: 18,339 h2: markdownFontSizes.h2,
323 h3: 16,340 h3: markdownFontSizes.h3,
324 h4: 14,341 h4: markdownFontSizes.h4,
325 h5: 14,342 h5: markdownFontSizes.h5,
326 h6: 14,343 h6: markdownFontSizes.h6,
327 },344 },
328 fontFamilies: {345 fontFamilies: {
329 regular: "DMSans_400Regular",346 regular: "DMSans_400Regular",
@@ -345,7 +362,7 @@ function useMarkdownStyles(onLinkPress: (href: string) => void): MarkdownStyleSe · 15 unmodified lines
345 list: { marginTop: 4, marginBottom: 8 },362 list: { marginTop: 4, marginBottom: 8 },
346 list_item: { marginTop: 0, marginBottom: 4 },363 list_item: { marginTop: 0, marginBottom: 4 },
347 task_list_item: { marginTop: 0, marginBottom: 4 },364 task_list_item: { marginTop: 0, marginBottom: 4 },
348 text: { lineHeight: 22 },365 text: { lineHeight: markdownFontSizes.bodyLineHeight },
349 bold: {366 bold: {
350 fontWeight: "700",367 fontWeight: "700",
351 color: markdownStrongColor,368 color: markdownStrongColor,
@@ -454,7 +471,8 @@ function useMarkdownStyles(onLinkPress: (href: string) => void): MarkdownStyleSe · 102 unmodified lines
454 marginRight: 5,471 marginRight: 5,
455 color: inlineTextColor,472 color: inlineTextColor,
456 fontFamily: "DMSans_400Regular",473 fontFamily: "DMSans_400Regular",
457 ...MOBILE_TYPOGRAPHY.body,474 fontSize: markdownFontSizes.m,
475 lineHeight: markdownFontSizes.bodyLineHeight,
458 textAlign: ordered ? "right" : "center",476 textAlign: ordered ? "right" : "center",
459 }}477 }}
460 >478 >
@@ -475,8 +493,8 @@ function useMarkdownStyles(onLinkPress: (href: string) => void): MarkdownStyleSe · 14 unmodified lines
475 style={{493 style={{
476 color: inlineCodeTextColor,494 color: inlineCodeTextColor,
477 fontFamily: "ui-monospace",495 fontFamily: "ui-monospace",
478 fontSize: MOBILE_TYPOGRAPHY.label.fontSize,496 fontSize: markdownFontSizes.codeBlockFontSize,
479 lineHeight: 22,497 lineHeight: markdownFontSizes.bodyLineHeight,
480 }}498 }}
481 >499 >
482 {value}500 {value}
@@ -512,7 +530,7 @@ function useMarkdownStyles(onLinkPress: (href: string) => void): MarkdownStyleSe · 29 unmodified lines
512 style={{530 style={{
513 color: markdownBodyColor,531 color: markdownBodyColor,
514 fontFamily: "ui-monospace",532 fontFamily: "ui-monospace",
515 fontSize: MOBILE_TYPOGRAPHY.label.fontSize,533 fontSize: markdownFontSizes.codeBlockFontSize,
516 opacity: 0.7,534 opacity: 0.7,
517 textTransform: "uppercase",535 textTransform: "uppercase",
518 }}536 }}
@@ -532,8 +550,8 @@ function useMarkdownStyles(onLinkPress: (href: string) => void): MarkdownStyleSe · 13 unmodified lines
532 style={{550 style={{
533 color: blockTextColor,551 color: blockTextColor,
534 fontFamily: "ui-monospace",552 fontFamily: "ui-monospace",
535 fontSize: MOBILE_TYPOGRAPHY.label.fontSize,553 fontSize: markdownFontSizes.codeBlockFontSize,
536 lineHeight: 18,554 lineHeight: markdownFontSizes.codeBlockLineHeight,
537 }}555 }}
538 >556 >
539 {content}557 {content}
@@ -612,7 +630,9 @@ function useMarkdownStyles(onLinkPress: (href: string) => void): MarkdownStyleSe · 72 unmodified lines
612 skillTextColor: "#f0abfc",630 skillTextColor: "#f0abfc",
613 quoteMarkerColor: markdownUserBodyColor,631 quoteMarkerColor: markdownUserBodyColor,
614 dividerColor: markdownUserBodyColor,632 dividerColor: markdownUserBodyColor,
615 ...MOBILE_TYPOGRAPHY.body,633 fontSize: nativeMarkdownTypography.fontSize,
634 lineHeight: nativeMarkdownTypography.lineHeight,
635 headingFontSizes: nativeMarkdownTypography.headingFontSizes,
616 fontFamily: "DMSans_400Regular",636 fontFamily: "DMSans_400Regular",
617 headingFontFamily: "DMSans_700Bold",637 headingFontFamily: "DMSans_700Bold",
618 boldFontFamily: "DMSans_700Bold",638 boldFontFamily: "DMSans_700Bold",
@@ -641,14 +661,16 @@ function useMarkdownStyles(onLinkPress: (href: string) => void): MarkdownStyleSe · 22 unmodified lines
641 skillTextColor: inlineSkillForeground,661 skillTextColor: inlineSkillForeground,
642 quoteMarkerColor: markdownBlockquoteBorder,662 quoteMarkerColor: markdownBlockquoteBorder,
643 dividerColor: markdownHrColor,663 dividerColor: markdownHrColor,
644 ...MOBILE_TYPOGRAPHY.body,664 fontSize: nativeMarkdownTypography.fontSize,
665 lineHeight: nativeMarkdownTypography.lineHeight,
666 headingFontSizes: nativeMarkdownTypography.headingFontSizes,
645 fontFamily: "DMSans_400Regular",667 fontFamily: "DMSans_400Regular",
646 headingFontFamily: "DMSans_700Bold",668 headingFontFamily: "DMSans_700Bold",
647 boldFontFamily: "DMSans_700Bold",669 boldFontFamily: "DMSans_700Bold",
648 },670 },
649 },671 },
650 };672 };
651 }, [colors, inlineSkillForeground, onLinkPress]);673 }, [colors, inlineSkillForeground, markdownFontSizes, nativeMarkdownTypography, onLinkPress]);
652}674}
653 675
654function renderFeedEntry(676function renderFeedEntry(
@@ -924,6 +946,7 @@ const ReviewCommentCard = memo(function ReviewCommentCard(props: { · 269 unmodified lines
924 readonly comment: ReviewInlineComment;946 readonly comment: ReviewInlineComment;
925 readonly colors: ReviewCommentColors;947 readonly colors: ReviewCommentColors;
926}) {948}) {
949 const { codeSurface, nativeReviewDiffStyle } = useAppearanceCodeSurface();
927 const colorScheme = useColorScheme();950 const colorScheme = useColorScheme();
928 const appearanceScheme = colorScheme === "light" ? "light" : "dark";951 const appearanceScheme = colorScheme === "light" ? "light" : "dark";
929 const NativeReviewDiffView = resolveNativeReviewDiffView();952 const NativeReviewDiffView = resolveNativeReviewDiffView();
@@ -946,18 +969,21 @@ const ReviewCommentCard = memo(function ReviewCommentCard(props: { · 16 unmodified lines
946 () => JSON.stringify(nativeReviewDiffTheme),969 () => JSON.stringify(nativeReviewDiffTheme),
947 [nativeReviewDiffTheme],970 [nativeReviewDiffTheme],
948 );971 );
949 const nativeStyleJson = useMemo(() => JSON.stringify(NATIVE_REVIEW_DIFF_STYLE), []);972 const nativeStyleJson = useMemo(
973 () => JSON.stringify(nativeReviewDiffStyle),
974 [nativeReviewDiffStyle],
975 );
950 const nativeDiffHeight = useMemo(976 const nativeDiffHeight = useMemo(
951 () =>977 () =>
952 Math.min(978 Math.min(
953 360,979 360,
954 Math.max(980 Math.max(
955 112,981 112,
956 compactNativeRows.length * NATIVE_REVIEW_DIFF_ROW_HEIGHT +982 compactNativeRows.length * nativeReviewDiffStyle.rowHeight +
957 NATIVE_REVIEW_DIFF_STYLE.fileHeaderVerticalMargin,983 nativeReviewDiffStyle.fileHeaderVerticalMargin,
958 ),984 ),
959 ),985 ),
960 [compactNativeRows.length],986 [compactNativeRows.length, nativeReviewDiffStyle],
961 );987 );
962 const shouldRenderNativeDiff = NativeReviewDiffView != null && compactNativeRows.length > 0;988 const shouldRenderNativeDiff = NativeReviewDiffView != null && compactNativeRows.length > 0;
963 989
@@ -987,7 +1013,7 @@ const ReviewCommentCard = memo(function ReviewCommentCard(props: { · 23 unmodified lines
987 </View>1013 </View>
988 <View className="min-w-0 flex-1">1014 <View className="min-w-0 flex-1">
989 <Text1015 <Text
990 className="font-mono text-xs leading-[16px]"1016 className="font-mono text-xs"
991 numberOfLines={1}1017 numberOfLines={1}
992 style={{ color: props.colors.text }}1018 style={{ color: props.colors.text }}
993 >1019 >
@@ -1010,7 +1036,7 @@ const ReviewCommentCard = memo(function ReviewCommentCard(props: { · 16 unmodified lines
1010 style={StyleSheet.absoluteFill}1036 style={StyleSheet.absoluteFill}
1011 appearanceScheme={appearanceScheme}1037 appearanceScheme={appearanceScheme}
1012 contentWidth={NATIVE_REVIEW_DIFF_CONTENT_WIDTH}1038 contentWidth={NATIVE_REVIEW_DIFF_CONTENT_WIDTH}
1013 rowHeight={NATIVE_REVIEW_DIFF_ROW_HEIGHT}1039 rowHeight={nativeReviewDiffStyle.rowHeight}
1014 rowsJson={nativeRowsJson}1040 rowsJson={nativeRowsJson}
1015 styleJson={nativeStyleJson}1041 styleJson={nativeStyleJson}
1016 themeJson={nativeThemeJson}1042 themeJson={nativeThemeJson}
@@ -1032,8 +1058,8 @@ const ReviewCommentCard = memo(function ReviewCommentCard(props: { · 15 unmodified lines
1032 style={{1058 style={{
1033 color: props.colors.text,1059 color: props.colors.text,
1034 fontFamily: "ui-monospace",1060 fontFamily: "ui-monospace",
1035 fontSize: MOBILE_CODE_SURFACE.fontSize,1061 fontSize: codeSurface.fontSize,
1036 lineHeight: MOBILE_CODE_SURFACE.rowHeight,1062 lineHeight: codeSurface.rowHeight,
1037 }}1063 }}
1038 >1064 >
1039 {props.comment.diff.trim()}1065 {props.comment.diff.trim()}
@@ -1042,11 +1068,7 @@ const ReviewCommentCard = memo(function ReviewCommentCard(props: { · 2 unmodified lines
1042 ) : null}1068 ) : null}
1043 {props.comment.text.length > 0 ? (1069 {props.comment.text.length > 0 ? (
1044 <View className="border-t px-3 py-3" style={{ borderColor: props.colors.border }}>1070 <View className="border-t px-3 py-3" style={{ borderColor: props.colors.border }}>
1045 <Text1071 <Text selectable className="text-base leading-snug" style={{ color: props.colors.text }}>
1046 selectable
1047 className="text-base leading-[21px]"
1048 style={{ color: props.colors.text }}
1049 >
1050 {props.comment.text}1072 {props.comment.text}
1051 </Text>1073 </Text>
1052 </View>1074 </View>
@@ -1087,7 +1109,6 @@ function ThreadFeedPlaceholder(props: { · 34 unmodified lines
1087 readonly bottomInset: number;1109 readonly bottomInset: number;
1088 readonly detail: string;1110 readonly detail: string;
1089 readonly horizontalPadding: number;1111 readonly horizontalPadding: number;
1090 readonly loading?: boolean;
1091 readonly title: string;1112 readonly title: string;
1092 readonly topInset: number;1113 readonly topInset: number;
1093}) {1114}) {
@@ -1104,9 +1125,8 @@ function ThreadFeedPlaceholder(props: { · 10 unmodified lines
1104 }}1125 }}
1105 >1126 >
1106 <View className="max-w-[320px] items-center gap-2">1127 <View className="max-w-[320px] items-center gap-2">
1107 {props.loading ? <ActivityIndicator style={{ marginBottom: 6 }} /> : null}
1108 <Text className="text-center font-t3-bold text-lg text-foreground">{props.title}</Text>1128 <Text className="text-center font-t3-bold text-lg text-foreground">{props.title}</Text>
1109 <Text className="text-center text-sm leading-5 text-foreground-secondary">1129 <Text className="text-center text-sm leading-normal text-foreground-secondary">
1110 {props.detail}1130 {props.detail}
1111 </Text>1131 </Text>
1112 </View>1132 </View>
@@ -1115,13 +1135,17 @@ function ThreadFeedPlaceholder(props: { · 2 unmodified lines
1115}1135}
1116 1136
1117export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) {1137export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) {
1118 const router = useRouter();1138 const navigation = useNavigation();
1119 const copyFeedbackTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);1139 const copyFeedbackTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
1120 const foldSettleFrameRef = useRef<number | null>(null);1140 const foldSettleFrameRef = useRef<number | null>(null);
1121 const foldSettleSecondFrameRef = useRef<number | null>(null);1141 const foldSettleSecondFrameRef = useRef<number | null>(null);
1122 const disclosureAnchorKeyRef = useRef<string | null>(null);1142 const disclosureAnchorKeyRef = useRef<string | null>(null);
1143 const headerMaterialVisibleRef = useRef(false);
1123 const previousLatestTurnRef = useRef(props.latestTurn);1144 const previousLatestTurnRef = useRef(props.latestTurn);
1124 const { width: viewportWidth } = useWindowDimensions();1145 const { width: windowWidth } = useWindowDimensions();
1146 const [viewportWidth, setViewportWidth] = useState(() =>
1147 props.layoutVariant === "split" ? 0 : windowWidth,
1148 );
1125 const [disclosureToggleSettling, setDisclosureToggleSettling] = useState(false);1149 const [disclosureToggleSettling, setDisclosureToggleSettling] = useState(false);
1126 const [interactionState, setInteractionState] = useState<{1150 const [interactionState, setInteractionState] = useState<{
1127 readonly copiedRowId: string | null;1151 readonly copiedRowId: string | null;
@@ -1140,12 +1164,19 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { · 12 unmodified lines
1140 headers?: Record<string, string>;1164 headers?: Record<string, string>;
1141 } | null>(null);1165 } | null>(null);
1142 const horizontalPadding = props.layoutVariant === "split" ? 20 : 16;1166 const horizontalPadding = props.layoutVariant === "split" ? 20 : 16;
1143 const contentWidth = Math.max(0, viewportWidth - horizontalPadding * 2);1167 const contentHorizontalPadding = deriveCenteredContentHorizontalPadding({
1168 viewportWidth,
1169 maxContentWidth: props.contentMaxWidth ?? null,
1170 minimumPadding: horizontalPadding,
1171 });
1172 const contentWidth = Math.max(0, viewportWidth - contentHorizontalPadding * 2);
1144 const userBubbleMaxWidth = contentWidth * 0.85;1173 const userBubbleMaxWidth = contentWidth * 0.85;
1145 const reviewCommentBubbleWidth = Math.min(Math.max(280, contentWidth * 0.85), contentWidth);1174 const reviewCommentBubbleWidth = Math.min(Math.max(280, contentWidth * 0.85), contentWidth);
1146 const insets = useSafeAreaInsets();1175 const insets = useSafeAreaInsets();
1147 const topContentInset = props.contentTopInset ?? insets.top + 44;1176 const topContentInset = props.contentTopInset ?? insets.top + 44;
1148 const bottomContentInset = props.contentBottomInset ?? 18;1177 const bottomContentInset = props.contentBottomInset ?? 18;
1178 const usesNativeAutomaticInsets =
1179 props.usesAutomaticContentInsets === true && Platform.OS === "ios";
1149 1180
1150 const iconSubtleColor = useThemeColor("--color-icon-subtle");1181 const iconSubtleColor = useThemeColor("--color-icon-subtle");
1151 const userBubbleColor = useThemeColor("--color-user-bubble");1182 const userBubbleColor = useThemeColor("--color-user-bubble");
@@ -1159,13 +1190,12 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { · 7 unmodified lines
1159 );1190 );
1160 if (relativePath) {1191 if (relativePath) {
1161 void Haptics.selectionAsync();1192 void Haptics.selectionAsync();
1162 router.push(1193 navigation.navigate("ThreadFile", {
1163 buildThreadFilesNavigation(1194 environmentId: String(props.environmentId),
1164 { environmentId: props.environmentId, threadId: props.threadId },1195 threadId: String(props.threadId),
1165 relativePath,1196 path: relativePath.split("/").filter((segment) => segment.length > 0),
1166 presentation.line,1197 ...(presentation.line ? { line: String(presentation.line) } : {}),
1167 ),1198 });
1168 );
1169 }1199 }
1170 return;1200 return;
1171 }1201 }
@@ -1174,7 +1204,7 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { · 2 unmodified lines
1174 void Linking.openURL(presentation.href);1204 void Linking.openURL(presentation.href);
1175 }1205 }
1176 },1206 },
1177 [props.environmentId, props.threadId, props.workspaceRoot, router],1207 [props.environmentId, props.threadId, props.workspaceRoot, navigation],
1178 );1208 );
1179 const markdownStyles = useMarkdownStyles(onMarkdownLinkPress);1209 const markdownStyles = useMarkdownStyles(onMarkdownLinkPress);
1180 const reviewCommentColors = useReviewCommentColors();1210 const reviewCommentColors = useReviewCommentColors();
@@ -1188,6 +1218,7 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { · 7 unmodified lines
1188 markdownStyles,1218 markdownStyles,
1189 reviewCommentColors,1219 reviewCommentColors,
1190 userBubbleColor,1220 userBubbleColor,
1221 viewportWidth,
1191 }),1222 }),
1192 [1223 [
1193 copiedRowId,1224 copiedRowId,
@@ -1196,8 +1227,34 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { · 2 unmodified lines
1196 markdownStyles,1227 markdownStyles,
1197 reviewCommentColors,1228 reviewCommentColors,
1198 userBubbleColor,1229 userBubbleColor,
1230 viewportWidth,
1199 ],1231 ],
1200 );1232 );
1233 const reportHeaderMaterialVisibility = useCallback(
1234 (visible: boolean) => {
1235 if (headerMaterialVisibleRef.current === visible) {
1236 return;
1237 }
1238 headerMaterialVisibleRef.current = visible;
1239 props.onHeaderMaterialVisibilityChange?.(visible);
1240 },
1241 [props.onHeaderMaterialVisibilityChange],
1242 );
1243 const handleScroll = useCallback(
1244 (event: NativeSyntheticEvent<NativeScrollEvent>) => {
1245 reportHeaderMaterialVisibility(event.nativeEvent.contentOffset.y + topContentInset > 6);
1246 },
1247 [reportHeaderMaterialVisibility, topContentInset],
1248 );
1249 const handleViewportLayout = useCallback((event: LayoutChangeEvent) => {
1250 const nextWidth = Math.round(event.nativeEvent.layout.width);
1251 setViewportWidth((current) => (Math.abs(current - nextWidth) > 1 ? nextWidth : current));
1252 }, []);
1253
1254 useEffect(() => {
1255 reportHeaderMaterialVisibility(false);
1256 }, [props.threadId, reportHeaderMaterialVisibility]);
1257
1201 const expandedWorkGroupIds = useMemo(() => {1258 const expandedWorkGroupIds = useMemo(() => {
1202 const ids = new Set<string>();1259 const ids = new Set<string>();
1203 for (const [groupId, expanded] of Object.entries(expandedWorkGroups)) {1260 for (const [groupId, expanded] of Object.entries(expandedWorkGroups)) {
@@ -1217,6 +1274,7 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { · 13 unmodified lines
1217 ),1274 ),
1218 [expandedTurnIds, expandedWorkGroupIds, props.feed, props.latestTurn],1275 [expandedTurnIds, expandedWorkGroupIds, props.feed, props.latestTurn],
1219 );1276 );
1277
1220 const anchoredEndSpace = useMemo(1278 const anchoredEndSpace = useMemo(
1221 () =>1279 () =>
1222 resolveChatListAnchoredEndSpace(1280 resolveChatListAnchoredEndSpace(
@@ -1424,19 +1482,6 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { · 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 if (props.contentPresentation.kind === "unavailable") {1485 if (props.contentPresentation.kind === "unavailable") {
1441 return (1486 return (
1442 <ThreadFeedPlaceholder1487 <ThreadFeedPlaceholder
@@ -1451,52 +1496,76 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { · 8 unmodified lines
1451 1496
1452 return (1497 return (
1453 <>1498 <>
1454 <View style={{ flex: 1 }}>1499 <View style={{ flex: 1 }} onLayout={handleViewportLayout}>
1455 <KeyboardAwareLegendList1500 <View style={{ flex: 1 }}>
1456 ref={props.listRef}1501 <KeyboardAwareLegendList
1457 key={props.threadId}1502 ref={props.listRef}
1458 style={{ flex: 1 }}1503 // The empty↔filled key remounts the list when messages first
1459 // RN 0.81+ drops touches inside the contentInset area1504 // arrive. LegendList's maintainScrollAtEnd calls scrollToEnd(),
1460 // (facebook/react-native#54123); the anchored end space after a send1505 // which is blind to UIKit's adjustedContentInset — inserting into
1461 // is pure inset, so without this the blank region can't be scrolled.1506 // an already-attached list under a transparent header can pin
1462 applyWorkaroundForContentInsetHitTestBug1507 // short content at offset 0 (one header-height too high). A fresh
1463 automaticallyAdjustsScrollIndicatorInsets={false}1508 // mount positions during attach, where UIKit applies the inset.
1464 scrollIndicatorInsets={{ top: topContentInset, bottom: 0 }}1509 key={`${props.threadId}:${props.feed.length === 0 ? "empty" : "filled"}`}
1465 {...(anchoredEndSpace ? { anchoredEndSpace } : {})}1510 style={{ flex: 1 }}
1466 contentInsetEndAdjustment={props.contentInsetEndAdjustment}1511 // RN 0.81+ drops touches inside the contentInset area
1467 freeze={props.freeze}1512 // (facebook/react-native#54123); the anchored end space after a send
1468 maintainScrollAtEnd={1513 // is pure inset, so without this the blank region can't be scrolled.
1469 disclosureToggleSettling1514 applyWorkaroundForContentInsetHitTestBug
1470 ? false1515 contentInsetAdjustmentBehavior={usesNativeAutomaticInsets ? "automatic" : "never"}
1471 : {1516 automaticallyAdjustsScrollIndicatorInsets={usesNativeAutomaticInsets}
1472 animated: false,1517 {...(usesNativeAutomaticInsets
1473 on: {1518 ? {
1474 dataChange: true,1519 // Do NOT pass a manual `contentInset` here. Like the Home
1475 itemLayout: true,1520 // ScrollView, we rely purely on `contentInsetAdjustmentBehavior:
1476 layout: true,1521 // "automatic"` so UIKit derives the top inset from the transparent
1477 },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 }1528 : { scrollIndicatorInsets: { top: topContentInset, bottom: 0 } })}
1480 maintainVisibleContentPosition={maintainVisibleContentPosition}1529 {...(anchoredEndSpace ? { anchoredEndSpace } : {})}
1481 data={presentedFeed}1530 contentInsetEndAdjustment={props.contentInsetEndAdjustment}
1482 extraData={listAppearanceData}1531 freeze={props.freeze}
1483 renderItem={renderItem}1532 maintainScrollAtEnd={
1484 keyExtractor={(entry) => entry.id}1533 disclosureToggleSettling
1485 getItemType={(entry) =>1534 ? false
1486 entry.type === "message" ? `message:${entry.message.role}` : entry.type1535 : {
1487 }1536 animated: false,
1488 keyboardShouldPersistTaps="always"1537 on: {
1489 keyboardDismissMode="none"1538 dataChange: true,
1490 keyboardLiftBehavior="whenAtEnd"1539 itemLayout: true,
1491 estimatedItemSize={180}1540 layout: true,
1492 initialScrollAtEnd1541 },
1493 ListHeaderComponent={<View style={{ height: topContentInset }} />}1542 }
1494 contentContainerStyle={{1543 }
1495 paddingTop: 12,1544 maintainVisibleContentPosition={maintainVisibleContentPosition}
1496 paddingHorizontal: horizontalPadding,1545 data={presentedFeed}
1497 }}1546 extraData={listAppearanceData}
1498 />1547 renderItem={renderItem}
1499 {props.feed.length === 0 ? (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 <View pointerEvents="none" style={StyleSheet.absoluteFill}>1569 <View pointerEvents="none" style={StyleSheet.absoluteFill}>
1501 <ThreadFeedPlaceholder1570 <ThreadFeedPlaceholder
1502 title="No conversation yet"1571 title="No conversation yet"
apps/mobile/src/features/threads/ThreadGitControls.tsx+382−122
@@ -1,20 +1,19 @@
1import type {1import {
2 EnvironmentId,2 EnvironmentId,
3 GitRunStackedActionResult,3 type GitRunStackedActionResult,
4 ProjectScript,4 type ProjectScript,
5 ThreadId,5 ThreadId,
6 VcsStatusResult,6 type VcsStatusResult,
7} from "@t3tools/contracts";7} from "@t3tools/contracts";
8import {8import {
9 type GitActionRequestInput,9 type GitActionRequestInput,
10 requiresDefaultBranchConfirmation,10 requiresDefaultBranchConfirmation,
11 resolveQuickAction,11 resolveQuickAction,
12} from "@t3tools/client-runtime/state/vcs";12} from "@t3tools/client-runtime/state/vcs";
13import { useLocalSearchParams, useRouter } from "expo-router";13import { useNavigation } from "@react-navigation/native";
14import Stack from "expo-router/stack";14import { NativeHeaderToolbar } from "../../native/StackHeader";
15import { useCallback, useMemo } from "react";15import { useCallback, useMemo } from "react";
16import { Alert } from "react-native";16import { Alert } from "react-native";
17import { buildThreadFilesNavigation, buildThreadReviewRoutePath } from "../../lib/routes";
18import { tryOpenExternalUrl } from "../../lib/openExternalUrl";17import { tryOpenExternalUrl } from "../../lib/openExternalUrl";
19import {18import {
20 basename,19 basename,
@@ -65,25 +64,52 @@ function compactMenuStatus(gitStatus: VcsStatusResult | null): string { · 44 unmodified lines
65 return parts.join(" · ");64 return parts.join(" · ");
66}65}
67 66
68export function ThreadGitControls(props: {67type HeaderItem = Record<string, unknown>;
68type HeaderItems = HeaderItem[];
69type ThreadGitHeaderActionItems = {
70 readonly terminal: HeaderItem;
71 readonly files: HeaderItem;
72 readonly git: HeaderItem;
73};
74type QuickActionIcon =
75 | "arrow.down.circle"
76 | "arrow.up.right.circle"
77 | "checkmark.circle"
78 | "arrow.up.circle";
79
80/** The subset of git-control wiring the standalone git menu needs. */
81export type ThreadGitMenuProps = {
82 readonly environmentId: EnvironmentId | string;
83 readonly threadId: ThreadId | string;
69 readonly currentBranch: string | null;84 readonly currentBranch: string | null;
70 readonly gitStatus: VcsStatusResult | null;85 readonly gitStatus: VcsStatusResult | null;
71 readonly gitOperationLabel: string | null;86 readonly gitOperationLabel: string | null;
87 readonly onOpenFilesInspector?: () => void;
88 readonly onOpenGitInspector?: () => void;
89 readonly onPull: () => Promise<void>;
90 readonly onRunAction: (input: GitActionRequestInput) => Promise<GitRunStackedActionResult | null>;
91};
92
93type ThreadGitControlsProps = ThreadGitMenuProps & {
94 readonly auxiliaryPaneControl?: {
95 readonly accessibilityLabel: string;
96 readonly onPress: () => void;
97 };
72 readonly canOpenTerminal: boolean;98 readonly canOpenTerminal: boolean;
73 readonly canOpenFiles: boolean;99 readonly canOpenFiles: boolean;
74 readonly projectScripts: ReadonlyArray<ProjectScript>;100 readonly projectScripts: ReadonlyArray<ProjectScript>;
75 readonly terminalSessions: ReadonlyArray<TerminalMenuSession>;101 readonly terminalSessions: ReadonlyArray<TerminalMenuSession>;
102 readonly showActionControls?: boolean;
103 readonly showDirectFileControl?: boolean;
76 readonly onOpenTerminal: (terminalId?: string | null) => void;104 readonly onOpenTerminal: (terminalId?: string | null) => void;
77 readonly onOpenNewTerminal: () => void;105 readonly onOpenNewTerminal: () => void;
78 readonly onRunProjectScript: (script: ProjectScript) => Promise<void>;106 readonly onRunProjectScript: (script: ProjectScript) => Promise<void>;
79 readonly onPull: () => Promise<void>;107};
80 readonly onRunAction: (input: GitActionRequestInput) => Promise<GitRunStackedActionResult | null>;108
81}) {109function useThreadGitControlModel(props: ThreadGitMenuProps) {
82 const router = useRouter();110 const navigation = useNavigation();
83 const { environmentId, threadId } = useLocalSearchParams<{111 const environmentId = props.environmentId;
84 environmentId: EnvironmentId;112 const threadId = props.threadId;
85 threadId: ThreadId;
86 }>();
87 const { gitStatus, gitOperationLabel, onPull, onRunAction } = props;113 const { gitStatus, gitOperationLabel, onPull, onRunAction } = props;
88 114
89 const currentBranchLabel = gitStatus?.refName ?? props.currentBranch ?? "Detached HEAD";115 const currentBranchLabel = gitStatus?.refName ?? props.currentBranch ?? "Detached HEAD";
@@ -109,7 +135,7 @@ export function ThreadGitControls(props: { · 19 unmodified lines
109 ? (quickAction.hint ?? "This action is unavailable.")135 ? (quickAction.hint ?? "This action is unavailable.")
110 : null;136 : null;
111 137
112 const quickActionIcon = (() => {138 const quickActionIcon: QuickActionIcon = (() => {
113 if (quickAction.kind === "run_pull") return "arrow.down.circle";139 if (quickAction.kind === "run_pull") return "arrow.down.circle";
114 if (quickAction.kind === "open_pr") return "arrow.up.right.circle";140 if (quickAction.kind === "open_pr") return "arrow.up.right.circle";
115 if (quickAction.kind === "run_action") {141 if (quickAction.kind === "run_action") {
@@ -147,24 +173,21 @@ export function ThreadGitControls(props: { · 31 unmodified lines
147 !input.featureBranch &&173 !input.featureBranch &&
148 requiresDefaultBranchConfirmation(input.action, isDefaultRef)174 requiresDefaultBranchConfirmation(input.action, isDefaultRef)
149 ) {175 ) {
150 router.push({176 navigation.navigate("GitConfirm", {
151 pathname: "/threads/[environmentId]/[threadId]/git-confirm",177 environmentId: String(environmentId),
152 params: {178 threadId: String(threadId),
153 environmentId,179 confirmAction: confirmableAction,
154 threadId,180 branchName,
155 confirmAction: confirmableAction,181 includesCommit: String(
156 branchName,182 input.action === "commit_push" || input.action === "commit_push_pr",
157 includesCommit: String(183 ),
158 input.action === "commit_push" || input.action === "commit_push_pr",
159 ),
160 },
161 });184 });
162 return;185 return;
163 }186 }
164 187
165 await onRunAction(input);188 await onRunAction(input);
166 },189 },
167 [environmentId, gitStatus, isDefaultRef, onRunAction, router, threadId],190 [environmentId, gitStatus, isDefaultRef, onRunAction, navigation, threadId],
168 );191 );
169 192
170 const runQuickAction = useCallback(async () => {193 const runQuickAction = useCallback(async () => {
@@ -181,102 +204,339 @@ export function ThreadGitControls(props: { · 10 unmodified lines
181 }204 }
182 }, [onPull, openExistingPr, quickAction, runActionWithPrompt]);205 }, [onPull, openExistingPr, quickAction, runActionWithPrompt]);
183 206
207 const openFiles = useCallback(() => {
208 if (props.onOpenFilesInspector) {
209 props.onOpenFilesInspector();
210 return;
211 }
212 navigation.navigate("ThreadFiles", {
213 environmentId: String(environmentId),
214 threadId: String(threadId),
215 });
216 }, [environmentId, props.onOpenFilesInspector, navigation, threadId]);
217
218 const openReview = useCallback(() => {
219 navigation.navigate("ThreadReview", {
220 environmentId: EnvironmentId.make(String(environmentId)),
221 threadId: ThreadId.make(String(threadId)),
222 });
223 }, [environmentId, navigation, threadId]);
224
225 const openGitInspector = useCallback(() => {
226 if (props.onOpenGitInspector) {
227 props.onOpenGitInspector();
228 return;
229 }
230 navigation.navigate("GitOverview", {
231 environmentId: String(environmentId),
232 threadId: String(threadId),
233 });
234 }, [environmentId, props.onOpenGitInspector, navigation, threadId]);
235
236 return {
237 currentBranchLabel,
238 isRepo,
239 openFiles,
240 openGitInspector,
241 openReview,
242 quickAction,
243 quickActionHint,
244 quickActionIcon,
245 runQuickAction,
246 };
247}
248
249function useThreadGitHeaderActionItems(props: ThreadGitControlsProps): ThreadGitHeaderActionItems {
250 const model = useThreadGitControlModel(props);
251
252 return useMemo(
253 () => ({
254 terminal: {
255 accessibilityLabel: "Open terminal",
256 disabled: !props.canOpenTerminal,
257 icon: { name: "terminal", type: "sfSymbol" },
258 identifier: "thread-right-terminal",
259 label: "Terminal",
260 menu: {
261 items: [
262 ...props.projectScripts.map((script) => ({
263 description: script.command,
264 icon: { name: projectScriptMenuIcon(script.icon), type: "sfSymbol" as const },
265 label: projectScriptMenuLabel(script),
266 onPress: () => void props.onRunProjectScript(script),
267 type: "action" as const,
268 })),
269 ...(props.projectScripts.length === 0
270 ? [
271 {
272 description: "This project has no saved scripts yet",
273 disabled: true,
274 icon: { name: "play", type: "sfSymbol" as const },
275 label: "No project scripts",
276 onPress: () => {},
277 type: "action" as const,
278 },
279 ]
280 : []),
281 ...props.terminalSessions.map((session) => ({
282 description: [
283 getTerminalStatusLabel({
284 status: session.status,
285 hasRunningSubprocess: session.hasRunningSubprocess,
286 }),
287 basename(session.cwd),
288 ]
289 .filter(Boolean)
290 .join(" · "),
291 icon: { name: "terminal", type: "sfSymbol" as const },
292 label: session.displayLabel,
293 onPress: () => props.onOpenTerminal(session.terminalId),
294 type: "action" as const,
295 })),
296 {
297 description: "Start another shell for this thread",
298 icon: { name: "plus", type: "sfSymbol" },
299 label: "Open new terminal",
300 onPress: props.onOpenNewTerminal,
301 type: "action",
302 },
303 ],
304 title: "Terminal",
305 },
306 sharesBackground: true,
307 type: "menu",
308 variant: "plain",
309 },
310 files: {
311 accessibilityLabel: "Open files",
312 disabled: !props.canOpenFiles,
313 icon: { name: "folder", type: "sfSymbol" },
314 identifier: "thread-right-files",
315 label: "Files",
316 onPress: model.openFiles,
317 sharesBackground: true,
318 type: "button",
319 variant: "plain",
320 },
321 git: {
322 accessibilityLabel: "Git actions",
323 icon: { name: "point.topleft.down.curvedto.point.bottomright.up", type: "sfSymbol" },
324 identifier: "thread-right-git",
325 label: "Git",
326 menu: {
327 items: [
328 {
329 description: compactMenuStatus(props.gitStatus),
330 disabled: true,
331 icon: {
332 name: "point.topleft.down.curvedto.point.bottomright.up",
333 type: "sfSymbol",
334 },
335 label: compactMenuBranchLabel(model.currentBranchLabel),
336 onPress: (): void => {},
337 type: "action",
338 },
339 {
340 description: model.quickActionHint ?? undefined,
341 disabled: model.quickAction.disabled,
342 icon: { name: model.quickActionIcon, type: "sfSymbol" },
343 label: model.quickAction.label,
344 onPress: (): void => void model.runQuickAction(),
345 type: "action",
346 },
347 {
348 description: "Turn diffs and worktree changes",
349 disabled: !model.isRepo,
350 icon: { name: "text.bubble", type: "sfSymbol" },
351 label: "Review changes",
352 onPress: model.openReview,
353 type: "action",
354 },
355 {
356 description: "Commit, files, branches",
357 icon: { name: "ellipsis", type: "sfSymbol" },
358 label: "More",
359 onPress: model.openGitInspector,
360 type: "action",
361 },
362 ],
363 title: "Git",
364 },
365 sharesBackground: true,
366 type: "menu",
367 variant: "plain",
368 },
369 }),
370 [
371 model.currentBranchLabel,
372 model.isRepo,
373 model.openFiles,
374 model.openGitInspector,
375 model.openReview,
376 model.quickAction.disabled,
377 model.quickAction.label,
378 model.quickActionHint,
379 model.quickActionIcon,
380 model.runQuickAction,
381 props.canOpenFiles,
382 props.canOpenTerminal,
383 props.gitStatus,
384 props.onOpenNewTerminal,
385 props.onOpenTerminal,
386 props.onRunProjectScript,
387 props.projectScripts,
388 props.terminalSessions,
389 ],
390 );
391}
392
393export function useThreadGitRightHeaderItems(props: ThreadGitControlsProps): HeaderItems {
394 const actionItems = useThreadGitHeaderActionItems(props);
395 return useMemo(
396 () => [actionItems.git, actionItems.files, actionItems.terminal] as HeaderItems,
397 [actionItems],
398 );
399}
400
401export function useThreadGitCenterHeaderItems(props: ThreadGitControlsProps): HeaderItems {
402 const actionItems = useThreadGitHeaderActionItems(props);
403 return useMemo(
404 () => [actionItems.files, actionItems.git, actionItems.terminal] as HeaderItems,
405 [actionItems],
406 );
407}
408
409export function ThreadGitControls(props: ThreadGitControlsProps) {
410 const model = useThreadGitControlModel(props);
411 const showActionControls = props.showActionControls ?? true;
412
413 if (!showActionControls) {
414 return null;
415 }
416
184 return (417 return (
185 <Stack.Toolbar placement="right">418 <NativeHeaderToolbar placement="right">
186 <Stack.Toolbar.Menu icon="terminal" disabled={!props.canOpenTerminal} separateBackground>419 {showActionControls && props.auxiliaryPaneControl ? (
187 {props.projectScripts.length > 0 ? (420 <NativeHeaderToolbar.Button
188 props.projectScripts.map((script) => (421 accessibilityLabel={props.auxiliaryPaneControl.accessibilityLabel}
189 <Stack.Toolbar.MenuAction422 icon="sidebar.right"
190 key={script.id}423 onPress={props.auxiliaryPaneControl.onPress}
191 icon={projectScriptMenuIcon(script.icon)}424 separateBackground
192 onPress={() => void props.onRunProjectScript(script)}425 />
193 subtitle={script.command}426 ) : null}
427 {showActionControls ? (
428 <NativeHeaderToolbar.Menu
429 icon="terminal"
430 disabled={!props.canOpenTerminal}
431 separateBackground
432 >
433 {props.projectScripts.length > 0 ? (
434 props.projectScripts.map((script) => (
435 <NativeHeaderToolbar.MenuAction
436 key={script.id}
437 icon={projectScriptMenuIcon(script.icon)}
438 onPress={() => void props.onRunProjectScript(script)}
439 subtitle={script.command}
440 >
441 <NativeHeaderToolbar.Label>
442 {projectScriptMenuLabel(script)}
443 </NativeHeaderToolbar.Label>
444 </NativeHeaderToolbar.MenuAction>
445 ))
446 ) : (
447 <NativeHeaderToolbar.MenuAction
448 icon="play"
449 disabled
450 onPress={() => {}}
451 subtitle="This project has no saved scripts yet"
194 >452 >
195 <Stack.Toolbar.Label>{projectScriptMenuLabel(script)}</Stack.Toolbar.Label>453 <NativeHeaderToolbar.Label>No project scripts</NativeHeaderToolbar.Label>
196 </Stack.Toolbar.MenuAction>454 </NativeHeaderToolbar.MenuAction>
197 ))455 )}
198 ) : (456 {props.terminalSessions.map((session) => (
199 <Stack.Toolbar.MenuAction457 <NativeHeaderToolbar.MenuAction
200 icon="play"458 key={session.terminalId}
201 disabled459 icon="terminal"
202 onPress={() => {}}460 onPress={() => props.onOpenTerminal(session.terminalId)}
203 subtitle="This project has no saved scripts yet"461 subtitle={[
204 >462 getTerminalStatusLabel({
205 <Stack.Toolbar.Label>No project scripts</Stack.Toolbar.Label>463 status: session.status,
206 </Stack.Toolbar.MenuAction>464 hasRunningSubprocess: session.hasRunningSubprocess,
207 )}465 }),
208 {props.terminalSessions.map((session) => (466 basename(session.cwd),
209 <Stack.Toolbar.MenuAction467 ]
210 key={session.terminalId}468 .filter(Boolean)
211 icon="terminal"469 .join(" · ")}
212 onPress={() => props.onOpenTerminal(session.terminalId)}470 >
213 subtitle={[471 <NativeHeaderToolbar.Label>{session.displayLabel}</NativeHeaderToolbar.Label>
214 getTerminalStatusLabel({472 </NativeHeaderToolbar.MenuAction>
215 status: session.status,473 ))}
216 hasRunningSubprocess: session.hasRunningSubprocess,474 <NativeHeaderToolbar.MenuAction
217 }),475 icon="plus"
218 basename(session.cwd),476 onPress={props.onOpenNewTerminal}
219 ]477 subtitle="Start another shell for this thread"
220 .filter(Boolean)
221 .join(" · ")}
222 >478 >
223 <Stack.Toolbar.Label>{session.displayLabel}</Stack.Toolbar.Label>479 <NativeHeaderToolbar.Label>Open new terminal</NativeHeaderToolbar.Label>
224 </Stack.Toolbar.MenuAction>480 </NativeHeaderToolbar.MenuAction>
225 ))}481 </NativeHeaderToolbar.Menu>
226 <Stack.Toolbar.MenuAction482 ) : null}
227 icon="plus"483 {showActionControls && props.showDirectFileControl ? (
228 onPress={props.onOpenNewTerminal}484 <NativeHeaderToolbar.Button
229 subtitle="Start another shell for this thread"485 accessibilityLabel="Open files"
230 >
231 <Stack.Toolbar.Label>Open new terminal</Stack.Toolbar.Label>
232 </Stack.Toolbar.MenuAction>
233 </Stack.Toolbar.Menu>
234 <Stack.Toolbar.Menu icon="point.topleft.down.curvedto.point.bottomright.up">
235 <Stack.Toolbar.MenuAction
236 icon="point.topleft.down.curvedto.point.bottomright.up"
237 disabled
238 onPress={() => {}}
239 subtitle={compactMenuStatus(gitStatus)}
240 >
241 <Stack.Toolbar.Label>{compactMenuBranchLabel(currentBranchLabel)}</Stack.Toolbar.Label>
242 </Stack.Toolbar.MenuAction>
243 <Stack.Toolbar.MenuAction
244 icon={quickActionIcon}
245 disabled={quickAction.disabled}
246 onPress={() => void runQuickAction()}
247 subtitle={quickActionHint ?? undefined}
248 >
249 <Stack.Toolbar.Label>{quickAction.label}</Stack.Toolbar.Label>
250 </Stack.Toolbar.MenuAction>
251 <Stack.Toolbar.MenuAction
252 icon="text.bubble"
253 disabled={!isRepo}
254 onPress={() => router.push(buildThreadReviewRoutePath({ environmentId, threadId }))}
255 subtitle="Turn diffs and worktree changes"
256 >
257 <Stack.Toolbar.Label>Review changes</Stack.Toolbar.Label>
258 </Stack.Toolbar.MenuAction>
259 <Stack.Toolbar.MenuAction
260 icon="folder"
261 disabled={!props.canOpenFiles}486 disabled={!props.canOpenFiles}
262 onPress={() => router.push(buildThreadFilesNavigation({ environmentId, threadId }))}487 icon="folder"
263 subtitle="Browse this workspace"488 onPress={model.openFiles}
264 >489 separateBackground
265 <Stack.Toolbar.Label>Files</Stack.Toolbar.Label>490 />
266 </Stack.Toolbar.MenuAction>491 ) : null}
267 <Stack.Toolbar.MenuAction492 {showActionControls ? <ThreadGitMenu {...props} /> : null}
268 icon="ellipsis.circle"493 </NativeHeaderToolbar>
269 onPress={() =>494 );
270 router.push({495}
271 pathname: "/threads/[environmentId]/[threadId]/git",496
272 params: { environmentId, threadId },497/**
273 })498 * The standalone git actions menu (branch status, quick commit/push action,
274 }499 * review, more). Rendered inside a NativeHeaderToolbar by both the thread
275 subtitle="Commit, files, branches"500 * chat header and the review screen's toolbar.
276 >501 */
277 <Stack.Toolbar.Label>More</Stack.Toolbar.Label>502export function ThreadGitMenu(props: ThreadGitMenuProps) {
278 </Stack.Toolbar.MenuAction>503 const model = useThreadGitControlModel(props);
279 </Stack.Toolbar.Menu>504
280 </Stack.Toolbar>505 return (
506 <NativeHeaderToolbar.Menu icon="point.topleft.down.curvedto.point.bottomright.up">
507 <NativeHeaderToolbar.MenuAction
508 icon="point.topleft.down.curvedto.point.bottomright.up"
509 disabled
510 onPress={() => {}}
511 subtitle={compactMenuStatus(props.gitStatus)}
512 >
513 <NativeHeaderToolbar.Label>
514 {compactMenuBranchLabel(model.currentBranchLabel)}
515 </NativeHeaderToolbar.Label>
516 </NativeHeaderToolbar.MenuAction>
517 <NativeHeaderToolbar.MenuAction
518 icon={model.quickActionIcon}
519 disabled={model.quickAction.disabled}
520 onPress={() => void model.runQuickAction()}
521 subtitle={model.quickActionHint ?? undefined}
522 >
523 <NativeHeaderToolbar.Label>{model.quickAction.label}</NativeHeaderToolbar.Label>
524 </NativeHeaderToolbar.MenuAction>
525 <NativeHeaderToolbar.MenuAction
526 icon="text.bubble"
527 disabled={!model.isRepo}
528 onPress={model.openReview}
529 subtitle="Turn diffs and worktree changes"
530 >
531 <NativeHeaderToolbar.Label>Review changes</NativeHeaderToolbar.Label>
532 </NativeHeaderToolbar.MenuAction>
533 <NativeHeaderToolbar.MenuAction
534 icon="ellipsis"
535 onPress={model.openGitInspector}
536 subtitle="Commit, files, branches"
537 >
538 <NativeHeaderToolbar.Label>More</NativeHeaderToolbar.Label>
539 </NativeHeaderToolbar.MenuAction>
540 </NativeHeaderToolbar.Menu>
281 );541 );
282}542}
apps/mobile/src/features/threads/ThreadNavigationDrawer.tsx+7−34
@@ -8,8 +8,6 @@ import { · 7 unmodified lines
8 useWindowDimensions,8 useWindowDimensions,
9 View,9 View,
10} from "react-native";10} from "react-native";
11import * as Arr from "effect/Array";
12import * as Order from "effect/Order";
13import { Gesture, GestureDetector } from "react-native-gesture-handler";11import { Gesture, GestureDetector } from "react-native-gesture-handler";
14import { useSafeAreaInsets } from "react-native-safe-area-context";12import { useSafeAreaInsets } from "react-native-safe-area-context";
15import Animated, {13import Animated, {
@@ -23,22 +21,11 @@ import { useThemeColor } from "../../lib/useThemeColor"; · 7 unmodified lines
23import { AppText as Text } from "../../components/AppText";21import { AppText as Text } from "../../components/AppText";
24import { StatusPill } from "../../components/StatusPill";22import { StatusPill } from "../../components/StatusPill";
25import { useProjects, useThreadShells } from "../../state/entities";23import { useProjects, useThreadShells } from "../../state/entities";
26import { groupProjectsByRepository } from "../../lib/repositoryGroups";
27import { scopedThreadKey } from "../../lib/scopedEntities";24import { scopedThreadKey } from "../../lib/scopedEntities";
28import { relativeTime } from "../../lib/time";25import { relativeTime } from "../../lib/time";
29import { threadStatusTone } from "./threadPresentation";26import { resolveThreadStatus } from "./threadPresentation";
30import { EnvironmentThreadShell } from "@t3tools/client-runtime/state/shell";27import type { EnvironmentThreadShell } from "@t3tools/client-runtime/state/shell";
31 28import { buildThreadNavigationGroups } from "./thread-navigation-groups";
32const threadActivityOrder = Order.mapInput(
33 Order.Struct({
34 activityAt: Order.flip(Order.Number),
35 title: Order.String,
36 }),
37 (thread: EnvironmentThreadShell) => ({
38 activityAt: new Date(thread.updatedAt ?? thread.createdAt).getTime(),
39 title: thread.title,
40 }),
41);
42 29
43export function ThreadNavigationDrawer(props: {30export function ThreadNavigationDrawer(props: {
44 readonly visible: boolean;31 readonly visible: boolean;
@@ -192,24 +179,9 @@ function ThreadNavigationDrawerContent(props: { · 147 unmodified lines
192}) {179}) {
193 const projects = useProjects();180 const projects = useProjects();
194 const threads = useThreadShells();181 const threads = useThreadShells();
195 const repositoryGroups = useMemo(
196 () => groupProjectsByRepository({ projects, threads }),
197 [projects, threads],
198 );
199 const groupedThreads = useMemo(182 const groupedThreads = useMemo(
200 () =>183 () => buildThreadNavigationGroups({ projects, threads }),
201 repositoryGroups.map((group) => {184 [projects, threads],
202 const threads: EnvironmentThreadShell[] = [];
203 for (const projectGroup of group.projects) {
204 threads.push(...projectGroup.threads);
205 }
206 return {
207 key: group.key,
208 title: group.projects[0]?.project.title ?? group.title,
209 threads: Arr.sort(threads, threadActivityOrder),
210 };
211 }),
212 [repositoryGroups],
213 );185 );
214 186
215 return (187 return (
@@ -239,6 +211,7 @@ function ThreadNavigationDrawerContent(props: { · 23 unmodified lines
239 group.threads.map((thread, index) => {211 group.threads.map((thread, index) => {
240 const threadKey = scopedThreadKey(thread.environmentId, thread.id);212 const threadKey = scopedThreadKey(thread.environmentId, thread.id);
241 const selected = props.selectedThreadKey === threadKey;213 const selected = props.selectedThreadKey === threadKey;
214 const status = resolveThreadStatus(thread);
242 215
243 return (216 return (
244 <Pressable217 <Pressable
@@ -268,7 +241,7 @@ function ThreadNavigationDrawerContent(props: { · 23 unmodified lines
268 {relativeTime(thread.updatedAt ?? thread.createdAt)}241 {relativeTime(thread.updatedAt ?? thread.createdAt)}
269 </Text>242 </Text>
270 </View>243 </View>
271 <StatusPill {...threadStatusTone(thread)} />244 {status ? <StatusPill {...status} /> : null}
272 </View>245 </View>
273 </Pressable>246 </Pressable>
274 );247 );
apps/mobile/src/features/threads/ThreadNavigationSidebar.tsx+820−0
@@ -0,0 +1,820 @@
1import { isLiquidGlassSupported, LiquidGlassView } from "@callstack/liquid-glass";
2import type { EnvironmentThreadShell } from "@t3tools/client-runtime/state/shell";
3import { LegendList } from "@legendapp/list/react-native";
4import type { MenuAction } from "@react-native-menu/menu";
5import { SymbolView } from "expo-symbols";
6import { useCallback, useMemo, useRef, useState, type ReactNode } from "react";
7import type { LayoutChangeEvent, NativeScrollEvent, NativeSyntheticEvent } from "react-native";
8import { Platform, StyleSheet, TextInput, View, useColorScheme } from "react-native";
9import { Gesture, GestureDetector } from "react-native-gesture-handler";
10import type { SwipeableMethods } from "react-native-gesture-handler/ReanimatedSwipeable";
11import { useSafeAreaInsets } from "react-native-safe-area-context";
12import type { SearchBarCommands } from "react-native-screens";
13import Svg, { Defs, LinearGradient, Rect, Stop } from "react-native-svg";
14
15import { AppText as Text } from "../../components/AppText";
16import { ControlPillMenu } from "../../components/ControlPill";
17import { NativeStackScreenOptions } from "../../native/StackHeader";
18import { scopedProjectKey, scopedThreadKey } from "../../lib/scopedEntities";
19import { useThemeColor } from "../../lib/useThemeColor";
20import { useProjects, useThreadShells } from "../../state/entities";
21import { usePendingNewTasks } from "../../state/use-pending-new-tasks";
22import { useWorkspaceState } from "../../state/workspace";
23import { useSavedRemoteConnections } from "../../state/use-remote-environment-registry";
24import { useHardwareKeyboardCommand } from "../keyboard/hardwareKeyboardCommands";
25import {
26 hasCustomHomeListOptions,
27 PROJECT_GROUPING_OPTIONS,
28 PROJECT_SORT_OPTIONS,
29 THREAD_SORT_OPTIONS,
30 useHomeListOptions,
31} from "../home/home-list-options";
32import { buildHomeListFilterMenu } from "../home/home-list-filter-menu";
33import {
34 buildHomeListLayout,
35 DEFAULT_GROUP_DISPLAY_STATE,
36 homeListItemsAreEqual,
37 nextGroupDisplayState,
38 type HomeGroupDisplayAction,
39 type HomeGroupDisplayState,
40 type HomeListItem,
41} from "../home/homeListItems";
42import { buildHomeThreadGroups } from "../home/homeThreadList";
43import { SwipeableScrollGateProvider, useSwipeableScrollGate } from "../home/thread-swipe-actions";
44import { usePendingTaskListActions } from "../home/usePendingTaskListActions";
45import { useThreadListActions } from "../home/useThreadListActions";
46import { WorkspaceConnectionStatus } from "../home/WorkspaceConnectionStatus";
47import { shouldShowWorkspaceConnectionStatus } from "../home/workspace-connection-status";
48import { SidebarHeaderActions } from "./sidebar-header-actions";
49import { SidebarFilterButton } from "./sidebar-filter-button";
50import { createSidebarHeaderItems } from "./sidebar-native-header-items";
51import { SidebarNavigationShell } from "./sidebar-navigation-shell";
52import {
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 */
63function 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
95const SIDEBAR_STICKY_HEADER_HEIGHT = 106;
96const SIDEBAR_STICKY_HEADER_FADE_HEIGHT = 44;
97const IOS_SEARCH_FILL_DARK = "rgba(118, 118, 128, 0.24)";
98const IOS_SEARCH_FILL_LIGHT = "rgba(118, 118, 128, 0.12)";
99const SIDEBAR_HEADER_WASH_OPACITY = {
100 dark: [0.22, 0.14, 0.04],
101 light: [0.46, 0.3, 0.08],
102} as const;
103
104interface 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 */
125export function ThreadNavigationSidebar(props: ThreadNavigationSidebarProps) {
126 if (Platform.OS !== "ios") {
127 return <ThreadNavigationSidebarPane {...props} nativeChrome={false} />;
128 }
129 return <NativeSidebarContainer {...props} />;
130}
131
132function 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
156function 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
756const 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});
apps/mobile/src/features/threads/ThreadRouteScreen.tsx+469−166
@@ -1,11 +1,17 @@
1import { Stack, useLocalSearchParams, useRouter } from "expo-router";1import { NativeStackScreenOptions } from "../../native/StackHeader";
2import { useCallback, useMemo, useState } from "react";2import {
3 StackActions,
4 useFocusEffect,
5 useNavigation,
6 type StaticScreenProps,
7} from "@react-navigation/native";
8import { useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from "react";
3import * as Option from "effect/Option";9import * as Option from "effect/Option";
4import { EnvironmentId, type ProjectScript } from "@t3tools/contracts";10import { EnvironmentId, ThreadId, type ProjectScript } from "@t3tools/contracts";
5import { projectScriptCwd, projectScriptRuntimeEnv } from "@t3tools/shared/projectScripts";11import { projectScriptCwd, projectScriptRuntimeEnv } from "@t3tools/shared/projectScripts";
6import * as Haptics from "expo-haptics";12import * as Haptics from "expo-haptics";
7import { SymbolView } from "expo-symbols";13import { Platform, Pressable, ScrollView, Text as RNText, View } from "react-native";
8import { Pressable, ScrollView, Text as RNText, View } from "react-native";14import { useSafeAreaInsets } from "react-native-safe-area-context";
9import { useWorkspaceState } from "../../state/workspace";15import { useWorkspaceState } from "../../state/workspace";
10import { useThemeColor } from "../../lib/useThemeColor";16import { useThemeColor } from "../../lib/useThemeColor";
11import { useEnvironmentQuery } from "../../state/query";17import { useEnvironmentQuery } from "../../state/query";
@@ -14,7 +20,6 @@ import { vcsEnvironment } from "../../state/vcs"; · 2 unmodified lines
14 20
15import { EmptyState } from "../../components/EmptyState";21import { EmptyState } from "../../components/EmptyState";
16import { LoadingScreen } from "../../components/LoadingScreen";22import { LoadingScreen } from "../../components/LoadingScreen";
17import { buildThreadRoutePath, buildThreadTerminalNavigation } from "../../lib/routes";
18import { scopedThreadKey } from "../../lib/scopedEntities";23import { scopedThreadKey } from "../../lib/scopedEntities";
19import { MOBILE_TYPOGRAPHY } from "../../lib/typography";24import { MOBILE_TYPOGRAPHY } from "../../lib/typography";
20import { connectionTone } from "../connection/connectionTone";25import { connectionTone } from "../connection/connectionTone";
@@ -39,7 +44,12 @@ import { · 18 unmodified lines
39} from "../terminal/terminalLaunchContext";44} from "../terminal/terminalLaunchContext";
40import { terminalDebugLog } from "../terminal/terminalDebugLog";45import { terminalDebugLog } from "../terminal/terminalDebugLog";
41import { ThreadDetailScreen } from "./ThreadDetailScreen";46import { ThreadDetailScreen } from "./ThreadDetailScreen";
42import { ThreadGitControls } from "./ThreadGitControls";47import {
48 ThreadGitControls,
49 useThreadGitCenterHeaderItems,
50 useThreadGitRightHeaderItems,
51} from "./ThreadGitControls";
52import { GitOverviewSheet } from "./git/GitOverviewSheet";
43import { ThreadNavigationDrawer } from "./ThreadNavigationDrawer";53import { ThreadNavigationDrawer } from "./ThreadNavigationDrawer";
44import { useAtomCommand } from "../../state/use-atom-command";54import { useAtomCommand } from "../../state/use-atom-command";
45import { useSelectedThreadGitActions } from "../../state/use-selected-thread-git-actions";55import { useSelectedThreadGitActions } from "../../state/use-selected-thread-git-actions";
@@ -49,6 +59,30 @@ import { useSelectedThreadWorktree } from "../../state/use-selected-thread-workt · 3 unmodified lines
49import { useThreadComposerState } from "../../state/use-thread-composer-state";59import { useThreadComposerState } from "../../state/use-thread-composer-state";
50import { threadEnvironment } from "../../state/threads";60import { threadEnvironment } from "../../state/threads";
51import { projectThreadContentPresentation } from "./threadContentPresentation";61import { projectThreadContentPresentation } from "./threadContentPresentation";
62import {
63 useAdaptiveWorkspaceLayout,
64 useAdaptiveWorkspacePaneRole,
65 useRegisterWorkspaceInspector,
66} from "../layout/AdaptiveWorkspaceLayout";
67import { withNativeGlassHeaderItem } from "../layout/native-glass-header-items";
68import { ThreadFileNavigatorPane } from "../files/thread-file-navigator-pane";
69import {
70 ThreadInspectorContentStack,
71 type ThreadInspectorMode,
72} from "./thread-inspector-content-stack";
73import { useHardwareKeyboardCommand } from "../keyboard/hardwareKeyboardCommands";
74
75interface ThreadInspectorSelection {
76 readonly routeThreadIdentity: string | null;
77 readonly mode: ThreadInspectorMode;
78}
79
80type NativeHeaderItems = ReadonlyArray<Record<string, unknown>>;
81
82function InspectorPaneRoleActivation() {
83 useAdaptiveWorkspacePaneRole("inspector");
84 return null;
85}
52 86
53function firstRouteParam(value: string | string[] | undefined): string | null {87function firstRouteParam(value: string | string[] | undefined): string | null {
54 if (Array.isArray(value)) {88 if (Array.isArray(value)) {
@@ -62,13 +96,99 @@ function OpeningThreadLoadingScreen() { · 7 unmodified lines
62 return <LoadingScreen message="Opening thread…" messagePlacement="above-spinner" />;96 return <LoadingScreen message="Opening thread…" messagePlacement="above-spinner" />;
63}97}
64 98
65export function ThreadRouteScreen() {99type ThreadRouteScreenRouteProps = StaticScreenProps<{
100 readonly environmentId: string;
101 readonly threadId: string;
102}>;
103
104interface ThreadRouteScreenProps extends ThreadRouteScreenRouteProps {
105 readonly onReturnToThread?: () => void;
106 readonly renderInspector?: (headerInset: number) => ReactNode;
107}
108
109function 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
129export function ThreadRouteScreen(props: ThreadRouteScreenProps) {
66 const { state: workspaceState } = useWorkspaceState();130 const { state: workspaceState } = useWorkspaceState();
67 const { connectionState } = useRemoteConnectionStatus();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
174function 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 const { onReconnectEnvironment } = useRemoteConnections();188 const { onReconnectEnvironment } = useRemoteConnections();
69 const { selectedThread, selectedThreadProject, selectedEnvironmentConnection } =189 const { selectedThread, selectedThreadProject, selectedEnvironmentConnection } =
70 useThreadSelection();190 useThreadSelection();
71 const selectedThreadDetailState = useSelectedThreadDetailState();191 const selectedThreadDetailState = props.selectedThreadDetailState;
72 const selectedThreadDetail = Option.getOrNull(selectedThreadDetailState.data);192 const selectedThreadDetail = Option.getOrNull(selectedThreadDetailState.data);
73 const { selectedThreadCwd } = useSelectedThreadWorktree();193 const { selectedThreadCwd } = useSelectedThreadWorktree();
74 const composer = useThreadComposerState();194 const composer = useThreadComposerState();
@@ -76,15 +196,72 @@ export function ThreadRouteScreen() { · 1 unmodified line
76 const gitActions = useSelectedThreadGitActions();196 const gitActions = useSelectedThreadGitActions();
77 const requests = useSelectedThreadRequests();197 const requests = useSelectedThreadRequests();
78 const interruptThreadTurn = useAtomCommand(threadEnvironment.interruptTurn, "thread interrupt");198 const interruptThreadTurn = useAtomCommand(threadEnvironment.interruptTurn, "thread interrupt");
79 const router = useRouter();199 const navigation = useNavigation();
80 const params = useLocalSearchParams<{200 const params = props.route.params;
81 environmentId?: string | string[];
82 threadId?: string | string[];
83 }>();
84 const [drawerVisible, setDrawerVisible] = useState(false);201 const [drawerVisible, setDrawerVisible] = useState(false);
85 const environmentIdRaw = firstRouteParam(params.environmentId);202 const environmentIdRaw = firstRouteParam(params.environmentId);
86 const environmentId = environmentIdRaw ? EnvironmentId.make(environmentIdRaw) : null;203 const environmentId = environmentIdRaw ? EnvironmentId.make(environmentIdRaw) : null;
87 const threadId = firstRouteParam(params.threadId);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 const routeEnvironmentRuntime = useRemoteEnvironmentRuntime(environmentId);265 const routeEnvironmentRuntime = useRemoteEnvironmentRuntime(environmentId);
89 const routeConnectionState =266 const routeConnectionState =
90 routeEnvironmentRuntime?.connectionState ?? (environmentId ? "available" : connectionState);267 routeEnvironmentRuntime?.connectionState ?? (environmentId ? "available" : connectionState);
@@ -103,10 +280,14 @@ export function ThreadRouteScreen() { · 12 unmodified lines
103 );280 );
104 281
105 /* ─── Native header theming ──────────────────────────────────────── */282 /* ─── Native header theming ──────────────────────────────────────── */
106 const iconColor = String(useThemeColor("--color-icon"));
107 const foregroundColor = String(useThemeColor("--color-foreground"));283 const foregroundColor = String(useThemeColor("--color-foreground"));
108 const secondaryFg = String(useThemeColor("--color-foreground-secondary"));284 const usesNativeHeaderGlass = Platform.OS === "ios";
109 285 const headerSubtitle = [
286 selectedThreadProject?.title ?? null,
287 selectedEnvironmentConnection?.environmentLabel ?? null,
288 ]
289 .filter(Boolean)
290 .join(" · ");
110 /* ─── Git status for native header trigger ───────────────────────── */291 /* ─── Git status for native header trigger ───────────────────────── */
111 const gitStatus = useEnvironmentQuery(292 const gitStatus = useEnvironmentQuery(
112 selectedThread !== null && selectedThreadCwd !== null293 selectedThread !== null && selectedThreadCwd !== null
@@ -147,13 +328,135 @@ export function ThreadRouteScreen() { · 34 unmodified lines
147 const gitActionProgress = useGitActionProgress(gitActionProgressTarget);328 const gitActionProgress = useGitActionProgress(gitActionProgressTarget);
148 329
149 const handleOpenDrawer = useCallback(() => {330 const handleOpenDrawer = useCallback(() => {
150 void Haptics.selectionAsync();331 if (!layout.usesSplitView) {
151 setDrawerVisible(true);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 const handleOpenConnectionEditor = useCallback(() => {457 const handleOpenConnectionEditor = useCallback(() => {
155 void router.push("/connections");458 void navigation.navigate("Connections");
156 }, [router]);459 }, [navigation]);
157 const handleStopThread = useCallback(() => {460 const handleStopThread = useCallback(() => {
158 if (461 if (
159 !selectedThread ||462 !selectedThread ||
@@ -185,9 +488,13 @@ export function ThreadRouteScreen() { · 25 unmodified lines
185 return;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 const handleOpenNewTerminal = useCallback(() => {500 const handleOpenNewTerminal = useCallback(() => {
@@ -204,8 +511,12 @@ export function ThreadRouteScreen() { · 10 unmodified lines
204 const nextId = nextOpenTerminalId({511 const nextId = nextOpenTerminalId({
205 listedTerminalIds: terminalMenuSessions.map((session) => session.terminalId),512 listedTerminalIds: terminalMenuSessions.map((session) => session.terminalId),
206 });513 });
207 void router.push(buildThreadTerminalNavigation(selectedThread, nextId));514 void navigation.navigate("ThreadTerminal", {
208 }, [router, selectedThread, selectedThreadProject?.workspaceRoot, terminalMenuSessions]);515 environmentId: String(selectedThread.environmentId),
516 threadId: String(selectedThread.id),
517 terminalId: nextId,
518 });
519 }, [navigation, selectedThread, selectedThreadProject?.workspaceRoot, terminalMenuSessions]);
209 520
210 const handleRunProjectScript = useCallback(521 const handleRunProjectScript = useCallback(
211 async (script: ProjectScript) => {522 async (script: ProjectScript) => {
@@ -262,134 +573,97 @@ export function ThreadRouteScreen() { · 50 unmodified lines
262 worktreePath: preferredWorktreePath,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 selectedThread,584 selectedThread,
270 selectedThreadDetailWorktreePath,585 selectedThreadDetailWorktreePath,
271 selectedThreadProject,586 selectedThreadProject,
272 terminalMenuSessions,587 terminalMenuSessions,
273 ],588 ],
274 );589 );
275 590 const threadGitControlProps = {
276 const headerSubtitle = [591 environmentId: environmentIdRaw ?? "",
277 selectedThreadProject?.title ?? null,592 threadId: threadId ?? "",
278 selectedEnvironmentConnection?.environmentLabel ?? null,593 auxiliaryPaneControl:
279 ]594 !layout.usesSplitView && fileInspector.supported && selectedThreadCwd !== null
280 .filter(Boolean)595 ? {
281 .join(" · ");596 accessibilityLabel: "Toggle inspector",
282 597 onPress: handleToggleInspector,
283 // Rendered in every state (loading / unavailable / ready) so the back button and598 }
284 // title stay consistent instead of flickering as the thread hydrates. Tapping the599 : undefined,
285 // title opens the thread switcher drawer.600 onOpenFilesInspector:
286 const headerScreen = (601 fileInspector.supported && selectedThreadCwd !== null ? handleOpenFilesInspector : undefined,
287 <Stack.Screen602 onOpenGitInspector: fileInspector.supported ? handleOpenGitInspector : undefined,
288 options={{603 currentBranch: selectedThread?.branch ?? null,
289 headerShown: true,604 gitStatus: gitStatus.data,
290 headerTransparent: true,605 gitOperationLabel: gitState.gitOperationLabel,
291 headerStyle: { backgroundColor: "transparent" },606 canOpenTerminal: Boolean(selectedThreadProject?.workspaceRoot),
292 headerShadowVisible: false,607 canOpenFiles: Boolean(selectedThreadProject?.workspaceRoot),
293 headerTintColor: iconColor,608 projectScripts: selectedThreadProject?.scripts ?? [],
294 headerBackTitle: "",609 terminalSessions: terminalMenuSessions,
295 headerBackVisible: true,610 showDirectFileControl: layout.usesSplitView,
296 headerBackButtonDisplayMode: "minimal",611 onOpenTerminal: handleOpenTerminal,
297 headerTitle: () => (612 onOpenNewTerminal: handleOpenNewTerminal,
298 <Pressable613 onRunProjectScript: handleRunProjectScript,
299 accessibilityRole="button"614 onPull: gitActions.onPullSelectedThreadBranch,
300 accessibilityLabel="Switch thread"615 onRunAction: gitActions.onRunSelectedThreadGitAction,
301 accessibilityHint="Opens the list of threads"616 };
302 disabled={selectedThread === null}617 const threadCenterHeaderItems = useThreadGitCenterHeaderItems(threadGitControlProps);
303 hitSlop={12}618 const compactRightHeaderItems = useThreadGitRightHeaderItems(threadGitControlProps);
304 onPress={handleOpenDrawer}619 const splitLeftHeaderItems = useMemo<NativeHeaderItems>(
305 style={{ alignItems: "center", maxWidth: 240 }}620 () => [
306 >621 {
307 <View style={{ flexDirection: "row", alignItems: "center", gap: 4, maxWidth: 240 }}>622 // Match Mail's split-view detail toolbar: the first detail action sits
308 <RNText623 // inside the content pane, not flush against the sidebar divider.
309 numberOfLines={1}624 spacing: 18,
310 style={{625 type: "spacing" as const,
311 fontFamily: "DMSans_700Bold",626 },
312 fontSize: MOBILE_TYPOGRAPHY.headline.fontSize,627 ...(props.onReturnToThread
313 fontWeight: "900",628 ? [
314 color: foregroundColor,629 withNativeGlassHeaderItem({
315 letterSpacing: -0.4,630 accessibilityLabel: "Return to chat",
316 }}631 icon: { name: "chevron.left", type: "sfSymbol" as const },
317 >632 identifier: "thread-left-return",
318 {selectedThread?.title ?? "Opening thread…"}633 onPress: props.onReturnToThread,
319 </RNText>634 type: "button" as const,
320 {selectedThread ? (635 }),
321 <SymbolView636 ]
322 name="chevron.down"637 : []),
323 size={11}638 withNativeGlassHeaderItem({
324 tintColor={secondaryFg}639 accessibilityLabel: panes.primarySidebarVisible
325 type="monochrome"640 ? "Maximize content"
326 />641 : "Show thread sidebar",
327 ) : null}642 icon: {
328 </View>643 name: panes.primarySidebarVisible ? "arrow.up.left.and.arrow.down.right" : "sidebar.left",
329 {headerSubtitle ? (644 type: "sfSymbol" as const,
330 <RNText645 },
331 numberOfLines={1}646 identifier: "thread-left-sidebar",
332 style={{647 onPress: togglePrimarySidebar,
333 fontFamily: "DMSans_700Bold",648 type: "button" as const,
334 fontSize: MOBILE_TYPOGRAPHY.label.fontSize,649 }),
335 fontWeight: "700",650 withNativeGlassHeaderItem({
336 color: secondaryFg,651 accessibilityLabel: "New task",
337 letterSpacing: 0.3,652 icon: { name: "square.and.pencil", type: "sfSymbol" as const },
338 }}653 identifier: "thread-left-new-task",
339 >654 onPress: () => navigation.navigate("NewTaskSheet", { screen: "NewTask" }),
340 {headerSubtitle}655 type: "button" as const,
341 </RNText>656 }),
342 ) : null}657 ],
343 </Pressable>658 [panes.primarySidebarVisible, props.onReturnToThread, navigation, togglePrimarySidebar],
344 ),
345 }}
346 />
347 );659 );
348 660
349 if (!environmentId || !threadId) {661 if (!environmentId || !threadId) {
350 return (662 return <OpeningThreadLoadingScreen />;
351 <>
352 {headerScreen}
353 <OpeningThreadLoadingScreen />
354 </>
355 );
356 }663 }
357 664
358 if (!selectedThread) {665 if (!selectedThread) {
359 const stillHydrating =666 return <OpeningThreadLoadingScreen />;
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 );
393 }667 }
394 668
395 const selectedThreadKey = scopedThreadKey(selectedThread.environmentId, selectedThread.id);669 const selectedThreadKey = scopedThreadKey(selectedThread.environmentId, selectedThread.id);
@@ -400,25 +674,9 @@ export function ThreadRouteScreen() { · 4 unmodified lines
400 connectionState: routeConnectionState,674 connectionState: routeConnectionState,
401 });675 });
402 const serverConfig = routeEnvironmentRuntime?.serverConfig ?? null;676 const serverConfig = routeEnvironmentRuntime?.serverConfig ?? null;
403 677 const renderThreadRouteBody = (showActionControls: boolean) => (
404 return (
405 <>678 <>
406 {headerScreen}679 <ThreadGitControls {...threadGitControlProps} showActionControls={showActionControls} />
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 />
422 680
423 <GitActionProgressOverlay progress={gitActionProgress} onDismiss={dismissGitActionResult} />681 <GitActionProgressOverlay progress={gitActionProgress} onDismiss={dismissGitActionResult} />
424 682
@@ -440,11 +698,15 @@ export function ThreadRouteScreen() { · 15 unmodified lines
440 draftMessage={composer.draftMessage}698 draftMessage={composer.draftMessage}
441 draftAttachments={composer.draftAttachments}699 draftAttachments={composer.draftAttachments}
442 connectionStateLabel={routeConnectionState}700 connectionStateLabel={routeConnectionState}
701 threadSyncStatus={selectedThreadDetailState.status}
443 activeThreadBusy={composer.activeThreadBusy}702 activeThreadBusy={composer.activeThreadBusy}
444 environmentId={selectedThread.environmentId}703 environmentId={selectedThread.environmentId}
445 projectWorkspaceRoot={selectedThreadProject?.workspaceRoot ?? null}704 projectWorkspaceRoot={selectedThreadProject?.workspaceRoot ?? null}
446 threadCwd={selectedThreadCwd}705 threadCwd={selectedThreadCwd}
447 selectedThreadQueueCount={composer.selectedThreadQueueCount}706 selectedThreadQueueCount={composer.selectedThreadQueueCount}
707 layoutVariant={layout.variant}
708 usesAutomaticContentInsets={usesNativeHeaderGlass}
709 onOpenDrawer={handleOpenDrawer}
448 onOpenConnectionEditor={handleOpenConnectionEditor}710 onOpenConnectionEditor={handleOpenConnectionEditor}
449 onChangeDraftMessage={composer.onChangeDraftMessage}711 onChangeDraftMessage={composer.onChangeDraftMessage}
450 onPickDraftImages={composer.onPickDraftImages}712 onPickDraftImages={composer.onPickDraftImages}
@@ -463,16 +725,57 @@ export function ThreadRouteScreen() { · 12 unmodified lines
463 onSubmitUserInput={requests.onSubmitUserInput}725 onSubmitUserInput={requests.onSubmitUserInput}
464 />726 />
465 727
466 <ThreadNavigationDrawer728 {layout.usesSplitView ? null : (
467 visible={drawerVisible}729 <ThreadNavigationDrawer
468 selectedThreadKey={selectedThreadKey}730 visible={drawerVisible}
469 onClose={() => setDrawerVisible(false)}731 selectedThreadKey={selectedThreadKey}
470 onSelectThread={(thread) => {732 onClose={() => setDrawerVisible(false)}
471 router.replace(buildThreadRoutePath(thread));733 onSelectThread={(thread) => {
472 }}734 navigation.dispatch(
473 onStartNewTask={() => router.push("/new")}735 StackActions.replace("Thread", {
474 />736 environmentId: String(thread.environmentId),
737 threadId: String(thread.id),
738 }),
739 );
740 }}
741 onStartNewTask={() => navigation.navigate("NewTaskSheet", { screen: "NewTask" })}
742 />
743 )}
475 </View>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}
apps/mobile/src/features/threads/git/GitBranchesSheet.tsx+11−6
@@ -1,5 +1,5 @@
1import { sanitizeFeatureBranchName } from "@t3tools/shared/git";1import { sanitizeFeatureBranchName } from "@t3tools/shared/git";
2import { useRouter } from "expo-router";2import { useNavigation, type StaticScreenProps } from "@react-navigation/native";
3import { useState } from "react";3import { useState } from "react";
4import { Pressable, ScrollView, View } from "react-native";4import { Pressable, ScrollView, View } from "react-native";
5import { useSafeAreaInsets } from "react-native-safe-area-context";5import { useSafeAreaInsets } from "react-native-safe-area-context";
@@ -14,8 +14,13 @@ import { useSelectedThreadWorktree } from "../../../state/use-selected-thread-wo · 8 unmodified lines
14import { vcsEnvironment } from "../../../state/vcs";14import { vcsEnvironment } from "../../../state/vcs";
15import { SheetActionButton } from "./gitSheetComponents";15import { SheetActionButton } from "./gitSheetComponents";
16 16
17export function GitBranchesSheet() {17type GitBranchesSheetProps = StaticScreenProps<{
18 const router = useRouter();18 readonly environmentId: string;
19 readonly threadId: string;
20}>;
21
22export function GitBranchesSheet(_props: GitBranchesSheetProps) {
23 const navigation = useNavigation();
19 const insets = useSafeAreaInsets();24 const insets = useSafeAreaInsets();
20 const { selectedThread } = useThreadSelection();25 const { selectedThread } = useThreadSelection();
21 const { selectedThreadCwd, selectedThreadWorktreePath } = useSelectedThreadWorktree();26 const { selectedThreadCwd, selectedThreadWorktreePath } = useSelectedThreadWorktree();
@@ -96,7 +101,7 @@ export function GitBranchesSheet() { · 74 unmodified lines
96 if (branch.length === 0) return;101 if (branch.length === 0) return;
97 void gitActions.onCreateSelectedThreadBranch(branch).then(() => {102 void gitActions.onCreateSelectedThreadBranch(branch).then(() => {
98 setNewBranchName("");103 setNewBranchName("");
99 router.dismiss();104 navigation.goBack();
100 });105 });
101 }}106 }}
102 />107 />
@@ -146,7 +151,7 @@ export function GitBranchesSheet() { · 43 unmodified lines
146 if (baseBranch.length === 0 || newBranch.length === 0) return;151 if (baseBranch.length === 0 || newBranch.length === 0) return;
147 void gitActions.onCreateSelectedThreadWorktree({ baseBranch, newBranch }).then(() => {152 void gitActions.onCreateSelectedThreadWorktree({ baseBranch, newBranch }).then(() => {
148 setWorktreeBranchName("");153 setWorktreeBranchName("");
149 router.dismiss();154 navigation.goBack();
150 });155 });
151 }}156 }}
152 />157 />
@@ -188,7 +193,7 @@ export function GitBranchesSheet() { · 35 unmodified lines
188 }}193 }}
189 onPress={() => {194 onPress={() => {
190 void gitActions.onCheckoutSelectedThreadBranch(branch.name).then(() => {195 void gitActions.onCheckoutSelectedThreadBranch(branch.name).then(() => {
191 router.dismiss();196 navigation.goBack();
192 });197 });
193 }}198 }}
194 >199 >
apps/mobile/src/features/threads/git/GitCommitSheet.tsx+15−10
@@ -1,4 +1,4 @@
1import { useRouter } from "expo-router";1import { useNavigation, type StaticScreenProps } from "@react-navigation/native";
2import { useCallback, useState } from "react";2import { useCallback, useState } from "react";
3import { Pressable, ScrollView, View, useColorScheme } from "react-native";3import { Pressable, ScrollView, View, useColorScheme } from "react-native";
4import { useSafeAreaInsets } from "react-native-safe-area-context";4import { useSafeAreaInsets } from "react-native-safe-area-context";
@@ -13,8 +13,13 @@ import { useSelectedThreadWorktree } from "../../../state/use-selected-thread-wo · 8 unmodified lines
13import { vcsEnvironment } from "../../../state/vcs";13import { vcsEnvironment } from "../../../state/vcs";
14import { SheetActionButton } from "./gitSheetComponents";14import { SheetActionButton } from "./gitSheetComponents";
15 15
16export function GitCommitSheet() {16type GitCommitSheetProps = StaticScreenProps<{
17 const router = useRouter();17 readonly environmentId: string;
18 readonly threadId: string;
19}>;
20
21export function GitCommitSheet(_props: GitCommitSheetProps) {
22 const navigation = useNavigation();
18 const insets = useSafeAreaInsets();23 const insets = useSafeAreaInsets();
19 const isDarkMode = useColorScheme() === "dark";24 const isDarkMode = useColorScheme() === "dark";
20 const { selectedThread } = useThreadSelection();25 const { selectedThread } = useThreadSelection();
@@ -55,7 +60,7 @@ export function GitCommitSheet() { · 34 unmodified lines
55 const runCommitAction = useCallback(60 const runCommitAction = useCallback(
56 async (featureBranch: boolean) => {61 async (featureBranch: boolean) => {
57 const commitMessage = dialogCommitMessage.trim();62 const commitMessage = dialogCommitMessage.trim();
58 router.dismiss();63 navigation.goBack();
59 await gitActions.onRunSelectedThreadGitAction({64 await gitActions.onRunSelectedThreadGitAction({
60 action: "commit",65 action: "commit",
61 featureBranch,66 featureBranch,
@@ -63,7 +68,7 @@ export function GitCommitSheet() { · 1 unmodified line
63 ...(!allSelected ? { filePaths: selectedFiles.map((file) => file.path) } : {}),68 ...(!allSelected ? { filePaths: selectedFiles.map((file) => file.path) } : {}),
64 });69 });
65 },70 },
66 [allSelected, dialogCommitMessage, gitActions, router, selectedFiles],71 [allSelected, dialogCommitMessage, gitActions, navigation, selectedFiles],
67 );72 );
68 73
69 return (74 return (
@@ -86,7 +91,7 @@ export function GitCommitSheet() { · 16 unmodified lines
86 </View>91 </View>
87 {isDefaultRef ? (92 {isDefaultRef ? (
88 <Text93 <Text
89 className="text-xs leading-[18px]"94 className="text-xs leading-normal"
90 style={{ color: isDarkMode ? "#fbbf24" : "#b45309" }}95 style={{ color: isDarkMode ? "#fbbf24" : "#b45309" }}
91 >96 >
92 Warning: this is the default branch.97 Warning: this is the default branch.
@@ -98,7 +103,7 @@ export function GitCommitSheet() { · 5 unmodified lines
98 <View className="flex-row items-center justify-between gap-3">103 <View className="flex-row items-center justify-between gap-3">
99 <View className="gap-1">104 <View className="gap-1">
100 <Text className="text-foreground text-base font-t3-bold">Files</Text>105 <Text className="text-foreground text-base font-t3-bold">Files</Text>
101 <Text className="text-foreground-muted text-xs leading-[18px]">106 <Text className="text-foreground-muted text-xs leading-normal">
102 {selectedFiles.length} selected · +{selectedInsertions} / -{selectedDeletions}107 {selectedFiles.length} selected · +{selectedInsertions} / -{selectedDeletions}
103 </Text>108 </Text>
104 </View>109 </View>
@@ -123,7 +128,7 @@ export function GitCommitSheet() { · 18 unmodified lines
123 </View>128 </View>
124 129
125 {allFiles.length === 0 ? (130 {allFiles.length === 0 ? (
126 <Text className="text-foreground-secondary text-sm leading-[19px]">131 <Text className="text-foreground-secondary text-sm leading-normal">
127 No changed files are available to commit.132 No changed files are available to commit.
128 </Text>133 </Text>
129 ) : !isEditingFiles ? (134 ) : !isEditingFiles ? (
@@ -142,7 +147,7 @@ export function GitCommitSheet() { · 12 unmodified lines
142 </View>147 </View>
143 ))}148 ))}
144 {selectedFiles.length > selectedFilePreview.length ? (149 {selectedFiles.length > selectedFilePreview.length ? (
145 <Text className="text-foreground-muted text-xs leading-[17px]">150 <Text className="text-foreground-muted text-xs leading-snug">
146 +{selectedFiles.length - selectedFilePreview.length} more files151 +{selectedFiles.length - selectedFilePreview.length} more files
147 </Text>152 </Text>
148 ) : null}153 ) : null}
@@ -182,7 +187,7 @@ export function GitCommitSheet() { · 33 unmodified lines
182 {file.path}187 {file.path}
183 </Text>188 </Text>
184 {!included ? (189 {!included ? (
185 <Text className="text-foreground-muted text-2xs leading-[16px]">190 <Text className="text-foreground-muted text-2xs leading-normal">
186 Excluded from this commit191 Excluded from this commit
187 </Text>192 </Text>
188 ) : null}193 ) : null}
apps/mobile/src/features/threads/git/GitConfirmSheet.tsx+30−15
@@ -2,7 +2,7 @@ import { resolveDefaultBranchActionDialogCopy } from "@t3tools/client-runtime/st · 1 unmodified line
2import { resolveAutoFeatureBranchName } from "@t3tools/shared/git";2import { resolveAutoFeatureBranchName } from "@t3tools/shared/git";
3import * as Arr from "effect/Array";3import * as Arr from "effect/Array";
4import * as Result from "effect/Result";4import * as Result from "effect/Result";
5import { useLocalSearchParams, useRouter } from "expo-router";5import { StackActions, useNavigation, type StaticScreenProps } from "@react-navigation/native";
6import { useCallback, useMemo } from "react";6import { useCallback, useMemo } from "react";
7import { View } from "react-native";7import { View } from "react-native";
8import { useSafeAreaInsets } from "react-native-safe-area-context";8import { useSafeAreaInsets } from "react-native-safe-area-context";
@@ -12,19 +12,23 @@ import { useSelectedThreadGitActions } from "../../../state/use-selected-thread- · 3 unmodified lines
12import { useSelectedThreadGitState } from "../../../state/use-selected-thread-git-state";12import { useSelectedThreadGitState } from "../../../state/use-selected-thread-git-state";
13import { SheetActionButton } from "./gitSheetComponents";13import { SheetActionButton } from "./gitSheetComponents";
14 14
15export function GitConfirmSheet() {15type GitConfirmSheetProps = StaticScreenProps<{
16 const router = useRouter();16 readonly environmentId: string;
17 readonly threadId: string;
18 readonly confirmAction?: string;
19 readonly branchName?: string;
20 readonly includesCommit?: string;
21 readonly commitMessage?: string;
22 readonly filePaths?: string;
23}>;
24
25export function GitConfirmSheet(props: GitConfirmSheetProps) {
26 const navigation = useNavigation();
17 const insets = useSafeAreaInsets();27 const insets = useSafeAreaInsets();
18 const gitState = useSelectedThreadGitState();28 const gitState = useSelectedThreadGitState();
19 const gitActions = useSelectedThreadGitActions();29 const gitActions = useSelectedThreadGitActions();
20 30
21 const params = useLocalSearchParams<{31 const params = props.route.params;
22 confirmAction?: string;
23 branchName?: string;
24 includesCommit?: string;
25 commitMessage?: string;
26 filePaths?: string;
27 }>();
28 32
29 const confirmAction = params.confirmAction as33 const confirmAction = params.confirmAction as
30 | "push"34 | "push"
@@ -34,6 +38,8 @@ export function GitConfirmSheet() { · 3 unmodified lines
34 | undefined;38 | undefined;
35 const branchName = params.branchName ?? "";39 const branchName = params.branchName ?? "";
36 const includesCommit = params.includesCommit === "true";40 const includesCommit = params.includesCommit === "true";
41 const environmentId = params.environmentId ?? "";
42 const threadId = params.threadId ?? "";
37 43
38 const copy = useMemo(44 const copy = useMemo(
39 () =>45 () =>
@@ -49,17 +55,17 @@ export function GitConfirmSheet() { · 9 unmodified lines
49 55
50 const continuePendingAction = useCallback(async () => {56 const continuePendingAction = useCallback(async () => {
51 if (!confirmAction) return;57 if (!confirmAction) return;
52 router.dismissAll();58 navigation.dispatch(StackActions.replace("Thread", { environmentId, threadId }));
53 await gitActions.onRunSelectedThreadGitAction({59 await gitActions.onRunSelectedThreadGitAction({
54 action: confirmAction,60 action: confirmAction,
55 ...(params.commitMessage ? { commitMessage: params.commitMessage } : {}),61 ...(params.commitMessage ? { commitMessage: params.commitMessage } : {}),
56 ...(params.filePaths ? { filePaths: params.filePaths.split(",") } : {}),62 ...(params.filePaths ? { filePaths: params.filePaths.split(",") } : {}),
57 });63 });
58 }, [confirmAction, gitActions, params, router]);64 }, [confirmAction, environmentId, gitActions, params, navigation, threadId]);
59 65
60 const movePendingActionToFeatureBranch = useCallback(async () => {66 const movePendingActionToFeatureBranch = useCallback(async () => {
61 if (!confirmAction) return;67 if (!confirmAction) return;
62 router.dismissAll();68 navigation.dispatch(StackActions.replace("Thread", { environmentId, threadId }));
63 69
64 if (includesCommit) {70 if (includesCommit) {
65 await gitActions.onRunSelectedThreadGitAction({71 await gitActions.onRunSelectedThreadGitAction({
@@ -82,7 +88,16 @@ export function GitConfirmSheet() { · 16 unmodified lines
82 );88 );
83 await gitActions.onCreateSelectedThreadBranch(newBranchName);89 await gitActions.onCreateSelectedThreadBranch(newBranchName);
84 await gitActions.onRunSelectedThreadGitAction({ action: confirmAction });90 await gitActions.onRunSelectedThreadGitAction({ action: confirmAction });
85 }, [confirmAction, gitActions, gitState.selectedThreadBranches, includesCommit, params, router]);91 }, [
92 confirmAction,
93 gitActions,
94 gitState.selectedThreadBranches,
95 includesCommit,
96 params,
97 navigation,
98 environmentId,
99 threadId,
100 ]);
86 101
87 return (102 return (
88 <View collapsable={false} className="flex-1 bg-sheet">103 <View collapsable={false} className="flex-1 bg-sheet">
@@ -98,7 +113,7 @@ export function GitConfirmSheet() { · 9 unmodified lines
98 <Text className="text-center text-3xl font-t3-bold">113 <Text className="text-center text-3xl font-t3-bold">
99 {copy?.title ?? "Run action on default branch?"}114 {copy?.title ?? "Run action on default branch?"}
100 </Text>115 </Text>
101 <Text className="text-center text-foreground-secondary text-sm font-medium leading-[19px]">116 <Text className="text-center text-foreground-secondary text-sm font-medium leading-normal">
102 {copy?.description ?? "Choose how to continue."}117 {copy?.description ?? "Choose how to continue."}
103 </Text>118 </Text>
104 </View>119 </View>
apps/mobile/src/features/threads/git/GitOverviewSheet.tsx+246−97
@@ -4,17 +4,18 @@ import { · 3 unmodified lines
4 getGitActionDisabledReason,4 getGitActionDisabledReason,
5 requiresDefaultBranchConfirmation,5 requiresDefaultBranchConfirmation,
6} from "@t3tools/client-runtime/state/vcs";6} from "@t3tools/client-runtime/state/vcs";
7import type { EnvironmentId, ThreadId } from "@t3tools/contracts";7import { EnvironmentId, ThreadId } from "@t3tools/contracts";
8import { useLocalSearchParams, useRouter } from "expo-router";8import { useNavigation, type StaticScreenProps } from "@react-navigation/native";
9import { SymbolView } from "expo-symbols";9import { SymbolView } from "expo-symbols";
10import { useCallback, useEffect, useMemo } from "react";10import { useCallback, useEffect, useMemo, useState } from "react";
11import { Alert, Pressable, ScrollView, View } from "react-native";11import { Alert, Platform, Pressable, RefreshControl, ScrollView, View } from "react-native";
12import { Screen, ScreenStack, ScreenStackHeaderConfig } from "react-native-screens";
12import { useSafeAreaInsets } from "react-native-safe-area-context";13import { useSafeAreaInsets } from "react-native-safe-area-context";
13import { useThemeColor } from "../../../lib/useThemeColor";14import { useThemeColor } from "../../../lib/useThemeColor";
14 15
15import { AppText as Text } from "../../../components/AppText";16import { AppText as Text } from "../../../components/AppText";
17import { nativeHeaderScrollEdgeEffects } from "../../../native/StackHeader";
16import { tryOpenExternalUrl } from "../../../lib/openExternalUrl";18import { tryOpenExternalUrl } from "../../../lib/openExternalUrl";
17import { buildThreadReviewRoutePath } from "../../../lib/routes";
18import { useEnvironmentQuery } from "../../../state/query";19import { useEnvironmentQuery } from "../../../state/query";
19import { useThreadSelection } from "../../../state/use-thread-selection";20import { useThreadSelection } from "../../../state/use-thread-selection";
20import { useSelectedThreadGitActions } from "../../../state/use-selected-thread-git-actions";21import { useSelectedThreadGitActions } from "../../../state/use-selected-thread-git-actions";
@@ -23,13 +24,23 @@ import { useSelectedThreadWorktree } from "../../../state/use-selected-thread-wo · 2 unmodified lines
23import { vcsEnvironment } from "../../../state/vcs";24import { vcsEnvironment } from "../../../state/vcs";
24import { MetaCard, SheetListRow, menuItemIconName, statusSummary } from "./gitSheetComponents";25import { MetaCard, SheetListRow, menuItemIconName, statusSummary } from "./gitSheetComponents";
25 26
26export function GitOverviewSheet() {27const HEADER_SCROLL_EDGE_EFFECTS = nativeHeaderScrollEdgeEffects(Platform.OS, Platform.Version);
27 const router = useRouter();28
29type GitOverviewSheetProps = StaticScreenProps<{
30 readonly environmentId: string;
31 readonly threadId: string;
32}> & {
33 readonly headerInset?: number;
34 readonly presentation?: "sheet" | "inspector";
35};
36
37export function GitOverviewSheet(props: GitOverviewSheetProps) {
38 const navigation = useNavigation();
28 const insets = useSafeAreaInsets();39 const insets = useSafeAreaInsets();
29 const { environmentId, threadId } = useLocalSearchParams<{40 const presentation = props.presentation ?? "sheet";
30 environmentId: EnvironmentId;41 const isInspector = presentation === "inspector";
31 threadId: ThreadId;42 const environmentId = EnvironmentId.make(props.route.params.environmentId);
32 }>();43 const threadId = ThreadId.make(props.route.params.threadId);
33 const { selectedThread } = useThreadSelection();44 const { selectedThread } = useThreadSelection();
34 const { selectedThreadCwd, selectedThreadWorktreePath } = useSelectedThreadWorktree();45 const { selectedThreadCwd, selectedThreadWorktreePath } = useSelectedThreadWorktree();
35 const gitState = useSelectedThreadGitState();46 const gitState = useSelectedThreadGitState();
@@ -37,6 +48,8 @@ export function GitOverviewSheet() { · 1 unmodified line
37 48
38 const iconColor = useThemeColor("--color-icon");49 const iconColor = useThemeColor("--color-icon");
39 const borderColor = useThemeColor("--color-border");50 const borderColor = useThemeColor("--color-border");
51 const foregroundColor = String(useThemeColor("--color-foreground"));
52 const sheetColor = String(useThemeColor("--color-sheet"));
40 53
41 const gitStatus = useEnvironmentQuery(54 const gitStatus = useEnvironmentQuery(
42 selectedThread !== null && selectedThreadCwd !== null55 selectedThread !== null && selectedThreadCwd !== null
@@ -48,6 +61,7 @@ export function GitOverviewSheet() { · 5 unmodified lines
48 );61 );
49 62
50 const currentBranchLabel = gitStatus.data?.refName ?? selectedThread?.branch ?? "Detached HEAD";63 const currentBranchLabel = gitStatus.data?.refName ?? selectedThread?.branch ?? "Detached HEAD";
64 const currentStatusSummary = statusSummary(gitStatus.data);
51 const currentWorktreePath = selectedThreadWorktreePath;65 const currentWorktreePath = selectedThreadWorktreePath;
52 const gitOperationLabel = gitState.gitOperationLabel;66 const gitOperationLabel = gitState.gitOperationLabel;
53 const busy = gitOperationLabel !== null;67 const busy = gitOperationLabel !== null;
@@ -105,25 +119,24 @@ export function GitOverviewSheet() { · 51 unmodified lines
105 !input.featureBranch &&119 !input.featureBranch &&
106 requiresDefaultBranchConfirmation(input.action, isDefaultRef)120 requiresDefaultBranchConfirmation(input.action, isDefaultRef)
107 ) {121 ) {
108 router.push({122 navigation.navigate("GitConfirm", {
109 pathname: "/threads/[environmentId]/[threadId]/git-confirm",123 environmentId: String(environmentId),
110 params: {124 threadId: String(threadId),
111 environmentId,125 confirmAction: confirmableAction,
112 threadId,126 branchName,
113 confirmAction: confirmableAction,127 includesCommit: String(
114 branchName,128 input.action === "commit_push" || input.action === "commit_push_pr",
115 includesCommit: String(129 ),
116 input.action === "commit_push" || input.action === "commit_push_pr",
117 ),
118 },
119 });130 });
120 return;131 return;
121 }132 }
122 133
123 router.dismiss();134 if (!isInspector) {
135 navigation.goBack();
136 }
124 await gitActions.onRunSelectedThreadGitAction(input);137 await gitActions.onRunSelectedThreadGitAction(input);
125 },138 },
126 [environmentId, gitActions, gitStatus.data, isDefaultRef, router, threadId],139 [environmentId, gitActions, gitStatus.data, isDefaultRef, isInspector, navigation, threadId],
127 );140 );
128 141
129 const onPressMenuItem = useCallback(142 const onPressMenuItem = useCallback(
@@ -134,9 +147,9 @@ export function GitOverviewSheet() { · 4 unmodified lines
134 return;147 return;
135 }148 }
136 if (item.dialogAction === "commit") {149 if (item.dialogAction === "commit") {
137 router.push({150 navigation.navigate("GitCommit", {
138 pathname: "/threads/[environmentId]/[threadId]/git/commit",151 environmentId: String(environmentId),
139 params: { environmentId, threadId },152 threadId: String(threadId),
140 });153 });
141 return;154 return;
142 }155 }
@@ -148,14 +161,209 @@ export function GitOverviewSheet() { · 5 unmodified lines
148 await runActionWithPrompt({ action: "create_pr" });161 await runActionWithPrompt({ action: "create_pr" });
149 }162 }
150 },163 },
151 [environmentId, openExistingPr, router, runActionWithPrompt, threadId],164 [environmentId, openExistingPr, navigation, runActionWithPrompt, threadId],
165 );
166
167 // Status facts live on the relevant rows instead of crowding the header
168 // subtitle: files changed → Commit, ahead → Push, PR → View PR, behind → Pull.
169 const rowStatusDetail = useCallback(
170 (item: (typeof menuItems)[number]): string | undefined => {
171 const status = gitStatus.data;
172 if (status == null) {
173 return undefined;
174 }
175 if (item.dialogAction === "commit" && status.hasWorkingTreeChanges) {
176 const fileCount = status.workingTree?.files.length ?? 0;
177 return `${fileCount} file${fileCount === 1 ? "" : "s"} changed`;
178 }
179 if (item.dialogAction === "push" && (status.aheadCount ?? 0) > 0) {
180 const ahead = status.aheadCount ?? 0;
181 return `${ahead} commit${ahead === 1 ? "" : "s"} ahead`;
182 }
183 if (item.kind === "open_pr" && status.pr?.number != null) {
184 return `PR #${status.pr.number} ${status.pr.state ?? "open"}`;
185 }
186 return undefined;
187 },
188 [gitStatus.data, menuItems],
189 );
190
191 const behindCount = gitStatus.data?.behindCount ?? 0;
192
193 // Deterministic pull-to-refresh state. Tying RefreshControl to the query's
194 // isPending flag left the spinner stuck (the status query reports pending
195 // during quiet background refreshes too).
196 const [isPullRefreshing, setIsPullRefreshing] = useState(false);
197 const handlePullRefresh = useCallback(async () => {
198 setIsPullRefreshing(true);
199 try {
200 await gitActions.refreshSelectedThreadGitStatus();
201 } finally {
202 setIsPullRefreshing(false);
203 }
204 }, [gitActions]);
205
206 const content = (
207 <ScrollView
208 contentInsetAdjustmentBehavior={Platform.OS === "ios" ? "automatic" : "never"}
209 showsVerticalScrollIndicator={false}
210 style={{ flex: 1, backgroundColor: sheetColor }}
211 contentInset={{ bottom: Math.max(insets.bottom, 18) + 18 }}
212 contentContainerStyle={{
213 paddingHorizontal: isInspector ? 12 : 20,
214 paddingTop: 8,
215 gap: 14,
216 }}
217 refreshControl={
218 <RefreshControl refreshing={isPullRefreshing} onRefresh={() => void handlePullRefresh()} />
219 }
220 >
221 <View
222 className={
223 isInspector
224 ? "overflow-hidden rounded-2xl border border-border bg-card px-3 py-1"
225 : "overflow-hidden rounded-[22px] border border-border bg-card px-4 py-1"
226 }
227 >
228 {sheetMenuItems.map(({ item, disabledReason }, index) => (
229 <View key={`${item.id}-${item.label}`}>
230 {index > 0 ? (
231 <View className="ml-12 h-px" style={{ backgroundColor: borderColor }} />
232 ) : null}
233 <SheetListRow
234 icon={menuItemIconName(item.icon)}
235 title={item.label}
236 subtitle={disabledReason ?? rowStatusDetail(item)}
237 disabled={item.disabled}
238 onPress={() => void onPressMenuItem(item)}
239 />
240 </View>
241 ))}
242 {behindCount > 0 ? (
243 <>
244 <View className="ml-12 h-px" style={{ backgroundColor: borderColor }} />
245 <SheetListRow
246 icon="arrow.down.circle"
247 title="Pull latest"
248 subtitle={`${behindCount} commit${behindCount === 1 ? "" : "s"} behind upstream`}
249 disabled={busy || !isRepo}
250 onPress={() => void gitActions.onPullSelectedThreadBranch()}
251 />
252 </>
253 ) : null}
254 <View className="ml-12 h-px" style={{ backgroundColor: borderColor }} />
255 <SheetListRow
256 icon="text.bubble"
257 title="Review changes"
258 subtitle="Inspect turn diffs, worktree changes, and base branch diff"
259 disabled={busy || !isRepo}
260 onPress={() => navigation.navigate("ThreadReview", { environmentId, threadId })}
261 />
262 <View className="ml-12 h-px" style={{ backgroundColor: borderColor }} />
263 <SheetListRow
264 icon="point.topleft.down.curvedto.point.bottomright.up"
265 title="Branches & worktrees"
266 subtitle="Switch branch, create branch, or move to a worktree"
267 disabled={busy || !isRepo}
268 onPress={() =>
269 navigation.navigate("GitBranches", {
270 environmentId: String(environmentId),
271 threadId: String(threadId),
272 })
273 }
274 />
275 </View>
276
277 {currentWorktreePath ? <MetaCard label="Worktree" value={currentWorktreePath} /> : null}
278 </ScrollView>
152 );279 );
153 280
281 if (isInspector && Platform.OS === "ios") {
282 return (
283 <View collapsable={false} className="flex-1 border-l border-border bg-sheet">
284 <ScreenStack style={{ flex: 1 }}>
285 <Screen
286 activityState={2}
287 enabled
288 isNativeStack
289 screenId="thread-git-inspector-native"
290 scrollEdgeEffects={HEADER_SCROLL_EDGE_EFFECTS}
291 style={{ backgroundColor: sheetColor, flex: 1 }}
292 >
293 {content}
294 <ScreenStackHeaderConfig
295 backgroundColor="rgba(0,0,0,0)"
296 color={foregroundColor}
297 hideBackButton
298 hideShadow={false}
299 navigationItemStyle="editor"
300 title={currentBranchLabel}
301 titleColor={foregroundColor}
302 titleFontSize={17}
303 titleFontWeight="700"
304 translucent
305 />
306 </Screen>
307 </ScreenStack>
308 </View>
309 );
310 }
311
312 if (Platform.OS === "ios") {
313 // Compact form sheet: a plain screen presented as formSheet never renders a
314 // stack header, so — like the Settings sheet — the header must come from a
315 // nested native stack INSIDE the sheet. This reuses the exact structure of the
316 // inspector branch below: branch as the title, status summary as the native
317 // subtitle, refresh as a header button.
318 return (
319 <View collapsable={false} className="flex-1 bg-sheet">
320 <ScreenStack style={{ flex: 1 }}>
321 <Screen
322 activityState={2}
323 enabled
324 isNativeStack
325 screenId="thread-git-sheet-native"
326 scrollEdgeEffects={HEADER_SCROLL_EDGE_EFFECTS}
327 style={{ backgroundColor: sheetColor, flex: 1 }}
328 >
329 {content}
330 <ScreenStackHeaderConfig
331 backgroundColor="rgba(0,0,0,0)"
332 color={foregroundColor}
333 hideBackButton
334 hideShadow={false}
335 navigationItemStyle="editor"
336 title={currentBranchLabel}
337 titleColor={foregroundColor}
338 titleFontSize={18}
339 titleFontWeight="800"
340 translucent
341 />
342 </Screen>
343 </ScreenStack>
344 </View>
345 );
346 }
347
154 return (348 return (
155 <View collapsable={false} className="flex-1 bg-sheet">349 <View
156 <View style={{ minHeight: 16, paddingTop: 8 }} />350 collapsable={false}
351 className={isInspector ? "flex-1 border-l border-border bg-sheet" : "flex-1 bg-sheet"}
352 >
353 <View
354 style={{
355 minHeight: isInspector ? (props.headerInset ?? 0) : 16,
356 paddingTop: isInspector ? (props.headerInset ?? 0) : 8,
357 }}
358 />
157 359
158 <View className="items-center gap-1 px-5 pb-3 pt-4">360 <View
361 className={
362 isInspector
363 ? "gap-1 border-b border-border px-4 pb-4 pt-3"
364 : "items-center gap-1 px-5 pb-3 pt-4"
365 }
366 >
159 <Pressable367 <Pressable
160 className="absolute right-3 top-4 h-9 w-9 items-center justify-center rounded-full bg-subtle"368 className="absolute right-3 top-4 h-9 w-9 items-center justify-center rounded-full bg-subtle"
161 style={{ zIndex: 1, opacity: busy ? 0.45 : 1 }}369 style={{ zIndex: 1, opacity: busy ? 0.45 : 1 }}
@@ -174,76 +382,17 @@ export function GitOverviewSheet() { · 12 unmodified lines
174 className="text-xs font-t3-bold uppercase text-foreground-muted"382 className="text-xs font-t3-bold uppercase text-foreground-muted"
175 style={{ letterSpacing: 1 }}383 style={{ letterSpacing: 1 }}
176 >384 >
177 Branch385 {isInspector ? "Repository" : "Branch"}
386 </Text>
387 <Text className={isInspector ? "pr-10 text-xl font-t3-bold" : "text-3xl font-t3-bold"}>
388 {currentBranchLabel}
178 </Text>389 </Text>
179 <Text className="text-3xl font-t3-bold">{currentBranchLabel}</Text>390 <Text className="text-foreground-secondary text-sm font-medium leading-normal">
180 <Text className="text-foreground-secondary text-sm font-medium leading-[19px]">391 {currentStatusSummary}
181 {statusSummary(gitStatus.data)}
182 </Text>392 </Text>
183 </View>393 </View>
184 394
185 <ScrollView395 {content}
186 showsVerticalScrollIndicator={false}
187 style={{ flex: 1 }}
188 contentInset={{ bottom: Math.max(insets.bottom, 18) + 18 }}
189 contentContainerStyle={{
190 paddingHorizontal: 20,
191 paddingTop: 8,
192 gap: 14,
193 }}
194 >
195 <View className="overflow-hidden rounded-[22px] border border-border bg-card px-4 py-1">
196 {sheetMenuItems.map(({ item, disabledReason }, index) => (
197 <View key={`${item.id}-${item.label}`}>
198 {index > 0 ? (
199 <View className="ml-12 h-px" style={{ backgroundColor: borderColor }} />
200 ) : null}
201 <SheetListRow
202 icon={menuItemIconName(item.icon)}
203 title={item.label}
204 subtitle={disabledReason}
205 disabled={item.disabled}
206 onPress={() => void onPressMenuItem(item)}
207 />
208 </View>
209 ))}
210 {(gitStatus.data?.behindCount ?? 0) > 0 ? (
211 <>
212 <View className="ml-12 h-px" style={{ backgroundColor: borderColor }} />
213 <SheetListRow
214 icon="arrow.down.circle"
215 title="Pull latest"
216 subtitle="Sync this branch with upstream"
217 disabled={busy || !isRepo}
218 onPress={() => void gitActions.onPullSelectedThreadBranch()}
219 />
220 </>
221 ) : null}
222 <View className="ml-12 h-px" style={{ backgroundColor: borderColor }} />
223 <SheetListRow
224 icon="text.bubble"
225 title="Review changes"
226 subtitle="Inspect turn diffs, worktree changes, and base branch diff"
227 disabled={busy || !isRepo}
228 onPress={() => router.push(buildThreadReviewRoutePath({ environmentId, threadId }))}
229 />
230 <View className="ml-12 h-px" style={{ backgroundColor: borderColor }} />
231 <SheetListRow
232 icon="point.topleft.down.curvedto.point.bottomright.up"
233 title="Branches & worktrees"
234 subtitle="Switch branch, create branch, or move to a worktree"
235 disabled={busy || !isRepo}
236 onPress={() =>
237 router.push({
238 pathname: "/threads/[environmentId]/[threadId]/git/branches",
239 params: { environmentId, threadId },
240 })
241 }
242 />
243 </View>
244
245 {currentWorktreePath ? <MetaCard label="Worktree" value={currentWorktreePath} /> : null}
246 </ScrollView>
247 </View>396 </View>
248 );397 );
249}398}
apps/mobile/src/features/threads/git/gitSheetComponents.tsx+1−1
@@ -104,7 +104,7 @@ export function SheetListRow(props: { · 103 unmodified lines
104 <View className="flex-1 gap-0.5">104 <View className="flex-1 gap-0.5">
105 <Text className="text-foreground text-base font-t3-bold">{props.title}</Text>105 <Text className="text-foreground text-base font-t3-bold">{props.title}</Text>
106 {props.subtitle ? (106 {props.subtitle ? (
107 <Text className="text-foreground-muted text-xs leading-[17px]">{props.subtitle}</Text>107 <Text className="text-foreground-muted text-xs leading-snug">{props.subtitle}</Text>
108 ) : null}108 ) : null}
109 </View>109 </View>
110 <SymbolView name="chevron.right" size={13} tintColor={iconSubtleColor} type="monochrome" />110 <SymbolView name="chevron.right" size={13} tintColor={iconSubtleColor} type="monochrome" />
apps/mobile/src/features/threads/new-task-flow-provider.tsx+318−10
@@ -1,4 +1,4 @@
1import React, { useCallback, useEffect, useMemo, useState } from "react";1import React, { useCallback, useEffect, useMemo, useRef, useState } from "react";
2 2
3import type {3import type {
4 EnvironmentId,4 EnvironmentId,
@@ -8,18 +8,29 @@ import type { · 3 unmodified lines
8 RuntimeMode,8 RuntimeMode,
9 ServerProviderSkill,9 ServerProviderSkill,
10} from "@t3tools/contracts";10} from "@t3tools/contracts";
11import { DEFAULT_PROVIDER_INTERACTION_MODE, DEFAULT_RUNTIME_MODE } from "@t3tools/contracts";11import {
12 CommandId,
13 DEFAULT_PROVIDER_INTERACTION_MODE,
14 DEFAULT_RUNTIME_MODE,
15 MessageId,
16 ThreadId,
17} from "@t3tools/contracts";
12import * as Arr from "effect/Array";18import * as Arr from "effect/Array";
13import { pipe } from "effect/Function";19import { pipe } from "effect/Function";
14 20
15import { useEnvironmentServerConfig, useProjects, useThreadShells } from "../../state/entities";21import { useEnvironmentServerConfig, useProjects, useThreadShells } from "../../state/entities";
22import type { TurnCommandMetadata } from "../../lib/commandMetadata";
16import type { DraftComposerImageAttachment } from "../../lib/composerImages";23import type { DraftComposerImageAttachment } from "../../lib/composerImages";
17import type { ModelOption, ProviderGroup } from "../../lib/modelOptions";24import type { ModelOption, ProviderGroup } from "../../lib/modelOptions";
18import { buildModelOptions, groupByProvider } from "../../lib/modelOptions";25import { buildModelOptions, groupByProvider } from "../../lib/modelOptions";
19import { groupProjectsByRepository } from "../../lib/repositoryGroups";26import { groupProjectsByRepository } from "../../lib/repositoryGroups";
20import { scopedProjectKey } from "../../lib/scopedEntities";27import { scopedProjectKey } from "../../lib/scopedEntities";
28import { appAtomRegistry } from "../../state/atom-registry";
21import {29import {
22 appendComposerDraftAttachments,30 appendComposerDraftAttachments,
31 clearComposerDraft,
32 getComposerDraftSnapshot,
33 isComposerDraftEmpty,
23 removeComposerDraftAttachment,34 removeComposerDraftAttachment,
24 replaceComposerDraftAttachments,35 replaceComposerDraftAttachments,
25 setComposerDraftText,36 setComposerDraftText,
@@ -27,6 +38,17 @@ import { · 1 unmodified line
27 useComposerDraft,38 useComposerDraft,
28} from "../../state/use-composer-drafts";39} from "../../state/use-composer-drafts";
29import { useBranches } from "../../state/queries";40import { useBranches } from "../../state/queries";
41import {
42 flattenQueuedThreadMessages,
43 threadOutboxManager,
44 updateThreadOutboxMessage,
45 type QueuedThreadMessage,
46} from "../../state/thread-outbox";
47import {
48 holdEditingQueuedMessage,
49 releaseEditingQueuedMessage,
50 useThreadOutboxMessages,
51} from "../../state/use-thread-outbox";
30import {52import {
31 setPendingConnectionError,53 setPendingConnectionError,
32 useSavedRemoteConnections,54 useSavedRemoteConnections,
@@ -36,6 +58,23 @@ import { type VcsRef } from "@t3tools/client-runtime/state/vcs"; · 3 unmodified lines
36 58
37type WorkspaceMode = "local" | "worktree";59type WorkspaceMode = "local" | "worktree";
38 60
61function 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.
69let activeEditingMessageId: string | null = null;
70
71function 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
39function normalizeSelectedWorktreePath(project: EnvironmentProject, branch: VcsRef): string | null {78function normalizeSelectedWorktreePath(project: EnvironmentProject, branch: VcsRef): string | null {
40 if (!branch.worktreePath) {79 if (!branch.worktreePath) {
41 return null;80 return null;
@@ -74,6 +113,9 @@ type NewTaskFlowContextValue = { · 32 unmodified lines
74 readonly workspaceMode: WorkspaceMode;113 readonly workspaceMode: WorkspaceMode;
75 readonly selectedBranchName: string | null;114 readonly selectedBranchName: string | null;
76 readonly selectedWorktreePath: string | null;115 readonly selectedWorktreePath: string | null;
116 readonly startFromOrigin: boolean;
117 readonly draftKey: string | null;
118 readonly editingPendingTask: QueuedThreadMessage | null;
77 readonly prompt: string;119 readonly prompt: string;
78 readonly attachments: ReadonlyArray<DraftComposerImageAttachment>;120 readonly attachments: ReadonlyArray<DraftComposerImageAttachment>;
79 readonly submitting: boolean;121 readonly submitting: boolean;
@@ -100,6 +142,11 @@ type NewTaskFlowContextValue = { · 20 unmodified lines
100 readonly setSelectedModelKey: (key: string | null) => void;142 readonly setSelectedModelKey: (key: string | null) => void;
101 readonly setWorkspaceMode: (mode: WorkspaceMode) => void;143 readonly setWorkspaceMode: (mode: WorkspaceMode) => void;
102 readonly selectBranch: (branch: VcsRef) => void;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 readonly setPrompt: (value: string) => void;150 readonly setPrompt: (value: string) => void;
104 readonly replaceAttachments: (attachments: ReadonlyArray<DraftComposerImageAttachment>) => void;151 readonly replaceAttachments: (attachments: ReadonlyArray<DraftComposerImageAttachment>) => void;
105 readonly appendAttachments: (attachments: ReadonlyArray<DraftComposerImageAttachment>) => void;152 readonly appendAttachments: (attachments: ReadonlyArray<DraftComposerImageAttachment>) => void;
@@ -162,6 +209,10 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) { · 56 unmodified lines
162 const [submitting, setSubmitting] = useState(false);209 const [submitting, setSubmitting] = useState(false);
163 const [branchQuery, setBranchQuery] = useState("");210 const [branchQuery, setBranchQuery] = useState("");
164 const [expandedProvider, setExpandedProvider] = useState<string | null>(null);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 const reset = useCallback(() => {217 const reset = useCallback(() => {
167 setSelectedEnvironmentId(null);218 setSelectedEnvironmentId(null);
@@ -169,6 +220,15 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) { · 1 unmodified line
169 setSubmitting(false);220 setSubmitting(false);
170 setBranchQuery("");221 setBranchQuery("");
171 setExpandedProvider(null);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 const projectsForEnvironment = useMemo(234 const projectsForEnvironment = useMemo(
@@ -180,12 +240,42 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) { · 5 unmodified lines
180 [projects, selectedEnvironmentId],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 const selectedProject =267 const selectedProject =
184 projectsForEnvironment.find(268 projectsForEnvironment.find(
185 (project) => scopedProjectKey(project.environmentId, project.id) === selectedProjectKey,269 (project) => scopedProjectKey(project.environmentId, project.id) === selectedProjectKey,
186 ) ??270 ) ??
187 projectsForEnvironment[0] ??271 // While editing a queued task whose project shell is absent, keep the task
188 null;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 // Only offer machines that actually host the currently selected repository, so280 // Only offer machines that actually host the currently selected repository, so
191 // switching computers moves the same repo across machines instead of jumping to281 // 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 const selectedEnvironmentServerConfig = useEnvironmentServerConfig(313 const selectedEnvironmentServerConfig = useEnvironmentServerConfig(
224 selectedProject?.environmentId ?? null,314 selectedProject?.environmentId ?? null,
225 );315 );
226 const selectedProjectDraftKey = selectedProject316 // While a queued pending task is being edited its draft lives under a key
227 ? `new-task:${scopedProjectKey(selectedProject.environmentId, selectedProject.id)}`317 // scoped to the queued message, so per-project new-task drafts stay intact.
228 : null;318 const selectedProjectDraftKey = editingPendingTask
319 ? pendingTaskDraftKey(editingPendingTask.messageId)
320 : selectedProject
321 ? `new-task:${scopedProjectKey(selectedProject.environmentId, selectedProject.id)}`
322 : null;
229 const selectedProjectDraft = useComposerDraft(selectedProjectDraftKey);323 const selectedProjectDraft = useComposerDraft(selectedProjectDraftKey);
230 const prompt = selectedProjectDraft.text;324 const prompt = selectedProjectDraft.text;
231 const attachments = selectedProjectDraft.attachments;325 const attachments = selectedProjectDraft.attachments;
232 const workspaceMode = selectedProjectDraft.workspaceSelection?.mode ?? "local";326 const workspaceMode = selectedProjectDraft.workspaceSelection?.mode ?? "local";
233 const selectedBranchName = selectedProjectDraft.workspaceSelection?.branch ?? null;327 const selectedBranchName = selectedProjectDraft.workspaceSelection?.branch ?? null;
234 const selectedWorktreePath = selectedProjectDraft.workspaceSelection?.worktreePath ?? null;328 const selectedWorktreePath = selectedProjectDraft.workspaceSelection?.worktreePath ?? null;
329 const startFromOrigin = selectedProjectDraft.workspaceSelection?.startFromOrigin ?? false;
235 const runtimeMode = selectedProjectDraft.runtimeMode ?? DEFAULT_RUNTIME_MODE;330 const runtimeMode = selectedProjectDraft.runtimeMode ?? DEFAULT_RUNTIME_MODE;
236 const interactionMode = selectedProjectDraft.interactionMode ?? DEFAULT_PROVIDER_INTERACTION_MODE;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 const branchTarget = useMemo(445 const branchTarget = useMemo(
351 () => ({446 () => ({
352 environmentId: selectedProject?.environmentId ?? null,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 query: null,450 query: null,
355 }),451 }),
356 [selectedProject?.environmentId, selectedProject?.workspaceRoot],452 [selectedProject?.environmentId, selectedProject?.workspaceRoot],
@@ -411,10 +507,11 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) { · 54 unmodified lines
411 mode,507 mode,
412 branch: selectedBranchName,508 branch: selectedBranchName,
413 worktreePath: selectedWorktreePath,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 const selectBranch = useCallback(517 const selectBranch = useCallback(
@@ -427,10 +524,28 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) { · 6 unmodified lines
427 mode: workspaceMode,524 mode: workspaceMode,
428 branch: branch.name,525 branch: branch.name,
429 worktreePath: normalizeSelectedWorktreePath(selectedProject, branch),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 const refreshBranches = branchState.refresh;551 const refreshBranches = branchState.refresh;
@@ -472,6 +587,183 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) { · 35 unmodified lines
472 [selectedProjectDraftKey],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 const value = useMemo<NewTaskFlowContextValue>(767 const value = useMemo<NewTaskFlowContextValue>(
476 () => ({768 () => ({
477 logicalProjects,769 logicalProjects,
@@ -481,6 +773,9 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) { · 3 unmodified lines
481 workspaceMode,773 workspaceMode,
482 selectedBranchName,774 selectedBranchName,
483 selectedWorktreePath,775 selectedWorktreePath,
776 startFromOrigin,
777 draftKey: selectedProjectDraftKey,
778 editingPendingTask,
484 prompt,779 prompt,
485 attachments,780 attachments,
486 submitting,781 submitting,
@@ -504,6 +799,11 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) { · 17 unmodified lines
504 setSelectedModelKey,799 setSelectedModelKey,
505 setWorkspaceMode,800 setWorkspaceMode,
506 selectBranch,801 selectBranch,
802 setStartFromOrigin,
803 beginEditingPendingTask,
804 finishEditingPendingTask,
805 cancelEditingPendingTask,
806 buildPendingTaskMessage,
507 setPrompt,807 setPrompt,
508 replaceAttachments,808 replaceAttachments,
509 appendAttachments,809 appendAttachments,
@@ -520,11 +820,16 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) { · 10 unmodified lines
520 [820 [
521 attachments,821 attachments,
522 availableBranches,822 availableBranches,
823 beginEditingPendingTask,
523 branchQuery,824 branchQuery,
524 branchesLoading,825 branchesLoading,
826 buildPendingTaskMessage,
827 cancelEditingPendingTask,
828 editingPendingTask,
525 environments,829 environments,
526 expandedProvider,830 expandedProvider,
527 filteredBranches,831 filteredBranches,
832 finishEditingPendingTask,
528 interactionMode,833 interactionMode,
529 loadBranches,834 loadBranches,
530 logicalProjects,835 logicalProjects,
@@ -539,6 +844,7 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) { · 8 unmodified lines
539 selectedModel,844 selectedModel,
540 selectedModelKey,845 selectedModelKey,
541 selectedModelOption,846 selectedModelOption,
847 selectedProjectDraftKey,
542 selectedProviderSkills,848 selectedProviderSkills,
543 setSelectedModelOptions,849 setSelectedModelOptions,
544 selectedProject,850 selectedProject,
@@ -551,7 +857,9 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) { · 6 unmodified lines
551 setPrompt,857 setPrompt,
552 setRuntimeMode,858 setRuntimeMode,
553 setSelectedModelKey,859 setSelectedModelKey,
860 setStartFromOrigin,
554 setWorkspaceMode,861 setWorkspaceMode,
862 startFromOrigin,
555 submitting,863 submitting,
556 workspaceMode,864 workspaceMode,
557 appendAttachments,865 appendAttachments,
apps/mobile/src/features/threads/sidebar-filter-button.tsx+55−0
@@ -0,0 +1,55 @@
1import { SymbolView } from "expo-symbols";
2import { Pressable, StyleSheet, useColorScheme } from "react-native";
3
4import { useThemeColor } from "../../lib/useThemeColor";
5
6export type SidebarFilterButtonIcon =
7 | "line.3.horizontal.decrease.circle"
8 | "line.3.horizontal.decrease.circle.fill";
9
10export function SidebarFilterButton(props: {
11 readonly accessibilityLabel: string;
12 readonly icon: SidebarFilterButtonIcon;
13 /** Rendered inside a shared capsule group — no own background/border. */
14 readonly grouped?: boolean;
15}) {
16 const iconColor = useThemeColor("--color-foreground");
17 const pressedBackgroundColor = useThemeColor("--color-subtle");
18 const colorScheme = useColorScheme() === "dark" ? "dark" : "light";
19 const idleBackgroundColor =
20 colorScheme === "dark" ? "rgba(118,118,128,0.24)" : "rgba(255,255,255,0.72)";
21 const borderColor = colorScheme === "dark" ? "rgba(255,255,255,0.08)" : "rgba(0,0,0,0.08)";
22
23 return (
24 <Pressable
25 accessibilityLabel={props.accessibilityLabel}
26 accessibilityRole="button"
27 hitSlop={4}
28 style={({ pressed }) => [
29 styles.button,
30 props.grouped
31 ? { backgroundColor: pressed ? pressedBackgroundColor : "transparent", borderWidth: 0 }
32 : {
33 backgroundColor: pressed ? pressedBackgroundColor : idleBackgroundColor,
34 borderColor,
35 },
36 ]}
37 >
38 <SymbolView name={props.icon} size={20} tintColor={iconColor} type="monochrome" />
39 </Pressable>
40 );
41}
42
43const styles = StyleSheet.create({
44 button: {
45 // Match the native glass UIBarButtonItem group metrics (~50pt slots,
46 // 44pt bar height, label-colored ~20pt glyphs).
47 width: 50,
48 height: 44,
49 borderRadius: 22,
50 borderWidth: StyleSheet.hairlineWidth,
51 alignItems: "center",
52 justifyContent: "center",
53 cursor: "pointer",
54 },
55});
apps/mobile/src/features/threads/sidebar-header-actions.tsx+74−0
@@ -0,0 +1,74 @@
1import { SymbolView } from "expo-symbols";
2import { Pressable, StyleSheet, View, useColorScheme } from "react-native";
3
4import { useThemeColor } from "../../lib/useThemeColor";
5
6export interface SidebarHeaderActionsProps {
7 readonly onOpenSettings: () => void;
8 /** Rendered inside a shared capsule group — buttons drop their own chrome. */
9 readonly grouped?: boolean;
10}
11
12function FallbackHeaderButton(props: {
13 readonly accessibilityLabel: string;
14 readonly icon: "gearshape" | "square.and.pencil";
15 readonly grouped?: boolean;
16 readonly onPress: () => void;
17}) {
18 const iconColor = useThemeColor("--color-foreground");
19 const pressedBackgroundColor = useThemeColor("--color-subtle");
20 const colorScheme = useColorScheme() === "dark" ? "dark" : "light";
21 const idleBackgroundColor =
22 colorScheme === "dark" ? "rgba(118,118,128,0.24)" : "rgba(255,255,255,0.72)";
23 const borderColor = colorScheme === "dark" ? "rgba(255,255,255,0.08)" : "rgba(0,0,0,0.08)";
24
25 return (
26 <Pressable
27 accessibilityLabel={props.accessibilityLabel}
28 accessibilityRole="button"
29 hitSlop={4}
30 onPress={props.onPress}
31 style={({ pressed }) => [
32 styles.button,
33 props.grouped
34 ? { backgroundColor: pressed ? pressedBackgroundColor : "transparent", borderWidth: 0 }
35 : {
36 backgroundColor: pressed ? pressedBackgroundColor : idleBackgroundColor,
37 borderColor,
38 },
39 ]}
40 >
41 <SymbolView name={props.icon} size={20} tintColor={iconColor} type="monochrome" />
42 </Pressable>
43 );
44}
45
46export function SidebarHeaderActions(props: SidebarHeaderActionsProps) {
47 return (
48 <View style={styles.actions}>
49 <FallbackHeaderButton
50 accessibilityLabel="Open settings"
51 grouped={props.grouped}
52 icon="gearshape"
53 onPress={props.onOpenSettings}
54 />
55 </View>
56 );
57}
58
59const styles = StyleSheet.create({
60 actions: {
61 flexDirection: "row",
62 alignItems: "center",
63 gap: 2,
64 },
65 button: {
66 // Match the native glass UIBarButtonItem group metrics.
67 width: 50,
68 height: 44,
69 borderRadius: 22,
70 borderWidth: StyleSheet.hairlineWidth,
71 alignItems: "center",
72 justifyContent: "center",
73 },
74});
apps/mobile/src/features/threads/sidebar-native-header-items.ts+63−0
@@ -0,0 +1,63 @@
1import type {
2 NativeStackHeaderItem,
3 NativeStackHeaderItemMenu,
4} from "@react-navigation/native-stack";
5
6import type { HomeListFilterMenu } from "../home/home-list-filter-menu";
7import { withNativeGlassHeaderItem } from "../layout/native-glass-header-items";
8
9type NativeHeaderMenuItems = NativeStackHeaderItemMenu["menu"]["items"];
10type NativeHeaderIcon = NonNullable<Extract<NativeStackHeaderItem, { type: "button" }>["icon"]>;
11
12function sfSymbolIcon(name: string): NativeHeaderIcon {
13 return { type: "sfSymbol", name: name as never };
14}
15
16function toNativeHeaderMenuItems(items: HomeListFilterMenu["items"]): NativeHeaderMenuItems {
17 return items.map((item) =>
18 item.type === "action"
19 ? {
20 type: "action" as const,
21 label: item.title,
22 description: item.subtitle,
23 onPress: item.onPress,
24 state: item.state === "on" ? ("on" as const) : undefined,
25 }
26 : {
27 type: "submenu" as const,
28 label: item.title,
29 items: toNativeHeaderMenuItems(item.items),
30 },
31 );
32}
33
34/**
35 * Right-side UINavigationBar items for the sidebar column: the thread list
36 * filter/sort menu plus the settings button, sharing one glass capsule —
37 * the Messages-style grouped header buttons.
38 */
39export function createSidebarHeaderItems(input: {
40 readonly filterIcon: string;
41 readonly filterMenu: HomeListFilterMenu;
42 readonly onOpenSettings: () => void;
43}): NativeStackHeaderItem[] {
44 return [
45 withNativeGlassHeaderItem({
46 type: "menu",
47 label: "",
48 accessibilityLabel: "Filter and sort threads",
49 icon: sfSymbolIcon(input.filterIcon),
50 menu: {
51 title: input.filterMenu.title,
52 items: toNativeHeaderMenuItems(input.filterMenu.items),
53 },
54 }),
55 withNativeGlassHeaderItem({
56 type: "button",
57 label: "",
58 accessibilityLabel: "Open settings",
59 icon: sfSymbolIcon("gearshape"),
60 onPress: input.onOpenSettings,
61 }),
62 ];
63}
apps/mobile/src/features/threads/sidebar-navigation-shell.tsx+72−0
@@ -0,0 +1,72 @@
1import {
2 DarkTheme,
3 DefaultTheme,
4 NavigationContainer,
5 NavigationIndependentTree,
6} from "@react-navigation/native";
7import {
8 createNativeStackNavigator,
9 type NativeStackNavigationOptions,
10} from "@react-navigation/native-stack";
11import type { ReactNode } from "react";
12import { Platform, useColorScheme } from "react-native";
13
14import { nativeHeaderScrollEdgeEffects } from "../../native/StackHeader";
15
16const SCROLL_EDGE_EFFECTS = nativeHeaderScrollEdgeEffects(Platform.OS, Platform.Version);
17
18type SidebarScreenOptions = NativeStackNavigationOptions & {
19 // Same patched RNS option the GLASS/SOLID presets in Stack.tsx use — the
20 // iOS 26 "editor" navigation-item style leading-aligns the inline title.
21 readonly unstable_navigationItemStyle?: "editor";
22};
23
24/**
25 * Static chrome for the sidebar column: a real UINavigationBar with a fixed
26 * inline title (no large title — saves vertical space, left-aligned via the
27 * editor item style) and the search bar pinned below it, scroll-edge blur
28 * sampling the list. Only genuinely dynamic values (search callbacks, header
29 * items) are set by the screen content via NativeStackScreenOptions.
30 */
31const SIDEBAR_SCREEN_OPTIONS: SidebarScreenOptions = {
32 contentStyle: { backgroundColor: "transparent" },
33 headerLargeTitle: false,
34 headerShadowVisible: false,
35 headerShown: true,
36 headerStyle: { backgroundColor: "transparent" },
37 headerTitleStyle: { fontSize: 18, fontWeight: "800" },
38 headerTransparent: true,
39 scrollEdgeEffects: SCROLL_EDGE_EFFECTS,
40 title: "Threads",
41 unstable_navigationItemStyle: "editor",
42};
43
44const SidebarStack = createNativeStackNavigator();
45
46/**
47 * Hosts the iPad sidebar pane inside its own single-screen native stack.
48 *
49 * The stack is navigation-inert — nothing is ever pushed onto it. It exists so
50 * the sidebar column owns a real UINavigationBar (large title, native bar
51 * button items, UISearchController), mirroring how each column of a
52 * UISplitViewController has its own UINavigationController. All real
53 * navigation still flows through the root stack via callbacks minted in
54 * AdaptiveWorkspaceLayout; NavigationIndependentTree only isolates the
55 * navigation hooks used for header configuration inside the pane.
56 */
57export function SidebarNavigationShell(props: { readonly children: ReactNode }) {
58 const colorScheme = useColorScheme();
59
60 return (
61 <NavigationIndependentTree>
62 <NavigationContainer theme={colorScheme === "dark" ? DarkTheme : DefaultTheme}>
63 <SidebarStack.Navigator
64 screenOptions={SIDEBAR_SCREEN_OPTIONS}
65 initialRouteName="SidebarThreads"
66 >
67 <SidebarStack.Screen name="SidebarThreads">{() => props.children}</SidebarStack.Screen>
68 </SidebarStack.Navigator>
69 </NavigationContainer>
70 </NavigationIndependentTree>
71 );
72}
apps/mobile/src/features/threads/thread-inspector-content-stack.tsx+101−0
@@ -0,0 +1,101 @@
1import { useEffect, useState, type ComponentType, type ReactNode } from "react";
2import { View } from "react-native";
3
4export type ThreadInspectorMode = "route" | "git" | "files";
5
6const INSPECTOR_PREWARM_DELAY_MS = 350;
7
8function InspectorContentPane(props: {
9 readonly children: ReactNode;
10 readonly mounted: boolean;
11 readonly visible: boolean;
12}) {
13 if (!props.mounted) {
14 return null;
15 }
16
17 return (
18 <View
19 accessibilityElementsHidden={!props.visible}
20 focusable={props.visible}
21 importantForAccessibility={props.visible ? "auto" : "no-hide-descendants"}
22 pointerEvents={props.visible ? "auto" : "none"}
23 style={{
24 position: "absolute",
25 inset: 0,
26 opacity: props.visible ? 1 : 0,
27 zIndex: props.visible ? 1 : 0,
28 }}
29 >
30 {props.children}
31 </View>
32 );
33}
34
35export function ThreadInspectorContentStack(props: {
36 readonly Files: ComponentType;
37 readonly Git: ComponentType;
38 readonly mode: ThreadInspectorMode;
39 readonly Route?: ComponentType;
40}) {
41 const [mountedModes, setMountedModes] = useState<ReadonlySet<ThreadInspectorMode>>(
42 () => new Set([props.mode]),
43 );
44
45 useEffect(() => {
46 setMountedModes((current) => {
47 if (current.has(props.mode)) {
48 return current;
49 }
50 return new Set([...current, props.mode]);
51 });
52
53 if (props.mode === "route") {
54 return;
55 }
56
57 // The file tree is expensive to detach because UIKit rebuilds its focus
58 // graph. Keep both chat inspectors alive after the opening animation so a
59 // later Files/Git switch only changes visibility.
60 const alternateMode = props.mode === "files" ? "git" : "files";
61 const timeout = setTimeout(() => {
62 setMountedModes((current) => {
63 if (current.has(alternateMode)) {
64 return current;
65 }
66 return new Set([...current, alternateMode]);
67 });
68 }, INSPECTOR_PREWARM_DELAY_MS);
69
70 return () => clearTimeout(timeout);
71 }, [props.mode]);
72
73 const Files = props.Files;
74 const Git = props.Git;
75 const Route = props.Route;
76
77 return (
78 <View style={{ flex: 1 }}>
79 <InspectorContentPane
80 mounted={mountedModes.has("files") || props.mode === "files"}
81 visible={props.mode === "files"}
82 >
83 <Files />
84 </InspectorContentPane>
85 <InspectorContentPane
86 mounted={mountedModes.has("git") || props.mode === "git"}
87 visible={props.mode === "git"}
88 >
89 <Git />
90 </InspectorContentPane>
91 {Route ? (
92 <InspectorContentPane
93 mounted={mountedModes.has("route") || props.mode === "route"}
94 visible={props.mode === "route"}
95 >
96 <Route />
97 </InspectorContentPane>
98 ) : null}
99 </View>
100 );
101}
apps/mobile/src/features/threads/thread-list-items.tsx+632−0
@@ -0,0 +1,632 @@
1import { useRecyclingState } from "@legendapp/list/react-native";
2import type {
3 EnvironmentProject,
4 EnvironmentThreadShell,
5} from "@t3tools/client-runtime/state/shell";
6import type { MenuAction } from "@react-native-menu/menu";
7import { SymbolView } from "expo-symbols";
8import { memo, useCallback, useMemo, type ComponentProps } from "react";
9import { Pressable, useWindowDimensions, View } from "react-native";
10import type { SwipeableMethods } from "react-native-gesture-handler/ReanimatedSwipeable";
11
12import { AppText as Text } from "../../components/AppText";
13import { ControlPillMenu } from "../../components/ControlPill";
14import { ProjectFavicon } from "../../components/ProjectFavicon";
15import { relativeTime } from "../../lib/time";
16import { useThemeColor } from "../../lib/useThemeColor";
17import type { PendingNewTask } from "../../state/use-pending-new-tasks";
18import { useThreadPr } from "../../state/use-thread-pr";
19import type { HomeGroupDisplayAction } from "../home/homeListItems";
20import { ThreadSwipeable } from "../home/thread-swipe-actions";
21import { 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 */
29export type ThreadListVariant = "compact" | "sidebar";
30
31/** Left inset that aligns compact secondary rows with the title column. */
32export const THREAD_LIST_COMPACT_INSET = 20;
33const SIDEBAR_ROW_RADIUS = 12;
34
35/* ─── Project group header ───────────────────────────────────────────── */
36
37export 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
132export 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
206const 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 */
215export 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.

apps/mobile/src/features/threads/thread-navigation-groups.test.ts+111−0

This diff is large: its 112 lines aren’t shown here.

apps/mobile/src/features/threads/thread-navigation-groups.ts+64−0

This diff is large: its 65 lines aren’t shown here.

apps/mobile/src/features/threads/thread-work-log.tsx+2−5

This diff is large: its 21 lines aren’t shown here.

apps/mobile/src/features/threads/threadContentPresentation.ts+7−1

This diff is large: its 15 lines aren’t shown here.

apps/mobile/src/features/threads/threadPresentation.ts+103−18

This diff is large: its 148 lines aren’t shown here.

apps/mobile/src/features/threads/use-project-actions.ts+22−51

This diff is large: its 117 lines aren’t shown here.

apps/mobile/src/lib/adaptive-navigation.test.ts+68−0

This diff is large: its 69 lines aren’t shown here.

apps/mobile/src/lib/adaptive-navigation.ts+35−0

This diff is large: its 36 lines aren’t shown here.

apps/mobile/src/lib/appearancePreferences.test.ts+142−0

This diff is large: its 143 lines aren’t shown here.

apps/mobile/src/lib/appearancePreferences.ts+245−0

This diff is large: its 246 lines aren’t shown here.

apps/mobile/src/lib/layout.test.ts+343−0

This diff is large: its 344 lines aren’t shown here.

apps/mobile/src/lib/layout.ts+226−4

This diff is large: its 252 lines aren’t shown here.

apps/mobile/src/lib/projectThreadStartTurn.ts+89−0

This diff is large: its 90 lines aren’t shown here.

apps/mobile/src/lib/routes.test.ts+0−45

This diff is large: its 46 lines aren’t shown here.

apps/mobile/src/lib/routes.ts+0−134

This diff is large: its 135 lines aren’t shown here.

apps/mobile/src/lib/storage.ts+26−3

This diff is large: its 50 lines aren’t shown here.

apps/mobile/src/lib/typography.test.ts+4−7

This diff is large: its 26 lines aren’t shown here.

apps/mobile/src/lib/typography.ts+10−11

This diff is large: its 30 lines aren’t shown here.

apps/mobile/src/lib/uuid.ts+6−0

This diff is large: its 10 lines aren’t shown here.

apps/mobile/src/native/StackHeader.tsx+359−0

This diff is large: its 360 lines aren’t shown here.

apps/mobile/src/native/T3ComposerEditor.ios.tsx+7−3

This diff is large: its 57 lines aren’t shown here.

apps/mobile/src/native/T3ComposerEditor.tsx+3−2

This diff is large: its 27 lines aren’t shown here.

apps/mobile/src/native/T3ComposerEditor.types.ts+2−0

This diff is large: its 7 lines aren’t shown here.

apps/mobile/src/native/T3KeyboardCommands.ios.tsx+31−0

This diff is large: its 32 lines aren’t shown here.

apps/mobile/src/native/T3KeyboardCommands.tsx+13−0

This diff is large: its 14 lines aren’t shown here.

apps/mobile/src/native/scrollEdgeEffects.test.ts+29−0

This diff is large: its 30 lines aren’t shown here.

apps/mobile/src/native/scrollEdgeEffects.ts+37−0

This diff is large: its 38 lines aren’t shown here.

apps/mobile/src/state/thread-outbox-manager.ts+31−0

This diff is large: its 52 lines aren’t shown here.

apps/mobile/src/state/thread-outbox-model.ts+56−2

This diff is large: its 117 lines aren’t shown here.

apps/mobile/src/state/thread-outbox.test.ts+121−0

This diff is large: its 166 lines aren’t shown here.

apps/mobile/src/state/thread-outbox.ts+5−0

This diff is large: its 12 lines aren’t shown here.

apps/mobile/src/state/use-composer-drafts.ts+6−0

This diff is large: its 27 lines aren’t shown here.

apps/mobile/src/state/use-pending-new-tasks.ts+35−0

This diff is large: its 36 lines aren’t shown here.

apps/mobile/src/state/use-thread-outbox-drain.ts+174−62

This diff is large: its 334 lines aren’t shown here.

apps/mobile/src/state/use-thread-outbox.ts+31−1

This diff is large: its 47 lines aren’t shown here.

apps/mobile/src/state/use-thread-pr.ts+67−0

This diff is large: its 68 lines aren’t shown here.

apps/mobile/src/state/use-thread-selection.ts+101−20

This diff is large: its 150 lines aren’t shown here.

apps/server/package.json+1−1

This diff is large: its 8 lines aren’t shown here.

apps/server/src/provider/Layers/ClaudeProvider.ts+36−1

This diff is large: its 51 lines aren’t shown here.

apps/web/package.json+1−1

This diff is large: its 8 lines aren’t shown here.

apps/web/src/components/RightPanelTabs.tsx+15−0

This diff is large: its 29 lines aren’t shown here.

apps/web/src/components/settings/ProviderModelsSection.tsx+1−1

This diff is large: its 9 lines aren’t shown here.

apps/web/src/index.css+23−0

This diff is large: its 36 lines aren’t shown here.

apps/web/vite.config.ts+9−0

This diff is large: its 23 lines aren’t shown here.

experiments/messages-glass-lab/MessagesGlassLab.xcodeproj/project.pbxproj+285−0

This diff is large: its 286 lines aren’t shown here.

experiments/messages-glass-lab/MessagesGlassLab/ContentView.swift+386−0

This diff is large: its 387 lines aren’t shown here.

experiments/messages-glass-lab/MessagesGlassLab/MessagesGlassLabApp.swift+10−0

This diff is large: its 11 lines aren’t shown here.

package.json+1−1

This diff is large: its 9 lines aren’t shown here.

packages/client-runtime/src/state/threadDetail.ts+1−1

This diff is large: its 9 lines aren’t shown here.

packages/client-runtime/src/state/threadState.ts+16−0

This diff is large: its 17 lines aren’t shown here.

packages/client-runtime/src/state/threads.ts+6−14

This diff is large: its 31 lines aren’t shown here.

packages/contracts/package.json+1−1

This diff is large: its 8 lines aren’t shown here.

packages/contracts/src/model.ts+5−2

This diff is large: its 21 lines aren’t shown here.

packages/shared/src/dpop.ts+9−22

This diff is large: its 67 lines aren’t shown here.

packages/shared/src/model.test.ts+1−88

This diff is large: its 110 lines aren’t shown here.

patches/@expo%2Fmetro-config@56.0.14.patchrenamed from patches/@expo%2Fmetro-config@56.0.13.patch+0−0

File renamed without changes.

patches/@react-native-menu__menu@2.0.0.patch+46−0

This diff is large: its 47 lines aren’t shown here.

patches/@react-navigation%2Fnative-stack@7.17.6.patch+100−0

This diff is large: its 101 lines aren’t shown here.

patches/expo-modules-jsi@56.0.10.patch+21−0

This diff is large: its 22 lines aren’t shown here.

patches/react-native-gesture-handler@2.31.2.patch+124−0

This diff is large: its 125 lines aren’t shown here.

patches/react-native-screens@4.25.2.patch+1542−0

This diff is large: its 1543 lines aren’t shown here.

pnpm-lock.yaml+952−719

This diff is large: its 3465 lines aren’t shown here.

pnpm-workspace.yaml+24−21

This diff is large: its 96 lines aren’t shown here.

vite.config.ts+4−0

This diff is large: its 11 lines aren’t shown here.