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 @@
1+vp staged
apps/desktop/package.json+1−1
@@ -1,6 +1,6 @@
11 {
22 "name": "@t3tools/desktop",
3− "version": "0.0.27",
3+ "version": "0.0.28",
44 "private": true,
55 "type": "module",
66 "main": "dist-electron/main.cjs",
apps/desktop/scripts/electron-launcher.mjs+13−6
@@ -358,10 +358,7 @@ function resolveLinuxSandboxArgs(electronBinaryPath) { · 357 unmodified lines
358358 }
359359
360360 export function resolveElectronPath() {
361− ensureElectronRuntime();
362−
363− const require = NodeModule.createRequire(import.meta.url);
364− const electronBinaryPath = require("electron");
361+ const electronBinaryPath = resolveElectronBinaryPath();
365362
366363 if (hostPlatform !== "darwin") {
367364 return electronBinaryPath;
@@ -378,13 +375,23 @@ export function resolveElectronLaunchCommand(args = []) { · 10 unmodified lines
378375 };
379376 }
380377
378+export function resolveElectronBinaryPath({
379+ ensureRuntime = ensureElectronRuntime,
380+ createRequire = NodeModule.createRequire,
381+ moduleUrl = import.meta.url,
382+} = {}) {
383+ ensureRuntime();
384+
385+ const require = createRequire(moduleUrl);
386+ return require("electron");
387+}
388+
381389 export function resolveDevProtocolClient() {
382390 if (hostPlatform !== "darwin" || !isDevelopment) {
383391 return null;
384392 }
385393
386− const require = NodeModule.createRequire(import.meta.url);
387− const electronBinaryPath = require("electron");
394+ const electronBinaryPath = resolveElectronBinaryPath();
388395 const launcherBinaryPath = buildMacLauncher(electronBinaryPath);
389396 return {
390397 appBundlePath: NodePath.resolve(launcherBinaryPath, "..", "..", ".."),
apps/desktop/scripts/electron-launcher.test.mjs+21−1
@@ -1,6 +1,6 @@
11 import { assert, describe, it } from "vite-plus/test";
22
3−import { makeDevelopmentLauncherScript } from "./electron-launcher.mjs";
3+import { makeDevelopmentLauncherScript, resolveElectronBinaryPath } from "./electron-launcher.mjs";
44
55 describe("electron development launcher", () => {
66 it("uses captured values only as fallbacks for a live runner environment", () => {
@@ -25,4 +25,24 @@ describe("electron development launcher", () => { · 18 unmodified lines
2525 "exec '/repo/node_modules/electron/Electron' --t3code-dev-root='/repo/apps/desktop' '/repo/apps/desktop/dist-electron/main.cjs' \"$@\"",
2626 );
2727 });
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+ });
2848 });
apps/desktop/src/backend/DesktopBackendConfiguration.ts+1−0
@@ -607,6 +607,7 @@ export const make = Effect.gen(function* () { · 606 unmodified lines
607607 const backendExposure = yield* serverExposure.backendConfig;
608608 const persistedSettings = yield* settings.get;
609609 const shared = yield* sharedInputs;
610+ yield* wslEnvironment.preWarm(persistedSettings.wslDistro);
610611 return yield* resolveWslStartConfig({
611612 ...shared,
612613 port: backendExposure.port,
apps/mobile/.swiftlint.yml+1−0
@@ -1,6 +1,7 @@
11 included:
22 - ios/T3Code
33 - modules/t3-composer-editor/ios
4+ - modules/t3-native-controls/ios
45 - modules/t3-terminal/ios
56 - modules/t3-review-diff/ios
67
apps/mobile/app.config.ts+14−1
@@ -17,6 +17,7 @@ const VARIANT_CONFIG: Record< · 16 unmodified lines
1717 readonly iosIcon: string;
1818 readonly iosBundleIdentifier: string;
1919 readonly androidPackage: string;
20+ readonly relyingParty?: string;
2021 }
2122 > = {
2223 development: {
@@ -25,6 +26,7 @@ const VARIANT_CONFIG: Record< · 2 unmodified lines
2526 iosIcon: "./assets/icon-composer-dev.icon",
2627 iosBundleIdentifier: "com.t3tools.t3code.dev",
2728 androidPackage: "com.t3tools.t3code.dev",
29+ relyingParty: "clerk.t3.codes",
2830 },
2931 preview: {
3032 appName: "T3 Code Preview",
@@ -32,6 +34,7 @@ const VARIANT_CONFIG: Record< · 1 unmodified line
3234 iosIcon: "./assets/icon-composer-prod.icon",
3335 iosBundleIdentifier: "com.t3tools.t3code.preview",
3436 androidPackage: "com.t3tools.t3code.preview",
37+ relyingParty: "clerk.t3.codes",
3538 },
3639 production: {
3740 appName: "T3 Code",
@@ -39,6 +42,7 @@ const VARIANT_CONFIG: Record< · 1 unmodified line
3942 iosIcon: "./assets/icon-composer-prod.icon",
4043 iosBundleIdentifier: "com.t3tools.t3code",
4144 androidPackage: "com.t3tools.t3code",
45+ relyingParty: "clerk.t3.codes",
4246 },
4347 };
4448
@@ -77,6 +81,14 @@ const config: ExpoConfig = { · 32 unmodified lines
7781 icon: variant.iosIcon,
7882 supportsTablet: true,
7983 bundleIdentifier: variant.iosBundleIdentifier,
84+ // Pin code signing to the T3 Tools team so non-interactive `expo run:ios`
85+ // does not fall back to a personal team (which cannot sign app groups,
86+ // Sign in with Apple, or push notification entitlements).
87+ appleTeamId: "ARK85ZXQ4Z",
88+ associatedDomains: [
89+ `applinks:${variant.relyingParty}`,
90+ `webcredentials:${variant.relyingParty}`,
91+ ],
8092 infoPlist: {
8193 NSAppTransportSecurity: {
8294 NSAllowsArbitraryLoads: true,
@@ -101,7 +113,6 @@ const config: ExpoConfig = { · 18 unmodified lines
101113 favicon: "./assets/favicon.png",
102114 },
103115 plugins: [
104− "expo-router",
105116 "expo-font",
106117 "expo-secure-store",
107118 ["@clerk/expo", { theme: "./clerk-theme.json" }],
@@ -139,6 +150,7 @@ const config: ExpoConfig = { · 31 unmodified lines
139150 },
140151 },
141152 ],
153+ "./plugins/withIosCocoaPodsUuidCache.cjs",
142154 [
143155 "expo-widgets",
144156 {
@@ -155,6 +167,7 @@ const config: ExpoConfig = { · 10 unmodified lines
155167 ],
156168 },
157169 ],
170+ "./plugins/withIosSceneLifecycle.cjs",
158171 "./plugins/withAndroidCleartextTraffic.cjs",
159172 ],
160173 extra: {
apps/mobile/global.css+18−18
@@ -199,24 +199,24 @@ · 198 unmodified lines
199199 --font-bold: "DMSans_700Bold";
200200
201201 /* Keep this scale aligned with src/lib/typography.ts for native style props. */
202− --text-3xs: 10px;
203− --text-3xs--line-height: 13px;
204− --text-2xs: 11px;
205− --text-2xs--line-height: 15px;
206− --text-xs: 12px;
207− --text-xs--line-height: 16px;
208− --text-sm: 13px;
209− --text-sm--line-height: 18px;
210− --text-base: 15px;
211− --text-base--line-height: 22px;
212− --text-lg: 17px;
213− --text-lg--line-height: 22px;
214− --text-xl: 20px;
215− --text-xl--line-height: 26px;
216− --text-2xl: 24px;
217− --text-2xl--line-height: 30px;
218− --text-3xl: 28px;
219− --text-3xl--line-height: 34px;
202+ --text-3xs: 11px;
203+ --text-3xs--line-height: 14px;
204+ --text-2xs: 12px;
205+ --text-2xs--line-height: 16px;
206+ --text-xs: 13px;
207+ --text-xs--line-height: 17px;
208+ --text-sm: 14px;
209+ --text-sm--line-height: 19px;
210+ --text-base: 16px;
211+ --text-base--line-height: 23px;
212+ --text-lg: 18px;
213+ --text-lg--line-height: 23px;
214+ --text-xl: 21px;
215+ --text-xl--line-height: 28px;
216+ --text-2xl: 26px;
217+ --text-2xl--line-height: 32px;
218+ --text-3xl: 30px;
219+ --text-3xl--line-height: 36px;
220220 }
221221
222222 /* ─── Custom utilities ──────────────────────────────────────────────── */
apps/mobile/index.ts+10−1
@@ -1,2 +1,11 @@
1+import { registerRootComponent } from "expo";
12 import "react-native-gesture-handler";
2−import "expo-router/entry";
3+import { featureFlags } from "react-native-screens";
4+
5+import App from "./src/App";
6+
7+// Required for react-native-screens' iOS FormSheet sizing fix when a nested
8+// native stack is rendered inside a non-fitToContents formSheet.
9+featureFlags.experiment.synchronousScreenUpdatesEnabled = true;
10+
11+registerRootComponent(App);
apps/mobile/modules/t3-composer-editor/ios/T3ComposerEditorModule.swift+1−0
@@ -47,6 +47,7 @@ public class T3ComposerEditorModule: Module { · 46 unmodified lines
4747 "onComposerSelectionChange",
4848 "onComposerFocus",
4949 "onComposerBlur",
50+ "onComposerSubmit",
5051 "onComposerPasteImages",
5152 "onComposerContentSizeChange"
5253 )
apps/mobile/modules/t3-composer-editor/ios/T3ComposerEditorView.swift+22−0
@@ -63,6 +63,24 @@ private final class ComposerTextView: UITextView { · 62 unmodified lines
6363
6464 var onPasteImages: (([String]) -> Void)?
6565 var onAttributedMutation: (() -> Void)?
66+ var onSubmit: (() -> Void)?
67+
68+ override var keyCommands: [UIKeyCommand]? {
69+ var commands = super.keyCommands ?? []
70+ let submit = UIKeyCommand(
71+ input: "\r",
72+ modifierFlags: .command,
73+ action: #selector(submitMessage(_:))
74+ )
75+ submit.discoverabilityTitle = "Send Message"
76+ submit.wantsPriorityOverSystemBehavior = true
77+ commands.append(submit)
78+ return commands
79+ }
80+
81+ @objc private func submitMessage(_ sender: UIKeyCommand) {
82+ onSubmit?()
83+ }
6684
6785 override func canPerformAction(_ action: Selector, withSender sender: Any?) -> Bool {
6886 if action == #selector(paste(_:)) {
@@ -299,6 +317,7 @@ public final class T3ComposerEditorView: ExpoView, UITextViewDelegate { · 230 unmodified lines
299317 let onComposerSelectionChange = EventDispatcher()
300318 let onComposerFocus = EventDispatcher()
301319 let onComposerBlur = EventDispatcher()
320+ let onComposerSubmit = EventDispatcher()
302321 let onComposerPasteImages = EventDispatcher()
303322 let onComposerContentSizeChange = EventDispatcher()
304323
@@ -320,6 +339,9 @@ public final class T3ComposerEditorView: ExpoView, UITextViewDelegate { · 15 unmodified lines
320339 textView.onAttributedMutation = { [weak self] in
321340 self?.emitTextChange()
322341 }
342+ textView.onSubmit = { [weak self] in
343+ self?.onComposerSubmit([:])
344+ }
323345 addSubview(textView)
324346
325347 placeholderLabel.numberOfLines = 0
apps/mobile/modules/t3-markdown-text/src/NativeMarkdownBlock.ios.tsx+14−5
@@ -26,6 +26,15 @@ function nodeKey(node: MarkdownNode, index: number): string { · 25 unmodified lines
2626 return `${node.type}:${node.beg ?? index}:${node.end ?? index}`;
2727 }
2828
29+/** Code inside markdown scales with the base text size (12pt at the default 15pt body). */
30+function codeBlockFontSize(textStyle: NativeMarkdownTextStyle): number {
31+ return Math.max(10, Math.round(textStyle.fontSize * 0.8));
32+}
33+
34+function codeBlockLineHeight(textStyle: NativeMarkdownTextStyle): number {
35+ return codeBlockFontSize(textStyle) + 6;
36+}
37+
2938 function nodeText(node: MarkdownNode): string {
3039 if (node.content !== undefined) {
3140 return node.content;
@@ -161,8 +170,8 @@ function HighlightedCodeText(props: { · 129 unmodified lines
161170 style={{
162171 color: props.textStyle.codeColor,
163172 fontFamily: "ui-monospace",
164− fontSize: 12,
165− lineHeight: 18,
173+ fontSize: codeBlockFontSize(props.textStyle),
174+ lineHeight: codeBlockLineHeight(props.textStyle),
166175 }}
167176 >
168177 {props.content}
@@ -196,8 +205,8 @@ function HighlightedCodeText(props: { · 27 unmodified lines
196205 style={{
197206 color: props.textStyle.codeColor,
198207 fontFamily: "ui-monospace",
199− fontSize: 12,
200− lineHeight: 18,
208+ fontSize: codeBlockFontSize(props.textStyle),
209+ lineHeight: codeBlockLineHeight(props.textStyle),
201210 }}
202211 >
203212 {keyedLines.map((line, lineIndex) => (
@@ -264,7 +273,7 @@ function NativeCodeBlock(props: { · 60 unmodified lines
264273 flex: 1,
265274 color: props.textStyle.mutedColor,
266275 fontFamily: "ui-monospace",
267− fontSize: 12,
276+ fontSize: codeBlockFontSize(props.textStyle),
268277 }}
269278 >
270279 {languageLabel}
apps/mobile/modules/t3-markdown-text/src/NativeMarkdownSelectableText.ios.tsx+21−4
@@ -32,11 +32,25 @@ function runKeySignature(run: NativeMarkdownTextRun): string { · 31 unmodified lines
3232 ].join(":");
3333 }
3434
35+const DEFAULT_BODY_FONT_SIZE = 15;
36+const DEFAULT_HEADING_FONT_SIZES = [22, 19, 17, 16, 15, 15] as const;
37+
38+function resolveHeadingFontSize(textStyle: NativeMarkdownTextStyle, headingLevel: number): number {
39+ const index = Math.max(0, Math.min(5, headingLevel - 1));
40+ const configured = textStyle.headingFontSizes?.[index];
41+ if (typeof configured === "number" && Number.isFinite(configured)) {
42+ return configured;
43+ }
44+
45+ const scale = textStyle.fontSize / DEFAULT_BODY_FONT_SIZE;
46+ return Math.max(12, Math.round(DEFAULT_HEADING_FONT_SIZES[index] * scale));
47+}
48+
3549 function runStyle(run: NativeMarkdownTextRun, textStyle: NativeMarkdownTextStyle): TextStyle {
3650 const isFile = run.fileIcon != null;
3751 const isSkill = run.skillName != null;
3852 const headingLevel = Math.max(1, Math.min(6, run.headingLevel ?? 1));
39− const headingFontSize = [22, 19, 17, 16, 15, 15][headingLevel - 1] ?? 15;
53+ const headingFontSize = resolveHeadingFontSize(textStyle, headingLevel);
4054 const isHeading = run.role === "heading";
4155 const isCodeBlock = run.role === "code-block" || run.role === "code-language";
4256 const hasParagraphStyle = run.headIndent !== undefined;
@@ -88,7 +102,7 @@ function runStyle(run: NativeMarkdownTextRun, textStyle: NativeMarkdownTextStyle · 45 unmodified lines
88102 : isHeading
89103 ? headingFontSize
90104 : run.role === "code-language"
91− ? 11
105+ ? Math.max(10, Math.round(textStyle.fontSize * 0.73))
92106 : run.code || isCodeBlock
93107 ? Math.max(12, textStyle.fontSize - 2)
94108 : textStyle.fontSize,
@@ -98,9 +112,9 @@ function runStyle(run: NativeMarkdownTextRun, textStyle: NativeMarkdownTextStyle · 3 unmodified lines
98112 : run.role === "list-break"
99113 ? textStyle.lineHeight + (run.spacing ?? 0)
100114 : isHeading
101− ? Math.max(headingFontSize + 6, 20)
115+ ? Math.max(headingFontSize + 6, textStyle.lineHeight + 2)
102116 : isCodeBlock
103− ? 18
117+ ? Math.max(16, textStyle.lineHeight - 2)
104118 : textStyle.lineHeight,
105119 fontStyle: run.italic ? "italic" : "normal",
106120 fontWeight: isHeading || run.bold || isFile || isSkill ? "700" : "400",
@@ -148,6 +162,9 @@ export function NativeMarkdownSelectableText(props: { · 41 unmodified lines
148162 // color-only child update can otherwise leave the previous appearance cached.
149163 const appearanceKey = [
150164 colorScheme ?? "unspecified",
165+ props.textStyle.fontSize,
166+ props.textStyle.lineHeight,
167+ props.textStyle.headingFontSizes?.join(","),
151168 props.textStyle.color,
152169 props.textStyle.strongColor,
153170 props.textStyle.mutedColor,
apps/mobile/modules/t3-markdown-text/src/SelectableMarkdownText.types.ts+1−0
@@ -16,6 +16,7 @@ export interface NativeMarkdownTextStyle { · 15 unmodified lines
1616 readonly fontFamily: string;
1717 readonly headingFontFamily: string;
1818 readonly boldFontFamily: string;
19+ readonly headingFontSizes?: ReadonlyArray<number>;
1920 }
2021
2122 export 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 @@
1+import ExpoModulesCore
2+import UIKit
3+
4+public final class T3HeaderButtonView: ExpoView {
5+ private static let size: CGFloat = 44
6+ private static let symbolSize: CGFloat = 18
7+
8+ private let button = UIButton(type: .system)
9+ private var systemImage = "circle"
10+
11+ let onTriggered = EventDispatcher()
12+
13+ public required init(appContext: AppContext? = nil) {
14+ super.init(appContext: appContext)
15+
16+ isAccessibilityElement = false
17+ button.frame = bounds
18+ button.autoresizingMask = [.flexibleWidth, .flexibleHeight]
19+ button.addTarget(self, action: #selector(handlePress), for: .primaryActionTriggered)
20+ addSubview(button)
21+ applyConfiguration()
22+ }
23+
24+ public override var intrinsicContentSize: CGSize {
25+ CGSize(width: Self.size, height: Self.size)
26+ }
27+
28+ public func setLabel(_ label: String) {
29+ button.accessibilityLabel = label
30+ }
31+
32+ public func setSystemImage(_ systemImage: String) {
33+ guard self.systemImage != systemImage else {
34+ return
35+ }
36+ self.systemImage = systemImage
37+ applyConfiguration()
38+ }
39+
40+ private func applyConfiguration() {
41+ var configuration: UIButton.Configuration
42+ if #available(iOS 26.0, *) {
43+ configuration = .glass()
44+ configuration.cornerStyle = .capsule
45+ } else {
46+ configuration = .plain()
47+ }
48+
49+ configuration.baseForegroundColor = .label
50+ configuration.contentInsets = .zero
51+ configuration.image = UIImage(systemName: systemImage)
52+ configuration.preferredSymbolConfigurationForImage = UIImage.SymbolConfiguration(
53+ pointSize: Self.symbolSize,
54+ weight: .regular
55+ )
56+ button.configuration = configuration
57+ }
58+
59+ @objc private func handlePress() {
60+ onTriggered()
61+ }
62+}
apps/mobile/modules/t3-native-controls/ios/T3KeyboardCommandsModule.swift+131−0
@@ -0,0 +1,131 @@
1+import ExpoModulesCore
2+import UIKit
3+
4+public final class T3KeyboardCommandsModule: Module {
5+ public func definition() -> ModuleDefinition {
6+ Name("T3KeyboardCommands")
7+
8+ View(T3KeyboardCommandsView.self) {
9+ Prop("enabledCommands") { (view: T3KeyboardCommandsView, commands: [String]) in
10+ view.setEnabledCommands(commands)
11+ }
12+ Events("onCommand")
13+ }
14+ }
15+}
16+
17+public final class T3KeyboardCommandsView: ExpoView {
18+ let onCommand = EventDispatcher()
19+ private var enabledCommands = Set<String>()
20+
21+ public override var canBecomeFirstResponder: Bool { true }
22+
23+ public override var keyCommands: [UIKeyCommand]? {
24+ [
25+ enabledCommand("newTask", input: "n", modifiers: .command, action: #selector(newTask), title: "New Task"),
26+ enabledCommand("focusSearch", input: "f", modifiers: .command, action: #selector(focusSearch), title: "Find"),
27+ enabledCommand("focusSearch", input: "k", modifiers: .command, action: #selector(focusSearch), title: "Focus Search"),
28+ enabledCommand("back", input: "[", modifiers: .command, action: #selector(goBack), title: "Back"),
29+ enabledCommand("files", input: "f", modifiers: [.command, .shift], action: #selector(openFiles), title: "Open Files"),
30+ enabledCommand("terminal", input: "t", modifiers: [.command, .shift], action: #selector(openTerminal), title: "Open Terminal"),
31+ enabledCommand("review", input: "r", modifiers: [.command, .shift], action: #selector(openReview), title: "Open Review"),
32+ enabledCommand("toggleSidebar", input: "\\", modifiers: .command, action: #selector(handleToggleSidebar), title: "Toggle Sidebar"),
33+ ].compactMap { $0 }
34+ }
35+
36+ func setEnabledCommands(_ commands: [String]) {
37+ enabledCommands = Set(commands)
38+ if isFirstResponder {
39+ resignFirstResponder()
40+ }
41+ reclaimFirstResponderIfAvailable()
42+ }
43+
44+ private func enabledCommand(
45+ _ identifier: String,
46+ input: String,
47+ modifiers: UIKeyModifierFlags,
48+ action: Selector,
49+ title: String
50+ ) -> UIKeyCommand? {
51+ guard enabledCommands.contains(identifier) else { return nil }
52+ return command(input, modifiers: modifiers, action: action, title: title)
53+ }
54+
55+ public required init(appContext: AppContext? = nil) {
56+ super.init(appContext: appContext)
57+ NotificationCenter.default.addObserver(
58+ self,
59+ selector: #selector(reclaimFirstResponderIfAvailable),
60+ name: UITextView.textDidEndEditingNotification,
61+ object: nil
62+ )
63+ NotificationCenter.default.addObserver(
64+ self,
65+ selector: #selector(reclaimFirstResponderIfAvailable),
66+ name: UITextField.textDidEndEditingNotification,
67+ object: nil
68+ )
69+ NotificationCenter.default.addObserver(
70+ self,
71+ selector: #selector(reclaimFirstResponderIfAvailable),
72+ name: UIApplication.didBecomeActiveNotification,
73+ object: nil
74+ )
75+ }
76+
77+ deinit {
78+ NotificationCenter.default.removeObserver(self)
79+ }
80+
81+ public override func didMoveToWindow() {
82+ super.didMoveToWindow()
83+ reclaimFirstResponderIfAvailable()
84+ }
85+
86+ public override func layoutSubviews() {
87+ super.layoutSubviews()
88+ reclaimFirstResponderIfAvailable()
89+ }
90+
91+ private func command(
92+ _ input: String,
93+ modifiers: UIKeyModifierFlags,
94+ action: Selector,
95+ title: String
96+ ) -> UIKeyCommand {
97+ let command = UIKeyCommand(input: input, modifierFlags: modifiers, action: action)
98+ command.discoverabilityTitle = title
99+ command.wantsPriorityOverSystemBehavior = true
100+ return command
101+ }
102+
103+ @objc private func newTask() { emit("newTask") }
104+ @objc private func focusSearch() { emit("focusSearch") }
105+ @objc private func goBack() { emit("back") }
106+ @objc private func openFiles() { emit("files") }
107+ @objc private func openTerminal() { emit("terminal") }
108+ @objc private func openReview() { emit("review") }
109+ @objc private func handleToggleSidebar() { emit("toggleSidebar") }
110+
111+ private func emit(_ command: String) {
112+ onCommand(["command": command])
113+ }
114+
115+ @objc private func reclaimFirstResponderIfAvailable() {
116+ DispatchQueue.main.async { [weak self] in
117+ guard let self, self.window?.t3FirstResponder == nil else { return }
118+ self.becomeFirstResponder()
119+ }
120+ }
121+}
122+
123+private extension UIView {
124+ var t3FirstResponder: UIResponder? {
125+ if isFirstResponder { return self }
126+ for subview in subviews {
127+ if let responder = subview.t3FirstResponder { return responder }
128+ }
129+ return nil
130+ }
131+}
apps/mobile/modules/t3-native-controls/ios/T3NativeControls.podspec+19−0
@@ -0,0 +1,19 @@
1+Pod::Spec.new do |s|
2+ s.name = 'T3NativeControls'
3+ s.version = '1.0.0'
4+ s.summary = 'Native UIKit controls for T3 Code mobile.'
5+ s.description = 'UIKit-backed controls that match native iOS navigation chrome.'
6+ s.author = 'T3 Tools'
7+ s.homepage = 'https://t3tools.com'
8+ s.platforms = {
9+ :ios => '18.0',
10+ }
11+ s.source = { :path => '.' }
12+ s.static_framework = true
13+
14+ s.dependency 'ExpoModulesCore'
15+ s.pod_target_xcconfig = {
16+ 'DEFINES_MODULE' => 'YES',
17+ }
18+ s.source_files = '**/*.{h,m,mm,swift,hpp,cpp}'
19+end
apps/mobile/modules/t3-native-controls/ios/T3NativeControlsModule.swift+18−0
@@ -0,0 +1,18 @@
1+import ExpoModulesCore
2+
3+public final class T3NativeControlsModule: Module {
4+ public func definition() -> ModuleDefinition {
5+ Name("T3NativeControls")
6+
7+ View(T3HeaderButtonView.self) {
8+ Prop("label") { (view: T3HeaderButtonView, label: String) in
9+ view.setLabel(label)
10+ }
11+ Prop("systemImage") { (view: T3HeaderButtonView, systemImage: String) in
12+ view.setSystemImage(systemImage)
13+ }
14+
15+ Events("onTriggered")
16+ }
17+ }
18+}
apps/mobile/modules/t3-review-diff/ios/T3ReviewDiffModule.swift+40−13
@@ -5,22 +5,14 @@ public class T3ReviewDiffModule: Module { · 4 unmodified lines
55 Name("T3ReviewDiffSurface")
66
77 View(T3ReviewDiffView.self) {
8− Prop("rowsJson") { (view: T3ReviewDiffView, rowsJson: String) in
9− view.setRowsJson(rowsJson)
10− }
11−
12− Prop("tokensJson") { (view: T3ReviewDiffView, tokensJson: String) in
13− view.setTokensJson(tokensJson)
14− }
15−
16− Prop("tokensPatchJson") { (view: T3ReviewDiffView, tokensPatchJson: String) in
17− view.setTokensPatchJson(tokensPatchJson)
18− }
19−
208 Prop("tokensResetKey") { (view: T3ReviewDiffView, tokensResetKey: String) in
219 view.setTokensResetKey(tokensResetKey)
2210 }
2311
12+ Prop("contentResetKey") { (view: T3ReviewDiffView, contentResetKey: String) in
13+ view.setContentResetKey(contentResetKey)
14+ }
15+
2416 Prop("collapsedFileIdsJson") { (view: T3ReviewDiffView, collapsedFileIdsJson: String) in
2517 view.setCollapsedFileIdsJson(collapsedFileIdsJson)
2618 }
@@ -61,7 +53,42 @@ public class T3ReviewDiffModule: Module { · 34 unmodified lines
6153 view.setInitialRowIndex(initialRowIndex)
6254 }
6355
64− Events("onDebug", "onToggleFile", "onToggleViewedFile", "onPressLine", "onToggleComment")
56+ Prop("refreshing") { (view: T3ReviewDiffView, refreshing: Bool) in
57+ view.setRefreshing(refreshing)
58+ }
59+
60+ Events(
61+ "onDebug",
62+ "onVisibleFileChange",
63+ "onToggleFile",
64+ "onToggleViewedFile",
65+ "onPressLine",
66+ "onToggleComment",
67+ "onPullToRefresh"
68+ )
69+
70+ AsyncFunction("scrollToFile") { (view: T3ReviewDiffView, fileId: String, animated: Bool) in
71+ view.scrollToFile(fileId, animated: animated)
72+ }
73+
74+ AsyncFunction("scrollToTop") { (view: T3ReviewDiffView, animated: Bool) in
75+ view.scrollToTop(animated: animated)
76+ }
77+
78+ // Large, frequently changing JSON values cannot be regular Fabric props. Expo's
79+ // prop adapter compares strings on the main thread before invoking a setter, which
80+ // makes a syntax-token patch capable of blocking a frame by itself.
81+ AsyncFunction("setRowsJson") { (view: T3ReviewDiffView, rowsJson: String) in
82+ view.setRowsJson(rowsJson)
83+ }
84+
85+ AsyncFunction("setTokensJson") { (view: T3ReviewDiffView, tokensJson: String) in
86+ view.setTokensJson(tokensJson)
87+ }
88+
89+ AsyncFunction("setTokensPatchJson") { (view: T3ReviewDiffView, tokensPatchJson: String) in
90+ view.setTokensPatchJson(tokensPatchJson)
91+ }
6592 }
6693 }
6794 }
apps/mobile/modules/t3-review-diff/ios/T3ReviewDiffView.swift+270−60
@@ -1,7 +1,7 @@
11 import ExpoModulesCore
22 import UIKit
33
4−private struct ReviewDiffNativeRow: Decodable {
4+private struct ReviewDiffNativeRow: Decodable, Sendable {
55 let kind: String
66 let id: String
77 let fileId: String?
@@ -21,18 +21,18 @@ private struct ReviewDiffNativeRow: Decodable { · 13 unmodified lines
2121 let commentSectionTitle: String?
2222 }
2323
24−private struct ReviewDiffNativeWordDiffRange: Decodable {
24+private struct ReviewDiffNativeWordDiffRange: Decodable, Sendable {
2525 let start: Int
2626 let end: Int
2727 }
2828
29−private struct ReviewDiffNativeToken: Decodable {
29+private struct ReviewDiffNativeToken: Decodable, Sendable {
3030 let content: String
3131 let color: String?
3232 let fontStyle: Int?
3333 }
3434
35−private struct ReviewDiffNativeTokenPatch: Decodable {
35+private struct ReviewDiffNativeTokenPatch: Decodable, Sendable {
3636 let resetKey: String?
3737 let chunkIndex: Int?
3838 let tokensByRowId: [String: [ReviewDiffNativeToken]]?
@@ -312,6 +312,10 @@ private struct ReviewDiffNativeStyle { · 273 unmodified lines
312312 }
313313
314314 public final class T3ReviewDiffView: ExpoView, UIScrollViewDelegate {
315+ private let payloadDecodeQueue = DispatchQueue(
316+ label: "com.t3tools.review-diff.payload-decode",
317+ qos: .userInitiated
318+ )
315319 private let scrollView = UIScrollView()
316320 private let contentView = ReviewDiffContentView()
317321 private var rows: [ReviewDiffNativeRow] = []
@@ -323,14 +327,25 @@ public final class T3ReviewDiffView: ExpoView, UIScrollViewDelegate { · 5 unmodified lines
323327 private var lastMetricsDebugKey = ""
324328 private var lastVisibleRangeDebugKey = ""
325329 private var tokensResetKey = ""
330+ private var contentResetKey = ""
326331 private var initialRowIndex: Int?
327332 private var hasAppliedInitialRowIndex = false
333+ private var lastVisibleFileId: String?
334+ private var pendingScrollFileId: String?
335+ private var pendingScrollAnimated = false
336+ private var isProgrammaticScrollActive = false
337+ private var rowsDecodeGeneration = 0
338+ private var tokensDecodeGeneration = 0
328339
329340 let onDebug = EventDispatcher()
341+ let onVisibleFileChange = EventDispatcher()
330342 let onToggleFile = EventDispatcher()
331343 let onToggleViewedFile = EventDispatcher()
332344 let onPressLine = EventDispatcher()
333345 let onToggleComment = EventDispatcher()
346+ let onPullToRefresh = EventDispatcher()
347+
348+ private let pullRefreshControl = UIRefreshControl()
334349
335350 public required init(appContext: AppContext? = nil) {
336351 super.init(appContext: appContext)
@@ -345,6 +360,8 @@ public final class T3ReviewDiffView: ExpoView, UIScrollViewDelegate { · 8 unmodified lines
345360 scrollView.showsVerticalScrollIndicator = true
346361 scrollView.showsHorizontalScrollIndicator = false
347362 scrollView.backgroundColor = contentView.theme.background
363+ pullRefreshControl.addTarget(self, action: #selector(handlePullToRefresh), for: .valueChanged)
364+ scrollView.refreshControl = pullRefreshControl
348365 addSubview(scrollView)
349366
350367 contentView.backgroundColor = contentView.theme.background
@@ -379,6 +396,13 @@ public final class T3ReviewDiffView: ExpoView, UIScrollViewDelegate { · 28 unmodified lines
379396 updateViewportFrame()
380397 }
381398
399+ public func scrollViewWillBeginDragging(_ scrollView: UIScrollView) {
400+ // A direct gesture takes ownership from any interrupted programmatic jump.
401+ // Resume visible-file events immediately so the inspector follows the finger.
402+ isProgrammaticScrollActive = false
403+ contentView.isVerticalScrollActive = true
404+ }
405+
382406 public func scrollViewDidEndDragging(_ scrollView: UIScrollView, willDecelerate decelerate: Bool) {
383407 guard !decelerate else {
384408 return
@@ -396,77 +420,125 @@ public final class T3ReviewDiffView: ExpoView, UIScrollViewDelegate { · 11 unmodified lines
396420 }
397421
398422 func setRowsJson(_ rowsJson: String) {
399− guard let data = rowsJson.data(using: .utf8) else {
400− return
401− }
423+ rowsDecodeGeneration += 1
424+ let generation = rowsDecodeGeneration
402425
403− do {
404− rows = try JSONDecoder().decode([ReviewDiffNativeRow].self, from: data)
405− contentView.rows = rows
406− hasAppliedInitialRowIndex = false
407− emitDebug("rows-decoded", [
408− "rows": rows.count,
409− "firstKind": rows.first?.kind ?? "none",
410− ])
411− updateContentMetrics()
412− } catch {
413− rows = []
414− contentView.rows = []
415− hasAppliedInitialRowIndex = false
416− updateContentMetrics()
417− emitDebug("rows-decode-failed", [
418− "error": error.localizedDescription,
419− ])
426+ payloadDecodeQueue.async { [weak self] in
427+ guard let data = rowsJson.data(using: .utf8) else {
428+ return
429+ }
430+
431+ do {
432+ let decodedRows = try JSONDecoder().decode([ReviewDiffNativeRow].self, from: data)
433+ DispatchQueue.main.async { [weak self] in
434+ guard let self, generation == self.rowsDecodeGeneration else {
435+ return
436+ }
437+ self.rows = decodedRows
438+ self.contentView.rows = decodedRows
439+ self.hasAppliedInitialRowIndex = false
440+ self.lastVisibleFileId = nil
441+ self.emitDebug("rows-decoded", [
442+ "rows": decodedRows.count,
443+ "firstKind": decodedRows.first?.kind ?? "none",
444+ ])
445+ self.updateContentMetrics()
446+ self.applyPendingScrollIfNeeded()
447+ }
448+ } catch {
449+ let message = error.localizedDescription
450+ DispatchQueue.main.async { [weak self] in
451+ guard let self, generation == self.rowsDecodeGeneration else {
452+ return
453+ }
454+ self.rows = []
455+ self.contentView.rows = []
456+ self.hasAppliedInitialRowIndex = false
457+ self.lastVisibleFileId = nil
458+ self.pendingScrollFileId = nil
459+ self.updateContentMetrics()
460+ self.emitDebug("rows-decode-failed", ["error": message])
461+ }
462+ }
420463 }
421464 }
422465
423466 func setTokensJson(_ tokensJson: String) {
424− guard let data = tokensJson.data(using: .utf8) else {
425− return
426− }
467+ tokensDecodeGeneration += 1
468+ let generation = tokensDecodeGeneration
427469
428− do {
429− contentView.tokensByRowId = try JSONDecoder().decode(
430− [String: [ReviewDiffNativeToken]].self,
431− from: data
432− )
433− } catch {
434− contentView.tokensByRowId = [:]
435− emitDebug("tokens-decode-failed", [
436− "error": error.localizedDescription,
437− ])
470+ payloadDecodeQueue.async { [weak self] in
471+ guard let data = tokensJson.data(using: .utf8) else {
472+ return
473+ }
474+
475+ do {
476+ let decodedTokens = try JSONDecoder().decode(
477+ [String: [ReviewDiffNativeToken]].self,
478+ from: data
479+ )
480+ DispatchQueue.main.async { [weak self] in
481+ guard let self, generation == self.tokensDecodeGeneration else {
482+ return
483+ }
484+ self.contentView.tokensByRowId = decodedTokens
485+ }
486+ } catch {
487+ let message = error.localizedDescription
488+ DispatchQueue.main.async { [weak self] in
489+ guard let self, generation == self.tokensDecodeGeneration else {
490+ return
491+ }
492+ self.contentView.tokensByRowId = [:]
493+ self.emitDebug("tokens-decode-failed", ["error": message])
494+ }
495+ }
438496 }
439497 }
440498
441499 func setTokensPatchJson(_ tokensPatchJson: String) {
442− guard let data = tokensPatchJson.data(using: .utf8) else {
443− return
444− }
500+ let contentResetKey = self.contentResetKey
445501
446− do {
447− let patch = try JSONDecoder().decode(ReviewDiffNativeTokenPatch.self, from: data)
448− if let resetKey = patch.resetKey, resetKey != tokensResetKey {
449− tokensResetKey = resetKey
450− contentView.tokensByRowId = [:]
451− }
452−
453− let tokensByRowId = patch.tokensByRowId ?? [:]
454− if tokensByRowId.isEmpty {
502+ payloadDecodeQueue.async { [weak self] in
503+ guard let data = tokensPatchJson.data(using: .utf8) else {
455504 return
456505 }
457506
458− contentView.mergeTokensByRowId(tokensByRowId)
459− if let chunkIndex = patch.chunkIndex, chunkIndex < 5 || chunkIndex.isMultiple(of: 10) {
460− emitDebug("tokens-patch-decoded", [
461− "chunkIndex": chunkIndex,
462− "rows": tokensByRowId.count,
463− "totalRows": contentView.tokensByRowId.count,
464− ])
507+ do {
508+ let patch = try JSONDecoder().decode(ReviewDiffNativeTokenPatch.self, from: data)
509+ DispatchQueue.main.async { [weak self] in
510+ guard let self else {
511+ return
512+ }
513+ guard contentResetKey == self.contentResetKey else {
514+ return
515+ }
516+ // A highlighter request from the previous file can finish after the view has
517+ // already reset. Never let that stale patch roll the native token state back.
518+ if let resetKey = patch.resetKey, resetKey != self.tokensResetKey {
519+ return
520+ }
521+
522+ let tokensByRowId = patch.tokensByRowId ?? [:]
523+ if tokensByRowId.isEmpty {
524+ return
525+ }
526+
527+ self.contentView.mergeTokensByRowId(tokensByRowId)
528+ if let chunkIndex = patch.chunkIndex, chunkIndex < 5 || chunkIndex.isMultiple(of: 10) {
529+ self.emitDebug("tokens-patch-decoded", [
530+ "chunkIndex": chunkIndex,
531+ "rows": tokensByRowId.count,
532+ "totalRows": self.contentView.tokensByRowId.count,
533+ ])
534+ }
535+ }
536+ } catch {
537+ let message = error.localizedDescription
538+ DispatchQueue.main.async { [weak self] in
539+ self?.emitDebug("tokens-patch-decode-failed", ["error": message])
540+ }
465541 }
466− } catch {
467− emitDebug("tokens-patch-decode-failed", [
468− "error": error.localizedDescription,
469− ])
470542 }
471543 }
472544
@@ -482,6 +554,27 @@ public final class T3ReviewDiffView: ExpoView, UIScrollViewDelegate { · 9 unmodified lines
482554 ])
483555 }
484556
557+ func setContentResetKey(_ contentResetKey: String) {
558+ guard contentResetKey != self.contentResetKey else {
559+ return
560+ }
561+
562+ self.contentResetKey = contentResetKey
563+ rowsDecodeGeneration += 1
564+ tokensDecodeGeneration += 1
565+ contentView.tokensByRowId = [:]
566+ rows = []
567+ contentView.rows = []
568+ hasAppliedInitialRowIndex = false
569+ pendingScrollFileId = nil
570+ isProgrammaticScrollActive = false
571+ scrollView.setContentOffset(.zero, animated: false)
572+ updateContentMetrics()
573+ updateViewportFrame()
574+ lastVisibleFileId = nil
575+ applyInitialRowIndexIfNeeded()
576+ }
577+
485578 func setCollapsedFileIdsJson(_ collapsedFileIdsJson: String) {
486579 let nextCollapsedFileIds = decodeFileIdSet(collapsedFileIdsJson)
487580 let changedFileIds = contentView.collapsedFileIds.symmetricDifference(nextCollapsedFileIds)
@@ -573,6 +666,8 @@ public final class T3ReviewDiffView: ExpoView, UIScrollViewDelegate { · 85 unmodified lines
573666 contentView.invalidateVisibleViewport()
574667 contentView.setNeedsDisplay()
575668 applyInitialRowIndexIfNeeded()
669+ applyPendingScrollIfNeeded()
670+ emitVisibleFileIfNeeded()
576671
577672 let debugKey = "\(rows.count):\(Int(bounds.width)):\(Int(bounds.height)):\(Int(height))"
578673 if debugKey != lastMetricsDebugKey {
@@ -596,11 +691,41 @@ public final class T3ReviewDiffView: ExpoView, UIScrollViewDelegate { · 17 unmodified lines
596691 }
597692
598693 private func finishVerticalScroll() {
694+ isProgrammaticScrollActive = false
599695 contentView.isVerticalScrollActive = false
600696 updateViewportFrame()
601697 emitVisibleRange(reason: "scroll-end")
602698 }
603699
700+ private func emitVisibleFileIfNeeded() {
701+ // Keep the explicit destination selected while UIKit animates through the files
702+ // between the old and new offsets. Emitting every intermediate header forces a
703+ // React render per crossing and makes the navigator visibly flash.
704+ guard !isProgrammaticScrollActive else {
705+ return
706+ }
707+
708+ // The top of the combined diff is the explicit "All files" destination.
709+ // Treat it as a first-class selection instead of immediately resolving the
710+ // first file header and undoing the navigator's optimistic selection.
711+ if scrollView.contentOffset.y <= 0.5 {
712+ guard lastVisibleFileId != nil else {
713+ return
714+ }
715+ lastVisibleFileId = nil
716+ onVisibleFileChange(["fileId": NSNull()])
717+ return
718+ }
719+
720+ guard let fileId = contentView.visibleFileId(atVerticalOffset: scrollView.contentOffset.y),
721+ fileId != lastVisibleFileId else {
722+ return
723+ }
724+
725+ lastVisibleFileId = fileId
726+ onVisibleFileChange(["fileId": fileId])
727+ }
728+
604729 private func emitVisibleRange(reason: String) {
605730 guard let range = contentView.currentVisibleRowRange() else {
606731 return
@@ -670,6 +795,33 @@ public final class T3ReviewDiffView: ExpoView, UIScrollViewDelegate { · 63 unmodified lines
670795 applyInitialRowIndexIfNeeded()
671796 }
672797
798+ func scrollToFile(_ fileId: String, animated: Bool) {
799+ pendingScrollFileId = fileId
800+ pendingScrollAnimated = animated
801+ applyPendingScrollIfNeeded()
802+ }
803+
804+ func scrollToTop(animated: Bool) {
805+ pendingScrollFileId = nil
806+ pendingScrollAnimated = false
807+ setVerticalContentOffset(0, animated: animated)
808+ }
809+
810+ @objc private func handlePullToRefresh() {
811+ onPullToRefresh([:])
812+ }
813+
814+ /// Driven from JS: set to false once the reload completes to dismiss the spinner.
815+ func setRefreshing(_ refreshing: Bool) {
816+ if refreshing {
817+ if !pullRefreshControl.isRefreshing {
818+ pullRefreshControl.beginRefreshing()
819+ }
820+ } else if pullRefreshControl.isRefreshing {
821+ pullRefreshControl.endRefreshing()
822+ }
823+ }
824+
673825 private func applyStyle() {
674826 contentView.style = ReviewDiffNativeStyle
675827 .resolve(stylePayload)
@@ -686,6 +838,38 @@ public final class T3ReviewDiffView: ExpoView, UIScrollViewDelegate { · 10 unmodified lines
686838 )
687839 contentView.verticalOffset = scrollView.contentOffset.y
688840 contentView.invalidateVisibleViewport()
841+ emitVisibleFileIfNeeded()
842+ }
843+
844+ private func applyPendingScrollIfNeeded() {
845+ guard let fileId = pendingScrollFileId,
846+ bounds.height > 0 else {
847+ return
848+ }
849+
850+ guard let headerOffset = contentView.fileHeaderOffset(forFileId: fileId) else {
851+ if !rows.isEmpty {
852+ pendingScrollFileId = nil
853+ }
854+ return
855+ }
856+
857+ let animated = pendingScrollAnimated
858+ pendingScrollFileId = nil
859+ pendingScrollAnimated = false
860+ setVerticalContentOffset(headerOffset, animated: animated)
861+ }
862+
863+ private func setVerticalContentOffset(_ targetOffset: CGFloat, animated: Bool) {
864+ let maxOffset = max(scrollView.contentSize.height - scrollView.bounds.height, 0)
865+ let clampedOffset = min(max(targetOffset, 0), maxOffset)
866+ let shouldAnimate = animated && abs(scrollView.contentOffset.y - clampedOffset) > 0.5
867+ isProgrammaticScrollActive = shouldAnimate
868+ contentView.isVerticalScrollActive = shouldAnimate
869+ scrollView.setContentOffset(CGPoint(x: 0, y: clampedOffset), animated: shouldAnimate)
870+ if !shouldAnimate {
871+ updateViewportFrame()
872+ }
689873 }
690874
691875 private func applyInitialRowIndexIfNeeded() {
@@ -1255,6 +1439,32 @@ private final class ReviewDiffContentView: UIView, UIGestureRecognizerDelegate { · 563 unmodified lines
12551439 return rowOffsets[rowIndex]
12561440 }
12571441
1442+ func visibleFileId(atVerticalOffset verticalOffset: CGFloat) -> String? {
1443+ guard let firstHeaderRowIndex = fileHeaderRowIndices.first else {
1444+ return nil
1445+ }
1446+
1447+ var lowerBound = 0
1448+ var upperBound = fileHeaderRowIndices.count
1449+ while lowerBound < upperBound {
1450+ let midpoint = (lowerBound + upperBound) / 2
1451+ let rowIndex = fileHeaderRowIndices[midpoint]
1452+ if rowOffsets[rowIndex] <= verticalOffset + 0.5 {
1453+ lowerBound = midpoint + 1
1454+ } else {
1455+ upperBound = midpoint
1456+ }
1457+ }
1458+
1459+ let rowIndex = lowerBound > 0
1460+ ? fileHeaderRowIndices[lowerBound - 1]
1461+ : firstHeaderRowIndex
1462+ guard rows.indices.contains(rowIndex) else {
1463+ return nil
1464+ }
1465+ return resolvedFileId(for: rows[rowIndex])
1466+ }
1467+
12581468 private func fileHeaderRowIndex(forFileId fileId: String) -> Int? {
12591469 fileHeaderRowIndices.first { rowIndex in
12601470 rows.indices.contains(rowIndex) && resolvedFileId(for: rows[rowIndex]) == fileId
apps/mobile/modules/t3-terminal/android/src/main/java/expo/modules/t3terminal/T3TerminalModule.kt+10−0
@@ -7,6 +7,12 @@ class T3TerminalModule : Module() { · 6 unmodified lines
77 override fun definition() = ModuleDefinition {
88 Name("T3TerminalSurface")
99
10+ // Bumped when native hardware-keyboard handling changes; surfaced in the JS debug
11+ // logs so a stale native binary is distinguishable from a broken key pipeline.
12+ Constants(
13+ "hardwareKeyRevision" to 2,
14+ )
15+
1016 View(T3TerminalView::class) {
1117 Prop("terminalKey") { view: T3TerminalView, terminalKey: String ->
1218 view.terminalKey = terminalKey
@@ -20,6 +26,10 @@ class T3TerminalModule : Module() { · 7 unmodified lines
2026 view.fontSize = fontSize.toFloat()
2127 }
2228
29+ Prop("focusRequest") { view: T3TerminalView, focusRequest: Double ->
30+ view.focusRequest = focusRequest
31+ }
32+
2333 Prop("appearanceScheme") { view: T3TerminalView, appearanceScheme: String ->
2434 view.appearanceScheme = appearanceScheme
2535 }
apps/mobile/modules/t3-terminal/android/src/main/java/expo/modules/t3terminal/T3TerminalView.kt+19−2
@@ -62,6 +62,15 @@ class T3TerminalView(context: Context, appContext: AppContext) : ExpoView(contex · 61 unmodified lines
6262
6363 var themeConfig: String = ""
6464
65+ var focusRequest: Double = 0.0
66+ set(value) {
67+ val previous = field
68+ field = value
69+ if (value != previous && value > 0) {
70+ requestKeyboardFocus()
71+ }
72+ }
73+
6574 var backgroundColorHex: String = "#24292E"
6675 set(value) {
6776 field = value
@@ -113,11 +122,19 @@ class T3TerminalView(context: Context, appContext: AppContext) : ExpoView(contex · 45 unmodified lines
113122 }
114123 inputView.setOnKeyListener { _, keyCode, event ->
115124 if (event.action != android.view.KeyEvent.ACTION_DOWN) return@setOnKeyListener false
116− when (keyCode) {
117− android.view.KeyEvent.KEYCODE_DEL -> {
125+ when {
126+ keyCode == android.view.KeyEvent.KEYCODE_DEL -> {
118127 onInput(mapOf("data" to "\u007F"))
119128 true
120129 }
130+ // Hardware keyboard Ctrl+A..Z -> control bytes 0x01..0x1A (Ctrl+C, Ctrl+Z, ...).
131+ event.isCtrlPressed &&
132+ keyCode in android.view.KeyEvent.KEYCODE_A..android.view.KeyEvent.KEYCODE_Z -> {
133+ onInput(
134+ mapOf("data" to (keyCode - android.view.KeyEvent.KEYCODE_A + 1).toChar().toString()),
135+ )
136+ true
137+ }
121138 else -> false
122139 }
123140 }
apps/mobile/modules/t3-terminal/ios/T3TerminalModule.swift+10−0
@@ -4,6 +4,12 @@ public class T3TerminalModule: Module { · 3 unmodified lines
44 public func definition() -> ModuleDefinition {
55 Name("T3TerminalSurface")
66
7+ // Bumped when native hardware-keyboard handling changes; surfaced in the JS debug
8+ // logs so a stale native binary is distinguishable from a broken key pipeline.
9+ Constants([
10+ "hardwareKeyRevision": 2,
11+ ])
12+
713 View(T3TerminalView.self) {
814 Prop("terminalKey") { (view: T3TerminalView, terminalKey: String) in
915 view.terminalKey = terminalKey
@@ -17,6 +23,10 @@ public class T3TerminalModule: Module { · 7 unmodified lines
1723 view.fontSize = CGFloat(fontSize)
1824 }
1925
26+ Prop("focusRequest") { (view: T3TerminalView, focusRequest: Double) in
27+ view.focusRequest = focusRequest
28+ }
29+
2030 Prop("appearanceScheme") { (view: T3TerminalView, appearanceScheme: String) in
2131 view.appearanceScheme = appearanceScheme
2232 }
apps/mobile/modules/t3-terminal/ios/T3TerminalView.swift+128−0
@@ -22,13 +22,129 @@ private enum GhosttyRuntime { · 21 unmodified lines
2222 }
2323 }
2424
25+/// Encodes hardware-keyboard combos that UITextField never surfaces through its
26+/// text-editing delegate (control combos, Escape, Tab, arrow keys) into the byte
27+/// sequences a terminal expects.
28+///
29+/// Capture uses UIKeyCommand with `wantsPriorityOverSystemBehavior` rather than
30+/// `pressesBegan`: while a text field is first responder, iPadOS routes hardware key
31+/// events through the text-input system, which can consume presses before they reach
32+/// responder press callbacks. Registered key commands are matched deterministically
33+/// before that happens.
34+private enum TerminalHardwareKeyEncoder {
35+ /// Characters that produce a control byte when combined with Ctrl.
36+ private static let controlInputs = "abcdefghijklmnopqrstuvwxyz@[\\]^_-? "
37+
38+ static func makeKeyCommands(action: Selector) -> [UIKeyCommand] {
39+ var commands: [UIKeyCommand] = []
40+
41+ let specialInputs = [
42+ UIKeyCommand.inputEscape,
43+ UIKeyCommand.inputUpArrow,
44+ UIKeyCommand.inputDownArrow,
45+ UIKeyCommand.inputLeftArrow,
46+ UIKeyCommand.inputRightArrow,
47+ "\t",
48+ ]
49+ for input in specialInputs {
50+ commands.append(makeCommand(input: input, modifierFlags: [], action: action))
51+ }
52+ commands.append(makeCommand(input: "\t", modifierFlags: .shift, action: action))
53+
54+ for character in controlInputs {
55+ commands.append(makeCommand(input: String(character), modifierFlags: .control, action: action))
56+ commands.append(
57+ makeCommand(input: String(character), modifierFlags: [.control, .shift], action: action)
58+ )
59+ }
60+
61+ return commands
62+ }
63+
64+ private static func makeCommand(
65+ input: String,
66+ modifierFlags: UIKeyModifierFlags,
67+ action: Selector
68+ ) -> UIKeyCommand {
69+ let command = UIKeyCommand(input: input, modifierFlags: modifierFlags, action: action)
70+ command.wantsPriorityOverSystemBehavior = true
71+ return command
72+ }
73+
74+ static func sequence(input: String, modifiers: UIKeyModifierFlags) -> String? {
75+ switch input {
76+ case UIKeyCommand.inputEscape:
77+ return "\u{1B}"
78+ case UIKeyCommand.inputUpArrow:
79+ return "\u{1B}[A"
80+ case UIKeyCommand.inputDownArrow:
81+ return "\u{1B}[B"
82+ case UIKeyCommand.inputRightArrow:
83+ return "\u{1B}[C"
84+ case UIKeyCommand.inputLeftArrow:
85+ return "\u{1B}[D"
86+ case "\t":
87+ return modifiers.contains(.shift) ? "\u{1B}[Z" : "\t"
88+ default:
89+ break
90+ }
91+
92+ guard modifiers.contains(.control) else { return nil }
93+ guard let scalar = input.lowercased().unicodeScalars.first else { return nil }
94+ return controlSequence(for: scalar)
95+ }
96+
97+ private static func controlSequence(for scalar: Unicode.Scalar) -> String? {
98+ switch scalar {
99+ case "a"..."z":
100+ // Ctrl+A..Z -> 0x01..0x1A (Ctrl+C = ETX, Ctrl+Z = SUB, ...).
101+ return UnicodeScalar(scalar.value - 96).map(String.init)
102+ case " ", "@":
103+ return "\u{00}"
104+ case "[":
105+ return "\u{1B}"
106+ case "\\":
107+ return "\u{1C}"
108+ case "]":
109+ return "\u{1D}"
110+ case "^":
111+ return "\u{1E}"
112+ case "_", "-":
113+ return "\u{1F}"
114+ case "?":
115+ return "\u{7F}"
116+ default:
117+ return nil
118+ }
119+ }
120+}
121+
25122 private final class TerminalInputField: UITextField {
26123 var onDeleteBackward: (() -> Void)?
124+ var onInsert: ((String) -> Void)?
125+
126+ private static let hardwareKeyCommands = TerminalHardwareKeyEncoder.makeKeyCommands(
127+ action: #selector(handleHardwareKeyCommand(_:))
128+ )
129+
130+ override var keyCommands: [UIKeyCommand]? {
131+ Self.hardwareKeyCommands
132+ }
27133
28134 override func deleteBackward() {
29135 onDeleteBackward?()
30136 super.deleteBackward()
31137 }
138+
139+ @objc
140+ private func handleHardwareKeyCommand(_ command: UIKeyCommand) {
141+ guard let input = command.input else { return }
142+ guard let sequence = TerminalHardwareKeyEncoder.sequence(
143+ input: input,
144+ modifiers: command.modifierFlags
145+ ) else { return }
146+ onInsert?(sequence)
147+ }
32148 }
33149
34150 private enum TerminalAppearanceScheme: String {
@@ -108,6 +224,15 @@ public final class T3TerminalView: ExpoView, UITextFieldDelegate { · 73 unmodified lines
108224 }
109225 }
110226
227+ var focusRequest: Double = 0 {
228+ didSet {
229+ guard oldValue != focusRequest else { return }
230+ DispatchQueue.main.async { [weak self] in
231+ self?.requestKeyboardFocus()
232+ }
233+ }
234+ }
235+
111236 var appearanceScheme: String = TerminalAppearanceScheme.dark.rawValue {
112237 didSet {
113238 guard oldValue != appearanceScheme else { return }
@@ -167,6 +292,9 @@ public final class T3TerminalView: ExpoView, UITextFieldDelegate { · 53 unmodified lines
167292 inputField.onDeleteBackward = { [weak self] in
168293 self?.emitInput("\u{7F}")
169294 }
295+ inputField.onInsert = { [weak self] data in
296+ self?.emitInput(data)
297+ }
170298
171299 focusTapGesture.addTarget(self, action: #selector(handleViewportTap))
172300 terminalViewport.addGestureRecognizer(focusTapGesture)
apps/mobile/package.json+31−20
@@ -5,7 +5,7 @@ · 4 unmodified lines
55 "main": "index.ts",
66 "scripts": {
77 "dev": "expo start --clear",
8− "dev:client": "APP_VARIANT=development expo start --dev-client --clear",
8+ "dev:client": "APP_VARIANT=development expo start --dev-client --scheme t3code-dev --clear",
99 "start": "expo start",
1010 "start:dev": "APP_VARIANT=development expo start",
1111 "start:preview": "APP_VARIANT=preview expo start",
@@ -43,12 +43,16 @@ · 31 unmodified lines
4343 "@clerk/expo": "catalog:",
4444 "@effect/atom-react": "catalog:",
4545 "@expo-google-fonts/dm-sans": "^0.4.2",
46− "@expo/ui": "~56.0.8",
46+ "@expo/metro-runtime": "~56.0.15",
47+ "@expo/ui": "~56.0.18",
4748 "@legendapp/list": "3.2.0",
4849 "@noble/curves": "catalog:",
4950 "@noble/hashes": "catalog:",
5051 "@pierre/diffs": "catalog:",
5152 "@react-native-menu/menu": "^2.0.0",
53+ "@react-navigation/elements": "2.9.26",
54+ "@react-navigation/native": "7.3.4",
55+ "@react-navigation/native-stack": "7.17.6",
5256 "@shikijs/core": "4.2.0",
5357 "@shikijs/engine-javascript": "4.2.0",
5458 "@shikijs/langs": "4.2.0",
@@ -62,38 +66,37 @@ · 7 unmodified lines
6266 "clsx": "^2.1.1",
6367 "diff": "8.0.3",
6468 "effect": "catalog:",
65− "expo": "^56.0.0",
66− "expo-asset": "~56.0.15",
67− "expo-auth-session": "~56.0.12",
68− "expo-build-properties": "~56.0.15",
69− "expo-camera": "~56.0.7",
70− "expo-clipboard": "~56.0.3",
71− "expo-constants": "~56.0.16",
69+ "expo": "~56.0.12",
70+ "expo-asset": "~56.0.17",
71+ "expo-auth-session": "~56.0.14",
72+ "expo-build-properties": "~56.0.19",
73+ "expo-camera": "~56.0.8",
74+ "expo-clipboard": "~56.0.4",
75+ "expo-constants": "~56.0.18",
7276 "expo-crypto": "~56.0.4",
73− "expo-dev-client": "~56.0.16",
74− "expo-file-system": "~56.0.7",
75− "expo-font": "~56.0.5",
77+ "expo-dev-client": "~56.0.20",
78+ "expo-file-system": "~56.0.8",
79+ "expo-font": "~56.0.7",
7680 "expo-glass-effect": "~56.0.4",
7781 "expo-haptics": "~56.0.3",
78− "expo-image-picker": "~56.0.14",
79− "expo-linking": "~56.0.12",
82+ "expo-image-picker": "~56.0.18",
83+ "expo-linking": "~56.0.14",
8084 "expo-network": "~56.0.5",
81− "expo-notifications": "~56.0.14",
85+ "expo-notifications": "~56.0.18",
8286 "expo-paste-input": "^0.1.15",
83− "expo-router": "~56.2.7",
8487 "expo-secure-store": "~56.0.4",
8588 "expo-splash-screen": "~56.0.10",
86− "expo-symbols": "~56.0.5",
87− "expo-updates": "~56.0.17",
89+ "expo-symbols": "~56.0.6",
90+ "expo-updates": "~56.0.19",
8891 "expo-web-browser": "~56.0.5",
89− "expo-widgets": "~56.0.15",
92+ "expo-widgets": "~56.0.19",
9093 "punycode": "^2.3.1",
9194 "react": "19.2.3",
9295 "react-dom": "19.2.3",
9396 "react-native": "0.85.3",
9497 "react-native-gesture-handler": "~2.31.1",
9598 "react-native-image-viewing": "^0.2.2",
96− "react-native-keyboard-controller": "1.21.7",
99+ "react-native-keyboard-controller": "1.21.6",
97100 "react-native-nitro-markdown": "^0.5.0",
98101 "react-native-nitro-modules": "0.35.9",
99102 "react-native-reanimated": "4.3.1",
@@ -117,5 +120,13 @@ · 17 unmodified lines
117120 },
118121 "overrides": {
119122 "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+ }
120131 }
121132 }
apps/mobile/plugins/withIosCocoaPodsUuidCache.cjs+52−0
@@ -0,0 +1,52 @@
1+const fs = require("node:fs");
2+const path = require("node:path");
3+
4+const { withDangerousMod } = require("expo/config-plugins");
5+
6+const MARKER = "# t3code: repair cached CocoaPods UUID allocation before SPM integration";
7+const UUID_REPAIR = `${MARKER}
8+ pods_project = installer.pods_project
9+ existing_uuids = pods_project.objects.map(&:uuid)
10+ uuid_prefix = pods_project.instance_variable_get(:@uuid_prefix)[0, 6]
11+ sequential_uuid = /\\A#{Regexp.escape(uuid_prefix)}([0-9A-F]{7})0\\z/
12+ highest_index = existing_uuids.filter_map do |uuid|
13+ match = sequential_uuid.match(uuid)
14+ match && match[1].to_i(16)
15+ end.max || -1
16+
17+ # Pod::Project generates sequential UUIDs without collision checks because CocoaPods
18+ # normally creates the project in this process. EAS can restore Pods.xcodeproj from
19+ # cache, leaving the allocator behind the loaded objects. React Native's SPM support
20+ # then reuses an existing UUID and silently corrupts the project graph.
21+ next_index = highest_index + 1
22+ pods_project.instance_variable_set(:@generated_uuids, Array.new(next_index))
23+ pods_project.instance_variable_set(:@available_uuids, [])
24+ pods_project.generate_available_uuid_list(1_000)
25+ Pod::UI.puts "T3Code: reset CocoaPods UUID allocator at #{next_index} (#{existing_uuids.length} existing objects)"
26+`;
27+
28+module.exports = function withIosCocoaPodsUuidCache(config) {
29+ return withDangerousMod(config, [
30+ "ios",
31+ (nextConfig) => {
32+ const podfilePath = path.join(nextConfig.modRequest.platformProjectRoot, "Podfile");
33+ const podfile = fs.readFileSync(podfilePath, "utf8");
34+
35+ if (podfile.includes(MARKER)) {
36+ return nextConfig;
37+ }
38+
39+ const postInstallStart = "post_install do |installer|\n";
40+ if (!podfile.includes(postInstallStart)) {
41+ throw new Error("Unable to repair CocoaPods UUID allocation: post_install is missing.");
42+ }
43+
44+ fs.writeFileSync(
45+ podfilePath,
46+ podfile.replace(postInstallStart, `${postInstallStart}${UUID_REPAIR}`),
47+ "utf8",
48+ );
49+ return nextConfig;
50+ },
51+ ]);
52+};
apps/mobile/plugins/withIosSceneLifecycle.cjs+99−0
@@ -0,0 +1,99 @@
1+const { withAppDelegate, withInfoPlist } = require("expo/config-plugins");
2+
3+const SCENE_DELEGATE = `
4+
5+class SceneDelegate: UIResponder, UIWindowSceneDelegate {
6+ var window: UIWindow?
7+
8+ func scene(
9+ _ scene: UIScene,
10+ willConnectTo session: UISceneSession,
11+ options connectionOptions: UIScene.ConnectionOptions
12+ ) {
13+ guard
14+ let windowScene = scene as? UIWindowScene,
15+ let appDelegate = UIApplication.shared.delegate as? AppDelegate
16+ else {
17+ return
18+ }
19+
20+ let appWindow: UIWindow
21+ if let existingWindow = appDelegate.window {
22+ appWindow = existingWindow
23+ } else {
24+ appWindow = UIWindow(windowScene: windowScene)
25+ appDelegate.window = appWindow
26+ appDelegate.reactNativeFactory?.startReactNative(
27+ withModuleName: "main",
28+ in: appWindow,
29+ launchOptions: nil)
30+ }
31+
32+ window = appWindow
33+ appWindow.windowScene = windowScene
34+ appWindow.makeKeyAndVisible()
35+
36+ if let url = connectionOptions.urlContexts.first?.url {
37+ _ = appDelegate.application(UIApplication.shared, open: url, options: [:])
38+ }
39+
40+ if let userActivity = connectionOptions.userActivities.first {
41+ _ = appDelegate.application(
42+ UIApplication.shared,
43+ continue: userActivity,
44+ restorationHandler: { _ in })
45+ }
46+ }
47+
48+ func scene(_ scene: UIScene, openURLContexts URLContexts: Set<UIOpenURLContext>) {
49+ guard
50+ let url = URLContexts.first?.url,
51+ let appDelegate = UIApplication.shared.delegate as? AppDelegate
52+ else {
53+ return
54+ }
55+
56+ _ = appDelegate.application(UIApplication.shared, open: url, options: [:])
57+ }
58+
59+ func scene(_ scene: UIScene, continue userActivity: NSUserActivity) {
60+ guard let appDelegate = UIApplication.shared.delegate as? AppDelegate else {
61+ return
62+ }
63+
64+ _ = appDelegate.application(
65+ UIApplication.shared,
66+ continue: userActivity,
67+ restorationHandler: { _ in })
68+ }
69+}`;
70+
71+module.exports = function withIosSceneLifecycle(config) {
72+ config = withInfoPlist(config, (nextConfig) => {
73+ nextConfig.modResults.UIApplicationSceneManifest = {
74+ UIApplicationSupportsMultipleScenes: false,
75+ UISceneConfigurations: {
76+ UIWindowSceneSessionRoleApplication: [
77+ {
78+ UISceneConfigurationName: "Default Configuration",
79+ UISceneDelegateClassName: "$(PRODUCT_MODULE_NAME).SceneDelegate",
80+ },
81+ ],
82+ },
83+ };
84+
85+ return nextConfig;
86+ });
87+
88+ return withAppDelegate(config, (nextConfig) => {
89+ if (nextConfig.modResults.language !== "swift") {
90+ throw new Error("The iOS scene lifecycle plugin requires a Swift AppDelegate.");
91+ }
92+
93+ if (!nextConfig.modResults.contents.includes("class SceneDelegate:")) {
94+ nextConfig.modResults.contents += SCENE_DELEGATE;
95+ }
96+
97+ return nextConfig;
98+ });
99+};
apps/mobile/src/App.tsx+72−0
@@ -0,0 +1,72 @@
1+import {
2+ DMSans_400Regular,
3+ DMSans_500Medium,
4+ DMSans_700Bold,
5+ useFonts,
6+} from "@expo-google-fonts/dm-sans";
7+import * as Linking from "expo-linking";
8+import * as SplashScreen from "expo-splash-screen";
9+import { StatusBar, useColorScheme } from "react-native";
10+import { GestureHandlerRootView } from "react-native-gesture-handler";
11+import { KeyboardProvider } from "react-native-keyboard-controller";
12+import { SafeAreaProvider } from "react-native-safe-area-context";
13+import { createStaticNavigation, DarkTheme, DefaultTheme } from "@react-navigation/native";
14+
15+import { RegistryContext } from "@effect/atom-react";
16+import { useEffect } from "react";
17+import { CloudAuthProvider } from "./features/cloud/CloudAuthProvider";
18+import { AppearancePreferencesProvider } from "./features/settings/appearance/AppearancePreferencesProvider";
19+import { RootStack } from "./Stack";
20+import { appAtomRegistry } from "./state/atom-registry";
21+import { useThemeColor } from "./lib/useThemeColor";
22+
23+import "../global.css";
24+
25+const appLinking = {
26+ prefixes: [Linking.createURL("/"), "t3code://", "t3code-dev://", "t3code-preview://"],
27+};
28+
29+const Navigation = createStaticNavigation(RootStack);
30+
31+export default function App() {
32+ const [fontsLoaded] = useFonts({
33+ DMSans_400Regular,
34+ DMSans_500Medium,
35+ DMSans_700Bold,
36+ });
37+ const colorScheme = useColorScheme();
38+ const statusBarBg = useThemeColor("--color-status-bar");
39+
40+ useEffect(() => {
41+ if (fontsLoaded) SplashScreen.hide();
42+ }, [fontsLoaded]);
43+
44+ return (
45+ <RegistryContext.Provider value={appAtomRegistry}>
46+ <CloudAuthProvider>
47+ <AppearancePreferencesProvider>
48+ <GestureHandlerRootView style={{ flex: 1 }}>
49+ <KeyboardProvider statusBarTranslucent>
50+ <SafeAreaProvider>
51+ <StatusBar
52+ barStyle={colorScheme === "dark" ? "light-content" : "dark-content"}
53+ backgroundColor={statusBarBg}
54+ translucent
55+ />
56+ {/* The navigation theme drives the NATIVE header appearance: native-stack
57+ forwards `dark` as the nav bar's overrideUserInterfaceStyle. Without
58+ this, React Navigation defaults to its light theme and every native
59+ header (glass buttons, title, materials) is forced light even when
60+ the system is in dark mode. */}
61+ <Navigation
62+ linking={appLinking}
63+ theme={colorScheme === "dark" ? DarkTheme : DefaultTheme}
64+ />
65+ </SafeAreaProvider>
66+ </KeyboardProvider>
67+ </GestureHandlerRootView>
68+ </AppearancePreferencesProvider>
69+ </CloudAuthProvider>
70+ </RegistryContext.Provider>
71+ );
72+}
apps/mobile/src/Stack.tsx+463−0
@@ -0,0 +1,463 @@
1+import {
2+ createPathConfigForStaticNavigation,
3+ getPathFromState,
4+ NavigationState,
5+ StackActions,
6+ useNavigation,
7+} from "@react-navigation/native";
8+import {
9+ createNativeStackNavigator,
10+ createNativeStackScreen,
11+ type NativeStackNavigationOptions,
12+} from "@react-navigation/native-stack";
13+import { DynamicColorIOS, Platform, Pressable, ScrollView, StyleSheet } from "react-native";
14+import { useResolveClassNames } from "uniwind";
15+
16+import { AppText as Text } from "./components/AppText";
17+import { ArchivedThreadsRouteScreen } from "./features/archive/ArchivedThreadsRouteScreen";
18+import { useAgentNotificationNavigation } from "./features/agent-awareness/notificationNavigation";
19+import { ClerkSettingsSheetDetentProvider } from "./features/cloud/ClerkSettingsSheetDetent";
20+import { ThreadFilesTreeScreen, ThreadFileScreen } from "./features/files/ThreadFilesRouteScreen";
21+import { AdaptiveWorkspaceLayout } from "./features/layout/AdaptiveWorkspaceLayout";
22+import { HardwareKeyboardCommandProvider } from "./features/keyboard/HardwareKeyboardCommandProvider";
23+import { ReviewCommentComposerSheet } from "./features/review/ReviewCommentComposerSheet";
24+import { ReviewSheet } from "./features/review/ReviewSheet";
25+import { ThreadTerminalRouteScreen } from "./features/terminal/ThreadTerminalRouteScreen";
26+import { GitBranchesSheet } from "./features/threads/git/GitBranchesSheet";
27+import { GitCommitSheet } from "./features/threads/git/GitCommitSheet";
28+import { GitConfirmSheet } from "./features/threads/git/GitConfirmSheet";
29+import { GitOverviewSheet } from "./features/threads/git/GitOverviewSheet";
30+import { ThreadRouteScreen } from "./features/threads/ThreadRouteScreen";
31+import { ConnectionsRouteScreen } from "./features/connection/ConnectionsRouteScreen";
32+import { ConnectionsNewRouteScreen } from "./features/connection/ConnectionsNewRouteScreen";
33+import { HomeRouteScreen } from "./features/home/HomeRouteScreen";
34+import { AddProjectDestinationRoute } from "./features/projects/AddProjectDestinationRoute";
35+import { AddProjectLocalRoute } from "./features/projects/AddProjectLocalRoute";
36+import { AddProjectRepositoryRoute } from "./features/projects/AddProjectRepositoryRoute";
37+import { AddProjectSourceRoute } from "./features/projects/AddProjectSourceRoute";
38+import { NewTaskDraftRouteScreen } from "./features/threads/NewTaskDraftRouteScreen";
39+import { NewTaskFlowProvider } from "./features/threads/new-task-flow-provider";
40+import { NewTaskRouteScreen } from "./features/threads/NewTaskRouteScreen";
41+import { SettingsAppearanceRouteScreen } from "./features/settings/SettingsAppearanceRouteScreen";
42+import { SettingsAuthRouteScreen } from "./features/settings/SettingsAuthRouteScreen";
43+import { SettingsEnvironmentsRouteScreen } from "./features/settings/SettingsEnvironmentsRouteScreen";
44+import { SettingsRouteScreen } from "./features/settings/SettingsRouteScreen";
45+import { SettingsWaitlistRouteScreen } from "./features/settings/SettingsWaitlistRouteScreen";
46+import { nativeHeaderScrollEdgeEffects } from "./native/StackHeader";
47+import { useThreadOutboxDrain } from "./state/use-thread-outbox-drain";
48+
49+const HEADER_SCROLL_EDGE_EFFECTS = nativeHeaderScrollEdgeEffects(Platform.OS, Platform.Version);
50+
51+// Matches --color-sheet in global.css (light/dark). DynamicColorIOS lets the header
52+// background stay STATIC config while still adapting to appearance changes.
53+const SHEET_BACKGROUND_COLOR =
54+ Platform.OS === "ios"
55+ ? DynamicColorIOS({ light: "rgba(242, 242, 247, 0.98)", dark: "rgba(14, 14, 14, 0.98)" })
56+ : undefined;
57+
58+type AppScreenOptions = NativeStackNavigationOptions & {
59+ readonly unstable_navigationItemStyle?: "editor";
60+};
61+
62+// Shared header presets. Screens only override genuinely dynamic values (titles,
63+// subtitles, toolbar items, search callbacks) via NativeStackScreenOptions.
64+//
65+// GLASS: transparent header over the screen's primary scroll view, with the iOS 26
66+// scroll-edge blur sampling the content (Home, Thread, Files tree, settings sheet).
67+const GLASS_HEADER_OPTIONS: AppScreenOptions = {
68+ headerBackButtonDisplayMode: "minimal",
69+ headerBackTitle: "",
70+ headerLargeTitle: false,
71+ headerShadowVisible: false,
72+ headerShown: true,
73+ headerStyle: Platform.OS === "ios" ? { backgroundColor: "transparent" } : undefined,
74+ headerTitleStyle: { fontSize: 18, fontWeight: "800" },
75+ headerTransparent: Platform.OS === "ios",
76+ scrollEdgeEffects: Platform.OS === "ios" ? HEADER_SCROLL_EDGE_EFFECTS : undefined,
77+ unstable_navigationItemStyle: Platform.OS === "ios" ? "editor" : undefined,
78+};
79+
80+// SOLID: opaque sheet-colored header for surfaces whose content scrolls internally
81+// (file viewer, terminal, review) — there is nothing for glass to sample there.
82+const SOLID_HEADER_OPTIONS: AppScreenOptions = {
83+ headerBackButtonDisplayMode: "minimal",
84+ headerBackTitle: "",
85+ headerLargeTitle: false,
86+ headerShadowVisible: false,
87+ headerShown: true,
88+ headerStyle:
89+ SHEET_BACKGROUND_COLOR !== undefined
90+ ? // native-stack types this as `string`, but the native side accepts any
91+ // ColorValue including DynamicColorIOS.
92+ { backgroundColor: SHEET_BACKGROUND_COLOR as unknown as string }
93+ : undefined,
94+ headerTitleStyle: { fontSize: 18, fontWeight: "800" },
95+ headerTransparent: false,
96+ unstable_navigationItemStyle: Platform.OS === "ios" ? "editor" : undefined,
97+};
98+
99+// Solid header variant for screens inside sheets (centered title, no editor style).
100+const SHEET_SOLID_HEADER_OPTIONS: AppScreenOptions = {
101+ ...SOLID_HEADER_OPTIONS,
102+ unstable_navigationItemStyle: undefined,
103+};
104+
105+const SettingsSheetStack = createNativeStackNavigator({
106+ initialRouteName: "Settings",
107+ screenOptions: {
108+ ...GLASS_HEADER_OPTIONS,
109+ // Sheets read better with the iOS-default centered title (no editor style).
110+ unstable_navigationItemStyle: undefined,
111+ },
112+ screens: {
113+ Settings: createNativeStackScreen({
114+ screen: SettingsRouteScreen,
115+ linking: "",
116+ options: {
117+ title: "Settings",
118+ },
119+ }),
120+ SettingsEnvironments: createNativeStackScreen({
121+ screen: SettingsEnvironmentsRouteScreen,
122+ linking: "environments",
123+ options: {
124+ title: "Environments",
125+ },
126+ }),
127+ SettingsEnvironmentNew: createNativeStackScreen({
128+ screen: ConnectionsNewRouteScreen,
129+ linking: "environment-new",
130+ options: {
131+ title: "Add Environment",
132+ },
133+ }),
134+ SettingsArchive: createNativeStackScreen({
135+ screen: ArchivedThreadsRouteScreen,
136+ linking: "archive",
137+ options: {
138+ title: "Archived Threads",
139+ },
140+ }),
141+ SettingsAppearance: createNativeStackScreen({
142+ screen: SettingsAppearanceRouteScreen,
143+ linking: "appearance",
144+ options: {
145+ title: "Appearance",
146+ },
147+ }),
148+ SettingsAuth: createNativeStackScreen({
149+ screen: SettingsAuthRouteScreen,
150+ linking: "auth",
151+ options: {
152+ title: "Sign in",
153+ },
154+ }),
155+ SettingsWaitlist: createNativeStackScreen({
156+ screen: SettingsWaitlistRouteScreen,
157+ linking: "waitlist",
158+ options: {
159+ title: "Join the waitlist",
160+ },
161+ }),
162+ },
163+});
164+
165+// Thread routes live FLAT in the root stack (not in a nested navigator). A nested
166+// stack means a second UINavigationController with its own UINavigationBar, which
167+// breaks iOS 26's shared-header morphing between Home and Thread (each pair inside
168+// one bar morphs; across two bars the whole screen slides). Flat linking paths keep
169+// the same deep-link URLs the nested config produced.
170+const THREAD_LINKING_PREFIX = "threads/:environmentId/:threadId";
171+
172+// New-task / add-project flow: nested navigator inside the formSheet (Settings-sheet
173+// pattern — a plain formSheet screen cannot render a stack header; the header and
174+// in-sheet pushes come from this nested stack).
175+const NewTaskSheetStack = createNativeStackNavigator({
176+ initialRouteName: "NewTask",
177+ screenOptions: {
178+ ...GLASS_HEADER_OPTIONS,
179+ // Sheets read better with the iOS-default centered title (no editor style).
180+ unstable_navigationItemStyle: undefined,
181+ },
182+ screens: {
183+ NewTask: createNativeStackScreen({
184+ screen: NewTaskRouteScreen,
185+ linking: "",
186+ options: {
187+ title: "Choose project",
188+ },
189+ }),
190+ NewTaskDraft: createNativeStackScreen({
191+ screen: NewTaskDraftRouteScreen,
192+ linking: "draft",
193+ // The draft composer has no scroll view for glass to sample; a solid
194+ // header also lays the content out below the bar (no manual inset).
195+ options: SHEET_SOLID_HEADER_OPTIONS,
196+ }),
197+ AddProject: createNativeStackScreen({
198+ screen: AddProjectSourceRoute,
199+ linking: "add-project",
200+ options: {
201+ title: "Add Project",
202+ },
203+ }),
204+ AddProjectRepository: createNativeStackScreen({
205+ screen: AddProjectRepositoryRoute,
206+ linking: "add-project/repository",
207+ }),
208+ AddProjectDestination: createNativeStackScreen({
209+ screen: AddProjectDestinationRoute,
210+ linking: "add-project/destination",
211+ }),
212+ AddProjectLocal: createNativeStackScreen({
213+ screen: AddProjectLocalRoute,
214+ linking: "add-project/local",
215+ }),
216+ },
217+});
218+
219+// Routes presented as sheets/overlays ON TOP of the workspace. They must not
220+// influence the adaptive workspace layout: opening Settings over Home should
221+// not flip the sidebar in or change the active thread.
222+const WORKSPACE_OVERLAY_ROUTES = new Set([
223+ "Connections",
224+ "ConnectionsNew",
225+ "GitBranches",
226+ "GitCommit",
227+ "GitConfirm",
228+ "GitOverview",
229+ "NewTaskSheet",
230+ "SettingsSheet",
231+ "ThreadReviewComment",
232+]);
233+
234+/**
235+ * Pathname of the topmost NON-overlay route — the screen the workspace is
236+ * actually "on", regardless of any sheets floating above it.
237+ */
238+function workspacePathFromState(state: NavigationState): string {
239+ const routes = state.routes.filter((route) => !WORKSPACE_OVERLAY_ROUTES.has(route.name));
240+ const effectiveState =
241+ routes.length > 0 && routes.length !== state.routes.length
242+ ? ({ ...state, routes, index: routes.length - 1 } as NavigationState)
243+ : state;
244+ const path = getPathFromState(effectiveState, navigationPathConfig);
245+ return path.startsWith("/") ? path : `/${path}`;
246+}
247+
248+function RootStackLayout(props: {
249+ readonly children: React.ReactNode;
250+ readonly state: NavigationState;
251+}) {
252+ useAgentNotificationNavigation();
253+ useThreadOutboxDrain();
254+ // Full pathname (sheets included) for keyboard-command scoping; the
255+ // workspace layout only reacts to the underlying non-overlay route.
256+ const path = getPathFromState(props.state, navigationPathConfig);
257+ const pathname = path.startsWith("/") ? path : `/${path}`;
258+ const workspacePathname = workspacePathFromState(props.state);
259+
260+ return (
261+ <HardwareKeyboardCommandProvider pathname={pathname}>
262+ <ClerkSettingsSheetDetentProvider initiallyExpanded={false}>
263+ <AdaptiveWorkspaceLayout pathname={workspacePathname}>
264+ {props.children}
265+ </AdaptiveWorkspaceLayout>
266+ </ClerkSettingsSheetDetentProvider>
267+ </HardwareKeyboardCommandProvider>
268+ );
269+}
270+
271+function NotFoundScreen() {
272+ const navigation = useNavigation();
273+ const screenBgStyle = StyleSheet.flatten(useResolveClassNames("bg-screen"));
274+ const primaryBgStyle = StyleSheet.flatten(useResolveClassNames("bg-primary"));
275+ const returnHomeButtonStyle = StyleSheet.flatten([
276+ {
277+ borderRadius: 999,
278+ paddingHorizontal: 20,
279+ paddingVertical: 14,
280+ },
281+ primaryBgStyle,
282+ ]);
283+
284+ return (
285+ <ScrollView
286+ contentInsetAdjustmentBehavior="automatic"
287+ contentContainerStyle={{
288+ flexGrow: 1,
289+ alignItems: "center",
290+ justifyContent: "center",
291+ gap: 16,
292+ paddingHorizontal: 24,
293+ paddingVertical: 32,
294+ }}
295+ style={[{ flex: 1 }, screenBgStyle]}
296+ >
297+ <Text className="text-3xl font-t3-bold text-foreground" selectable>
298+ Route not found
299+ </Text>
300+ <Pressable
301+ style={returnHomeButtonStyle}
302+ onPress={() => navigation.dispatch(StackActions.replace("Home"))}
303+ >
304+ <Text className="text-base font-t3-bold text-primary-foreground">Return home</Text>
305+ </Pressable>
306+ </ScrollView>
307+ );
308+}
309+
310+export const RootStack = createNativeStackNavigator({
311+ initialRouteName: "Home",
312+ layout: RootStackLayout,
313+ screenOptions: {
314+ headerShown: false,
315+ },
316+ screens: {
317+ Home: createNativeStackScreen({
318+ screen: HomeRouteScreen,
319+ linking: "",
320+ options: {
321+ ...GLASS_HEADER_OPTIONS,
322+ contentStyle: { backgroundColor: "transparent" },
323+ headerBackVisible: false,
324+ title: "Threads",
325+ },
326+ }),
327+ Thread: createNativeStackScreen({
328+ screen: ThreadRouteScreen,
329+ linking: THREAD_LINKING_PREFIX,
330+ options: GLASS_HEADER_OPTIONS,
331+ }),
332+ ThreadTerminal: createNativeStackScreen({
333+ screen: ThreadTerminalRouteScreen,
334+ linking: `${THREAD_LINKING_PREFIX}/terminal`,
335+ options: SOLID_HEADER_OPTIONS,
336+ }),
337+ ThreadReview: createNativeStackScreen({
338+ screen: ReviewSheet,
339+ linking: `${THREAD_LINKING_PREFIX}/review`,
340+ options: SOLID_HEADER_OPTIONS,
341+ }),
342+ ThreadReviewComment: createNativeStackScreen({
343+ screen: ReviewCommentComposerSheet,
344+ linking: `${THREAD_LINKING_PREFIX}/review-comment`,
345+ options: {
346+ presentation: "formSheet",
347+ sheetAllowedDetents: [0.55, 0.92],
348+ sheetGrabberVisible: true,
349+ },
350+ }),
351+ ThreadFiles: createNativeStackScreen({
352+ screen: ThreadFilesTreeScreen,
353+ linking: `${THREAD_LINKING_PREFIX}/files`,
354+ options: {
355+ ...GLASS_HEADER_OPTIONS,
356+ contentStyle:
357+ SHEET_BACKGROUND_COLOR !== undefined
358+ ? { backgroundColor: SHEET_BACKGROUND_COLOR }
359+ : undefined,
360+ title: "Files",
361+ },
362+ }),
363+ ThreadFile: createNativeStackScreen({
364+ screen: ThreadFileScreen,
365+ linking: `${THREAD_LINKING_PREFIX}/files/:path*`,
366+ options: SOLID_HEADER_OPTIONS,
367+ }),
368+ GitOverview: createNativeStackScreen({
369+ screen: GitOverviewSheet,
370+ linking: `${THREAD_LINKING_PREFIX}/git`,
371+ options: {
372+ presentation: "formSheet",
373+ sheetAllowedDetents: [0.55, 0.92],
374+ sheetGrabberVisible: true,
375+ },
376+ }),
377+ GitCommit: createNativeStackScreen({
378+ screen: GitCommitSheet,
379+ linking: `${THREAD_LINKING_PREFIX}/git/commit`,
380+ options: {
381+ presentation: "formSheet",
382+ sheetAllowedDetents: [0.55, 0.92],
383+ sheetGrabberVisible: true,
384+ },
385+ }),
386+ GitBranches: createNativeStackScreen({
387+ screen: GitBranchesSheet,
388+ linking: `${THREAD_LINKING_PREFIX}/git/branches`,
389+ options: {
390+ presentation: "formSheet",
391+ sheetAllowedDetents: [0.55, 0.92],
392+ sheetGrabberVisible: true,
393+ },
394+ }),
395+ GitConfirm: createNativeStackScreen({
396+ screen: GitConfirmSheet,
397+ linking: `${THREAD_LINKING_PREFIX}/git-confirm`,
398+ options: {
399+ presentation: "formSheet",
400+ sheetAllowedDetents: [0.45, 0.7],
401+ sheetGrabberVisible: true,
402+ },
403+ }),
404+ SettingsSheet: createNativeStackScreen({
405+ screen: SettingsSheetStack,
406+ linking: "settings",
407+ options: {
408+ gestureEnabled: true,
409+ headerShown: false,
410+ presentation: "formSheet",
411+ sheetAllowedDetents: [0.7, 0.92],
412+ sheetGrabberVisible: true,
413+ },
414+ }),
415+ Connections: createNativeStackScreen({
416+ screen: ConnectionsRouteScreen,
417+ linking: "connections",
418+ options: {
419+ title: "Environments",
420+ presentation: "formSheet",
421+ sheetAllowedDetents: [0.55, 0.7],
422+ sheetGrabberVisible: true,
423+ },
424+ }),
425+ ConnectionsNew: createNativeStackScreen({
426+ screen: ConnectionsNewRouteScreen,
427+ linking: "connections/new",
428+ options: {
429+ presentation: "formSheet",
430+ sheetAllowedDetents: [0.55, 0.7],
431+ sheetGrabberVisible: true,
432+ },
433+ }),
434+ NewTaskSheet: createNativeStackScreen({
435+ screen: NewTaskSheetStack,
436+ linking: "new",
437+ // The whole new-task flow (choose project → draft → add project) shares
438+ // draft state via NewTaskFlowProvider. The expo-router era mounted it in
439+ // app/new/_layout.tsx; this layout wrapper is the native-stack equivalent.
440+ layout: ({ children }) => <NewTaskFlowProvider>{children}</NewTaskFlowProvider>,
441+ options: {
442+ gestureEnabled: true,
443+ headerShown: false,
444+ presentation: "formSheet",
445+ sheetAllowedDetents: [0.92],
446+ sheetGrabberVisible: true,
447+ },
448+ }),
449+ NotFound: createNativeStackScreen({
450+ screen: NotFoundScreen,
451+ linking: "*",
452+ }),
453+ },
454+});
455+type RootStackType = typeof RootStack;
456+
457+const navigationPathConfig = {
458+ screens: createPathConfigForStaticNavigation(RootStack) ?? {},
459+};
460+
461+declare module "@react-navigation/native" {
462+ interface RootNavigator extends RootStackType {}
463+}
apps/mobile/src/app/+not-found.tsx+0−43
@@ -1,43 +0,0 @@
1−import { Link } from "expo-router";
2−import { Pressable, ScrollView, StyleSheet } from "react-native";
3−import { useResolveClassNames } from "uniwind";
4−
5−import { AppText as Text } from "../components/AppText";
6−
7−export default function NotFoundRoute() {
8− const screenBgStyle = StyleSheet.flatten(useResolveClassNames("bg-screen"));
9− const primaryBgStyle = StyleSheet.flatten(useResolveClassNames("bg-primary"));
10−
11− return (
12− <ScrollView
13− contentInsetAdjustmentBehavior="automatic"
14− contentContainerStyle={{
15− flexGrow: 1,
16− alignItems: "center",
17− justifyContent: "center",
18− gap: 16,
19− paddingHorizontal: 24,
20− paddingVertical: 32,
21− }}
22− style={[{ flex: 1 }, screenBgStyle]}
23− >
24− <Text className="text-3xl font-t3-bold text-foreground" selectable>
25− Route not found
26− </Text>
27− <Link href="/" asChild>
28− <Pressable
29− style={[
30− {
31− borderRadius: 999,
32− paddingHorizontal: 20,
33− paddingVertical: 14,
34− },
35− primaryBgStyle,
36− ]}
37− >
38− <Text className="text-base font-t3-bold text-primary-foreground">Return home</Text>
39− </Pressable>
40− </Link>
41− </ScrollView>
42− );
43−}
apps/mobile/src/app/_layout.tsx+0−149
@@ -1,149 +0,0 @@
1−import "../../global.css";
2−import {
3− DMSans_400Regular,
4− DMSans_500Medium,
5− DMSans_700Bold,
6− useFonts,
7−} from "@expo-google-fonts/dm-sans";
8−import { usePathname } from "expo-router";
9−import Stack from "expo-router/stack";
10−import { useCallback } from "react";
11−import { StatusBar, useColorScheme } from "react-native";
12−import { GestureHandlerRootView } from "react-native-gesture-handler";
13−import { KeyboardProvider } from "react-native-keyboard-controller";
14−import { SafeAreaProvider } from "react-native-safe-area-context";
15−import { useResolveClassNames } from "uniwind";
16−
17−import { LoadingScreen } from "../components/LoadingScreen";
18−
19−import { useWorkspaceState } from "../state/workspace";
20−import { useThreadOutboxDrain } from "../state/use-thread-outbox-drain";
21−import { RegistryContext } from "@effect/atom-react";
22−import { appAtomRegistry } from "../state/atom-registry";
23−import { CloudAuthProvider } from "../features/cloud/CloudAuthProvider";
24−import {
25− ClerkSettingsSheetDetentProvider,
26− useClerkSettingsSheetDetent,
27−} from "../features/cloud/ClerkSettingsSheetDetent";
28−import { useAgentNotificationNavigation } from "../features/agent-awareness/notificationNavigation";
29−import { useThemeColor } from "../lib/useThemeColor";
30−
31−function AppNavigator() {
32− const pathname = usePathname();
33− const expandedSettingsRouteIsActive =
34− pathname === "/settings/archive" || pathname === "/settings/auth";
35−
36− return (
37− <ClerkSettingsSheetDetentProvider initiallyExpanded={expandedSettingsRouteIsActive}>
38− <AppNavigatorContent />
39− </ClerkSettingsSheetDetentProvider>
40− );
41−}
42−
43−function AppNavigatorContent() {
44− const { state } = useWorkspaceState();
45− const { collapse, isExpanded } = useClerkSettingsSheetDetent();
46− const colorScheme = useColorScheme();
47− const statusBarBg = useThemeColor("--color-status-bar");
48− const sheetStyle = useResolveClassNames("bg-sheet");
49− useAgentNotificationNavigation();
50− useThreadOutboxDrain();
51−
52− const handleSettingsTransitionEnd = useCallback(
53− (event: { data: { closing: boolean } }) => {
54− if (event.data.closing) {
55− collapse();
56− }
57− },
58− [collapse],
59− );
60−
61− const newTaskScreenOptions = {
62− contentStyle: sheetStyle,
63− gestureEnabled: true,
64− headerShown: false,
65− presentation: "formSheet" as const,
66− sheetAllowedDetents: [0.92],
67− sheetGrabberVisible: true,
68− };
69−
70− const connectionSheetScreenOptions = {
71− contentStyle: sheetStyle,
72− gestureEnabled: true,
73− headerShown: false,
74− presentation: "formSheet" as const,
75− sheetAllowedDetents: [0.55, 0.7],
76− sheetGrabberVisible: true,
77− };
78−
79− const settingsSheetScreenOptions = {
80− ...connectionSheetScreenOptions,
81− sheetAllowedDetents: isExpanded ? [0.92] : [0.7],
82− };
83−
84− if (state.isLoadingConnections) {
85− return <LoadingScreen message="Loading remote workspace…" />;
86− }
87−
88− return (
89− <>
90− <StatusBar
91− barStyle={colorScheme === "dark" ? "light-content" : "dark-content"}
92− backgroundColor={String(statusBarBg)}
93− translucent
94− />
95− <Stack screenOptions={{ headerShown: false }}>
96− <Stack.Screen
97− name="index"
98− options={{
99− contentStyle: { backgroundColor: "transparent" },
100− headerShown: true,
101− headerTransparent: true,
102− headerShadowVisible: false,
103− }}
104− />
105− <Stack.Screen
106− name="settings"
107− listeners={{ transitionEnd: handleSettingsTransitionEnd }}
108− options={settingsSheetScreenOptions}
109− />
110− <Stack.Screen name="connections" options={connectionSheetScreenOptions} />
111− <Stack.Screen name="new" options={newTaskScreenOptions} />
112− <Stack.Screen
113− name="threads/[environmentId]/[threadId]"
114− options={{
115− animation: "slide_from_right",
116− contentStyle: { backgroundColor: "transparent" },
117− gestureEnabled: true,
118− headerShown: false,
119− }}
120− />
121− </Stack>
122− </>
123− );
124−}
125−
126−export default function RootLayout() {
127− const [fontsLoaded] = useFonts({
128− DMSans_400Regular,
129− DMSans_500Medium,
130− DMSans_700Bold,
131− });
132− return (
133− <RegistryContext.Provider value={appAtomRegistry}>
134− <CloudAuthProvider>
135− <GestureHandlerRootView style={{ flex: 1 }}>
136− <KeyboardProvider statusBarTranslucent>
137− <SafeAreaProvider>
138− {fontsLoaded ? (
139− <AppNavigator />
140− ) : (
141− <LoadingScreen message="Loading remote workspace…" />
142− )}
143− </SafeAreaProvider>
144− </KeyboardProvider>
145− </GestureHandlerRootView>
146− </CloudAuthProvider>
147− </RegistryContext.Provider>
148− );
149−}
apps/mobile/src/app/connections/_layout.tsx+0−29
@@ -1,29 +0,0 @@
1−import Stack from "expo-router/stack";
2−import { useResolveClassNames } from "uniwind";
3−import { useThemeColor } from "../../lib/useThemeColor";
4−
5−export const unstable_settings = {
6− anchor: "index",
7−};
8−
9−export default function ConnectionsLayout() {
10− const contentStyle = useResolveClassNames("bg-sheet");
11− const connSheetBg = useThemeColor("--color-sheet");
12− const headerTint = useThemeColor("--color-foreground");
13−
14− return (
15− <Stack
16− screenOptions={{
17− contentStyle,
18− headerStyle: { backgroundColor: connSheetBg },
19− headerTintColor: headerTint,
20− headerTitleStyle: { fontFamily: "DMSans_700Bold" },
21− headerShadowVisible: false,
22− headerBackButtonDisplayMode: "minimal",
23− }}
24− >
25− <Stack.Screen name="index" options={{ animation: "none" }} />
26− <Stack.Screen name="new" options={{ animation: "slide_from_right" }} />
27− </Stack>
28− );
29−}
apps/mobile/src/app/index.tsx+0−130
@@ -1,130 +0,0 @@
1−import type {
2− EnvironmentId,
3− SidebarProjectGroupingMode,
4− SidebarThreadSortOrder,
5−} from "@t3tools/contracts";
6−import {
7− DEFAULT_SIDEBAR_PROJECT_GROUPING_MODE,
8− DEFAULT_SIDEBAR_PROJECT_SORT_ORDER,
9− DEFAULT_SIDEBAR_THREAD_SORT_ORDER,
10−} from "@t3tools/contracts";
11−import * as Arr from "effect/Array";
12−import * as Order from "effect/Order";
13−import { useRouter } from "expo-router";
14−import { useCallback, useMemo, useState } from "react";
15−
16−import { useProjects, useThreadShells } from "../state/entities";
17−import { useWorkspaceState } from "../state/workspace";
18−import { buildThreadRoutePath } from "../lib/routes";
19−import { useSavedRemoteConnections } from "../state/use-remote-environment-registry";
20−import { HomeScreen } from "../features/home/HomeScreen";
21−import { HomeHeader } from "../features/home/HomeHeader";
22−import type { HomeProjectSortOrder } from "../features/home/homeThreadList";
23−import { useThreadListActions } from "../features/home/useThreadListActions";
24−
25−interface HomeListOptions {
26− readonly selectedEnvironmentId: EnvironmentId | null;
27− readonly projectSortOrder: HomeProjectSortOrder;
28− readonly threadSortOrder: SidebarThreadSortOrder;
29− readonly projectGroupingMode: SidebarProjectGroupingMode;
30−}
31−
32−/* ─── Route screen ───────────────────────────────────────────────────── */
33−
34−export default function HomeRouteScreen() {
35− const projects = useProjects();
36− const threads = useThreadShells();
37− const { state: catalogState } = useWorkspaceState();
38− const { savedConnectionsById } = useSavedRemoteConnections();
39− const router = useRouter();
40− const [searchQuery, setSearchQuery] = useState("");
41− const [listOptions, setListOptions] = useState<HomeListOptions>({
42− selectedEnvironmentId: null,
43− projectSortOrder:
44− DEFAULT_SIDEBAR_PROJECT_SORT_ORDER === "manual"
45− ? "updated_at"
46− : DEFAULT_SIDEBAR_PROJECT_SORT_ORDER,
47− threadSortOrder: DEFAULT_SIDEBAR_THREAD_SORT_ORDER,
48− projectGroupingMode: DEFAULT_SIDEBAR_PROJECT_GROUPING_MODE,
49− });
50− const { archiveThread, confirmDeleteThread } = useThreadListActions();
51− const environments = useMemo(
52− () =>
53− Arr.sort(
54− Object.values(savedConnectionsById).map((connection) => ({
55− environmentId: connection.environmentId,
56− label: connection.environmentLabel,
57− })),
58− Order.mapInput(
59− Order.String,
60− (environment: { readonly label: string }) => environment.label,
61− ),
62− ),
63− [savedConnectionsById],
64− );
65− const selectedEnvironmentId = environments.some(
66− (environment) => environment.environmentId === listOptions.selectedEnvironmentId,
67− )
68− ? listOptions.selectedEnvironmentId
69− : null;
70− const setSelectedEnvironmentId = useCallback((environmentId: EnvironmentId | null) => {
71− setListOptions((current) => ({ ...current, selectedEnvironmentId: environmentId }));
72− }, []);
73− const setProjectSortOrder = useCallback((projectSortOrder: HomeProjectSortOrder) => {
74− setListOptions((current) => ({ ...current, projectSortOrder }));
75− }, []);
76− const setThreadSortOrder = useCallback((threadSortOrder: SidebarThreadSortOrder) => {
77− setListOptions((current) => ({ ...current, threadSortOrder }));
78− }, []);
79− const setProjectGroupingMode = useCallback((projectGroupingMode: SidebarProjectGroupingMode) => {
80− setListOptions((current) => ({ ...current, projectGroupingMode }));
81− }, []);
82−
83− return (
84− <>
85− <HomeHeader
86− environments={environments}
87− selectedEnvironmentId={selectedEnvironmentId}
88− projectSortOrder={listOptions.projectSortOrder}
89− threadSortOrder={listOptions.threadSortOrder}
90− projectGroupingMode={listOptions.projectGroupingMode}
91− onEnvironmentChange={setSelectedEnvironmentId}
92− onOpenSettings={() => router.push("/settings")}
93− onProjectGroupingModeChange={setProjectGroupingMode}
94− onProjectSortOrderChange={setProjectSortOrder}
95− onSearchQueryChange={setSearchQuery}
96− onStartNewTask={() => router.push("/new")}
97− onThreadSortOrderChange={setThreadSortOrder}
98− />
99−
100− <HomeScreen
101− catalogState={catalogState}
102− onAddConnection={() => router.push("/connections/new")}
103− onArchiveThread={archiveThread}
104− onDeleteThread={confirmDeleteThread}
105− onNewThreadInProject={(project) => {
106− router.push({
107− pathname: "/new/draft",
108− params: {
109− environmentId: project.environmentId,
110− projectId: project.id,
111− title: project.title,
112− },
113− });
114− }}
115− onOpenEnvironments={() => router.push("/settings/environments")}
116− onSelectThread={(thread) => {
117− router.push(buildThreadRoutePath(thread));
118− }}
119− projectGroupingMode={listOptions.projectGroupingMode}
120− projects={projects}
121− projectSortOrder={listOptions.projectSortOrder}
122− savedConnectionsById={savedConnectionsById}
123− searchQuery={searchQuery}
124− selectedEnvironmentId={selectedEnvironmentId}
125− threads={threads}
126− threadSortOrder={listOptions.threadSortOrder}
127− />
128− </>
129− );
130−}
apps/mobile/src/app/new/_layout.tsx+0−50
@@ -1,50 +0,0 @@
1−import Stack from "expo-router/stack";
2−import { useResolveClassNames } from "uniwind";
3−
4−import { NewTaskFlowProvider } from "../../features/threads/new-task-flow-provider";
5−import { useThemeColor } from "../../lib/useThemeColor";
6−
7−export const unstable_settings = {
8− anchor: "index",
9−};
10−
11−export default function NewTaskLayout() {
12− const sheetStyle = useResolveClassNames("bg-sheet");
13− const sheetBg = useThemeColor("--color-sheet");
14− const headerTint = useThemeColor("--color-foreground");
15−
16− return (
17− <NewTaskFlowProvider>
18− <Stack
19− screenOptions={{
20− contentStyle: sheetStyle,
21− headerBackButtonDisplayMode: "minimal",
22− headerLargeTitle: false,
23− headerShadowVisible: false,
24− headerStyle: { backgroundColor: sheetBg },
25− headerTintColor: headerTint,
26− headerTitleStyle: { fontFamily: "DMSans_700Bold" },
27− }}
28− >
29− <Stack.Screen name="index" options={{ animation: "none", title: "Choose project" }} />
30− <Stack.Screen
31− name="add-project/index"
32− options={{ animation: "slide_from_right", title: "New project" }}
33− />
34− <Stack.Screen
35− name="add-project/repository"
36− options={{ animation: "slide_from_right", title: "Repository" }}
37− />
38− <Stack.Screen
39− name="add-project/destination"
40− options={{ animation: "slide_from_right", title: "Clone destination" }}
41− />
42− <Stack.Screen
43− name="add-project/local"
44− options={{ animation: "slide_from_right", title: "Local folder" }}
45− />
46− <Stack.Screen name="draft" options={{ animation: "slide_from_right", title: "New task" }} />
47− </Stack>
48− </NewTaskFlowProvider>
49− );
50−}
apps/mobile/src/app/new/add-project/destination.tsx+0−5
@@ -1,5 +0,0 @@
1−import { AddProjectDestinationScreen } from "../../../features/projects/AddProjectScreen";
2−
3−export default function AddProjectDestinationRoute() {
4− return <AddProjectDestinationScreen />;
5−}
apps/mobile/src/app/new/add-project/index.tsx+0−5
@@ -1,5 +0,0 @@
1−import { AddProjectSourceScreen } from "../../../features/projects/AddProjectScreen";
2−
3−export default function AddProjectRoute() {
4− return <AddProjectSourceScreen />;
5−}
apps/mobile/src/app/new/add-project/local.tsx+0−5
@@ -1,5 +0,0 @@
1−import { AddProjectLocalFolderScreen } from "../../../features/projects/AddProjectScreen";
2−
3−export default function AddProjectLocalRoute() {
4− return <AddProjectLocalFolderScreen />;
5−}
apps/mobile/src/app/new/add-project/repository.tsx+0−23
@@ -1,23 +0,0 @@
1−import { Stack, useLocalSearchParams } from "expo-router";
2−import { addProjectRemoteSourceLabel } from "@t3tools/client-runtime/operations/projects";
3−
4−import { AddProjectRepositoryScreen } from "../../../features/projects/AddProjectScreen";
5−
6−export default function AddProjectRepositoryRoute() {
7− const params = useLocalSearchParams<{ source?: string | string[] }>();
8− const source = Array.isArray(params.source) ? params.source[0] : params.source;
9− const title =
10− source === "github" ||
11− source === "gitlab" ||
12− source === "bitbucket" ||
13− source === "azure-devops"
14− ? addProjectRemoteSourceLabel(source)
15− : "Git URL";
16−
17− return (
18− <>
19− <Stack.Screen options={{ title }} />
20− <AddProjectRepositoryScreen />
21− </>
22− );
23−}
apps/mobile/src/app/new/draft.tsx+0−29
@@ -1,29 +0,0 @@
1−import { Stack, useLocalSearchParams } from "expo-router";
2−
3−import { NewTaskDraftScreen } from "../../features/threads/NewTaskDraftScreen";
4−
5−export default function NewTaskDraftRoute() {
6− const params = useLocalSearchParams<{
7− environmentId?: string | string[];
8− projectId?: string | string[];
9− title?: string | string[];
10− }>();
11−
12− return (
13− <>
14− <Stack.Screen
15− options={{
16− title: Array.isArray(params.title) ? params.title[0] : (params.title ?? "New task"),
17− }}
18− />
19− <NewTaskDraftScreen
20− initialProjectRef={{
21− environmentId: Array.isArray(params.environmentId)
22− ? params.environmentId[0]
23− : params.environmentId,
24− projectId: Array.isArray(params.projectId) ? params.projectId[0] : params.projectId,
25− }}
26− />
27− </>
28− );
29−}
apps/mobile/src/app/settings/_layout.tsx+0−62
@@ -1,62 +0,0 @@
1−import Stack from "expo-router/stack";
2−import { useCallback } from "react";
3−import { useResolveClassNames } from "uniwind";
4−
5−import { useClerkSettingsSheetDetent } from "../../features/cloud/ClerkSettingsSheetDetent";
6−import { useThemeColor } from "../../lib/useThemeColor";
7−
8−export const unstable_settings = {
9− anchor: "index",
10−};
11−
12−export default function SettingsLayout() {
13− const { collapse } = useClerkSettingsSheetDetent();
14− const contentStyle = useResolveClassNames("bg-sheet");
15− const sheetBg = useThemeColor("--color-sheet");
16− const headerTint = useThemeColor("--color-foreground");
17− const handleExpandedRouteTransitionEnd = useCallback(
18− (event: { data: { closing: boolean } }) => {
19− if (event.data.closing) {
20− collapse();
21− }
22− },
23− [collapse],
24− );
25−
26− return (
27− <Stack
28− screenOptions={{
29− contentStyle,
30− headerBackButtonDisplayMode: "minimal",
31− headerShadowVisible: false,
32− headerStyle: { backgroundColor: sheetBg },
33− headerTintColor: headerTint,
34− headerTitleStyle: { fontFamily: "DMSans_700Bold" },
35− }}
36− >
37− <Stack.Screen name="index" options={{ animation: "none", title: "Settings" }} />
38− <Stack.Screen
39− name="environments"
40− options={{ animation: "slide_from_right", title: "Environments" }}
41− />
42− <Stack.Screen
43− name="environment-new"
44− options={{ animation: "slide_from_right", title: "Add Environment" }}
45− />
46− <Stack.Screen
47− name="waitlist"
48− options={{ animation: "slide_from_right", title: "Join the waitlist" }}
49− />
50− <Stack.Screen
51− name="archive"
52− listeners={{ transitionEnd: handleExpandedRouteTransitionEnd }}
53− options={{ animation: "slide_from_right", title: "Archived Threads" }}
54− />
55− <Stack.Screen
56− name="auth"
57− listeners={{ transitionEnd: handleExpandedRouteTransitionEnd }}
58− options={{ animation: "slide_from_right", title: "Sign in" }}
59− />
60− </Stack>
61− );
62−}
apps/mobile/src/app/settings/archive.tsx+0−3
@@ -1,3 +0,0 @@
1−import { ArchivedThreadsRouteScreen } from "../../features/archive/ArchivedThreadsRouteScreen";
2−
3−export default ArchivedThreadsRouteScreen;
apps/mobile/src/app/settings/auth.tsx+0−33
@@ -1,33 +0,0 @@
1−import { useAuth } from "@clerk/expo";
2−import { AuthView, UserProfileView } from "@clerk/expo/native";
3−import { Redirect, Stack } from "expo-router";
4−import { View } from "react-native";
5−
6−import { hasCloudPublicConfig } from "../../features/cloud/publicConfig";
7−
8−export default function SettingsAuthRouteScreen() {
9− return hasCloudPublicConfig() ? (
10− <ConfiguredSettingsAuthRouteScreen />
11− ) : (
12− <Redirect href="/settings" />
13− );
14−}
15−
16−function ConfiguredSettingsAuthRouteScreen() {
17− const { isLoaded, isSignedIn } = useAuth({ treatPendingAsSignedOut: false });
18−
19− return (
20− <>
21− <Stack.Screen options={{ title: isSignedIn ? "Account" : "Sign in" }} />
22− <View collapsable={false} className="flex-1 overflow-hidden bg-sheet">
23− {isLoaded ? (
24− isSignedIn ? (
25− <UserProfileView isDismissible={false} />
26− ) : (
27− <AuthView isDismissible={false} />
28− )
29− ) : null}
30− </View>
31− </>
32− );
33−}
apps/mobile/src/app/settings/environment-new.tsx+0−1
@@ -1 +0,0 @@
1−export { default } from "../connections/new";
apps/mobile/src/app/threads/[environmentId]/[threadId]/_layout.tsx+0−109
@@ -1,109 +0,0 @@
1−import Stack from "expo-router/stack";
2−import { StyleSheet } from "react-native";
3−import { useResolveClassNames } from "uniwind";
4−
5−export default function ThreadLayout() {
6− const sheetStyle = StyleSheet.flatten(useResolveClassNames("bg-sheet"));
7− const headerBg = {
8− backgroundColor: (sheetStyle as { backgroundColor?: string })?.backgroundColor,
9− };
10−
11− return (
12− <Stack screenOptions={{ headerShown: false }}>
13− <Stack.Screen
14− name="index"
15− options={{
16− contentStyle: { backgroundColor: "transparent" },
17− headerShown: true,
18− headerTransparent: true,
19− headerShadowVisible: false,
20− headerTitle: "",
21− headerBackVisible: true,
22− headerBackButtonDisplayMode: "minimal",
23− }}
24− />
25− <Stack.Screen
26− name="git"
27− options={{
28− contentStyle: sheetStyle,
29− gestureEnabled: true,
30− headerShown: false,
31− presentation: "formSheet" as const,
32− sheetAllowedDetents: [0.85],
33− sheetGrabberVisible: true,
34− }}
35− />
36− <Stack.Screen
37− name="git-confirm"
38− options={{
39− contentStyle: sheetStyle,
40− gestureEnabled: true,
41− headerShown: false,
42− presentation: "formSheet" as const,
43− sheetAllowedDetents: [0.4],
44− sheetGrabberVisible: true,
45− }}
46− />
47− <Stack.Screen
48− name="review"
49− options={{
50− animation: "slide_from_right",
51− contentStyle: sheetStyle,
52− headerBackButtonDisplayMode: "minimal",
53− headerShown: true,
54− headerTitle: "Files changed",
55− headerBackTitle: "",
56− headerShadowVisible: false,
57− headerStyle: headerBg,
58− }}
59− />
60− <Stack.Screen
61− name="files/index"
62− options={{
63− animation: "slide_from_right",
64− contentStyle: sheetStyle,
65− headerBackButtonDisplayMode: "minimal",
66− headerShown: true,
67− headerTitle: "Files",
68− headerBackTitle: "",
69− headerShadowVisible: false,
70− headerStyle: headerBg,
71− }}
72− />
73− <Stack.Screen
74− name="files/[...path]"
75− options={{
76− animation: "slide_from_right",
77− contentStyle: sheetStyle,
78− headerBackButtonDisplayMode: "minimal",
79− headerShown: true,
80− headerTitle: "File",
81− headerBackTitle: "",
82− headerShadowVisible: false,
83− headerStyle: headerBg,
84− }}
85− />
86− <Stack.Screen
87− name="review-comment"
88− options={{
89− contentStyle: sheetStyle,
90− gestureEnabled: true,
91− headerShown: false,
92− presentation: "formSheet" as const,
93− sheetAllowedDetents: [0.72, 0.92],
94− sheetGrabberVisible: true,
95− }}
96− />
97− <Stack.Screen
98− name="terminal"
99− options={{
100− animation: "slide_from_right",
101− contentStyle: { backgroundColor: "#050505" },
102− headerBackButtonDisplayMode: "minimal",
103− headerShown: true,
104− headerShadowVisible: false,
105− }}
106− />
107− </Stack>
108− );
109−}
apps/mobile/src/app/threads/[environmentId]/[threadId]/files/[...path].tsx+0−5
@@ -1,5 +0,0 @@
1−import { ThreadFileScreen } from "../../../../../features/files/ThreadFilesRouteScreen";
2−
3−export default function ThreadFileRoute() {
4− return <ThreadFileScreen />;
5−}
apps/mobile/src/app/threads/[environmentId]/[threadId]/files/index.tsx+0−5
@@ -1,5 +0,0 @@
1−import { ThreadFilesTreeScreen } from "../../../../../features/files/ThreadFilesRouteScreen";
2−
3−export default function ThreadFilesIndexRoute() {
4− return <ThreadFilesTreeScreen />;
5−}
apps/mobile/src/app/threads/[environmentId]/[threadId]/git-confirm.tsx+0−5
@@ -1,5 +0,0 @@
1−import { GitConfirmSheet } from "../../../../features/threads/git/GitConfirmSheet";
2−
3−export default function GitConfirmRoute() {
4− return <GitConfirmSheet />;
5−}
apps/mobile/src/app/threads/[environmentId]/[threadId]/git/_layout.tsx+0−58
@@ -1,58 +0,0 @@
1−import Stack from "expo-router/stack";
2−import { StyleSheet } from "react-native";
3−import { useResolveClassNames } from "uniwind";
4−
5−export const unstable_settings = {
6− anchor: "index",
7−};
8−
9−export default function GitSheetLayout() {
10− const sheetStyle = StyleSheet.flatten(useResolveClassNames("bg-sheet"));
11− const headerBg = {
12− backgroundColor: (sheetStyle as { backgroundColor?: string })?.backgroundColor,
13− };
14−
15− return (
16− <Stack
17− screenOptions={{
18− contentStyle: sheetStyle,
19− headerShown: false,
20− }}
21− >
22− <Stack.Screen name="index" options={{ animation: "none" }} />
23− <Stack.Screen
24− name="commit"
25− options={{
26− animation: "slide_from_right",
27− headerShown: true,
28− headerTitle: "Commit changes",
29− headerBackTitle: "",
30− headerShadowVisible: false,
31− headerStyle: headerBg,
32− }}
33− />
34− <Stack.Screen
35− name="branches"
36− options={{
37− animation: "slide_from_right",
38− headerShown: true,
39− headerTitle: "Branches & worktrees",
40− headerBackTitle: "",
41− headerShadowVisible: false,
42− headerStyle: headerBg,
43− }}
44− />
45− <Stack.Screen
46− name="review"
47− options={{
48− animation: "slide_from_right",
49− headerShown: true,
50− headerTitle: "Review changes",
51− headerBackTitle: "",
52− headerShadowVisible: false,
53− headerStyle: headerBg,
54− }}
55− />
56− </Stack>
57− );
58−}
apps/mobile/src/app/threads/[environmentId]/[threadId]/git/branches.tsx+0−5
@@ -1,5 +0,0 @@
1−import { GitBranchesSheet } from "../../../../../features/threads/git/GitBranchesSheet";
2−
3−export default function GitBranchesRoute() {
4− return <GitBranchesSheet />;
5−}
apps/mobile/src/app/threads/[environmentId]/[threadId]/git/commit.tsx+0−5
@@ -1,5 +0,0 @@
1−import { GitCommitSheet } from "../../../../../features/threads/git/GitCommitSheet";
2−
3−export default function GitCommitRoute() {
4− return <GitCommitSheet />;
5−}
apps/mobile/src/app/threads/[environmentId]/[threadId]/git/index.tsx+0−5
@@ -1,5 +0,0 @@
1−import { GitOverviewSheet } from "../../../../../features/threads/git/GitOverviewSheet";
2−
3−export default function GitRoute() {
4− return <GitOverviewSheet />;
5−}
apps/mobile/src/app/threads/[environmentId]/[threadId]/git/review.tsx+0−18
@@ -1,18 +0,0 @@
1−import { Redirect, useLocalSearchParams } from "expo-router";
2−import type { EnvironmentId, ThreadId } from "@t3tools/contracts";
3−
4−export default function ReviewRoute() {
5− const { environmentId, threadId } = useLocalSearchParams<{
6− environmentId: EnvironmentId;
7− threadId: ThreadId;
8− }>();
9−
10− return (
11− <Redirect
12− href={{
13− pathname: "/threads/[environmentId]/[threadId]/review",
14− params: { environmentId, threadId },
15− }}
16− />
17− );
18−}
apps/mobile/src/app/threads/[environmentId]/[threadId]/index.tsx+0−5
@@ -1,5 +0,0 @@
1−import { ThreadRouteScreen } from "../../../../features/threads/ThreadRouteScreen";
2−
3−export default function ThreadRoute() {
4− return <ThreadRouteScreen />;
5−}
apps/mobile/src/app/threads/[environmentId]/[threadId]/review-comment.tsx+0−5
@@ -1,5 +0,0 @@
1−import { ReviewCommentComposerSheet } from "../../../../features/review/ReviewCommentComposerSheet";
2−
3−export default function ReviewCommentRoute() {
4− return <ReviewCommentComposerSheet />;
5−}
apps/mobile/src/app/threads/[environmentId]/[threadId]/review.tsx+0−10
@@ -1,10 +0,0 @@
1−import { ReviewHighlighterProvider } from "../../../../features/review/ReviewHighlighterProvider";
2−import { ReviewSheet } from "../../../../features/review/ReviewSheet";
3−
4−export default function ReviewRoute() {
5− return (
6− <ReviewHighlighterProvider>
7− <ReviewSheet />
8− </ReviewHighlighterProvider>
9− );
10−}
apps/mobile/src/app/threads/[environmentId]/[threadId]/terminal.tsx+0−5
@@ -1,5 +0,0 @@
1−import { ThreadTerminalRouteScreen } from "../../../../features/terminal/ThreadTerminalRouteScreen";
2−
3−export default function ThreadTerminalRoute() {
4− return <ThreadTerminalRouteScreen />;
5−}
apps/mobile/src/components/AppText.tsx+2−3
@@ -1,4 +1,3 @@
1−import type { Ref } from "react";
21 import {
32 Text as RNText,
43 TextInput as RNTextInput,
@@ -21,7 +20,7 @@ export function AppText({ className, ...props }: AppTextProps) { · 16 unmodified lines
2120
2221 export type AppTextInputProps = RNTextInputProps & {
2322 readonly className?: string;
24− readonly ref?: Ref<RNTextInput>;
23+ readonly ref?: React.Ref<RNTextInput>;
2524 };
2625
2726 /**
@@ -40,7 +39,7 @@ export function AppTextInput({ · 12 unmodified lines
4039 <RNTextInput
4140 ref={ref}
4241 className={cn(
43− "min-h-[54px] rounded-2xl border border-input-border bg-input px-3.5 py-3 font-sans text-base text-foreground",
42+ "min-h-13.5 rounded-2xl border border-input-border bg-input px-3.5 py-3 font-sans text-base text-foreground",
4443 className,
4544 )}
4645 placeholderTextColor={placeholderTextColor ?? placeholderColor}
apps/mobile/src/components/ControlPill.tsx+1−1
@@ -75,7 +75,7 @@ export function ControlPill(props: { · 74 unmodified lines
7575 }
7676
7777 export function ControlPillMenu(
78− props: Omit<ComponentProps<typeof MenuView>, "children" | "style" | "themeVariant"> & {
78+ props: Omit<ComponentProps<typeof MenuView>, "children" | "themeVariant"> & {
7979 readonly children: ReactNode;
8080 },
8181 ) {
apps/mobile/src/components/EmptyState.tsx+23−1
@@ -7,11 +7,33 @@ export function EmptyState(props: { · 6 unmodified lines
77 readonly detail: string;
88 readonly actionLabel?: string;
99 readonly onAction?: () => void;
10+ readonly variant?: "card" | "plain";
1011 }) {
12+ if (props.variant === "plain") {
13+ return (
14+ <View className="items-center px-8 py-8">
15+ <Text className="text-center text-xl font-t3-bold text-foreground">{props.title}</Text>
16+ <Text className="mt-2 text-center font-sans text-base leading-normal text-foreground-muted">
17+ {props.detail}
18+ </Text>
19+ {props.actionLabel && props.onAction ? (
20+ <Pressable
21+ className="mt-5 rounded-full bg-primary px-5 py-3 active:opacity-70"
22+ onPress={props.onAction}
23+ >
24+ <Text className="text-sm font-t3-bold text-primary-foreground">
25+ {props.actionLabel}
26+ </Text>
27+ </Pressable>
28+ ) : null}
29+ </View>
30+ );
31+ }
32+
1133 return (
1234 <View className="rounded-[22px] border border-border bg-card p-5">
1335 <Text className="font-t3-bold text-lg text-foreground">{props.title}</Text>
14− <Text className="mt-2 font-sans text-sm leading-[21px] text-foreground-muted">
36+ <Text className="mt-2 font-sans text-sm leading-relaxed text-foreground-muted">
1537 {props.detail}
1638 </Text>
1739 {props.actionLabel && props.onAction ? (
apps/mobile/src/components/ErrorBanner.tsx+1−1
@@ -4,7 +4,7 @@ import { AppText as Text } from "./AppText"; · 3 unmodified lines
44 export function ErrorBanner(props: { readonly message: string }) {
55 return (
66 <View className="rounded-2xl border border-rose-300/70 bg-rose-100/80 px-3.5 py-3 dark:border-rose-400/28 dark:bg-rose-500/12">
7− <Text className="font-t3-medium text-sm leading-[18px] text-rose-700 dark:text-rose-300">
7+ <Text className="font-t3-medium text-sm text-rose-700 dark:text-rose-300">
88 {props.message}
99 </Text>
1010 </View>
apps/mobile/src/features/agent-awareness/notificationNavigation.ts+4−4
@@ -1,12 +1,12 @@
11 import { useEffect, useRef } from "react";
22 import * as Notifications from "expo-notifications";
3−import { useRouter } from "expo-router";
3+import { useLinkTo } from "@react-navigation/native";
44
55 import { routeAgentNotificationResponseOnce } from "./notificationPayload";
66 import { consumeLastAgentNotificationResponse } from "./notificationResponseConsumer";
77
88 export function useAgentNotificationNavigation(): void {
9− const router = useRouter();
9+ const linkTo = useLinkTo();
1010 const handledResponseIds = useRef(new Set<string>());
1111
1212 useEffect(() => {
@@ -14,7 +14,7 @@ export function useAgentNotificationNavigation(): void { · 1 unmodified line
1414 routeAgentNotificationResponseOnce({
1515 handledResponseIds: handledResponseIds.current,
1616 response,
17− navigate: (deepLink) => router.push(deepLink as never),
17+ navigate: linkTo,
1818 });
1919 };
2020
@@ -28,5 +28,5 @@ export function useAgentNotificationNavigation(): void { · 7 unmodified lines
2828 return () => {
2929 subscription.remove();
3030 };
31− }, [router]);
31+ }, [linkTo]);
3232 }
apps/mobile/src/features/archive/ArchivedThreadsRouteScreen.tsx+1−1
@@ -1,7 +1,7 @@
11 import type { EnvironmentId } from "@t3tools/contracts";
22 import * as Arr from "effect/Array";
33 import * as Order from "effect/Order";
4−import { useFocusEffect } from "expo-router";
4+import { useFocusEffect } from "@react-navigation/native";
55 import { useCallback, useMemo, useState } from "react";
66
77 import { 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
22 EnvironmentProject,
33 EnvironmentThreadShell,
44 } from "@t3tools/client-runtime/state/shell";
5+import { LegendList } from "@legendapp/list/react-native";
56 import type { EnvironmentId } from "@t3tools/contracts";
67 import type { MenuAction } from "@react-native-menu/menu";
7−import * as Haptics from "expo-haptics";
8−import { Stack } from "expo-router";
8+import { NativeHeaderToolbar, NativeStackScreenOptions } from "../../native/StackHeader";
99 import { SymbolView } from "expo-symbols";
10−import { useCallback, useRef } from "react";
10+import { useCallback, useMemo, useRef, type ComponentProps } from "react";
1111 import {
1212 ActivityIndicator,
13+ Platform,
1314 Pressable,
1415 RefreshControl,
15− ScrollView,
1616 useWindowDimensions,
1717 View,
1818 } from "react-native";
19−import ReanimatedSwipeable, {
20− type SwipeableMethods,
21−} from "react-native-gesture-handler/ReanimatedSwipeable";
19+import { Gesture, GestureDetector } from "react-native-gesture-handler";
20+import type { SwipeableMethods } from "react-native-gesture-handler/ReanimatedSwipeable";
2221
2322 import { AppText as Text } from "../../components/AppText";
2423 import { ControlPillMenu } from "../../components/ControlPill";
@@ -26,11 +25,8 @@ import { EmptyState } from "../../components/EmptyState"; · 1 unmodified line
2625 import { ProjectFavicon } from "../../components/ProjectFavicon";
2726 import { relativeTime } from "../../lib/time";
2827 import { useThemeColor } from "../../lib/useThemeColor";
29−import {
30− THREAD_SWIPE_ACTIONS_WIDTH,
31− THREAD_SWIPE_SPRING,
32− ThreadSwipeActions,
33−} from "../home/thread-swipe-actions";
28+import { ThreadSwipeable } from "../home/thread-swipe-actions";
29+import { createNativeMailSearchToolbarItem } from "../layout/native-mail-search-toolbar";
3430 import type { ArchivedThreadGroup, ArchivedThreadSortOrder } from "./archivedThreadList";
3531
3632 export interface ArchivedThreadsHeaderEnvironment {
@@ -52,84 +48,185 @@ const THREAD_ACTIONS: MenuAction[] = [ · 15 unmodified lines
5248 },
5349 ];
5450
51+type ArchivedThreadListItem =
52+ | {
53+ readonly kind: "project";
54+ readonly key: string;
55+ readonly environmentLabel: string | null;
56+ readonly project: EnvironmentProject;
57+ }
58+ | {
59+ readonly kind: "thread";
60+ readonly key: string;
61+ readonly environmentLabel: string | null;
62+ readonly isFirst: boolean;
63+ readonly isLast: boolean;
64+ readonly thread: EnvironmentThreadShell;
65+ };
66+
5567 function ArchivedThreadsHeader(props: {
5668 readonly environments: ReadonlyArray<ArchivedThreadsHeaderEnvironment>;
5769 readonly selectedEnvironmentId: EnvironmentId | null;
5870 readonly sortOrder: ArchivedThreadSortOrder;
5971 readonly onEnvironmentChange: (environmentId: EnvironmentId | null) => void;
72+ readonly onRefresh: () => void;
6073 readonly onSearchQueryChange: (query: string) => void;
6174 readonly onSortOrderChange: (sortOrder: ArchivedThreadSortOrder) => void;
6275 }) {
76+ const { width } = useWindowDimensions();
6377 const hasCustomFilter = props.selectedEnvironmentId !== null || props.sortOrder !== "newest";
78+ const usesNativeChrome = Platform.OS === "ios";
79+ const usesCompactMailToolbar = Platform.OS === "ios" && width < 700;
80+ const archiveFilterMenu = {
81+ title: "Archived thread options",
82+ items: [
83+ {
84+ type: "submenu" as const,
85+ title: "Environment",
86+ items: [
87+ {
88+ type: "action" as const,
89+ title: "All environments",
90+ state: props.selectedEnvironmentId === null ? ("on" as const) : ("off" as const),
91+ onPress: () => props.onEnvironmentChange(null),
92+ },
93+ ...props.environments.map((environment) => ({
94+ type: "action" as const,
95+ title: environment.label,
96+ state:
97+ props.selectedEnvironmentId === environment.environmentId
98+ ? ("on" as const)
99+ : ("off" as const),
100+ onPress: () => props.onEnvironmentChange(environment.environmentId),
101+ })),
102+ ],
103+ },
104+ {
105+ type: "submenu" as const,
106+ title: "Sort by archived date",
107+ items: [
108+ {
109+ type: "action" as const,
110+ title: "Newest first",
111+ state: props.sortOrder === "newest" ? ("on" as const) : ("off" as const),
112+ onPress: () => props.onSortOrderChange("newest"),
113+ },
114+ {
115+ type: "action" as const,
116+ title: "Oldest first",
117+ state: props.sortOrder === "oldest" ? ("on" as const) : ("off" as const),
118+ onPress: () => props.onSortOrderChange("oldest"),
119+ },
120+ ],
121+ },
122+ ],
123+ };
64124
65125 return (
66126 <>
67− <Stack.Screen
127+ {/* Static header config (glass preset + title) lives in Stack.tsx; only
128+ dynamic toolbar/search wiring is set here. */}
129+ <NativeStackScreenOptions
68130 options={{
69− title: "Archived Threads",
70− headerSearchBarOptions: {
71− autoCapitalize: "none",
72− hideNavigationBar: false,
73− obscureBackground: false,
74− placeholder: "Search archived threads",
75− placement: "stacked",
76− onChangeText: (event) => {
77− props.onSearchQueryChange(event.nativeEvent.text);
78− },
79− onCancelButtonPress: () => {
80− props.onSearchQueryChange("");
81− },
82− },
131+ unstable_headerToolbarItems: usesCompactMailToolbar
132+ ? () => [
133+ createNativeMailSearchToolbarItem({
134+ composeButtonId: "archived-refresh",
135+ composeSystemImageName: "arrow.clockwise",
136+ filterMenu: archiveFilterMenu,
137+ filterButtonId: "archived-filter",
138+ filterSystemImageName: hasCustomFilter
139+ ? "line.3.horizontal.decrease.circle.fill"
140+ : "line.3.horizontal.decrease",
141+ onComposePress: props.onRefresh,
142+ onSearchTextChange: props.onSearchQueryChange,
143+ placeholder: "Search",
144+ searchTextChangeId: "archived-search-text",
145+ }),
146+ ]
147+ : undefined,
148+ headerSearchBarOptions: usesCompactMailToolbar
149+ ? undefined
150+ : {
151+ ...(usesNativeChrome
152+ ? {
153+ allowToolbarIntegration: true,
154+ placement: "integratedButton" as const,
155+ }
156+ : {
157+ placement: "stacked" as const,
158+ }),
159+ autoCapitalize: "none",
160+ hideNavigationBar: false,
161+ obscureBackground: false,
162+ placeholder: "Search archived threads",
163+ onChangeText: (event) => {
164+ props.onSearchQueryChange(event.nativeEvent.text);
165+ },
166+ onCancelButtonPress: () => {
167+ props.onSearchQueryChange("");
168+ },
169+ },
83170 }}
84171 />
85172
86− <Stack.Toolbar placement="right">
87− <Stack.Toolbar.Menu
88− accessibilityLabel="Filter and sort archived threads"
89− icon={
90− hasCustomFilter
91− ? "line.3.horizontal.decrease.circle.fill"
92− : "line.3.horizontal.decrease.circle"
93− }
94− separateBackground
95− title="Archived thread options"
96− >
97− <Stack.Toolbar.Menu title="Environment">
98− <Stack.Toolbar.Label>Environment</Stack.Toolbar.Label>
99− <Stack.Toolbar.MenuAction
100− isOn={props.selectedEnvironmentId === null}
101− onPress={() => props.onEnvironmentChange(null)}
102− >
103− <Stack.Toolbar.Label>All environments</Stack.Toolbar.Label>
104− </Stack.Toolbar.MenuAction>
105− {props.environments.map((environment) => (
106− <Stack.Toolbar.MenuAction
107− key={environment.environmentId}
108− isOn={props.selectedEnvironmentId === environment.environmentId}
109− onPress={() => props.onEnvironmentChange(environment.environmentId)}
173+ {usesCompactMailToolbar ? null : (
174+ <NativeHeaderToolbar placement="right">
175+ {usesNativeChrome ? (
176+ <NativeHeaderToolbar.Button
177+ accessibilityLabel="Refresh archived threads"
178+ icon="arrow.clockwise"
179+ onPress={props.onRefresh}
180+ separateBackground
181+ />
182+ ) : null}
183+ <NativeHeaderToolbar.Menu
184+ accessibilityLabel="Filter and sort archived threads"
185+ icon={
186+ hasCustomFilter
187+ ? "line.3.horizontal.decrease.circle.fill"
188+ : "line.3.horizontal.decrease.circle"
189+ }
190+ separateBackground
191+ title="Archived thread options"
192+ >
193+ <NativeHeaderToolbar.Menu title="Environment">
194+ <NativeHeaderToolbar.Label>Environment</NativeHeaderToolbar.Label>
195+ <NativeHeaderToolbar.MenuAction
196+ isOn={props.selectedEnvironmentId === null}
197+ onPress={() => props.onEnvironmentChange(null)}
110198 >
111− <Stack.Toolbar.Label>{environment.label}</Stack.Toolbar.Label>
112− </Stack.Toolbar.MenuAction>
113− ))}
114− </Stack.Toolbar.Menu>
199+ <NativeHeaderToolbar.Label>All environments</NativeHeaderToolbar.Label>
200+ </NativeHeaderToolbar.MenuAction>
201+ {props.environments.map((environment) => (
202+ <NativeHeaderToolbar.MenuAction
203+ key={environment.environmentId}
204+ isOn={props.selectedEnvironmentId === environment.environmentId}
205+ onPress={() => props.onEnvironmentChange(environment.environmentId)}
206+ >
207+ <NativeHeaderToolbar.Label>{environment.label}</NativeHeaderToolbar.Label>
208+ </NativeHeaderToolbar.MenuAction>
209+ ))}
210+ </NativeHeaderToolbar.Menu>
115211
116− <Stack.Toolbar.Menu title="Sort by archived date">
117− <Stack.Toolbar.Label>Sort by archived date</Stack.Toolbar.Label>
118− <Stack.Toolbar.MenuAction
119− isOn={props.sortOrder === "newest"}
120− onPress={() => props.onSortOrderChange("newest")}
121− >
122− <Stack.Toolbar.Label>Newest first</Stack.Toolbar.Label>
123− </Stack.Toolbar.MenuAction>
124− <Stack.Toolbar.MenuAction
125− isOn={props.sortOrder === "oldest"}
126− onPress={() => props.onSortOrderChange("oldest")}
127− >
128− <Stack.Toolbar.Label>Oldest first</Stack.Toolbar.Label>
129− </Stack.Toolbar.MenuAction>
130− </Stack.Toolbar.Menu>
131− </Stack.Toolbar.Menu>
132− </Stack.Toolbar>
212+ <NativeHeaderToolbar.Menu title="Sort by archived date">
213+ <NativeHeaderToolbar.Label>Sort by archived date</NativeHeaderToolbar.Label>
214+ <NativeHeaderToolbar.MenuAction
215+ isOn={props.sortOrder === "newest"}
216+ onPress={() => props.onSortOrderChange("newest")}
217+ >
218+ <NativeHeaderToolbar.Label>Newest first</NativeHeaderToolbar.Label>
219+ </NativeHeaderToolbar.MenuAction>
220+ <NativeHeaderToolbar.MenuAction
221+ isOn={props.sortOrder === "oldest"}
222+ onPress={() => props.onSortOrderChange("oldest")}
223+ >
224+ <NativeHeaderToolbar.Label>Oldest first</NativeHeaderToolbar.Label>
225+ </NativeHeaderToolbar.MenuAction>
226+ </NativeHeaderToolbar.Menu>
227+ </NativeHeaderToolbar.Menu>
228+ </NativeHeaderToolbar>
229+ )}
133230 </>
134231 );
135232 }
@@ -164,30 +261,25 @@ function ProjectGroupLabel(props: { · 28 unmodified lines
164261
165262 function ArchivedThreadRow(props: {
166263 readonly environmentLabel: string | null;
264+ readonly isFirst: boolean;
167265 readonly isLast: boolean;
168266 readonly onDelete: () => void;
169267 readonly onSwipeableClose: (methods: SwipeableMethods) => void;
170268 readonly onSwipeableWillOpen: (methods: SwipeableMethods) => void;
269+ readonly simultaneousSwipeGesture?: ComponentProps<
270+ typeof ThreadSwipeable
271+ >["simultaneousWithExternalGesture"];
171272 readonly onUnarchive: () => void;
172273 readonly thread: EnvironmentThreadShell;
173274 }) {
174− const swipeableRef = useRef<SwipeableMethods | null>(null);
175− const fullSwipeArmedRef = useRef(false);
176275 const { width: windowWidth } = useWindowDimensions();
177276 const cardColor = useThemeColor("--color-card");
178277 const iconColor = useThemeColor("--color-icon-subtle");
179278 const separatorColor = useThemeColor("--color-separator");
180− const fullSwipeThreshold = Math.max(THREAD_SWIPE_ACTIONS_WIDTH + 44, (windowWidth - 32) * 0.58);
181279 const timestamp = relativeTime(props.thread.archivedAt ?? props.thread.updatedAt);
182280 const subtitle = [props.environmentLabel, props.thread.branch].filter((part): part is string =>
183281 Boolean(part),
184282 );
185− const handleFullSwipeArmedChange = useCallback((armed: boolean) => {
186− if (armed && !fullSwipeArmedRef.current && process.env.EXPO_OS === "ios") {
187− void Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Medium);
188− }
189− fullSwipeArmedRef.current = armed;
190− }, []);
191283 const handleMenuAction = useCallback(
192284 (event: { nativeEvent: { event: string } }) => {
193285 if (event.nativeEvent.event === "unarchive") {
@@ -200,114 +292,84 @@ function ArchivedThreadRow(props: { · 6 unmodified lines
200292 );
201293
202294 return (
203− <ReanimatedSwipeable
204− ref={swipeableRef}
205− animationOptions={THREAD_SWIPE_SPRING}
206− childrenContainerStyle={{ backgroundColor: cardColor }}
207− containerStyle={{ backgroundColor: cardColor }}
208− dragOffsetFromRightEdge={8}
209− enableTrackpadTwoFingerGesture
210− friction={1}
211− onSwipeableClose={() => {
212− fullSwipeArmedRef.current = false;
213− if (swipeableRef.current) {
214− props.onSwipeableClose(swipeableRef.current);
215− }
216− }}
217− onSwipeableOpenStartDrag={() => {
218− if (swipeableRef.current) {
219− props.onSwipeableWillOpen(swipeableRef.current);
220− }
295+ <ThreadSwipeable
296+ backgroundColor={cardColor}
297+ fullSwipeWidth={windowWidth - 32}
298+ onDelete={props.onDelete}
299+ onSwipeableClose={props.onSwipeableClose}
300+ onSwipeableWillOpen={props.onSwipeableWillOpen}
301+ primaryAction={{
302+ accessibilityLabel: `Unarchive ${props.thread.title}`,
303+ icon: "arrow.uturn.backward",
304+ label: "Unarchive",
305+ onPress: props.onUnarchive,
221306 }}
222− onSwipeableWillOpen={() => {
223− const methods = swipeableRef.current;
224− if (!methods) return;
225−
226− props.onSwipeableWillOpen(methods);
227− if (fullSwipeArmedRef.current) {
228− fullSwipeArmedRef.current = false;
229− methods.close();
230− props.onDelete();
231− }
232− }}
233− overshootFriction={1}
234− overshootRight
235− renderRightActions={(_progress, translation, methods) => (
236− <ThreadSwipeActions
237− backgroundColor={cardColor}
238− fullSwipeThreshold={fullSwipeThreshold}
239− onDelete={props.onDelete}
240− onFullSwipeArmedChange={handleFullSwipeArmedChange}
241− primaryAction={{
242− accessibilityLabel: `Unarchive ${props.thread.title}`,
243− icon: "arrow.uturn.backward",
244− label: "Unarchive",
245− onPress: props.onUnarchive,
246− }}
247− swipeableMethods={methods}
248− threadTitle={props.thread.title}
249− translation={translation}
250− />
251− )}
252− rightThreshold={THREAD_SWIPE_ACTIONS_WIDTH * 0.42}
307+ simultaneousWithExternalGesture={props.simultaneousSwipeGesture}
308+ threadTitle={props.thread.title}
253309 >
254− <View
255− className="flex-row items-center gap-3 bg-card px-4 py-3"
256− style={{
257− borderBottomColor: separatorColor,
258− borderBottomWidth: props.isLast ? 0 : 1,
259− }}
260− >
261− <View className="h-[34px] w-[34px] items-center justify-center rounded-[11px] bg-subtle">
262− <SymbolView name="archivebox.fill" size={15} tintColor={iconColor} type="monochrome" />
263− </View>
264−
265− <View className="min-w-0 flex-1 gap-1">
266− <View className="flex-row items-center gap-2">
267− <Text
268− className="min-w-0 flex-1 text-base font-t3-bold leading-[20px] text-foreground"
269− numberOfLines={1}
270− >
271− {props.thread.title}
272− </Text>
273− <Text
274− className="text-xs text-foreground-tertiary"
275− style={{ fontVariant: ["tabular-nums"], minWidth: 30, textAlign: "right" }}
276− >
277− {timestamp}
278− </Text>
310+ {() => (
311+ <View
312+ className="flex-row items-center gap-3 bg-card px-4 py-3"
313+ style={{
314+ borderBottomColor: separatorColor,
315+ borderBottomWidth: props.isLast ? 0 : 1,
316+ borderTopLeftRadius: props.isFirst ? 20 : 0,
317+ borderTopRightRadius: props.isFirst ? 20 : 0,
318+ borderBottomLeftRadius: props.isLast ? 20 : 0,
319+ borderBottomRightRadius: props.isLast ? 20 : 0,
320+ }}
321+ >
322+ <View className="h-[34px] w-[34px] items-center justify-center rounded-[11px] bg-subtle">
323+ <SymbolView name="archivebox.fill" size={15} tintColor={iconColor} type="monochrome" />
279324 </View>
280− {subtitle.length > 0 ? (
281− <View className="flex-row items-center gap-1.5">
282− <SymbolView
283− name="arrow.triangle.branch"
284− size={10}
285− tintColor={iconColor}
286− type="monochrome"
287− />
325+
326+ <View className="min-w-0 flex-1 gap-1">
327+ <View className="flex-row items-center gap-2">
288328 <Text
289− className="min-w-0 flex-1 text-2xs text-foreground-tertiary"
329+ className="min-w-0 flex-1 text-base font-t3-bold leading-snug text-foreground"
290330 numberOfLines={1}
291− style={{ fontFamily: "monospace" }}
292331 >
293− {subtitle.join(" · ")}
332+ {props.thread.title}
333+ </Text>
334+ <Text
335+ className="text-xs text-foreground-tertiary"
336+ style={{ fontVariant: ["tabular-nums"], minWidth: 30, textAlign: "right" }}
337+ >
338+ {timestamp}
294339 </Text>
295340 </View>
296− ) : null}
297− </View>
341+ {subtitle.length > 0 ? (
342+ <View className="flex-row items-center gap-1.5">
343+ <SymbolView
344+ name="arrow.triangle.branch"
345+ size={10}
346+ tintColor={iconColor}
347+ type="monochrome"
348+ />
349+ <Text
350+ className="min-w-0 flex-1 text-2xs text-foreground-tertiary"
351+ numberOfLines={1}
352+ style={{ fontFamily: "monospace" }}
353+ >
354+ {subtitle.join(" · ")}
355+ </Text>
356+ </View>
357+ ) : null}
358+ </View>
298359
299− <ControlPillMenu actions={THREAD_ACTIONS} onPressAction={handleMenuAction}>
300− <Pressable
301− accessibilityLabel={`Actions for ${props.thread.title}`}
302− accessibilityRole="button"
303− className="h-8 w-8 items-center justify-center rounded-full active:bg-subtle"
304− hitSlop={6}
305− >
306− <SymbolView name="ellipsis" size={16} tintColor={iconColor} type="monochrome" />
307− </Pressable>
308− </ControlPillMenu>
309− </View>
310− </ReanimatedSwipeable>
360+ <ControlPillMenu actions={THREAD_ACTIONS} onPressAction={handleMenuAction}>
361+ <Pressable
362+ accessibilityLabel={`Actions for ${props.thread.title}`}
363+ accessibilityRole="button"
364+ className="h-8 w-8 items-center justify-center rounded-full active:bg-subtle"
365+ hitSlop={6}
366+ >
367+ <SymbolView name="ellipsis" size={16} tintColor={iconColor} type="monochrome" />
368+ </Pressable>
369+ </ControlPillMenu>
370+ </View>
371+ )}
372+ </ThreadSwipeable>
311373 );
312374 }
313375
@@ -317,7 +379,7 @@ function ArchiveError(props: { readonly message: string; readonly onRetry: () => · 3 unmodified lines
317379 <Text className="text-base font-t3-bold text-danger-foreground">
318380 Could not load every archive
319381 </Text>
320− <Text className="mt-1 text-sm leading-[18px] text-foreground-muted">{props.message}</Text>
382+ <Text className="mt-1 text-sm text-foreground-muted">{props.message}</Text>
321383 <Pressable className="mt-3 self-start active:opacity-60" onPress={props.onRetry}>
322384 <Text className="text-sm font-t3-bold text-danger-foreground">Try again</Text>
323385 </Pressable>
@@ -340,8 +402,41 @@ export function ArchivedThreadsScreen(props: { · 16 unmodified lines
340402 readonly onSortOrderChange: (sortOrder: ArchivedThreadSortOrder) => void;
341403 readonly onUnarchiveThread: (thread: EnvironmentThreadShell) => void;
342404 }) {
405+ const { onDeleteThread, onUnarchiveThread } = props;
343406 const openSwipeableRef = useRef<SwipeableMethods | null>(null);
407+ const archiveScrollGesture = useMemo(() => Gesture.Native(), []);
344408 const refreshTint = useThemeColor("--color-icon");
409+ const environmentLabelsById = useMemo(
410+ () =>
411+ new Map(
412+ props.environments.map((environment) => [environment.environmentId, environment.label]),
413+ ),
414+ [props.environments],
415+ );
416+ const listItems = useMemo<ReadonlyArray<ArchivedThreadListItem>>(() => {
417+ const items: ArchivedThreadListItem[] = [];
418+ for (const group of props.groups) {
419+ const environmentLabel = environmentLabelsById.get(group.project.environmentId) ?? null;
420+ items.push({
421+ kind: "project",
422+ key: `${group.key}:project`,
423+ environmentLabel,
424+ project: group.project,
425+ });
426+
427+ group.threads.forEach((thread, index) => {
428+ items.push({
429+ kind: "thread",
430+ key: `${thread.environmentId}:${thread.id}`,
431+ environmentLabel,
432+ isFirst: index === 0,
433+ isLast: index === group.threads.length - 1,
434+ thread,
435+ });
436+ });
437+ }
438+ return items;
439+ }, [environmentLabelsById, props.groups]);
345440 const handleSwipeableWillOpen = useCallback((methods: SwipeableMethods) => {
346441 if (openSwipeableRef.current && openSwipeableRef.current !== methods) {
347442 openSwipeableRef.current.close();
@@ -355,82 +450,103 @@ export function ArchivedThreadsScreen(props: { · 7 unmodified lines
355450 }, []);
356451 const isInitialLoad = props.isLoading && props.groups.length === 0 && props.error === null;
357452 const isFiltered = props.searchQuery.trim().length > 0 || props.selectedEnvironmentId !== null;
453+ const renderListItem = useCallback(
454+ ({ item }: { item: ArchivedThreadListItem }) => {
455+ if (item.kind === "project") {
456+ return (
457+ <View className="pt-4">
458+ <ProjectGroupLabel environmentLabel={item.environmentLabel} project={item.project} />
459+ </View>
460+ );
461+ }
462+
463+ return (
464+ <ArchivedThreadRow
465+ environmentLabel={item.environmentLabel}
466+ isFirst={item.isFirst}
467+ isLast={item.isLast}
468+ onDelete={() => onDeleteThread(item.thread)}
469+ onSwipeableClose={handleSwipeableClose}
470+ onSwipeableWillOpen={handleSwipeableWillOpen}
471+ onUnarchive={() => onUnarchiveThread(item.thread)}
472+ simultaneousSwipeGesture={archiveScrollGesture}
473+ thread={item.thread}
474+ />
475+ );
476+ },
477+ [
478+ archiveScrollGesture,
479+ handleSwipeableClose,
480+ handleSwipeableWillOpen,
481+ onDeleteThread,
482+ onUnarchiveThread,
483+ ],
484+ );
485+ const listEmptyComponent = useMemo(() => {
486+ if (isInitialLoad) {
487+ return (
488+ <View className="items-center py-16">
489+ <ActivityIndicator color={refreshTint} />
490+ <Text className="mt-3 text-sm text-foreground-muted">Loading archive...</Text>
491+ </View>
492+ );
493+ }
494+
495+ return (
496+ <EmptyState
497+ detail={
498+ isFiltered
499+ ? "Try another search or environment."
500+ : "Threads you archive will appear here."
501+ }
502+ title={isFiltered ? "No matching threads" : "No archived threads"}
503+ />
504+ );
505+ }, [isFiltered, isInitialLoad, refreshTint]);
358506
359507 return (
360508 <View className="flex-1 bg-sheet">
361509 <ArchivedThreadsHeader
362510 environments={props.environments}
363511 onEnvironmentChange={props.onEnvironmentChange}
512+ onRefresh={props.onRefresh}
364513 onSearchQueryChange={props.onSearchQueryChange}
365514 onSortOrderChange={props.onSortOrderChange}
366515 selectedEnvironmentId={props.selectedEnvironmentId}
367516 sortOrder={props.sortOrder}
368517 />
369518
370− <ScrollView
371− className="flex-1"
372− contentContainerStyle={{ gap: 20, paddingBottom: 32, paddingHorizontal: 16, paddingTop: 8 }}
373− contentInsetAdjustmentBehavior="automatic"
374− keyboardDismissMode="on-drag"
375− keyboardShouldPersistTaps="handled"
376− onScrollBeginDrag={() => openSwipeableRef.current?.close()}
377− refreshControl={
378− <RefreshControl
379− onRefresh={props.onRefresh}
380− refreshing={props.isLoading && !isInitialLoad}
381− tintColor={String(refreshTint)}
382− />
383− }
384− showsVerticalScrollIndicator={false}
385− >
386− {props.error ? <ArchiveError message={props.error} onRetry={props.onRefresh} /> : null}
387−
388− {isInitialLoad ? (
389− <View className="items-center py-16">
390− <ActivityIndicator color={refreshTint} />
391− <Text className="mt-3 text-sm text-foreground-muted">Loading archive…</Text>
392− </View>
393− ) : props.groups.length === 0 ? (
394− <EmptyState
395− detail={
396− isFiltered
397− ? "Try another search or environment."
398− : "Threads you archive will appear here."
399− }
400− title={isFiltered ? "No matching threads" : "No archived threads"}
401− />
402− ) : (
403− props.groups.map((group) => {
404− const environmentLabel =
405− props.environments.find(
406− (environment) => environment.environmentId === group.project.environmentId,
407− )?.label ?? null;
408−
409− return (
410− <View key={group.key} collapsable={false}>
411− <ProjectGroupLabel environmentLabel={environmentLabel} project={group.project} />
412− <View
413− className="overflow-hidden rounded-[20px] bg-card"
414− style={{ borderCurve: "continuous" }}
415− >
416− {group.threads.map((thread, index) => (
417− <ArchivedThreadRow
418− key={`${thread.environmentId}:${thread.id}`}
419− environmentLabel={environmentLabel}
420− isLast={index === group.threads.length - 1}
421− onDelete={() => props.onDeleteThread(thread)}
422− onSwipeableClose={handleSwipeableClose}
423− onSwipeableWillOpen={handleSwipeableWillOpen}
424− onUnarchive={() => props.onUnarchiveThread(thread)}
425− thread={thread}
426− />
427− ))}
428− </View>
429− </View>
430− );
431− })
432− )}
433− </ScrollView>
519+ <GestureDetector gesture={archiveScrollGesture}>
520+ <LegendList
521+ className="flex-1"
522+ contentContainerStyle={{
523+ paddingBottom: 32,
524+ paddingHorizontal: 16,
525+ paddingTop: 4,
526+ }}
527+ contentInsetAdjustmentBehavior="automatic"
528+ data={listItems}
529+ estimatedItemSize={62}
530+ getItemType={(item) => item.kind}
531+ keyboardDismissMode="on-drag"
532+ keyboardShouldPersistTaps="handled"
533+ keyExtractor={(item) => item.key}
534+ ListEmptyComponent={listEmptyComponent}
535+ ListHeaderComponent={
536+ props.error ? <ArchiveError message={props.error} onRetry={props.onRefresh} /> : null
537+ }
538+ onScrollBeginDrag={() => openSwipeableRef.current?.close()}
539+ refreshControl={
540+ <RefreshControl
541+ onRefresh={props.onRefresh}
542+ refreshing={props.isLoading && !isInitialLoad}
543+ tintColor={String(refreshTint)}
544+ />
545+ }
546+ renderItem={renderListItem}
547+ showsVerticalScrollIndicator={false}
548+ />
549+ </GestureDetector>
434550 </View>
435551 );
436552 }
apps/mobile/src/features/cloud/CloudWaitlistEnrollment.tsx+11−43
@@ -2,7 +2,6 @@ import { useWaitlist } from "@clerk/expo"; · 1 unmodified line
22 import { ActivityIndicator, Pressable, StyleSheet, Text, TextInput, View } from "react-native";
33 import { useState } from "react";
44
5−import { MOBILE_TYPOGRAPHY } from "../../lib/typography";
65 import { useThemeColor } from "../../lib/useThemeColor";
76 import { CloudWaitlistJoinRejectedError, joinCloudWaitlist } from "./cloudWaitlistJoin";
87
@@ -36,8 +35,10 @@ export function CloudWaitlistEnrollment(props: { readonly onSignIn: () => void } · 27 unmodified lines
3635 if (waitlist.id) {
3736 return (
3837 <View style={styles.content}>
39− <Text style={[styles.title, { color: colors.foreground }]}>You are on the waitlist</Text>
40− <Text style={[styles.body, styles.confirmationBody, { color: colors.secondaryForeground }]}>
38+ <Text className="text-center font-t3-bold text-xl text-foreground">
39+ You are on the waitlist
40+ </Text>
41+ <Text className="text-center font-sans text-base text-foreground-secondary">
4142 We will email you when your T3 Cloud access is ready.
4243 </Text>
4344 <SignInAction onPress={props.onSignIn} />
@@ -47,17 +48,18 @@ export function CloudWaitlistEnrollment(props: { readonly onSignIn: () => void } · 3 unmodified lines
4748
4849 return (
4950 <View style={styles.content}>
50− <Text style={[styles.body, { color: colors.secondaryForeground }]}>
51+ <Text className="font-sans text-base text-foreground-secondary">
5152 Enter your email and we will let you know when access is ready.
5253 </Text>
5354
5455 <View style={styles.field}>
55− <Text style={[styles.label, { color: colors.secondaryForeground }]}>Email address</Text>
56+ <Text className="font-t3-bold text-sm text-foreground-secondary">Email address</Text>
5657 <TextInput
5758 accessibilityLabel="Email address"
5859 autoCapitalize="none"
5960 autoComplete="email"
6061 autoCorrect={false}
62+ className="font-sans text-lg text-foreground"
6163 keyboardType="email-address"
6264 onChangeText={(value) => {
6365 setEmailAddress(value);
@@ -73,7 +75,6 @@ export function CloudWaitlistEnrollment(props: { readonly onSignIn: () => void } · 9 unmodified lines
7375 backgroundColor: colors.input,
7476 borderColor:
7577 fieldError || requestError ? colors.dangerForeground : colors.inputBorder,
76− color: colors.foreground,
7778 },
7879 ]}
7980 textContentType="emailAddress"
@@ -82,7 +83,7 @@ export function CloudWaitlistEnrollment(props: { readonly onSignIn: () => void } · 2 unmodified lines
8283 {fieldError || requestError ? (
8384 <Text
8485 accessibilityLiveRegion="polite"
85− style={[styles.error, { color: colors.dangerForeground }]}
86+ className="font-sans text-sm text-danger-foreground"
8687 selectable
8788 >
8889 {fieldError ?? requestError}
@@ -107,7 +108,7 @@ export function CloudWaitlistEnrollment(props: { readonly onSignIn: () => void } · 18 unmodified lines
107108 ]}
108109 >
109110 {isSubmitting ? <ActivityIndicator color={colors.primaryForeground} size="small" /> : null}
110− <Text style={[styles.buttonText, { color: colors.primaryForeground }]}>
111+ <Text className="font-t3-bold text-base text-primary-foreground">
111112 {isSubmitting ? "Joining" : "Join the waitlist"}
112113 </Text>
113114 </Pressable>
@@ -118,12 +119,11 @@ export function CloudWaitlistEnrollment(props: { readonly onSignIn: () => void } · 4 unmodified lines
118119 }
119120
120121 function SignInAction(props: { readonly onPress: () => void }) {
121− const colors = useCloudWaitlistColors();
122122 return (
123123 <View style={styles.signInRow}>
124− <Text style={[styles.body, { color: colors.secondaryForeground }]}>Already have access?</Text>
124+ <Text className="font-sans text-base text-foreground-secondary">Already have access?</Text>
125125 <Pressable accessibilityRole="button" hitSlop={8} onPress={props.onPress}>
126− <Text style={[styles.signInText, { color: colors.foreground }]}>Sign in</Text>
126+ <Text className="font-t3-bold text-base text-foreground">Sign in</Text>
127127 </Pressable>
128128 </View>
129129 );
@@ -132,35 +132,18 @@ function SignInAction(props: { readonly onPress: () => void }) { · 2 unmodified lines
132132 function useCloudWaitlistColors() {
133133 return {
134134 dangerForeground: String(useThemeColor("--color-danger-foreground")),
135− foreground: String(useThemeColor("--color-foreground")),
136135 input: String(useThemeColor("--color-input")),
137136 inputBorder: String(useThemeColor("--color-input-border")),
138137 placeholder: String(useThemeColor("--color-placeholder")),
139138 primary: String(useThemeColor("--color-primary")),
140139 primaryForeground: String(useThemeColor("--color-primary-foreground")),
141− secondaryForeground: String(useThemeColor("--color-foreground-secondary")),
142140 };
143141 }
144142
145143 const styles = StyleSheet.create({
146− body: {
147− fontFamily: "DMSans_400Regular",
148− ...MOBILE_TYPOGRAPHY.body,
149− },
150− buttonText: {
151− fontFamily: "DMSans_700Bold",
152− fontSize: MOBILE_TYPOGRAPHY.body.fontSize,
153− },
154144 content: {
155145 gap: 18,
156146 },
157− confirmationBody: {
158− textAlign: "center",
159− },
160− error: {
161− fontFamily: "DMSans_400Regular",
162− ...MOBILE_TYPOGRAPHY.footnote,
163− },
164147 field: {
165148 gap: 8,
166149 },
@@ -168,16 +151,10 @@ const styles = StyleSheet.create({ · 1 unmodified line
168151 borderCurve: "continuous",
169152 borderRadius: 16,
170153 borderWidth: 1,
171− fontFamily: "DMSans_400Regular",
172− fontSize: MOBILE_TYPOGRAPHY.headline.fontSize,
173154 minHeight: 54,
174155 paddingHorizontal: 16,
175156 paddingVertical: 14,
176157 },
177− label: {
178− fontFamily: "DMSans_700Bold",
179− ...MOBILE_TYPOGRAPHY.footnote,
180− },
181158 primaryButton: {
182159 alignItems: "center",
183160 borderRadius: 999,
@@ -195,13 +172,4 @@ const styles = StyleSheet.create({ · 11 unmodified lines
195172 justifyContent: "center",
196173 paddingTop: 4,
197174 },
198− signInText: {
199− fontFamily: "DMSans_700Bold",
200− ...MOBILE_TYPOGRAPHY.body,
201− },
202− title: {
203− fontFamily: "DMSans_700Bold",
204− ...MOBILE_TYPOGRAPHY.title,
205− textAlign: "center",
206− },
207175 });
apps/mobile/src/features/cloud/dpop.ts+41−9
@@ -8,12 +8,7 @@ import * as Schema from "effect/Schema"; · 7 unmodified lines
88 import * as ExpoCrypto from "expo-crypto";
99 import * as SecureStore from "expo-secure-store";
1010 import { p256 } from "@noble/curves/nist";
11−import {
12− computeDpopAccessTokenHash,
13− computeDpopJwkThumbprint,
14− DpopPublicJwk,
15− normalizeDpopHtu,
16−} from "@t3tools/shared/dpop";
11+import { DpopPublicJwk, normalizeDpopHtu } from "@t3tools/shared/dpopCommon";
1712 import * as Layer from "effect/Layer";
1813
1914 export class CloudDpopError extends Data.TaggedError("CloudDpopError")<{
@@ -107,6 +102,40 @@ function sha256Digest( · 87 unmodified lines
107102 );
108103 }
109104
105+function base64UrlSha256(
106+ data: Uint8Array,
107+ message: string,
108+): Effect.Effect<string, CloudDpopError, Crypto.Crypto> {
109+ return sha256Digest(data, message).pipe(Effect.map(Encoding.encodeBase64Url));
110+}
111+
112+function dpopThumbprintInput(jwk: DpopPublicJwk): string {
113+ return JSON.stringify({
114+ crv: jwk.crv,
115+ kty: jwk.kty,
116+ x: jwk.x,
117+ y: jwk.y,
118+ });
119+}
120+
121+function computeDpopJwkThumbprintEffect(
122+ jwk: DpopPublicJwk,
123+): Effect.Effect<string, CloudDpopError, Crypto.Crypto> {
124+ return base64UrlSha256(
125+ new TextEncoder().encode(dpopThumbprintInput(jwk)),
126+ "Could not hash the DPoP public key thumbprint.",
127+ );
128+}
129+
130+function computeDpopAccessTokenHashEffect(
131+ accessToken: string,
132+): Effect.Effect<string, CloudDpopError, Crypto.Crypto> {
133+ return base64UrlSha256(
134+ new TextEncoder().encode(accessToken),
135+ "Could not hash the DPoP access token.",
136+ );
137+}
138+
110139 function secureRandomBytes(
111140 byteCount: number,
112141 message: string,
@@ -153,7 +182,7 @@ export function generateDpopProofKeyPair(): Effect.Effect< · 40 unmodified lines
153182 try: () => publicJwkFromUncompressedPublicKey(p256.getPublicKey(privateKey, false)),
154183 catch: cloudDpopError("Generated DPoP public key is invalid."),
155184 });
156− const thumbprint = computeDpopJwkThumbprint(publicJwk);
185+ const thumbprint = yield* computeDpopJwkThumbprintEffect(publicJwk);
157186 return {
158187 privateJwk: privateJwkFromPrivateKey(privateKey, publicJwk),
159188 publicJwk,
@@ -189,9 +218,10 @@ export function loadOrCreateDpopProofKeyPair(): Effect.Effect< · 29 unmodified lines
189218 },
190219 catch: cloudDpopError("Stored DPoP proof key is invalid."),
191220 });
221+ const thumbprint = yield* computeDpopJwkThumbprintEffect(restored.publicJwk);
192222 return {
193223 ...restored,
194− thumbprint: computeDpopJwkThumbprint(restored.publicJwk),
224+ thumbprint,
195225 };
196226 }
197227 const generated = yield* generateDpopProofKeyPair();
@@ -243,7 +273,9 @@ export function createDpopProof(input: { · 45 unmodified lines
243273 Effect.map(Encoding.encodeBase64Url),
244274 Effect.mapError(cloudDpopError("Could not encode DPoP proof header.")),
245275 );
246− const ath = input.accessToken ? computeDpopAccessTokenHash(input.accessToken) : null;
276+ const ath = input.accessToken
277+ ? yield* computeDpopAccessTokenHashEffect(input.accessToken)
278+ : null;
247279 const payload = yield* encodeDpopJwtPayloadJson({
248280 htm: input.method.toUpperCase(),
249281 htu,
apps/mobile/src/features/connection/ConnectionEnvironmentRow.tsx+4−4
@@ -76,16 +76,16 @@ export function ConnectionEnvironmentRow(props: { · 75 unmodified lines
7676 />
7777
7878 <View className="flex-1 gap-0.5">
79− <Text className="text-base font-t3-bold leading-[21px] text-foreground" numberOfLines={1}>
79+ <Text className="text-base font-t3-bold leading-snug text-foreground" numberOfLines={1}>
8080 {props.environment.environmentLabel}
8181 </Text>
82− <Text className="text-xs leading-[16px] text-foreground-muted" numberOfLines={1}>
82+ <Text className="text-xs text-foreground-muted" numberOfLines={1}>
8383 {props.environment.displayUrl}
8484 </Text>
8585 {statusLabel ? (
8686 <Text
8787 className={cn(
88− "text-xs leading-[16px]",
88+ "text-xs",
8989 hasConnectionFailure ? "text-rose-500 dark:text-rose-400" : "text-foreground-muted",
9090 )}
9191 numberOfLines={props.expanded ? undefined : 1}
@@ -134,7 +134,7 @@ export function ConnectionEnvironmentRow(props: { · 42 unmodified lines
134134 className="gap-3 px-4 pb-4"
135135 >
136136 {props.environment.isRelayManaged ? (
137− <Text className="text-sm leading-[18px] text-foreground-muted">
137+ <Text className="text-sm text-foreground-muted">
138138 Managed by T3 Cloud. Tunnel details update automatically.
139139 </Text>
140140 ) : (
apps/mobile/src/features/connection/ConnectionsNewRouteScreen.tsxrenamed from apps/mobile/src/app/connections/new.tsx+27−15
@@ -1,5 +1,6 @@
11 import { CameraView, useCameraPermissions } from "expo-camera";
2−import { Stack, useLocalSearchParams, useRouter } from "expo-router";
2+import { NativeHeaderToolbar, NativeStackScreenOptions } from "../../native/StackHeader";
3+import { StackActions, useNavigation, type StaticScreenProps } from "@react-navigation/native";
34 import { AsyncResult } from "effect/unstable/reactivity";
45 import { useCallback, useEffect, useState } from "react";
56 import { Alert, ScrollView, View } from "react-native";
@@ -8,21 +9,26 @@ import { useThemeColor } from "../../lib/useThemeColor"; · 2 unmodified lines
89
910 import { AppText as Text, AppTextInput as TextInput } from "../../components/AppText";
1011 import { ErrorBanner } from "../../components/ErrorBanner";
11−import { dismissRoute } from "../../lib/routes";
12−import { ConnectionSheetButton } from "../../features/connection/ConnectionSheetButton";
13−import { extractPairingUrlFromQrPayload } from "../../features/connection/pairing";
12+import { ConnectionSheetButton } from "./ConnectionSheetButton";
13+import { extractPairingUrlFromQrPayload } from "./pairing";
1414 import { useRemoteConnections } from "../../state/use-remote-environment-registry";
15−import { buildPairingUrl, parsePairingUrl } from "../../features/connection/pairing";
15+import { buildPairingUrl, parsePairingUrl } from "./pairing";
1616
17−export default function ConnectionsNewRouteScreen() {
17+type ConnectionsNewRouteParams = {
18+ readonly mode?: string;
19+};
20+
21+export function ConnectionsNewRouteScreen({
22+ route,
23+}: StaticScreenProps<ConnectionsNewRouteParams | undefined>) {
1824 const {
1925 connectionPairingUrl,
2026 onChangeConnectionPairingUrl,
2127 onConnectPress,
2228 pairingConnectionError,
2329 } = useRemoteConnections();
24− const router = useRouter();
25− const params = useLocalSearchParams<{ mode?: string }>();
30+ const navigation = useNavigation();
31+ const params = route.params ?? {};
2632 const insets = useSafeAreaInsets();
2733 const [hostInput, setHostInput] = useState("");
2834 const [codeInput, setCodeInput] = useState("");
@@ -31,6 +37,7 @@ export default function ConnectionsNewRouteScreen() { · 2 unmodified lines
3137 const [cameraPermission, requestCameraPermission] = useCameraPermissions();
3238 const [scannerLocked, setScannerLocked] = useState(false);
3339
40+ const headerIconColor = useThemeColor("--color-icon");
3441 const placeholderColor = useThemeColor("--color-placeholder");
3542
3643 const connectDisabled = isSubmitting || hostInput.trim().length === 0;
@@ -116,21 +123,25 @@ export default function ConnectionsNewRouteScreen() { · 79 unmodified lines
116123 onChangeConnectionPairingUrl(pairingUrl);
117124 const result = await onConnectPress(pairingUrl);
118125 if (AsyncResult.isSuccess(result)) {
119− dismissRoute(router);
126+ if (navigation.canGoBack()) {
127+ navigation.goBack();
128+ } else {
129+ navigation.dispatch(StackActions.replace("Home"));
130+ }
120131 } else {
121132 setIsSubmitting(false);
122133 }
123− }, [codeInput, hostInput, onChangeConnectionPairingUrl, onConnectPress, router]);
134+ }, [codeInput, hostInput, onChangeConnectionPairingUrl, onConnectPress, navigation]);
124135
125136 return (
126137 <View collapsable={false} className="flex-1 bg-sheet">
127− <Stack.Screen
138+ <NativeStackScreenOptions
128139 options={{
129140 title: showScanner ? "Scan QR Code" : "Add Environment",
130141 }}
131142 />
132− <Stack.Toolbar placement="right">
133− <Stack.Toolbar.Button
143+ <NativeHeaderToolbar placement="right">
144+ <NativeHeaderToolbar.Button
134145 icon={showScanner ? "xmark" : "qrcode.viewfinder"}
135146 onPress={() => {
136147 if (showScanner) {
@@ -140,8 +151,9 @@ export default function ConnectionsNewRouteScreen() { · 3 unmodified lines
140151 }
141152 }}
142153 separateBackground
154+ tintColor={headerIconColor}
143155 />
144− </Stack.Toolbar>
156+ </NativeHeaderToolbar>
145157
146158 <ScrollView
147159 contentInsetAdjustmentBehavior="automatic"
@@ -171,7 +183,7 @@ export default function ConnectionsNewRouteScreen() { · 23 unmodified lines
171183 className="items-center gap-3 rounded-[24px] bg-card px-5 py-8"
172184 style={{ borderCurve: "continuous" }}
173185 >
174− <Text className="text-center text-sm leading-[20px] text-foreground-muted">
186+ <Text className="text-center text-sm leading-normal text-foreground-muted">
175187 Camera permission is required to scan a QR code.
176188 </Text>
177189 <ConnectionSheetButton
apps/mobile/src/features/connection/ConnectionsRouteScreen.tsxrenamed from apps/mobile/src/app/connections/index.tsx+10−14
@@ -1,4 +1,5 @@
1−import { Stack, useRouter } from "expo-router";
1+import { NativeHeaderToolbar } from "../../native/StackHeader";
2+import { useNavigation } from "@react-navigation/native";
23 import { SymbolView } from "expo-symbols";
34 import type { EnvironmentId } from "@t3tools/contracts";
45 import { useCallback, useState } from "react";
@@ -9,16 +10,16 @@ import { useThemeColor } from "../../lib/useThemeColor"; · 4 unmodified lines
910 import { AppText as Text } from "../../components/AppText";
1011 import { cn } from "../../lib/cn";
1112 import { useRemoteConnections } from "../../state/use-remote-environment-registry";
12−import { ConnectionEnvironmentRow } from "../../features/connection/ConnectionEnvironmentRow";
13+import { ConnectionEnvironmentRow } from "./ConnectionEnvironmentRow";
1314
14−export default function ConnectionsRouteScreen() {
15+export function ConnectionsRouteScreen() {
1516 const {
1617 connectedEnvironments,
1718 onReconnectEnvironment,
1819 onRemoveEnvironmentPress,
1920 onUpdateEnvironment,
2021 } = useRemoteConnections();
21− const router = useRouter();
22+ const navigation = useNavigation();
2223 const insets = useSafeAreaInsets();
2324 const hasEnvironments = connectedEnvironments.length > 0;
2425 const [expandedId, setExpandedId] = useState<EnvironmentId | null>(null);
@@ -31,18 +32,13 @@ export default function ConnectionsRouteScreen() { · 6 unmodified lines
3132
3233 return (
3334 <View collapsable={false} className="flex-1 bg-sheet">
34− <Stack.Screen
35− options={{
36− title: "Environments",
37− }}
38− />
39− <Stack.Toolbar placement="right">
40− <Stack.Toolbar.Button
35+ <NativeHeaderToolbar placement="right">
36+ <NativeHeaderToolbar.Button
4137 icon="plus"
42− onPress={() => router.push("/connections/new")}
38+ onPress={() => navigation.navigate("ConnectionsNew")}
4339 separateBackground
4440 />
45− </Stack.Toolbar>
41+ </NativeHeaderToolbar>
4642 <ScrollView
4743 contentInsetAdjustmentBehavior="automatic"
4844 showsVerticalScrollIndicator={false}
@@ -85,7 +81,7 @@ export default function ConnectionsRouteScreen() { · 36 unmodified lines
8581 type="monochrome"
8682 />
8783 </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">
8985 No environments connected yet.{"\n"}Tap{" "}
9086 <Text className="font-t3-bold text-foreground">+</Text> to add one.
9187 </Text>
apps/mobile/src/features/connection/EnvironmentConnectionNotice.tsx+2−2
@@ -73,10 +73,10 @@ export function EnvironmentConnectionNotice(props: { · 72 unmodified lines
7373 />
7474 )}
7575
76− <Text className="text-center text-lg font-t3-bold leading-[22px] text-foreground">
76+ <Text className="text-center text-lg font-t3-bold text-foreground">
7777 {noticeTitle(props.connection.phase, props.environmentLabel)}
7878 </Text>
79− <Text className="text-center text-sm leading-[20px] text-foreground-muted">
79+ <Text className="text-center text-sm leading-normal text-foreground-muted">
8080 {noticeDetail(props.connection.phase, props.resourceName, props.connection.error)}
8181 {props.connection.traceId ? (
8282 <>
apps/mobile/src/features/diffs/nativeReviewDiffSurface.test.ts+22−2
@@ -33,11 +33,14 @@ describe("resolveNativeReviewDiffView", () => { · 32 unmodified lines
3333 expect(expoMocks.requireNativeView).not.toHaveBeenCalled();
3434 });
3535
36− it("returns the native review diff view when the view config is installed", async () => {
36+ it("returns the payload bridge when the native review diff view is installed", async () => {
3737 setExpoViewConfigAvailable();
3838 expoMocks.requireNativeView.mockReturnValue(nativeView);
3939 const { resolveNativeReviewDiffView } = await import("./nativeReviewDiffSurface");
40− expect(resolveNativeReviewDiffView()).toBe(nativeView);
40+ const resolvedView = resolveNativeReviewDiffView();
41+ expect(resolvedView).not.toBeNull();
42+ expect(resolvedView).not.toBe(nativeView);
43+ expect(resolveNativeReviewDiffView()).toBe(resolvedView);
4144 expect(expoMocks.requireNativeView).toHaveBeenCalledWith("T3ReviewDiffSurface");
4245 });
4346
@@ -78,3 +81,20 @@ describe("resolveNativeReviewDiffView", () => { · 34 unmodified lines
7881 expect(consoleError).toHaveBeenCalledTimes(1);
7982 });
8083 });
84+
85+describe("isPendingNativeViewRegistration", () => {
86+ it("recognizes registration races for the installed native view name", async () => {
87+ const { isPendingNativeViewRegistration } = await import("./nativeReviewDiffSurface");
88+
89+ expect(
90+ isPendingNativeViewRegistration(
91+ new Error("Unable to find the 'T3ReviewDiffSurface' view for this native tag"),
92+ ),
93+ ).toBe(true);
94+ expect(
95+ isPendingNativeViewRegistration(
96+ new Error("Unable to find the 'T3ReviewDiffView' view for this native tag"),
97+ ),
98+ ).toBe(false);
99+ });
100+});
apps/mobile/src/features/diffs/nativeReviewDiffSurface.ts+133−6
@@ -1,10 +1,18 @@
1−import type { ComponentType } from "react";
1+import {
2+ createElement,
3+ useEffect,
4+ useImperativeHandle,
5+ useRef,
6+ type ComponentType,
7+ type Ref,
8+} from "react";
29 import type { NativeSyntheticEvent, ViewProps } from "react-native";
310 import { requireNativeView } from "expo";
411
512 import { NativeViewResolutionError } from "../../native/nativeViewResolutionError";
613
714 const NATIVE_REVIEW_DIFF_MODULE_NAME = "T3ReviewDiffSurface";
15+const NATIVE_REVIEW_DIFF_PAYLOAD_RETRY_FRAMES = 60;
816
917 interface ExpoGlobalWithViewConfig {
1018 readonly expo?: {
@@ -103,6 +111,7 @@ export interface NativeReviewDiffViewProps extends ViewProps { · 92 unmodified lines
103111 readonly tokensJson?: string;
104112 readonly tokensPatchJson?: string;
105113 readonly tokensResetKey?: string;
114+ readonly contentResetKey?: string;
106115 readonly collapsedFileIdsJson?: string;
107116 readonly viewedFileIdsJson?: string;
108117 readonly selectedRowIdsJson?: string;
@@ -113,7 +122,13 @@ export interface NativeReviewDiffViewProps extends ViewProps { · 4 unmodified lines
113122 readonly rowHeight: number;
114123 readonly contentWidth: number;
115124 readonly initialRowIndex?: number;
125+ readonly refreshing?: boolean;
126+ readonly nativeViewRef?: Ref<NativeReviewDiffViewHandle>;
127+ readonly onPullToRefresh?: (event: NativeSyntheticEvent<Record<string, never>>) => void;
116128 readonly onDebug?: (event: NativeSyntheticEvent<Record<string, unknown>>) => void;
129+ readonly onVisibleFileChange?: (
130+ event: NativeSyntheticEvent<{ readonly fileId?: string | null }>,
131+ ) => void;
117132 readonly onToggleFile?: (event: NativeSyntheticEvent<{ readonly fileId?: string }>) => void;
118133 readonly onToggleViewedFile?: (event: NativeSyntheticEvent<{ readonly fileId?: string }>) => void;
119134 readonly onPressLine?: (
@@ -129,18 +144,130 @@ export interface NativeReviewDiffViewProps extends ViewProps { · 9 unmodified lines
129144 readonly onToggleComment?: (event: NativeSyntheticEvent<{ readonly commentId?: string }>) => void;
130145 }
131146
132−let cachedNativeReviewDiffView: ComponentType<NativeReviewDiffViewProps> | undefined;
147+export interface NativeReviewDiffViewHandle {
148+ readonly scrollToFile: (fileId: string, animated?: boolean) => Promise<void>;
149+ readonly scrollToTop: (animated?: boolean) => Promise<void>;
150+}
151+
152+interface NativeReviewDiffViewRef {
153+ readonly setRowsJson: (rowsJson: string) => Promise<void>;
154+ readonly setTokensJson: (tokensJson: string) => Promise<void>;
155+ readonly setTokensPatchJson: (tokensPatchJson: string) => Promise<void>;
156+ readonly scrollToFile: (fileId: string, animated: boolean) => Promise<void>;
157+ readonly scrollToTop: (animated: boolean) => Promise<void>;
158+}
159+
160+type NativeReviewDiffRawViewProps = Omit<
161+ NativeReviewDiffViewProps,
162+ "nativeViewRef" | "rowsJson" | "tokensJson" | "tokensPatchJson"
163+> & {
164+ readonly ref?: Ref<NativeReviewDiffViewRef>;
165+};
166+
167+let cachedNativeReviewDiffRawView: ComponentType<NativeReviewDiffRawViewProps> | undefined;
133168 let nativeReviewDiffViewResolutionFailed = false;
134169
170+type NativeReviewDiffPayloadMethod = "setRowsJson" | "setTokensJson" | "setTokensPatchJson";
171+
172+export function isPendingNativeViewRegistration(error: unknown): boolean {
173+ return (
174+ error instanceof Error &&
175+ error.message.includes(`Unable to find the '${NATIVE_REVIEW_DIFF_MODULE_NAME}' view`)
176+ );
177+}
178+
179+function useNativeReviewDiffPayload(
180+ nativeRef: React.RefObject<NativeReviewDiffViewRef | null>,
181+ method: NativeReviewDiffPayloadMethod,
182+ payload: string | undefined,
183+) {
184+ useEffect(() => {
185+ if (payload === undefined) {
186+ return;
187+ }
188+
189+ let cancelled = false;
190+ let frame: number | null = null;
191+ let attempts = 0;
192+
193+ const dispatch = () => {
194+ if (cancelled) {
195+ return;
196+ }
197+
198+ const view = nativeRef.current;
199+ const command = view?.[method];
200+ if (!view || !command) {
201+ if (attempts < NATIVE_REVIEW_DIFF_PAYLOAD_RETRY_FRAMES) {
202+ attempts += 1;
203+ frame = requestAnimationFrame(dispatch);
204+ }
205+ return;
206+ }
207+
208+ void command.call(view, payload).catch((error: unknown) => {
209+ if (
210+ !cancelled &&
211+ attempts < NATIVE_REVIEW_DIFF_PAYLOAD_RETRY_FRAMES &&
212+ isPendingNativeViewRegistration(error)
213+ ) {
214+ attempts += 1;
215+ frame = requestAnimationFrame(dispatch);
216+ return;
217+ }
218+ console.error(`[native-review-diff] ${method} failed`, error);
219+ });
220+ };
221+
222+ // Fabric attaches the React ref before Expo registers the native tag used by
223+ // view functions. Starting on the next frame avoids racing that registration.
224+ frame = requestAnimationFrame(dispatch);
225+
226+ return () => {
227+ cancelled = true;
228+ if (frame !== null) {
229+ cancelAnimationFrame(frame);
230+ }
231+ };
232+ }, [method, nativeRef, payload]);
233+}
234+
135235 function getExpoViewConfig(moduleName: string) {
136236 return (globalThis as typeof globalThis & ExpoGlobalWithViewConfig).expo?.getViewConfig?.(
137237 moduleName,
138238 );
139239 }
140240
241+function NativeReviewDiffView(props: NativeReviewDiffViewProps) {
242+ const { nativeViewRef, rowsJson, tokensJson, tokensPatchJson, ...nativeProps } = props;
243+ const nativeRef = useRef<NativeReviewDiffViewRef>(null);
244+ useNativeReviewDiffPayload(nativeRef, "setRowsJson", rowsJson);
245+ useNativeReviewDiffPayload(nativeRef, "setTokensJson", tokensJson);
246+ useNativeReviewDiffPayload(nativeRef, "setTokensPatchJson", tokensPatchJson);
247+ useImperativeHandle(
248+ nativeViewRef,
249+ () => ({
250+ scrollToFile: async (fileId, animated = true) => {
251+ await nativeRef.current?.scrollToFile(fileId, animated);
252+ },
253+ scrollToTop: async (animated = true) => {
254+ await nativeRef.current?.scrollToTop(animated);
255+ },
256+ }),
257+ [],
258+ );
259+
260+ const RawNativeView = cachedNativeReviewDiffRawView;
261+ if (!RawNativeView) {
262+ return null;
263+ }
264+
265+ return createElement(RawNativeView, { ...nativeProps, ref: nativeRef });
266+}
267+
141268 export function resolveNativeReviewDiffView(): ComponentType<NativeReviewDiffViewProps> | null {
142− if (cachedNativeReviewDiffView) {
143− return cachedNativeReviewDiffView;
269+ if (cachedNativeReviewDiffRawView) {
270+ return NativeReviewDiffView;
144271 }
145272
146273 if (nativeReviewDiffViewResolutionFailed) {
@@ -152,7 +279,7 @@ export function resolveNativeReviewDiffView(): ComponentType<NativeReviewDiffVie · 5 unmodified lines
152279 }
153280
154281 try {
155− cachedNativeReviewDiffView = requireNativeView<NativeReviewDiffViewProps>(
282+ cachedNativeReviewDiffRawView = requireNativeView<NativeReviewDiffRawViewProps>(
156283 NATIVE_REVIEW_DIFF_MODULE_NAME,
157284 );
158285 } catch (cause) {
@@ -166,5 +293,5 @@ export function resolveNativeReviewDiffView(): ComponentType<NativeReviewDiffVie · 7 unmodified lines
166293 return null;
167294 }
168295
169− return cachedNativeReviewDiffView ?? null;
296+ return cachedNativeReviewDiffRawView ? NativeReviewDiffView : null;
170297 }
apps/mobile/src/features/files/FileMarkdownPreview.tsx+51−7
@@ -1,15 +1,19 @@
1−import { useCallback, useMemo } from "react";
1+import { useCallback, useMemo, useState } from "react";
22 import {
33 Markdown,
44 type CustomRenderers,
55 type NodeStyleOverrides,
66 type PartialMarkdownTheme,
77 } from "react-native-nitro-markdown";
8−import { ScrollView, Text as NativeText, View } from "react-native";
8+import { RefreshControl, ScrollView, Text as NativeText, View } from "react-native";
99
1010 import { tryOpenExternalUrl } from "../../lib/openExternalUrl";
11−import { MOBILE_TYPOGRAPHY } from "../../lib/typography";
11+import {
12+ resolveMarkdownFontSizes,
13+ resolveNativeMarkdownTypography,
14+} from "../../lib/appearancePreferences";
1215 import { useThemeColor } from "../../lib/useThemeColor";
16+import { useAppearancePreferences } from "../settings/appearance/AppearancePreferencesProvider";
1317 import {
1418 hasNativeSelectableMarkdownText,
1519 SelectableMarkdownText,
@@ -24,6 +28,15 @@ interface MarkdownPreviewStyles { · 8 unmodified lines
2428 }
2529
2630 function useMarkdownPreviewStyles(): MarkdownPreviewStyles {
31+ const { appearance } = useAppearancePreferences();
32+ const markdownFontSizes = useMemo(
33+ () => resolveMarkdownFontSizes(appearance.baseFontSize),
34+ [appearance.baseFontSize],
35+ );
36+ const nativeMarkdownTypography = useMemo(
37+ () => resolveNativeMarkdownTypography(appearance.baseFontSize),
38+ [appearance.baseFontSize],
39+ );
2740 const body = String(useThemeColor("--color-md-body"));
2841 const strong = String(useThemeColor("--color-md-strong"));
2942 const link = String(useThemeColor("--color-md-link"));
@@ -74,7 +87,8 @@ function useMarkdownPreviewStyles(): MarkdownPreviewStyles { · 44 unmodified lines
7487 text: {
7588 color: body,
7689 fontFamily: "DMSans_400Regular",
77− ...MOBILE_TYPOGRAPHY.body,
90+ fontSize: markdownFontSizes.m,
91+ lineHeight: markdownFontSizes.bodyLineHeight,
7892 },
7993 heading: {
8094 color: strong,
@@ -124,7 +138,9 @@ function useMarkdownPreviewStyles(): MarkdownPreviewStyles { · 43 unmodified lines
124138 skillTextColor: codeText,
125139 quoteMarkerColor: blockquoteBorder,
126140 dividerColor: horizontalRule,
127− ...MOBILE_TYPOGRAPHY.body,
141+ fontSize: nativeMarkdownTypography.fontSize,
142+ lineHeight: nativeMarkdownTypography.lineHeight,
143+ headingFontSizes: nativeMarkdownTypography.headingFontSizes,
128144 fontFamily: "DMSans_400Regular",
129145 headingFontFamily: "DMSans_700Bold",
130146 boldFontFamily: "DMSans_700Bold",
@@ -138,18 +154,46 @@ function useMarkdownPreviewStyles(): MarkdownPreviewStyles { · 7 unmodified lines
138154 codeText,
139155 horizontalRule,
140156 link,
157+ markdownFontSizes,
158+ nativeMarkdownTypography,
141159 strong,
142160 ]);
143161 }
144162
145−export function FileMarkdownPreview(props: { readonly markdown: string }) {
163+export function FileMarkdownPreview(props: {
164+ readonly markdown: string;
165+ readonly onRefresh?: () => Promise<void> | void;
166+}) {
167+ const [isPullRefreshing, setIsPullRefreshing] = useState(false);
168+ const handlePullToRefresh = useCallback(async () => {
169+ if (!props.onRefresh) {
170+ return;
171+ }
172+ setIsPullRefreshing(true);
173+ try {
174+ await props.onRefresh();
175+ } finally {
176+ setIsPullRefreshing(false);
177+ }
178+ }, [props.onRefresh]);
146179 const styles = useMarkdownPreviewStyles();
147180 const onLinkPress = useCallback((href: string) => {
148181 void tryOpenExternalUrl(href, "markdown-link");
149182 }, []);
150183
151184 return (
152− <ScrollView className="flex-1 bg-card" contentContainerStyle={{ padding: 18 }}>
185+ <ScrollView
186+ className="flex-1 bg-sheet"
187+ contentContainerStyle={{ padding: 18 }}
188+ refreshControl={
189+ props.onRefresh ? (
190+ <RefreshControl
191+ refreshing={isPullRefreshing}
192+ onRefresh={() => void handlePullToRefresh()}
193+ />
194+ ) : undefined
195+ }
196+ >
153197 <View className="mx-auto w-full max-w-[760px]">
154198 {hasNativeSelectableMarkdownText() ? (
155199 <SelectableMarkdownText
apps/mobile/src/features/files/FileTreeBrowser.tsx+149−55
@@ -1,7 +1,15 @@
11 import type { ProjectEntry } from "@t3tools/contracts";
22 import { SymbolView } from "expo-symbols";
3−import { memo, useCallback, useEffect, useMemo, useState } from "react";
4−import { ActivityIndicator, FlatList, Pressable, RefreshControl, View } from "react-native";
3+import { memo, useCallback, useEffect, useMemo, useRef, useState } from "react";
4+import {
5+ ActivityIndicator,
6+ FlatList,
7+ Platform,
8+ Pressable,
9+ RefreshControl,
10+ View,
11+} from "react-native";
12+import { useSafeAreaInsets } from "react-native-safe-area-context";
513
614 import { AppText as Text } from "../../components/AppText";
715 import { PierreEntryIcon } from "../../components/PierreEntryIcon";
@@ -11,9 +19,25 @@ import { · 3 unmodified lines
1119 buildFileTree,
1220 defaultExpandedTreePaths,
1321 flattenFileTree,
22+ type FileTreeNode,
1423 type VisibleFileTreeNode,
1524 } from "./fileTree";
1625
26+const fileTreeCache = new WeakMap<ReadonlyArray<ProjectEntry>, ReadonlyArray<FileTreeNode>>();
27+const FILE_TREE_INITIAL_RENDER_COUNT = 20;
28+const FILE_TREE_RENDER_BATCH_SIZE = 12;
29+const OPTIMISTIC_SELECTION_TIMEOUT_MS = 1_000;
30+
31+function cachedFileTree(entries: ReadonlyArray<ProjectEntry>): ReadonlyArray<FileTreeNode> {
32+ const cached = fileTreeCache.get(entries);
33+ if (cached !== undefined) {
34+ return cached;
35+ }
36+ const tree = buildFileTree(entries);
37+ fileTreeCache.set(entries, tree);
38+ return tree;
39+}
40+
1741 function ancestorPaths(path: string): ReadonlyArray<string> {
1842 const parts = path.split("/").filter(Boolean);
1943 const ancestors: string[] = [];
@@ -25,19 +49,24 @@ function ancestorPaths(path: string): ReadonlyArray<string> { · 5 unmodified lines
2549
2650 const FileTreeRow = memo(function FileTreeRow(props: {
2751 readonly item: VisibleFileTreeNode;
28− readonly selectedPath: string | null;
52+ readonly selected: boolean;
2953 readonly expanded: boolean;
3054 readonly iconColor: string;
3155 readonly onPressDirectory: (path: string) => void;
56+ readonly onPreviewFile?: (path: string) => void;
3257 readonly onPressFile: (path: string) => void;
3358 }) {
3459 const { node, depth } = props.item;
35− const selected = node.kind === "file" && node.path === props.selectedPath;
3660
3761 return (
3862 <Pressable
3963 accessibilityRole="button"
4064 accessibilityLabel={node.path}
65+ onPressIn={() => {
66+ if (node.kind === "file") {
67+ props.onPreviewFile?.(node.path);
68+ }
69+ }}
4170 onPress={() => {
4271 if (node.kind === "directory") {
4372 props.onPressDirectory(node.path);
@@ -47,7 +76,7 @@ const FileTreeRow = memo(function FileTreeRow(props: { · 3 unmodified lines
4776 }}
4877 className={cn(
4978 "mx-2 min-h-[42px] flex-row items-center gap-2 rounded-[12px] px-2 active:bg-subtle",
50− selected && "bg-subtle-strong",
79+ props.selected && "bg-subtle-strong",
5180 )}
5281 style={{ paddingLeft: 8 + depth * 18 }}
5382 >
@@ -64,8 +93,10 @@ const FileTreeRow = memo(function FileTreeRow(props: { · 10 unmodified lines
6493 <PierreEntryIcon path={node.path} kind={node.kind} size={17} />
6594 <Text
6695 className={cn(
67− "min-w-0 flex-1 text-sm leading-[19px]",
68− selected ? "font-t3-bold text-foreground" : "font-t3-medium text-foreground-secondary",
96+ "min-w-0 flex-1 text-sm leading-normal",
97+ props.selected
98+ ? "font-t3-bold text-foreground"
99+ : "font-t3-medium text-foreground-secondary",
69100 )}
70101 numberOfLines={1}
71102 >
@@ -86,13 +117,30 @@ export function FileTreeBrowser(props: { · 14 unmodified lines
86117 readonly isPending: boolean;
87118 readonly searchQuery: string;
88119 readonly selectedPath: string | null;
120+ readonly onPreviewFile?: (path: string) => void;
89121 readonly onRefresh: () => void;
90122 readonly onSelectFile: (path: string) => void;
91123 }) {
92124 const [expandedPaths, setExpandedPaths] = useState<ReadonlySet<string>>(() => new Set());
125+ const [pendingSelection, setPendingSelection] = useState<{
126+ readonly path: string;
127+ readonly selectedPathAtPress: string | null;
128+ } | null>(null);
129+ const insets = useSafeAreaInsets();
130+ // Native transparent-header height ≈ safe-area top + nav bar (~44). Matches the
131+ // observed adjustedContentInset bottom (~102) seen in the native trace.
132+ const headerInset = Platform.OS === "ios" ? insets.top + 44 : 0;
93133 const iconColor = String(useThemeColor("--color-icon-muted"));
134+ const { onPreviewFile, onSelectFile, selectedPath: controlledSelectedPath } = props;
135+ const controlledSelectedPathRef = useRef(controlledSelectedPath);
136+ const pendingSelectionTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
137+ controlledSelectedPathRef.current = controlledSelectedPath;
94138
95− const tree = useMemo(() => buildFileTree(props.entries), [props.entries]);
139+ const selectedPath =
140+ pendingSelection?.selectedPathAtPress === controlledSelectedPath
141+ ? pendingSelection.path
142+ : controlledSelectedPath;
143+ const tree = useMemo(() => cachedFileTree(props.entries), [props.entries]);
96144 const defaultExpanded = useMemo(() => defaultExpandedTreePaths(tree), [tree]);
97145 const visibleNodes = useMemo(
98146 () =>
@@ -114,17 +162,30 @@ export function FileTreeBrowser(props: { · 15 unmodified lines
114162 }, [defaultExpanded]);
115163
116164 useEffect(() => {
117− if (!props.selectedPath) {
165+ if (!controlledSelectedPath) {
118166 return;
119167 }
120168 setExpandedPaths((current) => {
169+ const ancestors = ancestorPaths(controlledSelectedPath);
170+ if (ancestors.every((ancestor) => current.has(ancestor))) {
171+ return current;
172+ }
121173 const next = new Set(current);
122− for (const ancestor of ancestorPaths(props.selectedPath ?? "")) {
174+ for (const ancestor of ancestors) {
123175 next.add(ancestor);
124176 }
125177 return next;
126178 });
127− }, [props.selectedPath]);
179+ }, [controlledSelectedPath]);
180+
181+ useEffect(
182+ () => () => {
183+ if (pendingSelectionTimeoutRef.current !== null) {
184+ clearTimeout(pendingSelectionTimeoutRef.current);
185+ }
186+ },
187+ [],
188+ );
128189
129190 const toggleDirectory = useCallback((path: string) => {
130191 setExpandedPaths((current) => {
@@ -137,53 +198,86 @@ export function FileTreeBrowser(props: { · 6 unmodified lines
137198 return next;
138199 });
139200 }, []);
201+ const handleSelectFile = useCallback(
202+ (path: string) => {
203+ if (pendingSelectionTimeoutRef.current !== null) {
204+ clearTimeout(pendingSelectionTimeoutRef.current);
205+ }
206+ setPendingSelection({
207+ path,
208+ selectedPathAtPress: controlledSelectedPathRef.current,
209+ });
210+ pendingSelectionTimeoutRef.current = setTimeout(() => {
211+ pendingSelectionTimeoutRef.current = null;
212+ setPendingSelection((current) => (current?.path === path ? null : current));
213+ }, OPTIMISTIC_SELECTION_TIMEOUT_MS);
214+ onSelectFile(path);
215+ },
216+ [onSelectFile],
217+ );
218+ const renderItem = useCallback(
219+ ({ item }: { readonly item: VisibleFileTreeNode }) => (
220+ <FileTreeRow
221+ item={item}
222+ selected={item.node.kind === "file" && item.node.path === selectedPath}
223+ expanded={expandedPaths.has(item.node.path)}
224+ iconColor={iconColor}
225+ onPressDirectory={toggleDirectory}
226+ onPreviewFile={onPreviewFile}
227+ onPressFile={handleSelectFile}
228+ />
229+ ),
230+ [expandedPaths, handleSelectFile, iconColor, onPreviewFile, selectedPath, toggleDirectory],
231+ );
140232
233+ if (props.error && props.entries.length === 0) {
234+ return (
235+ <View className="flex-1 bg-sheet px-4 py-5">
236+ <Text className="text-sm font-t3-bold text-foreground">Files unavailable</Text>
237+ <Text className="mt-1 text-xs leading-normal text-foreground-muted">{props.error}</Text>
238+ </View>
239+ );
240+ }
241+
242+ // SPIKE: render the FlatList as the screen's DIRECT content (no wrapping View), and
243+ // mirror the Home ScrollView exactly — `contentInsetAdjustmentBehavior: "automatic"`
244+ // with NO manual contentInset. iOS only applies the nav-bar top inset + scroll-edge
245+ // blur to a scroll view in the screen's primary position; a scroll view buried in
246+ // flex-1 Views is ignored, which is why the tree rendered under the header with no blur.
141247 return (
142− <View className="flex-1 bg-sheet">
143− {props.error && props.entries.length === 0 ? (
248+ <FlatList
249+ style={{ flex: 1 }}
250+ data={visibleNodes}
251+ keyExtractor={(item) => item.node.path}
252+ contentInsetAdjustmentBehavior={Platform.OS === "ios" ? "automatic" : "never"}
253+ scrollIndicatorInsets={
254+ Platform.OS === "ios" ? { top: headerInset, left: 0, right: 0, bottom: 0 } : undefined
255+ }
256+ keyboardDismissMode="on-drag"
257+ keyboardShouldPersistTaps="handled"
258+ initialNumToRender={FILE_TREE_INITIAL_RENDER_COUNT}
259+ maxToRenderPerBatch={FILE_TREE_RENDER_BATCH_SIZE}
260+ updateCellsBatchingPeriod={16}
261+ windowSize={5}
262+ contentContainerStyle={{ paddingTop: 8, paddingBottom: 8 }}
263+ refreshControl={<RefreshControl refreshing={props.isPending} onRefresh={props.onRefresh} />}
264+ renderItem={renderItem}
265+ ListEmptyComponent={
144266 <View className="px-4 py-5">
145− <Text className="text-sm font-t3-bold text-foreground">Files unavailable</Text>
146− <Text className="mt-1 text-xs leading-[18px] text-foreground-muted">{props.error}</Text>
147− </View>
148− ) : (
149− <FlatList
150− data={visibleNodes}
151− keyExtractor={(item) => item.node.path}
152− contentInsetAdjustmentBehavior="automatic"
153− keyboardDismissMode="on-drag"
154− keyboardShouldPersistTaps="handled"
155− contentContainerStyle={{ paddingVertical: 8 }}
156− refreshControl={
157− <RefreshControl refreshing={props.isPending} onRefresh={props.onRefresh} />
158− }
159− renderItem={({ item }) => (
160− <FileTreeRow
161− item={item}
162− selectedPath={props.selectedPath}
163− expanded={expandedPaths.has(item.node.path)}
164− iconColor={iconColor}
165− onPressDirectory={toggleDirectory}
166− onPressFile={props.onSelectFile}
167− />
267+ {props.isPending ? (
268+ <ActivityIndicator size="small" />
269+ ) : (
270+ <>
271+ <Text className="text-sm font-t3-bold text-foreground">No files found</Text>
272+ <Text className="mt-1 text-xs leading-normal text-foreground-muted">
273+ {props.searchQuery.trim().length > 0
274+ ? "Try a different search."
275+ : "The workspace file index is empty."}
276+ </Text>
277+ </>
168278 )}
169− ListEmptyComponent={
170− <View className="px-4 py-5">
171− {props.isPending ? (
172− <ActivityIndicator size="small" />
173− ) : (
174− <>
175− <Text className="text-sm font-t3-bold text-foreground">No files found</Text>
176− <Text className="mt-1 text-xs leading-[18px] text-foreground-muted">
177− {props.searchQuery.trim().length > 0
178− ? "Try a different search."
179− : "The workspace file index is empty."}
180− </Text>
181− </>
182− )}
183− </View>
184− }
185− />
186− )}
187− </View>
279+ </View>
280+ }
281+ />
188282 );
189283 }
apps/mobile/src/features/files/SourceFileSurface.tsx+98−65
@@ -1,8 +1,15 @@
11 import { useAtomValue } from "@effect/atom-react";
22 import { AsyncResult } from "effect/unstable/reactivity";
33 import type { ComponentType } from "react";
4−import { memo, useCallback, useEffect, useMemo, useRef } from "react";
5−import { FlatList, ScrollView, Text as NativeText, useColorScheme, View } from "react-native";
4+import { memo, useCallback, useEffect, useMemo, useRef, useState } from "react";
5+import {
6+ FlatList,
7+ ScrollView,
8+ Text as NativeText,
9+ useColorScheme,
10+ useWindowDimensions,
11+ View,
12+} from "react-native";
613
714 import { AppText as Text } from "../../components/AppText";
815 import { LoadingStrip } from "../../components/LoadingStrip";
@@ -11,71 +18,62 @@ import { · 2 unmodified lines
1118 resolveNativeReviewDiffView,
1219 } from "../diffs/nativeReviewDiffSurface";
1320 import { createNativeReviewDiffTheme } from "../review/nativeReviewDiffAdapter";
14−import {
15− REVIEW_DIFF_LINE_HEIGHT,
16− REVIEW_MONO_FONT_FAMILY,
17− renderVisibleWhitespace,
18−} from "../review/reviewDiffRendering";
21+import { REVIEW_MONO_FONT_FAMILY, renderVisibleWhitespace } from "../review/reviewDiffRendering";
1922 import type { ReviewHighlightedToken } from "../review/shikiReviewHighlighter";
2023 import { cn } from "../../lib/cn";
21−import { MOBILE_CODE_SURFACE } from "../../lib/typography";
24+import type { ResolvedMobileCodeSurface } from "../../lib/appearancePreferences";
25+import { useAppearanceCodeSurface } from "../settings/appearance/useAppearanceCodeSurface";
2226 import {
23− buildNativeSourceRows,
2427 buildNativeSourceTokens,
2528 NATIVE_SOURCE_CONTENT_WIDTH,
26− NATIVE_SOURCE_ROW_HEIGHT,
27− NATIVE_SOURCE_STYLE,
2829 nativeSourceRowId,
2930 } from "./nativeSourceFileAdapter";
31+import { prepareSourceFileDocument } from "./source-file-document";
3032 import { sourceHighlightAtom } from "./sourceHighlightingState";
3133
32−const SOURCE_LINE_HEIGHT = MOBILE_CODE_SURFACE.rowHeight;
33−const SOURCE_LINE_NUMBER_WIDTH = MOBILE_CODE_SURFACE.gutterWidth;
34−const NATIVE_SOURCE_STYLE_JSON = JSON.stringify(NATIVE_SOURCE_STYLE);
35−
3634 interface SourceFileSurfaceProps {
3735 readonly contents: string;
3836 readonly path: string;
3937 readonly initialLine?: number | null;
38+ /** Enables native pull-to-refresh on the source surface. */
39+ readonly onRefresh?: () => Promise<void> | void;
4040 }
4141
4242 type SourceHighlightStatus = "highlighting" | "ready" | "error";
4343
44−function splitSourceLines(contents: string): ReadonlyArray<string> {
45− return contents.replace(/\r\n?/g, "\n").split("\n");
46−}
47−
4844 const HighlightedSourceLine = memo(function HighlightedSourceLine(props: {
45+ readonly codeSurface: ResolvedMobileCodeSurface;
4946 readonly index: number;
5047 readonly line: string;
5148 readonly tokens: ReadonlyArray<ReviewHighlightedToken> | null;
5249 readonly highlighted: boolean;
50+ readonly wordBreak: boolean;
5351 }) {
5452 return (
5553 <View
5654 className={cn("flex-row", props.highlighted && "bg-primary/10")}
57− style={{ minHeight: SOURCE_LINE_HEIGHT }}
55+ style={{ minHeight: props.codeSurface.rowHeight }}
5856 >
5957 <NativeText
6058 className="select-none pr-3 text-right text-foreground-tertiary"
6159 style={{
62− width: SOURCE_LINE_NUMBER_WIDTH,
60+ width: props.codeSurface.gutterWidth,
6361 fontFamily: REVIEW_MONO_FONT_FAMILY,
64− fontSize: MOBILE_CODE_SURFACE.lineNumberFontSize,
65− lineHeight: MOBILE_CODE_SURFACE.rowHeight,
62+ fontSize: props.codeSurface.lineNumberFontSize,
63+ lineHeight: props.codeSurface.rowHeight,
6664 }}
6765 >
6866 {props.index + 1}
6967 </NativeText>
7068 <NativeText
7169 selectable
72− numberOfLines={1}
73− className="font-normal text-foreground"
70+ numberOfLines={props.wordBreak ? undefined : 1}
71+ className="flex-1 font-normal text-foreground"
7472 style={{
7573 fontFamily: REVIEW_MONO_FONT_FAMILY,
76− fontSize: MOBILE_CODE_SURFACE.fontSize,
77− lineHeight: MOBILE_CODE_SURFACE.rowHeight,
78− minWidth: 320,
74+ fontSize: props.codeSurface.fontSize,
75+ lineHeight: props.codeSurface.rowHeight,
76+ minWidth: props.wordBreak ? undefined : 320,
7977 }}
8078 >
8179 {props.tokens && props.tokens.length > 0
@@ -119,11 +117,8 @@ const HighlightedSourceLine = memo(function HighlightedSourceLine(props: { · 37 unmodified lines
119117 function useSourceFileModel(props: SourceFileSurfaceProps) {
120118 const colorScheme = useColorScheme();
121119 const theme: "dark" | "light" = colorScheme === "dark" ? "dark" : "light";
122− const normalizedContents = useMemo(
123− () => props.contents.replace(/\r\n?/g, "\n"),
124− [props.contents],
125− );
126− const lines = useMemo(() => splitSourceLines(normalizedContents), [normalizedContents]);
120+ const document = useMemo(() => prepareSourceFileDocument(props.contents), [props.contents]);
121+ const { contents: normalizedContents, lines, rowsJson } = document;
127122 const targetIndex =
128123 props.initialLine !== null && props.initialLine !== undefined && props.initialLine > 0
129124 ? Math.min(Math.floor(props.initialLine) - 1, Math.max(0, lines.length - 1))
@@ -140,7 +135,7 @@ function useSourceFileModel(props: SourceFileSurfaceProps) { · 10 unmodified lines
140135 ? "ready"
141136 : "highlighting";
142137
143− return { lines, status, targetIndex, theme, tokens };
138+ return { lines, rowsJson, status, targetIndex, theme, tokens };
144139 }
145140
146141 function SourceHighlightStatusView(props: { readonly status: SourceHighlightStatus }) {
@@ -162,39 +157,63 @@ function NativeSourceFileSurface( · 15 unmodified lines
162157 readonly NativeView: ComponentType<NativeReviewDiffViewProps>;
163158 },
164159 ) {
165− const { NativeView } = props;
166− const { lines, status, targetIndex, theme, tokens } = useSourceFileModel(props);
167− const rowsJson = useMemo(() => JSON.stringify(buildNativeSourceRows(lines)), [lines]);
160+ const { NativeView, onRefresh } = props;
161+ const { codeSurface, codeWordBreak, nativeSourceStyle } = useAppearanceCodeSurface();
162+ const { width: viewportWidth } = useWindowDimensions();
163+ const { rowsJson, status, targetIndex, theme, tokens } = useSourceFileModel(props);
164+ const [isPullRefreshing, setIsPullRefreshing] = useState(false);
165+ const handlePullToRefresh = useCallback(async () => {
166+ if (!onRefresh) {
167+ return;
168+ }
169+ setIsPullRefreshing(true);
170+ try {
171+ await onRefresh();
172+ } finally {
173+ setIsPullRefreshing(false);
174+ }
175+ }, [onRefresh]);
168176 const tokensJson = useMemo(() => JSON.stringify(buildNativeSourceTokens(tokens)), [tokens]);
169177 const selectedRowIdsJson = useMemo(
170178 () => JSON.stringify(targetIndex === null ? [] : [nativeSourceRowId(targetIndex)]),
171179 [targetIndex],
172180 );
173181 const themeJson = useMemo(() => JSON.stringify(createNativeReviewDiffTheme(theme)), [theme]);
182+ const styleJson = useMemo(() => JSON.stringify(nativeSourceStyle), [nativeSourceStyle]);
183+ const contentWidth = codeWordBreak
184+ ? Math.max(240, viewportWidth - codeSurface.gutterWidth - 24)
185+ : NATIVE_SOURCE_CONTENT_WIDTH;
174186
175187 return (
176− <View className="relative flex-1 bg-card">
188+ <View className="relative flex-1 bg-sheet">
177189 <SourceHighlightStatusView status={status} />
178190 <NativeView
179− key={props.path}
180191 collapsable={false}
181192 testID="source-native-code-view"
182193 style={{ flex: 1 }}
183194 appearanceScheme={theme}
184− contentWidth={NATIVE_SOURCE_CONTENT_WIDTH}
195+ contentResetKey={props.path}
196+ contentWidth={contentWidth}
185197 initialRowIndex={targetIndex ?? -1}
186− rowHeight={NATIVE_SOURCE_ROW_HEIGHT}
198+ rowHeight={nativeSourceStyle.rowHeight ?? codeSurface.rowHeight}
187199 rowsJson={rowsJson}
188200 selectedRowIdsJson={selectedRowIdsJson}
189− styleJson={NATIVE_SOURCE_STYLE_JSON}
201+ styleJson={styleJson}
190202 themeJson={themeJson}
191203 tokensJson={tokensJson}
204+ {...(onRefresh
205+ ? {
206+ refreshing: isPullRefreshing,
207+ onPullToRefresh: () => void handlePullToRefresh(),
208+ }
209+ : {})}
192210 />
193211 </View>
194212 );
195213 }
196214
197215 function JavaScriptSourceFileSurface(props: SourceFileSurfaceProps) {
216+ const { codeSurface, codeWordBreak } = useAppearanceCodeSurface();
198217 const { lines, status, targetIndex, tokens } = useSourceFileModel(props);
199218 const listRef = useRef<FlatList<string>>(null);
200219
@@ -211,39 +230,53 @@ function JavaScriptSourceFileSurface(props: SourceFileSurfaceProps) { · 10 unmodified lines
211230 const renderLine = useCallback(
212231 ({ item, index }: { item: string; index: number }) => (
213232 <HighlightedSourceLine
233+ codeSurface={codeSurface}
214234 index={index}
215235 line={item}
216236 tokens={tokens?.[index] ?? null}
217237 highlighted={index === targetIndex}
238+ wordBreak={codeWordBreak}
218239 />
219240 ),
220− [targetIndex, tokens],
241+ [codeSurface, codeWordBreak, targetIndex, tokens],
242+ );
243+
244+ const list = (
245+ <FlatList
246+ ref={listRef}
247+ data={lines}
248+ keyExtractor={(_line, index) => String(index)}
249+ initialNumToRender={80}
250+ maxToRenderPerBatch={80}
251+ windowSize={12}
252+ {...(codeWordBreak
253+ ? {}
254+ : {
255+ getItemLayout: (_data, index) => ({
256+ length: codeSurface.rowHeight,
257+ offset: codeSurface.rowHeight * index,
258+ index,
259+ }),
260+ })}
261+ contentContainerStyle={{
262+ minWidth: codeWordBreak ? undefined : "100%",
263+ paddingBottom: codeSurface.rowHeight,
264+ paddingTop: 8,
265+ }}
266+ renderItem={renderLine}
267+ />
221268 );
222269
223270 return (
224− <View className="relative flex-1 bg-card">
271+ <View className="relative flex-1 bg-sheet">
225272 <SourceHighlightStatusView status={status} />
226− <ScrollView horizontal bounces={false} className="flex-1">
227− <FlatList
228− ref={listRef}
229− data={lines}
230− keyExtractor={(_line, index) => String(index)}
231− initialNumToRender={80}
232− maxToRenderPerBatch={80}
233− windowSize={12}
234− getItemLayout={(_data, index) => ({
235− length: SOURCE_LINE_HEIGHT,
236− offset: SOURCE_LINE_HEIGHT * index,
237− index,
238− })}
239− contentContainerStyle={{
240− minWidth: "100%",
241− paddingBottom: REVIEW_DIFF_LINE_HEIGHT,
242− paddingTop: 8,
243− }}
244− renderItem={renderLine}
245− />
246− </ScrollView>
273+ {codeWordBreak ? (
274+ list
275+ ) : (
276+ <ScrollView horizontal bounces={false} className="flex-1">
277+ {list}
278+ </ScrollView>
279+ )}
247280 </View>
248281 );
249282 }
apps/mobile/src/features/files/ThreadFilesRouteScreen.tsx+327−308
@@ -1,8 +1,15 @@
1−import Stack from "expo-router/stack";
2−import { SymbolView } from "expo-symbols";
3−import { useLocalSearchParams, useRouter } from "expo-router";
4−import { useCallback, useMemo, useRef, useState } from "react";
5−import { ActivityIndicator, Pressable, ScrollView, Text as RNText, View } from "react-native";
1+import { NativeHeaderToolbar, NativeStackScreenOptions } from "../../native/StackHeader";
2+import { StackActions, useNavigation, type StaticScreenProps } from "@react-navigation/native";
3+import { useCallback, useEffect, useMemo, useRef, useState } from "react";
4+import {
5+ ActivityIndicator,
6+ Platform,
7+ Pressable,
8+ ScrollView,
9+ useColorScheme,
10+ View,
11+} from "react-native";
12+import { useSafeAreaInsets } from "react-native-safe-area-context";
613 import Svg, { Defs, LinearGradient, Rect, Stop } from "react-native-svg";
714 import {
815 EnvironmentId,
@@ -12,22 +19,31 @@ import { · 3 unmodified lines
1219 } from "@t3tools/contracts";
1320
1421 import { AppText as Text } from "../../components/AppText";
15−import { CopyTextButton } from "../../components/CopyTextButton";
1622 import { EmptyState } from "../../components/EmptyState";
1723 import { LoadingScreen } from "../../components/LoadingScreen";
1824 import { cn } from "../../lib/cn";
25+import { resolveFileSelectionNavigationAction } from "../../lib/adaptive-navigation";
26+import { copyTextWithHaptic } from "../../lib/copyTextWithHaptic";
1927 import { tryOpenExternalUrl } from "../../lib/openExternalUrl";
20−import { buildThreadFilesNavigation } from "../../lib/routes";
21−import { MOBILE_TYPOGRAPHY } from "../../lib/typography";
2228 import { useThemeColor } from "../../lib/useThemeColor";
2329 import { useThreadSelection } from "../../state/use-thread-selection";
2430 import { useSelectedThreadWorktree } from "../../state/use-selected-thread-worktree";
2531 import { useEnvironmentQuery } from "../../state/query";
2632 import { projectEnvironment } from "../../state/projects";
33+import {
34+ useAdaptiveWorkspaceLayout,
35+ useAdaptiveWorkspacePaneRole,
36+ useRegisterWorkspaceInspector,
37+} from "../layout/AdaptiveWorkspaceLayout";
38+import { createNativeMailSearchToolbarItem } from "../layout/native-mail-search-toolbar";
39+import { WorkspaceSidebarToolbar } from "../layout/workspace-sidebar-toolbar";
2740 import { ReviewHighlighterProvider } from "../review/ReviewHighlighterProvider";
41+import { ThreadRouteScreen } from "../threads/ThreadRouteScreen";
2842 import { FileMarkdownPreview } from "./FileMarkdownPreview";
2943 import { FileTreeBrowser } from "./FileTreeBrowser";
44+import { preloadWorkspaceFileContents } from "./preload-workspace-file";
3045 import { SourceFileSurface } from "./SourceFileSurface";
46+import { ThreadFileNavigatorPane } from "./thread-file-navigator-pane";
3147 import { WorkspaceFileImagePreview } from "./WorkspaceFileImagePreview";
3248 import { WorkspaceFileWebPreview } from "./WorkspaceFileWebPreview";
3349 import {
@@ -72,196 +88,6 @@ function defaultViewMode(path: string | null): FileViewMode { · 38 unmodified lines
7288 : "source";
7389 }
7490
75−function ModeButton(props: {
76− readonly active: boolean;
77− readonly icon: "doc.text" | "eye";
78− readonly label: string;
79− readonly onPress: () => void;
80−}) {
81− const iconColor = String(
82− useThemeColor(props.active ? "--color-primary-foreground" : "--color-icon-muted"),
83− );
84−
85− return (
86− <Pressable
87− accessibilityRole="button"
88− accessibilityState={{ selected: props.active }}
89− className={cn(
90− "h-8 flex-row items-center justify-center gap-1.5 rounded-full px-3 active:opacity-70",
91− props.active ? "bg-primary" : "bg-subtle",
92− )}
93− onPress={props.onPress}
94− >
95− <SymbolView name={props.icon} size={13} tintColor={iconColor} type="monochrome" />
96− <Text
97− className={cn(
98− "text-xs font-t3-bold",
99− props.active ? "text-primary-foreground" : "text-foreground-muted",
100− )}
101− >
102− {props.label}
103− </Text>
104− </Pressable>
105− );
106−}
107−
108−function BreadcrumbFade(props: { readonly color: string; readonly side: "left" | "right" }) {
109− const gradientId = `file-breadcrumb-${props.side}-fade`;
110− const isLeft = props.side === "left";
111−
112− return (
113− <View
114− pointerEvents="none"
115− className={cn("absolute inset-y-0 w-7", isLeft ? "left-0" : "right-0")}
116− >
117− <Svg width="100%" height="100%">
118− <Defs>
119− <LinearGradient id={gradientId} x1="0%" x2="100%" y1="0%" y2="0%">
120− <Stop offset="0%" stopColor={props.color} stopOpacity={isLeft ? 1 : 0} />
121− <Stop offset="100%" stopColor={props.color} stopOpacity={isLeft ? 0 : 1} />
122− </LinearGradient>
123− </Defs>
124− <Rect width="100%" height="100%" fill={`url(#${gradientId})`} />
125− </Svg>
126− </View>
127− );
128−}
129−
130−function FileBreadcrumbs(props: { readonly projectName: string; readonly relativePath: string }) {
131− const iconColor = String(useThemeColor("--color-icon-muted"));
132− const cardColor = String(useThemeColor("--color-card"));
133− const scrollMetrics = useRef({ contentWidth: 0, offsetX: 0, viewportWidth: 0 });
134− const [fadeVisibility, setFadeVisibility] = useState({ left: false, right: false });
135− const breadcrumbs = useMemo(
136− () => fileBreadcrumbs(props.projectName, props.relativePath),
137− [props.projectName, props.relativePath],
138− );
139− const updateFadeVisibility = useCallback(
140− (metrics: Partial<(typeof scrollMetrics)["current"]>) => {
141− Object.assign(scrollMetrics.current, metrics);
142− const { contentWidth, offsetX, viewportWidth } = scrollMetrics.current;
143− const maxOffset = Math.max(0, contentWidth - viewportWidth);
144− const next = {
145− left: maxOffset > 1 && offsetX > 1,
146− right: maxOffset > 1 && offsetX < maxOffset - 1,
147− };
148−
149− setFadeVisibility((current) =>
150− current.left === next.left && current.right === next.right ? current : next,
151− );
152− },
153− [],
154− );
155−
156− return (
157− <View className="min-w-0 flex-1">
158− <ScrollView
159− horizontal
160− showsHorizontalScrollIndicator={false}
161− onContentSizeChange={(contentWidth) => {
162− updateFadeVisibility({ contentWidth });
163− }}
164− onLayout={(event) => {
165− updateFadeVisibility({ viewportWidth: event.nativeEvent.layout.width });
166− }}
167− onScroll={(event) => {
168− updateFadeVisibility({ offsetX: event.nativeEvent.contentOffset.x });
169− }}
170− scrollEventThrottle={16}
171− >
172− <View className="h-8 flex-row items-center">
173− {breadcrumbs.map((crumb, index) => (
174− <View key={crumb.path || "project"} className="flex-row items-center">
175− {index > 0 ? (
176− <SymbolView
177− name="chevron.right"
178− size={10}
179− tintColor={iconColor}
180− type="monochrome"
181− />
182− ) : null}
183− <Text
184− className={cn(
185− "max-w-[180px] px-1 text-xs",
186− crumb.kind === "file"
187− ? "font-t3-bold text-foreground"
188− : "font-t3-medium text-foreground-muted",
189− )}
190− numberOfLines={1}
191− >
192− {crumb.label}
193− </Text>
194− </View>
195− ))}
196− </View>
197− </ScrollView>
198− {fadeVisibility.left ? <BreadcrumbFade color={cardColor} side="left" /> : null}
199− {fadeVisibility.right ? <BreadcrumbFade color={cardColor} side="right" /> : null}
200− </View>
201− );
202−}
203−
204−function FilePreviewHeader(props: {
205− readonly activeMode: FileViewMode;
206− readonly showModeSelector: boolean;
207− readonly externalPreviewUri?: string | null;
208− readonly projectName: string;
209− readonly relativePath: string;
210− readonly onSetMode: (mode: FileViewMode) => void;
211−}) {
212− const iconColor = String(useThemeColor("--color-icon-muted"));
213−
214− return (
215− <View className="border-b border-border bg-card px-3 py-2">
216− <View className="flex-row items-center gap-2">
217− <FileBreadcrumbs projectName={props.projectName} relativePath={props.relativePath} />
218− <CopyTextButton
219− accessibilityLabel="Copy file path"
220− text={props.relativePath}
221− tintColor={iconColor}
222− buttonSize={32}
223− iconSize={13}
224− />
225− </View>
226− {props.showModeSelector ? (
227− <View className="mt-2 flex-row items-center gap-2">
228− <ModeButton
229− active={props.activeMode === "preview"}
230− icon="eye"
231− label="Preview"
232− onPress={() => props.onSetMode("preview")}
233− />
234− <ModeButton
235− active={props.activeMode === "source"}
236− icon="doc.text"
237− label="Source"
238− onPress={() => props.onSetMode("source")}
239− />
240− {props.externalPreviewUri !== undefined ? (
241− <Pressable
242− accessibilityRole="button"
243− accessibilityLabel="Open preview in Safari"
244− disabled={props.externalPreviewUri === null}
245− hitSlop={8}
246− className={cn(
247− "ml-auto h-8 w-8 items-center justify-center rounded-full bg-subtle active:opacity-70",
248− props.externalPreviewUri === null && "opacity-40",
249− )}
250− onPress={() => {
251− if (typeof props.externalPreviewUri === "string") {
252− void tryOpenExternalUrl(props.externalPreviewUri, "file-preview");
253− }
254− }}
255− >
256− <SymbolView name="safari" size={15} tintColor={iconColor} type="monochrome" />
257− </Pressable>
258− ) : null}
259− </View>
260− ) : null}
261− </View>
262− );
263−}
264−
26591 function FileContent(props: {
26692 readonly activeMode: FileViewMode;
26793 readonly previewUri: string | null;
@@ -270,6 +96,7 @@ function FileContent(props: { · 2 unmodified lines
27096 readonly relativePath: string;
27197 readonly initialLine: number | null;
27298 readonly truncated: boolean;
99+ readonly onRefresh?: () => Promise<void> | void;
273100 }) {
274101 const isMarkdown = isMarkdownPreviewFile(props.relativePath);
275102 const isBrowserFile = isBrowserPreviewFile(props.relativePath);
@@ -293,7 +120,7 @@ function FileContent(props: { · 17 unmodified lines
293120
294121 if (props.fileError && props.fileContents === null) {
295122 return (
296− <View className="flex-1 items-center justify-center bg-card px-6">
123+ <View className="flex-1 items-center justify-center bg-sheet px-6">
297124 <EmptyState title="File unavailable" detail={props.fileError} />
298125 </View>
299126 );
@@ -301,7 +128,7 @@ function FileContent(props: { · 1 unmodified line
301128
302129 if (props.fileContents === null) {
303130 return (
304− <View className="flex-1 items-center justify-center gap-3 bg-card px-6">
131+ <View className="flex-1 items-center justify-center gap-3 bg-sheet px-6">
305132 <ActivityIndicator />
306133 <Text className="text-center text-sm text-foreground-muted">Loading file...</Text>
307134 </View>
@@ -309,35 +136,47 @@ function FileContent(props: { · 1 unmodified line
309136 }
310137
311138 return (
312− <View className="flex-1 bg-card">
139+ <View className="flex-1 bg-sheet">
313140 {props.truncated ? (
314141 <View className="border-b border-amber-200 bg-amber-50 px-4 py-2 dark:border-amber-900/60 dark:bg-amber-950/40">
315142 <Text className="text-2xs font-t3-bold uppercase text-amber-700 dark:text-amber-300">
316143 Partial file
317144 </Text>
318− <Text className="text-xs leading-[17px] text-amber-800 dark:text-amber-200">
145+ <Text className="text-xs leading-snug text-amber-800 dark:text-amber-200">
319146 Preview limited to the first 1 MB of a truncated file.
320147 </Text>
321148 </View>
322149 ) : null}
323150 {props.activeMode === "preview" && isMarkdown ? (
324− <FileMarkdownPreview markdown={props.fileContents} />
151+ <FileMarkdownPreview markdown={props.fileContents} onRefresh={props.onRefresh} />
325152 ) : (
326153 <SourceFileSurface
327154 contents={props.fileContents}
328155 path={props.relativePath}
329156 initialLine={props.initialLine}
157+ onRefresh={props.onRefresh}
330158 />
331159 )}
332160 </View>
333161 );
334162 }
335163
336−function useThreadFilesWorkspace() {
337− const params = useLocalSearchParams<{
338− environmentId?: string | string[];
339− threadId?: string | string[];
340− }>();
164+type ThreadFilesRouteScreenProps = StaticScreenProps<{
165+ readonly environmentId: string;
166+ readonly threadId: string;
167+}>;
168+
169+type ThreadFileRouteScreenProps = StaticScreenProps<{
170+ readonly environmentId: string;
171+ readonly threadId: string;
172+ readonly path: string[];
173+ readonly line?: string;
174+}>;
175+
176+function useThreadFilesWorkspace(params: {
177+ readonly environmentId?: string | string[];
178+ readonly threadId?: string | string[];
179+}) {
341180 const routeEnvironmentId = firstRouteParam(params.environmentId);
342181 const routeThreadId = firstRouteParam(params.threadId);
343182 const { selectedThread, selectedThreadProject } = useThreadSelection();
@@ -364,7 +203,7 @@ function useThreadFilesWorkspace() { · 20 unmodified lines
364203 function FilesUnavailable() {
365204 return (
366205 <View className="flex-1 items-center justify-center bg-sheet px-6">
367− <Stack.Screen options={{ title: "Files" }} />
206+ <NativeStackScreenOptions options={{ title: "Files" }} />
368207 <EmptyState
369208 title="Files unavailable"
370209 detail="This thread does not have an active workspace path."
@@ -373,40 +212,6 @@ function FilesUnavailable() { · 2 unmodified lines
373212 );
374213 }
375214
376−function FilesHeaderTitle(props: { readonly projectName: string }) {
377− const foregroundColor = String(useThemeColor("--color-foreground"));
378− const secondaryForegroundColor = String(useThemeColor("--color-foreground-secondary"));
379−
380− return (
381− <View style={{ alignItems: "center", maxWidth: 220 }}>
382− <RNText
383− numberOfLines={1}
384− style={{
385− color: foregroundColor,
386− fontFamily: "DMSans_700Bold",
387− fontSize: MOBILE_TYPOGRAPHY.headline.fontSize,
388− fontWeight: "900",
389− letterSpacing: -0.4,
390− }}
391− >
392− Files
393− </RNText>
394− <RNText
395− numberOfLines={1}
396− style={{
397− color: secondaryForegroundColor,
398− fontFamily: "DMSans_500Medium",
399− fontSize: MOBILE_TYPOGRAPHY.label.fontSize,
400− fontWeight: "500",
401− letterSpacing: 0.2,
402− }}
403− >
404− {props.projectName}
405− </RNText>
406− </View>
407− );
408−}
409−
410215 function FilesToolbarBottomFade() {
411216 const sheetColor = String(useThemeColor("--color-sheet"));
412217
@@ -442,12 +247,20 @@ function FilesToolbarBottomFade() { · 29 unmodified lines
442247 );
443248 }
444249
445−export function ThreadFilesTreeScreen() {
446− const router = useRouter();
250+export function ThreadFilesTreeScreen(props: ThreadFilesRouteScreenProps) {
251+ useAdaptiveWorkspacePaneRole("inspector");
252+ const navigation = useNavigation();
253+ const { fileInspector, layout, panes, showAuxiliaryPane, togglePrimarySidebar } =
254+ useAdaptiveWorkspaceLayout();
447255 const [searchQuery, setSearchQuery] = useState("");
448− const { cwd, environmentId, projectName, selectedThread, threadId } = useThreadFilesWorkspace();
256+ const colorScheme = useColorScheme();
257+ const highlightTheme = colorScheme === "dark" ? "dark" : "light";
258+ const { cwd, environmentId, projectName, selectedThread, threadId } = useThreadFilesWorkspace(
259+ props.route.params,
260+ );
261+ const revealedInspectorRef = useRef(false);
449262 const entriesQuery = useEnvironmentQuery(
450− environmentId !== null && cwd !== null
263+ environmentId !== null && cwd !== null && !fileInspector.supported
451264 ? projectEnvironment.listEntries({
452265 environmentId,
453266 input: { cwd },
@@ -455,18 +268,87 @@ export function ThreadFilesTreeScreen() { · 1 unmodified line
455268 : null,
456269 );
457270 const entriesData = entriesQuery.data as ProjectListEntriesResult | null;
271+ const handleReturnToThread = useCallback(() => {
272+ if (navigation.canGoBack()) {
273+ navigation.goBack();
274+ return;
275+ }
276+ if (environmentId !== null && threadId !== null) {
277+ navigation.dispatch(
278+ StackActions.replace("Thread", {
279+ environmentId: String(environmentId),
280+ threadId: String(threadId),
281+ }),
282+ );
283+ }
284+ }, [environmentId, navigation, threadId]);
458285
459286 const handleSelectFile = useCallback(
460287 (path: string) => {
461288 if (environmentId === null || threadId === null) {
462289 return;
463290 }
464− router.push(buildThreadFilesNavigation({ environmentId, threadId }, path));
291+ const params = {
292+ environmentId: String(environmentId),
293+ threadId: String(threadId),
294+ path: path.split("/").filter((segment) => segment.length > 0),
295+ };
296+ const navigationAction = resolveFileSelectionNavigationAction({
297+ hasPersistentFileInspector: fileInspector.supported,
298+ });
299+ if (navigationAction === "replace") {
300+ navigation.dispatch(StackActions.replace("ThreadFile", params));
301+ return;
302+ }
303+ navigation.navigate("ThreadFile", params);
465304 },
466− [environmentId, router, threadId],
305+ [environmentId, fileInspector.supported, navigation, threadId],
467306 );
307+ const renderInspector = useCallback(
308+ (headerInset: number) =>
309+ environmentId !== null && cwd !== null ? (
310+ <ThreadFileNavigatorPane
311+ cwd={cwd}
312+ environmentId={environmentId}
313+ headerInset={headerInset}
314+ projectName={projectName}
315+ selectedPath={null}
316+ onSelectFile={handleSelectFile}
317+ />
318+ ) : null,
319+ [cwd, environmentId, handleSelectFile, projectName],
320+ );
321+ const handlePreviewFile = useCallback(
322+ (relativePath: string) => {
323+ if (environmentId === null || cwd === null) {
324+ return;
325+ }
326+ preloadWorkspaceFileContents({
327+ cwd,
328+ environmentId,
329+ relativePath,
330+ theme: highlightTheme,
331+ });
332+ },
333+ [cwd, environmentId, highlightTheme],
334+ );
335+ useEffect(() => {
336+ if (fileInspector.supported && cwd !== null && !revealedInspectorRef.current) {
337+ revealedInspectorRef.current = true;
338+ showAuxiliaryPane("inspector");
339+ }
340+ }, [cwd, fileInspector.supported, showAuxiliaryPane]);
468341
469342 if (selectedThread === null || environmentId === null || threadId === null) {
343+ if (fileInspector.supported) {
344+ return (
345+ <ThreadRouteScreen
346+ onReturnToThread={handleReturnToThread}
347+ renderInspector={renderInspector}
348+ route={props.route}
349+ />
350+ );
351+ }
470352 return <LoadingScreen message="Opening files..." messagePlacement="above-spinner" />;
471353 }
472354
@@ -474,62 +356,95 @@ export function ThreadFilesTreeScreen() { · 1 unmodified line
474356 return <FilesUnavailable />;
475357 }
476358
359+ if (fileInspector.supported) {
360+ return (
361+ <ThreadRouteScreen
362+ onReturnToThread={handleReturnToThread}
363+ renderInspector={renderInspector}
364+ route={props.route}
365+ />
366+ );
367+ }
368+
369+ const usesCompactMailToolbar = Platform.OS === "ios" && !layout.usesSplitView;
370+
477371 return (
478− <View className="flex-1 bg-sheet">
479− <Stack.Screen
372+ <>
373+ {/* Static header config (glass preset, title, contentStyle) lives in Stack.tsx.
374+ Only genuinely dynamic options are set here. */}
375+ <NativeStackScreenOptions
480376 options={{
481− title: "Files",
482− headerShown: true,
483− headerTransparent: true,
484− headerStyle: { backgroundColor: "transparent" },
485− headerShadowVisible: false,
486− headerTitle: () => <FilesHeaderTitle projectName={projectName} />,
487− headerSearchBarOptions: {
488− allowToolbarIntegration: true,
489− autoCapitalize: "none",
490− hideNavigationBar: false,
491− placeholder: "Search files",
492− onChangeText: (event) => {
493− setSearchQuery(event.nativeEvent.text);
494− },
495− onCancelButtonPress: () => {
496− setSearchQuery("");
497− },
498− },
377+ unstable_headerSubtitle:
378+ Platform.OS === "ios" && projectName.length > 0 ? projectName : undefined,
379+ // No refresh button: the list already supports pull-to-refresh.
380+ unstable_headerToolbarItems: usesCompactMailToolbar
381+ ? () => [
382+ createNativeMailSearchToolbarItem({
383+ onSearchTextChange: setSearchQuery,
384+ placeholder: "Search files",
385+ searchTextChangeId: "files-search-text",
386+ }),
387+ ]
388+ : undefined,
389+ headerSearchBarOptions: usesCompactMailToolbar
390+ ? undefined
391+ : {
392+ allowToolbarIntegration: true,
393+ autoCapitalize: "none",
394+ hideNavigationBar: false,
395+ placeholder: "Search files",
396+ onChangeText: (event) => {
397+ setSearchQuery(event.nativeEvent.text);
398+ },
399+ onCancelButtonPress: () => {
400+ setSearchQuery("");
401+ },
402+ },
499403 }}
500404 />
501− <Stack.Toolbar placement="right">
502− <Stack.Toolbar.Button
503− accessibilityLabel="Refresh files"
504− icon="arrow.clockwise"
505− onPress={entriesQuery.refresh}
506− />
507− </Stack.Toolbar>
508− <Stack.Toolbar placement="bottom">
509− <Stack.Toolbar.SearchBarSlot />
510− </Stack.Toolbar>
405+ {layout.usesSplitView ? (
406+ <NativeHeaderToolbar placement="left">
407+ <NativeHeaderToolbar.Button
408+ accessibilityLabel={panes.primarySidebarVisible ? "Maximize files" : "Show threads"}
409+ icon={
410+ panes.primarySidebarVisible ? "arrow.up.left.and.arrow.down.right" : "sidebar.left"
411+ }
412+ onPress={togglePrimarySidebar}
413+ separateBackground
414+ />
415+ </NativeHeaderToolbar>
416+ ) : null}
417+ {usesCompactMailToolbar ? null : (
418+ <NativeHeaderToolbar placement="bottom">
419+ <NativeHeaderToolbar.SearchBarSlot />
420+ </NativeHeaderToolbar>
421+ )}
511422 <FileTreeBrowser
512423 entries={entriesData?.entries ?? []}
513424 error={entriesQuery.error}
514425 isPending={entriesQuery.isPending}
515426 searchQuery={searchQuery}
516427 selectedPath={null}
428+ onPreviewFile={handlePreviewFile}
517429 onRefresh={entriesQuery.refresh}
518430 onSelectFile={handleSelectFile}
519431 />
520432 <FilesToolbarBottomFade />
521− </View>
433+ </>
522434 );
523435 }
524436
525−export function ThreadFileScreen() {
526− const params = useLocalSearchParams<{
527− line?: string | string[];
528− path?: string | string[];
529− }>();
437+export function ThreadFileScreen(props: ThreadFileRouteScreenProps) {
438+ useAdaptiveWorkspacePaneRole("inspector");
439+ const navigation = useNavigation();
440+ const { fileInspector, panes, toggleAuxiliaryPane } = useAdaptiveWorkspaceLayout();
441+ const iconColor = useThemeColor("--color-icon");
442+ const params = props.route.params;
530443 const relativePath = normalizeRoutePath(params.path);
531444 const targetLine = normalizeRouteLine(firstRouteParam(params.line));
532− const { cwd, environmentId, projectName, selectedThread, threadId } = useThreadFilesWorkspace();
445+ const { cwd, environmentId, projectName, selectedThread, threadId } = useThreadFilesWorkspace(
446+ props.route.params,
447+ );
533448 const [modeOverride, setModeOverride] = useState<{
534449 readonly path: string;
535450 readonly mode: FileViewMode;
@@ -568,6 +483,43 @@ export function ThreadFileScreen() { · 32 unmodified lines
568483 );
569484 const fileData = fileQuery.data as ProjectReadFileResult | null;
570485
486+ const handleSelectFile = useCallback(
487+ (path: string) => {
488+ navigation.navigate("ThreadFile", {
489+ environmentId: String(environmentId),
490+ threadId: String(threadId),
491+ path: path.split("/").filter(Boolean),
492+ });
493+ },
494+ [environmentId, navigation, threadId],
495+ );
496+ const renderInspector = useCallback(
497+ (headerInset: number) =>
498+ fileInspector.supported && environmentId !== null && cwd !== null ? (
499+ <ThreadFileNavigatorPane
500+ cwd={cwd}
501+ environmentId={environmentId}
502+ headerInset={headerInset}
503+ projectName={projectName}
504+ selectedPath={relativePath}
505+ onSelectFile={handleSelectFile}
506+ />
507+ ) : undefined,
508+ [cwd, environmentId, fileInspector.supported, handleSelectFile, projectName, relativePath],
509+ );
510+ // The workspace inspector column spans the full window height. On iOS the
511+ // pane brings its own nested native header; elsewhere it pads itself below
512+ // the top inset.
513+ const safeAreaInsets = useSafeAreaInsets();
514+ const inspectorHeaderInset = Platform.OS === "ios" ? 0 : safeAreaInsets.top;
515+ // Hand the file navigator to the workspace so it renders beside the
516+ // navigator, outside this screen's native header.
517+ const renderWorkspaceInspector = useCallback(
518+ () => renderInspector(inspectorHeaderInset),
519+ [inspectorHeaderInset, renderInspector],
520+ );
521+ useRegisterWorkspaceInspector(fileInspector.supported ? renderWorkspaceInspector : undefined);
522+
571523 if (selectedThread === null || environmentId === null || threadId === null) {
572524 return <LoadingScreen message="Opening file..." messagePlacement="above-spinner" />;
573525 }
@@ -579,38 +531,104 @@ export function ThreadFileScreen() { · 5 unmodified lines
579531 if (relativePath === null) {
580532 return (
581533 <View className="flex-1 items-center justify-center bg-sheet px-6">
582− <Stack.Screen options={{ title: "Files" }} />
534+ <NativeStackScreenOptions options={{ title: "Files" }} />
583535 <EmptyState title="File unavailable" detail="This file path is invalid." />
584536 </View>
585537 );
586538 }
587539
540+ const parentDir = relativePath.split("/").slice(0, -1).join("/");
541+ const headerSubtitle = [projectName, parentDir].filter(Boolean).join(" · ");
542+
588543 return (
589544 <ReviewHighlighterProvider>
590545 <View className="flex-1 bg-sheet">
591− <Stack.Screen options={{ title: basename(relativePath) }} />
592− <Stack.Toolbar placement="right">
593− <Stack.Toolbar.Button
594− icon="arrow.clockwise"
595− onPress={() => {
596− if (resolvedActiveMode === "preview" && (isBrowserFile || isImageFile)) {
597− setPreviewRevision((current) => current + 1);
598− return;
599− }
600− fileQuery.refresh();
601− }}
602− />
603− </Stack.Toolbar>
604− <FilePreviewHeader
605− activeMode={resolvedActiveMode}
606− showModeSelector={canPreview && !isImageFile}
607− externalPreviewUri={isBrowserFile ? assetPreviewUri : undefined}
608− projectName={projectName}
609− relativePath={relativePath}
610− onSetMode={(mode) => {
611− setModeOverride({ path: relativePath, mode });
546+ <NativeStackScreenOptions
547+ options={{
548+ // Static header config lives in Stack.tsx (SOLID_HEADER_OPTIONS: solid
549+ // sheet-colored header — this route's content scrolls internally, so
550+ // there is nothing for glass to sample). Only dynamic values here.
551+ headerTintColor: iconColor,
552+ headerTitle: basename(relativePath),
553+ title: basename(relativePath),
554+ unstable_headerSubtitle:
555+ Platform.OS === "ios" && headerSubtitle.length > 0 ? headerSubtitle : undefined,
612556 }}
613557 />
558+ <WorkspaceSidebarToolbar>
559+ {fileInspector.supported ? (
560+ <NativeHeaderToolbar.Button
561+ accessibilityLabel="Return to chat"
562+ icon="chevron.left"
563+ onPress={() => {
564+ navigation.dispatch(
565+ StackActions.replace("Thread", {
566+ environmentId: String(environmentId),
567+ threadId: String(threadId),
568+ }),
569+ );
570+ }}
571+ />
572+ ) : null}
573+ </WorkspaceSidebarToolbar>
574+ <NativeHeaderToolbar placement="right">
575+ {fileInspector.supported ? (
576+ <NativeHeaderToolbar.Button
577+ accessibilityLabel={
578+ panes.auxiliaryPaneVisible ? "Hide file navigator" : "Show file navigator"
579+ }
580+ icon="sidebar.right"
581+ onPress={toggleAuxiliaryPane}
582+ separateBackground
583+ />
584+ ) : null}
585+ <NativeHeaderToolbar.Menu accessibilityLabel="File actions" icon="ellipsis">
586+ {canPreview && !isImageFile ? (
587+ <NativeHeaderToolbar.Menu inline>
588+ <NativeHeaderToolbar.MenuAction
589+ icon="eye"
590+ isOn={resolvedActiveMode === "preview"}
591+ onPress={() => setModeOverride({ path: relativePath, mode: "preview" })}
592+ >
593+ Preview
594+ </NativeHeaderToolbar.MenuAction>
595+ <NativeHeaderToolbar.MenuAction
596+ icon="doc.text"
597+ isOn={resolvedActiveMode === "source"}
598+ onPress={() => setModeOverride({ path: relativePath, mode: "source" })}
599+ >
600+ Source
601+ </NativeHeaderToolbar.MenuAction>
602+ </NativeHeaderToolbar.Menu>
603+ ) : null}
604+ <NativeHeaderToolbar.MenuAction
605+ icon="doc.on.doc"
606+ onPress={() => copyTextWithHaptic(relativePath)}
607+ >
608+ Copy path
609+ </NativeHeaderToolbar.MenuAction>
610+ {isBrowserFile && typeof assetPreviewUri === "string" ? (
611+ <NativeHeaderToolbar.MenuAction
612+ icon="safari"
613+ onPress={() => {
614+ void tryOpenExternalUrl(assetPreviewUri, "file-preview");
615+ }}
616+ >
617+ Open in Safari
618+ </NativeHeaderToolbar.MenuAction>
619+ ) : null}
620+ {resolvedActiveMode === "preview" && (isBrowserFile || isImageFile) ? (
621+ <NativeHeaderToolbar.MenuAction
622+ icon="arrow.clockwise"
623+ onPress={() => {
624+ setPreviewRevision((current) => current + 1);
625+ }}
626+ >
627+ Refresh
628+ </NativeHeaderToolbar.MenuAction>
629+ ) : null}
630+ </NativeHeaderToolbar.Menu>
631+ </NativeHeaderToolbar>
614632 <FileContent
615633 activeMode={resolvedActiveMode}
616634 previewUri={previewUri}
@@ -619,6 +637,7 @@ export function ThreadFileScreen() { · 2 unmodified lines
619637 initialLine={targetLine}
620638 relativePath={relativePath}
621639 truncated={fileData?.truncated ?? false}
640+ onRefresh={() => fileQuery.refresh()}
622641 />
623642 </View>
624643 </ReviewHighlighterProvider>
apps/mobile/src/features/files/WorkspaceFileWebPreview.tsx+1−1
@@ -24,7 +24,7 @@ export function WorkspaceFileWebPreview(props: { readonly uri: string | null }) · 23 unmodified lines
2424 {loadError ? (
2525 <View className="border-b border-border bg-card px-4 py-2">
2626 <Text className="text-xs font-t3-bold text-foreground">Preview failed</Text>
27− <Text className="mt-0.5 text-xs leading-[17px] text-foreground-muted">{loadError}</Text>
27+ <Text className="mt-0.5 text-xs leading-snug text-foreground-muted">{loadError}</Text>
2828 </View>
2929 ) : null}
3030 <WebView
apps/mobile/src/features/files/nativeSourceFileAdapter.ts+26−14
@@ -3,26 +3,38 @@ import type { · 2 unmodified lines
33 NativeReviewDiffStyle,
44 NativeReviewDiffToken,
55 } from "../diffs/nativeReviewDiffSurface";
6+import type { ResolvedMobileCodeSurface } from "../../lib/appearancePreferences";
7+import { resolveMobileCodeSurface } from "../../lib/appearancePreferences";
68 import { MOBILE_CODE_SURFACE, MOBILE_TYPOGRAPHY } from "../../lib/typography";
79 import type { SourceHighlightTokens } from "./sourceHighlightingState";
810
911 export const NATIVE_SOURCE_ROW_HEIGHT = MOBILE_CODE_SURFACE.rowHeight;
1012 export const NATIVE_SOURCE_CONTENT_WIDTH = 32_000;
1113
12−export const NATIVE_SOURCE_STYLE: NativeReviewDiffStyle = {
13− rowHeight: NATIVE_SOURCE_ROW_HEIGHT,
14− contentWidth: NATIVE_SOURCE_CONTENT_WIDTH,
15− changeBarWidth: 0,
16− gutterWidth: MOBILE_CODE_SURFACE.gutterWidth,
17− codePadding: MOBILE_CODE_SURFACE.codePadding,
18− textVerticalInset: MOBILE_CODE_SURFACE.textVerticalInset,
19− codeFontSize: MOBILE_CODE_SURFACE.fontSize,
20− codeFontWeight: "regular",
21− lineNumberFontSize: MOBILE_CODE_SURFACE.lineNumberFontSize,
22− lineNumberFontWeight: "regular",
23− emptyStateFontSize: MOBILE_TYPOGRAPHY.label.fontSize,
24− emptyStateFontWeight: "medium",
25−};
14+export const NATIVE_SOURCE_STYLE: NativeReviewDiffStyle = createNativeSourceStyle(
15+ resolveMobileCodeSurface(MOBILE_CODE_SURFACE.fontSize),
16+);
17+
18+export function createNativeSourceStyle(
19+ codeSurface: ResolvedMobileCodeSurface,
20+): NativeReviewDiffStyle {
21+ return {
22+ rowHeight: codeSurface.rowHeight,
23+ contentWidth: NATIVE_SOURCE_CONTENT_WIDTH,
24+ changeBarWidth: 0,
25+ gutterWidth: codeSurface.gutterWidth,
26+ codePadding: codeSurface.codePadding,
27+ textVerticalInset: codeSurface.textVerticalInset,
28+ codeFontSize: codeSurface.fontSize,
29+ codeFontWeight: "regular",
30+ lineNumberFontSize: codeSurface.lineNumberFontSize,
31+ lineNumberFontWeight: "regular",
32+ emptyStateFontSize: Math.round(
33+ MOBILE_TYPOGRAPHY.label.fontSize * (codeSurface.fontSize / MOBILE_CODE_SURFACE.fontSize),
34+ ),
35+ emptyStateFontWeight: "medium",
36+ };
37+}
2638
2739 const SOURCE_FILE_ID = "source-file";
2840
apps/mobile/src/features/files/preload-workspace-file.ts+74−0
@@ -0,0 +1,74 @@
1+import { executeAtomQuery } from "@t3tools/client-runtime/state/runtime";
2+import type { EnvironmentId } from "@t3tools/contracts";
3+
4+import { appAtomRegistry } from "../../state/atom-registry";
5+import { projectEnvironment } from "../../state/projects";
6+import { isBrowserPreviewFile, isImagePreviewFile } from "./filePath";
7+import { prepareSourceFileDocument } from "./source-file-document";
8+import { sourceHighlightAtom } from "./sourceHighlightingState";
9+import type { ReviewDiffTheme } from "../review/shikiReviewHighlighter";
10+
11+const inFlightPreloads = new Map<string, Promise<void>>();
12+const MAX_HIGHLIGHT_PRELOAD_CHARACTERS = 256 * 1024;
13+
14+function preloadKey(input: {
15+ readonly cwd: string;
16+ readonly environmentId: EnvironmentId;
17+ readonly relativePath: string;
18+}): string {
19+ return JSON.stringify([input.environmentId, input.cwd, input.relativePath]);
20+}
21+
22+export function preloadWorkspaceFileContents(input: {
23+ readonly cwd: string;
24+ readonly environmentId: EnvironmentId;
25+ readonly relativePath: string;
26+ readonly theme: ReviewDiffTheme;
27+}): void {
28+ if (isBrowserPreviewFile(input.relativePath) || isImagePreviewFile(input.relativePath)) {
29+ return;
30+ }
31+
32+ const key = preloadKey(input);
33+ if (inFlightPreloads.has(key)) {
34+ return;
35+ }
36+
37+ const preload = executeAtomQuery(
38+ appAtomRegistry,
39+ projectEnvironment.readFile({
40+ environmentId: input.environmentId,
41+ input: { cwd: input.cwd, relativePath: input.relativePath },
42+ }),
43+ {
44+ label: "workspace file preload",
45+ reportDefect: false,
46+ reportFailure: false,
47+ },
48+ )
49+ .then(async (result) => {
50+ if (result._tag === "Success") {
51+ const document = prepareSourceFileDocument(result.value.contents);
52+ if (document.contents.length <= MAX_HIGHLIGHT_PRELOAD_CHARACTERS) {
53+ await executeAtomQuery(
54+ appAtomRegistry,
55+ sourceHighlightAtom({
56+ path: input.relativePath,
57+ contents: document.contents,
58+ theme: input.theme,
59+ }),
60+ {
61+ label: "workspace source highlight preload",
62+ reportDefect: false,
63+ reportFailure: false,
64+ },
65+ );
66+ }
67+ }
68+ })
69+ .finally(() => {
70+ inFlightPreloads.delete(key);
71+ });
72+
73+ inFlightPreloads.set(key, preload);
74+}
apps/mobile/src/features/files/source-file-document.test.ts+16−0
@@ -0,0 +1,16 @@
1+import { describe, expect, it } from "vite-plus/test";
2+
3+import { prepareSourceFileDocument } from "./source-file-document";
4+
5+describe("prepareSourceFileDocument", () => {
6+ it("normalizes and serializes source rows once for repeated consumers", () => {
7+ const first = prepareSourceFileDocument("const value = 1;\r\n\tvalue;\r");
8+ const second = prepareSourceFileDocument("const value = 1;\r\n\tvalue;\r");
9+ const rows = JSON.parse(first.rowsJson) as ReadonlyArray<{ readonly content: string }>;
10+
11+ expect(first.contents).toBe("const value = 1;\n\tvalue;\n");
12+ expect(first.lines).toEqual(["const value = 1;", "\tvalue;", ""]);
13+ expect(rows.map((row) => row.content)).toEqual(["const value = 1;", " value;", ""]);
14+ expect(second).toBe(first);
15+ });
16+});
apps/mobile/src/features/files/source-file-document.ts+54−0
@@ -0,0 +1,54 @@
1+import { buildNativeSourceRows } from "./nativeSourceFileAdapter";
2+
3+const MAX_CACHED_DOCUMENTS = 8;
4+const MAX_CACHED_CHARACTERS = 4 * 1024 * 1024;
5+
6+export interface SourceFileDocument {
7+ readonly contents: string;
8+ readonly lines: ReadonlyArray<string>;
9+ readonly rowsJson: string;
10+}
11+
12+const documentCache = new Map<string, SourceFileDocument>();
13+let cachedCharacterCount = 0;
14+
15+function removeOldestCachedDocument(): void {
16+ const oldestKey = documentCache.keys().next().value;
17+ if (typeof oldestKey !== "string") {
18+ return;
19+ }
20+ const document = documentCache.get(oldestKey);
21+ documentCache.delete(oldestKey);
22+ cachedCharacterCount -= (document?.contents.length ?? 0) + (document?.rowsJson.length ?? 0);
23+}
24+
25+export function prepareSourceFileDocument(contents: string): SourceFileDocument {
26+ const cached = documentCache.get(contents);
27+ if (cached !== undefined) {
28+ documentCache.delete(contents);
29+ documentCache.set(contents, cached);
30+ return cached;
31+ }
32+
33+ const normalizedContents = contents.replace(/\r\n?/g, "\n");
34+ const lines = normalizedContents.split("\n");
35+ const document = {
36+ contents: normalizedContents,
37+ lines,
38+ rowsJson: JSON.stringify(buildNativeSourceRows(lines)),
39+ } satisfies SourceFileDocument;
40+ const characterCount = document.contents.length + document.rowsJson.length;
41+
42+ if (characterCount <= MAX_CACHED_CHARACTERS) {
43+ while (
44+ documentCache.size >= MAX_CACHED_DOCUMENTS ||
45+ cachedCharacterCount + characterCount > MAX_CACHED_CHARACTERS
46+ ) {
47+ removeOldestCachedDocument();
48+ }
49+ documentCache.set(contents, document);
50+ cachedCharacterCount += characterCount;
51+ }
52+
53+ return document;
54+}
apps/mobile/src/features/files/sourceHighlightingState.test.ts+1−1
@@ -88,7 +88,7 @@ describe("sourceHighlightingState", () => { · 87 unmodified lines
8888 expect(AsyncResult.isSuccess(registry.get(atom))).toBe(true);
8989 });
9090 firstUnmount();
91− await new Promise((resolve) => setTimeout(resolve, 25));
91+ await new Promise((resolve) => setTimeout(resolve, 100));
9292
9393 const secondUnmount = registry.mount(atom);
9494 await vi.waitFor(() => {
apps/mobile/src/features/files/thread-file-navigator-pane.tsx+175−0
@@ -0,0 +1,175 @@
1+import type { EnvironmentId, ProjectListEntriesResult } from "@t3tools/contracts";
2+import { SymbolView } from "expo-symbols";
3+import { useCallback, useMemo, useState, type ComponentProps } from "react";
4+import { Platform, Pressable, useColorScheme, View, type NativeSyntheticEvent } from "react-native";
5+import {
6+ Screen,
7+ ScreenStack,
8+ ScreenStackHeaderConfig,
9+ ScreenStackHeaderSearchBarView,
10+ SearchBar,
11+} from "react-native-screens";
12+
13+import { AppText as Text, AppTextInput as TextInput } from "../../components/AppText";
14+import { nativeHeaderScrollEdgeEffects } from "../../native/StackHeader";
15+import { useThemeColor } from "../../lib/useThemeColor";
16+import { projectEnvironment } from "../../state/projects";
17+import { useEnvironmentQuery } from "../../state/query";
18+import { FileTreeBrowser } from "./FileTreeBrowser";
19+import { preloadWorkspaceFileContents } from "./preload-workspace-file";
20+
21+export function ThreadFileNavigatorPane(props: {
22+ readonly cwd: string;
23+ readonly environmentId: EnvironmentId;
24+ readonly headerInset: number;
25+ readonly projectName: string;
26+ readonly selectedPath: string | null;
27+ readonly onSelectFile: (path: string) => void;
28+}) {
29+ const [searchQuery, setSearchQuery] = useState("");
30+ const colorScheme = useColorScheme();
31+ const highlightTheme = colorScheme === "dark" ? "dark" : "light";
32+ const iconColor = String(useThemeColor("--color-icon-muted"));
33+ const foregroundColor = String(useThemeColor("--color-foreground"));
34+ const sheetColor = String(useThemeColor("--color-sheet"));
35+ const headerScrollEdgeEffects = nativeHeaderScrollEdgeEffects(Platform.OS, Platform.Version);
36+ const entriesQuery = useEnvironmentQuery(
37+ projectEnvironment.listEntries({
38+ environmentId: props.environmentId,
39+ input: { cwd: props.cwd },
40+ }),
41+ );
42+ const entriesData = entriesQuery.data as ProjectListEntriesResult | null;
43+ const handlePreviewFile = useCallback(
44+ (relativePath: string) => {
45+ preloadWorkspaceFileContents({
46+ cwd: props.cwd,
47+ environmentId: props.environmentId,
48+ relativePath,
49+ theme: highlightTheme,
50+ });
51+ },
52+ [highlightTheme, props.cwd, props.environmentId],
53+ );
54+ const nativeHeaderRightBarButtonItems = useMemo(
55+ () =>
56+ [
57+ {
58+ accessibilityLabel: "Refresh files",
59+ icon: { name: "arrow.clockwise", type: "sfSymbol" as const },
60+ identifier: "thread-file-navigator-refresh",
61+ onPress: entriesQuery.refresh,
62+ sharesBackground: false,
63+ tintColor: foregroundColor,
64+ type: "button" as const,
65+ width: 44,
66+ },
67+ ] as ComponentProps<typeof ScreenStackHeaderConfig>["headerRightBarButtonItems"],
68+ [entriesQuery.refresh, foregroundColor],
69+ );
70+
71+ const fileTree = (
72+ <FileTreeBrowser
73+ entries={entriesData?.entries ?? []}
74+ error={entriesQuery.error}
75+ isPending={entriesQuery.isPending}
76+ searchQuery={searchQuery}
77+ selectedPath={props.selectedPath}
78+ onPreviewFile={handlePreviewFile}
79+ onRefresh={entriesQuery.refresh}
80+ onSelectFile={props.onSelectFile}
81+ />
82+ );
83+
84+ if (Platform.OS === "ios") {
85+ return (
86+ <View className="flex-1 border-l border-border bg-sheet">
87+ <ScreenStack style={{ flex: 1 }}>
88+ <Screen
89+ activityState={2}
90+ enabled
91+ isNativeStack
92+ screenId="thread-file-navigator-native"
93+ scrollEdgeEffects={headerScrollEdgeEffects}
94+ style={{ backgroundColor: sheetColor, flex: 1 }}
95+ >
96+ {fileTree}
97+ <ScreenStackHeaderConfig
98+ backgroundColor="rgba(0,0,0,0)"
99+ color={foregroundColor}
100+ headerRightBarButtonItems={nativeHeaderRightBarButtonItems}
101+ hideBackButton
102+ hideShadow={false}
103+ navigationItemStyle="editor"
104+ subtitle={props.projectName}
105+ title="Files"
106+ titleColor={foregroundColor}
107+ titleFontSize={17}
108+ titleFontWeight="700"
109+ translucent
110+ >
111+ <ScreenStackHeaderSearchBarView>
112+ <SearchBar
113+ allowToolbarIntegration
114+ autoCapitalize="none"
115+ barTintColor={sheetColor}
116+ hideNavigationBar={false}
117+ hideWhenScrolling={false}
118+ obscureBackground={false}
119+ onCancelButtonPress={() => {
120+ setSearchQuery("");
121+ }}
122+ onChangeText={(event: NativeSyntheticEvent<{ readonly text?: string }>) => {
123+ setSearchQuery(event.nativeEvent.text ?? "");
124+ }}
125+ placement="integratedButton"
126+ placeholder="Search files"
127+ textColor={foregroundColor}
128+ tintColor={foregroundColor}
129+ />
130+ </ScreenStackHeaderSearchBarView>
131+ </ScreenStackHeaderConfig>
132+ </Screen>
133+ </ScreenStack>
134+ </View>
135+ );
136+ }
137+
138+ return (
139+ <View className="flex-1 border-l border-border bg-sheet">
140+ <View className="border-b border-border" style={{ paddingTop: props.headerInset }}>
141+ <View className="h-12 flex-row items-center gap-2 px-3">
142+ <View className="min-w-0 flex-1">
143+ <Text className="text-sm font-t3-bold text-foreground">Files</Text>
144+ <Text className="text-xs text-foreground-muted" numberOfLines={1}>
145+ {props.projectName}
146+ </Text>
147+ </View>
148+ <Pressable
149+ accessibilityRole="button"
150+ accessibilityLabel="Refresh files"
151+ hitSlop={8}
152+ className="h-8 w-8 items-center justify-center rounded-full active:bg-subtle"
153+ onPress={entriesQuery.refresh}
154+ >
155+ <SymbolView name="arrow.clockwise" size={14} tintColor={iconColor} type="monochrome" />
156+ </Pressable>
157+ </View>
158+ <View className="flex-row items-center gap-2 border-t border-border px-3 py-2">
159+ <SymbolView name="magnifyingglass" size={15} tintColor={iconColor} type="monochrome" />
160+ <TextInput
161+ accessibilityLabel="Search files"
162+ autoCapitalize="none"
163+ autoCorrect={false}
164+ clearButtonMode="while-editing"
165+ className="min-h-10 flex-1 rounded-xl py-2 text-sm"
166+ placeholder="Search files"
167+ value={searchQuery}
168+ onChangeText={setSearchQuery}
169+ />
170+ </View>
171+ </View>
172+ {fileTree}
173+ </View>
174+ );
175+}
apps/mobile/src/features/home/HomeHeader.tsx+169−203
@@ -3,60 +3,30 @@ import type { · 2 unmodified lines
33 SidebarProjectGroupingMode,
44 SidebarThreadSortOrder,
55 } from "@t3tools/contracts";
6−import {
7− DEFAULT_SIDEBAR_PROJECT_GROUPING_MODE,
8− DEFAULT_SIDEBAR_PROJECT_SORT_ORDER,
9− DEFAULT_SIDEBAR_THREAD_SORT_ORDER,
10−} from "@t3tools/contracts";
11−import { Stack } from "expo-router";
12−import { Text as RNText, View } from "react-native";
6+import { NativeHeaderToolbar, NativeStackScreenOptions } from "../../native/StackHeader";
7+import { useCallback, useRef } from "react";
8+import { Platform } from "react-native";
9+import type { SearchBarCommands } from "react-native-screens";
1310
11+import { nativeHeaderScrollEdgeEffects } from "../../native/StackHeader";
1412 import { useThemeColor } from "../../lib/useThemeColor";
15−import { MOBILE_TYPOGRAPHY } from "../../lib/typography";
13+import { useHardwareKeyboardCommand } from "../keyboard/hardwareKeyboardCommands";
14+import { withNativeGlassHeaderItem } from "../layout/native-glass-header-items";
15+import { createNativeMailSearchToolbarItem } from "../layout/native-mail-search-toolbar";
1616 import type { HomeProjectSortOrder } from "./homeThreadList";
17+import {
18+ buildHomeListFilterMenu,
19+ type HomeListFilterMenuEnvironment,
20+} from "./home-list-filter-menu";
21+import {
22+ hasCustomHomeListOptions,
23+ PROJECT_GROUPING_OPTIONS,
24+ PROJECT_SORT_OPTIONS,
25+ THREAD_SORT_OPTIONS,
26+} from "./home-list-options";
1727
18−export interface HomeHeaderEnvironment {
19− readonly environmentId: EnvironmentId;
20− readonly label: string;
21−}
22−
23−const PROJECT_SORT_OPTIONS: ReadonlyArray<{
24− readonly value: HomeProjectSortOrder;
25− readonly label: string;
26−}> = [
27− { value: "updated_at", label: "Last user message" },
28− { value: "created_at", label: "Created at" },
29−];
30−
31−const THREAD_SORT_OPTIONS: ReadonlyArray<{
32− readonly value: SidebarThreadSortOrder;
33− readonly label: string;
34−}> = [
35− { value: "updated_at", label: "Last user message" },
36− { value: "created_at", label: "Created at" },
37−];
38−
39−const PROJECT_GROUPING_OPTIONS: ReadonlyArray<{
40− readonly value: SidebarProjectGroupingMode;
41− readonly label: string;
42− readonly subtitle: string;
43−}> = [
44− {
45− value: "repository",
46− label: "Group by repository",
47− subtitle: "Combine matching repositories across environments",
48− },
49− {
50− value: "repository_path",
51− label: "Group by repository path",
52− subtitle: "Combine only matching paths within a repository",
53− },
54− {
55− value: "separate",
56− label: "Keep separate",
57− subtitle: "Show every project path separately",
58− },
59−];
28+export type HomeHeaderEnvironment = HomeListFilterMenuEnvironment;
29+const HEADER_SCROLL_EDGE_EFFECTS = nativeHeaderScrollEdgeEffects(Platform.OS, Platform.Version);
6030
6131 export function HomeHeader(props: {
6232 readonly environments: ReadonlyArray<HomeHeaderEnvironment>;
@@ -72,174 +42,170 @@ export function HomeHeader(props: { · 9 unmodified lines
7242 readonly onOpenSettings: () => void;
7343 readonly onStartNewTask: () => void;
7444 }) {
45+ const searchBarRef = useRef<SearchBarCommands>(null);
7546 const iconColor = useThemeColor("--color-icon");
76− const mutedColor = useThemeColor("--color-foreground-muted");
77− const subtleColor = useThemeColor("--color-subtle");
78− const hasCustomListOptions =
79− props.selectedEnvironmentId !== null ||
80− props.projectSortOrder !== DEFAULT_SIDEBAR_PROJECT_SORT_ORDER ||
81− props.threadSortOrder !== DEFAULT_SIDEBAR_THREAD_SORT_ORDER ||
82− props.projectGroupingMode !== DEFAULT_SIDEBAR_PROJECT_GROUPING_MODE;
47+ const hasCustomListOptions = hasCustomHomeListOptions(props);
48+ const focusSearch = useCallback(() => {
49+ searchBarRef.current?.focus();
50+ return searchBarRef.current !== null;
51+ }, []);
52+ useHardwareKeyboardCommand("focusSearch", focusSearch);
53+ const filterMenu = buildHomeListFilterMenu(props);
8354
8455 return (
8556 <>
86− <Stack.Screen
57+ <NativeStackScreenOptions
8758 options={{
88− headerShown: true,
89− headerTransparent: true,
90− headerStyle: { backgroundColor: "transparent" },
91− headerShadowVisible: false,
59+ // Static header config (glass, title, fonts) lives in Stack.tsx
60+ // (GLASS_HEADER_OPTIONS). Only dynamic values are set here.
9261 headerTintColor: iconColor,
93− headerTitle: "",
94− headerSearchBarOptions: {
95− placeholder: "Search threads",
96− hideNavigationBar: false,
97− onChangeText: (event) => {
98− props.onSearchQueryChange(event.nativeEvent.text);
99− },
100− onCancelButtonPress: () => {
101− props.onSearchQueryChange("");
102− },
103− allowToolbarIntegration: true,
104− },
62+ unstable_headerRightItems:
63+ Platform.OS === "ios"
64+ ? () => [
65+ withNativeGlassHeaderItem({
66+ accessibilityLabel: "Open settings",
67+ icon: { name: "ellipsis", type: "sfSymbol" } as const,
68+ identifier: "home-settings",
69+ label: "",
70+ onPress: props.onOpenSettings,
71+ type: "button",
72+ }),
73+ ]
74+ : undefined,
75+ unstable_headerToolbarItems:
76+ Platform.OS === "ios"
77+ ? () => [
78+ createNativeMailSearchToolbarItem({
79+ composeButtonId: "home-new-task",
80+ composeSystemImageName: "square.and.pencil",
81+ filterMenu,
82+ filterButtonId: "home-filter",
83+ filterSystemImageName: hasCustomListOptions
84+ ? "line.3.horizontal.decrease.circle.fill"
85+ : "line.3.horizontal.decrease",
86+ onComposePress: props.onStartNewTask,
87+ onSearchTextChange: props.onSearchQueryChange,
88+ placeholder: "Search",
89+ searchTextChangeId: "home-search-text",
90+ }),
91+ ]
92+ : undefined,
93+ headerSearchBarOptions:
94+ Platform.OS === "ios"
95+ ? undefined
96+ : {
97+ ref: searchBarRef,
98+ allowToolbarIntegration: true,
99+ hideNavigationBar: false,
100+ placeholder: "Search",
101+ onCancelButtonPress: () => {
102+ props.onSearchQueryChange("");
103+ },
104+ onChangeText: (event) => {
105+ props.onSearchQueryChange(event.nativeEvent.text);
106+ },
107+ },
105108 }}
106109 />
107110
108− <Stack.Toolbar placement="left">
109− <Stack.Toolbar.View hidesSharedBackground>
110− <View
111− style={{
112− width: 128,
113− height: 32,
114− flexDirection: "row",
115− alignItems: "center",
116− gap: 8,
117− }}
118− >
119− <RNText
120− style={{
121− fontFamily: "DMSans_700Bold",
122− fontSize: MOBILE_TYPOGRAPHY.headline.fontSize,
123− color: iconColor,
124− letterSpacing: -0.4,
125− }}
126− >
127− T3 Code
128− </RNText>
129− <View
130− style={{
131− backgroundColor: subtleColor,
132− borderRadius: 99,
133− paddingHorizontal: 8,
134− paddingVertical: 3,
135− }}
136− >
137− <RNText
138− style={{
139− fontFamily: "DMSans_700Bold",
140− fontSize: MOBILE_TYPOGRAPHY.micro.fontSize,
141− color: mutedColor,
142− letterSpacing: 1.1,
143− textTransform: "uppercase",
144− }}
145− >
146− Alpha
147− </RNText>
148− </View>
149− </View>
150− </Stack.Toolbar.View>
151− </Stack.Toolbar>
152−
153− <Stack.Toolbar placement="right">
154− <Stack.Toolbar.Menu
155− accessibilityLabel="Filter and sort threads"
156− icon={
157− hasCustomListOptions
158− ? "line.3.horizontal.decrease.circle.fill"
159− : "line.3.horizontal.decrease.circle"
160− }
161− separateBackground
162− title="Thread list options"
163− >
164− <Stack.Toolbar.Menu title="Environment">
165− <Stack.Toolbar.Label>Environment</Stack.Toolbar.Label>
166− <Stack.Toolbar.MenuAction
167− isOn={props.selectedEnvironmentId === null}
168− onPress={() => props.onEnvironmentChange(null)}
169− subtitle="Show threads from every environment"
170− >
171− <Stack.Toolbar.Label>All environments</Stack.Toolbar.Label>
172− </Stack.Toolbar.MenuAction>
173− {props.environments.map((environment) => (
174− <Stack.Toolbar.MenuAction
175− key={environment.environmentId}
176− isOn={props.selectedEnvironmentId === environment.environmentId}
177− onPress={() => props.onEnvironmentChange(environment.environmentId)}
178− >
179− <Stack.Toolbar.Label>{environment.label}</Stack.Toolbar.Label>
180− </Stack.Toolbar.MenuAction>
181− ))}
182− </Stack.Toolbar.Menu>
111+ {Platform.OS === "ios" ? null : (
112+ <NativeHeaderToolbar placement="right">
113+ <NativeHeaderToolbar.Button
114+ accessibilityLabel="Open settings"
115+ icon="gearshape"
116+ onPress={props.onOpenSettings}
117+ separateBackground
118+ />
119+ </NativeHeaderToolbar>
120+ )}
183121
184− <Stack.Toolbar.Menu title="Sort projects">
185− <Stack.Toolbar.Label>Sort projects</Stack.Toolbar.Label>
186− {PROJECT_SORT_OPTIONS.map((option) => (
187− <Stack.Toolbar.MenuAction
188− key={option.value}
189− isOn={props.projectSortOrder === option.value}
190− onPress={() => props.onProjectSortOrderChange(option.value)}
191− >
192− <Stack.Toolbar.Label>{option.label}</Stack.Toolbar.Label>
193− </Stack.Toolbar.MenuAction>
194− ))}
195− </Stack.Toolbar.Menu>
122+ {Platform.OS === "ios" ? null : (
123+ <NativeHeaderToolbar placement="bottom">
124+ <NativeHeaderToolbar.Menu
125+ accessibilityLabel="Filter and sort threads"
126+ icon={
127+ hasCustomListOptions
128+ ? "line.3.horizontal.decrease.circle.fill"
129+ : "line.3.horizontal.decrease.circle"
130+ }
131+ title="Thread list options"
132+ separateBackground
133+ >
134+ <NativeHeaderToolbar.MenuAction onPress={props.onOpenSettings}>
135+ <NativeHeaderToolbar.Label>Settings</NativeHeaderToolbar.Label>
136+ </NativeHeaderToolbar.MenuAction>
196137
197− <Stack.Toolbar.Menu title="Sort threads">
198− <Stack.Toolbar.Label>Sort threads</Stack.Toolbar.Label>
199− {THREAD_SORT_OPTIONS.map((option) => (
200− <Stack.Toolbar.MenuAction
201− key={option.value}
202− isOn={props.threadSortOrder === option.value}
203− onPress={() => props.onThreadSortOrderChange(option.value)}
138+ <NativeHeaderToolbar.Menu title="Environment">
139+ <NativeHeaderToolbar.Label>Environment</NativeHeaderToolbar.Label>
140+ <NativeHeaderToolbar.MenuAction
141+ isOn={props.selectedEnvironmentId === null}
142+ onPress={() => props.onEnvironmentChange(null)}
143+ subtitle="Show threads from every environment"
204144 >
205− <Stack.Toolbar.Label>{option.label}</Stack.Toolbar.Label>
206− </Stack.Toolbar.MenuAction>
207− ))}
208− </Stack.Toolbar.Menu>
145+ <NativeHeaderToolbar.Label>All environments</NativeHeaderToolbar.Label>
146+ </NativeHeaderToolbar.MenuAction>
147+ {props.environments.map((environment) => (
148+ <NativeHeaderToolbar.MenuAction
149+ key={environment.environmentId}
150+ isOn={props.selectedEnvironmentId === environment.environmentId}
151+ onPress={() => props.onEnvironmentChange(environment.environmentId)}
152+ >
153+ <NativeHeaderToolbar.Label>{environment.label}</NativeHeaderToolbar.Label>
154+ </NativeHeaderToolbar.MenuAction>
155+ ))}
156+ </NativeHeaderToolbar.Menu>
209157
210− <Stack.Toolbar.Menu title="Group projects">
211− <Stack.Toolbar.Label>Group projects</Stack.Toolbar.Label>
212− {PROJECT_GROUPING_OPTIONS.map((option) => (
213− <Stack.Toolbar.MenuAction
214− key={option.value}
215− isOn={props.projectGroupingMode === option.value}
216− onPress={() => props.onProjectGroupingModeChange(option.value)}
217− subtitle={option.subtitle}
218− >
219− <Stack.Toolbar.Label>{option.label}</Stack.Toolbar.Label>
220− </Stack.Toolbar.MenuAction>
221− ))}
222− </Stack.Toolbar.Menu>
223− </Stack.Toolbar.Menu>
158+ <NativeHeaderToolbar.Menu title="Sort projects">
159+ <NativeHeaderToolbar.Label>Sort projects</NativeHeaderToolbar.Label>
160+ {PROJECT_SORT_OPTIONS.map((option) => (
161+ <NativeHeaderToolbar.MenuAction
162+ key={option.value}
163+ isOn={props.projectSortOrder === option.value}
164+ onPress={() => props.onProjectSortOrderChange(option.value)}
165+ >
166+ <NativeHeaderToolbar.Label>{option.label}</NativeHeaderToolbar.Label>
167+ </NativeHeaderToolbar.MenuAction>
168+ ))}
169+ </NativeHeaderToolbar.Menu>
224170
225− <Stack.Toolbar.Button
226− accessibilityLabel="Open settings"
227− icon="gearshape"
228− onPress={props.onOpenSettings}
229− separateBackground
230− />
231− </Stack.Toolbar>
171+ <NativeHeaderToolbar.Menu title="Sort threads">
172+ <NativeHeaderToolbar.Label>Sort threads</NativeHeaderToolbar.Label>
173+ {THREAD_SORT_OPTIONS.map((option) => (
174+ <NativeHeaderToolbar.MenuAction
175+ key={option.value}
176+ isOn={props.threadSortOrder === option.value}
177+ onPress={() => props.onThreadSortOrderChange(option.value)}
178+ >
179+ <NativeHeaderToolbar.Label>{option.label}</NativeHeaderToolbar.Label>
180+ </NativeHeaderToolbar.MenuAction>
181+ ))}
182+ </NativeHeaderToolbar.Menu>
232183
233− <Stack.Toolbar placement="bottom">
234− <Stack.Toolbar.SearchBarSlot />
235− <Stack.Toolbar.Spacer width={8} sharesBackground={false} />
236− <Stack.Toolbar.Button
237− accessibilityLabel="New task"
238− icon="square.and.pencil"
239− onPress={props.onStartNewTask}
240− separateBackground
241− />
242− </Stack.Toolbar>
184+ <NativeHeaderToolbar.Menu title="Group projects">
185+ <NativeHeaderToolbar.Label>Group projects</NativeHeaderToolbar.Label>
186+ {PROJECT_GROUPING_OPTIONS.map((option) => (
187+ <NativeHeaderToolbar.MenuAction
188+ key={option.value}
189+ isOn={props.projectGroupingMode === option.value}
190+ onPress={() => props.onProjectGroupingModeChange(option.value)}
191+ subtitle={option.subtitle}
192+ >
193+ <NativeHeaderToolbar.Label>{option.label}</NativeHeaderToolbar.Label>
194+ </NativeHeaderToolbar.MenuAction>
195+ ))}
196+ </NativeHeaderToolbar.Menu>
197+ </NativeHeaderToolbar.Menu>
198+ <NativeHeaderToolbar.Spacer width={8} sharesBackground={false} />
199+ <NativeHeaderToolbar.SearchBarSlot />
200+ <NativeHeaderToolbar.Spacer width={8} sharesBackground={false} />
201+ <NativeHeaderToolbar.Button
202+ accessibilityLabel="New task"
203+ icon="square.and.pencil"
204+ onPress={props.onStartNewTask}
205+ separateBackground
206+ />
207+ </NativeHeaderToolbar>
208+ )}
243209 </>
244210 );
245211 }
apps/mobile/src/features/home/HomeRouteScreen.tsx+149−0
@@ -0,0 +1,149 @@
1+import * as Arr from "effect/Array";
2+import * as Order from "effect/Order";
3+import { useNavigation } from "@react-navigation/native";
4+import { useMemo, useState } from "react";
5+
6+import { NativeHeaderToolbar, NativeStackScreenOptions } from "../../native/StackHeader";
7+import { useProjects, useThreadShells } from "../../state/entities";
8+import { usePendingNewTasks } from "../../state/use-pending-new-tasks";
9+import { useWorkspaceState } from "../../state/workspace";
10+import { useSavedRemoteConnections } from "../../state/use-remote-environment-registry";
11+import { useAdaptiveWorkspaceLayout } from "../layout/AdaptiveWorkspaceLayout";
12+import { WorkspaceEmptyDetail } from "../layout/WorkspaceEmptyDetail";
13+import { WorkspaceSidebarToolbar } from "../layout/workspace-sidebar-toolbar";
14+import { HomeScreen } from "./HomeScreen";
15+import { HomeHeader } from "./HomeHeader";
16+import { useHomeListOptions } from "./home-list-options";
17+import { usePendingTaskListActions } from "./usePendingTaskListActions";
18+import { useThreadListActions } from "./useThreadListActions";
19+
20+/* ─── Route screen ───────────────────────────────────────────────────── */
21+
22+export function HomeRouteScreen() {
23+ const { layout } = useAdaptiveWorkspaceLayout();
24+ const projects = useProjects();
25+ const threads = useThreadShells();
26+ const { state: catalogState } = useWorkspaceState();
27+ const { savedConnectionsById } = useSavedRemoteConnections();
28+ const navigation = useNavigation();
29+ const [searchQuery, setSearchQuery] = useState("");
30+ const { archiveThread, confirmDeleteThread } = useThreadListActions();
31+ const pendingTasks = usePendingNewTasks();
32+ const { openPendingTask, confirmDeletePendingTask } = usePendingTaskListActions();
33+ const environments = useMemo(
34+ () =>
35+ Arr.sort(
36+ Object.values(savedConnectionsById).map((connection) => ({
37+ environmentId: connection.environmentId,
38+ label: connection.environmentLabel,
39+ })),
40+ Order.mapInput(
41+ Order.String,
42+ (environment: { readonly label: string }) => environment.label,
43+ ),
44+ ),
45+ [savedConnectionsById],
46+ );
47+ const availableEnvironmentIds = useMemo(
48+ () => new Set(environments.map((environment) => environment.environmentId)),
49+ [environments],
50+ );
51+ const {
52+ options: listOptions,
53+ setSelectedEnvironmentId,
54+ setProjectGroupingMode,
55+ setProjectSortOrder,
56+ setThreadSortOrder,
57+ } = useHomeListOptions(availableEnvironmentIds);
58+ const selectedEnvironmentId = listOptions.selectedEnvironmentId;
59+
60+ // In split layouts the persistent sidebar IS the thread list — Home becomes
61+ // an empty detail pane so selecting a thread never transitions layouts.
62+ if (layout.usesSplitView) {
63+ return (
64+ <>
65+ <NativeStackScreenOptions options={{ title: "", headerTitle: "" }} />
66+ <WorkspaceSidebarToolbar
67+ afterSidebarButton={
68+ <NativeHeaderToolbar.Button
69+ accessibilityLabel="New task"
70+ icon="square.and.pencil"
71+ onPress={() => navigation.navigate("NewTaskSheet", { screen: "NewTask" })}
72+ />
73+ }
74+ />
75+ <WorkspaceEmptyDetail
76+ onStartNewTask={() => navigation.navigate("NewTaskSheet", { screen: "NewTask" })}
77+ />
78+ </>
79+ );
80+ }
81+
82+ return (
83+ <>
84+ {/* Restore the compact title in case the split branch blanked it. */}
85+ <NativeStackScreenOptions options={{ title: "Threads", headerTitle: "Threads" }} />
86+ <HomeHeader
87+ environments={environments}
88+ selectedEnvironmentId={selectedEnvironmentId}
89+ projectSortOrder={listOptions.projectSortOrder}
90+ threadSortOrder={listOptions.threadSortOrder}
91+ projectGroupingMode={listOptions.projectGroupingMode}
92+ onEnvironmentChange={setSelectedEnvironmentId}
93+ onOpenSettings={() => navigation.navigate("SettingsSheet", { screen: "Settings" })}
94+ onProjectGroupingModeChange={setProjectGroupingMode}
95+ onProjectSortOrderChange={setProjectSortOrder}
96+ onSearchQueryChange={setSearchQuery}
97+ onStartNewTask={() => navigation.navigate("NewTaskSheet", { screen: "NewTask" })}
98+ onThreadSortOrderChange={setThreadSortOrder}
99+ />
100+
101+ <HomeScreen
102+ catalogState={catalogState}
103+ environments={environments}
104+ onAddConnection={() =>
105+ navigation.navigate("SettingsSheet", { screen: "SettingsEnvironmentNew" })
106+ }
107+ onArchiveThread={archiveThread}
108+ onDeleteThread={confirmDeleteThread}
109+ onEnvironmentChange={setSelectedEnvironmentId}
110+ onOpenEnvironments={() =>
111+ navigation.navigate("SettingsSheet", { screen: "SettingsEnvironments" })
112+ }
113+ onOpenSettings={() => navigation.navigate("SettingsSheet", { screen: "Settings" })}
114+ onProjectGroupingModeChange={setProjectGroupingMode}
115+ onProjectSortOrderChange={setProjectSortOrder}
116+ onSearchQueryChange={setSearchQuery}
117+ onSelectThread={(thread) => {
118+ navigation.navigate("Thread", {
119+ environmentId: thread.environmentId,
120+ threadId: thread.id,
121+ });
122+ }}
123+ onSelectPendingTask={openPendingTask}
124+ onDeletePendingTask={confirmDeletePendingTask}
125+ onNewThreadInProject={(project) => {
126+ navigation.navigate("NewTaskSheet", {
127+ screen: "NewTaskDraft",
128+ params: {
129+ environmentId: String(project.environmentId),
130+ projectId: String(project.id),
131+ title: project.title,
132+ },
133+ });
134+ }}
135+ onStartNewTask={() => navigation.navigate("NewTaskSheet", { screen: "NewTask" })}
136+ onThreadSortOrderChange={setThreadSortOrder}
137+ pendingTasks={pendingTasks}
138+ projectGroupingMode={listOptions.projectGroupingMode}
139+ projects={projects}
140+ projectSortOrder={listOptions.projectSortOrder}
141+ savedConnectionsById={savedConnectionsById}
142+ searchQuery={searchQuery}
143+ selectedEnvironmentId={selectedEnvironmentId}
144+ threads={threads}
145+ threadSortOrder={listOptions.threadSortOrder}
146+ />
147+ </>
148+ );
149+}
apps/mobile/src/features/home/HomeScreen.tsx+275−466
@@ -1,3 +1,8 @@
1+import {
2+ LegendList,
3+ type LegendListRef,
4+ type LegendListRenderItemProps,
5+} from "@legendapp/list/react-native";
16 import {
27 type EnvironmentProject,
38 type EnvironmentThreadShell,
@@ -7,102 +12,74 @@ import type { · 3 unmodified lines
712 SidebarProjectGroupingMode,
813 SidebarThreadSortOrder,
914 } from "@t3tools/contracts";
10−import * as Haptics from "expo-haptics";
11−import { SymbolView } from "expo-symbols";
1215 import { useCallback, useMemo, useRef, useState } from "react";
13−import { ActivityIndicator, Pressable, ScrollView, useWindowDimensions, View } from "react-native";
14−import ReanimatedSwipeable, {
15− type SwipeableMethods,
16−} from "react-native-gesture-handler/ReanimatedSwipeable";
17−import Animated, {
18− Easing,
19− LinearTransition,
20− type ExitAnimationsValues,
21− withDelay,
22− withTiming,
23−} from "react-native-reanimated";
16+import { ActivityIndicator, Platform, View } from "react-native";
17+import type { SwipeableMethods } from "react-native-gesture-handler/ReanimatedSwipeable";
2418 import { useSafeAreaInsets } from "react-native-safe-area-context";
2519 import { useThemeColor } from "../../lib/useThemeColor";
2620
27−import { AppText as Text } from "../../components/AppText";
2821 import { EmptyState } from "../../components/EmptyState";
29−import { ProjectFavicon } from "../../components/ProjectFavicon";
3022 import type { WorkspaceState } from "../../state/workspaceModel";
3123 import type { SavedRemoteConnection } from "../../lib/connection";
32−import { relativeTime } from "../../lib/time";
33−import { threadStatusTone } from "../threads/threadPresentation";
34−import { buildHomeThreadGroups, type HomeProjectSortOrder } from "./homeThreadList";
24+import { scopedProjectKey } from "../../lib/scopedEntities";
25+import type { PendingNewTask } from "../../state/use-pending-new-tasks";
3526 import {
36− THREAD_SWIPE_ACTIONS_WIDTH,
37− THREAD_SWIPE_SPRING,
38− ThreadSwipeActions,
39−} from "./thread-swipe-actions";
27+ PendingTaskListRow,
28+ ThreadListGroupHeader,
29+ ThreadListRow,
30+ ThreadListShowMoreRow,
31+} from "../threads/thread-list-items";
32+import type { HomeListFilterMenuEnvironment } from "./home-list-filter-menu";
33+import {
34+ buildHomeListLayout,
35+ DEFAULT_GROUP_DISPLAY_STATE,
36+ homeListItemsAreEqual,
37+ nextGroupDisplayState,
38+ type HomeGroupDisplayAction,
39+ type HomeGroupDisplayState,
40+ type HomeListItem,
41+} from "./homeListItems";
42+import { buildHomeThreadGroups, type HomeProjectSortOrder } from "./homeThreadList";
43+import { SwipeableScrollGateProvider, useSwipeableScrollGate } from "./thread-swipe-actions";
44+import { WorkspaceConnectionStatus } from "./WorkspaceConnectionStatus";
45+import { shouldShowWorkspaceConnectionStatus } from "./workspace-connection-status";
4046
4147 /* ─── Types ──────────────────────────────────────────────────────────── */
4248
4349 interface HomeScreenProps {
4450 readonly projects: ReadonlyArray<EnvironmentProject>;
4551 readonly threads: ReadonlyArray<EnvironmentThreadShell>;
52+ readonly pendingTasks: ReadonlyArray<PendingNewTask>;
4653 readonly catalogState: WorkspaceState;
4754 readonly savedConnectionsById: Readonly<Record<string, SavedRemoteConnection>>;
55+ readonly environments: ReadonlyArray<HomeListFilterMenuEnvironment>;
4856 readonly searchQuery: string;
4957 readonly selectedEnvironmentId: EnvironmentId | null;
5058 readonly projectSortOrder: HomeProjectSortOrder;
5159 readonly threadSortOrder: SidebarThreadSortOrder;
5260 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;
5366 readonly onAddConnection: () => void;
5467 readonly onOpenEnvironments: () => void;
68+ readonly onOpenSettings: () => void;
69+ readonly onStartNewTask: () => void;
5570 readonly onSelectThread: (thread: EnvironmentThreadShell) => void;
5671 readonly onArchiveThread: (thread: EnvironmentThreadShell) => void;
5772 readonly onDeleteThread: (thread: EnvironmentThreadShell) => void;
73+ readonly onSelectPendingTask: (pendingTask: PendingNewTask) => void;
74+ readonly onDeletePendingTask: (pendingTask: PendingNewTask) => void;
5875 readonly onNewThreadInProject: (project: EnvironmentProject) => void;
5976 }
6077
61−/* ─── Status indicator colors ────────────────────────────────────────── */
62−
63−function statusColors(thread: EnvironmentThreadShell): { bg: string; fg: string } {
64− switch (thread.session?.status) {
65− case "running":
66− return { bg: "rgba(249,115,22,0.14)", fg: "#f97316" };
67− case "ready":
68− return { bg: "rgba(34,197,94,0.14)", fg: "#22c55e" };
69− case "starting":
70− return { bg: "rgba(59,130,246,0.14)", fg: "#3b82f6" };
71− case "error":
72− return { bg: "rgba(239,68,68,0.14)", fg: "#ef4444" };
73− default:
74− return { bg: "rgba(163,163,163,0.10)", fg: "#a3a3a3" };
75− }
76−}
77−
78−const COLLAPSED_THREAD_LIMIT = 6;
79−const THREAD_LAYOUT_TRANSITION = LinearTransition.duration(220).easing(Easing.out(Easing.cubic));
78+/* ─── Layout constants ───────────────────────────────────────────────── */
8079
81−function threadRowExit(values: ExitAnimationsValues) {
82− "worklet";
83−
84− return {
85− initialValues: {
86− height: values.currentHeight,
87− opacity: 1,
88− originX: values.currentOriginX,
89− },
90− animations: {
91− height: withDelay(
92− 90,
93− withTiming(0, {
94− duration: 170,
95− easing: Easing.inOut(Easing.cubic),
96− }),
97− ),
98− opacity: withDelay(80, withTiming(0, { duration: 100 })),
99− originX: withTiming(values.currentOriginX - values.windowWidth, {
100− duration: 190,
101− easing: Easing.out(Easing.cubic),
102− }),
103− },
104− };
105−}
80+const ESTIMATED_THREAD_ROW_HEIGHT = 72;
81+/** Height of the floating custom header on non-iOS platforms. */
82+const CUSTOM_HEADER_HEIGHT = 78;
10683
10784 function deriveEmptyState(props: {
10885 readonly catalogState: WorkspaceState;
@@ -167,304 +144,28 @@ function deriveEmptyState(props: { · 58 unmodified lines
167144 };
168145 }
169146
170−/* ─── Project group header ───────────────────────────────────────────── */
171−
172−function ProjectGroupLabel(props: {
173− readonly project: EnvironmentProject;
174− readonly title: string;
175− readonly hiddenCount: number;
176− readonly isExpanded: boolean;
177− readonly onToggleExpand: () => void;
178− readonly onNewThread: () => void;
179−}) {
180− const iconMutedColor = useThemeColor("--color-icon-muted");
181−
182− return (
183− <View className="flex-row items-center gap-2.5 px-1 pb-2">
184− <ProjectFavicon
185− environmentId={props.project.environmentId}
186− size={18}
187− projectTitle={props.project.title}
188− workspaceRoot={props.project.workspaceRoot}
189− />
190− <Text
191− className="flex-1 text-xs font-t3-medium uppercase text-foreground-muted"
192− style={{ letterSpacing: 0.5 }}
193− numberOfLines={1}
194− >
195− {props.title}
196− </Text>
197−
198− <Pressable
199− accessibilityLabel={`Create new thread in ${props.title}`}
200− accessibilityRole="button"
201− hitSlop={8}
202− onPress={props.onNewThread}
203− style={({ pressed }) => ({ opacity: pressed ? 0.5 : 1 })}
204− >
205− <SymbolView
206− name="square.and.pencil"
207− size={15}
208− tintColor={iconMutedColor}
209− type="monochrome"
210− weight="medium"
211− />
212− </Pressable>
213−
214− {props.hiddenCount > 0 ? (
215− <Pressable onPress={props.onToggleExpand} hitSlop={8}>
216− <Text
217− className="text-xs font-t3-medium text-foreground-muted"
218− style={{ letterSpacing: 0.4 }}
219− >
220− {props.isExpanded ? "Show less" : `${props.hiddenCount} more`}
221− </Text>
222− </Pressable>
223− ) : null}
224− </View>
225− );
226−}
227−
228−/* ─── Thread row ─────────────────────────────────────────────────────── */
229−
230−function ThreadRow(props: {
231− readonly thread: EnvironmentThreadShell;
232− readonly environmentLabel: string | null;
233− readonly onPress: () => void;
234− readonly onArchive: () => void;
235− readonly onDelete: () => void;
236− readonly onSwipeableWillOpen: (methods: SwipeableMethods) => void;
237− readonly onSwipeableClose: (methods: SwipeableMethods) => void;
238− readonly isLast: boolean;
239−}) {
240− const swipeableRef = useRef<SwipeableMethods | null>(null);
241− const fullSwipeArmedRef = useRef(false);
242− const { width: windowWidth } = useWindowDimensions();
243− const separatorColor = useThemeColor("--color-separator");
244− const iconSubtleColor = useThemeColor("--color-icon-subtle");
245− const cardColor = useThemeColor("--color-card");
246− const fullSwipeThreshold = Math.max(THREAD_SWIPE_ACTIONS_WIDTH + 44, (windowWidth - 32) * 0.58);
247− const { bg, fg } = statusColors(props.thread);
248− const tone = threadStatusTone(props.thread);
249− const timestamp = relativeTime(
250− props.thread.latestUserMessageAt ?? props.thread.updatedAt ?? props.thread.createdAt,
251− );
252− const branch = props.thread.branch;
253− const subtitleParts = [props.environmentLabel, branch].filter((part): part is string =>
254− Boolean(part),
255− );
256− const handleFullSwipeArmedChange = useCallback((armed: boolean) => {
257− if (armed && !fullSwipeArmedRef.current && process.env.EXPO_OS === "ios") {
258− void Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Medium);
259− }
260− fullSwipeArmedRef.current = armed;
261− }, []);
262−
263− return (
264− <ReanimatedSwipeable
265− ref={swipeableRef}
266− animationOptions={THREAD_SWIPE_SPRING}
267− childrenContainerStyle={{ backgroundColor: cardColor }}
268− containerStyle={{ backgroundColor: cardColor }}
269− dragOffsetFromRightEdge={8}
270− enableTrackpadTwoFingerGesture
271− friction={1}
272− onSwipeableClose={() => {
273− fullSwipeArmedRef.current = false;
274− if (swipeableRef.current) {
275− props.onSwipeableClose(swipeableRef.current);
276− }
277− }}
278− onSwipeableOpenStartDrag={() => {
279− if (swipeableRef.current) {
280− props.onSwipeableWillOpen(swipeableRef.current);
281− }
282− }}
283− onSwipeableWillOpen={() => {
284− const methods = swipeableRef.current;
285− if (!methods) {
286− return;
287− }
288−
289− props.onSwipeableWillOpen(methods);
290− if (fullSwipeArmedRef.current) {
291− fullSwipeArmedRef.current = false;
292− methods.close();
293− props.onDelete();
294− }
295− }}
296− overshootFriction={1}
297− overshootRight
298− renderRightActions={(_progress, translation, methods) => (
299− <ThreadSwipeActions
300− backgroundColor={cardColor}
301− fullSwipeThreshold={fullSwipeThreshold}
302− onDelete={props.onDelete}
303− onFullSwipeArmedChange={handleFullSwipeArmedChange}
304− primaryAction={{
305− accessibilityLabel: `Archive ${props.thread.title}`,
306− icon: "archivebox",
307− label: "Archive",
308− onPress: props.onArchive,
309− }}
310− swipeableMethods={methods}
311− threadTitle={props.thread.title}
312− translation={translation}
313− />
314− )}
315− rightThreshold={THREAD_SWIPE_ACTIONS_WIDTH * 0.42}
316− >
317− <Pressable
318− accessibilityHint="Swipe left for archive and delete actions"
319− accessibilityLabel={props.thread.title}
320− accessibilityRole="button"
321− className="bg-card"
322− onPress={() => {
323− swipeableRef.current?.close();
324− props.onPress();
325− }}
326− style={({ pressed }) => ({ opacity: pressed ? 0.7 : 1 })}
327− >
328− <View
329− style={{
330− flexDirection: "row",
331− paddingLeft: 16,
332− paddingRight: 16,
333− paddingVertical: 10,
334− gap: 12,
335− borderBottomWidth: props.isLast ? 0 : 1,
336− borderBottomColor: separatorColor,
337− }}
338− >
339− <View
340− style={{
341− width: 30,
342− height: 30,
343− borderRadius: 9,
344− backgroundColor: bg,
345− alignItems: "center",
346− justifyContent: "center",
347− marginTop: 2,
348− }}
349− >
350− <SymbolView name="arrow.triangle.branch" size={13} tintColor={fg} type="monochrome" />
351− </View>
352−
353− <View style={{ flex: 1, gap: 3 }}>
354− <View className="flex-row items-center justify-between gap-2">
355− <Text
356− className="flex-1 text-base font-t3-bold leading-[20px] text-foreground"
357− numberOfLines={1}
358− >
359− {props.thread.title}
360− </Text>
361− <View className="flex-row items-center gap-2">
362− <View
363− className={tone.pillClassName}
364− style={{ borderRadius: 99, paddingHorizontal: 6, paddingVertical: 2 }}
365− >
366− <Text className={`text-3xs font-t3-bold ${tone.textClassName}`}>
367− {tone.label}
368− </Text>
369− </View>
370− <Text
371− className="text-xs text-foreground-tertiary"
372− style={{ fontVariant: ["tabular-nums"], minWidth: 30, textAlign: "right" }}
373− >
374− {timestamp}
375− </Text>
376− </View>
377− </View>
378−
379− {subtitleParts.length > 0 ? (
380− <View className="flex-row items-center gap-1.5" style={{ marginTop: 1 }}>
381− <SymbolView
382− name="arrow.triangle.branch"
383− size={10}
384− tintColor={iconSubtleColor}
385− type="monochrome"
386− />
387− <Text
388− className="text-2xs text-foreground-tertiary"
389− numberOfLines={1}
390− style={{ fontFamily: "monospace" }}
391− >
392− {subtitleParts.join(" · ")}
393− </Text>
394− </View>
395− ) : null}
396− </View>
397− </View>
398− </Pressable>
399− </ReanimatedSwipeable>
400− );
147+function HomeTopContentSpacer(props: { readonly topInset: number }) {
148+ return <View style={{ height: props.topInset + CUSTOM_HEADER_HEIGHT }} />;
401149 }
402150
403151 /* ─── Main screen ────────────────────────────────────────────────────── */
404152
405−function staleCatalogPillLabel(props: { readonly catalogState: WorkspaceState }): string {
406− if (props.catalogState.networkStatus === "offline") {
407− return "You are offline";
408− }
409− const connectingEnvironments = props.catalogState.connectingEnvironments;
410− if (connectingEnvironments.length === 1) {
411− return `Reconnecting to ${connectingEnvironments[0]!.environmentLabel}`;
412− }
413− if (connectingEnvironments.length > 1) {
414− return `Reconnecting ${connectingEnvironments.length} environments`;
415− }
416− return "Not connected";
417−}
418−
419−function StaleCatalogStatusPill(props: {
420− readonly catalogState: WorkspaceState;
421− readonly onPress: () => void;
422−}) {
423− const iconColor = useThemeColor("--color-icon-muted");
424− const label = staleCatalogPillLabel(props);
425− const isReconnecting = props.catalogState.connectingEnvironments.length > 0;
426−
427− return (
428− <Pressable
429− accessibilityRole="button"
430− onPress={props.onPress}
431− className="flex-row items-center gap-2 rounded-full bg-card px-4 py-2.5"
432− style={{
433− shadowColor: "#000",
434− shadowOffset: { width: 0, height: 10 },
435− shadowOpacity: 0.12,
436− shadowRadius: 24,
437− }}
438− >
439− {isReconnecting ? (
440− <ActivityIndicator color={iconColor} size="small" />
441− ) : (
442− <SymbolView
443− name="wifi.slash"
444− size={15}
445− tintColor={iconColor}
446− type="monochrome"
447− weight="semibold"
448− />
449− )}
450− <Text className="max-w-[260px] text-sm font-t3-bold text-foreground" numberOfLines={1}>
451− {label}
452− </Text>
453− </Pressable>
454− );
455−}
456−
457153 export function HomeScreen(props: HomeScreenProps) {
458− const [expandedProjects, setExpandedProjects] = useState<Set<string>>(() => new Set());
154+ const [groupDisplayStates, setGroupDisplayStates] = useState<
155+ ReadonlyMap<string, HomeGroupDisplayState>
156+ >(() => new Map());
459157 const openSwipeableRef = useRef<SwipeableMethods | null>(null);
158+ const listRef = useRef<LegendListRef | null>(null);
460159 const insets = useSafeAreaInsets();
461160 const accentColor = useThemeColor("--color-icon-muted");
462161
463− const toggleExpanded = useCallback((key: string) => {
464− setExpandedProjects((prev) => {
465− const next = new Set(prev);
466− if (next.has(key)) next.delete(key);
467− else next.add(key);
162+ const updateGroupDisplay = useCallback((key: string, action: HomeGroupDisplayAction) => {
163+ setGroupDisplayStates((previous) => {
164+ const next = new Map(previous);
165+ next.set(
166+ key,
167+ nextGroupDisplayState(previous.get(key) ?? DEFAULT_GROUP_DISPLAY_STATE, action),
168+ );
468169 return next;
469170 });
470171 }, []);
@@ -482,11 +183,19 @@ export function HomeScreen(props: HomeScreenProps) { · 11 unmodified lines
482183 }
483184 }, []);
484185
186+ const handleScrollBeginDrag = useCallback(() => {
187+ openSwipeableRef.current?.close();
188+ }, []);
189+ const { swipeEnabled, scrollGateHandlers } = useSwipeableScrollGate({
190+ onScrollBeginDrag: handleScrollBeginDrag,
191+ });
192+
485193 const projectGroups = useMemo(
486194 () =>
487195 buildHomeThreadGroups({
488196 projects: props.projects,
489197 threads: props.threads,
198+ pendingTasks: props.pendingTasks,
490199 environmentId: props.selectedEnvironmentId,
491200 searchQuery: props.searchQuery,
492201 projectSortOrder: props.projectSortOrder,
@@ -494,6 +203,7 @@ export function HomeScreen(props: HomeScreenProps) { · 1 unmodified line
494203 projectGroupingMode: props.projectGroupingMode,
495204 }),
496205 [
206+ props.pendingTasks,
497207 props.projectGroupingMode,
498208 props.projects,
499209 props.projectSortOrder,
@@ -504,137 +214,236 @@ export function HomeScreen(props: HomeScreenProps) { · 4 unmodified lines
504214 ],
505215 );
506216
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+
507323 /* 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;
509326 const hasResults = projectGroups.length > 0;
510327 const selectedEnvironmentLabel =
511328 props.selectedEnvironmentId === null
512329 ? null
513330 : (props.savedConnectionsById[props.selectedEnvironmentId]?.environmentLabel ??
514331 "this environment");
515− const hasSearchQuery = props.searchQuery.trim().length > 0;
516− const shouldShowConnectionStatus =
517− props.catalogState.networkStatus === "offline" ||
518− props.catalogState.hasConnectingEnvironment ||
519− (props.catalogState.hasLoadedShellSnapshot && !props.catalogState.hasReadyEnvironment);
332+ const shouldShowConnectionStatus = shouldShowWorkspaceConnectionStatus(props.catalogState);
520333 const emptyState = deriveEmptyState({
521334 catalogState: props.catalogState,
522335 projectCount: props.projects.length,
523336 });
524−
525− return (
526− <View className="flex-1 bg-screen">
527− <ScrollView
528− contentInsetAdjustmentBehavior="automatic"
529− showsVerticalScrollIndicator={false}
530− keyboardDismissMode="on-drag"
531− keyboardShouldPersistTaps="handled"
532− onScrollBeginDrag={() => openSwipeableRef.current?.close()}
533− className="flex-1"
534− contentContainerStyle={{
535− paddingHorizontal: 16,
536− paddingTop: 8,
537− paddingBottom: 24,
538− gap: 20,
337+ const connectionStatus =
338+ shouldShowConnectionStatus && Platform.OS !== "ios" ? (
339+ <View
340+ className="absolute left-0 right-0 items-center"
341+ style={{ bottom: Math.max(insets.bottom, 18) + 76 }}
342+ >
343+ <WorkspaceConnectionStatus state={props.catalogState} onPress={props.onOpenEnvironments} />
344+ </View>
345+ ) : null;
346+
347+ if (!hasAnyThreads) {
348+ return (
349+ <View
350+ className="flex-1 items-center justify-center bg-screen px-8"
351+ style={{
352+ paddingBottom: Math.max(insets.bottom, 24),
353+ paddingTop: Platform.OS === "ios" ? insets.top + 72 : insets.top,
539354 }}
540355 >
541− {!hasAnyThreads ? (
542− <View>
543− <EmptyState
544− title={emptyState.title}
545− detail={emptyState.detail}
546− actionLabel={!props.catalogState.hasReadyEnvironment ? "Add environment" : undefined}
547− onAction={!props.catalogState.hasReadyEnvironment ? props.onAddConnection : undefined}
548− />
549− {emptyState.loading ? (
550− <View className="absolute right-5 top-5">
551− <ActivityIndicator color={accentColor} />
552− </View>
553− ) : null}
554− </View>
555− ) : !hasResults && hasSearchQuery ? (
556− <EmptyState title="No results" detail={`No threads matching "${props.searchQuery}".`} />
557− ) : !hasResults && selectedEnvironmentLabel ? (
558− <EmptyState
559− title={`No threads in ${selectedEnvironmentLabel}`}
560− detail="Choose another environment or create a new task."
561− />
562− ) : !hasResults ? (
356+ <View className="w-full max-w-[430px]">
563357 <EmptyState
564− title="No threads yet"
565− detail="Create a task to start a new coding session."
358+ title={emptyState.title}
359+ detail={emptyState.detail}
360+ actionLabel={!props.catalogState.hasReadyEnvironment ? "Add environment" : undefined}
361+ onAction={!props.catalogState.hasReadyEnvironment ? props.onAddConnection : undefined}
362+ variant="plain"
566363 />
567− ) : (
568− projectGroups.map((group) => {
569− const isExpanded = expandedProjects.has(group.key);
570− // Collapsed shows recent threads only; expanding reveals full history.
571− const collapsedThreads = group.recentThreads.slice(0, COLLAPSED_THREAD_LIMIT);
572− const visibleThreads = isExpanded ? group.threads : collapsedThreads;
573− const hiddenCount = group.threads.length - collapsedThreads.length;
364+ {emptyState.loading ? (
365+ <View className="mt-4 items-center">
366+ <ActivityIndicator color={accentColor} />
367+ </View>
368+ ) : null}
369+ </View>
370+ {connectionStatus}
371+ </View>
372+ );
373+ }
574374
575− return (
576− <Animated.View
577− key={group.key}
578− collapsable={false}
579− exiting={threadRowExit}
580− layout={THREAD_LAYOUT_TRANSITION}
581− style={{ overflow: "hidden" }}
582− >
583− <ProjectGroupLabel
584− isExpanded={isExpanded}
585− onNewThread={() => props.onNewThreadInProject(group.representative)}
586− onToggleExpand={() => toggleExpanded(group.key)}
587− project={group.representative}
588− title={group.title}
589− hiddenCount={hiddenCount}
590− />
591− <View
592− className="overflow-hidden rounded-[20px] bg-card"
593− style={{ borderCurve: "continuous" }}
594− >
595− {visibleThreads.map((thread, i) => {
596− const threadKey = `${thread.environmentId}:${thread.id}`;
597− return (
598− <Animated.View
599− key={threadKey}
600− collapsable={false}
601− exiting={threadRowExit}
602− layout={THREAD_LAYOUT_TRANSITION}
603− style={{ overflow: "hidden" }}
604− >
605− <ThreadRow
606− thread={thread}
607− environmentLabel={
608− props.savedConnectionsById[thread.environmentId]?.environmentLabel ??
609− null
610− }
611− isLast={i === visibleThreads.length - 1}
612− onArchive={() => props.onArchiveThread(thread)}
613− onDelete={() => props.onDeleteThread(thread)}
614− onPress={() => props.onSelectThread(thread)}
615− onSwipeableClose={handleSwipeableClose}
616− onSwipeableWillOpen={handleSwipeableWillOpen}
617− />
618− </Animated.View>
619− );
620− })}
621− </View>
622− </Animated.View>
623− );
624− })
625− )}
626− </ScrollView>
627− {shouldShowConnectionStatus ? (
628− <View
629− className="absolute left-0 right-0 items-center"
630− style={{ bottom: Math.max(insets.bottom, 18) + 76 }}
631− >
632− <StaleCatalogStatusPill
633− catalogState={props.catalogState}
375+ const listHeader = (
376+ <>
377+ {Platform.OS === "ios" ? null : <HomeTopContentSpacer topInset={insets.top} />}
378+
379+ {shouldShowConnectionStatus && Platform.OS === "ios" ? (
380+ <View style={{ paddingBottom: 16 }}>
381+ <WorkspaceConnectionStatus
382+ state={props.catalogState}
634383 onPress={props.onOpenEnvironments}
384+ variant="sidebar"
635385 />
636386 </View>
637387 ) : 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}
638447 </View>
639448 );
640449 }
apps/mobile/src/features/home/WorkspaceConnectionStatus.test.ts+70−0
@@ -0,0 +1,70 @@
1+import { describe, expect, it } from "vite-plus/test";
2+
3+import type { WorkspaceState } from "../../state/workspaceModel";
4+import {
5+ shouldShowWorkspaceConnectionStatus,
6+ workspaceConnectionStatusLabel,
7+} from "./workspace-connection-status";
8+
9+function workspaceState(overrides: Partial<WorkspaceState> = {}): WorkspaceState {
10+ return {
11+ isLoadingConnections: false,
12+ hasConnections: true,
13+ hasLoadedShellSnapshot: true,
14+ hasPendingShellSnapshot: false,
15+ hasReadyEnvironment: true,
16+ hasConnectingEnvironment: false,
17+ connectingEnvironments: [],
18+ connectionState: "connected",
19+ connectionError: null,
20+ shellSnapshotError: null,
21+ latestCachedSnapshotReceivedAt: null,
22+ networkStatus: "online",
23+ ...overrides,
24+ };
25+}
26+
27+describe("workspace connection status", () => {
28+ it("stays hidden while a ready environment is connected", () => {
29+ expect(shouldShowWorkspaceConnectionStatus(workspaceState())).toBe(false);
30+ });
31+
32+ it("surfaces offline snapshots", () => {
33+ const state = workspaceState({ networkStatus: "offline", hasReadyEnvironment: false });
34+
35+ expect(shouldShowWorkspaceConnectionStatus(state)).toBe(true);
36+ expect(workspaceConnectionStatusLabel(state)).toBe("You are offline");
37+ });
38+
39+ it("names the environment while reconnecting", () => {
40+ const state = workspaceState({
41+ hasConnectingEnvironment: true,
42+ hasReadyEnvironment: false,
43+ connectingEnvironments: [
44+ {
45+ environmentId: "environment-1" as never,
46+ environmentLabel: "Julius’s Mac mini",
47+ displayUrl: "",
48+ isRelayManaged: false,
49+ connectionState: "reconnecting",
50+ connectionError: null,
51+ connectionErrorTraceId: null,
52+ },
53+ ],
54+ });
55+
56+ expect(shouldShowWorkspaceConnectionStatus(state)).toBe(true);
57+ expect(workspaceConnectionStatusLabel(state)).toBe("Reconnecting to Julius’s Mac mini");
58+ });
59+
60+ it("surfaces connection errors before the generic disconnected fallback", () => {
61+ const state = workspaceState({
62+ connectionError: "Could not reach Julius’s Mac mini",
63+ hasLoadedShellSnapshot: false,
64+ hasReadyEnvironment: false,
65+ });
66+
67+ expect(shouldShowWorkspaceConnectionStatus(state)).toBe(true);
68+ expect(workspaceConnectionStatusLabel(state)).toBe("Could not reach Julius’s Mac mini");
69+ });
70+});
apps/mobile/src/features/home/WorkspaceConnectionStatus.tsx+53−0
@@ -0,0 +1,53 @@
1+import { SymbolView } from "expo-symbols";
2+import { ActivityIndicator, Pressable } from "react-native";
3+
4+import { AppText as Text } from "../../components/AppText";
5+import { useThemeColor } from "../../lib/useThemeColor";
6+import type { WorkspaceState } from "../../state/workspaceModel";
7+import { workspaceConnectionStatusLabel } from "./workspace-connection-status";
8+
9+export function WorkspaceConnectionStatus(props: {
10+ readonly state: WorkspaceState;
11+ readonly onPress: () => void;
12+ readonly variant?: "floating" | "sidebar";
13+}) {
14+ const iconColor = useThemeColor("--color-icon-muted");
15+ const isReconnecting = props.state.connectingEnvironments.length > 0;
16+ const variant = props.variant ?? "floating";
17+
18+ return (
19+ <Pressable
20+ accessibilityHint="Opens environment settings"
21+ accessibilityLabel={workspaceConnectionStatusLabel(props.state)}
22+ accessibilityRole="button"
23+ onPress={props.onPress}
24+ className={
25+ variant === "sidebar"
26+ ? "mx-3 flex-row items-center gap-2 rounded-xl bg-subtle px-3 py-2.5"
27+ : "flex-row items-center gap-2 rounded-full bg-card px-4 py-2.5"
28+ }
29+ style={
30+ variant === "floating"
31+ ? {
32+ shadowColor: "#000",
33+ shadowOffset: { width: 0, height: 10 },
34+ shadowOpacity: 0.12,
35+ shadowRadius: 24,
36+ }
37+ : undefined
38+ }
39+ >
40+ {isReconnecting ? (
41+ <ActivityIndicator color={iconColor} size="small" />
42+ ) : (
43+ <SymbolView name="wifi.slash" size={15} tintColor={iconColor} type="monochrome" />
44+ )}
45+ <Text className="min-w-0 flex-1 text-sm font-t3-bold text-foreground" numberOfLines={1}>
46+ {workspaceConnectionStatusLabel(props.state)}
47+ </Text>
48+ {variant === "sidebar" ? (
49+ <SymbolView name="chevron.right" size={11} tintColor={iconColor} type="monochrome" />
50+ ) : null}
51+ </Pressable>
52+ );
53+}
apps/mobile/src/features/home/home-list-filter-menu.ts+120−0
@@ -0,0 +1,120 @@
1+import type {
2+ EnvironmentId,
3+ SidebarProjectGroupingMode,
4+ SidebarThreadSortOrder,
5+} from "@t3tools/contracts";
6+
7+import type { HomeProjectSortOrder } from "./homeThreadList";
8+import {
9+ PROJECT_GROUPING_OPTIONS,
10+ PROJECT_SORT_OPTIONS,
11+ THREAD_SORT_OPTIONS,
12+} from "./home-list-options";
13+
14+export interface HomeListFilterMenuEnvironment {
15+ readonly environmentId: EnvironmentId;
16+ readonly label: string;
17+}
18+
19+type HomeListFilterMenuAction = {
20+ readonly type: "action";
21+ readonly title: string;
22+ readonly subtitle?: string;
23+ readonly state?: "on" | "off";
24+ readonly onPress: () => void;
25+};
26+
27+type HomeListFilterMenuSubmenu = {
28+ readonly type: "submenu";
29+ readonly title: string;
30+ readonly items: HomeListFilterMenuAction[];
31+};
32+
33+export interface HomeListFilterMenu {
34+ readonly title: string;
35+ readonly items: Array<HomeListFilterMenuAction | HomeListFilterMenuSubmenu>;
36+}
37+
38+export function buildHomeListFilterMenu(props: {
39+ readonly environments: ReadonlyArray<HomeListFilterMenuEnvironment>;
40+ readonly selectedEnvironmentId: EnvironmentId | null;
41+ readonly projectSortOrder: HomeProjectSortOrder;
42+ readonly threadSortOrder: SidebarThreadSortOrder;
43+ readonly projectGroupingMode: SidebarProjectGroupingMode;
44+ readonly onEnvironmentChange: (environmentId: EnvironmentId | null) => void;
45+ readonly onProjectSortOrderChange: (sortOrder: HomeProjectSortOrder) => void;
46+ readonly onThreadSortOrderChange: (sortOrder: SidebarThreadSortOrder) => void;
47+ readonly onProjectGroupingModeChange: (mode: SidebarProjectGroupingMode) => void;
48+ readonly onOpenSettings?: () => void;
49+}): HomeListFilterMenu {
50+ const items: Array<HomeListFilterMenuAction | HomeListFilterMenuSubmenu> = [];
51+
52+ if (props.onOpenSettings) {
53+ items.push({
54+ type: "action",
55+ title: "Settings",
56+ onPress: props.onOpenSettings,
57+ });
58+ }
59+
60+ items.push(
61+ {
62+ type: "submenu",
63+ title: "Environment",
64+ items: [
65+ {
66+ type: "action",
67+ title: "All environments",
68+ subtitle: "Show threads from every environment",
69+ state: props.selectedEnvironmentId === null ? "on" : "off",
70+ onPress: () => props.onEnvironmentChange(null),
71+ },
72+ ...props.environments.map((environment) => ({
73+ type: "action" as const,
74+ title: environment.label,
75+ state:
76+ props.selectedEnvironmentId === environment.environmentId
77+ ? ("on" as const)
78+ : ("off" as const),
79+ onPress: () => props.onEnvironmentChange(environment.environmentId),
80+ })),
81+ ],
82+ },
83+ {
84+ type: "submenu",
85+ title: "Sort projects",
86+ items: PROJECT_SORT_OPTIONS.map((option) => ({
87+ type: "action",
88+ title: option.label,
89+ state: props.projectSortOrder === option.value ? "on" : "off",
90+ onPress: () => props.onProjectSortOrderChange(option.value),
91+ })),
92+ },
93+ {
94+ type: "submenu",
95+ title: "Sort threads",
96+ items: THREAD_SORT_OPTIONS.map((option) => ({
97+ type: "action",
98+ title: option.label,
99+ state: props.threadSortOrder === option.value ? "on" : "off",
100+ onPress: () => props.onThreadSortOrderChange(option.value),
101+ })),
102+ },
103+ {
104+ type: "submenu",
105+ title: "Group projects",
106+ items: PROJECT_GROUPING_OPTIONS.map((option) => ({
107+ type: "action",
108+ title: option.label,
109+ subtitle: option.subtitle,
110+ state: props.projectGroupingMode === option.value ? "on" : "off",
111+ onPress: () => props.onProjectGroupingModeChange(option.value),
112+ })),
113+ },
114+ );
115+
116+ return {
117+ title: "Thread list options",
118+ items,
119+ };
120+}
apps/mobile/src/features/home/home-list-options.test.ts+31−0
@@ -0,0 +1,31 @@
1+import {
2+ DEFAULT_SIDEBAR_PROJECT_GROUPING_MODE,
3+ DEFAULT_SIDEBAR_PROJECT_SORT_ORDER,
4+ DEFAULT_SIDEBAR_THREAD_SORT_ORDER,
5+} from "@t3tools/contracts";
6+import { describe, expect, it } from "vite-plus/test";
7+
8+import { hasCustomHomeListOptions, type HomeListOptions } from "./home-list-options";
9+
10+const defaults: HomeListOptions = {
11+ selectedEnvironmentId: null,
12+ projectSortOrder:
13+ DEFAULT_SIDEBAR_PROJECT_SORT_ORDER === "manual"
14+ ? "updated_at"
15+ : DEFAULT_SIDEBAR_PROJECT_SORT_ORDER,
16+ threadSortOrder: DEFAULT_SIDEBAR_THREAD_SORT_ORDER,
17+ projectGroupingMode: DEFAULT_SIDEBAR_PROJECT_GROUPING_MODE,
18+};
19+
20+describe("home list options", () => {
21+ it("recognizes default options", () => {
22+ expect(hasCustomHomeListOptions(defaults)).toBe(false);
23+ });
24+
25+ it("marks environment filters and grouping changes as customized", () => {
26+ expect(
27+ hasCustomHomeListOptions({ ...defaults, selectedEnvironmentId: "environment-1" as never }),
28+ ).toBe(true);
29+ expect(hasCustomHomeListOptions({ ...defaults, projectGroupingMode: "separate" })).toBe(true);
30+ });
31+});
apps/mobile/src/features/home/home-list-options.ts+144−0
@@ -0,0 +1,144 @@
1+import type {
2+ EnvironmentId,
3+ SidebarProjectGroupingMode,
4+ SidebarThreadSortOrder,
5+} from "@t3tools/contracts";
6+import {
7+ DEFAULT_SIDEBAR_PROJECT_GROUPING_MODE,
8+ DEFAULT_SIDEBAR_PROJECT_SORT_ORDER,
9+ DEFAULT_SIDEBAR_THREAD_SORT_ORDER,
10+} from "@t3tools/contracts";
11+import {
12+ createContext,
13+ createElement,
14+ useCallback,
15+ useContext,
16+ useMemo,
17+ useState,
18+ type PropsWithChildren,
19+ type Dispatch,
20+ type SetStateAction,
21+} from "react";
22+
23+import type { HomeProjectSortOrder } from "./homeThreadList";
24+
25+export interface HomeListOptions {
26+ readonly selectedEnvironmentId: EnvironmentId | null;
27+ readonly projectSortOrder: HomeProjectSortOrder;
28+ readonly threadSortOrder: SidebarThreadSortOrder;
29+ readonly projectGroupingMode: SidebarProjectGroupingMode;
30+}
31+
32+export const PROJECT_SORT_OPTIONS: ReadonlyArray<{
33+ readonly value: HomeProjectSortOrder;
34+ readonly label: string;
35+}> = [
36+ { value: "updated_at", label: "Last user message" },
37+ { value: "created_at", label: "Created at" },
38+];
39+
40+export const THREAD_SORT_OPTIONS: ReadonlyArray<{
41+ readonly value: SidebarThreadSortOrder;
42+ readonly label: string;
43+}> = [
44+ { value: "updated_at", label: "Last user message" },
45+ { value: "created_at", label: "Created at" },
46+];
47+
48+export const PROJECT_GROUPING_OPTIONS: ReadonlyArray<{
49+ readonly value: SidebarProjectGroupingMode;
50+ readonly label: string;
51+ readonly subtitle: string;
52+}> = [
53+ {
54+ value: "repository",
55+ label: "Group by repository",
56+ subtitle: "Combine matching repositories across environments",
57+ },
58+ {
59+ value: "repository_path",
60+ label: "Group by repository path",
61+ subtitle: "Combine only matching paths within a repository",
62+ },
63+ {
64+ value: "separate",
65+ label: "Keep separate",
66+ subtitle: "Show every project path separately",
67+ },
68+];
69+
70+function defaultHomeListOptions(): HomeListOptions {
71+ return {
72+ selectedEnvironmentId: null,
73+ projectSortOrder:
74+ DEFAULT_SIDEBAR_PROJECT_SORT_ORDER === "manual"
75+ ? "updated_at"
76+ : DEFAULT_SIDEBAR_PROJECT_SORT_ORDER,
77+ threadSortOrder: DEFAULT_SIDEBAR_THREAD_SORT_ORDER,
78+ projectGroupingMode: DEFAULT_SIDEBAR_PROJECT_GROUPING_MODE,
79+ };
80+}
81+
82+interface HomeListOptionsContextValue {
83+ readonly options: HomeListOptions;
84+ readonly setOptions: Dispatch<SetStateAction<HomeListOptions>>;
85+}
86+
87+const HomeListOptionsContext = createContext<HomeListOptionsContextValue | null>(null);
88+
89+/** Keeps list preferences stable while the app moves between compact and split shells. */
90+export function HomeListOptionsProvider({ children }: PropsWithChildren) {
91+ const [options, setOptions] = useState<HomeListOptions>(defaultHomeListOptions);
92+ const value = useMemo(() => ({ options, setOptions }), [options]);
93+ return createElement(HomeListOptionsContext, { value }, children);
94+}
95+
96+export function hasCustomHomeListOptions(options: HomeListOptions): boolean {
97+ const defaultProjectSortOrder =
98+ DEFAULT_SIDEBAR_PROJECT_SORT_ORDER === "manual"
99+ ? "updated_at"
100+ : DEFAULT_SIDEBAR_PROJECT_SORT_ORDER;
101+ return (
102+ options.selectedEnvironmentId !== null ||
103+ options.projectSortOrder !== defaultProjectSortOrder ||
104+ options.threadSortOrder !== DEFAULT_SIDEBAR_THREAD_SORT_ORDER ||
105+ options.projectGroupingMode !== DEFAULT_SIDEBAR_PROJECT_GROUPING_MODE
106+ );
107+}
108+
109+export function useHomeListOptions(availableEnvironmentIds: ReadonlySet<EnvironmentId>) {
110+ const shared = useContext(HomeListOptionsContext);
111+ const [localOptions, setLocalOptions] = useState<HomeListOptions>(defaultHomeListOptions);
112+ const options = shared?.options ?? localOptions;
113+ const setOptions = shared?.setOptions ?? setLocalOptions;
114+ const selectedEnvironmentId =
115+ options.selectedEnvironmentId !== null &&
116+ availableEnvironmentIds.has(options.selectedEnvironmentId)
117+ ? options.selectedEnvironmentId
118+ : null;
119+ const resolvedOptions =
120+ selectedEnvironmentId === options.selectedEnvironmentId
121+ ? options
122+ : { ...options, selectedEnvironmentId };
123+
124+ const setSelectedEnvironmentId = useCallback((value: EnvironmentId | null) => {
125+ setOptions((current) => ({ ...current, selectedEnvironmentId: value }));
126+ }, []);
127+ const setProjectSortOrder = useCallback((value: HomeProjectSortOrder) => {
128+ setOptions((current) => ({ ...current, projectSortOrder: value }));
129+ }, []);
130+ const setThreadSortOrder = useCallback((value: SidebarThreadSortOrder) => {
131+ setOptions((current) => ({ ...current, threadSortOrder: value }));
132+ }, []);
133+ const setProjectGroupingMode = useCallback((value: SidebarProjectGroupingMode) => {
134+ setOptions((current) => ({ ...current, projectGroupingMode: value }));
135+ }, []);
136+
137+ return {
138+ options: resolvedOptions,
139+ setSelectedEnvironmentId,
140+ setProjectSortOrder,
141+ setThreadSortOrder,
142+ setProjectGroupingMode,
143+ } as const;
144+}
apps/mobile/src/features/home/homeListItems.test.ts+197−0
@@ -0,0 +1,197 @@
1+import type {
2+ EnvironmentProject,
3+ EnvironmentThreadShell,
4+} from "@t3tools/client-runtime/state/shell";
5+import { EnvironmentId, ProjectId, ProviderInstanceId, ThreadId } from "@t3tools/contracts";
6+import { describe, expect, it } from "vite-plus/test";
7+
8+import {
9+ buildHomeListLayout,
10+ DEFAULT_GROUP_DISPLAY_STATE,
11+ HOME_INITIAL_VISIBLE_THREADS,
12+ HOME_SHOW_MORE_STEP,
13+ nextGroupDisplayState,
14+ type HomeGroupDisplayState,
15+ type HomeListItem,
16+} from "./homeListItems";
17+import type { HomeThreadGroup } from "./homeThreadList";
18+
19+const environmentId = EnvironmentId.make("environment-1");
20+
21+function makeProject(id: string, title: string): EnvironmentProject {
22+ return {
23+ environmentId,
24+ id: ProjectId.make(id),
25+ title,
26+ workspaceRoot: `/workspaces/${id}`,
27+ repositoryIdentity: null,
28+ defaultModelSelection: null,
29+ scripts: [],
30+ createdAt: "2026-06-01T00:00:00.000Z",
31+ updatedAt: "2026-06-01T00:00:00.000Z",
32+ };
33+}
34+
35+function makeThread(id: string, projectId: ProjectId): EnvironmentThreadShell {
36+ return {
37+ environmentId,
38+ id: ThreadId.make(id),
39+ projectId,
40+ title: `Thread ${id}`,
41+ modelSelection: { instanceId: ProviderInstanceId.make("codex"), model: "gpt-5.4" },
42+ runtimeMode: "full-access",
43+ interactionMode: "default",
44+ branch: null,
45+ worktreePath: null,
46+ latestTurn: null,
47+ createdAt: "2026-06-01T00:00:00.000Z",
48+ updatedAt: "2026-06-01T00:00:00.000Z",
49+ archivedAt: null,
50+ session: null,
51+ latestUserMessageAt: null,
52+ hasPendingApprovals: false,
53+ hasPendingUserInput: false,
54+ hasActionableProposedPlan: false,
55+ };
56+}
57+
58+function makeGroup(key: string, threadCount: number): HomeThreadGroup {
59+ const project = makeProject(key, key);
60+ const threads = Array.from({ length: threadCount }, (_, index) =>
61+ makeThread(`${key}-thread-${index}`, project.id),
62+ );
63+ return {
64+ key,
65+ title: key,
66+ representative: project,
67+ projects: [project],
68+ pendingTasks: [],
69+ threads,
70+ // All threads inside the recency window, so the baseline stays at the
71+ // initial page size and the pagination expectations below hold.
72+ recentThreads: threads,
73+ };
74+}
75+
76+function itemTypes(items: ReadonlyArray<HomeListItem>): string[] {
77+ return items.map((item) => item.type);
78+}
79+
80+function displayStates(
81+ entries: Record<string, HomeGroupDisplayState>,
82+): ReadonlyMap<string, HomeGroupDisplayState> {
83+ return new Map(Object.entries(entries));
84+}
85+
86+describe("buildHomeListLayout", () => {
87+ it("renders a header plus all threads for a small group without a show-more row", () => {
88+ const layout = buildHomeListLayout({
89+ groups: [makeGroup("alpha", 3)],
90+ displayStates: displayStates({}),
91+ });
92+
93+ expect(itemTypes(layout.items)).toEqual(["header", "thread", "thread", "thread"]);
94+ expect(layout.stickyHeaderIndices).toEqual([0]);
95+ expect(layout.items.at(-1)).toMatchObject({ type: "thread", isLast: true });
96+ });
97+
98+ it("limits large groups to the initial visible count with a show-more row", () => {
99+ const layout = buildHomeListLayout({
100+ groups: [makeGroup("alpha", 133)],
101+ displayStates: displayStates({}),
102+ });
103+
104+ const threadItems = layout.items.filter((item) => item.type === "thread");
105+ expect(threadItems).toHaveLength(HOME_INITIAL_VISIBLE_THREADS);
106+ expect(layout.items.at(-1)).toMatchObject({
107+ type: "show-more",
108+ groupKey: "alpha",
109+ hiddenCount: 133 - HOME_INITIAL_VISIBLE_THREADS,
110+ canShowLess: false,
111+ });
112+ // The show-more row takes over the last slot, so no thread is marked last.
113+ expect(threadItems.every((item) => item.type === "thread" && !item.isLast)).toBe(true);
114+ });
115+
116+ it("reveals more threads per show-more step and offers show-less when exhausted", () => {
117+ const group = makeGroup("alpha", 20);
118+
119+ const expandedOnce = buildHomeListLayout({
120+ groups: [group],
121+ displayStates: displayStates({
122+ alpha: nextGroupDisplayState(DEFAULT_GROUP_DISPLAY_STATE, "show-more"),
123+ }),
124+ });
125+ expect(expandedOnce.items.filter((item) => item.type === "thread")).toHaveLength(
126+ HOME_INITIAL_VISIBLE_THREADS + HOME_SHOW_MORE_STEP,
127+ );
128+ expect(expandedOnce.items.at(-1)).toMatchObject({
129+ type: "show-more",
130+ hiddenCount: 4,
131+ canShowLess: true,
132+ });
133+
134+ const fullyExpanded = buildHomeListLayout({
135+ groups: [group],
136+ displayStates: displayStates({
137+ alpha: nextGroupDisplayState(
138+ nextGroupDisplayState(DEFAULT_GROUP_DISPLAY_STATE, "show-more"),
139+ "show-more",
140+ ),
141+ }),
142+ });
143+ expect(fullyExpanded.items.filter((item) => item.type === "thread")).toHaveLength(20);
144+ expect(fullyExpanded.items.at(-1)).toMatchObject({
145+ type: "show-more",
146+ hiddenCount: 0,
147+ canShowLess: true,
148+ });
149+
150+ const reset = nextGroupDisplayState(
151+ nextGroupDisplayState(
152+ nextGroupDisplayState(DEFAULT_GROUP_DISPLAY_STATE, "show-more"),
153+ "show-more",
154+ ),
155+ "show-less",
156+ );
157+ expect(reset.visibleCount).toBe(HOME_INITIAL_VISIBLE_THREADS);
158+ });
159+
160+ it("hides threads and the show-more row for collapsed groups", () => {
161+ const layout = buildHomeListLayout({
162+ groups: [makeGroup("alpha", 12), makeGroup("beta", 2)],
163+ displayStates: displayStates({
164+ alpha: nextGroupDisplayState(DEFAULT_GROUP_DISPLAY_STATE, "toggle-collapsed"),
165+ }),
166+ });
167+
168+ expect(itemTypes(layout.items)).toEqual(["header", "header", "thread", "thread"]);
169+ expect(layout.items[0]).toMatchObject({ type: "header", collapsed: true, isFirst: true });
170+ expect(layout.items[1]).toMatchObject({ type: "header", collapsed: false, isFirst: false });
171+ expect(layout.stickyHeaderIndices).toEqual([0, 1]);
172+ });
173+
174+ it("suspends collapse and pagination while searching", () => {
175+ const layout = buildHomeListLayout({
176+ groups: [makeGroup("alpha", 12)],
177+ displayStates: displayStates({
178+ alpha: nextGroupDisplayState(DEFAULT_GROUP_DISPLAY_STATE, "toggle-collapsed"),
179+ }),
180+ showAllThreads: true,
181+ });
182+
183+ expect(layout.items.filter((item) => item.type === "thread")).toHaveLength(12);
184+ expect(layout.items.some((item) => item.type === "show-more")).toBe(false);
185+ });
186+
187+ it("keeps sticky indices aligned across multiple expanded groups", () => {
188+ const layout = buildHomeListLayout({
189+ groups: [makeGroup("alpha", 8), makeGroup("beta", 1)],
190+ displayStates: displayStates({}),
191+ });
192+
193+ // header + 6 threads + show-more = 8 items, so beta's header is index 8.
194+ expect(layout.stickyHeaderIndices).toEqual([0, 8]);
195+ expect(layout.items[8]).toMatchObject({ type: "header", isFirst: false });
196+ });
197+});
apps/mobile/src/features/home/homeListItems.ts+204−0
@@ -0,0 +1,204 @@
1+import type { EnvironmentThreadShell } from "@t3tools/client-runtime/state/shell";
2+
3+import type { PendingNewTask } from "../../state/use-pending-new-tasks";
4+import type { HomeThreadGroup } from "./homeThreadList";
5+
6+/** Threads shown per project before the "Show more" affordance appears. */
7+export const HOME_INITIAL_VISIBLE_THREADS = 6;
8+/** Additional threads revealed per "Show more" tap. */
9+export const HOME_SHOW_MORE_STEP = 10;
10+
11+export interface HomeGroupDisplayState {
12+ readonly collapsed: boolean;
13+ /** How many threads are currently revealed (clamped to the group size). */
14+ readonly visibleCount: number;
15+}
16+
17+export const DEFAULT_GROUP_DISPLAY_STATE: HomeGroupDisplayState = {
18+ collapsed: false,
19+ visibleCount: HOME_INITIAL_VISIBLE_THREADS,
20+};
21+
22+export interface HomeHeaderListItem {
23+ readonly type: "header";
24+ readonly key: string;
25+ readonly group: HomeThreadGroup;
26+ readonly collapsed: boolean;
27+ readonly isFirst: boolean;
28+}
29+
30+export interface HomeThreadListItem {
31+ readonly type: "thread";
32+ readonly key: string;
33+ readonly thread: EnvironmentThreadShell;
34+ readonly isLast: boolean;
35+}
36+
37+export interface HomePendingTaskListItem {
38+ readonly type: "pending-task";
39+ readonly key: string;
40+ readonly pendingTask: PendingNewTask;
41+ readonly isLast: boolean;
42+}
43+
44+export interface HomeShowMoreListItem {
45+ readonly type: "show-more";
46+ readonly key: string;
47+ readonly groupKey: string;
48+ /** Threads still hidden. 0 means the group is fully expanded. */
49+ readonly hiddenCount: number;
50+ /** Whether more than the initial count is revealed, so "Show less" applies. */
51+ readonly canShowLess: boolean;
52+}
53+
54+export type HomeListItem =
55+ | HomeHeaderListItem
56+ | HomePendingTaskListItem
57+ | HomeThreadListItem
58+ | HomeShowMoreListItem;
59+
60+export interface HomeListLayout {
61+ readonly items: ReadonlyArray<HomeListItem>;
62+ readonly stickyHeaderIndices: ReadonlyArray<number>;
63+}
64+
65+export type HomeGroupDisplayAction = "toggle-collapsed" | "show-more" | "show-less";
66+
67+export function nextGroupDisplayState(
68+ current: HomeGroupDisplayState,
69+ action: HomeGroupDisplayAction,
70+): HomeGroupDisplayState {
71+ switch (action) {
72+ case "toggle-collapsed":
73+ return { ...current, collapsed: !current.collapsed };
74+ case "show-more":
75+ return { ...current, visibleCount: current.visibleCount + HOME_SHOW_MORE_STEP };
76+ case "show-less":
77+ return { ...current, visibleCount: HOME_INITIAL_VISIBLE_THREADS };
78+ }
79+}
80+
81+/**
82+ * Structural equality for list items. Item objects are rebuilt on every
83+ * collapse/show-more toggle; without this the lists would consider every
84+ * mounted row changed and re-render all of them (each carrying a swipeable +
85+ * a vcs-status subscription). Group/thread references are stable across
86+ * toggles.
87+ */
88+export function homeListItemsAreEqual(previous: HomeListItem, item: HomeListItem): boolean {
89+ switch (item.type) {
90+ case "header":
91+ return (
92+ previous.type === "header" &&
93+ previous.group === item.group &&
94+ previous.collapsed === item.collapsed &&
95+ previous.isFirst === item.isFirst
96+ );
97+ case "pending-task":
98+ return (
99+ previous.type === "pending-task" &&
100+ previous.pendingTask === item.pendingTask &&
101+ previous.isLast === item.isLast
102+ );
103+ case "thread":
104+ return (
105+ previous.type === "thread" &&
106+ previous.thread === item.thread &&
107+ previous.isLast === item.isLast
108+ );
109+ case "show-more":
110+ return (
111+ previous.type === "show-more" &&
112+ previous.groupKey === item.groupKey &&
113+ previous.hiddenCount === item.hiddenCount &&
114+ previous.canShowLess === item.canShowLess
115+ );
116+ }
117+}
118+
119+export function buildHomeListLayout(input: {
120+ readonly groups: ReadonlyArray<HomeThreadGroup>;
121+ readonly displayStates: ReadonlyMap<string, HomeGroupDisplayState>;
122+ /**
123+ * When searching, pagination is suspended so every match stays visible.
124+ */
125+ readonly showAllThreads?: boolean;
126+}): HomeListLayout {
127+ const items: HomeListItem[] = [];
128+ const stickyHeaderIndices: number[] = [];
129+
130+ for (const [groupIndex, group] of input.groups.entries()) {
131+ const display = input.displayStates.get(group.key) ?? DEFAULT_GROUP_DISPLAY_STATE;
132+ const collapsed = display.collapsed && input.showAllThreads !== true;
133+
134+ stickyHeaderIndices.push(items.length);
135+ items.push({
136+ type: "header",
137+ key: `header:${group.key}`,
138+ group,
139+ collapsed,
140+ isFirst: groupIndex === 0,
141+ });
142+
143+ if (collapsed) {
144+ continue;
145+ }
146+
147+ const totalCount = group.threads.length;
148+ // Default to the group's recent-activity window (last few days, or a small
149+ // fallback for stale projects), capped at the initial page size. Until the
150+ // user taps "Show more", older threads stay hidden to save vertical space;
151+ // "Show less" resets visibleCount to the initial constant, which lands back
152+ // here at the recency baseline.
153+ const baselineCount = Math.min(
154+ group.recentThreads.length,
155+ HOME_INITIAL_VISIBLE_THREADS,
156+ totalCount,
157+ );
158+ const visibleCount = input.showAllThreads
159+ ? totalCount
160+ : Math.min(
161+ display.visibleCount > HOME_INITIAL_VISIBLE_THREADS
162+ ? display.visibleCount
163+ : baselineCount,
164+ totalCount,
165+ );
166+ const visibleThreads = group.threads.slice(0, visibleCount);
167+ const hiddenCount = totalCount - visibleCount;
168+ const hasShowMoreRow = !input.showAllThreads && totalCount > baselineCount;
169+
170+ // Pending (unsent) tasks lead the group and are never paginated away.
171+ for (const [pendingIndex, pendingTask] of group.pendingTasks.entries()) {
172+ items.push({
173+ type: "pending-task",
174+ key: `pending-task:${pendingTask.message.messageId}`,
175+ pendingTask,
176+ isLast:
177+ pendingIndex === group.pendingTasks.length - 1 &&
178+ visibleThreads.length === 0 &&
179+ !hasShowMoreRow,
180+ });
181+ }
182+
183+ for (const [threadIndex, thread] of visibleThreads.entries()) {
184+ items.push({
185+ type: "thread",
186+ key: `thread:${thread.environmentId}:${thread.id}`,
187+ thread,
188+ isLast: threadIndex === visibleThreads.length - 1 && !hasShowMoreRow,
189+ });
190+ }
191+
192+ if (hasShowMoreRow) {
193+ items.push({
194+ type: "show-more",
195+ key: `show-more:${group.key}`,
196+ groupKey: group.key,
197+ hiddenCount,
198+ canShowLess: visibleCount > HOME_INITIAL_VISIBLE_THREADS,
199+ });
200+ }
201+ }
202+
203+ return { items, stickyHeaderIndices };
204+}
apps/mobile/src/features/home/homeThreadList.ts+57−4
@@ -17,6 +17,7 @@ import * as Arr from "effect/Array"; · 16 unmodified lines
1717 import * as Order from "effect/Order";
1818
1919 import { scopedProjectKey } from "../../lib/scopedEntities";
20+import type { PendingNewTask } from "../../state/use-pending-new-tasks";
2021
2122 export type HomeProjectSortOrder = Exclude<SidebarProjectSortOrder, "manual">;
2223
@@ -33,6 +34,7 @@ export interface HomeThreadGroup { · 10 unmodified lines
3334 readonly title: string;
3435 readonly representative: EnvironmentProject;
3536 readonly projects: ReadonlyArray<EnvironmentProject>;
37+ readonly pendingTasks: ReadonlyArray<PendingNewTask>;
3638 /** Full sorted thread history for the group (revealed when expanded / searching). */
3739 readonly threads: ReadonlyArray<EnvironmentThreadShell>;
3840 /** 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
4244 interface MutableHomeThreadGroup {
4345 readonly key: string;
4446 readonly projects: EnvironmentProject[];
47+ readonly pendingTasks: PendingNewTask[];
4548 readonly threads: EnvironmentThreadShell[];
4649 }
4750
4851 function groupSortTimestamp(group: HomeThreadGroup, sortOrder: HomeProjectSortOrder): number {
49− return group.threads.reduce(
52+ const latestThread = group.threads.reduce(
5053 (latest, thread) => Math.max(latest, getThreadSortTimestamp(thread, sortOrder)),
5154 Number.NEGATIVE_INFINITY,
5255 );
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);
5360 }
5461
5562 /**
@@ -73,6 +80,7 @@ function selectRecentThreads( · 17 unmodified lines
7380 export function buildHomeThreadGroups(input: {
7481 readonly projects: ReadonlyArray<EnvironmentProject>;
7582 readonly threads: ReadonlyArray<EnvironmentThreadShell>;
83+ readonly pendingTasks?: ReadonlyArray<PendingNewTask>;
7684 readonly environmentId: EnvironmentId | null;
7785 readonly searchQuery: string;
7886 readonly projectSortOrder: HomeProjectSortOrder;
@@ -100,10 +108,49 @@ export function buildHomeThreadGroups(input: { · 21 unmodified lines
100108 if (existing) {
101109 existing.projects.push(project);
102110 } else {
103− groups.set(groupKey, { key: groupKey, projects: [project], threads: [] });
111+ groups.set(groupKey, { key: groupKey, projects: [project], pendingTasks: [], threads: [] });
104112 }
105113 }
106114
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+
107154 for (const thread of input.threads) {
108155 if (thread.archivedAt !== null) {
109156 continue;
@@ -125,7 +172,7 @@ export function buildHomeThreadGroups(input: { · 15 unmodified lines
125172
126173 for (const group of groups.values()) {
127174 const representative = group.projects[0];
128− if (!representative || group.threads.length === 0) {
175+ if (!representative || (group.threads.length === 0 && group.pendingTasks.length === 0)) {
129176 continue;
130177 }
131178
@@ -140,8 +187,13 @@ export function buildHomeThreadGroups(input: { · 8 unmodified lines
140187 const matchingThreads = groupMatches
141188 ? group.threads
142189 : 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+ );
143195
144− if (matchingThreads.length === 0) {
196+ if (matchingThreads.length === 0 && matchingPendingTasks.length === 0) {
145197 continue;
146198 }
147199
@@ -158,6 +210,7 @@ export function buildHomeThreadGroups(input: { · 10 unmodified lines
158210 title,
159211 representative,
160212 projects: group.projects,
213+ pendingTasks: matchingPendingTasks,
161214 threads: sortedThreads,
162215 recentThreads,
163216 });
apps/mobile/src/features/home/thread-swipe-actions.tsx+247−9
@@ -1,8 +1,26 @@
11 import { SymbolView } from "expo-symbols";
2−import type { ComponentProps } from "react";
3−import type { ColorValue } from "react-native";
2+import * as Haptics from "expo-haptics";
3+import {
4+ createContext,
5+ use,
6+ useCallback,
7+ useEffect,
8+ useRef,
9+ useState,
10+ type ComponentProps,
11+ type ReactNode,
12+} from "react";
13+import type {
14+ ColorValue,
15+ NativeScrollEvent,
16+ NativeSyntheticEvent,
17+ StyleProp,
18+ ViewStyle,
19+} from "react-native";
420 import { Pressable, View } from "react-native";
5−import type { SwipeableMethods } from "react-native-gesture-handler/ReanimatedSwipeable";
21+import ReanimatedSwipeable, {
22+ type SwipeableMethods,
23+} from "react-native-gesture-handler/ReanimatedSwipeable";
624 import Animated, {
725 Extrapolation,
826 interpolate,
@@ -26,6 +44,231 @@ export const THREAD_SWIPE_SPRING = { · 17 unmodified lines
2644 stiffness: 330,
2745 };
2846
47+interface ThreadSwipePrimaryAction {
48+ readonly accessibilityLabel: string;
49+ readonly icon: ComponentProps<typeof SymbolView>["name"];
50+ readonly label: string;
51+ readonly onPress: () => void;
52+}
53+
54+/**
55+ * Delivers the scroll gate to swipeables via context so that flipping it does
56+ * NOT re-render whole rows: putting the flag in list extraData/renderItem deps
57+ * re-rendered every visible row (hooks, subscriptions and all) exactly at
58+ * scroll start — peak frame pressure. As a context value only the
59+ * ThreadSwipeable consumers re-render.
60+ */
61+const SwipeableScrollGateContext = createContext(true);
62+
63+export function SwipeableScrollGateProvider(props: {
64+ readonly enabled: boolean;
65+ readonly children: ReactNode;
66+}) {
67+ return (
68+ <SwipeableScrollGateContext.Provider value={props.enabled}>
69+ {props.children}
70+ </SwipeableScrollGateContext.Provider>
71+ );
72+}
73+
74+/**
75+ * Gates row swipes on list scroll activity, mirroring UIKit's own swipe
76+ * actions (`!isDragging && !isDecelerating`). failOffsetY on the swipe pan
77+ * covers the first pan of a scroll, but trackpad scroll sessions spawn fresh
78+ * gesture sessions (momentum catch, direction changes) whose reset
79+ * translation can re-activate a swipe mid-scroll — so while the list has
80+ * moved vertically during an active drag/momentum phase, row swipes are
81+ * disabled entirely.
82+ *
83+ * Spread the returned handlers onto the list and pass `swipeEnabled` to rows.
84+ */
85+export function useSwipeableScrollGate(options?: {
86+ readonly onScroll?: (event: NativeSyntheticEvent<NativeScrollEvent>) => void;
87+ readonly onScrollBeginDrag?: (event: NativeSyntheticEvent<NativeScrollEvent>) => void;
88+}) {
89+ const [gateActive, setGateActive] = useState(false);
90+ const gateActiveRef = useRef(false);
91+ const draggingRef = useRef(false);
92+ const dragStartYRef = useRef(0);
93+ const settleTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
94+ const externalOnScroll = options?.onScroll;
95+ const externalOnScrollBeginDrag = options?.onScrollBeginDrag;
96+
97+ const update = useCallback((next: boolean) => {
98+ if (gateActiveRef.current !== next) {
99+ gateActiveRef.current = next;
100+ setGateActive(next);
101+ }
102+ }, []);
103+ const clearSettle = useCallback(() => {
104+ if (settleTimerRef.current !== null) {
105+ clearTimeout(settleTimerRef.current);
106+ settleTimerRef.current = null;
107+ }
108+ }, []);
109+ useEffect(() => clearSettle, [clearSettle]);
110+
111+ const onScrollBeginDrag = useCallback(
112+ (event: NativeSyntheticEvent<NativeScrollEvent>) => {
113+ draggingRef.current = true;
114+ dragStartYRef.current = event.nativeEvent.contentOffset.y;
115+ clearSettle();
116+ externalOnScrollBeginDrag?.(event);
117+ },
118+ [clearSettle, externalOnScrollBeginDrag],
119+ );
120+ const onScroll = useCallback(
121+ (event: NativeSyntheticEvent<NativeScrollEvent>) => {
122+ // Only vertical movement during a user drag arms the gate — a purely
123+ // horizontal row swipe never moves contentOffset.y, and inset-driven
124+ // offset changes at mount happen outside a drag.
125+ if (
126+ draggingRef.current &&
127+ !gateActiveRef.current &&
128+ Math.abs(event.nativeEvent.contentOffset.y - dragStartYRef.current) > 4
129+ ) {
130+ update(true);
131+ }
132+ externalOnScroll?.(event);
133+ },
134+ [externalOnScroll, update],
135+ );
136+ const onScrollEndDrag = useCallback(() => {
137+ draggingRef.current = false;
138+ clearSettle();
139+ // If momentum follows, onMomentumScrollBegin cancels this and the gate
140+ // stays armed until the deceleration finishes.
141+ settleTimerRef.current = setTimeout(() => update(false), 160);
142+ }, [clearSettle, update]);
143+ const onMomentumScrollBegin = useCallback(() => {
144+ clearSettle();
145+ }, [clearSettle]);
146+ const onMomentumScrollEnd = useCallback(() => {
147+ update(false);
148+ }, [update]);
149+
150+ return {
151+ swipeEnabled: !gateActive,
152+ scrollGateHandlers: {
153+ onScroll,
154+ onScrollBeginDrag,
155+ onScrollEndDrag,
156+ onMomentumScrollBegin,
157+ onMomentumScrollEnd,
158+ },
159+ };
160+}
161+
162+export function ThreadSwipeable(props: {
163+ readonly backgroundColor: ColorValue;
164+ readonly children: (close: () => void) => ReactNode;
165+ readonly containerStyle?: StyleProp<ViewStyle>;
166+ /** Disables NEW swipe activations (e.g. while the list scrolls). */
167+ readonly enabled?: boolean;
168+ readonly enableTrackpadSwipe?: boolean;
169+ readonly fullSwipeWidth: number;
170+ readonly onDelete: () => void;
171+ readonly onSwipeableClose?: (methods: SwipeableMethods) => void;
172+ readonly onSwipeableWillOpen?: (methods: SwipeableMethods) => void;
173+ readonly primaryAction: ThreadSwipePrimaryAction;
174+ /**
175+ * Identity of the content being wrapped. When a recycled list reuses this
176+ * component for a different item, the swipeable snaps back to closed so an
177+ * open/mid-drag state can't leak onto another row.
178+ */
179+ readonly resetKey?: string;
180+ readonly simultaneousWithExternalGesture?: ComponentProps<
181+ typeof ReanimatedSwipeable
182+ >["simultaneousWithExternalGesture"];
183+ readonly threadTitle: string;
184+}) {
185+ const swipeableRef = useRef<SwipeableMethods | null>(null);
186+ const fullSwipeArmedRef = useRef(false);
187+ const fullSwipeThreshold = Math.max(THREAD_SWIPE_ACTIONS_WIDTH + 44, props.fullSwipeWidth * 0.58);
188+ const close = useCallback(() => swipeableRef.current?.close(), []);
189+ const gateEnabled = use(SwipeableScrollGateContext);
190+ const resetKey = props.resetKey;
191+ useEffect(() => {
192+ if (resetKey === undefined) {
193+ return;
194+ }
195+ fullSwipeArmedRef.current = false;
196+ swipeableRef.current?.reset();
197+ }, [resetKey]);
198+ const handleFullSwipeArmedChange = useCallback((armed: boolean) => {
199+ if (armed && !fullSwipeArmedRef.current && process.env.EXPO_OS === "ios") {
200+ void Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Medium);
201+ }
202+ fullSwipeArmedRef.current = armed;
203+ }, []);
204+
205+ return (
206+ <ReanimatedSwipeable
207+ ref={swipeableRef}
208+ animationOptions={THREAD_SWIPE_SPRING}
209+ childrenContainerStyle={{ backgroundColor: props.backgroundColor }}
210+ containerStyle={[{ backgroundColor: props.backgroundColor }, props.containerStyle]}
211+ dragOffsetFromRightEdge={8}
212+ enabled={props.enabled !== false && gateEnabled}
213+ enableTrackpadTwoFingerGesture={props.enableTrackpadSwipe ?? true}
214+ // Fail the swipe once the pan is vertically dominant (patched-in RNGH
215+ // prop) — otherwise trackpad scrolls with ~8px of horizontal drift
216+ // start opening rows because the swipe pan runs simultaneously with
217+ // the list scroll gesture and never gets disqualified by Y movement.
218+ failOffsetY={[-10, 10]}
219+ friction={1}
220+ onSwipeableClose={() => {
221+ fullSwipeArmedRef.current = false;
222+ if (swipeableRef.current) {
223+ props.onSwipeableClose?.(swipeableRef.current);
224+ }
225+ }}
226+ onSwipeableOpenStartDrag={() => {
227+ if (swipeableRef.current) {
228+ props.onSwipeableWillOpen?.(swipeableRef.current);
229+ }
230+ }}
231+ onSwipeableWillOpen={() => {
232+ const methods = swipeableRef.current;
233+ if (!methods) {
234+ return;
235+ }
236+
237+ props.onSwipeableWillOpen?.(methods);
238+ if (fullSwipeArmedRef.current) {
239+ fullSwipeArmedRef.current = false;
240+ methods.close();
241+ props.onDelete();
242+ }
243+ }}
244+ overshootFriction={1}
245+ overshootRight
246+ renderRightActions={(_progress, translation, methods) => (
247+ <ThreadSwipeActions
248+ backgroundColor={props.backgroundColor}
249+ fullSwipeThreshold={fullSwipeThreshold}
250+ onDelete={props.onDelete}
251+ onFullSwipeArmedChange={handleFullSwipeArmedChange}
252+ primaryAction={{
253+ ...props.primaryAction,
254+ onPress: () => {
255+ methods.close();
256+ props.primaryAction.onPress();
257+ },
258+ }}
259+ swipeableMethods={methods}
260+ threadTitle={props.threadTitle}
261+ translation={translation}
262+ />
263+ )}
264+ rightThreshold={THREAD_SWIPE_ACTIONS_WIDTH * 0.42}
265+ simultaneousWithExternalGesture={props.simultaneousWithExternalGesture}
266+ >
267+ {props.children(close)}
268+ </ReanimatedSwipeable>
269+ );
270+}
271+
29272 function SwipeActionButton(props: {
30273 readonly accessibilityLabel: string;
31274 readonly backgroundColor: string;
@@ -179,12 +422,7 @@ export function ThreadSwipeActions(props: { · 147 unmodified lines
179422 readonly fullSwipeThreshold: number;
180423 readonly onDelete: () => void;
181424 readonly onFullSwipeArmedChange: (armed: boolean) => void;
182− readonly primaryAction: {
183− readonly accessibilityLabel: string;
184− readonly icon: ComponentProps<typeof SymbolView>["name"];
185− readonly label: string;
186− readonly onPress: () => void;
187− };
425+ readonly primaryAction: ThreadSwipePrimaryAction;
188426 readonly swipeableMethods: SwipeableMethods;
189427 readonly threadTitle: string;
190428 readonly translation: SharedValue<number>;
apps/mobile/src/features/home/usePendingTaskListActions.ts+57−0
@@ -0,0 +1,57 @@
1+import { useNavigation } from "@react-navigation/native";
2+import { useCallback } from "react";
3+import { Alert } from "react-native";
4+
5+import { removeThreadOutboxMessage } from "../../state/thread-outbox";
6+import type { PendingNewTask } from "../../state/use-pending-new-tasks";
7+import { releaseEditingQueuedMessage } from "../../state/use-thread-outbox";
8+
9+export function usePendingTaskListActions(): {
10+ readonly openPendingTask: (pendingTask: PendingNewTask) => void;
11+ readonly confirmDeletePendingTask: (pendingTask: PendingNewTask) => void;
12+} {
13+ const navigation = useNavigation();
14+
15+ const openPendingTask = useCallback(
16+ (pendingTask: PendingNewTask) => {
17+ navigation.navigate("NewTaskSheet", {
18+ screen: "NewTaskDraft",
19+ params: {
20+ environmentId: String(pendingTask.message.environmentId),
21+ projectId: String(pendingTask.creation.projectId),
22+ pendingTaskId: String(pendingTask.message.messageId),
23+ },
24+ });
25+ },
26+ [navigation],
27+ );
28+
29+ const confirmDeletePendingTask = useCallback((pendingTask: PendingNewTask) => {
30+ Alert.alert(
31+ "Delete pending task?",
32+ `“${pendingTask.title}” has not been sent yet and will be removed from the outbox.`,
33+ [
34+ { text: "Cancel", style: "cancel" },
35+ {
36+ text: "Delete",
37+ style: "destructive",
38+ onPress: () => {
39+ // Release the edit lock only after removal succeeds, and only if
40+ // it is held for THIS task — clearing it up front (or for another
41+ // task) would let the drain deliver a mid-edit payload.
42+ void removeThreadOutboxMessage(pendingTask.message)
43+ .then(() => releaseEditingQueuedMessage(pendingTask.message.messageId))
44+ .catch((error) => {
45+ Alert.alert(
46+ "Could not delete pending task",
47+ error instanceof Error ? error.message : "The pending task could not be removed.",
48+ );
49+ });
50+ },
51+ },
52+ ],
53+ );
54+ }, []);
55+
56+ return { openPendingTask, confirmDeletePendingTask };
57+}
apps/mobile/src/features/home/workspace-connection-status.ts+22−0
@@ -0,0 +1,22 @@
1+import type { WorkspaceState } from "../../state/workspaceModel";
2+
3+export function shouldShowWorkspaceConnectionStatus(state: WorkspaceState): boolean {
4+ return (
5+ state.networkStatus === "offline" ||
6+ state.connectionError !== null ||
7+ state.hasConnectingEnvironment ||
8+ (state.hasLoadedShellSnapshot && !state.hasReadyEnvironment)
9+ );
10+}
11+
12+export function workspaceConnectionStatusLabel(state: WorkspaceState): string {
13+ if (state.networkStatus === "offline") return "You are offline";
14+ if (state.connectingEnvironments.length === 1) {
15+ return `Reconnecting to ${state.connectingEnvironments[0]!.environmentLabel}`;
16+ }
17+ if (state.connectingEnvironments.length > 1) {
18+ return `Reconnecting ${state.connectingEnvironments.length} environments`;
19+ }
20+ if (state.connectionError !== null) return state.connectionError;
21+ return "Not connected";
22+}
apps/mobile/src/features/keyboard/HardwareKeyboardCommandProvider.tsx+73−0
@@ -0,0 +1,73 @@
1+import { StackActions, useNavigation } from "@react-navigation/native";
2+import { useCallback, useMemo, useSyncExternalStore, type PropsWithChildren } from "react";
3+
4+import { T3KeyboardCommands } from "../../native/T3KeyboardCommands";
5+import {
6+ dispatchHardwareKeyboardCommand,
7+ getHardwareKeyboardCommandRegistrationVersion,
8+ getRegisteredHardwareKeyboardCommands,
9+ parseActiveThreadPath,
10+ subscribeToHardwareKeyboardCommandRegistrations,
11+ type HardwareKeyboardCommand,
12+} from "./hardwareKeyboardCommands";
13+
14+export function HardwareKeyboardCommandProvider({
15+ children,
16+ pathname,
17+}: PropsWithChildren<{ readonly pathname: string }>) {
18+ const navigation = useNavigation();
19+ const registrationVersion = useSyncExternalStore(
20+ subscribeToHardwareKeyboardCommandRegistrations,
21+ getHardwareKeyboardCommandRegistrationVersion,
22+ getHardwareKeyboardCommandRegistrationVersion,
23+ );
24+ const enabledCommands = useMemo(() => {
25+ const commands = new Set<HardwareKeyboardCommand>(getRegisteredHardwareKeyboardCommands());
26+ commands.add("newTask");
27+ if (pathname !== "/" || navigation.canGoBack()) commands.add("back");
28+ if (parseActiveThreadPath(pathname)) {
29+ commands.add("files");
30+ commands.add("terminal");
31+ commands.add("review");
32+ }
33+ return [...commands];
34+ }, [pathname, registrationVersion, navigation]);
35+
36+ const onCommand = useCallback(
37+ (command: HardwareKeyboardCommand) => {
38+ if (dispatchHardwareKeyboardCommand(command)) return;
39+
40+ if (command === "newTask") {
41+ navigation.navigate("NewTaskSheet", { screen: "NewTask" });
42+ return;
43+ }
44+ if (command === "back") {
45+ if (navigation.canGoBack()) {
46+ navigation.goBack();
47+ } else {
48+ navigation.dispatch(StackActions.replace("Home"));
49+ }
50+ return;
51+ }
52+
53+ const thread = parseActiveThreadPath(pathname);
54+ if (!thread) return;
55+ if (command === "files" && !/\/files(?:\/|$)/.test(pathname)) {
56+ navigation.navigate("ThreadFiles", thread);
57+ }
58+ if (command === "terminal" && !/\/terminal(?:\/|$)/.test(pathname)) {
59+ navigation.navigate("ThreadTerminal", thread);
60+ }
61+ if (command === "review" && !/\/review(?:\/|$)/.test(pathname)) {
62+ navigation.navigate("ThreadReview", thread);
63+ }
64+ },
65+ [pathname, navigation],
66+ );
67+
68+ return (
69+ <T3KeyboardCommands enabledCommands={enabledCommands} onCommand={onCommand}>
70+ {children}
71+ </T3KeyboardCommands>
72+ );
73+}
apps/mobile/src/features/keyboard/hardwareKeyboardCommands.test.ts+28−0
@@ -0,0 +1,28 @@
1+import { describe, expect, it } from "vite-plus/test";
2+
3+import { parseActiveThreadPath } from "./hardwareKeyboardCommands";
4+
5+describe("parseActiveThreadPath", () => {
6+ it("extracts the active thread from thread subroutes", () => {
7+ expect(parseActiveThreadPath("/threads/environment-1/thread-1/files/src/index.ts")).toEqual({
8+ environmentId: "environment-1",
9+ threadId: "thread-1",
10+ });
11+ });
12+
13+ it("decodes route components", () => {
14+ expect(parseActiveThreadPath("/threads/local%20machine/thread%2Fone/review")).toEqual({
15+ environmentId: "local machine",
16+ threadId: "thread/one",
17+ });
18+ });
19+
20+ it("ignores non-thread routes", () => {
21+ expect(parseActiveThreadPath("/settings")).toBeNull();
22+ expect(parseActiveThreadPath("/threads/environment-only")).toBeNull();
23+ });
24+
25+ it("ignores malformed encoded route components", () => {
26+ expect(parseActiveThreadPath("/threads/%E0%A4%A/thread-1")).toBeNull();
27+ });
28+});
apps/mobile/src/features/keyboard/hardwareKeyboardCommands.ts+78−0
@@ -0,0 +1,78 @@
1+import { EnvironmentId, ThreadId } from "@t3tools/contracts";
2+import { useEffect } from "react";
3+
4+export type HardwareKeyboardCommand =
5+ | "newTask"
6+ | "focusSearch"
7+ | "back"
8+ | "files"
9+ | "terminal"
10+ | "review"
11+ | "toggleSidebar";
12+
13+type CommandHandler = () => boolean | void;
14+
15+const handlers = new Map<HardwareKeyboardCommand, Set<CommandHandler>>();
16+const registrationListeners = new Set<() => void>();
17+let registrationVersion = 0;
18+
19+/**
20+ * Registers a context-specific hardware-keyboard action. The most recently mounted handler gets
21+ * the first chance to consume the command, allowing focused screens to override app defaults.
22+ */
23+export function useHardwareKeyboardCommand(
24+ command: HardwareKeyboardCommand,
25+ handler: CommandHandler,
26+): void {
27+ useEffect(() => {
28+ const commandHandlers = handlers.get(command) ?? new Set<CommandHandler>();
29+ commandHandlers.add(handler);
30+ handlers.set(command, commandHandlers);
31+ registrationVersion += 1;
32+ registrationListeners.forEach((listener) => listener());
33+ return () => {
34+ commandHandlers.delete(handler);
35+ if (commandHandlers.size === 0) handlers.delete(command);
36+ registrationVersion += 1;
37+ registrationListeners.forEach((listener) => listener());
38+ };
39+ }, [command, handler]);
40+}
41+
42+export function getRegisteredHardwareKeyboardCommands(): ReadonlySet<HardwareKeyboardCommand> {
43+ return new Set(handlers.keys());
44+}
45+
46+export function getHardwareKeyboardCommandRegistrationVersion(): number {
47+ return registrationVersion;
48+}
49+
50+export function subscribeToHardwareKeyboardCommandRegistrations(listener: () => void): () => void {
51+ registrationListeners.add(listener);
52+ return () => registrationListeners.delete(listener);
53+}
54+
55+export function dispatchHardwareKeyboardCommand(command: HardwareKeyboardCommand): boolean {
56+ const commandHandlers = handlers.get(command);
57+ if (!commandHandlers) return false;
58+ for (const handler of [...commandHandlers].toReversed()) {
59+ if (handler() !== false) return true;
60+ }
61+ return false;
62+}
63+
64+export function parseActiveThreadPath(pathname: string): {
65+ readonly environmentId: EnvironmentId;
66+ readonly threadId: ThreadId;
67+} | null {
68+ const match = /^\/threads\/([^/]+)\/([^/]+)(?:\/|$)/.exec(pathname);
69+ if (!match?.[1] || !match[2]) return null;
70+ try {
71+ return {
72+ environmentId: EnvironmentId.make(decodeURIComponent(match[1])),
73+ threadId: ThreadId.make(decodeURIComponent(match[2])),
74+ };
75+ } catch {
76+ return null;
77+ }
78+}
apps/mobile/src/features/layout/AdaptiveWorkspaceLayout.tsx+507−0
@@ -0,0 +1,507 @@
1+import type { EnvironmentThreadShell } from "@t3tools/client-runtime/state/shell";
2+import { EnvironmentId, ThreadId } from "@t3tools/contracts";
3+import { useFocusEffect } from "@react-navigation/native";
4+import {
5+ NavigationContext,
6+ NavigationRouteContext,
7+ StackActions,
8+ useNavigation,
9+} from "@react-navigation/native";
10+import {
11+ createContext,
12+ use,
13+ useCallback,
14+ useEffect,
15+ useMemo,
16+ useRef,
17+ useState,
18+ type ReactNode,
19+} from "react";
20+import { useWindowDimensions, View } from "react-native";
21+import Animated, { useAnimatedStyle, useSharedValue, withTiming } from "react-native-reanimated";
22+
23+import {
24+ deriveFileInspectorPaneLayout,
25+ deriveLayout,
26+ deriveWorkspacePaneLayout,
27+ type FileInspectorPaneLayout,
28+ type Layout,
29+ type WorkspaceAuxiliaryPaneRole,
30+ type WorkspacePaneLayout,
31+} from "../../lib/layout";
32+import { resolveThreadSelectionNavigationAction } from "../../lib/adaptive-navigation";
33+import { scopedThreadKey } from "../../lib/scopedEntities";
34+import {
35+ parseActiveThreadPath,
36+ useHardwareKeyboardCommand,
37+} from "../keyboard/hardwareKeyboardCommands";
38+import { HomeListOptionsProvider } from "../home/home-list-options";
39+import { ThreadNavigationSidebar } from "../threads/ThreadNavigationSidebar";
40+import { WORKSPACE_PANE_TIMING } from "./workspace-pane-animation";
41+import { WorkspaceInspectorPane } from "./workspace-inspector-pane";
42+
43+interface AdaptiveWorkspaceContextValue {
44+ readonly layout: Layout;
45+ readonly panes: WorkspacePaneLayout;
46+ readonly fileInspector: FileInspectorPaneLayout;
47+ readonly primarySidebarSearchQuery: string;
48+ readonly activateAuxiliaryPaneRole: (role: WorkspaceAuxiliaryPaneRole) => () => void;
49+ /**
50+ * Route screens hand their inspector pane content to the workspace so it
51+ * renders BESIDE the navigator (outside the native stack header) instead of
52+ * inside the route. Returns a deactivate callback: the pane animates closed
53+ * (content kept mounted for the exit transition) unless a newer
54+ * registration already took over — stale deactivates never clobber it.
55+ * Prefer useRegisterWorkspaceInspector over calling this directly.
56+ */
57+ readonly registerWorkspaceInspector: (render: () => ReactNode) => () => void;
58+ readonly setPrimarySidebarSearchQuery: (query: string) => void;
59+ readonly showAuxiliaryPane: (role: WorkspaceAuxiliaryPaneRole) => void;
60+ readonly toggleAuxiliaryPane: () => void;
61+ readonly togglePrimarySidebar: () => void;
62+ readonly setAuxiliaryPaneWidth: (width: number) => void;
63+}
64+
65+const compactLayout = deriveLayout({ width: 0, height: 0 });
66+const compactPanes = deriveWorkspacePaneLayout({
67+ layout: compactLayout,
68+ viewportWidth: 0,
69+ primarySidebarPreferredVisible: true,
70+ auxiliaryPanePreferredVisible: true,
71+});
72+const compactFileInspector = deriveFileInspectorPaneLayout({
73+ layout: compactLayout,
74+ viewportWidth: 0,
75+});
76+const AdaptiveWorkspaceContext = createContext<AdaptiveWorkspaceContextValue>({
77+ layout: compactLayout,
78+ panes: compactPanes,
79+ fileInspector: compactFileInspector,
80+ primarySidebarSearchQuery: "",
81+ activateAuxiliaryPaneRole: () => () => undefined,
82+ registerWorkspaceInspector: () => () => undefined,
83+ setPrimarySidebarSearchQuery: () => undefined,
84+ showAuxiliaryPane: () => undefined,
85+ toggleAuxiliaryPane: () => undefined,
86+ togglePrimarySidebar: () => undefined,
87+ setAuxiliaryPaneWidth: () => undefined,
88+});
89+
90+export function useAdaptiveWorkspaceLayout(): AdaptiveWorkspaceContextValue {
91+ return use(AdaptiveWorkspaceContext);
92+}
93+
94+export function useAdaptiveWorkspacePaneRole(role: WorkspaceAuxiliaryPaneRole) {
95+ const { activateAuxiliaryPaneRole } = useAdaptiveWorkspaceLayout();
96+
97+ useFocusEffect(
98+ useCallback(() => activateAuxiliaryPaneRole(role), [activateAuxiliaryPaneRole, role]),
99+ );
100+}
101+
102+/**
103+ * Register this screen's inspector pane content with the workspace column.
104+ *
105+ * The column renders BESIDE the navigator — outside any screen — so the
106+ * registering screen's navigation and route contexts are captured here and
107+ * re-provided around the portal content. Without them, useNavigation/useRoute
108+ * inside the pane (e.g. GitOverviewSheet via useThreadSelection) throw
109+ * "Couldn't find a route object".
110+ *
111+ * Registration is FOCUS-scoped, driven by navigation events rather than the
112+ * screen's own render cycle: react-native-screens freezes blurred screens, so
113+ * a cleanup that depends on the blurred subtree re-rendering never runs and
114+ * would leak the pane into the next route. Blur deactivates the pane (it
115+ * animates closed, or is replaced seamlessly when the next route registers in
116+ * the same commit); focus re-registers it.
117+ */
118+export function useRegisterWorkspaceInspector(render: (() => ReactNode) | undefined) {
119+ const { registerWorkspaceInspector } = useAdaptiveWorkspaceLayout();
120+ // Raw context values (not the useNavigation/useRoute wrappers) so the
121+ // portal re-provides exactly what this screen sees.
122+ const navigation = use(NavigationContext);
123+ const route = use(NavigationRouteContext);
124+
125+ const wrappedRender = useMemo(() => {
126+ if (render === undefined) {
127+ return undefined;
128+ }
129+ return () => (
130+ <NavigationContext.Provider value={navigation}>
131+ <NavigationRouteContext.Provider value={route}>{render()}</NavigationRouteContext.Provider>
132+ </NavigationContext.Provider>
133+ );
134+ }, [navigation, render, route]);
135+
136+ const wrappedRenderRef = useRef(wrappedRender);
137+ wrappedRenderRef.current = wrappedRender;
138+ const focusedRef = useRef(false);
139+ const deactivateRef = useRef<(() => void) | null>(null);
140+
141+ const syncRegistration = useCallback(() => {
142+ if (!focusedRef.current || wrappedRenderRef.current === undefined) {
143+ deactivateRef.current?.();
144+ return;
145+ }
146+ deactivateRef.current = registerWorkspaceInspector(wrappedRenderRef.current);
147+ }, [registerWorkspaceInspector]);
148+
149+ // Focus lifecycle. Blur/focus events fire even when the blurred subtree is
150+ // frozen (events are navigation-driven, renders are not).
151+ useFocusEffect(
152+ useCallback(() => {
153+ focusedRef.current = true;
154+ syncRegistration();
155+ return () => {
156+ focusedRef.current = false;
157+ syncRegistration();
158+ };
159+ }, [syncRegistration]),
160+ );
161+
162+ // Content changes while focused re-register in place.
163+ useEffect(() => {
164+ if (focusedRef.current) {
165+ syncRegistration();
166+ }
167+ }, [syncRegistration, wrappedRender]);
168+
169+ // Unmount: hand the pane back (owner-guarded, so a route that already
170+ // took over is unaffected).
171+ useEffect(
172+ () => () => {
173+ deactivateRef.current?.();
174+ deactivateRef.current = null;
175+ },
176+ [],
177+ );
178+}
179+
180+export function AdaptiveWorkspaceLayout(props: {
181+ readonly children: ReactNode;
182+ readonly pathname: string;
183+}) {
184+ const { width, height } = useWindowDimensions();
185+ const pathname = props.pathname;
186+ const navigation = useNavigation();
187+ const activeRoleOwner = useRef<symbol | null>(null);
188+ const [primarySidebarPreferredVisible, setPrimarySidebarPreferredVisible] = useState(true);
189+ const [supplementaryPanePreferredVisible, setSupplementaryPanePreferredVisible] = useState(true);
190+ const [supplementaryPanePreferredWidth, setSupplementaryPanePreferredWidth] = useState<
191+ number | null
192+ >(null);
193+ const [fileInspectorPreferredVisible, setFileInspectorPreferredVisible] = useState(true);
194+ const [fileInspectorPreferredWidth, setFileInspectorPreferredWidth] = useState<number | null>(
195+ null,
196+ );
197+ const [primarySidebarSearchQuery, setPrimarySidebarSearchQuery] = useState("");
198+ const [focusedAuxiliaryPaneRole, setFocusedAuxiliaryPaneRole] =
199+ useState<WorkspaceAuxiliaryPaneRole | null>(null);
200+ const baseLayout = useMemo(() => deriveLayout({ width, height }), [height, width]);
201+ const layout = baseLayout;
202+ // In split layouts the sidebar IS the thread list — it renders on every
203+ // route, including Home (which shows an empty-detail pane instead of the
204+ // compact list).
205+ const shouldRenderPrimarySidebar = layout.usesSplitView;
206+ const fileInspector = useMemo(
207+ () =>
208+ deriveFileInspectorPaneLayout({
209+ layout,
210+ viewportWidth: width,
211+ preferredWidth: fileInspectorPreferredWidth ?? undefined,
212+ reservedLeadingWidth:
213+ shouldRenderPrimarySidebar && primarySidebarPreferredVisible
214+ ? (layout.listPaneWidth ?? 0)
215+ : 0,
216+ }),
217+ [
218+ fileInspectorPreferredWidth,
219+ layout,
220+ primarySidebarPreferredVisible,
221+ shouldRenderPrimarySidebar,
222+ width,
223+ ],
224+ );
225+ const auxiliaryPaneRole: WorkspaceAuxiliaryPaneRole =
226+ focusedAuxiliaryPaneRole ?? (/\/files(?:\/|$)/.test(pathname) ? "inspector" : "supplementary");
227+ const auxiliaryPanePreferredVisible =
228+ auxiliaryPaneRole === "inspector"
229+ ? fileInspectorPreferredVisible
230+ : supplementaryPanePreferredVisible;
231+ const auxiliaryPanePreferredWidth =
232+ auxiliaryPaneRole === "inspector"
233+ ? fileInspectorPreferredWidth
234+ : supplementaryPanePreferredWidth;
235+ const panes = useMemo(
236+ () =>
237+ deriveWorkspacePaneLayout({
238+ layout,
239+ viewportWidth: width,
240+ primarySidebarPreferredVisible,
241+ auxiliaryPanePreferredVisible,
242+ auxiliaryPaneRole,
243+ auxiliaryPanePreferredWidth: auxiliaryPanePreferredWidth ?? undefined,
244+ }),
245+ [
246+ auxiliaryPanePreferredVisible,
247+ auxiliaryPaneRole,
248+ auxiliaryPanePreferredWidth,
249+ layout,
250+ primarySidebarPreferredVisible,
251+ width,
252+ ],
253+ );
254+ const activeThread = parseActiveThreadPath(pathname);
255+ const environmentId = activeThread?.environmentId ?? null;
256+ const threadId = activeThread?.threadId ?? null;
257+ const selectedThreadKey = useMemo(() => {
258+ if (environmentId === null || threadId === null) {
259+ return null;
260+ }
261+ try {
262+ return scopedThreadKey(EnvironmentId.make(environmentId), ThreadId.make(threadId));
263+ } catch {
264+ return null;
265+ }
266+ }, [environmentId, threadId]);
267+ // Wrapped in an object: bare functions in useState would be treated as
268+ // lazy initializers/updaters. `active: false` keeps the outgoing route's
269+ // content mounted so the pane can animate closed (or be replaced
270+ // seamlessly by the next route's registration in the same commit).
271+ const [workspaceInspector, setWorkspaceInspector] = useState<{
272+ readonly render: () => ReactNode;
273+ readonly active: boolean;
274+ } | null>(null);
275+ const workspaceInspectorOwner = useRef<symbol | null>(null);
276+ const registerWorkspaceInspector = useCallback((render: () => ReactNode) => {
277+ const owner = Symbol("workspace-inspector");
278+ workspaceInspectorOwner.current = owner;
279+ setWorkspaceInspector({ render, active: true });
280+
281+ return () => {
282+ // During a push/replace the outgoing screen deactivates AFTER the
283+ // incoming screen registered — only the current owner may deactivate.
284+ if (workspaceInspectorOwner.current !== owner) {
285+ return;
286+ }
287+ setWorkspaceInspector((current) => (current === null ? null : { ...current, active: false }));
288+ };
289+ }, []);
290+ // Once the close animation settles, drop the stale content entirely.
291+ const handleWorkspaceInspectorClosed = useCallback(() => {
292+ setWorkspaceInspector((current) => (current !== null && !current.active ? null : current));
293+ }, []);
294+ const activateAuxiliaryPaneRole = useCallback((role: WorkspaceAuxiliaryPaneRole) => {
295+ const owner = Symbol(role);
296+ activeRoleOwner.current = owner;
297+ setFocusedAuxiliaryPaneRole(role);
298+
299+ return () => {
300+ if (activeRoleOwner.current !== owner) {
301+ return;
302+ }
303+ activeRoleOwner.current = null;
304+ setFocusedAuxiliaryPaneRole(null);
305+ };
306+ }, []);
307+ const togglePrimarySidebar = useCallback(() => {
308+ if (!panes.primarySidebarVisible && panes.primarySidebarSuppressedByAuxiliary) {
309+ setFileInspectorPreferredVisible(false);
310+ setPrimarySidebarPreferredVisible(true);
311+ return;
312+ }
313+ setPrimarySidebarPreferredVisible((current) => !current);
314+ }, [panes.primarySidebarSuppressedByAuxiliary, panes.primarySidebarVisible]);
315+ const revealPrimarySidebar = useCallback(() => {
316+ if (panes.primarySidebarSuppressedByAuxiliary) {
317+ setFileInspectorPreferredVisible(false);
318+ }
319+ setPrimarySidebarPreferredVisible(true);
320+ }, [panes.primarySidebarSuppressedByAuxiliary]);
321+ const handleToggleSidebarCommand = useCallback(() => {
322+ togglePrimarySidebar();
323+ return true;
324+ }, [togglePrimarySidebar]);
325+ useHardwareKeyboardCommand("toggleSidebar", handleToggleSidebarCommand);
326+ const showAuxiliaryPane = useCallback((role: WorkspaceAuxiliaryPaneRole) => {
327+ if (role === "inspector") {
328+ setFocusedAuxiliaryPaneRole("inspector");
329+ setFileInspectorPreferredVisible(true);
330+ return;
331+ }
332+ setFocusedAuxiliaryPaneRole("supplementary");
333+ setSupplementaryPanePreferredVisible(true);
334+ }, []);
335+ const handleOpenFilesCommand = useCallback(() => {
336+ const activeThread = parseActiveThreadPath(pathname);
337+ if (!layout.usesSplitView || !fileInspector.supported || activeThread === null) {
338+ return false;
339+ }
340+ showAuxiliaryPane("inspector");
341+ if (/\/files(?:\/|$)/.test(pathname)) {
342+ return true;
343+ }
344+ navigation.navigate("ThreadFiles", activeThread);
345+ return true;
346+ }, [fileInspector.supported, layout.usesSplitView, pathname, navigation, showAuxiliaryPane]);
347+ useHardwareKeyboardCommand("files", handleOpenFilesCommand);
348+ const toggleAuxiliaryPane = useCallback(() => {
349+ if (auxiliaryPaneRole === "inspector") {
350+ setFileInspectorPreferredVisible((current) => !current);
351+ return;
352+ }
353+ setSupplementaryPanePreferredVisible((current) => !current);
354+ }, [auxiliaryPaneRole]);
355+ const setAuxiliaryPaneWidth = useCallback(
356+ (nextWidth: number) => {
357+ if (auxiliaryPaneRole === "inspector") {
358+ setFileInspectorPreferredWidth(nextWidth);
359+ return;
360+ }
361+ setSupplementaryPanePreferredWidth(nextWidth);
362+ },
363+ [auxiliaryPaneRole],
364+ );
365+ const contextValue = useMemo(
366+ () => ({
367+ layout,
368+ panes,
369+ fileInspector,
370+ primarySidebarSearchQuery,
371+ activateAuxiliaryPaneRole,
372+ registerWorkspaceInspector,
373+ setPrimarySidebarSearchQuery,
374+ showAuxiliaryPane,
375+ toggleAuxiliaryPane,
376+ togglePrimarySidebar,
377+ setAuxiliaryPaneWidth,
378+ }),
379+ [
380+ activateAuxiliaryPaneRole,
381+ fileInspector,
382+ layout,
383+ panes,
384+ primarySidebarSearchQuery,
385+ registerWorkspaceInspector,
386+ showAuxiliaryPane,
387+ setPrimarySidebarSearchQuery,
388+ setAuxiliaryPaneWidth,
389+ toggleAuxiliaryPane,
390+ togglePrimarySidebar,
391+ ],
392+ );
393+
394+ const handleOpenSettings = useCallback(() => {
395+ navigation.navigate("SettingsSheet", { screen: "Settings" });
396+ }, [navigation]);
397+
398+ // Minted here (root stack navigation) so the sidebar pane stays free of
399+ // navigation hooks — on iOS it renders inside an independent nav tree.
400+ const handleOpenEnvironmentSettings = useCallback(() => {
401+ navigation.navigate("SettingsSheet", { screen: "SettingsEnvironments" });
402+ }, [navigation]);
403+
404+ const renderedSidebarWidth = useSharedValue(
405+ panes.primarySidebarVisible ? (layout.listPaneWidth ?? 0) : 0,
406+ );
407+ useEffect(() => {
408+ const targetWidth = panes.primarySidebarVisible ? (layout.listPaneWidth ?? 0) : 0;
409+ renderedSidebarWidth.value = withTiming(targetWidth, WORKSPACE_PANE_TIMING);
410+ }, [layout.listPaneWidth, panes.primarySidebarVisible, renderedSidebarWidth]);
411+ const sidebarAnimatedStyle = useAnimatedStyle(() => ({
412+ opacity: Math.min(1, renderedSidebarWidth.value / 80),
413+ width: renderedSidebarWidth.value,
414+ }));
415+
416+ // Freeze the content pane at its SETTLED width while the side panes
417+ // animate. The navigator (native header + markdown feed) lays out ONCE per
418+ // pane toggle instead of re-measuring on every animation frame — the
419+ // animating columns merely clip/reveal it over a matching background.
420+ // Continuously re-wrapping the chat feed was the main source of dropped
421+ // frames during sidebar/inspector transitions.
422+ const inspectorColumnTargetWidth =
423+ workspaceInspector !== null && workspaceInspector.active && panes.auxiliaryPaneVisible
424+ ? (panes.auxiliaryPaneWidth ?? 0)
425+ : 0;
426+ const contentSettledWidth = layout.usesSplitView
427+ ? Math.max(0, panes.contentPaneWidth - inspectorColumnTargetWidth)
428+ : null;
429+
430+ const handleSelectThread = useCallback(
431+ (thread: EnvironmentThreadShell) => {
432+ const params = {
433+ environmentId: String(thread.environmentId),
434+ threadId: String(thread.id),
435+ };
436+ const navigationAction = resolveThreadSelectionNavigationAction({
437+ usesSplitView: layout.usesSplitView,
438+ pathname,
439+ });
440+ if (navigationAction === "set-params") {
441+ const nextThreadKey = scopedThreadKey(thread.environmentId, thread.id);
442+ if (nextThreadKey === selectedThreadKey) {
443+ return;
444+ }
445+ setFileInspectorPreferredVisible(false);
446+ navigation.navigate("Thread", params);
447+ return;
448+ }
449+ if (navigationAction === "replace") {
450+ setFileInspectorPreferredVisible(false);
451+ navigation.dispatch(StackActions.replace("Thread", params));
452+ return;
453+ }
454+ navigation.navigate("Thread", params);
455+ },
456+ [layout.usesSplitView, pathname, navigation, selectedThreadKey],
457+ );
458+
459+ return (
460+ <HomeListOptionsProvider>
461+ <AdaptiveWorkspaceContext.Provider value={contextValue}>
462+ <View testID="adaptive-workspace-layout" style={{ flex: 1, flexDirection: "row" }}>
463+ {shouldRenderPrimarySidebar && layout.listPaneWidth !== null ? (
464+ <Animated.View
465+ accessibilityElementsHidden={!panes.primarySidebarVisible}
466+ collapsable={false}
467+ importantForAccessibility={
468+ panes.primarySidebarVisible ? "auto" : "no-hide-descendants"
469+ }
470+ pointerEvents={panes.primarySidebarVisible ? "auto" : "none"}
471+ style={[{ alignSelf: "stretch", overflow: "hidden" }, sidebarAnimatedStyle]}
472+ >
473+ <ThreadNavigationSidebar
474+ width={layout.listPaneWidth}
475+ visible={panes.primarySidebarVisible}
476+ onRequestVisibility={revealPrimarySidebar}
477+ selectedThreadKey={selectedThreadKey}
478+ onOpenSettings={handleOpenSettings}
479+ onOpenEnvironmentSettings={handleOpenEnvironmentSettings}
480+ onSelectThread={handleSelectThread}
481+ onSearchQueryChange={setPrimarySidebarSearchQuery}
482+ searchQuery={primarySidebarSearchQuery}
483+ />
484+ </Animated.View>
485+ ) : null}
486+ <View className="bg-screen" collapsable={false} style={{ flex: 1, overflow: "hidden" }}>
487+ <View
488+ collapsable={false}
489+ style={
490+ contentSettledWidth !== null ? { flex: 1, width: contentSettledWidth } : { flex: 1 }
491+ }
492+ >
493+ {props.children}
494+ </View>
495+ </View>
496+ <WorkspaceInspectorPane
497+ active={workspaceInspector?.active ?? false}
498+ panes={panes}
499+ renderInspector={workspaceInspector?.render}
500+ setAuxiliaryPaneWidth={setAuxiliaryPaneWidth}
501+ onClosed={handleWorkspaceInspectorClosed}
502+ />
503+ </View>
504+ </AdaptiveWorkspaceContext.Provider>
505+ </HomeListOptionsProvider>
506+ );
507+}
apps/mobile/src/features/layout/WorkspaceEmptyDetail.tsx+30−0
@@ -0,0 +1,30 @@
1+import { SymbolView } from "expo-symbols";
2+import { Pressable, View } from "react-native";
3+
4+import { AppText as Text } from "../../components/AppText";
5+import { useThemeColor } from "../../lib/useThemeColor";
6+
7+export function WorkspaceEmptyDetail(props: { readonly onStartNewTask?: () => void }) {
8+ const iconColor = useThemeColor("--color-icon-subtle");
9+
10+ return (
11+ <View className="flex-1 items-center justify-center bg-screen px-10">
12+ <View className="max-w-[360px] items-center gap-3">
13+ <SymbolView name="sidebar.left" size={34} tintColor={iconColor} type="hierarchical" />
14+ <Text className="text-center text-xl font-t3-bold">Select a thread</Text>
15+ <Text className="text-center text-base text-foreground-muted">
16+ Choose a thread from the sidebar or start a new task.
17+ </Text>
18+ {props.onStartNewTask ? (
19+ <Pressable
20+ accessibilityRole="button"
21+ className="mt-2 flex-row items-center gap-2 rounded-full bg-primary px-5 py-3 active:opacity-70"
22+ onPress={props.onStartNewTask}
23+ >
24+ <Text className="text-base font-t3-bold text-primary-foreground">New Task</Text>
25+ </Pressable>
26+ ) : null}
27+ </View>
28+ </View>
29+ );
30+}
apps/mobile/src/features/layout/native-glass-header-items.ts+33−0
@@ -0,0 +1,33 @@
1+type NativeGlassHeaderItem = {
2+ readonly type: "button" | "menu";
3+ readonly glassEffect?: boolean;
4+ readonly hidesSharedBackground?: boolean;
5+ readonly sharesBackground?: boolean;
6+ readonly variant?: "plain" | "done" | "prominent";
7+ readonly width?: number;
8+};
9+
10+/**
11+ * iOS 26/27 Mail-style header controls need the native glass button
12+ * shared background configuration when they are not part of a larger toolbar.
13+ * Do not enable `glassEffect` for normal bar-button items: react-native-screens
14+ * renders that as a custom UIButton, which creates a second skinny capsule.
15+ */
16+export function withNativeGlassHeaderItem<T extends NativeGlassHeaderItem>(
17+ item: T,
18+ options: {
19+ readonly hidesSharedBackground?: boolean;
20+ readonly sharesBackground?: boolean;
21+ readonly width?: number;
22+ } = {},
23+): T {
24+ const sharesBackground = options.sharesBackground ?? item.sharesBackground ?? true;
25+ return {
26+ ...item,
27+ glassEffect: item.glassEffect ?? false,
28+ hidesSharedBackground: options.hidesSharedBackground ?? item.hidesSharedBackground ?? false,
29+ sharesBackground,
30+ variant: item.variant ?? "plain",
31+ width: options.width ?? item.width,
32+ } as T;
33+}
apps/mobile/src/features/layout/native-mail-search-toolbar.ts+25−0
@@ -0,0 +1,25 @@
1+import type { HeaderBarButtonMailSearchToolbarItem } from "react-native-screens";
2+
3+type NativeMailSearchToolbarInput = Omit<
4+ HeaderBarButtonMailSearchToolbarItem,
5+ "type" | "useFallbackSearchField"
6+>;
7+
8+/**
9+ * Builds the patched react-native-screens Mail-style bottom search toolbar.
10+ *
11+ * Keeping this behind an app-level helper makes the iOS-only RNS patch an
12+ * explicit layout primitive instead of a per-screen object literal. Android can
13+ * keep using platform-specific header/search primitives without depending on
14+ * this helper.
15+ */
16+export function createNativeMailSearchToolbarItem(
17+ input: NativeMailSearchToolbarInput,
18+): HeaderBarButtonMailSearchToolbarItem {
19+ return {
20+ placeholder: "Search",
21+ ...input,
22+ type: "mailSearchToolbar",
23+ useFallbackSearchField: true,
24+ };
25+}
apps/mobile/src/features/layout/workspace-inspector-pane.tsx+145−0
@@ -0,0 +1,145 @@
1+import { useCallback, useEffect, useRef, useState, type ReactNode } from "react";
2+import Animated, {
3+ runOnJS,
4+ useAnimatedStyle,
5+ useSharedValue,
6+ withTiming,
7+} from "react-native-reanimated";
8+
9+import { constrainAuxiliaryPaneWidth, type WorkspacePaneLayout } from "../../lib/layout";
10+import { WORKSPACE_PANE_TIMING } from "./workspace-pane-animation";
11+import { WorkspacePaneDivider } from "./workspace-pane-divider";
12+
13+/**
14+ * The trailing inspector column: resize divider + animated reveal.
15+ *
16+ * Rendered by AdaptiveWorkspaceLayout as a SIBLING of the navigator so the
17+ * native stack header (and its trailing toolbar items) spans only the content
18+ * pane — the inspector owns its own full-height column, mirroring how each
19+ * column of a UISplitViewController has its own chrome.
20+ *
21+ * Receives the pane layout via props (not the workspace context hook) so this
22+ * module stays import-cycle-free with AdaptiveWorkspaceLayout.
23+ */
24+export function WorkspaceInspectorPane(props: {
25+ /**
26+ * When false the pane animates closed but keeps its content mounted for the
27+ * exit transition (a route that lost focus). `onClosed` fires once the
28+ * close animation settles so the owner can drop the stale content.
29+ */
30+ readonly active?: boolean;
31+ readonly onClosed?: () => void;
32+ readonly panes: WorkspacePaneLayout;
33+ readonly renderInspector?: () => ReactNode;
34+ readonly setAuxiliaryPaneWidth: (width: number) => void;
35+}) {
36+ const { panes, setAuxiliaryPaneWidth } = props;
37+ const inspectorWidth = panes.auxiliaryPaneWidth;
38+ const inspectorSupported = props.renderInspector !== undefined && inspectorWidth !== null;
39+ const inspectorVisible =
40+ inspectorSupported && panes.auxiliaryPaneVisible && (props.active ?? true);
41+ const resizeStartWidth = useRef(0);
42+ const [resizing, setResizing] = useState(false);
43+
44+ // A file-to-file replace remounts the route. Initialize an already-visible
45+ // inspector at its final position so route replacement never replays an
46+ // entering transition. Only visibility and explicit resizing change it.
47+ const inspectorProgress = useSharedValue(inspectorVisible ? 1 : 0);
48+ const renderedInspectorWidth = useSharedValue(inspectorVisible ? (inspectorWidth ?? 0) : 0);
49+ // The content keeps its own width so the reveal (outer width) clips a
50+ // fully-laid-out pane instead of reflowing text every frame. When the OPEN
51+ // pane's target width changes (e.g. the sidebar toggles and reserves
52+ // space), animate the content width in lockstep rather than snapping.
53+ const renderedContentWidth = useSharedValue(inspectorWidth ?? 0);
54+
55+ const onClosed = props.onClosed;
56+ useEffect(() => {
57+ inspectorProgress.value = withTiming(
58+ inspectorVisible ? 1 : 0,
59+ WORKSPACE_PANE_TIMING,
60+ (finished) => {
61+ if (finished === true && !inspectorVisible && onClosed !== undefined) {
62+ runOnJS(onClosed)();
63+ }
64+ },
65+ );
66+ const targetWidth = inspectorVisible ? (inspectorWidth ?? 0) : 0;
67+ renderedInspectorWidth.value = resizing
68+ ? targetWidth
69+ : withTiming(targetWidth, WORKSPACE_PANE_TIMING);
70+ }, [
71+ inspectorProgress,
72+ inspectorVisible,
73+ inspectorWidth,
74+ onClosed,
75+ renderedInspectorWidth,
76+ resizing,
77+ ]);
78+
79+ useEffect(() => {
80+ const targetWidth = inspectorWidth ?? 0;
81+ if (!inspectorVisible || resizing) {
82+ // Hidden panes re-measure silently; during a divider drag the content
83+ // tracks the finger directly.
84+ renderedContentWidth.value = targetWidth;
85+ return;
86+ }
87+ renderedContentWidth.value = withTiming(targetWidth, WORKSPACE_PANE_TIMING);
88+ }, [inspectorVisible, inspectorWidth, renderedContentWidth, resizing]);
89+
90+ const inspectorStyle = useAnimatedStyle(
91+ () => ({
92+ opacity: inspectorProgress.value,
93+ transform: [{ translateX: (1 - inspectorProgress.value) * 24 }],
94+ width: renderedInspectorWidth.value,
95+ }),
96+ [],
97+ );
98+ const inspectorContentStyle = useAnimatedStyle(() => ({ width: renderedContentWidth.value }), []);
99+ const beginResize = useCallback(() => {
100+ resizeStartWidth.current = inspectorWidth ?? 0;
101+ setResizing(true);
102+ }, [inspectorWidth]);
103+ const resizeBy = useCallback(
104+ (delta: number) => {
105+ setAuxiliaryPaneWidth(
106+ constrainAuxiliaryPaneWidth({
107+ preferredWidth: resizeStartWidth.current + delta,
108+ availableWidth: panes.contentPaneWidth,
109+ }),
110+ );
111+ },
112+ [panes.contentPaneWidth, setAuxiliaryPaneWidth],
113+ );
114+ const endResize = useCallback(() => {
115+ setResizing(false);
116+ }, []);
117+
118+ return (
119+ <>
120+ {inspectorVisible ? (
121+ <WorkspacePaneDivider
122+ accessibilityLabel="Resize detail pane"
123+ currentWidth={inspectorWidth ?? 0}
124+ resizeDirection={-1}
125+ onResizeStart={beginResize}
126+ onResizeBy={resizeBy}
127+ onResizeEnd={endResize}
128+ />
129+ ) : null}
130+ {inspectorSupported ? (
131+ <Animated.View
132+ accessibilityElementsHidden={!inspectorVisible}
133+ collapsable={false}
134+ importantForAccessibility={inspectorVisible ? "auto" : "no-hide-descendants"}
135+ pointerEvents={inspectorVisible ? "auto" : "none"}
136+ style={[{ flexShrink: 0, overflow: "hidden" }, inspectorStyle]}
137+ >
138+ <Animated.View style={[{ flex: 1 }, inspectorContentStyle]}>
139+ {props.renderInspector?.()}
140+ </Animated.View>
141+ </Animated.View>
142+ ) : null}
143+ </>
144+ );
145+}
apps/mobile/src/features/layout/workspace-pane-animation.ts+16−0
@@ -0,0 +1,16 @@
1+import { Easing, ReduceMotion } from "react-native-reanimated";
2+
3+/**
4+ * One timing curve for every workspace pane (primary sidebar + inspector).
5+ *
6+ * Panes frequently animate together — opening the sidebar can auto-close the
7+ * inspector when both no longer fit — so identical duration and easing on
8+ * every pane keeps the center content pane from wobbling while both edges
9+ * move. Asymmetric open/close timings (the previous 220ms out-cubic open vs
10+ * 160ms in-cubic close) read as jank during those simultaneous swaps.
11+ */
12+export const WORKSPACE_PANE_TIMING = {
13+ duration: 260,
14+ easing: Easing.inOut(Easing.cubic),
15+ reduceMotion: ReduceMotion.System,
16+} as const;
apps/mobile/src/features/layout/workspace-pane-divider.tsx+116−0
@@ -0,0 +1,116 @@
1+import { useCallback, useMemo, useRef, useState } from "react";
2+import {
3+ Platform,
4+ PlatformColor,
5+ Pressable,
6+ StyleSheet,
7+ View,
8+ type AccessibilityActionEvent,
9+} from "react-native";
10+import { Gesture, GestureDetector } from "react-native-gesture-handler";
11+import { runOnJS } from "react-native-reanimated";
12+
13+const ACCESSIBILITY_RESIZE_STEP = 24;
14+
15+interface WorkspacePaneDividerProps {
16+ readonly accessibilityLabel: string;
17+ readonly currentWidth: number;
18+ /** 1 when dragging right grows the pane, -1 when dragging left grows it. */
19+ readonly resizeDirection: 1 | -1;
20+ readonly onResizeStart?: () => void;
21+ readonly onResizeBy: (delta: number) => void;
22+ readonly onResizeEnd?: () => void;
23+}
24+
25+/** A forgiving divider target for touch, pointer, and VoiceOver users. */
26+export function WorkspacePaneDivider(props: WorkspacePaneDividerProps) {
27+ const latestProps = useRef(props);
28+ latestProps.current = props;
29+ const [hovered, setHovered] = useState(false);
30+ const [dragging, setDragging] = useState(false);
31+ const handleResizeStart = useCallback(() => {
32+ setDragging(true);
33+ latestProps.current.onResizeStart?.();
34+ }, []);
35+ const handleResize = useCallback((translationX: number) => {
36+ latestProps.current.onResizeBy(translationX * latestProps.current.resizeDirection);
37+ }, []);
38+ const handleResizeEnd = useCallback(() => {
39+ setDragging(false);
40+ latestProps.current.onResizeEnd?.();
41+ }, []);
42+ const resizeGesture = useMemo(
43+ () =>
44+ Gesture.Pan()
45+ .activeOffsetX([-4, 4])
46+ .failOffsetY([-24, 24])
47+ .onStart(() => {
48+ runOnJS(handleResizeStart)();
49+ })
50+ .onUpdate((event) => {
51+ runOnJS(handleResize)(event.translationX);
52+ })
53+ .onFinalize(() => {
54+ runOnJS(handleResizeEnd)();
55+ }),
56+ [handleResize, handleResizeEnd, handleResizeStart],
57+ );
58+
59+ const handleAccessibilityAction = (event: AccessibilityActionEvent) => {
60+ props.onResizeStart?.();
61+ if (event.nativeEvent.actionName === "increment") {
62+ props.onResizeBy(ACCESSIBILITY_RESIZE_STEP);
63+ } else if (event.nativeEvent.actionName === "decrement") {
64+ props.onResizeBy(-ACCESSIBILITY_RESIZE_STEP);
65+ }
66+ props.onResizeEnd?.();
67+ };
68+
69+ return (
70+ <GestureDetector gesture={resizeGesture}>
71+ <Pressable
72+ accessibilityActions={[
73+ { name: "increment", label: "Make pane wider" },
74+ { name: "decrement", label: "Make pane narrower" },
75+ ]}
76+ accessibilityLabel={props.accessibilityLabel}
77+ accessibilityRole="adjustable"
78+ accessibilityValue={{
79+ now: Math.round(props.currentWidth),
80+ text: `${Math.round(props.currentWidth)} points wide`,
81+ }}
82+ onAccessibilityAction={handleAccessibilityAction}
83+ onHoverIn={() => setHovered(true)}
84+ onHoverOut={() => setHovered(false)}
85+ style={styles.hitTarget}
86+ >
87+ <View style={[styles.line, (hovered || dragging) && styles.activeLine]} />
88+ </Pressable>
89+ </GestureDetector>
90+ );
91+}
92+
93+const styles = StyleSheet.create({
94+ hitTarget: {
95+ alignSelf: "stretch",
96+ cursor: "pointer",
97+ justifyContent: "center",
98+ marginHorizontal: -22,
99+ position: "relative",
100+ width: 44,
101+ zIndex: 100,
102+ },
103+ line: {
104+ alignSelf: "center",
105+ backgroundColor:
106+ Platform.OS === "ios" ? PlatformColor("separator") : "rgba(120, 120, 128, 0.28)",
107+ height: "100%",
108+ opacity: 0.7,
109+ width: StyleSheet.hairlineWidth,
110+ },
111+ activeLine: {
112+ backgroundColor: Platform.OS === "ios" ? PlatformColor("systemBlueColor") : "#0a84ff",
113+ opacity: 1,
114+ width: 2,
115+ },
116+});
apps/mobile/src/features/layout/workspace-sidebar-toolbar.tsx+31−0
@@ -0,0 +1,31 @@
1+import { NativeHeaderToolbar } from "../../native/StackHeader";
2+import type { ReactNode } from "react";
3+
4+import { useAdaptiveWorkspaceLayout } from "./AdaptiveWorkspaceLayout";
5+
6+export function WorkspaceSidebarToolbar(
7+ props: {
8+ readonly children?: ReactNode;
9+ readonly afterSidebarButton?: ReactNode;
10+ } = {},
11+) {
12+ const { layout, panes, togglePrimarySidebar } = useAdaptiveWorkspaceLayout();
13+
14+ if (!layout.usesSplitView) {
15+ return null;
16+ }
17+
18+ return (
19+ <NativeHeaderToolbar placement="left">
20+ {props.children}
21+ <NativeHeaderToolbar.Button
22+ accessibilityLabel={
23+ panes.primarySidebarVisible ? "Maximize content" : "Show thread sidebar"
24+ }
25+ icon={panes.primarySidebarVisible ? "arrow.up.left.and.arrow.down.right" : "sidebar.left"}
26+ onPress={togglePrimarySidebar}
27+ />
28+ {props.afterSidebarButton}
29+ </NativeHeaderToolbar>
30+ );
31+}
apps/mobile/src/features/projects/AddProjectDestinationRoute.tsx+15−0
@@ -0,0 +1,15 @@
1+import type { StaticScreenProps } from "@react-navigation/native";
2+import { AddProjectDestinationScreen } from "./AddProjectScreen";
3+
4+type AddProjectDestinationRouteParams = {
5+ readonly environmentId?: string | string[];
6+ readonly source?: string | string[];
7+ readonly remoteUrl?: string | string[];
8+ readonly repositoryTitle?: string | string[];
9+};
10+
11+export function AddProjectDestinationRoute({
12+ route,
13+}: StaticScreenProps<AddProjectDestinationRouteParams | undefined>) {
14+ return <AddProjectDestinationScreen {...(route.params ?? {})} />;
15+}
apps/mobile/src/features/projects/AddProjectLocalRoute.tsx+12−0
@@ -0,0 +1,12 @@
1+import type { StaticScreenProps } from "@react-navigation/native";
2+import { AddProjectLocalFolderScreen } from "./AddProjectScreen";
3+
4+type AddProjectLocalRouteParams = {
5+ readonly environmentId?: string | string[];
6+};
7+
8+export function AddProjectLocalRoute({
9+ route,
10+}: StaticScreenProps<AddProjectLocalRouteParams | undefined>) {
11+ return <AddProjectLocalFolderScreen {...(route.params ?? {})} />;
12+}
apps/mobile/src/features/projects/AddProjectRepositoryRoute.tsx+31−0
@@ -0,0 +1,31 @@
1+import type { StaticScreenProps } from "@react-navigation/native";
2+import { NativeStackScreenOptions } from "../../native/StackHeader";
3+import { addProjectRemoteSourceLabel } from "@t3tools/client-runtime/operations/projects";
4+
5+import { AddProjectRepositoryScreen } from "./AddProjectScreen";
6+
7+type AddProjectRepositoryRouteParams = {
8+ readonly environmentId?: string | string[];
9+ readonly source?: string | string[];
10+};
11+
12+export function AddProjectRepositoryRoute({
13+ route,
14+}: StaticScreenProps<AddProjectRepositoryRouteParams>) {
15+ const params = route.params ?? {};
16+ const source = Array.isArray(params.source) ? params.source[0] : params.source;
17+ const title =
18+ source === "github" ||
19+ source === "gitlab" ||
20+ source === "bitbucket" ||
21+ source === "azure-devops"
22+ ? addProjectRemoteSourceLabel(source)
23+ : "Git URL";
24+
25+ return (
26+ <>
27+ <NativeStackScreenOptions options={{ title }} />
28+ <AddProjectRepositoryScreen {...params} />
29+ </>
30+ );
31+}
apps/mobile/src/features/projects/AddProjectScreen.tsx+56−58
@@ -22,7 +22,7 @@ import { · 21 unmodified lines
2222 isFilesystemBrowseQuery,
2323 } from "@t3tools/client-runtime/state/projects";
2424 import { CommandId, type EnvironmentId, ProjectId } from "@t3tools/contracts";
25−import { useLocalSearchParams, useRouter } from "expo-router";
25+import { StackActions, useNavigation } from "@react-navigation/native";
2626 import { SymbolView } from "expo-symbols";
2727 import { useCallback, useEffect, useMemo, useState, type ReactNode } from "react";
2828 import { ActivityIndicator, Alert, Pressable, ScrollView, View } from "react-native";
@@ -168,9 +168,9 @@ function ListRow(props: { · 139 unmodified lines
168168 {props.icon}
169169 </View>
170170 <View className="flex-1 gap-0.5">
171− <Text className="text-base leading-[21px] font-t3-bold">{props.title}</Text>
171+ <Text className="text-base leading-snug font-t3-bold">{props.title}</Text>
172172 {props.subtitle ? (
173− <Text className="text-sm leading-[17px] text-foreground-muted" numberOfLines={2}>
173+ <Text className="text-sm leading-snug text-foreground-muted" numberOfLines={2}>
174174 {props.subtitle}
175175 </Text>
176176 ) : null}
@@ -215,7 +215,7 @@ function ProjectPathInput(props: { · 38 unmodified lines
215215 }) {
216216 return (
217217 <TextInput
218− className="h-12 min-h-12 rounded-[24px] px-4 py-0 text-base leading-[20px]"
218+ className="h-12 min-h-12 rounded-[24px] px-4 py-0 text-base leading-snug"
219219 value={props.value}
220220 onChangeText={props.onChangeText}
221221 autoCapitalize="none"
@@ -250,35 +250,31 @@ function useSelectedEnvironment(): { · 28 unmodified lines
250250 readonly selectedEnvironment: EnvironmentOption | null;
251251 readonly setSelectedEnvironmentId: (environmentId: EnvironmentId) => void;
252252 } {
253− const router = useRouter();
254− const params = useLocalSearchParams<{ environmentId?: string }>();
253+ const [selectedEnvironmentId, setSelectedEnvironmentId] = useState<EnvironmentId | null>(null);
255254 const environmentOptions = useEnvironmentOptions();
256− const requestedEnvironmentId = stringParam(params.environmentId) as EnvironmentId | null;
257255 const selectedEnvironment =
258− environmentOptions.find(
259− (environment) => environment.environmentId === requestedEnvironmentId,
260− ) ??
256+ environmentOptions.find((environment) => environment.environmentId === selectedEnvironmentId) ??
261257 environmentOptions[0] ??
262258 null;
263259
264260 return {
265261 environmentOptions,
266262 selectedEnvironment,
267− setSelectedEnvironmentId: (environmentId) => router.setParams({ environmentId }),
263+ setSelectedEnvironmentId,
268264 };
269265 }
270266
271267 function EmptyEnvironmentState() {
272− const router = useRouter();
268+ const navigation = useNavigation();
273269
274270 return (
275271 <View className="items-center gap-3 rounded-2xl bg-card px-5 py-8">
276272 <Text className="text-center text-lg font-t3-bold">No environments connected</Text>
277− <Text className="text-center text-sm leading-[20px] text-foreground-muted">
273+ <Text className="text-center text-sm leading-normal text-foreground-muted">
278274 Add an environment before adding a project.
279275 </Text>
280276 <Pressable
281− onPress={() => router.replace("/connections/new")}
277+ onPress={() => navigation.dispatch(StackActions.replace("ConnectionsNew"))}
282278 className="mt-1 rounded-full bg-primary px-4 py-2.5 active:opacity-70"
283279 >
284280 <Text className="text-sm font-t3-bold text-primary-foreground">Add environment</Text>
@@ -294,7 +290,7 @@ function SourceControlRow(props: { · 9 unmodified lines
294290 readonly hint: string;
295291 readonly isFirst: boolean;
296292 }) {
297− const router = useRouter();
293+ const navigation = useNavigation();
298294 const iconColor = useThemeColor("--color-icon");
299295 const title =
300296 props.source === "url" ? "Git URL" : `${addProjectRemoteSourceLabel(props.source)} repository`;
@@ -322,8 +318,8 @@ function SourceControlRow(props: { · 21 unmodified lines
322318 icon={icon}
323319 isFirst={props.isFirst}
324320 onPress={() =>
325− router.push({
326− pathname: "/new/add-project/repository",
321+ navigation.navigate("NewTaskSheet", {
322+ screen: "AddProjectRepository",
327323 params: {
328324 environmentId: props.selectedEnvironmentId,
329325 source: props.source,
@@ -335,7 +331,7 @@ function SourceControlRow(props: { · 5 unmodified lines
335331 }
336332
337333 export function AddProjectSourceScreen() {
338− const router = useRouter();
334+ const navigation = useNavigation();
339335 const accentColor = useThemeColor("--color-icon-muted");
340336 const iconColor = useThemeColor("--color-icon");
341337 const { environmentOptions, selectedEnvironment, setSelectedEnvironmentId } =
@@ -409,9 +405,11 @@ export function AddProjectSourceScreen() { · 67 unmodified lines
409405 }
410406 isFirst
411407 onPress={() =>
412− router.push({
413− pathname: "/new/add-project/local",
414− params: { environmentId: selectedEnvironment.environmentId },
408+ navigation.navigate("NewTaskSheet", {
409+ screen: "AddProjectLocal",
410+ params: {
411+ environmentId: selectedEnvironment.environmentId,
412+ },
415413 })
416414 }
417415 />
@@ -440,7 +438,7 @@ export function AddProjectSourceScreen() { · 22 unmodified lines
440438 }
441439
442440 function useCreateProject(environment: EnvironmentOption | null) {
443− const router = useRouter();
441+ const navigation = useNavigation();
444442 const createProject = useAtomCommand(projectEnvironment.create, { reportFailure: false });
445443 const projects = useProjects();
446444
@@ -455,14 +453,13 @@ function useCreateProject(environment: EnvironmentOption | null) { · 8 unmodified lines
455453 });
456454 if (existing) {
457455 Alert.alert("Project already exists", existing.title);
458− router.replace({
459− pathname: "/new/draft",
460− params: {
456+ navigation.dispatch(
457+ StackActions.replace("NewTaskDraft", {
461458 environmentId: existing.environmentId,
462459 projectId: existing.id,
463460 title: existing.title,
464− },
465− });
461+ }),
462+ );
466463 return;
467464 }
468465
@@ -480,24 +477,24 @@ function useCreateProject(environment: EnvironmentOption | null) { · 11 unmodified lines
480477 if (AsyncResult.isFailure(result)) {
481478 return result;
482479 }
483− router.replace({
484− pathname: "/new/draft",
485− params: {
480+ navigation.dispatch(
481+ StackActions.replace("NewTaskDraft", {
486482 environmentId: environment.environmentId,
487483 projectId,
488484 title: inferProjectTitleFromPath(workspaceRoot),
489− },
490− });
485+ }),
486+ );
491487 return result;
492488 },
493− [createProject, environment, projects, router],
489+ [createProject, environment, projects, navigation],
494490 );
495491 }
496492
497−function useEnvironmentFromParam(): EnvironmentOption | null {
498− const params = useLocalSearchParams<{ environmentId?: string }>();
493+function useEnvironmentFromParam(
494+ environmentIdParam: string | string[] | undefined,
495+): EnvironmentOption | null {
499496 const environmentOptions = useEnvironmentOptions();
500− const environmentId = stringParam(params.environmentId) as EnvironmentId | null;
497+ const environmentId = stringParam(environmentIdParam) as EnvironmentId | null;
501498 return (
502499 environmentOptions.find((environment) => environment.environmentId === environmentId) ??
503500 environmentOptions[0] ??
@@ -505,14 +502,16 @@ function useEnvironmentFromParam(): EnvironmentOption | null { · 1 unmodified line
505502 );
506503 }
507504
508−export function AddProjectRepositoryScreen() {
505+export function AddProjectRepositoryScreen(props: {
506+ readonly environmentId?: string | string[];
507+ readonly source?: string | string[];
508+}) {
509509 const lookupRepositoryQuery = useAtomQueryRunner(sourceControlEnvironment.repository, {
510510 reportFailure: false,
511511 });
512− const router = useRouter();
513− const params = useLocalSearchParams<{ environmentId?: string; source?: string }>();
514− const environment = useEnvironmentFromParam();
515− const source = sourceFromParam(params.source);
512+ const navigation = useNavigation();
513+ const environment = useEnvironmentFromParam(props.environmentId);
514+ const source = sourceFromParam(props.source);
516515 const [repositoryInput, setRepositoryInput] = useState("");
517516 const [isSubmitting, setIsSubmitting] = useState(false);
518517 const [error, setError] = useState<string | null>(null);
@@ -524,8 +523,8 @@ export function AddProjectRepositoryScreen() { · 5 unmodified lines
524523 const provider = addProjectRemoteSourceProvider(source);
525524 if (!provider) {
526525 const remoteUrl = repositoryInput.trim();
527− router.push({
528− pathname: "/new/add-project/destination",
526+ navigation.navigate("NewTaskSheet", {
527+ screen: "AddProjectDestination",
529528 params: {
530529 environmentId: environment.environmentId,
531530 source,
@@ -548,8 +547,8 @@ export function AddProjectRepositoryScreen() { · 16 unmodified lines
548547 setError(errorMessage(Cause.squash(result.cause)));
549548 } else {
550549 const repository = result.value;
551− router.push({
552− pathname: "/new/add-project/destination",
550+ navigation.navigate("NewTaskSheet", {
551+ screen: "AddProjectDestination",
553552 params: {
554553 environmentId: environment.environmentId,
555554 source,
@@ -559,13 +558,13 @@ export function AddProjectRepositoryScreen() { · 3 unmodified lines
559558 });
560559 }
561560 setIsSubmitting(false);
562− }, [environment, isSubmitting, lookupRepositoryQuery, repositoryInput, router, source]);
561+ }, [environment, isSubmitting, lookupRepositoryQuery, repositoryInput, navigation, source]);
563562
564563 return (
565564 <AddProjectShell>
566565 {error ? <ErrorBanner message={error} /> : null}
567566 <TextInput
568− className="h-12 min-h-12 rounded-[24px] px-4 py-0 text-base leading-[20px]"
567+ className="h-12 min-h-12 rounded-[24px] px-4 py-0 text-base leading-snug"
569568 value={repositoryInput}
570569 onChangeText={setRepositoryInput}
571570 autoCapitalize="none"
@@ -681,8 +680,8 @@ function FolderBrowser(props: { · 109 unmodified lines
681680 );
682681 }
683682
684−export function AddProjectLocalFolderScreen() {
685− const environment = useEnvironmentFromParam();
683+export function AddProjectLocalFolderScreen(props: { readonly environmentId?: string | string[] }) {
684+ const environment = useEnvironmentFromParam(props.environmentId);
686685 const createProject = useCreateProject(environment);
687686 const [pathInput, setPathInput] = useState(() =>
688687 getAddProjectInitialQuery(environment?.baseDirectory),
@@ -745,19 +744,18 @@ export function AddProjectLocalFolderScreen() { · 56 unmodified lines
745744 );
746745 }
747746
748−export function AddProjectDestinationScreen() {
747+export function AddProjectDestinationScreen(props: {
748+ readonly environmentId?: string | string[];
749+ readonly remoteUrl?: string | string[];
750+ readonly repositoryTitle?: string | string[];
751+}) {
749752 const cloneRepository = useAtomCommand(sourceControlEnvironment.cloneRepository, {
750753 reportFailure: false,
751754 });
752− const params = useLocalSearchParams<{
753− environmentId?: string;
754− remoteUrl?: string;
755− repositoryTitle?: string;
756− }>();
757− const environment = useEnvironmentFromParam();
755+ const environment = useEnvironmentFromParam(props.environmentId);
758756 const createProject = useCreateProject(environment);
759− const remoteUrl = stringParam(params.remoteUrl);
760− const repositoryTitle = stringParam(params.repositoryTitle);
757+ const remoteUrl = stringParam(props.remoteUrl);
758+ const repositoryTitle = stringParam(props.repositoryTitle);
761759 const [pathInput, setPathInput] = useState(() =>
762760 getAddProjectInitialQuery(environment?.baseDirectory),
763761 );
apps/mobile/src/features/projects/AddProjectSourceRoute.tsx+5−0
@@ -0,0 +1,5 @@
1+import { AddProjectSourceScreen } from "./AddProjectScreen";
2+
3+export function AddProjectSourceRoute() {
4+ return <AddProjectSourceScreen />;
5+}
apps/mobile/src/features/review/ReviewCommentComposerSheet.tsx+21−16
@@ -1,4 +1,4 @@
1−import { useLocalSearchParams, useRouter } from "expo-router";
1+import { useNavigation, type StaticScreenProps } from "@react-navigation/native";
22 import { SymbolView } from "expo-symbols";
33 import { TextInputWrapper } from "expo-paste-input";
44 import type { EnvironmentId, ThreadId } from "@t3tools/contracts";
@@ -25,10 +25,10 @@ import { · 20 unmodified lines
2525 getSelectedReviewCommentLines,
2626 useReviewCommentTarget,
2727 } from "./reviewCommentSelection";
28+import { useAppearanceCodeSurface } from "../settings/appearance/useAppearanceCodeSurface";
2829 import {
2930 changeTone,
3031 DiffTokenText,
31− REVIEW_DIFF_LINE_HEIGHT,
3232 REVIEW_MONO_FONT_FAMILY,
3333 ReviewChangeBar,
3434 } from "./reviewDiffRendering";
@@ -40,17 +40,20 @@ import { · 5 unmodified lines
4040
4141 const REVIEW_COMMENT_PREVIEW_MAX_LINES = 5;
4242
43−export function ReviewCommentComposerSheet() {
44− const router = useRouter();
43+type ReviewCommentComposerSheetProps = StaticScreenProps<{
44+ readonly environmentId: EnvironmentId;
45+ readonly threadId: ThreadId;
46+}>;
47+
48+export function ReviewCommentComposerSheet(props: ReviewCommentComposerSheetProps) {
49+ const navigation = useNavigation();
4550 const insets = useSafeAreaInsets();
4651 const { width } = useWindowDimensions();
4752 const colorScheme = useColorScheme();
4853 const iconTint = String(useThemeColor("--color-icon"));
4954 const target = useReviewCommentTarget();
50− const { environmentId, threadId } = useLocalSearchParams<{
51− environmentId: EnvironmentId;
52− threadId: ThreadId;
53− }>();
55+ const { codeSurface } = useAppearanceCodeSurface();
56+ const { environmentId, threadId } = props.route.params;
5457 const [commentText, setCommentText] = useState("");
5558 const [highlightedLinesById, setHighlightedLinesById] = useState<
5659 Record<string, ReadonlyArray<ReviewHighlightedToken>>
@@ -78,14 +81,14 @@ export function ReviewCommentComposerSheet() { · 21 unmodified lines
7881 ? `Lines ${firstNumber}-${lastNumber}`
7982 : `${selectedLines.length} lines selected`;
8083 const previewHeight = Math.max(
81− Math.min(selectedLines.length, REVIEW_COMMENT_PREVIEW_MAX_LINES) * REVIEW_DIFF_LINE_HEIGHT,
82− REVIEW_DIFF_LINE_HEIGHT,
84+ Math.min(selectedLines.length, REVIEW_COMMENT_PREVIEW_MAX_LINES) * codeSurface.rowHeight,
85+ codeSurface.rowHeight,
8386 );
8487 const previewViewportWidth = Math.max(width - 40, 280);
8588 const dismissComposer = useCallback(() => {
8689 clearReviewCommentTarget();
87− router.dismiss();
88− }, [router]);
90+ navigation.goBack();
91+ }, [navigation]);
8992 const handleNativePaste = useNativePaste((uris) => {
9093 void (async () => {
9194 try {
@@ -167,7 +170,7 @@ export function ReviewCommentComposerSheet() { · 75 unmodified lines
167170 {!target ? (
168171 <View className="rounded-[22px] border border-border bg-card px-4 py-5">
169172 <Text className="text-base font-t3-bold text-foreground">No selection</Text>
170− <Text className="mt-1 text-sm leading-[19px] text-foreground-muted">
173+ <Text className="mt-1 text-sm leading-normal text-foreground-muted">
171174 Select a diff line or range first.
172175 </Text>
173176 </View>
@@ -178,7 +181,7 @@ export function ReviewCommentComposerSheet() { · 4 unmodified lines
178181 {selectionLabel}
179182 </Text>
180183 <Text
181− className="font-mono text-xs leading-[17px] text-foreground-muted"
184+ className="font-mono text-xs leading-snug text-foreground-muted"
182185 ellipsizeMode="middle"
183186 numberOfLines={2}
184187 >
@@ -211,9 +214,9 @@ export function ReviewCommentComposerSheet() { · 26 unmodified lines
211214 <View
212215 key={line.id}
213216 className={cn("flex-row items-start", changeTone(line.change))}
214− style={{ height: REVIEW_DIFF_LINE_HEIGHT }}
217+ style={{ height: codeSurface.rowHeight }}
215218 >
216− <ReviewChangeBar change={line.change} />
219+ <ReviewChangeBar change={line.change} height={codeSurface.rowHeight} />
217220 <Text
218221 className="w-9 py-1 pr-1 text-right text-2xs font-t3-medium text-foreground-muted"
219222 style={{ fontFamily: REVIEW_MONO_FONT_FAMILY }}
@@ -225,6 +228,8 @@ export function ReviewCommentComposerSheet() { · 5 unmodified lines
225228 fallback={line.content}
226229 tokens={highlightedLinesById[line.id] ?? null}
227230 change={line.change}
231+ fontSize={codeSurface.fontSize}
232+ lineHeight={codeSurface.rowHeight}
228233 />
229234 </View>
230235 </View>
apps/mobile/src/features/review/ReviewSheet.tsx+478−182
@@ -1,14 +1,30 @@
11 import type { EnvironmentId, ThreadId } from "@t3tools/contracts";
2−import { useLocalSearchParams } from "expo-router";
3−import Stack from "expo-router/stack";
2+import { useNavigation, type StaticScreenProps } from "@react-navigation/native";
3+import {
4+ NativeHeaderToolbar,
5+ NativeStackScreenOptions,
6+ nativeHeaderScrollEdgeEffects,
7+} from "../../native/StackHeader";
8+import { Screen, ScreenStack, ScreenStackHeaderConfig } from "react-native-screens";
49 import { SymbolView } from "expo-symbols";
5−import { memo, type ReactElement, useCallback, useMemo } from "react";
10+import {
11+ memo,
12+ type Ref,
13+ type ReactElement,
14+ useCallback,
15+ useEffect,
16+ useImperativeHandle,
17+ useMemo,
18+ useRef,
19+ useState,
20+} from "react";
621 import {
722 ActivityIndicator,
23+ FlatList,
24+ Platform,
825 Pressable,
926 ScrollView,
1027 type NativeSyntheticEvent,
11− Text as NativeText,
1228 StyleSheet,
1329 useColorScheme,
1430 View,
@@ -20,23 +36,38 @@ import { environmentCatalog } from "../../connection/catalog"; · 5 unmodified lines
2036 import { useEnvironmentPresentation } from "../../state/presentation";
2137 import { useAtomCommand } from "../../state/use-atom-command";
2238 import { useThemeColor } from "../../lib/useThemeColor";
23−import { MOBILE_TYPOGRAPHY } from "../../lib/typography";
2439 import { useThreadDraftForThread } from "../../state/use-thread-composer-state";
2540 import { EnvironmentConnectionNotice } from "../connection/EnvironmentConnectionNotice";
41+import {
42+ useAdaptiveWorkspaceLayout,
43+ useAdaptiveWorkspacePaneRole,
44+ useRegisterWorkspaceInspector,
45+} from "../layout/AdaptiveWorkspaceLayout";
46+import { useEnvironmentQuery } from "../../state/query";
47+import { useSelectedThreadGitActions } from "../../state/use-selected-thread-git-actions";
48+import { useSelectedThreadGitState } from "../../state/use-selected-thread-git-state";
49+import { useSelectedThreadWorktree } from "../../state/use-selected-thread-worktree";
50+import { useThreadSelection } from "../../state/use-thread-selection";
51+import { vcsEnvironment } from "../../state/vcs";
52+import { WorkspaceSidebarToolbar } from "../layout/workspace-sidebar-toolbar";
53+import { ThreadGitMenu } from "../threads/ThreadGitControls";
2654 import { useReviewCacheForThread } from "./reviewState";
27−import { resolveNativeReviewDiffView } from "../diffs/nativeReviewDiffSurface";
2855 import {
29− NATIVE_REVIEW_DIFF_CONTENT_WIDTH,
30− NATIVE_REVIEW_DIFF_ROW_HEIGHT,
31−} from "./nativeReviewDiffAdapter";
56+ type NativeReviewDiffViewHandle,
57+ resolveNativeReviewDiffView,
58+} from "../diffs/nativeReviewDiffSurface";
59+import { NATIVE_REVIEW_DIFF_CONTENT_WIDTH } from "./nativeReviewDiffAdapter";
60+import { useAppearanceCodeSurface } from "../settings/appearance/useAppearanceCodeSurface";
3261 import { useReviewDiffData } from "./useReviewDiffData";
62+import { useReviewDiffPrewarming } from "./useReviewDiffPrewarming";
3363 import { useReviewFileVisibility } from "./reviewFileVisibility";
3464 import { useReviewSections } from "./useReviewSections";
3565 import { useNativeReviewDiffBridge } from "./useNativeReviewDiffBridge";
3666 import { useReviewCommentSelectionController } from "./useReviewCommentSelectionController";
3767 import { resolveReviewAvailability } from "./reviewAvailability";
68+import { resolveSelectedReviewFileId } from "./reviewPaneSelection";
69+import { buildReviewSectionMenu } from "./review-section-menu";
3870
39−const IOS_NAV_BAR_HEIGHT = 44;
4071 const REVIEW_HEADER_SPACING = 0;
4172
4273 const ReviewNotice = memo(function ReviewNotice(props: { readonly notice: string }) {
@@ -45,7 +76,7 @@ const ReviewNotice = memo(function ReviewNotice(props: { readonly notice: string · 2 unmodified lines
4576 <Text className="text-xs font-t3-bold uppercase text-amber-700 dark:text-amber-300">
4677 Partial diff
4778 </Text>
48− <Text className="text-xs leading-[18px] text-amber-800 dark:text-amber-200">
79+ <Text className="text-xs leading-normal text-amber-800 dark:text-amber-200">
4980 {props.notice}
5081 </Text>
5182 </View>
@@ -110,36 +141,244 @@ function ReviewSelectionActionBar(props: { · 58 unmodified lines
110141 );
111142 }
112143
113−export function ReviewSheet() {
144+interface ReviewNavigatorFile {
145+ readonly id: string;
146+ readonly path: string;
147+ readonly additions: number;
148+ readonly deletions: number;
149+}
150+
151+const ReviewFileNavigatorRow = memo(function ReviewFileNavigatorRow(props: {
152+ readonly file: ReviewNavigatorFile;
153+ readonly selected: boolean;
154+ readonly onSelectFile: (fileId: string | null) => void;
155+}) {
156+ const { file, selected, onSelectFile } = props;
157+ // Tapping the selected file again returns to the all-files diff.
158+ const handlePress = useCallback(() => {
159+ onSelectFile(selected ? null : file.id);
160+ }, [file.id, onSelectFile, selected]);
161+
162+ return (
163+ <Pressable
164+ accessibilityRole="button"
165+ accessibilityState={{ selected }}
166+ className={
167+ selected
168+ ? "mt-1 min-h-12 justify-center rounded-xl bg-subtle-strong px-3 py-2"
169+ : "mt-1 min-h-12 justify-center rounded-xl px-3 py-2 active:bg-subtle"
170+ }
171+ onPress={handlePress}
172+ >
173+ <Text
174+ className={
175+ selected
176+ ? "text-xs font-t3-bold text-foreground"
177+ : "text-xs font-t3-medium text-foreground-secondary"
178+ }
179+ numberOfLines={2}
180+ >
181+ {file.path}
182+ </Text>
183+ <View className="mt-1 flex-row gap-2">
184+ <Text className="text-2xs font-t3-bold text-emerald-600">+{file.additions}</Text>
185+ <Text className="text-2xs font-t3-bold text-rose-600">-{file.deletions}</Text>
186+ </View>
187+ </Pressable>
188+ );
189+});
190+
191+interface ReviewFileNavigatorHandle {
192+ readonly setVisibleFile: (fileId: string | null) => void;
193+}
194+
195+interface ReviewFileNavigatorProps {
196+ readonly files: ReadonlyArray<ReviewNavigatorFile>;
197+ readonly headerInset: number;
198+ readonly sectionId: string | null;
199+ readonly onSelectFile: (fileId: string | null) => void;
200+ readonly ref?: Ref<ReviewFileNavigatorHandle>;
201+}
202+
203+function ReviewFileNavigator({
204+ files,
205+ headerInset,
206+ sectionId,
207+ onSelectFile,
208+ ref,
209+}: ReviewFileNavigatorProps) {
210+ const insets = useSafeAreaInsets();
211+ const sheetColor = String(useThemeColor("--color-sheet"));
212+ const foregroundColor = String(useThemeColor("--color-foreground"));
213+ const headerScrollEdgeEffects = nativeHeaderScrollEdgeEffects(Platform.OS, Platform.Version);
214+ const [fileSelection, setFileSelection] = useState<{
215+ readonly sectionId: string | null;
216+ readonly fileId: string | null;
217+ }>({ sectionId: null, fileId: null });
218+ const availableFileIds = useMemo(() => files.map((file) => file.id), [files]);
219+ const selectedFileId = resolveSelectedReviewFileId({
220+ selection: fileSelection,
221+ sectionId,
222+ availableFileIds,
223+ });
224+
225+ useImperativeHandle(
226+ ref,
227+ () => ({
228+ setVisibleFile: (fileId) => {
229+ if (fileId !== null && !availableFileIds.includes(fileId)) {
230+ return;
231+ }
232+ setFileSelection((current) => {
233+ if (current.sectionId === sectionId && current.fileId === fileId) {
234+ return current;
235+ }
236+ return { sectionId, fileId };
237+ });
238+ },
239+ }),
240+ [availableFileIds, sectionId],
241+ );
242+
243+ const handleSelectFile = useCallback(
244+ (fileId: string | null) => {
245+ setFileSelection({ sectionId, fileId });
246+ onSelectFile(fileId);
247+ },
248+ [onSelectFile, sectionId],
249+ );
250+
251+ const renderFile = useCallback(
252+ ({ item }: { readonly item: ReviewNavigatorFile }) => (
253+ <ReviewFileNavigatorRow
254+ file={item}
255+ selected={selectedFileId === item.id}
256+ onSelectFile={handleSelectFile}
257+ />
258+ ),
259+ [handleSelectFile, selectedFileId],
260+ );
261+
262+ const fileList = (
263+ <FlatList
264+ data={files}
265+ extraData={selectedFileId}
266+ keyExtractor={(file) => file.id}
267+ contentContainerStyle={{
268+ paddingHorizontal: 8,
269+ paddingBottom: 8,
270+ // The nested native header is translucent; start the list below it so
271+ // the scroll-edge effect can sample the content (same treatment as
272+ // FileTreeBrowser in the Files pane).
273+ paddingTop: Platform.OS === "ios" ? insets.top + 44 + 8 : 8,
274+ }}
275+ scrollIndicatorInsets={Platform.OS === "ios" ? { top: insets.top + 44 } : undefined}
276+ renderItem={renderFile}
277+ />
278+ );
279+
280+ if (Platform.OS === "ios") {
281+ return (
282+ <View className="flex-1 border-l border-border bg-sheet">
283+ <ScreenStack style={{ flex: 1 }}>
284+ <Screen
285+ activityState={2}
286+ enabled
287+ isNativeStack
288+ screenId="review-file-navigator-native"
289+ scrollEdgeEffects={headerScrollEdgeEffects}
290+ style={{ backgroundColor: sheetColor, flex: 1 }}
291+ >
292+ {fileList}
293+ <ScreenStackHeaderConfig
294+ backgroundColor="rgba(0,0,0,0)"
295+ color={foregroundColor}
296+ hideBackButton
297+ hideShadow={false}
298+ navigationItemStyle="editor"
299+ subtitle={`${files.length} ${files.length === 1 ? "file" : "files"}`}
300+ title="Changed files"
301+ titleColor={foregroundColor}
302+ titleFontSize={17}
303+ titleFontWeight="700"
304+ translucent
305+ />
306+ </Screen>
307+ </ScreenStack>
308+ </View>
309+ );
310+ }
311+
312+ return (
313+ <View className="flex-1 border-l border-border bg-sheet">
314+ <View className="border-b border-border" style={{ paddingTop: headerInset }}>
315+ <View className="px-4 py-3">
316+ <Text className="text-sm font-t3-bold text-foreground">Changed files</Text>
317+ <Text className="text-xs text-foreground-muted">
318+ {files.length} {files.length === 1 ? "file" : "files"}
319+ </Text>
320+ </View>
321+ </View>
322+ {fileList}
323+ </View>
324+ );
325+}
326+
327+type ReviewSheetProps = StaticScreenProps<{
328+ readonly environmentId: EnvironmentId;
329+ readonly threadId: ThreadId;
330+}>;
331+
332+export function ReviewSheet(props: ReviewSheetProps) {
333+ const { nativeReviewDiffStyle } = useAppearanceCodeSurface();
334+ useAdaptiveWorkspacePaneRole("inspector");
335+ const { panes, showAuxiliaryPane, toggleAuxiliaryPane } = useAdaptiveWorkspaceLayout();
336+ const navigation = useNavigation();
114337 const insets = useSafeAreaInsets();
115338 const colorScheme = useColorScheme();
116− const headerForeground = String(useThemeColor("--color-foreground"));
117− const headerMuted = String(useThemeColor("--color-foreground-muted"));
118339 const headerIcon = String(useThemeColor("--color-icon"));
119− const { environmentId, threadId } = useLocalSearchParams<{
120− environmentId: EnvironmentId;
121− threadId: ThreadId;
122− }>();
340+ const { environmentId, threadId } = props.route.params;
123341 const environment = useEnvironmentPresentation(environmentId);
124342 const retryEnvironment = useAtomCommand(environmentCatalog.retryNow, "environment retry");
125343 const isEnvironmentReady = environment.presentation?.connection.phase === "connected";
126344 const { draftMessage } = useThreadDraftForThread({ environmentId, threadId });
127345 const reviewCache = useReviewCacheForThread({ environmentId, threadId });
346+ /* ─── Git actions for the toolbar menu (commit/push without leaving review) ── */
347+ const { selectedThread } = useThreadSelection();
348+ const { selectedThreadCwd } = useSelectedThreadWorktree();
349+ const gitState = useSelectedThreadGitState();
350+ const gitActions = useSelectedThreadGitActions();
351+ const gitStatusQuery = useEnvironmentQuery(
352+ selectedThread !== null && selectedThreadCwd !== null
353+ ? vcsEnvironment.status({
354+ environmentId: selectedThread.environmentId,
355+ input: { cwd: selectedThreadCwd },
356+ })
357+ : null,
358+ );
359+ // The selection-based git hooks only apply when this review belongs to the
360+ // selected thread (it always does when reached from the thread's toolbar).
361+ const gitMenuAvailable =
362+ selectedThread !== null && String(selectedThread.id) === String(threadId);
128363 const selectedTheme = colorScheme === "dark" ? "dark" : "light";
129− const topContentInset = insets.top + IOS_NAV_BAR_HEIGHT;
130− const {
131− error,
132− loadingGitDiffs,
133− loadingTurnIds,
134− reviewSections,
135− selectedSection,
136− refreshSelectedSection,
137− selectSection,
138− } = useReviewSections({
139− enabled: isEnvironmentReady,
140− environmentId,
141− threadId,
142− reviewCache,
364+ // With a solid (non-overlay) header the content lays out below the header
365+ // natively, so no manual top inset is needed.
366+ const topContentInset = 0;
367+
368+ useEffect(() => {
369+ showAuxiliaryPane("inspector");
370+ }, [environmentId, showAuxiliaryPane, threadId]);
371+ const { error, reviewSections, selectedSection, refreshSelectedSection, selectSection } =
372+ useReviewSections({
373+ enabled: isEnvironmentReady,
374+ environmentId,
375+ threadId,
376+ reviewCache,
377+ });
378+ useReviewDiffPrewarming({
379+ threadKey: reviewCache.threadKey,
380+ sections: reviewSections,
381+ selectedSectionId: selectedSection?.id ?? null,
143382 });
144383 const { headerDiffSummary, nativeReviewDiffData, parsedDiff, pendingReviewCommentCount } =
145384 useReviewDiffData({
@@ -148,6 +387,18 @@ export function ReviewSheet() { · 2 unmodified lines
148387 draftMessage,
149388 });
150389 const NativeReviewDiffView = resolveNativeReviewDiffView()!;
390+ const nativeReviewDiffViewRef = useRef<NativeReviewDiffViewHandle>(null);
391+ // Native pull-to-refresh on the diff surface (replaces the old Refresh menu item).
392+ const [isPullRefreshing, setIsPullRefreshing] = useState(false);
393+ const handlePullToRefresh = useCallback(async () => {
394+ setIsPullRefreshing(true);
395+ try {
396+ await refreshSelectedSection();
397+ } finally {
398+ setIsPullRefreshing(false);
399+ }
400+ }, [refreshSelectedSection]);
401+ const reviewFileNavigatorRef = useRef<ReviewFileNavigatorHandle>(null);
151402 const reviewFiles = parsedDiff.kind === "files" ? parsedDiff.files : [];
152403 const fileVisibility = useReviewFileVisibility({
153404 threadKey: reviewCache.threadKey,
@@ -179,6 +430,43 @@ export function ReviewSheet() { · 25 unmodified lines
179430 canHighlight: parsedDiff.kind === "files",
180431 });
181432
433+ const handleSelectFile = useCallback(
434+ (fileId: string | null) => {
435+ commentSelection.clearSelection();
436+ if (fileId !== null && collapsedFileIds.includes(fileId)) {
437+ toggleExpandedFile(fileId);
438+ }
439+ const navigation =
440+ fileId === null
441+ ? nativeReviewDiffViewRef.current?.scrollToTop(true)
442+ : nativeReviewDiffViewRef.current?.scrollToFile(fileId, true);
443+ void navigation?.catch((error: unknown) => {
444+ console.error("[review] Failed to navigate to diff file", error);
445+ });
446+ },
447+ [collapsedFileIds, commentSelection, toggleExpandedFile],
448+ );
449+ const handleVisibleFileChange = useCallback(
450+ (event: NativeSyntheticEvent<{ readonly fileId?: string | null }>) => {
451+ reviewFileNavigatorRef.current?.setVisibleFile(event.nativeEvent.fileId ?? null);
452+ },
453+ [],
454+ );
455+ const renderInspector = useCallback(
456+ () => (
457+ <ReviewFileNavigator
458+ ref={reviewFileNavigatorRef}
459+ files={nativeReviewDiffData.files}
460+ // The workspace inspector column spans the full window height, so the
461+ // pane clears the status bar itself.
462+ headerInset={insets.top}
463+ sectionId={selectedSection?.id ?? null}
464+ onSelectFile={handleSelectFile}
465+ />
466+ ),
467+ [handleSelectFile, insets.top, nativeReviewDiffData.files, selectedSection?.id],
468+ );
469+
182470 const handleNativeToggleFile = useCallback(
183471 (event: NativeSyntheticEvent<{ readonly fileId?: string }>) => {
184472 const { fileId } = event.nativeEvent;
@@ -203,6 +491,7 @@ export function ReviewSheet() { · 18 unmodified lines
203491 parsedDiff.kind === "files" || parsedDiff.kind === "raw" ? parsedDiff.notice : null;
204492 const hasCachedSelectedDiff = selectedSection?.diff != null;
205493 const hasAnyCachedDiff = reviewSections.some((section) => section.diff != null);
494+ const sectionMenu = useMemo(() => buildReviewSectionMenu(reviewSections), [reviewSections]);
206495 const { showConnectionNotice, showSectionToolbar } = resolveReviewAvailability({
207496 hasEnvironmentPresentation: environment.isReady,
208497 isEnvironmentConnected: isEnvironmentReady,
@@ -212,6 +501,22 @@ export function ReviewSheet() { · 3 unmodified lines
212501 const handleRetryEnvironment = useCallback(() => {
213502 void retryEnvironment(environmentId);
214503 }, [environmentId, retryEnvironment]);
504+ const handleReturnToThread = useCallback(() => {
505+ if (navigation.canGoBack()) {
506+ navigation.goBack();
507+ return;
508+ }
509+ navigation.navigate("Thread", {
510+ environmentId: String(environmentId),
511+ threadId: String(threadId),
512+ });
513+ }, [environmentId, navigation, threadId]);
514+
515+ // The changed-files navigator lives in the workspace inspector column —
516+ // the single right-hand pane per route — instead of an in-screen panel.
517+ const showChangedFilesPane =
518+ !showConnectionNotice && selectedSection !== null && parsedDiff.kind === "files";
519+ useRegisterWorkspaceInspector(showChangedFilesPane ? renderInspector : undefined);
215520
216521 const listHeader = useMemo(() => {
217522 const children: ReactElement[] = [];
@@ -220,7 +525,7 @@ export function ReviewSheet() { · 2 unmodified lines
220525 children.push(
221526 <View key="review-error" className="border-b border-border bg-card px-4 py-3">
222527 <Text className="text-sm font-t3-bold text-foreground">Review unavailable</Text>
223− <Text className="text-xs leading-[18px] text-foreground-muted">{error}</Text>
528+ <Text className="text-xs leading-normal text-foreground-muted">{error}</Text>
224529 </View>,
225530 );
226531 }
@@ -235,134 +540,117 @@ export function ReviewSheet() { · 8 unmodified lines
235540
236541 return <>{children}</>;
237542 }, [error, parsedDiffNotice]);
543+ const headerSubtitle = [
544+ headerDiffSummary.additions,
545+ headerDiffSummary.deletions,
546+ pendingReviewCommentCount > 0
547+ ? `${pendingReviewCommentCount} comment${pendingReviewCommentCount === 1 ? "" : "s"}`
548+ : null,
549+ ]
550+ .filter(Boolean)
551+ .join(" · ");
552+ const headerTitleText = selectedSection?.title ?? "Review changes";
238553
239554 return (
240555 <>
241− <Stack.Screen
556+ <NativeStackScreenOptions
242557 options={{
243− headerTransparent: true,
244− headerShadowVisible: false,
558+ // Static header config lives in Stack.tsx (SOLID_HEADER_OPTIONS — the native
559+ // diff scrolls internally, nothing for glass to sample). Only dynamic values
560+ // here.
245561 headerTintColor: headerIcon,
246− headerStyle: {
247− backgroundColor: "transparent",
248− },
249− headerTitle: () => (
250− <View style={{ alignItems: "center" }}>
251− <NativeText
252− numberOfLines={1}
253− style={{
254− fontFamily: "DMSans_700Bold",
255− fontSize: MOBILE_TYPOGRAPHY.headline.fontSize,
256− fontWeight: "900",
257− color: headerForeground,
258− letterSpacing: -0.4,
259− }}
260− >
261− Files Changed
262− </NativeText>
263− <View
264− style={{
265− flexDirection: "row",
266− alignItems: "center",
267− justifyContent: "center",
268− gap: 6,
269− flexWrap: "wrap",
270− }}
271− >
272− {headerDiffSummary.additions && headerDiffSummary.deletions ? (
273− <>
274− <NativeText
275− style={{
276− fontFamily: "DMSans_700Bold",
277− fontSize: MOBILE_TYPOGRAPHY.label.fontSize,
278− fontWeight: "700",
279− color: "#16a34a",
280− }}
281− >
282− {headerDiffSummary.additions}
283− </NativeText>
284− <NativeText
285− style={{
286− fontFamily: "DMSans_700Bold",
287− fontSize: MOBILE_TYPOGRAPHY.label.fontSize,
288− fontWeight: "700",
289− color: "#e11d48",
290− }}
291− >
292− {headerDiffSummary.deletions}
293− </NativeText>
294− {pendingReviewCommentCount > 0 ? (
295− <NativeText
296− style={{
297− fontFamily: "DMSans_700Bold",
298− fontSize: MOBILE_TYPOGRAPHY.label.fontSize,
299− fontWeight: "700",
300− color: "#b45309",
301− }}
302− >
303− {pendingReviewCommentCount} pending
304− </NativeText>
305− ) : null}
306− </>
307− ) : (
308− <View style={{ flexDirection: "row", alignItems: "center", gap: 6 }}>
309− <NativeText
310− numberOfLines={1}
311− style={{
312− fontFamily: "DMSans_700Bold",
313− fontSize: MOBILE_TYPOGRAPHY.label.fontSize,
314− fontWeight: "700",
315− color: headerMuted,
316− }}
317− >
318− {selectedSection?.title ?? "Review changes"}
319− </NativeText>
320− {pendingReviewCommentCount > 0 ? (
321− <NativeText
322− style={{
323− fontFamily: "DMSans_700Bold",
324− fontSize: MOBILE_TYPOGRAPHY.label.fontSize,
325− fontWeight: "700",
326− color: "#b45309",
327− }}
328− >
329− {pendingReviewCommentCount} pending
330− </NativeText>
331− ) : null}
332− </View>
333− )}
334− </View>
335− </View>
336− ),
562+ headerTitle: headerTitleText,
563+ title: headerTitleText,
564+ unstable_headerSubtitle:
565+ Platform.OS === "ios" && headerSubtitle.length > 0 ? headerSubtitle : undefined,
337566 }}
338567 />
339568
340− {showSectionToolbar ? (
341− <Stack.Toolbar placement="right">
342− <Stack.Toolbar.Menu icon="ellipsis.circle" title="Select diff" separateBackground>
343− {reviewSections.map((section) => (
344− <Stack.Toolbar.MenuAction
345− key={section.id}
346− icon={section.id === selectedSection?.id ? "checkmark" : "circle"}
347− onPress={() => selectSection(section.id)}
348− subtitle={section.subtitle ?? undefined}
349− >
350− <Stack.Toolbar.Label>{section.title}</Stack.Toolbar.Label>
351− </Stack.Toolbar.MenuAction>
352− ))}
353− <Stack.Toolbar.MenuAction
354− icon="arrow.clockwise"
355− disabled={
356− loadingGitDiffs ||
357− (selectedSection?.kind === "turn" && loadingTurnIds[selectedSection.id] === true)
569+ <WorkspaceSidebarToolbar>
570+ <NativeHeaderToolbar.Button
571+ accessibilityLabel="Back to chat"
572+ icon="chevron.left"
573+ onPress={handleReturnToThread}
574+ />
575+ </WorkspaceSidebarToolbar>
576+
577+ {showSectionToolbar || panes.supportsAuxiliaryPane || gitMenuAvailable ? (
578+ <NativeHeaderToolbar placement="right">
579+ {panes.supportsAuxiliaryPane ? (
580+ <NativeHeaderToolbar.Button
581+ accessibilityLabel={
582+ panes.auxiliaryPaneVisible ? "Hide changed files" : "Show changed files"
358583 }
359− onPress={() => void refreshSelectedSection()}
360− subtitle="Reload current diff"
361− >
362− <Stack.Toolbar.Label>Refresh</Stack.Toolbar.Label>
363− </Stack.Toolbar.MenuAction>
364− </Stack.Toolbar.Menu>
365− </Stack.Toolbar>
584+ icon="sidebar.right"
585+ onPress={toggleAuxiliaryPane}
586+ separateBackground
587+ />
588+ ) : null}
589+ {gitMenuAvailable && selectedThread !== null ? (
590+ <ThreadGitMenu
591+ environmentId={environmentId}
592+ threadId={threadId}
593+ currentBranch={selectedThread.branch ?? null}
594+ gitStatus={gitStatusQuery.data}
595+ gitOperationLabel={gitState.gitOperationLabel}
596+ onPull={gitActions.onPullSelectedThreadBranch}
597+ onRunAction={gitActions.onRunSelectedThreadGitAction}
598+ />
599+ ) : null}
600+ {showSectionToolbar ? (
601+ <NativeHeaderToolbar.Menu icon="ellipsis" title="Select diff" separateBackground>
602+ <NativeHeaderToolbar.Menu inline>
603+ <NativeHeaderToolbar.MenuAction
604+ disabled={sectionMenu.workingTree === null}
605+ isOn={selectedSection?.id === sectionMenu.workingTree?.id}
606+ onPress={() => {
607+ if (sectionMenu.workingTree) {
608+ selectSection(sectionMenu.workingTree.id);
609+ }
610+ }}
611+ >
612+ <NativeHeaderToolbar.Label>Working tree</NativeHeaderToolbar.Label>
613+ </NativeHeaderToolbar.MenuAction>
614+ <NativeHeaderToolbar.MenuAction
615+ disabled={sectionMenu.branchChanges === null}
616+ isOn={selectedSection?.id === sectionMenu.branchChanges?.id}
617+ onPress={() => {
618+ if (sectionMenu.branchChanges) {
619+ selectSection(sectionMenu.branchChanges.id);
620+ }
621+ }}
622+ >
623+ <NativeHeaderToolbar.Label>Branch changes</NativeHeaderToolbar.Label>
624+ </NativeHeaderToolbar.MenuAction>
625+ <NativeHeaderToolbar.MenuAction
626+ disabled={sectionMenu.latestTurn === null}
627+ isOn={selectedSection?.id === sectionMenu.latestTurn?.id}
628+ onPress={() => {
629+ if (sectionMenu.latestTurn) {
630+ selectSection(sectionMenu.latestTurn.id);
631+ }
632+ }}
633+ >
634+ <NativeHeaderToolbar.Label>Latest turn</NativeHeaderToolbar.Label>
635+ </NativeHeaderToolbar.MenuAction>
636+ {sectionMenu.turns.length > 0 ? (
637+ <NativeHeaderToolbar.Menu title="Turn">
638+ {sectionMenu.turns.map((section) => (
639+ <NativeHeaderToolbar.MenuAction
640+ key={section.id}
641+ isOn={section.id === selectedSection?.id}
642+ onPress={() => selectSection(section.id)}
643+ subtitle={section.subtitle ?? undefined}
644+ >
645+ <NativeHeaderToolbar.Label>{section.title}</NativeHeaderToolbar.Label>
646+ </NativeHeaderToolbar.MenuAction>
647+ ))}
648+ </NativeHeaderToolbar.Menu>
649+ ) : null}
650+ </NativeHeaderToolbar.Menu>
651+ </NativeHeaderToolbar.Menu>
652+ ) : null}
653+ </NativeHeaderToolbar>
366654 ) : null}
367655
368656 <View className="flex-1 bg-sheet">
@@ -386,34 +674,42 @@ export function ReviewSheet() { · 17 unmodified lines
386674 className="flex-1"
387675 style={{
388676 backgroundColor: nativeBridge.theme.background,
389− paddingTop: topContentInset + REVIEW_HEADER_SPACING,
390677 }}
391678 >
392− {listHeader}
393− <View className="flex-1" collapsable={false}>
394− <NativeReviewDiffView
395− key={`${reviewCache.threadKey}:${selectedSection.id}`}
396− collapsable={false}
397− testID="review-native-diff-view"
398− style={StyleSheet.absoluteFill}
399− appearanceScheme={selectedTheme}
400− collapsedFileIdsJson={nativeBridge.collapsedFileIdsJson}
401− collapsedCommentIdsJson={nativeBridge.collapsedCommentIdsJson}
402− contentWidth={NATIVE_REVIEW_DIFF_CONTENT_WIDTH}
403− rowHeight={NATIVE_REVIEW_DIFF_ROW_HEIGHT}
404− rowsJson={nativeBridge.rowsJson}
405− selectedRowIdsJson={nativeBridge.selectedRowIdsJson}
406− styleJson={nativeBridge.styleJson}
407− themeJson={nativeBridge.themeJson}
408− tokensPatchJson={nativeBridge.tokensPatchJson}
409− tokensResetKey={nativeBridge.tokensResetKey}
410− viewedFileIdsJson={nativeBridge.viewedFileIdsJson}
411− onDebug={nativeBridge.onDebug}
412− onPressLine={commentSelection.onPressLine}
413− onToggleComment={nativeBridge.onToggleComment}
414− onToggleFile={handleNativeToggleFile}
415− onToggleViewedFile={handleNativeToggleViewedFile}
416− />
679+ <View
680+ className="min-w-0 flex-1"
681+ style={{ paddingTop: topContentInset + REVIEW_HEADER_SPACING }}
682+ >
683+ {listHeader}
684+ <View className="min-w-0 flex-1" collapsable={false}>
685+ <NativeReviewDiffView
686+ collapsable={false}
687+ testID="review-native-diff-view"
688+ refreshing={isPullRefreshing}
689+ onPullToRefresh={() => void handlePullToRefresh()}
690+ style={StyleSheet.absoluteFill}
691+ appearanceScheme={selectedTheme}
692+ collapsedFileIdsJson={nativeBridge.collapsedFileIdsJson}
693+ collapsedCommentIdsJson={nativeBridge.collapsedCommentIdsJson}
694+ contentResetKey={`${reviewCache.threadKey}:${selectedSection.id}`}
695+ contentWidth={NATIVE_REVIEW_DIFF_CONTENT_WIDTH}
696+ nativeViewRef={nativeReviewDiffViewRef}
697+ rowHeight={nativeReviewDiffStyle.rowHeight}
698+ rowsJson={nativeBridge.rowsJson}
699+ selectedRowIdsJson={nativeBridge.selectedRowIdsJson}
700+ styleJson={nativeBridge.styleJson}
701+ themeJson={nativeBridge.themeJson}
702+ tokensPatchJson={nativeBridge.tokensPatchJson}
703+ tokensResetKey={nativeBridge.tokensResetKey}
704+ viewedFileIdsJson={nativeBridge.viewedFileIdsJson}
705+ onDebug={nativeBridge.onDebug}
706+ onPressLine={commentSelection.onPressLine}
707+ onVisibleFileChange={handleVisibleFileChange}
708+ onToggleComment={nativeBridge.onToggleComment}
709+ onToggleFile={handleNativeToggleFile}
710+ onToggleViewedFile={handleNativeToggleViewedFile}
711+ />
712+ </View>
417713 </View>
418714 </View>
419715 ) : (
@@ -432,7 +728,7 @@ export function ReviewSheet() { · 12 unmodified lines
432728 {!selectedSection ? (
433729 <View className="border-b border-border bg-card px-4 py-5">
434730 <Text className="text-sm font-t3-bold text-foreground">No review diffs</Text>
435− <Text className="text-xs leading-[18px] text-foreground-muted">
731+ <Text className="text-xs leading-normal text-foreground-muted">
436732 This thread has no ready turn diffs and the worktree diff is empty.
437733 </Text>
438734 </View>
@@ -444,17 +740,17 @@ export function ReviewSheet() { · 5 unmodified lines
444740 ) : parsedDiff.kind === "empty" ? (
445741 <View className="border-b border-border bg-card px-4 py-5">
446742 <Text className="text-sm font-t3-bold text-foreground">No changes</Text>
447− <Text className="text-xs leading-[18px] text-foreground-muted">
743+ <Text className="text-xs leading-normal text-foreground-muted">
448744 {selectedSection.subtitle ?? "This diff is empty."}
449745 </Text>
450746 </View>
451747 ) : parsedDiff.kind === "raw" ? (
452748 <View className="gap-3 border-b border-border bg-card px-4 py-4">
453− <Text className="text-xs leading-[18px] text-foreground-muted">
749+ <Text className="text-xs leading-normal text-foreground-muted">
454750 {parsedDiff.reason}
455751 </Text>
456752 <ScrollView horizontal showsHorizontalScrollIndicator={false} bounces={false}>
457− <Text selectable className="font-mono text-xs leading-[19px] text-foreground">
753+ <Text selectable className="font-mono text-xs leading-relaxed text-foreground">
458754 {parsedDiff.text}
459755 </Text>
460756 </ScrollView>
apps/mobile/src/features/review/nativeReviewDiffAdapter.test.ts+56−0
@@ -0,0 +1,56 @@
1+import { describe, expect, it } from "vite-plus/test";
2+
3+import {
4+ getCachedNativeReviewDiffData,
5+ type BuildNativeReviewDiffDataInput,
6+} from "./nativeReviewDiffAdapter";
7+import type { ReviewInlineComment } from "./reviewCommentSelection";
8+import { buildReviewParsedDiff } from "./reviewModel";
9+
10+const parsedDiff = buildReviewParsedDiff(
11+ [
12+ "diff --git a/example.ts b/example.ts",
13+ "--- a/example.ts",
14+ "+++ b/example.ts",
15+ "@@ -1 +1 @@",
16+ "-const before = 1;",
17+ "+const after = 2;",
18+ ].join("\n"),
19+ "native-review-cache-test",
20+);
21+
22+function makeComment(text: string): ReviewInlineComment {
23+ return {
24+ id: "comment-1",
25+ sectionId: "git:working-tree",
26+ sectionTitle: "Dirty worktree",
27+ filePath: "example.ts",
28+ startIndex: 0,
29+ endIndex: 0,
30+ rangeLabel: "-1",
31+ text,
32+ diff: "@@ -1,1 +1,0 @@\n-const before = 1;",
33+ };
34+}
35+
36+function buildInput(comments: BuildNativeReviewDiffDataInput["comments"]) {
37+ return { parsedDiff, comments } satisfies BuildNativeReviewDiffDataInput;
38+}
39+
40+describe("getCachedNativeReviewDiffData", () => {
41+ it("reuses the row model for equivalent empty comment arrays", () => {
42+ const first = getCachedNativeReviewDiffData(buildInput([]));
43+ const second = getCachedNativeReviewDiffData(buildInput([]));
44+
45+ expect(second).toBe(first);
46+ });
47+
48+ it("reuses equivalent comment contents and invalidates changed comments", () => {
49+ const first = getCachedNativeReviewDiffData(buildInput([makeComment("First")]));
50+ const equivalent = getCachedNativeReviewDiffData(buildInput([makeComment("First")]));
51+ const changed = getCachedNativeReviewDiffData(buildInput([makeComment("Changed")]));
52+
53+ expect(equivalent).toBe(first);
54+ expect(changed).not.toBe(first);
55+ });
56+});
apps/mobile/src/features/review/nativeReviewDiffAdapter.ts+98−36
@@ -5,6 +5,8 @@ import type { · 4 unmodified lines
55 } from "../diffs/nativeReviewDiffTypes";
66 import * as Arr from "effect/Array";
77 import { pipe } from "effect/Function";
8+import type { ResolvedMobileCodeSurface } from "../../lib/appearancePreferences";
9+import { resolveMobileCodeSurface } from "../../lib/appearancePreferences";
810 import { MOBILE_CODE_SURFACE } from "../../lib/typography";
911 import { getPierreTerminalTheme, type TerminalAppearanceScheme } from "../terminal/terminalTheme";
1012 import { computeWordAltDiffRanges } from "./reviewWordDiffs";
@@ -22,38 +24,44 @@ const NATIVE_REVIEW_MAX_WORD_DIFF_COVERAGE = 0.45; · 11 unmodified lines
2224 export const NATIVE_REVIEW_DIFF_ROW_HEIGHT = MOBILE_CODE_SURFACE.rowHeight;
2325 export const NATIVE_REVIEW_DIFF_CONTENT_WIDTH = 2_800;
2426
25−export const NATIVE_REVIEW_DIFF_STYLE = {
26− rowHeight: NATIVE_REVIEW_DIFF_ROW_HEIGHT,
27− contentWidth: NATIVE_REVIEW_DIFF_CONTENT_WIDTH,
28− changeBarWidth: 4,
29− gutterWidth: MOBILE_CODE_SURFACE.gutterWidth,
30− codePadding: MOBILE_CODE_SURFACE.codePadding,
31− textVerticalInset: MOBILE_CODE_SURFACE.textVerticalInset,
32− fileHeaderHeight: 56,
33− fileHeaderHorizontalMargin: 8,
34− fileHeaderVerticalMargin: 6,
35− fileHeaderCornerRadius: 10,
36− fileHeaderHorizontalPadding: 10,
37− fileHeaderPathRightPadding: 118,
38− fileHeaderCountColumnWidth: 38,
39− fileHeaderCountGap: 5,
40− codeFontSize: MOBILE_CODE_SURFACE.fontSize,
41− codeFontWeight: "regular",
42− lineNumberFontSize: MOBILE_CODE_SURFACE.lineNumberFontSize,
43− lineNumberFontWeight: "regular",
44− hunkFontSize: 11,
45− hunkFontWeight: "medium",
46− fileHeaderFontSize: 11,
47− fileHeaderFontWeight: "semibold",
48− fileHeaderMetaFontSize: 10,
49− fileHeaderMetaFontWeight: "semibold",
50− fileHeaderSubtextFontSize: 11,
51− fileHeaderSubtextFontWeight: "medium",
52− fileHeaderStatusFontSize: 9,
53− fileHeaderStatusFontWeight: "bold",
54− emptyStateFontSize: 12,
55− emptyStateFontWeight: "medium",
56−} as const;
27+export const NATIVE_REVIEW_DIFF_STYLE = createNativeReviewDiffStyle(
28+ resolveMobileCodeSurface(MOBILE_CODE_SURFACE.fontSize),
29+);
30+
31+export function createNativeReviewDiffStyle(codeSurface: ResolvedMobileCodeSurface) {
32+ return {
33+ rowHeight: codeSurface.rowHeight,
34+ contentWidth: NATIVE_REVIEW_DIFF_CONTENT_WIDTH,
35+ changeBarWidth: 4,
36+ gutterWidth: codeSurface.gutterWidth,
37+ codePadding: codeSurface.codePadding,
38+ textVerticalInset: codeSurface.textVerticalInset,
39+ fileHeaderHeight: 56,
40+ fileHeaderHorizontalMargin: 8,
41+ fileHeaderVerticalMargin: 6,
42+ fileHeaderCornerRadius: 10,
43+ fileHeaderHorizontalPadding: 10,
44+ fileHeaderPathRightPadding: 118,
45+ fileHeaderCountColumnWidth: 38,
46+ fileHeaderCountGap: 5,
47+ codeFontSize: codeSurface.fontSize,
48+ codeFontWeight: "regular",
49+ lineNumberFontSize: codeSurface.lineNumberFontSize,
50+ lineNumberFontWeight: "regular",
51+ hunkFontSize: 11,
52+ hunkFontWeight: "medium",
53+ fileHeaderFontSize: 11,
54+ fileHeaderFontWeight: "semibold",
55+ fileHeaderMetaFontSize: 10,
56+ fileHeaderMetaFontWeight: "semibold",
57+ fileHeaderSubtextFontSize: 11,
58+ fileHeaderSubtextFontWeight: "medium",
59+ fileHeaderStatusFontSize: 9,
60+ fileHeaderStatusFontWeight: "bold",
61+ emptyStateFontSize: 12,
62+ emptyStateFontWeight: "medium",
63+ } as const;
64+}
5765
5866 export interface NativeReviewDiffData {
5967 readonly rows: ReadonlyArray<NativeReviewDiffRow>;
@@ -75,6 +83,33 @@ export interface BuildNativeReviewDiffDataInput { · 15 unmodified lines
7583 readonly comments?: ReadonlyArray<ReviewInlineComment>;
7684 }
7785
86+interface CachedNativeReviewDiffData {
87+ readonly commentsKey: string;
88+ readonly data: NativeReviewDiffData;
89+}
90+
91+const nativeReviewDiffDataCache = new WeakMap<ReviewParsedDiff, CachedNativeReviewDiffData>();
92+
93+function buildReviewCommentsCacheKey(comments: ReadonlyArray<ReviewInlineComment>): string {
94+ if (comments.length === 0) {
95+ return "none";
96+ }
97+
98+ return comments
99+ .map((comment) =>
100+ [
101+ comment.id,
102+ comment.sectionId,
103+ comment.filePath,
104+ comment.startIndex,
105+ comment.endIndex,
106+ comment.rangeLabel,
107+ comment.text,
108+ ].join("\u001f"),
109+ )
110+ .join("\u001e");
111+}
112+
78113 export function createNativeReviewDiffTheme(
79114 scheme: TerminalAppearanceScheme,
80115 ): NativeReviewDiffTheme {
@@ -83,10 +118,12 @@ export function createNativeReviewDiffTheme( · 2 unmodified lines
83118
84119 if (scheme === "dark") {
85120 return {
86− background: terminalTheme.background,
121+ // Match the app surface (--color-sheet) so code views blend with the rest of
122+ // the app instead of using a distinct code-editor background.
123+ background: "#0e0e0e",
87124 text: terminalTheme.foreground,
88125 mutedText: terminalTheme.mutedForeground,
89− headerBackground: terminalTheme.background,
126+ headerBackground: "#0e0e0e",
90127 border: terminalTheme.border,
91128 hunkBackground: "#071f28",
92129 hunkText: terminalBlue ?? "#009fff",
@@ -100,10 +137,12 @@ export function createNativeReviewDiffTheme( · 7 unmodified lines
100137 }
101138
102139 return {
103− background: "#ffffff",
140+ // Match the app surface (--color-sheet) so code views blend with the rest of the
141+ // app instead of using a distinct code-editor background.
142+ background: "#f2f2f7",
104143 text: "#070707",
105144 mutedText: terminalTheme.mutedForeground,
106− headerBackground: "#ffffff",
145+ headerBackground: "#f2f2f7",
107146 border: terminalTheme.border,
108147 hunkBackground: "#e0f2ff",
109148 hunkText: terminalBlue ?? "#009fff",
@@ -430,3 +469,26 @@ export function buildNativeReviewDiffData( · 320 unmodified lines
430469 deletions: parsedDiff.deletions,
431470 };
432471 }
472+
473+/**
474+ * Reuses the expensive flattened native row model across React development
475+ * render probes and unrelated draft updates. Only the latest comment version
476+ * is retained for each parsed diff so editing a comment cannot grow the cache.
477+ */
478+export function getCachedNativeReviewDiffData(
479+ input: BuildNativeReviewDiffDataInput,
480+): NativeReviewDiffData {
481+ const comments = input.comments ?? [];
482+ const commentsKey = buildReviewCommentsCacheKey(comments);
483+ const cached = nativeReviewDiffDataCache.get(input.parsedDiff);
484+ if (cached?.commentsKey === commentsKey) {
485+ return cached.data;
486+ }
487+
488+ const data = buildNativeReviewDiffData({
489+ parsedDiff: input.parsedDiff,
490+ comments,
491+ });
492+ nativeReviewDiffDataCache.set(input.parsedDiff, { commentsKey, data });
493+ return data;
494+}
apps/mobile/src/features/review/review-section-menu.test.ts+40−0
@@ -0,0 +1,40 @@
1+import { describe, expect, it } from "vite-plus/test";
2+
3+import type { ReviewSectionItem, ReviewSectionKind } from "./reviewModel";
4+import { buildReviewSectionMenu } from "./review-section-menu";
5+
6+function section(id: string, kind: ReviewSectionKind): ReviewSectionItem {
7+ return {
8+ id,
9+ kind,
10+ title: id,
11+ subtitle: null,
12+ diff: null,
13+ isLoading: false,
14+ };
15+}
16+
17+describe("buildReviewSectionMenu", () => {
18+ it("exposes git scopes and the latest turn at the top level", () => {
19+ const turn28 = section("turn:28", "turn");
20+ const turn27 = section("turn:27", "turn");
21+ const workingTree = section("git:working-tree", "working-tree");
22+ const branchChanges = section("git:branch-range", "branch-range");
23+
24+ expect(buildReviewSectionMenu([turn28, turn27, workingTree, branchChanges])).toEqual({
25+ workingTree,
26+ branchChanges,
27+ latestTurn: turn28,
28+ turns: [turn28, turn27],
29+ });
30+ });
31+
32+ it("keeps unavailable scopes empty while data loads", () => {
33+ expect(buildReviewSectionMenu([])).toEqual({
34+ workingTree: null,
35+ branchChanges: null,
36+ latestTurn: null,
37+ turns: [],
38+ });
39+ });
40+});
apps/mobile/src/features/review/review-section-menu.ts+21−0
@@ -0,0 +1,21 @@
1+import type { ReviewSectionItem } from "./reviewModel";
2+
3+export interface ReviewSectionMenu {
4+ readonly workingTree: ReviewSectionItem | null;
5+ readonly branchChanges: ReviewSectionItem | null;
6+ readonly latestTurn: ReviewSectionItem | null;
7+ readonly turns: ReadonlyArray<ReviewSectionItem>;
8+}
9+
10+export function buildReviewSectionMenu(
11+ sections: ReadonlyArray<ReviewSectionItem>,
12+): ReviewSectionMenu {
13+ const turns = sections.filter((section) => section.kind === "turn");
14+
15+ return {
16+ workingTree: sections.find((section) => section.kind === "working-tree") ?? null,
17+ branchChanges: sections.find((section) => section.kind === "branch-range") ?? null,
18+ latestTurn: turns[0] ?? null,
19+ turns,
20+ };
21+}
apps/mobile/src/features/review/reviewDiffBridgeKeys.test.tsrenamed from apps/mobile/src/features/review/useNativeReviewDiffBridge.test.ts+1−1
@@ -1,6 +1,6 @@
11 import { describe, expect, it } from "vite-plus/test";
22
3−import { buildNativeReviewTokensResetKey, hashReviewDiffKey } from "./useNativeReviewDiffBridge";
3+import { buildNativeReviewTokensResetKey, hashReviewDiffKey } from "./reviewDiffBridgeKeys";
44
55 describe("native review diff bridge", () => {
66 it("builds stable reset keys from the rendered diff identity", () => {
apps/mobile/src/features/review/reviewDiffBridgeKeys.ts+35−0
@@ -0,0 +1,35 @@
1+import type { NativeReviewDiffHighlightScheme } from "../diffs/nativeReviewDiffHighlighter";
2+
3+// Pure key-derivation helpers for the native review diff bridge. Kept free of
4+// react-native / hook imports so they stay unit-testable in node.
5+
6+export function hashReviewDiffKey(diff: string | null | undefined): string {
7+ if (!diff) {
8+ return "empty";
9+ }
10+
11+ let hash = 5381;
12+ for (let index = 0; index < diff.length; index += 1) {
13+ hash = (hash * 33) ^ diff.charCodeAt(index);
14+ }
15+
16+ return `${diff.length}:${(hash >>> 0).toString(36)}`;
17+}
18+
19+export function buildNativeReviewTokensResetKey(input: {
20+ readonly threadKey: string | null;
21+ readonly sectionId: string | null;
22+ readonly scheme: NativeReviewDiffHighlightScheme;
23+ readonly diff: string | null | undefined;
24+ readonly fileCount: number;
25+ readonly rowCount: number;
26+}): string {
27+ return [
28+ input.threadKey ?? "none",
29+ input.sectionId ?? "none",
30+ input.scheme,
31+ hashReviewDiffKey(input.diff),
32+ input.fileCount,
33+ input.rowCount,
34+ ].join(":");
35+}
apps/mobile/src/features/review/reviewDiffRendering.tsx+16−9
@@ -13,7 +13,6 @@ export const REVIEW_MONO_FONT_FAMILY = Platform.select({ · 12 unmodified lines
1313 });
1414
1515 export const REVIEW_DIFF_LINE_HEIGHT = MOBILE_CODE_SURFACE.rowHeight;
16−const REVIEW_DELETE_STRIPE_COUNT = REVIEW_DIFF_LINE_HEIGHT / 2;
1716
1817 export function renderVisibleWhitespace(value: string): string {
1918 const expandedTabs = value.replace(/\t/g, " ");
@@ -38,12 +37,16 @@ function diffHighlightColor(change: ReviewRenderableLineRow["change"]): string | · 18 unmodified lines
3837 return undefined;
3938 }
4039
41−export function ReviewChangeBar(props: { readonly change: ReviewRenderableLineRow["change"] }) {
40+export function ReviewChangeBar(props: {
41+ readonly change: ReviewRenderableLineRow["change"];
42+ readonly height?: number;
43+}) {
44+ const height = props.height ?? REVIEW_DIFF_LINE_HEIGHT;
4245 if (props.change === "delete") {
4346 return (
44− <View className="w-[5px] overflow-hidden" style={{ height: REVIEW_DIFF_LINE_HEIGHT }}>
47+ <View className="w-[5px] overflow-hidden" style={{ height }}>
4548 <View>
46− {Array.from({ length: REVIEW_DELETE_STRIPE_COUNT }, (_, index) => (
49+ {Array.from({ length: Math.ceil(height / 2) }, (_, index) => (
4750 <View key={index}>
4851 <View className="w-[5px] bg-rose-400" style={{ height: 1 }} />
4952 <View style={{ height: 1 }} />
@@ -55,7 +58,7 @@ export function ReviewChangeBar(props: { readonly change: ReviewRenderableLineRo · 5 unmodified lines
5558 }
5659
5760 return (
58− <View className="w-[5px] overflow-hidden" style={{ height: REVIEW_DIFF_LINE_HEIGHT }}>
61+ <View className="w-[5px] overflow-hidden" style={{ height }}>
5962 <View className={cn("h-full w-[5px] flex-1", changeBarTone(props.change))} />
6063 </View>
6164 );
@@ -66,7 +69,11 @@ export function DiffTokenText(props: { · 4 unmodified lines
6669 readonly fallback: string;
6770 readonly change?: ReviewRenderableLineRow["change"];
6871 readonly className?: string;
72+ readonly fontSize?: number;
73+ readonly lineHeight?: number;
6974 }) {
75+ const fontSize = props.fontSize ?? MOBILE_CODE_SURFACE.fontSize;
76+ const lineHeight = props.lineHeight ?? MOBILE_CODE_SURFACE.rowHeight;
7077 if (!props.tokens || props.tokens.length === 0) {
7178 return (
7279 <NativeText
@@ -75,8 +82,8 @@ export function DiffTokenText(props: { · 2 unmodified lines
7582 className={cn("font-normal text-foreground", props.className)}
7683 style={{
7784 fontFamily: REVIEW_MONO_FONT_FAMILY,
78− fontSize: MOBILE_CODE_SURFACE.fontSize,
79− lineHeight: MOBILE_CODE_SURFACE.rowHeight,
85+ fontSize,
86+ lineHeight,
8087 }}
8188 >
8289 {renderVisibleWhitespace(props.fallback || " ")}
@@ -91,8 +98,8 @@ export function DiffTokenText(props: { · 8 unmodified lines
9198 className={cn("font-normal text-foreground", props.className)}
9299 style={{
93100 fontFamily: REVIEW_MONO_FONT_FAMILY,
94− fontSize: MOBILE_CODE_SURFACE.fontSize,
95− lineHeight: MOBILE_CODE_SURFACE.rowHeight,
101+ fontSize,
102+ lineHeight,
96103 }}
97104 >
98105 {(() => {
apps/mobile/src/features/review/reviewPaneSelection.test.ts+35−0
@@ -0,0 +1,35 @@
1+import { describe, expect, it } from "vite-plus/test";
2+
3+import { resolveSelectedReviewFileId } from "./reviewPaneSelection";
4+
5+describe("resolveSelectedReviewFileId", () => {
6+ it("keeps a visible file selected within the active section", () => {
7+ expect(
8+ resolveSelectedReviewFileId({
9+ selection: { sectionId: "worktree", fileId: "second" },
10+ sectionId: "worktree",
11+ availableFileIds: ["first", "second"],
12+ }),
13+ ).toBe("second");
14+ });
15+
16+ it("clears selection when the review section changes", () => {
17+ expect(
18+ resolveSelectedReviewFileId({
19+ selection: { sectionId: "turn-1", fileId: "first" },
20+ sectionId: "turn-2",
21+ availableFileIds: ["first"],
22+ }),
23+ ).toBeNull();
24+ });
25+
26+ it("clears a file that no longer exists in the diff", () => {
27+ expect(
28+ resolveSelectedReviewFileId({
29+ selection: { sectionId: "worktree", fileId: "removed" },
30+ sectionId: "worktree",
31+ availableFileIds: ["first", "second"],
32+ }),
33+ ).toBeNull();
34+ });
35+});
apps/mobile/src/features/review/reviewPaneSelection.ts+16−0
@@ -0,0 +1,16 @@
1+export interface ReviewPaneFileSelection {
2+ readonly sectionId: string | null;
3+ readonly fileId: string | null;
4+}
5+
6+export function resolveSelectedReviewFileId(input: {
7+ readonly selection: ReviewPaneFileSelection;
8+ readonly sectionId: string | null;
9+ readonly availableFileIds: ReadonlyArray<string>;
10+}): string | null {
11+ if (input.selection.sectionId !== input.sectionId || input.selection.fileId === null) {
12+ return null;
13+ }
14+
15+ return input.availableFileIds.includes(input.selection.fileId) ? input.selection.fileId : null;
16+}
apps/mobile/src/features/review/useNativeReviewDiffBridge.ts+6−36
@@ -2,43 +2,12 @@ import { useCallback, useMemo, useState } from "react"; · 1 unmodified line
22 import type { NativeSyntheticEvent } from "react-native";
33
44 import { type NativeReviewDiffHighlightScheme } from "../diffs/nativeReviewDiffHighlighter";
5−import {
6− createNativeReviewDiffTheme,
7− NATIVE_REVIEW_DIFF_STYLE,
8− type NativeReviewDiffData,
9−} from "./nativeReviewDiffAdapter";
5+import { createNativeReviewDiffTheme, type NativeReviewDiffData } from "./nativeReviewDiffAdapter";
6+import { useAppearanceCodeSurface } from "../settings/appearance/useAppearanceCodeSurface";
107 import { useNativeReviewDiffHighlighting } from "./useNativeReviewDiffHighlighting";
8+import { buildNativeReviewTokensResetKey } from "./reviewDiffBridgeKeys";
119
12−export function hashReviewDiffKey(diff: string | null | undefined): string {
13− if (!diff) {
14− return "empty";
15− }
16−
17− let hash = 5381;
18− for (let index = 0; index < diff.length; index += 1) {
19− hash = (hash * 33) ^ diff.charCodeAt(index);
20− }
21−
22− return `${diff.length}:${(hash >>> 0).toString(36)}`;
23−}
24−
25−export function buildNativeReviewTokensResetKey(input: {
26− readonly threadKey: string | null;
27− readonly sectionId: string | null;
28− readonly scheme: NativeReviewDiffHighlightScheme;
29− readonly diff: string | null | undefined;
30− readonly fileCount: number;
31− readonly rowCount: number;
32−}): string {
33− return [
34− input.threadKey ?? "none",
35− input.sectionId ?? "none",
36− input.scheme,
37− hashReviewDiffKey(input.diff),
38− input.fileCount,
39− input.rowCount,
40− ].join(":");
41−}
10+export { buildNativeReviewTokensResetKey, hashReviewDiffKey } from "./reviewDiffBridgeKeys";
4211
4312 export function useNativeReviewDiffBridge(input: {
4413 readonly threadKey: string | null;
@@ -62,6 +31,7 @@ export function useNativeReviewDiffBridge(input: { · 17 unmodified lines
6231 threadKey,
6332 viewedFileIds,
6433 } = input;
34+ const { nativeReviewDiffStyle } = useAppearanceCodeSurface();
6535 const [collapsedCommentIds, setCollapsedCommentIds] = useState<ReadonlySet<string>>(
6636 () => new Set(),
6737 );
@@ -76,7 +46,7 @@ export function useNativeReviewDiffBridge(input: { · 8 unmodified lines
7646 [collapsedCommentIds],
7747 );
7848 const themeJson = useMemo(() => JSON.stringify(theme), [theme]);
79− const styleJson = useMemo(() => JSON.stringify(NATIVE_REVIEW_DIFF_STYLE), []);
49+ const styleJson = useMemo(() => JSON.stringify(nativeReviewDiffStyle), [nativeReviewDiffStyle]);
8050 const tokensResetKey = useMemo(
8151 () =>
8252 buildNativeReviewTokensResetKey({
apps/mobile/src/features/review/useReviewCommentSelectionController.ts+6−7
@@ -1,12 +1,11 @@
11 import { useCallback, useEffect, useMemo, useState } from "react";
22 import type { NativeSyntheticEvent } from "react-native";
3−import { useRouter } from "expo-router";
3+import { useNavigation } from "@react-navigation/native";
44 import * as Arr from "effect/Array";
55 import { pipe } from "effect/Function";
66 import * as Result from "effect/Result";
77
88 import type { EnvironmentId, ThreadId } from "@t3tools/contracts";
9−
109 import {
1110 buildReviewCommentTarget,
1211 clearReviewCommentTarget,
@@ -34,7 +33,7 @@ export function useReviewCommentSelectionController(input: { · 21 unmodified lines
3433 readonly nativeReviewDiffData: NativeReviewDiffData;
3534 }) {
3635 const { environmentId, nativeReviewDiffData, selectedSection, threadId } = input;
37− const { push } = useRouter();
36+ const navigation = useNavigation();
3837 const activeCommentTarget = useReviewCommentTarget();
3938 const [pendingNativeCommentSelection, setPendingNativeCommentSelection] =
4039 useState<PendingNativeCommentSelection | null>(null);
@@ -44,11 +43,11 @@ export function useReviewCommentSelectionController(input: { · 3 unmodified lines
4443 return;
4544 }
4645
47− push({
48− pathname: "/threads/[environmentId]/[threadId]/review-comment",
49− params: { environmentId, threadId },
46+ navigation.navigate("ThreadReviewComment", {
47+ environmentId,
48+ threadId,
5049 });
51− }, [environmentId, push, threadId]);
50+ }, [environmentId, navigation, threadId]);
5251
5352 const selectedRowIds = useMemo(() => {
5453 if (
apps/mobile/src/features/review/useReviewDiffData.ts+11−9
@@ -1,11 +1,13 @@
11 import { useEffect, useMemo } from "react";
22
33 import { countReviewCommentContexts, parseReviewInlineComments } from "./reviewCommentSelection";
4−import { buildNativeReviewDiffData } from "./nativeReviewDiffAdapter";
4+import { getCachedNativeReviewDiffData } from "./nativeReviewDiffAdapter";
55 import { markReviewEvent, measureReviewWork } from "./reviewPerf";
66 import { getCachedReviewParsedDiff } from "./reviewState";
77 import type { ReviewParsedDiff, ReviewSectionItem } from "./reviewModel";
88
9+const EMPTY_INLINE_REVIEW_COMMENTS = Object.freeze([]);
10+
911 function isReviewDiffDebugLoggingEnabled(): boolean {
1012 return typeof __DEV__ !== "undefined" ? __DEV__ : false;
1113 }
@@ -43,6 +45,7 @@ export function useReviewDiffData(input: { · 31 unmodified lines
4345 readonly draftMessage: string;
4446 }) {
4547 const { draftMessage, selectedSection, threadKey } = input;
48+ const selectedSectionId = selectedSection?.id ?? null;
4649 const parsedDiff = useMemo(
4750 () =>
4851 measureReviewWork("parse-diff", () =>
@@ -59,17 +62,16 @@ export function useReviewDiffData(input: { · 10 unmodified lines
5962 () => parseReviewInlineComments(draftMessage),
6063 [draftMessage],
6164 );
62− const selectedSectionInlineComments = useMemo(
63− () =>
64− selectedSection
65− ? inlineReviewComments.filter((comment) => comment.sectionId === selectedSection.id)
66− : [],
67− [inlineReviewComments, selectedSection],
68− );
65+ const selectedSectionInlineComments = useMemo(() => {
66+ if (!selectedSectionId || inlineReviewComments.length === 0) {
67+ return EMPTY_INLINE_REVIEW_COMMENTS;
68+ }
69+ return inlineReviewComments.filter((comment) => comment.sectionId === selectedSectionId);
70+ }, [inlineReviewComments, selectedSectionId]);
6971 const nativeReviewDiffData = useMemo(
7072 () =>
7173 measureReviewWork("build-native-diff-data", () =>
72− buildNativeReviewDiffData({
74+ getCachedNativeReviewDiffData({
7375 parsedDiff,
7476 comments: selectedSectionInlineComments,
7577 }),
apps/mobile/src/features/review/useReviewDiffPrewarming.ts+101−0
@@ -0,0 +1,101 @@
1+import { useEffect } from "react";
2+
3+import { getCachedNativeReviewDiffData } from "./nativeReviewDiffAdapter";
4+import type { ReviewSectionItem } from "./reviewModel";
5+import { getCachedReviewParsedDiff } from "./reviewState";
6+
7+interface IdleDeadlineLike {
8+ readonly didTimeout: boolean;
9+ timeRemaining(): number;
10+}
11+
12+type IdleCallback = (deadline: IdleDeadlineLike) => void;
13+
14+function scheduleIdle(callback: IdleCallback): number {
15+ if (typeof globalThis.requestIdleCallback === "function") {
16+ return globalThis.requestIdleCallback(callback, { timeout: 2_000 });
17+ }
18+
19+ return setTimeout(
20+ () => callback({ didTimeout: true, timeRemaining: () => 0 }),
21+ 100,
22+ ) as unknown as number;
23+}
24+
25+function cancelIdle(handle: number): void {
26+ if (typeof globalThis.cancelIdleCallback === "function") {
27+ globalThis.cancelIdleCallback(handle);
28+ return;
29+ }
30+ clearTimeout(handle);
31+}
32+
33+export function prewarmReviewDiffSection(input: {
34+ readonly threadKey: string;
35+ readonly section: ReviewSectionItem;
36+}): void {
37+ const { section, threadKey } = input;
38+ if (section.diff === null) {
39+ return;
40+ }
41+
42+ const parsedDiff = getCachedReviewParsedDiff({
43+ threadKey,
44+ sectionId: section.id,
45+ diff: section.diff,
46+ });
47+ getCachedNativeReviewDiffData({ parsedDiff, comments: [] });
48+}
49+
50+/** Warms one cached section per idle period, after navigation animations finish. */
51+export function useReviewDiffPrewarming(input: {
52+ readonly threadKey: string | null;
53+ readonly sections: ReadonlyArray<ReviewSectionItem>;
54+ readonly selectedSectionId: string | null;
55+}): void {
56+ const { sections, selectedSectionId, threadKey } = input;
57+
58+ useEffect(() => {
59+ if (!threadKey) {
60+ return;
61+ }
62+
63+ const pendingSections = sections.filter(
64+ (section) => section.id !== selectedSectionId && section.diff !== null,
65+ );
66+ if (pendingSections.length === 0) {
67+ return;
68+ }
69+
70+ let cancelled = false;
71+ let idleHandle: number | null = null;
72+ let nextSectionIndex = 0;
73+
74+ const scheduleNext = () => {
75+ idleHandle = scheduleIdle(() => {
76+ if (cancelled) {
77+ return;
78+ }
79+
80+ const section = pendingSections[nextSectionIndex];
81+ if (!section) {
82+ return;
83+ }
84+ nextSectionIndex += 1;
85+ prewarmReviewDiffSection({ threadKey, section });
86+
87+ if (nextSectionIndex < pendingSections.length) {
88+ scheduleNext();
89+ }
90+ });
91+ };
92+
93+ scheduleNext();
94+ return () => {
95+ cancelled = true;
96+ if (idleHandle !== null) {
97+ cancelIdle(idleHandle);
98+ }
99+ };
100+ }, [sections, selectedSectionId, threadKey]);
101+}
apps/mobile/src/features/settings/SettingsAppearanceRouteScreen.tsx+30−0
@@ -0,0 +1,30 @@
1+import { ScrollView, View } from "react-native";
2+import { useSafeAreaInsets } from "react-native-safe-area-context";
3+
4+import { CodeAppearanceSection } from "./appearance/sections/CodeAppearanceSection";
5+import { TerminalAppearanceSection } from "./appearance/sections/TerminalAppearanceSection";
6+import { TextAppearanceSection } from "./appearance/sections/TextAppearanceSection";
7+
8+export function SettingsAppearanceRouteScreen() {
9+ const insets = useSafeAreaInsets();
10+
11+ return (
12+ <View collapsable={false} className="flex-1 bg-sheet">
13+ <ScrollView
14+ contentInsetAdjustmentBehavior="automatic"
15+ showsVerticalScrollIndicator={false}
16+ style={{ flex: 1 }}
17+ contentContainerStyle={{
18+ gap: 24,
19+ paddingBottom: Math.max(insets.bottom, 18) + 18,
20+ paddingHorizontal: 20,
21+ paddingTop: 16,
22+ }}
23+ >
24+ <TextAppearanceSection />
25+ <TerminalAppearanceSection />
26+ <CodeAppearanceSection />
27+ </ScrollView>
28+ </View>
29+ );
30+}
apps/mobile/src/features/settings/SettingsAuthRouteScreen.tsx+39−0
@@ -0,0 +1,39 @@
1+import { useAuth } from "@clerk/expo";
2+import { AuthView, UserProfileView } from "@clerk/expo/native";
3+import { StackActions, useNavigation } from "@react-navigation/native";
4+import { NativeStackScreenOptions } from "../../native/StackHeader";
5+import { useEffect } from "react";
6+import { View } from "react-native";
7+
8+import { hasCloudPublicConfig } from "../cloud/publicConfig";
9+
10+export function SettingsAuthRouteScreen() {
11+ const navigation = useNavigation();
12+
13+ useEffect(() => {
14+ if (!hasCloudPublicConfig()) {
15+ navigation.dispatch(StackActions.replace("Settings"));
16+ }
17+ }, [navigation]);
18+
19+ return hasCloudPublicConfig() ? <ConfiguredSettingsAuthRouteScreen /> : null;
20+}
21+
22+function ConfiguredSettingsAuthRouteScreen() {
23+ const { isLoaded, isSignedIn } = useAuth({ treatPendingAsSignedOut: false });
24+
25+ return (
26+ <>
27+ <NativeStackScreenOptions options={{ title: isSignedIn ? "Account" : "Sign in" }} />
28+ <View collapsable={false} className="flex-1 overflow-hidden bg-sheet">
29+ {isLoaded ? (
30+ isSignedIn ? (
31+ <UserProfileView isDismissible={false} />
32+ ) : (
33+ <AuthView isDismissible={false} />
34+ )
35+ ) : null}
36+ </View>
37+ </>
38+ );
39+}
apps/mobile/src/features/settings/SettingsEnvironmentsRouteScreen.tsxrenamed from apps/mobile/src/app/settings/environments.tsx+24−28
@@ -1,5 +1,6 @@
11 import { useAuth } from "@clerk/expo";
2−import { Stack, useRouter } from "expo-router";
2+import { NativeHeaderToolbar } from "../../native/StackHeader";
3+import { useNavigation } from "@react-navigation/native";
34 import { SymbolView } from "expo-symbols";
45 import {
56 connectionStatusText,
@@ -22,26 +23,26 @@ import { AppText as Text } from "../../components/AppText"; · 16 unmodified lines
2223 import {
2324 type RelayEnvironmentView,
2425 useConnectionController,
25−} from "../../features/connection/useConnectionController";
26−import { hasCloudPublicConfig } from "../../features/cloud/publicConfig";
27−import { availableCloudEnvironmentPresentation } from "../../features/cloud/cloudEnvironmentPresentation";
28−import { ConnectionEnvironmentRow } from "../../features/connection/ConnectionEnvironmentRow";
29−import { ConnectionStatusDot } from "../../features/connection/ConnectionStatusDot";
30−import { splitEnvironmentSections } from "../../features/connection/environmentSections";
26+} from "../connection/useConnectionController";
27+import { hasCloudPublicConfig } from "../cloud/publicConfig";
28+import { availableCloudEnvironmentPresentation } from "../cloud/cloudEnvironmentPresentation";
29+import { ConnectionEnvironmentRow } from "../connection/ConnectionEnvironmentRow";
30+import { ConnectionStatusDot } from "../connection/ConnectionStatusDot";
31+import { splitEnvironmentSections } from "../connection/environmentSections";
3132 import { cn } from "../../lib/cn";
3233 import { copyTextWithHaptic } from "../../lib/copyTextWithHaptic";
3334 import { useThemeColor } from "../../lib/useThemeColor";
3435 import type { ConnectedEnvironmentSummary } from "../../state/remote-runtime-types";
3536 import { useRemoteConnections } from "../../state/use-remote-environment-registry";
3637
37−export default function SettingsEnvironmentsRouteScreen() {
38+export function SettingsEnvironmentsRouteScreen() {
3839 const {
3940 connectedEnvironments,
4041 onReconnectEnvironment,
4142 onRemoveEnvironmentPress,
4243 onUpdateEnvironment,
4344 } = useRemoteConnections();
44− const router = useRouter();
45+ const navigation = useNavigation();
4546 const insets = useSafeAreaInsets();
4647 const { localEnvironments, connectedCloudEnvironments } = splitEnvironmentSections({
4748 connectedEnvironments,
@@ -50,6 +51,7 @@ export default function SettingsEnvironmentsRouteScreen() { · 2 unmodified lines
5051 const hasLocalEnvironments = localEnvironments.length > 0;
5152 const [expandedId, setExpandedId] = useState<EnvironmentId | null>(null);
5253 const accentColor = useThemeColor("--color-icon-muted");
54+ const headerIconColor = useThemeColor("--color-icon");
5355
5456 const handleToggle = useCallback((environmentId: EnvironmentId) => {
5557 setExpandedId((prev) => (prev === environmentId ? null : environmentId));
@@ -57,18 +59,14 @@ export default function SettingsEnvironmentsRouteScreen() { · 1 unmodified line
5759
5860 return (
5961 <View collapsable={false} className="flex-1 bg-sheet">
60− <Stack.Screen
61− options={{
62− title: "Environments",
63− }}
64− />
65− <Stack.Toolbar placement="right">
66− <Stack.Toolbar.Button
62+ <NativeHeaderToolbar placement="right">
63+ <NativeHeaderToolbar.Button
6764 icon="plus"
68− onPress={() => router.push("/settings/environment-new")}
65+ onPress={() => navigation.navigate("SettingsSheet", { screen: "SettingsEnvironmentNew" })}
6966 separateBackground
67+ tintColor={headerIconColor}
7068 />
71− </Stack.Toolbar>
69+ </NativeHeaderToolbar>
7270 <ScrollView
7371 contentInsetAdjustmentBehavior="automatic"
7472 showsVerticalScrollIndicator={false}
@@ -111,7 +109,7 @@ export default function SettingsEnvironmentsRouteScreen() { · 36 unmodified lines
111109 type="monochrome"
112110 />
113111 </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">
115113 No environments connected yet.{"\n"}Tap{" "}
116114 <Text className="font-t3-bold text-foreground">+</Text> to add one.
117115 </Text>
@@ -204,7 +202,7 @@ function ConfiguredCloudEnvironmentRows(props: { · 86 unmodified lines
204202 ) : controller.relayDiscovery.isRefreshing ? (
205203 <View collapsable={false} className="items-center gap-3 rounded-[24px] bg-card p-6">
206204 <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">
208206 Loading linked cloud environments.
209207 </Text>
210208 </View>
@@ -213,16 +211,14 @@ function ConfiguredCloudEnvironmentRows(props: { · 2 unmodified lines
213211 <Text className="text-base font-t3-bold text-foreground">
214212 Could not load T3 Cloud environments
215213 </Text>
216− <Text className="text-sm leading-[18px] text-foreground-muted">
217− {controller.relayDiscovery.error}
218− </Text>
214+ <Text className="text-sm text-foreground-muted">{controller.relayDiscovery.error}</Text>
219215 {controller.relayDiscovery.errorTraceId ? (
220216 <CopyTraceIdButton traceId={controller.relayDiscovery.errorTraceId} />
221217 ) : null}
222218 </View>
223219 ) : (
224220 <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">
226222 No additional linked cloud environments.
227223 </Text>
228224 </View>
@@ -361,7 +357,7 @@ function CloudEnvironmentRowShell(props: { · 132 unmodified lines
361357 <View className="min-w-0 flex-row items-center gap-2">
362358 <ConnectionStatusDot state={props.connectionState} pulse={shouldPulse} size={7} />
363359 <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"
365361 numberOfLines={1}
366362 >
367363 {props.label}
@@ -370,7 +366,7 @@ function CloudEnvironmentRowShell(props: { · 2 unmodified lines
370366 {props.connectionError ? (
371367 <Text
372368 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)}
374370 onTextLayout={onMeasuredErrorTextLayout}
375371 style={{ opacity: 0, zIndex: -1 }}
376372 >
@@ -384,7 +380,7 @@ function CloudEnvironmentRowShell(props: { · 7 unmodified lines
384380 className="min-w-0 flex-row items-start gap-1"
385381 >
386382 <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)}
388384 numberOfLines={isErrorExpanded ? undefined : 1}
389385 >
390386 {statusText}
@@ -394,7 +390,7 @@ function CloudEnvironmentRowShell(props: { · 3 unmodified lines
394390 <Text
395391 accessibilityHint="Copies the trace ID"
396392 accessibilityRole="button"
397− className={cn("text-xs leading-[16px] underline", statusClassName)}
393+ className={cn("text-xs underline", statusClassName)}
398394 onLongPress={(event) => {
399395 event.stopPropagation();
400396 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 @@
11 import { useAuth, useUser } from "@clerk/expo";
22 import * as Notifications from "expo-notifications";
3−import { Link, Stack, useRouter } from "expo-router";
3+import { useNavigation } from "@react-navigation/native";
4+import { NativeStackScreenOptions } from "../../native/StackHeader";
45 import { SymbolView } from "expo-symbols";
56 import * as Effect from "effect/Effect";
67 import { useCallback, useEffect, useMemo, useState } from "react";
7−import type { ComponentProps, ReactNode } from "react";
8−import { Alert, Linking, Pressable, ScrollView, Switch, View } from "react-native";
8+import { Alert, Linking, Platform, ScrollView, View } from "react-native";
99 import { useSafeAreaInsets } from "react-native-safe-area-context";
1010
1111 import {
@@ -16,25 +16,51 @@ import { · 4 unmodified lines
1616 squashAtomCommandFailure,
1717 } from "@t3tools/client-runtime/state/runtime";
1818 import { AppText as Text } from "../../components/AppText";
19−import { setLiveActivityUpdatesEnabled } from "../../features/agent-awareness/liveActivityPreferences";
20−import { requestAgentNotificationPermission } from "../../features/agent-awareness/notificationPermissions";
21−import { refreshAgentAwarenessRegistration } from "../../features/agent-awareness/remoteRegistration";
22−import { refreshManagedRelayEnvironments } from "../../features/cloud/managedRelayState";
23−import { useClerkSettingsSheetDetent } from "../../features/cloud/ClerkSettingsSheetDetent";
24−import {
25− hasCloudPublicConfig,
26− resolveRelayClerkTokenOptions,
27−} from "../../features/cloud/publicConfig";
19+import { setLiveActivityUpdatesEnabled } from "../agent-awareness/liveActivityPreferences";
20+import { requestAgentNotificationPermission } from "../agent-awareness/notificationPermissions";
21+import { refreshAgentAwarenessRegistration } from "../agent-awareness/remoteRegistration";
22+import { refreshManagedRelayEnvironments } from "../cloud/managedRelayState";
23+import { useClerkSettingsSheetDetent } from "../cloud/ClerkSettingsSheetDetent";
24+import { hasCloudPublicConfig, resolveRelayClerkTokenOptions } from "../cloud/publicConfig";
25+import { withNativeGlassHeaderItem } from "../layout/native-glass-header-items";
26+import { WorkspaceSidebarToolbar } from "../layout/workspace-sidebar-toolbar";
2827 import { runtime } from "../../lib/runtime";
2928 import { loadPreferences } from "../../lib/storage";
3029 import { useThemeColor } from "../../lib/useThemeColor";
3130 import { useSavedRemoteConnections } from "../../state/use-remote-environment-registry";
31+import { SettingsRow } from "./components/SettingsRow";
32+import { SettingsSection } from "./components/SettingsSection";
33+import { SettingsSwitchRow } from "./components/SettingsSwitchRow";
3234
3335 type NotificationStatus = "checking" | "enabled" | "disabled" | "unsupported";
3436 type LiveActivityStatus = "checking" | "enabled" | "disabled" | "signed-out" | "linking";
3537
36−export default function SettingsRouteScreen() {
37− return hasCloudPublicConfig() ? <ConfiguredSettingsRouteScreen /> : <LocalSettingsRouteScreen />;
38+export function SettingsRouteScreen() {
39+ const navigation = useNavigation();
40+
41+ return (
42+ <>
43+ <WorkspaceSidebarToolbar />
44+ <NativeStackScreenOptions
45+ options={{
46+ unstable_headerRightItems:
47+ Platform.OS === "ios"
48+ ? () => [
49+ withNativeGlassHeaderItem({
50+ accessibilityLabel: "Close settings",
51+ icon: { name: "xmark", type: "sfSymbol" } as const,
52+ identifier: "settings-close",
53+ label: "",
54+ onPress: () => navigation.goBack(),
55+ type: "button",
56+ }),
57+ ]
58+ : undefined,
59+ }}
60+ />
61+ {hasCloudPublicConfig() ? <ConfiguredSettingsRouteScreen /> : <LocalSettingsRouteScreen />}
62+ </>
63+ );
3864 }
3965
4066 function LocalSettingsRouteScreen() {
@@ -44,7 +70,6 @@ function LocalSettingsRouteScreen() { · 3 unmodified lines
4470
4571 return (
4672 <View collapsable={false} className="flex-1 bg-sheet">
47− <Stack.Screen options={{ title: "Settings" }} />
4873 <ScrollView
4974 contentInsetAdjustmentBehavior="automatic"
5075 showsVerticalScrollIndicator={false}
@@ -61,10 +86,14 @@ function LocalSettingsRouteScreen() { · 10 unmodified lines
6186 icon="desktopcomputer"
6287 label="Environments"
6388 value={`${environmentCount}`}
64− href="/settings/environments"
89+ target="SettingsEnvironments"
6590 />
6691 </SettingsSection>
6792
93+ <SettingsSection title="Appearance">
94+ <SettingsRow icon="paintbrush" label="Appearance" target="SettingsAppearance" />
95+ </SettingsSection>
96+
6897 <ArchivedThreadsSettingsSection />
6998
7099 <AppSettingsSection />
@@ -75,7 +104,7 @@ function LocalSettingsRouteScreen() { · 4 unmodified lines
75104
76105 function ConfiguredSettingsRouteScreen() {
77106 const insets = useSafeAreaInsets();
78− const { push } = useRouter();
107+ const navigation = useNavigation();
79108 const { expand: expandClerkSheet } = useClerkSettingsSheetDetent();
80109 const { getToken, isLoaded, isSignedIn } = useAuth({ treatPendingAsSignedOut: false });
81110 const { user } = useUser();
@@ -186,10 +215,13 @@ function ConfiguredSettingsRouteScreen() { · 104 unmodified lines
186215 "Live Activity updates require approved T3 Cloud access so relay can deliver updates to this device.",
187216 [
188217 { text: "Cancel", style: "cancel" },
189− { text: "Continue", onPress: () => push("/settings/waitlist") },
218+ {
219+ text: "Continue",
220+ onPress: () => navigation.navigate("SettingsSheet", { screen: "SettingsWaitlist" }),
221+ },
190222 ],
191223 );
192− }, [push]);
224+ }, [navigation]);
193225
194226 const linkEnvironments = useCallback(async () => {
195227 if (!isSignedIn) {
@@ -316,16 +348,15 @@ function ConfiguredSettingsRouteScreen() { · 120 unmodified lines
316348 const openAccount = useCallback(() => {
317349 if (!isLoaded) return;
318350 if (!isSignedIn) {
319− push("/settings/waitlist");
351+ navigation.navigate("SettingsSheet", { screen: "SettingsWaitlist" });
320352 return;
321353 }
322354 expandClerkSheet();
323− push("/settings/auth");
324− }, [expandClerkSheet, isLoaded, isSignedIn, push]);
355+ navigation.navigate("SettingsSheet", { screen: "SettingsAuth" });
356+ }, [expandClerkSheet, isLoaded, isSignedIn, navigation]);
325357
326358 return (
327359 <View collapsable={false} className="flex-1 bg-sheet">
328− <Stack.Screen options={{ title: "Settings" }} />
329360 <ScrollView
330361 contentInsetAdjustmentBehavior="automatic"
331362 showsVerticalScrollIndicator={false}
@@ -346,7 +377,7 @@ function ConfiguredSettingsRouteScreen() { · 14 unmodified lines
346377 onPress={openAccount}
347378 />
348379 </SettingsSection>
349− <Text className="px-2 text-sm leading-[18px] text-foreground-muted">
380+ <Text className="px-2 text-sm text-foreground-muted">
350381 T3 Code works locally without signing in. Cloud features are optional.
351382 </Text>
352383 </View>
@@ -356,7 +387,7 @@ function ConfiguredSettingsRouteScreen() { · 3 unmodified lines
356387 icon="desktopcomputer"
357388 label="Environments"
358389 value={`${environmentCount}`}
359− href="/settings/environments"
390+ target="SettingsEnvironments"
360391 />
361392 <SettingsSwitchRow
362393 icon="bell.badge"
@@ -376,6 +407,10 @@ function ConfiguredSettingsRouteScreen() { · 13 unmodified lines
376407 />
377408 </SettingsSection>
378409
410+ <SettingsSection title="Appearance">
411+ <SettingsRow icon="paintbrush" label="Appearance" target="SettingsAppearance" />
412+ </SettingsSection>
413+
379414 <ArchivedThreadsSettingsSection />
380415
381416 <AppSettingsSection />
@@ -384,22 +419,6 @@ function ConfiguredSettingsRouteScreen() { · 2 unmodified lines
384419 );
385420 }
386421
387−type SymbolName = ComponentProps<typeof SymbolView>["name"];
388−
389−function SettingsSection(props: { readonly title: string; readonly children: ReactNode }) {
390− return (
391− <View className="gap-2">
392− <Text className="px-2 text-sm font-t3-medium text-foreground-muted">{props.title}</Text>
393− <View
394− className="overflow-hidden rounded-[28px] bg-card"
395− style={{ borderCurve: "continuous" }}
396− >
397− {props.children}
398− </View>
399− </View>
400− );
401−}
402−
403422 function AppSettingsSection() {
404423 const icon = useThemeColor("--color-icon");
405424
@@ -423,93 +442,7 @@ function AppSettingsSection() { · 17 unmodified lines
423442 function ArchivedThreadsSettingsSection() {
424443 return (
425444 <SettingsSection title="Threads">
426− <SettingsRow icon="archivebox" label="Archived Threads" href="/settings/archive" />
445+ <SettingsRow icon="archivebox" label="Archived Threads" target="SettingsArchive" />
427446 </SettingsSection>
428447 );
429448 }
430−
431−function SettingsRow(props: {
432− readonly disabled?: boolean;
433− readonly icon: SymbolName;
434− readonly label: string;
435− readonly value?: string;
436− readonly href?: "/settings/archive" | "/settings/environments";
437− readonly onPress?: () => void;
438−}) {
439− const icon = useThemeColor("--color-icon");
440− const chevron = useThemeColor("--color-chevron");
441− const content = (
442− <View
443− className="flex-row items-center gap-4 p-4"
444− style={{ opacity: props.disabled ? 0.45 : 1 }}
445− >
446− <SymbolView name={props.icon} size={22} tintColor={icon} type="monochrome" weight="regular" />
447− <Text className="shrink-0 text-lg text-foreground" numberOfLines={1}>
448− {props.label}
449− </Text>
450− <View className="min-w-0 flex-1 items-end">
451− {props.value ? (
452− <Text
453− className="max-w-[180px] text-right text-base text-foreground-muted"
454− ellipsizeMode="middle"
455− numberOfLines={1}
456− >
457− {props.value}
458− </Text>
459− ) : null}
460− </View>
461− <SymbolView
462− name="chevron.right"
463− size={16}
464− tintColor={chevron}
465− type="monochrome"
466− weight="semibold"
467− />
468− </View>
469− );
470−
471− if (props.href) {
472− return (
473− <Link href={props.href} asChild>
474− <Pressable accessibilityLabel={props.label} accessibilityRole="button">
475− {content}
476− </Pressable>
477− </Link>
478− );
479− }
480−
481− return (
482− <Pressable accessibilityRole="button" disabled={props.disabled} onPress={props.onPress}>
483− {content}
484− </Pressable>
485− );
486−}
487−
488−function SettingsSwitchRow(props: {
489− readonly disabled?: boolean;
490− readonly icon: SymbolName;
491− readonly label: string;
492− readonly value: boolean;
493− readonly onValueChange: (value: boolean) => void;
494−}) {
495− const icon = useThemeColor("--color-icon");
496− const activeTrack = String(useThemeColor("--color-switch-active"));
497− const track = String(useThemeColor("--color-secondary-border"));
498−
499− return (
500− <View
501− className="flex-row items-center gap-4 p-4"
502− style={{ opacity: props.disabled ? 0.45 : 1 }}
503− >
504− <SymbolView name={props.icon} size={22} tintColor={icon} type="monochrome" weight="regular" />
505− <Text className="flex-1 text-lg text-foreground">{props.label}</Text>
506− <Switch
507− disabled={props.disabled}
508− ios_backgroundColor={track}
509− onValueChange={props.onValueChange}
510− trackColor={{ false: track, true: activeTrack }}
511− value={props.value}
512− />
513− </View>
514− );
515−}
apps/mobile/src/features/settings/SettingsWaitlistRouteScreen.tsxrenamed from apps/mobile/src/app/settings/waitlist.tsx+19−14
@@ -1,36 +1,41 @@
11 import { useAuth } from "@clerk/expo";
2−import { Redirect, Stack, useFocusEffect, useRouter } from "expo-router";
2+import { StackActions, useFocusEffect, useNavigation } from "@react-navigation/native";
33 import { useCallback } from "react";
44 import { ScrollView } from "react-native";
55
6−import { CloudWaitlistEnrollment } from "../../features/cloud/CloudWaitlistEnrollment";
7−import { useClerkSettingsSheetDetent } from "../../features/cloud/ClerkSettingsSheetDetent";
8−import { hasCloudPublicConfig } from "../../features/cloud/publicConfig";
6+import { CloudWaitlistEnrollment } from "../cloud/CloudWaitlistEnrollment";
7+import { useClerkSettingsSheetDetent } from "../cloud/ClerkSettingsSheetDetent";
8+import { hasCloudPublicConfig } from "../cloud/publicConfig";
99
10−export default function SettingsWaitlistRouteScreen() {
11− return hasCloudPublicConfig() ? (
12− <ConfiguredSettingsWaitlistRouteScreen />
13− ) : (
14− <Redirect href="/settings" />
10+export function SettingsWaitlistRouteScreen() {
11+ const navigation = useNavigation();
12+
13+ useFocusEffect(
14+ useCallback(() => {
15+ if (!hasCloudPublicConfig()) {
16+ navigation.dispatch(StackActions.replace("Settings"));
17+ }
18+ }, [navigation]),
1519 );
20+
21+ return hasCloudPublicConfig() ? <ConfiguredSettingsWaitlistRouteScreen /> : null;
1622 }
1723
1824 function ConfiguredSettingsWaitlistRouteScreen() {
1925 const { isLoaded, isSignedIn } = useAuth({ treatPendingAsSignedOut: false });
2026 const { expand } = useClerkSettingsSheetDetent();
21− const router = useRouter();
27+ const navigation = useNavigation();
2228
2329 useFocusEffect(
2430 useCallback(() => {
2531 if (isLoaded && isSignedIn) {
26− router.replace("/settings");
32+ navigation.dispatch(StackActions.replace("Settings"));
2733 }
28− }, [isLoaded, isSignedIn, router]),
34+ }, [isLoaded, isSignedIn, navigation]),
2935 );
3036
3137 return (
3238 <>
33− <Stack.Screen options={{ title: "Join the waitlist" }} />
3439 <ScrollView
3540 automaticallyAdjustKeyboardInsets
3641 contentContainerStyle={{
@@ -46,7 +51,7 @@ function ConfiguredSettingsWaitlistRouteScreen() { · 9 unmodified lines
4651 <CloudWaitlistEnrollment
4752 onSignIn={() => {
4853 expand();
49− router.push("/settings/auth");
54+ navigation.navigate("SettingsSheet", { screen: "SettingsAuth" });
5055 }}
5156 />
5257 </ScrollView>
apps/mobile/src/features/settings/appearance/AppearancePreferencesProvider.tsx+158−0
@@ -0,0 +1,158 @@
1+import {
2+ createContext,
3+ useCallback,
4+ useContext,
5+ useEffect,
6+ useMemo,
7+ useState,
8+ type ReactNode,
9+} from "react";
10+
11+import { Uniwind } from "uniwind";
12+
13+import {
14+ resolveAppearance,
15+ resolveAppearancePreferences,
16+ resolveTextScaleVariables,
17+ type AppearancePreferences,
18+ type ResolvedAppearance,
19+} from "../../../lib/appearancePreferences";
20+import { loadPreferences, savePreferencesPatch } from "../../../lib/storage";
21+import { cacheTerminalFontSize } from "../../terminal/terminalUiState";
22+
23+interface AppearancePreferencesContextValue {
24+ /** Effective values with base-size derivation applied. Use this for rendering. */
25+ readonly appearance: ResolvedAppearance;
26+ readonly isReady: boolean;
27+ readonly setBaseFontSize: (value: number) => void;
28+ /** Pass null to clear the override and follow the base font size. */
29+ readonly setTerminalFontSize: (value: number | null) => void;
30+ /** Pass null to clear the override and follow the base font size. */
31+ readonly setCodeFontSize: (value: number | null) => void;
32+ readonly setCodeWordBreak: (value: boolean) => void;
33+}
34+
35+const AppearancePreferencesContext = createContext<AppearancePreferencesContextValue | null>(null);
36+
37+/**
38+ * Injects the scaled `--text-*` variables into Uniwind so every
39+ * className-based text size (`text-sm`, `text-base`, ...) re-resolves live.
40+ * Updates the current theme last so the active stylesheet settles correctly.
41+ */
42+function applyTextScaleVariables(baseFontSize: number) {
43+ const variables = resolveTextScaleVariables(baseFontSize);
44+ const currentTheme = Uniwind.currentTheme;
45+
46+ for (const theme of ["light", "dark"] as const) {
47+ if (theme !== currentTheme) {
48+ Uniwind.updateCSSVariables(theme, variables);
49+ }
50+ }
51+ Uniwind.updateCSSVariables(currentTheme, variables);
52+}
53+
54+export function AppearancePreferencesProvider(props: { readonly children: ReactNode }) {
55+ const [preferences, setPreferences] = useState<AppearancePreferences>(() =>
56+ resolveAppearancePreferences(null),
57+ );
58+ const [isReady, setIsReady] = useState(false);
59+
60+ useEffect(() => {
61+ let cancelled = false;
62+
63+ void loadPreferences()
64+ .then((stored) => {
65+ if (cancelled) {
66+ return;
67+ }
68+
69+ const resolved = resolveAppearancePreferences(stored);
70+ setPreferences(resolved);
71+ cacheTerminalFontSize(resolveAppearance(resolved).terminalFontSize);
72+ setIsReady(true);
73+ })
74+ .catch(() => {
75+ if (cancelled) {
76+ return;
77+ }
78+
79+ setIsReady(true);
80+ });
81+
82+ return () => {
83+ cancelled = true;
84+ };
85+ }, []);
86+
87+ useEffect(() => {
88+ applyTextScaleVariables(preferences.baseFontSize);
89+ }, [preferences.baseFontSize]);
90+
91+ const updatePreferences = useCallback((patch: Partial<AppearancePreferences>) => {
92+ setPreferences((current) => {
93+ const next = resolveAppearancePreferences({ ...current, ...patch });
94+ cacheTerminalFontSize(resolveAppearance(next).terminalFontSize);
95+ void savePreferencesPatch({
96+ baseFontSize: next.baseFontSize,
97+ terminalFontSize: next.terminalFontSize,
98+ codeFontSize: next.codeFontSize,
99+ codeWordBreak: next.codeWordBreak,
100+ }).catch(() => undefined);
101+ return next;
102+ });
103+ }, []);
104+
105+ const setBaseFontSize = useCallback(
106+ (value: number) => {
107+ updatePreferences({ baseFontSize: value });
108+ },
109+ [updatePreferences],
110+ );
111+
112+ const setTerminalFontSize = useCallback(
113+ (value: number | null) => {
114+ updatePreferences({ terminalFontSize: value });
115+ },
116+ [updatePreferences],
117+ );
118+
119+ const setCodeFontSize = useCallback(
120+ (value: number | null) => {
121+ updatePreferences({ codeFontSize: value });
122+ },
123+ [updatePreferences],
124+ );
125+
126+ const setCodeWordBreak = useCallback(
127+ (value: boolean) => {
128+ updatePreferences({ codeWordBreak: value });
129+ },
130+ [updatePreferences],
131+ );
132+
133+ const value = useMemo(
134+ (): AppearancePreferencesContextValue => ({
135+ appearance: resolveAppearance(preferences),
136+ isReady,
137+ setBaseFontSize,
138+ setTerminalFontSize,
139+ setCodeFontSize,
140+ setCodeWordBreak,
141+ }),
142+ [preferences, isReady, setBaseFontSize, setTerminalFontSize, setCodeFontSize, setCodeWordBreak],
143+ );
144+
145+ return (
146+ <AppearancePreferencesContext.Provider value={value}>
147+ {props.children}
148+ </AppearancePreferencesContext.Provider>
149+ );
150+}
151+
152+export function useAppearancePreferences(): AppearancePreferencesContextValue {
153+ const context = useContext(AppearancePreferencesContext);
154+ if (!context) {
155+ throw new Error("useAppearancePreferences must be used within AppearancePreferencesProvider");
156+ }
157+ return context;
158+}
apps/mobile/src/features/settings/appearance/components/AppearancePreviews.tsx+177−0
@@ -0,0 +1,177 @@
1+import { Platform, ScrollView, View, useColorScheme } from "react-native";
2+
3+import { AppText as Text } from "../../../../components/AppText";
4+import {
5+ resolveMarkdownFontSizes,
6+ resolveMobileCodeSurface,
7+} from "../../../../lib/appearancePreferences";
8+import { useThemeColor } from "../../../../lib/useThemeColor";
9+import { getPierreTerminalTheme } from "../../../terminal/terminalTheme";
10+
11+const CODE_FONT_FAMILY = Platform.select({
12+ ios: "ui-monospace",
13+ android: "monospace",
14+ default: "monospace",
15+});
16+
17+/** Hairline between a section's preview surface and its control rows. */
18+export function AppearancePreviewSeparator() {
19+ return <View className="h-px bg-separator" />;
20+}
21+
22+/** Live sample of body text rendered at the chosen base font size. */
23+export function TextAppearancePreview(props: { readonly fontSize: number }) {
24+ const sizes = resolveMarkdownFontSizes(props.fontSize);
25+
26+ return (
27+ <View className="gap-1 p-4">
28+ <Text
29+ className="text-foreground"
30+ style={{ fontSize: sizes.m, lineHeight: sizes.bodyLineHeight }}
31+ >
32+ The quick brown fox jumps over the lazy dog.
33+ </Text>
34+ <Text
35+ className="text-foreground-muted"
36+ style={{ fontSize: sizes.s, lineHeight: Math.round(sizes.s * 1.4) }}
37+ >
38+ Messages, labels, and headings scale with this size.
39+ </Text>
40+ </View>
41+ );
42+}
43+
44+/**
45+ * Live terminal sample using the real terminal theme's text colors and font,
46+ * on the shared card background so it reads like the other previews.
47+ */
48+export function TerminalAppearancePreview(props: { readonly fontSize: number }) {
49+ const scheme = useColorScheme() === "light" ? "light" : "dark";
50+ const theme = getPierreTerminalTheme(scheme);
51+ const lineHeight = Math.round(props.fontSize * 1.6);
52+ const lineStyle = {
53+ fontFamily: "Menlo",
54+ fontSize: props.fontSize,
55+ lineHeight,
56+ } as const;
57+
58+ return (
59+ <View className="p-4">
60+ <Text style={[lineStyle, { color: theme.foreground }]}>$ npm run dev</Text>
61+ <Text style={[lineStyle, { color: theme.palette[2] }]}>✓ Ready in 430ms</Text>
62+ <Text style={[lineStyle, { color: theme.foreground }]}>
63+ Local: http://localhost:3000{" "}
64+ <Text style={[lineStyle, { color: theme.cursorForeground }]}>▏</Text>
65+ </Text>
66+ </View>
67+ );
68+}
69+
70+interface CodePreviewToken {
71+ readonly text: string;
72+ readonly keyword?: boolean;
73+}
74+
75+interface CodePreviewLine {
76+ readonly id: string;
77+ readonly tokens: ReadonlyArray<CodePreviewToken>;
78+}
79+
80+const CODE_PREVIEW_LINES: ReadonlyArray<CodePreviewLine> = [
81+ {
82+ id: "signature",
83+ tokens: [{ text: "function", keyword: true }, { text: " formatUser(user) {" }],
84+ },
85+ {
86+ id: "body",
87+ tokens: [
88+ { text: " " },
89+ { text: "return", keyword: true },
90+ { text: " `${user.name} <${user.email}>` // demonstrates how long lines behave" },
91+ ],
92+ },
93+ { id: "close", tokens: [{ text: "}" }] },
94+];
95+
96+/**
97+ * Live code sample matching the code & diff surface metrics. Long lines wrap
98+ * when word break is on and scroll horizontally when it is off, mirroring the
99+ * real code surface.
100+ */
101+export function CodeAppearancePreview(props: {
102+ readonly fontSize: number;
103+ readonly wordBreak: boolean;
104+}) {
105+ const surface = resolveMobileCodeSurface(props.fontSize);
106+ const lineNumberColor = useThemeColor("--color-icon-subtle");
107+ const keywordColor = useThemeColor("--color-md-link");
108+
109+ const lineNumber = (line: CodePreviewLine, index: number) => (
110+ <Text
111+ className="text-right"
112+ key={line.id}
113+ style={{
114+ color: lineNumberColor,
115+ fontFamily: CODE_FONT_FAMILY,
116+ fontSize: surface.lineNumberFontSize,
117+ lineHeight: surface.rowHeight,
118+ width: 22,
119+ }}
120+ >
121+ {index + 1}
122+ </Text>
123+ );
124+
125+ const codeLine = (line: CodePreviewLine, wrap: boolean) => (
126+ <Text
127+ className="text-foreground"
128+ key={line.id}
129+ numberOfLines={wrap ? undefined : 1}
130+ style={{
131+ fontFamily: CODE_FONT_FAMILY,
132+ fontSize: surface.fontSize,
133+ lineHeight: surface.rowHeight,
134+ }}
135+ >
136+ {line.tokens.map((token) => (
137+ <Text
138+ key={token.text}
139+ style={{
140+ color: token.keyword ? keywordColor : undefined,
141+ fontFamily: CODE_FONT_FAMILY,
142+ fontSize: surface.fontSize,
143+ lineHeight: surface.rowHeight,
144+ }}
145+ >
146+ {token.text}
147+ </Text>
148+ ))}
149+ </Text>
150+ );
151+
152+ if (props.wordBreak) {
153+ return (
154+ <View className="p-4">
155+ {CODE_PREVIEW_LINES.map((line, index) => (
156+ <View className="flex-row" key={line.id}>
157+ {lineNumber(line, index)}
158+ <View className="flex-1 pl-3">{codeLine(line, true)}</View>
159+ </View>
160+ ))}
161+ </View>
162+ );
163+ }
164+
165+ return (
166+ <View className="flex-row p-4">
167+ <View>{CODE_PREVIEW_LINES.map((line, index) => lineNumber(line, index))}</View>
168+ <ScrollView
169+ horizontal
170+ contentContainerStyle={{ paddingLeft: 12 }}
171+ showsHorizontalScrollIndicator={false}
172+ >
173+ <View>{CODE_PREVIEW_LINES.map((line) => codeLine(line, false))}</View>
174+ </ScrollView>
175+ </View>
176+ );
177+}
apps/mobile/src/features/settings/appearance/components/FontSizeSliderRow.tsx+214−0
@@ -0,0 +1,214 @@
1+import * as Haptics from "expo-haptics";
2+import { SymbolView } from "expo-symbols";
3+import { useCallback, useEffect, useMemo, useRef } from "react";
4+import { View, type AccessibilityActionEvent } from "react-native";
5+import { Gesture, GestureDetector } from "react-native-gesture-handler";
6+import Animated, {
7+ runOnJS,
8+ useAnimatedStyle,
9+ useSharedValue,
10+ withTiming,
11+} from "react-native-reanimated";
12+import type { ComponentProps } from "react";
13+
14+import { AppText as Text } from "../../../../components/AppText";
15+import { useThemeColor } from "../../../../lib/useThemeColor";
16+
17+type SymbolName = ComponentProps<typeof SymbolView>["name"];
18+
19+const THUMB_SIZE = 26;
20+const TRACK_HEIGHT = 4;
21+const SNAP_ANIMATION = { duration: 120 } as const;
22+
23+function clampFraction(value: number): number {
24+ "worklet";
25+ return Math.min(1, Math.max(0, value));
26+}
27+
28+export function FontSizeSliderRow(props: {
29+ readonly disabled?: boolean;
30+ readonly icon: SymbolName;
31+ readonly label: string;
32+ readonly valueLabel: string;
33+ readonly min: number;
34+ readonly max: number;
35+ readonly step: number;
36+ readonly value: number;
37+ readonly onChange: (value: number) => void;
38+}) {
39+ const icon = useThemeColor("--color-icon");
40+ const iconMuted = String(useThemeColor("--color-icon-muted"));
41+ const trackColor = String(useThemeColor("--color-secondary-border"));
42+ const fillColor = String(useThemeColor("--color-primary"));
43+
44+ const latest = useRef(props);
45+ latest.current = props;
46+
47+ const { min, max, step, value, disabled } = props;
48+ const fraction = (value - min) / (max - min);
49+
50+ const progress = useSharedValue(clampFraction(fraction));
51+ const trackWidth = useSharedValue(0);
52+ const dragging = useSharedValue(false);
53+
54+ useEffect(() => {
55+ if (!dragging.value) {
56+ progress.value = withTiming(clampFraction(fraction), SNAP_ANIMATION);
57+ }
58+ }, [dragging, fraction, progress]);
59+
60+ const commit = useCallback((next: number) => {
61+ if (next === latest.current.value) {
62+ return;
63+ }
64+ Haptics.selectionAsync().catch(() => undefined);
65+ latest.current.onChange(next);
66+ }, []);
67+
68+ const gesture = useMemo(() => {
69+ const snapValue = (raw: number): number => {
70+ "worklet";
71+ const stepped = Math.round((raw - min) / step) * step + min;
72+ return Math.min(max, Math.max(min, stepped));
73+ };
74+ const fractionAt = (x: number): number => {
75+ "worklet";
76+ const usable = trackWidth.value - THUMB_SIZE;
77+ if (usable <= 0) {
78+ return 0;
79+ }
80+ return clampFraction((x - THUMB_SIZE / 2) / usable);
81+ };
82+ const valueAtFraction = (f: number): number => {
83+ "worklet";
84+ return snapValue(min + f * (max - min));
85+ };
86+ const fractionOfValue = (v: number): number => {
87+ "worklet";
88+ return clampFraction((v - min) / (max - min));
89+ };
90+
91+ const pan = Gesture.Pan()
92+ .enabled(!disabled)
93+ .activeOffsetX([-8, 8])
94+ .failOffsetY([-12, 12])
95+ .onUpdate((event) => {
96+ dragging.value = true;
97+ const f = fractionAt(event.x);
98+ progress.value = f;
99+ runOnJS(commit)(valueAtFraction(f));
100+ })
101+ .onFinalize(() => {
102+ if (!dragging.value) {
103+ return;
104+ }
105+ dragging.value = false;
106+ progress.value = withTiming(
107+ fractionOfValue(valueAtFraction(progress.value)),
108+ SNAP_ANIMATION,
109+ );
110+ });
111+
112+ const tap = Gesture.Tap()
113+ .enabled(!disabled)
114+ .onEnd((event) => {
115+ const next = valueAtFraction(fractionAt(event.x));
116+ progress.value = withTiming(fractionOfValue(next), SNAP_ANIMATION);
117+ runOnJS(commit)(next);
118+ });
119+
120+ return Gesture.Race(pan, tap);
121+ }, [commit, disabled, dragging, max, min, progress, step, trackWidth]);
122+
123+ const fillStyle = useAnimatedStyle(() => ({
124+ width: THUMB_SIZE / 2 + progress.value * Math.max(0, trackWidth.value - THUMB_SIZE),
125+ }));
126+ const thumbStyle = useAnimatedStyle(() => ({
127+ transform: [{ translateX: progress.value * Math.max(0, trackWidth.value - THUMB_SIZE) }],
128+ }));
129+
130+ const handleAccessibilityAction = (event: AccessibilityActionEvent) => {
131+ if (event.nativeEvent.actionName === "increment") {
132+ commit(Math.min(max, value + step));
133+ } else if (event.nativeEvent.actionName === "decrement") {
134+ commit(Math.max(min, value - step));
135+ }
136+ };
137+
138+ return (
139+ <View className="gap-1 p-4" style={{ opacity: disabled ? 0.45 : 1 }}>
140+ <View className="flex-row items-center gap-4">
141+ <SymbolView
142+ name={props.icon}
143+ size={22}
144+ tintColor={icon}
145+ type="monochrome"
146+ weight="regular"
147+ />
148+ <Text className="flex-1 text-lg text-foreground">{props.label}</Text>
149+ <Text className="text-base font-t3-medium text-foreground-muted">{props.valueLabel}</Text>
150+ </View>
151+ <View className="flex-row items-center gap-3">
152+ <SymbolView
153+ name="textformat.size.smaller"
154+ size={15}
155+ tintColor={iconMuted}
156+ type="monochrome"
157+ weight="regular"
158+ />
159+ <GestureDetector gesture={gesture}>
160+ <View
161+ accessible
162+ accessibilityActions={[
163+ { name: "increment", label: `Increase ${props.label}` },
164+ { name: "decrement", label: `Decrease ${props.label}` },
165+ ]}
166+ accessibilityLabel={props.label}
167+ accessibilityRole="adjustable"
168+ accessibilityValue={{ min, max, now: value, text: props.valueLabel }}
169+ className="h-11 flex-1 justify-center"
170+ onAccessibilityAction={handleAccessibilityAction}
171+ onLayout={(event) => {
172+ trackWidth.value = event.nativeEvent.layout.width;
173+ }}
174+ >
175+ <View
176+ className="w-full rounded-full"
177+ style={{ backgroundColor: trackColor, height: TRACK_HEIGHT }}
178+ >
179+ <Animated.View
180+ className="absolute inset-y-0 left-0 rounded-full"
181+ style={[{ backgroundColor: fillColor }, fillStyle]}
182+ />
183+ </View>
184+ <Animated.View
185+ className="absolute left-0 rounded-full bg-white"
186+ style={[
187+ {
188+ borderColor: "rgba(0, 0, 0, 0.06)",
189+ borderWidth: 1,
190+ height: THUMB_SIZE,
191+ marginTop: -THUMB_SIZE / 2,
192+ shadowColor: "#000000",
193+ shadowOffset: { height: 2, width: 0 },
194+ shadowOpacity: 0.18,
195+ shadowRadius: 3,
196+ top: "50%",
197+ width: THUMB_SIZE,
198+ },
199+ thumbStyle,
200+ ]}
201+ />
202+ </View>
203+ </GestureDetector>
204+ <SymbolView
205+ name="textformat.size.larger"
206+ size={22}
207+ tintColor={iconMuted}
208+ type="monochrome"
209+ weight="regular"
210+ />
211+ </View>
212+ </View>
213+ );
214+}
apps/mobile/src/features/settings/appearance/sections/CodeAppearanceSection.tsx+64−0
@@ -0,0 +1,64 @@
1+import { useCallback } from "react";
2+
3+import {
4+ CODE_FONT_SIZE_STEP,
5+ MAX_CODE_FONT_SIZE,
6+ MIN_CODE_FONT_SIZE,
7+} from "../../../../lib/appearancePreferences";
8+import { SettingsSection } from "../../components/SettingsSection";
9+import { SettingsSwitchRow } from "../../components/SettingsSwitchRow";
10+import { useAppearancePreferences } from "../AppearancePreferencesProvider";
11+import {
12+ AppearancePreviewSeparator,
13+ CodeAppearancePreview,
14+} from "../components/AppearancePreviews";
15+import { FontSizeSliderRow } from "../components/FontSizeSliderRow";
16+
17+export function CodeAppearanceSection() {
18+ const { isReady, appearance, setCodeFontSize, setCodeWordBreak } = useAppearancePreferences();
19+ const custom = appearance.isCodeFontSizeCustom;
20+
21+ const handleToggleCustom = useCallback(
22+ (enabled: boolean) => {
23+ setCodeFontSize(enabled ? appearance.codeFontSize : null);
24+ },
25+ [appearance.codeFontSize, setCodeFontSize],
26+ );
27+
28+ return (
29+ <SettingsSection title="Code & Diffs">
30+ <CodeAppearancePreview
31+ fontSize={appearance.codeFontSize}
32+ wordBreak={appearance.codeWordBreak}
33+ />
34+ <AppearancePreviewSeparator />
35+ <SettingsSwitchRow
36+ disabled={!isReady}
37+ icon="chevron.left.forwardslash.chevron.right"
38+ label="Custom font size"
39+ onValueChange={handleToggleCustom}
40+ value={custom}
41+ />
42+ {custom ? (
43+ <FontSizeSliderRow
44+ disabled={!isReady}
45+ icon="textformat.size"
46+ label="Font size"
47+ max={MAX_CODE_FONT_SIZE}
48+ min={MIN_CODE_FONT_SIZE}
49+ onChange={setCodeFontSize}
50+ step={CODE_FONT_SIZE_STEP}
51+ value={appearance.codeFontSize}
52+ valueLabel={`${appearance.codeFontSize} pt`}
53+ />
54+ ) : null}
55+ <SettingsSwitchRow
56+ disabled={!isReady}
57+ icon="text.word.spacing"
58+ label="Word break"
59+ onValueChange={setCodeWordBreak}
60+ value={appearance.codeWordBreak}
61+ />
62+ </SettingsSection>
63+ );
64+}
apps/mobile/src/features/settings/appearance/sections/TerminalAppearanceSection.tsx+54−0
@@ -0,0 +1,54 @@
1+import { useCallback } from "react";
2+
3+import {
4+ MAX_TERMINAL_FONT_SIZE,
5+ MIN_TERMINAL_FONT_SIZE,
6+ TERMINAL_FONT_SIZE_STEP,
7+} from "../../../../lib/appearancePreferences";
8+import { SettingsSection } from "../../components/SettingsSection";
9+import { SettingsSwitchRow } from "../../components/SettingsSwitchRow";
10+import { useAppearancePreferences } from "../AppearancePreferencesProvider";
11+import {
12+ AppearancePreviewSeparator,
13+ TerminalAppearancePreview,
14+} from "../components/AppearancePreviews";
15+import { FontSizeSliderRow } from "../components/FontSizeSliderRow";
16+
17+export function TerminalAppearanceSection() {
18+ const { isReady, appearance, setTerminalFontSize } = useAppearancePreferences();
19+ const custom = appearance.isTerminalFontSizeCustom;
20+
21+ const handleToggleCustom = useCallback(
22+ (enabled: boolean) => {
23+ setTerminalFontSize(enabled ? appearance.terminalFontSize : null);
24+ },
25+ [appearance.terminalFontSize, setTerminalFontSize],
26+ );
27+
28+ return (
29+ <SettingsSection title="Terminal">
30+ <TerminalAppearancePreview fontSize={appearance.terminalFontSize} />
31+ <AppearancePreviewSeparator />
32+ <SettingsSwitchRow
33+ disabled={!isReady}
34+ icon="terminal"
35+ label="Custom font size"
36+ onValueChange={handleToggleCustom}
37+ value={custom}
38+ />
39+ {custom ? (
40+ <FontSizeSliderRow
41+ disabled={!isReady}
42+ icon="textformat.size"
43+ label="Font size"
44+ max={MAX_TERMINAL_FONT_SIZE}
45+ min={MIN_TERMINAL_FONT_SIZE}
46+ onChange={setTerminalFontSize}
47+ step={TERMINAL_FONT_SIZE_STEP}
48+ value={appearance.terminalFontSize}
49+ valueLabel={`${appearance.terminalFontSize.toFixed(1)} pt`}
50+ />
51+ ) : null}
52+ </SettingsSection>
53+ );
54+}
apps/mobile/src/features/settings/appearance/sections/TextAppearanceSection.tsx+34−0
@@ -0,0 +1,34 @@
1+import {
2+ BASE_FONT_SIZE_STEP,
3+ MAX_BASE_FONT_SIZE,
4+ MIN_BASE_FONT_SIZE,
5+} from "../../../../lib/appearancePreferences";
6+import { SettingsSection } from "../../components/SettingsSection";
7+import { useAppearancePreferences } from "../AppearancePreferencesProvider";
8+import {
9+ AppearancePreviewSeparator,
10+ TextAppearancePreview,
11+} from "../components/AppearancePreviews";
12+import { FontSizeSliderRow } from "../components/FontSizeSliderRow";
13+
14+export function TextAppearanceSection() {
15+ const { isReady, appearance, setBaseFontSize } = useAppearancePreferences();
16+
17+ return (
18+ <SettingsSection title="Text">
19+ <TextAppearancePreview fontSize={appearance.baseFontSize} />
20+ <AppearancePreviewSeparator />
21+ <FontSizeSliderRow
22+ disabled={!isReady}
23+ icon="textformat.size"
24+ label="Text size"
25+ max={MAX_BASE_FONT_SIZE}
26+ min={MIN_BASE_FONT_SIZE}
27+ onChange={setBaseFontSize}
28+ step={BASE_FONT_SIZE_STEP}
29+ value={appearance.baseFontSize}
30+ valueLabel={`${appearance.baseFontSize} pt`}
31+ />
32+ </SettingsSection>
33+ );
34+}
apps/mobile/src/features/settings/appearance/useAppearanceCodeSurface.ts+34−0
@@ -0,0 +1,34 @@
1+import { useMemo } from "react";
2+
3+import {
4+ resolveMobileCodeSurface,
5+ type ResolvedMobileCodeSurface,
6+} from "../../../lib/appearancePreferences";
7+import { createNativeReviewDiffStyle } from "../../review/nativeReviewDiffAdapter";
8+import { createNativeSourceStyle } from "../../files/nativeSourceFileAdapter";
9+import { useAppearancePreferences } from "./AppearancePreferencesProvider";
10+
11+export function useAppearanceCodeSurface(): {
12+ readonly codeSurface: ResolvedMobileCodeSurface;
13+ readonly codeWordBreak: boolean;
14+ readonly nativeSourceStyle: ReturnType<typeof createNativeSourceStyle>;
15+ readonly nativeReviewDiffStyle: ReturnType<typeof createNativeReviewDiffStyle>;
16+} {
17+ const { appearance } = useAppearancePreferences();
18+ const codeSurface = useMemo(
19+ () => resolveMobileCodeSurface(appearance.codeFontSize),
20+ [appearance.codeFontSize],
21+ );
22+ const nativeSourceStyle = useMemo(() => createNativeSourceStyle(codeSurface), [codeSurface]);
23+ const nativeReviewDiffStyle = useMemo(
24+ () => createNativeReviewDiffStyle(codeSurface),
25+ [codeSurface],
26+ );
27+
28+ return {
29+ codeSurface,
30+ codeWordBreak: appearance.codeWordBreak,
31+ nativeSourceStyle,
32+ nativeReviewDiffStyle,
33+ };
34+}
apps/mobile/src/features/settings/appearance/useScaledTextRole.ts+36−0
@@ -0,0 +1,36 @@
1+import { useCSSVariable } from "uniwind";
2+
3+import { MOBILE_TYPOGRAPHY } from "../../../lib/typography";
4+
5+const TEXT_ROLE_VARIABLES = {
6+ micro: "--text-3xs",
7+ caption: "--text-2xs",
8+ label: "--text-xs",
9+ footnote: "--text-sm",
10+ body: "--text-base",
11+ headline: "--text-lg",
12+ title: "--text-xl",
13+ largeTitle: "--text-2xl",
14+ display: "--text-3xl",
15+} as const satisfies Record<keyof typeof MOBILE_TYPOGRAPHY, string>;
16+
17+export interface ScaledTextRole {
18+ readonly fontSize: number;
19+ readonly lineHeight: number;
20+}
21+
22+/**
23+ * Reads a typography role's current size from the Uniwind `--text-*` CSS
24+ * variables (scaled at runtime with the base font size). Use for style-prop
25+ * consumers that can't express their size as a `text-*` className. Reactive:
26+ * re-renders when the appearance provider re-injects the variables.
27+ */
28+export function useScaledTextRole(role: keyof typeof MOBILE_TYPOGRAPHY): ScaledTextRole {
29+ const variable = TEXT_ROLE_VARIABLES[role];
30+ const [fontSize, lineHeight] = useCSSVariable([variable, `${variable}--line-height`]);
31+
32+ return {
33+ fontSize: typeof fontSize === "number" ? fontSize : MOBILE_TYPOGRAPHY[role].fontSize,
34+ lineHeight: typeof lineHeight === "number" ? lineHeight : MOBILE_TYPOGRAPHY[role].lineHeight,
35+ };
36+}
apps/mobile/src/features/settings/components/SettingsRow.tsx+76−0
@@ -0,0 +1,76 @@
1+import { useNavigation } from "@react-navigation/native";
2+import { SymbolView } from "expo-symbols";
3+import type { ComponentProps } from "react";
4+import { Pressable, View } from "react-native";
5+
6+import { AppText as Text } from "../../../components/AppText";
7+import { useThemeColor } from "../../../lib/useThemeColor";
8+import type { SettingsSheetTarget } from "./settings-sheet-targets";
9+
10+type SymbolName = ComponentProps<typeof SymbolView>["name"];
11+
12+export function SettingsRow(props: {
13+ readonly disabled?: boolean;
14+ readonly icon: SymbolName;
15+ readonly label: string;
16+ readonly value?: string;
17+ readonly target?: SettingsSheetTarget;
18+ readonly onPress?: () => void;
19+}) {
20+ const navigation = useNavigation();
21+ const icon = useThemeColor("--color-icon");
22+ const chevron = useThemeColor("--color-chevron");
23+ const content = (
24+ <View
25+ className="flex-row items-center gap-4 p-4"
26+ style={{ opacity: props.disabled ? 0.45 : 1 }}
27+ >
28+ <SymbolView name={props.icon} size={22} tintColor={icon} type="monochrome" weight="regular" />
29+ <Text className="shrink-0 text-lg text-foreground" numberOfLines={1}>
30+ {props.label}
31+ </Text>
32+ <View className="min-w-0 flex-1 items-end">
33+ {props.value ? (
34+ <Text
35+ className="max-w-[180px] text-right text-base text-foreground-muted"
36+ ellipsizeMode="middle"
37+ numberOfLines={1}
38+ >
39+ {props.value}
40+ </Text>
41+ ) : null}
42+ </View>
43+ <SymbolView
44+ name="chevron.right"
45+ size={16}
46+ tintColor={chevron}
47+ type="monochrome"
48+ weight="semibold"
49+ />
50+ </View>
51+ );
52+
53+ const target = props.target;
54+ if (target) {
55+ return (
56+ <Pressable
57+ accessibilityLabel={props.label}
58+ accessibilityRole="button"
59+ disabled={props.disabled}
60+ onPress={() =>
61+ navigation.navigate("SettingsSheet", {
62+ screen: target,
63+ })
64+ }
65+ >
66+ {content}
67+ </Pressable>
68+ );
69+ }
70+
71+ return (
72+ <Pressable accessibilityRole="button" disabled={props.disabled} onPress={props.onPress}>
73+ {content}
74+ </Pressable>
75+ );
76+}
apps/mobile/src/features/settings/components/SettingsSection.tsx+18−0
@@ -0,0 +1,18 @@
1+import type { ReactNode } from "react";
2+import { View } from "react-native";
3+
4+import { AppText as Text } from "../../../components/AppText";
5+
6+export function SettingsSection(props: { readonly title: string; readonly children: ReactNode }) {
7+ return (
8+ <View className="gap-2">
9+ <Text className="px-2 text-sm font-t3-medium text-foreground-muted">{props.title}</Text>
10+ <View
11+ className="overflow-hidden rounded-[28px] bg-card"
12+ style={{ borderCurve: "continuous" }}
13+ >
14+ {props.children}
15+ </View>
16+ </View>
17+ );
18+}
apps/mobile/src/features/settings/components/SettingsSwitchRow.tsx+37−0
@@ -0,0 +1,37 @@
1+import { SymbolView } from "expo-symbols";
2+import type { ComponentProps } from "react";
3+import { Switch, View } from "react-native";
4+
5+import { AppText as Text } from "../../../components/AppText";
6+import { useThemeColor } from "../../../lib/useThemeColor";
7+
8+type SymbolName = ComponentProps<typeof SymbolView>["name"];
9+
10+export function SettingsSwitchRow(props: {
11+ readonly disabled?: boolean;
12+ readonly icon: SymbolName;
13+ readonly label: string;
14+ readonly value: boolean;
15+ readonly onValueChange: (value: boolean) => void;
16+}) {
17+ const icon = useThemeColor("--color-icon");
18+ const activeTrack = String(useThemeColor("--color-switch-active"));
19+ const track = String(useThemeColor("--color-secondary-border"));
20+
21+ return (
22+ <View
23+ className="flex-row items-center gap-4 p-4"
24+ style={{ opacity: props.disabled ? 0.45 : 1 }}
25+ >
26+ <SymbolView name={props.icon} size={22} tintColor={icon} type="monochrome" weight="regular" />
27+ <Text className="flex-1 text-lg text-foreground">{props.label}</Text>
28+ <Switch
29+ disabled={props.disabled}
30+ ios_backgroundColor={track}
31+ onValueChange={props.onValueChange}
32+ trackColor={{ false: track, true: activeTrack }}
33+ value={props.value}
34+ />
35+ </View>
36+ );
37+}
apps/mobile/src/features/settings/components/settings-sheet-targets.ts+1−0
@@ -0,0 +1 @@
1+export type SettingsSheetTarget = "SettingsEnvironments" | "SettingsArchive" | "SettingsAppearance";
apps/mobile/src/features/terminal/NativeTerminalSurface.tsx+17−44
@@ -12,7 +12,10 @@ import { · 11 unmodified lines
1212
1313 import { AppText as Text } from "../../components/AppText";
1414 import { MOBILE_TYPOGRAPHY } from "../../lib/typography";
15−import { resolveNativeTerminalSurfaceView } from "./nativeTerminalModule";
15+import {
16+ getNativeTerminalHardwareKeyRevision,
17+ resolveNativeTerminalSurfaceView,
18+} from "./nativeTerminalModule";
1619 import {
1720 buildGhosttyThemeConfig,
1821 getPierreTerminalTheme,
@@ -92,9 +95,9 @@ const FallbackTerminalSurface = memo(function FallbackTerminalSurface(props: Ter · 73 unmodified lines
9295 >
9396 <View style={{ flex: 1, paddingHorizontal: 10, paddingVertical: 8 }}>
9497 <Text
98+ className="text-2xs"
9599 style={{
96100 color: theme.mutedForeground,
97− fontSize: MOBILE_TYPOGRAPHY.caption.fontSize,
98101 paddingBottom: 8,
99102 }}
100103 >
@@ -137,11 +140,11 @@ const FallbackTerminalSurface = memo(function FallbackTerminalSurface(props: Ter · 36 unmodified lines
137140 placeholder="type and press return"
138141 placeholderTextColor={theme.mutedForeground}
139142 returnKeyType="send"
143+ className="text-sm"
140144 style={{
141145 color: theme.foreground,
142146 flex: 1,
143147 fontFamily: "Menlo",
144− fontSize: MOBILE_TYPOGRAPHY.footnote.fontSize,
145148 padding: 0,
146149 }}
147150 onSubmitEditing={(event) => {
@@ -163,10 +166,10 @@ const FallbackTerminalSurface = memo(function FallbackTerminalSurface(props: Ter · 15 unmodified lines
163166 onPress={() => props.onInput("\u0003")}
164167 >
165168 <Text
169+ className="text-2xs"
166170 style={{
167171 color: theme.foreground,
168172 fontFamily: "DMSans_700Bold",
169− fontSize: MOBILE_TYPOGRAPHY.caption.fontSize,
170173 }}
171174 >
172175 Ctrl-C
@@ -179,7 +182,6 @@ const FallbackTerminalSurface = memo(function FallbackTerminalSurface(props: Ter · 6 unmodified lines
179182
180183 export const TerminalSurface = memo(function TerminalSurface(props: TerminalSurfaceProps) {
181184 const fontSize = props.fontSize ?? MOBILE_TYPOGRAPHY.label.fontSize;
182− const keyboardInputRef = useRef<TextInput>(null);
183185 const appearanceScheme = useColorScheme() === "light" ? "light" : "dark";
184186 const theme = props.theme ?? getPierreTerminalTheme(appearanceScheme);
185187 const { onInput, onResize } = props;
@@ -190,15 +192,23 @@ export const TerminalSurface = memo(function TerminalSurface(props: TerminalSurf · 4 unmodified lines
190192 terminalDebugLog("native:surface", {
191193 terminalKey: props.terminalKey,
192194 native: hasNativeSurface,
195+ // null = installed binary predates native hardware-key handling (rebuild needed).
196+ hardwareKeyRevision: getNativeTerminalHardwareKeyRevision(),
193197 bufferLen: props.buffer.length,
194198 isRunning: props.isRunning,
195199 });
196200 }, [hasNativeSurface, props.buffer.length, props.isRunning, props.terminalKey]);
197201 const handleNativeInput = useCallback(
198202 (event: NativeSyntheticEvent<TerminalInputEvent>) => {
203+ if (!props.isRunning) {
204+ return;
205+ }
206+ terminalDebugLog("native:onInput", {
207+ codes: Array.from(event.nativeEvent.data, (char) => char.codePointAt(0)),
208+ });
199209 onInput(event.nativeEvent.data);
200210 },
201− [onInput],
211+ [onInput, props.isRunning],
202212 );
203213 const handleNativeResize = useCallback(
204214 (event: NativeSyntheticEvent<TerminalResizeEvent>) => {
@@ -210,33 +220,13 @@ export const TerminalSurface = memo(function TerminalSurface(props: TerminalSurf · 5 unmodified lines
210220 [onResize],
211221 );
212222
213− // Reopen focus through React and forward input normally; this avoids a native focus-command bridge.
214− useEffect(() => {
215− if (!NativeTerminalSurfaceView || (props.keyboardFocusRequest ?? 0) <= 0) {
216− return undefined;
217− }
218−
219− keyboardInputRef.current?.blur();
220− const focusFrame = requestAnimationFrame(() => keyboardInputRef.current?.focus());
221− return () => cancelAnimationFrame(focusFrame);
222− }, [NativeTerminalSurfaceView, props.keyboardFocusRequest]);
223−
224− const handleKeyboardInput = useCallback(
225− (data: string) => {
226− if (data.length > 0) {
227− onInput(data);
228− keyboardInputRef.current?.clear();
229− }
230− },
231− [onInput],
232− );
233−
234223 if (NativeTerminalSurfaceView) {
235224 return (
236225 <View style={props.style}>
237226 <NativeTerminalSurfaceView
238227 appearanceScheme={appearanceScheme}
239228 backgroundColor={theme.background}
229+ focusRequest={props.isRunning ? (props.keyboardFocusRequest ?? 0) : 0}
240230 foregroundColor={theme.foreground}
241231 mutedForegroundColor={theme.mutedForeground}
242232 terminalKey={props.terminalKey}
@@ -247,23 +237,6 @@ export const TerminalSurface = memo(function TerminalSurface(props: TerminalSurf · 4 unmodified lines
247237 onInput={handleNativeInput}
248238 onResize={handleNativeResize}
249239 />
250− <TextInput
251− ref={keyboardInputRef}
252− autoCapitalize="none"
253− autoCorrect={false}
254− blurOnSubmit={false}
255− caretHidden
256− editable={props.isRunning}
257− keyboardType="ascii-capable"
258− style={{ bottom: 0, height: 1, left: 0, opacity: 0.01, position: "absolute", width: 1 }}
259− onChangeText={handleKeyboardInput}
260− onKeyPress={(event) => {
261− if (event.nativeEvent.key === "Backspace") {
262− onInput("\u007f");
263− }
264− }}
265− onSubmitEditing={() => onInput("\n")}
266− />
267240 </View>
268241 );
269242 }
apps/mobile/src/features/terminal/ThreadTerminalRouteScreen.tsx+98−165
@@ -1,9 +1,10 @@
11 import { DEFAULT_TERMINAL_ID, EnvironmentId, ThreadId } from "@t3tools/contracts";
22 import { type KnownTerminalSession } from "@t3tools/client-runtime/state/terminal";
33 import { SymbolView } from "expo-symbols";
4−import { Stack, useLocalSearchParams, useRouter } from "expo-router";
4+import { NativeHeaderToolbar, NativeStackScreenOptions } from "../../native/StackHeader";
5+import { StackActions, useNavigation, type StaticScreenProps } from "@react-navigation/native";
56 import { useCallback, useEffect, useMemo, useRef, useState } from "react";
6−import { Pressable, Text as RNText, View, useColorScheme } from "react-native";
7+import { Platform, Pressable, View, useColorScheme } from "react-native";
78 import {
89 KeyboardController,
910 KeyboardEvents,
@@ -24,8 +25,13 @@ import { useEnvironmentPresentation } from "../../state/presentation"; · 14 unmodified lines
2425 import { terminalEnvironment } from "../../state/terminal";
2526 import { useAtomCommand } from "../../state/use-atom-command";
2627 import { useWorkspaceState } from "../../state/workspace";
27−import { buildThreadTerminalNavigation } from "../../lib/routes";
28−import { MOBILE_TYPOGRAPHY } from "../../lib/typography";
28+import {
29+ MAX_TERMINAL_FONT_SIZE,
30+ MIN_TERMINAL_FONT_SIZE,
31+ TERMINAL_FONT_SIZE_STEP,
32+ stepTerminalFontSize,
33+} from "../../lib/appearancePreferences";
34+import { useAppearancePreferences } from "../settings/appearance/AppearancePreferencesProvider";
2935 import {
3036 useAttachedTerminalSession,
3137 useKnownTerminalSessions,
@@ -33,9 +39,9 @@ import { · 1 unmodified line
3339 import { useThreadSelection } from "../../state/use-thread-selection";
3440 import { useSelectedThreadDetail } from "../../state/use-thread-detail";
3541 import { EnvironmentConnectionNotice } from "../connection/EnvironmentConnectionNotice";
42+import { useAdaptiveWorkspaceLayout } from "../layout/AdaptiveWorkspaceLayout";
3643 import { TerminalSurface } from "./NativeTerminalSurface";
3744 import { getPierreTerminalTheme } from "./terminalTheme";
38−import { loadPreferences, savePreferencesPatch } from "../../lib/storage";
3945 import { terminalDebugLog } from "./terminalDebugLog";
4046 import {
4147 getTerminalBufferReplayKey,
@@ -55,19 +61,7 @@ import { · 13 unmodified lines
5561 resolveTerminalSessionLabel,
5662 type TerminalMenuSession,
5763 } from "./terminalMenu";
58−import {
59− DEFAULT_TERMINAL_FONT_SIZE,
60− MAX_TERMINAL_FONT_SIZE,
61− MIN_TERMINAL_FONT_SIZE,
62− TERMINAL_FONT_SIZE_STEP,
63− normalizeTerminalFontSize,
64−} from "./terminalPreferences";
65−import {
66− cacheTerminalFontSize,
67− cacheTerminalGridSize,
68− getCachedTerminalFontSize,
69− getCachedTerminalGridSize,
70−} from "./terminalUiState";
64+import { cacheTerminalGridSize, getCachedTerminalGridSize } from "./terminalUiState";
7165
7266 const DEFAULT_TERMINAL_COLS = 80;
7367 const DEFAULT_TERMINAL_ROWS = 24;
@@ -153,19 +147,22 @@ function pickRunningTerminalSessionForBootstrap( · 79 unmodified lines
153147 );
154148 }
155149
156−export function ThreadTerminalRouteScreen() {
157− const router = useRouter();
150+type ThreadTerminalRouteScreenProps = StaticScreenProps<{
151+ readonly environmentId: string;
152+ readonly threadId: string;
153+ readonly terminalId?: string;
154+}>;
155+
156+export function ThreadTerminalRouteScreen(props: ThreadTerminalRouteScreenProps) {
157+ const navigation = useNavigation();
158158 const writeTerminal = useAtomCommand(terminalEnvironment.write, "terminal write");
159159 const resizeTerminal = useAtomCommand(terminalEnvironment.resize, "terminal resize");
160160 const clearTerminal = useAtomCommand(terminalEnvironment.clear, "terminal clear");
161161 const retryEnvironment = useAtomCommand(environmentCatalog.retryNow, "environment retry");
162162 const appearanceScheme = useColorScheme() === "light" ? "light" : "dark";
163163 const { state: workspaceState } = useWorkspaceState();
164− const params = useLocalSearchParams<{
165− environmentId?: string | string[];
166− threadId?: string | string[];
167− terminalId?: string | string[];
168− }>();
164+ const { layout, panes, togglePrimarySidebar } = useAdaptiveWorkspaceLayout();
165+ const params = props.route.params;
169166 const { selectedThread, selectedThreadProject, selectedEnvironmentConnection } =
170167 useThreadSelection();
171168 const selectedThreadDetail = useSelectedThreadDetail();
@@ -179,7 +176,12 @@ export function ThreadTerminalRouteScreen() { · 7 unmodified lines
179176 const isEnvironmentReady = environment.presentation?.connection.phase === "connected";
180177 const requestedTerminalId = firstRouteParam(params.terminalId);
181178 const terminalId = requestedTerminalId ?? DEFAULT_TERMINAL_ID;
182− const cachedFontSize = getCachedTerminalFontSize();
179+ const {
180+ isReady: hasResolvedFontPreference,
181+ appearance,
182+ setTerminalFontSize,
183+ } = useAppearancePreferences();
184+ const fontSize = appearance.terminalFontSize;
183185 const cachedRouteGridSize =
184186 routeEnvironmentId && routeThreadId
185187 ? getCachedTerminalGridSize({
@@ -239,7 +241,6 @@ export function ThreadTerminalRouteScreen() { · 53 unmodified lines
239241 rows: DEFAULT_TERMINAL_ROWS,
240242 },
241243 );
242− const [fontSize, setFontSize] = useState(cachedFontSize ?? DEFAULT_TERMINAL_FONT_SIZE);
243244 const [keyboardFocusRequest, setKeyboardFocusRequest] = useState(0);
244245 const [isAccessoryDismissed, setIsAccessoryDismissed] = useState(false);
245246 const bufferReplayTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
@@ -247,9 +248,6 @@ export function ThreadTerminalRouteScreen() { · 1 unmodified line
247248 const lastBufferReplayKeyRef = useRef<string | null>(null);
248249 const sentInitialInputKeyRef = useRef<string | null>(null);
249250 const [readyBufferReplayKey, setReadyBufferReplayKey] = useState<string | null>(null);
250− const [hasResolvedFontPreference, setHasResolvedFontPreference] = useState(
251− cachedFontSize !== null,
252− );
253251 /** Default grid is always valid for attach; onResize refines cols/rows. Requiring a cached size blocked bootstrap for new terminal routes. */
254252 const [hasMeasuredSurface, setHasMeasuredSurface] = useState(true);
255253 const [pendingModifierState, setPendingModifierState] = useState<{
@@ -404,24 +402,10 @@ export function ThreadTerminalRouteScreen() { · 148 unmodified lines
404402 );
405403
406404 const terminalTheme = getPierreTerminalTheme(appearanceScheme);
405+ const usesNativeHeaderGlass = Platform.OS === "ios";
407406 const pendingModifier =
408407 pendingModifierState.terminalId === terminalId ? pendingModifierState.value : null;
409− const headerTitle = useMemo(() => {
410− const topLineParts = [
411− selectedEnvironmentConnection?.environmentLabel ?? null,
412− selectedThreadProject?.title ?? null,
413− ].filter((value): value is string => Boolean(value));
414−
415− return {
416− topLine: topLineParts.join(" \u00b7 "),
417− bottomLine: cwd ?? selectedThreadProject?.workspaceRoot ?? "",
418− };
419− }, [
420− cwd,
421− selectedEnvironmentConnection?.environmentLabel,
422− selectedThreadProject?.title,
423− selectedThreadProject?.workspaceRoot,
424− ]);
408+ const headerSubtitle = selectedThreadProject?.title ?? "";
425409 const terminalToolbarActions = useMemo<ReadonlyArray<TerminalToolbarAction>>(() => {
426410 const modifierActions: ReadonlyArray<TerminalToolbarAction> =
427411 hostPlatform === "mac"
@@ -546,8 +530,14 @@ export function ThreadTerminalRouteScreen() { · 118 unmodified lines
546530 if (!shouldRedirectToRunningTerminal || !selectedThread || !runningSession) {
547531 return;
548532 }
549− router.replace(buildThreadTerminalNavigation(selectedThread, runningSession.target.terminalId));
550− }, [router, runningSession, selectedThread, shouldRedirectToRunningTerminal]);
533+ navigation.dispatch(
534+ StackActions.replace("ThreadTerminal", {
535+ environmentId: String(selectedThread.environmentId),
536+ threadId: String(selectedThread.id),
537+ terminalId: runningSession.target.terminalId,
538+ }),
539+ );
540+ }, [navigation, runningSession, selectedThread, shouldRedirectToRunningTerminal]);
551541
552542 useEffect(() => {
553543 const initialInput = pendingLaunch?.initialInput;
@@ -637,42 +627,6 @@ export function ThreadTerminalRouteScreen() { · 83 unmodified lines
637627 setHasMeasuredSurface(true);
638628 }, [routeEnvironmentId, routeThreadId, terminalId]);
639629
640− useEffect(() => {
641− let cancelled = false;
642−
643− void loadPreferences()
644− .then((preferences) => {
645− if (cancelled) {
646− return;
647− }
648−
649− setFontSize(cacheTerminalFontSize(preferences.terminalFontSize));
650− setHasResolvedFontPreference(true);
651− })
652− .catch(() => {
653− if (cancelled) {
654− return;
655− }
656−
657− setHasResolvedFontPreference(true);
658− });
659−
660− return () => {
661− cancelled = true;
662− };
663− }, []);
664−
665− useEffect(() => {
666− if (!hasResolvedFontPreference) {
667− return;
668− }
669−
670− cacheTerminalFontSize(fontSize);
671− void savePreferencesPatch({
672− terminalFontSize: normalizeTerminalFontSize(fontSize),
673− });
674− }, [fontSize, hasResolvedFontPreference]);
675−
676630 const writeInput = useCallback(
677631 (data: string) => {
678632 if (!selectedThread || !isRunning) {
@@ -772,9 +726,15 @@ export function ThreadTerminalRouteScreen() { · 93 unmodified lines
772726 return;
773727 }
774728
775− router.replace(buildThreadTerminalNavigation(selectedThread, nextTerminalId));
729+ navigation.dispatch(
730+ StackActions.replace("ThreadTerminal", {
731+ environmentId: String(selectedThread.environmentId),
732+ threadId: String(selectedThread.id),
733+ terminalId: nextTerminalId,
734+ }),
735+ );
776736 },
777− [router, selectedThread, terminalId],
737+ [navigation, selectedThread, terminalId],
778738 );
779739
780740 const handleOpenNewTerminal = useCallback(() => {
@@ -782,30 +742,25 @@ export function ThreadTerminalRouteScreen() { · 1 unmodified line
782742 return;
783743 }
784744
785− router.replace(
786− buildThreadTerminalNavigation(
787− selectedThread,
788− nextOpenTerminalId({
745+ navigation.dispatch(
746+ StackActions.replace("ThreadTerminal", {
747+ environmentId: String(selectedThread.environmentId),
748+ threadId: String(selectedThread.id),
749+ terminalId: nextOpenTerminalId({
789750 listedTerminalIds: terminalMenuSessions.map((session) => session.terminalId),
790751 activeRouteTerminalId: terminalId,
791752 }),
792− ),
753+ }),
793754 );
794− }, [router, selectedThread, terminalId, terminalMenuSessions]);
795−
796− const adjustFontSize = useCallback((delta: number) => {
797− setTimeout(() => {
798− setFontSize((current) => cacheTerminalFontSize(current + delta));
799− }, 0);
800− }, []);
755+ }, [navigation, selectedThread, terminalId, terminalMenuSessions]);
801756
802757 const handleDecreaseFontSize = useCallback(() => {
803− adjustFontSize(-TERMINAL_FONT_SIZE_STEP);
804− }, [adjustFontSize]);
758+ setTerminalFontSize(stepTerminalFontSize(fontSize, -1));
759+ }, [fontSize, setTerminalFontSize]);
805760
806761 const handleIncreaseFontSize = useCallback(() => {
807− adjustFontSize(TERMINAL_FONT_SIZE_STEP);
808− }, [adjustFontSize]);
762+ setTerminalFontSize(stepTerminalFontSize(fontSize, 1));
763+ }, [fontSize, setTerminalFontSize]);
809764
810765 const handleClearTerminal = useCallback(() => {
811766 if (!selectedThread) {
@@ -898,80 +853,58 @@ export function ThreadTerminalRouteScreen() { · 86 unmodified lines
898853
899854 return (
900855 <>
901− <Stack.Screen
856+ <NativeStackScreenOptions
902857 options={{
903− headerShown: true,
904− headerBackButtonDisplayMode: "minimal",
905− headerBackTitle: "",
906− headerShadowVisible: false,
907− headerStyle: { backgroundColor: terminalTheme.background },
908− headerTintColor: terminalTheme.foreground,
909− headerTitleAlign: "center",
910− title: "",
911− headerTitle: () => (
912− <View
913− style={{
914− alignItems: "center",
915− gap: 1,
916− maxWidth: 240,
917− }}
918− >
919− <RNText
920− numberOfLines={1}
921− style={{
922− color: terminalTheme.foreground,
923− fontFamily: "DMSans_700Bold",
924− fontSize: MOBILE_TYPOGRAPHY.footnote.fontSize,
925− lineHeight: 16,
926− }}
927− >
928− {headerTitle.topLine}
929− </RNText>
930− <RNText
931− ellipsizeMode="middle"
932− numberOfLines={1}
933− style={{
934− color: terminalTheme.mutedForeground,
935− fontFamily: "Menlo",
936− fontSize: MOBILE_TYPOGRAPHY.caption.fontSize,
937− lineHeight: 14,
938− }}
939− >
940− {headerTitle.bottomLine}
941− </RNText>
942− </View>
943− ),
858+ // Static header config lives in Stack.tsx (SOLID_HEADER_OPTIONS — the pty
859+ // scrolls internally, nothing for glass to sample). Default title/subtitle
860+ // styling, like every other page.
861+ title: "Terminal",
862+ unstable_headerSubtitle:
863+ usesNativeHeaderGlass && headerSubtitle.length > 0 ? headerSubtitle : undefined,
944864 }}
945865 />
946866
867+ {layout.usesSplitView ? (
868+ <NativeHeaderToolbar placement="left">
869+ <NativeHeaderToolbar.Button
870+ accessibilityLabel={panes.primarySidebarVisible ? "Maximize terminal" : "Show threads"}
871+ icon={
872+ panes.primarySidebarVisible ? "arrow.up.left.and.arrow.down.right" : "sidebar.left"
873+ }
874+ onPress={togglePrimarySidebar}
875+ separateBackground
876+ />
877+ </NativeHeaderToolbar>
878+ ) : null}
879+
947880 {isEnvironmentReady ? (
948− <Stack.Toolbar placement="right">
949− <Stack.Toolbar.Menu icon="terminal" title="Terminal options" separateBackground>
950− <Stack.Toolbar.Label>
881+ <NativeHeaderToolbar placement="right">
882+ <NativeHeaderToolbar.Menu icon="terminal" title="Terminal options" separateBackground>
883+ <NativeHeaderToolbar.Label>
951884 {getTerminalStatusLabel({
952885 status: terminal.status,
953886 hasRunningSubprocess: terminal.hasRunningSubprocess,
954887 })}
955− </Stack.Toolbar.Label>
956− <Stack.Toolbar.Menu icon="textformat.size" inline title="Text size">
957− <Stack.Toolbar.Label>Text size</Stack.Toolbar.Label>
958− <Stack.Toolbar.MenuAction
888+ </NativeHeaderToolbar.Label>
889+ <NativeHeaderToolbar.Menu icon="textformat.size" inline title="Text size">
890+ <NativeHeaderToolbar.Label>Text size</NativeHeaderToolbar.Label>
891+ <NativeHeaderToolbar.MenuAction
959892 disabled={fontSize <= MIN_TERMINAL_FONT_SIZE}
960893 discoverabilityLabel="Decrease terminal text size"
961894 onPress={handleDecreaseFontSize}
962895 >
963− <Stack.Toolbar.Label>{`A- ${Math.max(MIN_TERMINAL_FONT_SIZE, fontSize - TERMINAL_FONT_SIZE_STEP).toFixed(1)} pt`}</Stack.Toolbar.Label>
964− </Stack.Toolbar.MenuAction>
965− <Stack.Toolbar.MenuAction
896+ <NativeHeaderToolbar.Label>{`A- ${Math.max(MIN_TERMINAL_FONT_SIZE, fontSize - TERMINAL_FONT_SIZE_STEP).toFixed(1)} pt`}</NativeHeaderToolbar.Label>
897+ </NativeHeaderToolbar.MenuAction>
898+ <NativeHeaderToolbar.MenuAction
966899 disabled={fontSize >= MAX_TERMINAL_FONT_SIZE}
967900 discoverabilityLabel="Increase terminal text size"
968901 onPress={handleIncreaseFontSize}
969902 >
970− <Stack.Toolbar.Label>{`A+ ${Math.min(MAX_TERMINAL_FONT_SIZE, fontSize + TERMINAL_FONT_SIZE_STEP).toFixed(1)} pt`}</Stack.Toolbar.Label>
971− </Stack.Toolbar.MenuAction>
972− </Stack.Toolbar.Menu>
903+ <NativeHeaderToolbar.Label>{`A+ ${Math.min(MAX_TERMINAL_FONT_SIZE, fontSize + TERMINAL_FONT_SIZE_STEP).toFixed(1)} pt`}</NativeHeaderToolbar.Label>
904+ </NativeHeaderToolbar.MenuAction>
905+ </NativeHeaderToolbar.Menu>
973906 {terminalMenuSessions.map((session) => (
974− <Stack.Toolbar.MenuAction
907+ <NativeHeaderToolbar.MenuAction
975908 key={session.terminalId}
976909 icon={session.terminalId === terminalId ? "checkmark" : "terminal"}
977910 onPress={() => handleSelectTerminal(session.terminalId)}
@@ -982,18 +915,18 @@ export function ThreadTerminalRouteScreen() { · 4 unmodified lines
982915 .filter(Boolean)
983916 .join(" · ")}
984917 >
985− <Stack.Toolbar.Label>{session.displayLabel}</Stack.Toolbar.Label>
986− </Stack.Toolbar.MenuAction>
918+ <NativeHeaderToolbar.Label>{session.displayLabel}</NativeHeaderToolbar.Label>
919+ </NativeHeaderToolbar.MenuAction>
987920 ))}
988− <Stack.Toolbar.MenuAction
921+ <NativeHeaderToolbar.MenuAction
989922 icon="plus"
990923 onPress={handleOpenNewTerminal}
991924 subtitle={`Start another shell in ${basename(selectedThreadProject.workspaceRoot) ?? "this workspace"}`}
992925 >
993− <Stack.Toolbar.Label>Open new terminal</Stack.Toolbar.Label>
994− </Stack.Toolbar.MenuAction>
995− </Stack.Toolbar.Menu>
996− </Stack.Toolbar>
926+ <NativeHeaderToolbar.Label>Open new terminal</NativeHeaderToolbar.Label>
927+ </NativeHeaderToolbar.MenuAction>
928+ </NativeHeaderToolbar.Menu>
929+ </NativeHeaderToolbar>
997930 ) : null}
998931
999932 <View style={{ flex: 1, backgroundColor: terminalTheme.background }}>
apps/mobile/src/features/terminal/nativeTerminalModule.ts+21−1
@@ -1,6 +1,6 @@
11 import type { ComponentType } from "react";
22 import type { NativeSyntheticEvent, ViewProps } from "react-native";
3−import { requireNativeView } from "expo";
3+import { requireNativeView, requireOptionalNativeModule } from "expo";
44
55 import { NativeViewResolutionError } from "../../native/nativeViewResolutionError";
66
@@ -23,6 +23,7 @@ interface TerminalResizeEvent { · 16 unmodified lines
2323
2424 export interface NativeTerminalSurfaceProps extends ViewProps {
2525 readonly appearanceScheme?: "light" | "dark";
26+ readonly focusRequest?: number;
2627 readonly themeConfig?: string;
2728 readonly backgroundColor?: string;
2829 readonly foregroundColor?: string;
@@ -74,6 +75,25 @@ export function resolveNativeTerminalSurfaceView(): ComponentType<NativeTerminal · 45 unmodified lines
7475 return cachedNativeTerminalSurfaceView ?? null;
7576 }
7677
78+/**
79+ * Revision of the native hardware-keyboard handling compiled into the installed binary,
80+ * or `null` when the binary predates the revision constant (or the module is missing).
81+ * Used in terminal debug logs to detect stale native builds.
82+ */
83+export function getNativeTerminalHardwareKeyRevision(): number | null {
84+ try {
85+ if (typeof requireOptionalNativeModule !== "function") {
86+ return null;
87+ }
88+ const module = requireOptionalNativeModule<{ readonly hardwareKeyRevision?: number }>(
89+ NATIVE_TERMINAL_MODULE_NAME,
90+ );
91+ return module?.hardwareKeyRevision ?? null;
92+ } catch {
93+ return null;
94+ }
95+}
96+
7797 export function hasNativeTerminalSurface() {
7898 return resolveNativeTerminalSurfaceView() !== null;
7999 }
apps/mobile/src/features/terminal/terminalPreferences.ts+1−1
@@ -1,4 +1,4 @@
1−export const DEFAULT_TERMINAL_FONT_SIZE = 10;
1+export const DEFAULT_TERMINAL_FONT_SIZE = 10.5;
22 export const TERMINAL_FONT_SIZE_STEP = 0.5;
33 export const MIN_TERMINAL_FONT_SIZE = 6;
44 export const MAX_TERMINAL_FONT_SIZE = 14;
apps/mobile/src/features/threads/ComposerCommandPopover.tsx+1−3
@@ -7,8 +7,6 @@ import { Pressable, ScrollView, useColorScheme, View, type ViewStyle } from "rea · 6 unmodified lines
77
88 import { AppText as Text } from "../../components/AppText";
99 import { PierreEntryIcon } from "../../components/PierreEntryIcon";
10−import { MOBILE_TYPOGRAPHY } from "../../lib/typography";
11−
1210 export type ComposerCommandItem =
1311 | {
1412 readonly id: string;
@@ -165,11 +163,11 @@ const CommandRow = memo(function CommandRow(props: { · 150 unmodified lines
165163 </Text>
166164 {props.item.description ? (
167165 <Text
166+ className="text-xs"
168167 numberOfLines={1}
169168 style={{
170169 flex: 1,
171170 minWidth: 0,
172− fontSize: MOBILE_TYPOGRAPHY.label.fontSize,
173171 color: "#a1a1aa",
174172 }}
175173 >
apps/mobile/src/features/threads/NewTaskDraftRouteScreen.tsx+45−0
@@ -0,0 +1,45 @@
1+import type { StaticScreenProps } from "@react-navigation/native";
2+import { useMemo } from "react";
3+import { NativeStackScreenOptions } from "../../native/StackHeader";
4+
5+import { NewTaskDraftScreen } from "./NewTaskDraftScreen";
6+
7+type NewTaskDraftRouteParams = {
8+ readonly environmentId?: string | string[];
9+ readonly projectId?: string | string[];
10+ readonly title?: string | string[];
11+ readonly pendingTaskId?: string | string[];
12+};
13+
14+export function NewTaskDraftRouteScreen({ route }: StaticScreenProps<NewTaskDraftRouteParams>) {
15+ const params = route.params ?? {};
16+
17+ // Keyed on the params object so a fresh navigation to this (already
18+ // mounted) screen produces a new reference, letting the draft screen
19+ // re-apply the requested project.
20+ const initialProjectRef = useMemo(
21+ () => ({
22+ environmentId: Array.isArray(params.environmentId)
23+ ? params.environmentId[0]
24+ : params.environmentId,
25+ projectId: Array.isArray(params.projectId) ? params.projectId[0] : params.projectId,
26+ }),
27+ [route.params],
28+ );
29+
30+ return (
31+ <>
32+ <NativeStackScreenOptions
33+ options={{
34+ title: Array.isArray(params.title) ? params.title[0] : (params.title ?? "New task"),
35+ }}
36+ />
37+ <NewTaskDraftScreen
38+ initialProjectRef={initialProjectRef}
39+ pendingTaskId={
40+ Array.isArray(params.pendingTaskId) ? params.pendingTaskId[0] : params.pendingTaskId
41+ }
42+ />
43+ </>
44+ );
45+}
apps/mobile/src/features/threads/NewTaskDraftScreen.tsx+178−34
@@ -1,4 +1,5 @@
1−import { Stack, useRouter } from "expo-router";
1+import { NativeStackScreenOptions } from "../../native/StackHeader";
2+import { StackActions, useNavigation } from "@react-navigation/native";
23 import { useCallback, useEffect, useMemo, useRef } from "react";
34 import { Alert, InteractionManager, View, useColorScheme } from "react-native";
45 import { KeyboardAvoidingView, useKeyboardState } from "react-native-keyboard-controller";
@@ -22,6 +23,7 @@ import { ComposerAttachmentStrip } from "../../components/ComposerAttachmentStri · 17 unmodified lines
2223 import { ControlPillMenu } from "../../components/ControlPill";
2324 import { ProviderIcon } from "../../components/ProviderIcon";
2425
26+import { makeTurnCommandMetadata } from "../../lib/commandMetadata";
2527 import { convertPastedImagesToAttachments, pickComposerImages } from "../../lib/composerImages";
2628 import {
2729 applyProviderOptionMenuEvent,
@@ -29,11 +31,11 @@ import { · 1 unmodified line
2931 providerOptionsConfigurationLabel,
3032 resolveProviderOptionDescriptors,
3133 } from "../../lib/providerOptions";
32−import { buildThreadRoutePath } from "../../lib/routes";
33−import { scopedProjectKey } from "../../lib/scopedEntities";
34−import { MOBILE_TYPOGRAPHY } from "../../lib/typography";
34+import { useScaledTextRole } from "../settings/appearance/useScaledTextRole";
3535 import { getComposerDraftSnapshot } from "../../state/use-composer-drafts";
3636 import { useProjects } from "../../state/entities";
37+import { enqueueThreadOutboxMessage, removeThreadOutboxMessage } from "../../state/thread-outbox";
38+import { useRemoteConnectionStatus } from "../../state/use-remote-environment-registry";
3739 import { branchBadgeLabel, useNewTaskFlow } from "./new-task-flow-provider";
3840 import { useCreateProjectThread } from "./use-project-actions";
3941
@@ -54,25 +56,79 @@ export function NewTaskDraftScreen(props: { · 14 unmodified lines
5456 readonly environmentId?: string;
5557 readonly projectId?: string;
5658 };
59+ /** Queued outbox message id when editing an existing pending task. */
60+ readonly pendingTaskId?: string;
5761 }) {
5862 const projects = useProjects();
5963 const createProjectThread = useCreateProjectThread();
6064 const flow = useNewTaskFlow();
61− const router = useRouter();
65+ const navigation = useNavigation();
6266 const insets = useSafeAreaInsets();
6367 const colorScheme = useColorScheme();
6468 const isKeyboardVisible = useKeyboardState((state) => state.isVisible);
6569 const controlsBottomPadding = isKeyboardVisible ? 8 : Math.max(insets.bottom, 10);
6670 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";
6777 const promptInputRef = useRef<ComposerEditorHandle>(null);
6878 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]);
70112
71113 const borderColor = useThemeColor("--color-border");
114+ const headlineText = useScaledTextRole("headline");
72115 const sheetFadeOpaque = colorScheme === "dark" ? "rgba(14,14,14,0.98)" : "rgba(242,242,247,0.98)";
73116 const sheetFadeTransparent = colorScheme === "dark" ? "rgba(14,14,14,0)" : "rgba(242,242,247,0)";
74117
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+
75122 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+ }
76132 const initialEnvironmentId = props.initialProjectRef?.environmentId;
77133 const initialProjectId = props.initialProjectRef?.projectId;
78134 if (initialEnvironmentId && initialProjectId) {
@@ -83,19 +139,20 @@ export function NewTaskDraftScreen(props: { · 4 unmodified lines
83139 ) ?? null;
84140
85141 if (directProject) {
86− // Honor the route's project ref only once. Re-applying it on every
87− // selection change would immediately revert a manual computer switch,
88− // silently snapping back to the machine the draft was opened from.
89− const initialKey = `${initialEnvironmentId}:${initialProjectId}`;
90− if (appliedInitialRefKeyRef.current !== initialKey) {
91− appliedInitialRefKeyRef.current = initialKey;
92− if (
93− selectedProject?.environmentId !== directProject.environmentId ||
94− selectedProject.id !== directProject.id
95− ) {
96− setProject(directProject);
97− }
142+ // Apply the route's project once. Re-applying on every change would
143+ // instantly revert environment/project switches made in the picker.
144+ const directProjectKey = `${directProject.environmentId}:${directProject.id}`;
145+ if (appliedInitialProjectKeyRef.current === directProjectKey) {
146+ return;
98147 }
148+ appliedInitialProjectKeyRef.current = directProjectKey;
149+ if (
150+ selectedProject?.environmentId === directProject.environmentId &&
151+ selectedProject.id === directProject.id
152+ ) {
153+ return;
154+ }
155+ setProject(directProject);
99156 return;
100157 }
101158 }
@@ -109,13 +166,13 @@ export function NewTaskDraftScreen(props: { · 7 unmodified lines
109166 return;
110167 }
111168
112− router.replace("/new");
169+ navigation.dispatch(StackActions.replace("NewTask"));
113170 }, [
114171 logicalProjects,
115172 projects,
116− props.initialProjectRef?.environmentId,
117− props.initialProjectRef?.projectId,
118− router,
173+ props.initialProjectRef,
174+ props.pendingTaskId,
175+ navigation,
119176 selectedProject,
120177 setProject,
121178 ]);
@@ -281,12 +338,24 @@ export function NewTaskDraftScreen(props: { · 159 unmodified lines
281338 subtitle: flow.selectedBranchName ?? "Choose branch",
282339 subactions: branchActions,
283340 },
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+ : []),
284352 ];
285353 }, [
286354 flow.availableBranches,
287355 flow.branchesLoading,
288356 flow.selectedBranchName,
289357 flow.selectedProject,
358+ flow.startFromOrigin,
290359 flow.workspaceMode,
291360 ]);
292361
@@ -351,6 +420,10 @@ export function NewTaskDraftScreen(props: { · 58 unmodified lines
351420 );
352421 return;
353422 }
423+ if (event === "workspace:start-from-origin") {
424+ flow.setStartFromOrigin(!flow.startFromOrigin);
425+ return;
426+ }
354427 if (event.startsWith("workspace:branch:")) {
355428 const branchName = event.slice("workspace:branch:".length);
356429 const branch = flow.availableBranches.find((candidate) => candidate.name === branchName);
@@ -386,17 +459,17 @@ export function NewTaskDraftScreen(props: { · 29 unmodified lines
386459
387460 async function handleStart(): Promise<void> {
388461 const selectedProject = flow.selectedProject;
389− if (!selectedProject) {
462+ const draftKey = flow.draftKey;
463+ if (!selectedProject || !draftKey) {
390464 return;
391465 }
392− const draft = getComposerDraftSnapshot(
393− `new-task:${scopedProjectKey(selectedProject.environmentId, selectedProject.id)}`,
394− );
466+ const draft = getComposerDraftSnapshot(draftKey);
395467 const modelSelection = draft.modelSelection ?? flow.selectedModel;
396468 const workspaceMode = draft.workspaceSelection?.mode ?? flow.workspaceMode;
397469 const selectedBranchName = draft.workspaceSelection?.branch ?? flow.selectedBranchName;
398470 const selectedWorktreePath =
399471 draft.workspaceSelection?.worktreePath ?? flow.selectedWorktreePath;
472+ const startFromOrigin = draft.workspaceSelection?.startFromOrigin ?? flow.startFromOrigin;
400473 const runtimeMode = draft.runtimeMode ?? flow.runtimeMode;
401474 const interactionMode = draft.interactionMode ?? flow.interactionMode;
402475 const initialMessageText = draft.text.trim();
@@ -410,6 +483,46 @@ export function NewTaskDraftScreen(props: { · 7 unmodified lines
410483 return;
411484 }
412485
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+
413526 flow.setSubmitting(true);
414527 const result = await createProjectThread({
415528 project: selectedProject,
@@ -417,10 +530,21 @@ export function NewTaskDraftScreen(props: { · 1 unmodified line
417530 envMode: workspaceMode,
418531 branch: selectedBranchName,
419532 worktreePath: workspaceMode === "worktree" ? null : selectedWorktreePath,
533+ startFromOrigin,
420534 runtimeMode,
421535 interactionMode,
422536 initialMessageText,
423537 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+ : {}),
424548 });
425549 flow.setSubmitting(false);
426550
@@ -435,22 +559,36 @@ export function NewTaskDraftScreen(props: { · 8 unmodified lines
435559 return;
436560 }
437561
438− flow.setPrompt("");
439− flow.clearAttachments();
440− router.replace(buildThreadRoutePath(result.value));
562+ if (editingPendingTask) {
563+ try {
564+ await removeThreadOutboxMessage(editingPendingTask);
565+ } catch (error) {
566+ console.warn("[new-task] failed to remove delivered pending task", error);
567+ }
568+ flow.finishEditingPendingTask();
569+ } else {
570+ flow.setPrompt("");
571+ flow.clearAttachments();
572+ }
573+ navigation.dispatch(
574+ StackActions.replace("Thread", {
575+ environmentId: String(result.value.environmentId),
576+ threadId: String(result.value.threadId),
577+ }),
578+ );
441579 }
442580
443581 if (!selectedProject) {
444582 return (
445583 <View className="flex-1 bg-sheet">
446− <Stack.Screen options={{ title: "Loading task" }} />
584+ <NativeStackScreenOptions options={{ title: "Loading task" }} />
447585 </View>
448586 );
449587 }
450588
451589 return (
452590 <View className="flex-1 bg-sheet">
453− <Stack.Screen options={{ title: selectedProject.title }} />
591+ <NativeStackScreenOptions options={{ title: selectedProject.title }} />
454592
455593 <KeyboardAvoidingView automaticOffset behavior="padding" style={{ flex: 1 }}>
456594 <View style={{ flex: 1, minHeight: 0, paddingHorizontal: 20, paddingTop: 8 }}>
@@ -465,7 +603,7 @@ export function NewTaskDraftScreen(props: { · 8 unmodified lines
465603 onPasteImages={(uris) => void handleNativePasteImages(uris)}
466604 placeholder={`Describe a coding task in ${selectedProject.title}`}
467605 style={{ flex: 1, minHeight: 0 }}
468− textStyle={MOBILE_TYPOGRAPHY.headline}
606+ textStyle={headlineText}
469607 />
470608 </View>
471609
@@ -540,8 +678,14 @@ export function NewTaskDraftScreen(props: { · 68 unmodified lines
540678 </ControlPillMenu>
541679 </ComposerToolbarScroller>
542680 <ComposerToolbarButton
543− accessibilityLabel={flow.submitting ? "Starting task" : "Start task"}
544− icon="arrow.up"
681+ accessibilityLabel={
682+ flow.submitting
683+ ? "Starting task"
684+ : environmentConnected
685+ ? "Start task"
686+ : "Queue task"
687+ }
688+ icon={environmentConnected ? "arrow.up" : "tray.and.arrow.up"}
545689 onPress={() => void handleStart()}
546690 variant="primary"
547691 showChevron={false}
apps/mobile/src/features/threads/NewTaskRouteScreen.tsxrenamed from apps/mobile/src/app/new/index.tsx+61−52
@@ -1,4 +1,5 @@
1−import { Link, Stack, useRouter } from "expo-router";
1+import { NativeHeaderToolbar } from "../../native/StackHeader";
2+import { useNavigation } from "@react-navigation/native";
23 import { SymbolView } from "expo-symbols";
34 import type { EnvironmentId, ProjectId } from "@t3tools/contracts";
45 import { useMemo } from "react";
@@ -12,6 +13,7 @@ import { useProjects, useThreadShells } from "../../state/entities"; · 7 unmodified lines
1213 import type { WorkspaceState } from "../../state/workspaceModel";
1314 import { useWorkspaceState } from "../../state/workspace";
1415 import { groupProjectsByRepository } from "../../lib/repositoryGroups";
16+import { useAdaptiveWorkspaceLayout } from "../layout/AdaptiveWorkspaceLayout";
1517
1618 function deriveProjectEmptyState(catalogState: WorkspaceState): {
1719 readonly title: string;
@@ -68,11 +70,12 @@ function deriveProjectEmptyState(catalogState: WorkspaceState): { · 50 unmodified lines
6870 };
6971 }
7072
71−export default function NewTaskRoute() {
73+export function NewTaskRouteScreen() {
7274 const projects = useProjects();
7375 const threads = useThreadShells();
7476 const { state: catalogState } = useWorkspaceState();
75− const router = useRouter();
77+ const navigation = useNavigation();
78+ const { layout } = useAdaptiveWorkspaceLayout();
7679 const insets = useSafeAreaInsets();
7780 const chevronColor = useThemeColor("--color-chevron");
7881 const accentColor = useThemeColor("--color-icon-muted");
@@ -108,16 +111,24 @@ export default function NewTaskRoute() { · 29 unmodified lines
108111
109112 return (
110113 <View collapsable={false} className="flex-1 bg-sheet">
111− <Stack.Screen options={{ title: "Choose project" }} />
112− <Stack.Toolbar placement="right">
113− <Stack.Toolbar.Button
114+ <NativeHeaderToolbar placement="right">
115+ {layout.usesSplitView ? (
116+ <NativeHeaderToolbar.Button
117+ accessibilityLabel="Close new task"
118+ icon="xmark"
119+ onPress={() => navigation.goBack()}
120+ separateBackground
121+ />
122+ ) : null}
123+ <NativeHeaderToolbar.Button
114124 icon="plus"
115− onPress={() => router.push("/new/add-project")}
125+ onPress={() => navigation.navigate("NewTaskSheet", { screen: "AddProject" })}
116126 separateBackground
117127 />
118− </Stack.Toolbar>
128+ </NativeHeaderToolbar>
119129
120130 <ScrollView
131+ contentInsetAdjustmentBehavior="automatic"
121132 showsVerticalScrollIndicator={false}
122133 style={{ flex: 1 }}
123134 contentInset={{ bottom: Math.max(insets.bottom, 18) + 18 }}
@@ -132,13 +143,13 @@ export default function NewTaskRoute() { · 8 unmodified lines
132143 <Text className="text-center text-lg font-t3-bold text-foreground">
133144 {projectEmptyState.title}
134145 </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">
136147 {projectEmptyState.detail}
137148 </Text>
138149 {!catalogState.hasReadyEnvironment ? (
139150 <Pressable
140151 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")}
142153 >
143154 <Text className="text-sm font-t3-bold text-primary-foreground">
144155 Add environment
@@ -147,7 +158,7 @@ export default function NewTaskRoute() { · 2 unmodified lines
147158 ) : (
148159 <Pressable
149160 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" })}
151162 >
152163 <Text className="text-sm font-t3-bold text-primary-foreground">
153164 Add new project
@@ -162,52 +173,50 @@ export default function NewTaskRoute() { · 8 unmodified lines
162173 const isLast = index === items.length - 1;
163174
164175 return (
165− <Link
176+ <Pressable
166177 key={item.key}
167− href={{
168− pathname: "/new/draft",
169− params: {
170− environmentId: item.environmentId,
171− projectId: item.id,
172− title: item.title,
173− },
178+ className="bg-card"
179+ onPress={() =>
180+ navigation.navigate("NewTaskSheet", {
181+ screen: "NewTaskDraft",
182+ params: {
183+ environmentId: item.environmentId,
184+ projectId: item.id,
185+ title: item.title,
186+ },
187+ })
188+ }
189+ style={{
190+ paddingHorizontal: 16,
191+ paddingVertical: 14,
192+ borderTopWidth: isFirst ? 0 : 1,
193+ borderTopColor: borderSubtleColor,
194+ borderTopLeftRadius: isFirst ? 24 : 0,
195+ borderTopRightRadius: isFirst ? 24 : 0,
196+ borderBottomLeftRadius: isLast ? 24 : 0,
197+ borderBottomRightRadius: isLast ? 24 : 0,
174198 }}
175− asChild
176199 >
177− <Pressable
178− className="bg-card"
179− style={{
180− paddingHorizontal: 16,
181− paddingVertical: 14,
182− borderTopWidth: isFirst ? 0 : 1,
183− borderTopColor: borderSubtleColor,
184− borderTopLeftRadius: isFirst ? 24 : 0,
185− borderTopRightRadius: isFirst ? 24 : 0,
186− borderBottomLeftRadius: isLast ? 24 : 0,
187− borderBottomRightRadius: isLast ? 24 : 0,
188− }}
189− >
190− <View className="flex-row items-center justify-between gap-3">
191− <View className="h-7 w-7 items-center justify-center">
192− <ProjectFavicon
193− environmentId={item.environmentId}
194− size={20}
195− projectTitle={item.title}
196− workspaceRoot={item.workspaceRoot}
197− />
198− </View>
199− <View className="flex-1">
200− <Text className="text-base leading-[21px] font-t3-bold">{item.title}</Text>
201− </View>
202− <SymbolView
203− name="chevron.right"
204− size={14}
205− tintColor={chevronColor}
206− type="monochrome"
200+ <View className="flex-row items-center justify-between gap-3">
201+ <View className="h-7 w-7 items-center justify-center">
202+ <ProjectFavicon
203+ environmentId={item.environmentId}
204+ size={20}
205+ projectTitle={item.title}
206+ workspaceRoot={item.workspaceRoot}
207207 />
208208 </View>
209− </Pressable>
210− </Link>
209+ <View className="flex-1">
210+ <Text className="text-base leading-snug font-t3-bold">{item.title}</Text>
211+ </View>
212+ <SymbolView
213+ name="chevron.right"
214+ size={14}
215+ tintColor={chevronColor}
216+ type="monochrome"
217+ />
218+ </View>
219+ </Pressable>
211220 );
212221 })}
213222 </View>
apps/mobile/src/features/threads/PendingApprovalCard.tsx+1−1
@@ -23,7 +23,7 @@ export function PendingApprovalCard(props: PendingApprovalCardProps) { · 22 unmodified lines
2323 {props.approval.requestKind}
2424 </Text>
2525 {props.approval.detail ? (
26− <Text className="font-sans text-sm leading-5 text-neutral-600 dark:text-neutral-400">
26+ <Text className="font-sans text-sm leading-normal text-neutral-600 dark:text-neutral-400">
2727 {props.approval.detail}
2828 </Text>
2929 ) : null}
apps/mobile/src/features/threads/PendingUserInputCard.tsx+1−1
@@ -39,7 +39,7 @@ export function PendingUserInputCard(props: PendingUserInputCardProps) { · 38 unmodified lines
3939 <Text className="font-t3-bold text-xs uppercase tracking-[1px] text-neutral-500 dark:text-neutral-500">
4040 {question.header}
4141 </Text>
42− <Text className="font-sans text-base leading-[21px] text-neutral-950 dark:text-neutral-50">
42+ <Text className="font-sans text-base leading-snug text-neutral-950 dark:text-neutral-50">
4343 {question.question}
4444 </Text>
4545 <View className="flex-row flex-wrap gap-2.5">
apps/mobile/src/features/threads/ThreadComposer.tsx+87−39
@@ -26,6 +26,7 @@ import { · 25 unmodified lines
2626 } from "react-native";
2727 import ImageViewing from "react-native-image-viewing";
2828 import { useThemeColor } from "../../lib/useThemeColor";
29+import { scopedThreadKey } from "../../lib/scopedEntities";
2930
3031 import { AppText as Text } from "../../components/AppText";
3132 import { ComposerAttachmentStrip } from "../../components/ComposerAttachmentStrip";
@@ -44,7 +45,7 @@ import { ControlPill, ControlPillMenu } from "../../components/ControlPill"; · 12 unmodified lines
4445 import { ProviderIcon } from "../../components/ProviderIcon";
4546 import type { DraftComposerImageAttachment } from "../../lib/composerImages";
4647 import { buildModelOptions, groupByProvider } from "../../lib/modelOptions";
47−import { MOBILE_TYPOGRAPHY } from "../../lib/typography";
48+import { useScaledTextRole } from "../settings/appearance/useScaledTextRole";
4849 import type { RemoteClientConnectionState } from "../../lib/connection";
4950 import {
5051 insertRankedSearchResult,
@@ -76,10 +77,17 @@ export interface ThreadComposerProps { · 25 unmodified lines
7677 readonly draftMessage: string;
7778 readonly draftAttachments: ReadonlyArray<DraftComposerImageAttachment>;
7879 readonly placeholder: string;
80+ readonly contentMaxWidth?: number;
7981 readonly bottomInset?: number;
8082 readonly connectionState: RemoteClientConnectionState;
8183 readonly connectionError: string | null;
8284 readonly environmentLabel: string | null;
85+ /**
86+ * Message sync phase for the selected thread (drives the status pill):
87+ * "loading" = first fetch, nothing to show yet; "syncing" = cached messages
88+ * are on screen while they reconcile with the server.
89+ */
90+ readonly threadSyncPhase?: "loading" | "syncing" | null;
8391 readonly selectedThread: OrchestrationThreadShell;
8492 readonly serverConfig: T3ServerConfig | null;
8593 readonly queueCount: number;
@@ -109,41 +117,61 @@ function ComposerSurface(props: { · 23 unmodified lines
109117 readonly style: ViewStyle;
110118 readonly isDarkMode: boolean;
111119 }) {
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+
112131 if (isLiquidGlassSupported) {
113132 return (
114− <LiquidGlassView
115− effect="clear"
116− interactive
117− tintColor={props.isDarkMode ? "rgba(44,44,46,0.5)" : "rgba(255,255,255,0.45)"}
118− colorScheme={props.isDarkMode ? "dark" : "light"}
119− style={props.style}
120− >
121− {props.children}
122− </LiquidGlassView>
133+ <View style={shadowStyle}>
134+ <LiquidGlassView
135+ effect="regular"
136+ interactive
137+ colorScheme={props.isDarkMode ? "dark" : "light"}
138+ style={props.style}
139+ >
140+ {props.children}
141+ </LiquidGlassView>
142+ </View>
123143 );
124144 }
125145
126146 return (
127− <View
128− style={[
129− props.style,
130− {
131− backgroundColor: props.isDarkMode ? "rgba(44,44,46,0.96)" : "rgba(255,255,255,0.96)",
132− borderWidth: 1,
133− borderColor: props.isDarkMode ? "rgba(255,255,255,0.08)" : "rgba(0,0,0,0.06)",
134− },
135− ]}
136− >
137− {props.children}
147+ <View style={shadowStyle}>
148+ <View
149+ style={[
150+ props.style,
151+ {
152+ backgroundColor: props.isDarkMode ? "rgba(44,44,46,0.96)" : "rgba(255,255,255,0.96)",
153+ borderWidth: 1,
154+ borderColor: props.isDarkMode ? "rgba(255,255,255,0.08)" : "rgba(0,0,0,0.06)",
155+ },
156+ ]}
157+ >
158+ {props.children}
159+ </View>
138160 </View>
139161 );
140162 }
141163
164+type ComposerStatusPillState = {
165+ readonly kind: "unavailable" | "reconnecting" | "syncing";
166+ readonly label: string;
167+};
168+
142169 function composerConnectionStatus(input: {
143170 readonly connectionError: string | null;
144171 readonly connectionState: RemoteClientConnectionState;
145172 readonly environmentLabel: string | null;
146−}): { readonly kind: "unavailable" | "reconnecting"; readonly label: string } | null {
173+ readonly threadSyncPhase?: "loading" | "syncing" | null;
174+}): ComposerStatusPillState | null {
147175 const environmentLabel = input.environmentLabel ?? "Environment";
148176
149177 switch (input.connectionState) {
@@ -168,15 +196,27 @@ function composerConnectionStatus(input: { · 18 unmodified lines
168196 case "available":
169197 return { kind: "unavailable", label: `${environmentLabel} is not connected` };
170198 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:
171211 return null;
172212 }
173213 }
174214
175215 const ComposerConnectionStatusPill = memo(function ComposerConnectionStatusPill(props: {
176216 readonly onPress: () => void;
177− readonly status: { readonly kind: "unavailable" | "reconnecting"; readonly label: string };
217+ readonly status: ComposerStatusPillState;
178218 }) {
179− const isReconnecting = props.status.kind === "reconnecting";
219+ const isReconnecting = props.status.kind !== "unavailable";
180220
181221 return (
182222 <View className="items-center pb-2">
@@ -191,7 +231,7 @@ const ComposerConnectionStatusPill = memo(function ComposerConnectionStatusPill( · 8 unmodified lines
191231 <View className="h-2 w-2 rounded-full bg-red-500" />
192232 )}
193233 <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"
195235 numberOfLines={1}
196236 >
197237 {props.status.label}
@@ -204,10 +244,12 @@ const ComposerConnectionStatusPill = memo(function ComposerConnectionStatusPill( · 6 unmodified lines
204244 export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposerProps) {
205245 const isDarkMode = useColorScheme() === "dark";
206246 const foregroundColor = useThemeColor("--color-foreground");
247+ const bodyText = useScaledTextRole("body");
207248 const fallbackInputRef = useRef<ComposerEditorHandle>(null);
208249 const inputRef = props.editorRef ?? fallbackInputRef;
209250 const [isFocused, setIsFocused] = useState(false);
210251 const wasExpandedBeforePreviewRef = useRef(false);
252+ const inFlightThreadIdsRef = useRef(new Set<string>());
211253 const { onExpandedChange } = props;
212254
213255 const [previewImageUri, setPreviewImageUri] = useState<string | null>(null);
@@ -254,6 +296,7 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer · 40 unmodified lines
254296 connectionError: props.connectionError,
255297 connectionState: props.connectionState,
256298 environmentLabel: props.environmentLabel,
299+ threadSyncPhase: props.threadSyncPhase,
257300 });
258301 const toolbarFadeOpaque = isDarkMode ? "rgba(0,0,0,0.95)" : "rgba(255,255,255,0.95)";
259302 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
447490 // ── Handle command selection ──────────────────────────────
448491 const { onChangeDraftMessage, onUpdateInteractionMode, draftMessage, onSendMessage } = props;
449492
450− const handleSend = useCallback(() => {
451− void onSendMessage();
452− }, [onSendMessage]);
493+ const handleSend = useCallback(async () => {
494+ const threadKey = scopedThreadKey(props.environmentId, props.selectedThread.id);
495+ if (inFlightThreadIdsRef.current.has(threadKey)) return;
496+ inFlightThreadIdsRef.current.add(threadKey);
497+ try {
498+ await onSendMessage();
499+ } finally {
500+ inFlightThreadIdsRef.current.delete(threadKey);
501+ }
502+ }, [onSendMessage, props.environmentId, props.selectedThread.id]);
453503 const handleCommandSelect = useCallback(
454504 (item: ComposerCommandItem) => {
455505 if (!composerTrigger) return;
@@ -625,11 +675,14 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer · 169 unmodified lines
625675 paddingTop: isExpanded ? 8 : 6,
626676 paddingBottom: (props.bottomInset ?? 0) + (isExpanded ? 8 : 6),
627677 experimental_backgroundImage: isDarkMode
628− ? "linear-gradient(to bottom, rgba(0,0,0,0) 0%, rgba(0,0,0,0.85) 40%, rgba(0,0,0,0.95) 100%)"
629− : "linear-gradient(to bottom, rgba(255,255,255,0) 0%, rgba(255,255,255,0.85) 40%, rgba(255,255,255,0.95) 100%)",
678+ ? "linear-gradient(to bottom, rgba(0,0,0,0) 0%, rgba(0,0,0,0.6) 55%, rgba(0,0,0,0.9) 100%)"
679+ : "linear-gradient(to bottom, rgba(255,255,255,0) 0%, rgba(255,255,255,0.6) 55%, rgba(255,255,255,0.9) 100%)",
630680 }}
631681 >
632− <View className="w-full" style={{ position: "relative" }}>
682+ <View
683+ className="w-full"
684+ style={{ alignSelf: "center", maxWidth: props.contentMaxWidth, position: "relative" }}
685+ >
633686 {composerTrigger && composerMenuItems.length > 0 ? (
634687 <View
635688 style={{
@@ -702,6 +755,7 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer · 66 unmodified lines
702755 placeholder={props.placeholder}
703756 onFocus={handleFocus}
704757 onBlur={handleBlur}
758+ onSubmit={handleSend}
705759 scrollEnabled={isExpanded}
706760 contentInsetVertical={isExpanded ? 0 : 6}
707761 style={
@@ -717,7 +771,7 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer · 9 unmodified lines
717771 }
718772 }
719773 textStyle={{
720− ...MOBILE_TYPOGRAPHY.composer,
774+ ...bodyText,
721775 color: foregroundColor,
722776 fontFamily: "DMSans_400Regular",
723777 }}
@@ -827,13 +881,7 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer · 103 unmodified lines
827881
828882 {/* Queue count */}
829883 {props.queueCount > 0 ? (
830− <Text
831− className="text-foreground-muted"
832− style={{
833− ...MOBILE_TYPOGRAPHY.label,
834− paddingTop: 8,
835− }}
836− >
884+ <Text className="text-xs text-foreground-muted" style={{ paddingTop: 8 }}>
837885 {props.queueCount} queued message{props.queueCount === 1 ? "" : "s"} will send
838886 automatically.
839887 </Text>
apps/mobile/src/features/threads/ThreadDetailScreen.tsx+158−97
@@ -1,4 +1,5 @@
11 import { type EnvironmentConnectionPhase } from "@t3tools/client-runtime/connection";
2+import type { EnvironmentThreadStatus } from "@t3tools/client-runtime/state/threads";
23 import { useKeyboardChatComposerInset, useKeyboardScrollToEnd } from "@legendapp/list/keyboard";
34 import type { LegendListRef } from "@legendapp/list/react-native";
45 import type {
@@ -15,17 +16,18 @@ import type { · 10 unmodified lines
1516 } from "@t3tools/contracts";
1617 import { formatElapsed } from "@t3tools/shared/orchestrationTiming";
1718 import * as Haptics from "expo-haptics";
18−import { useHeaderHeight } from "expo-router/build/react-navigation/elements";
1919 import { memo, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
2020 import { View, type GestureResponderEvent } from "react-native";
21+import { Gesture, GestureDetector } from "react-native-gesture-handler";
2122 import { KeyboardController, KeyboardStickyView } from "react-native-keyboard-controller";
2223 import { useSafeAreaInsets } from "react-native-safe-area-context";
24+import { runOnJS } from "react-native-reanimated";
2325
2426 import { AppText as Text } from "../../components/AppText";
2527 import type { ComposerEditorHandle } from "../../components/ComposerEditor";
2628 import type { StatusTone } from "../../components/StatusPill";
2729 import type { DraftComposerImageAttachment } from "../../lib/composerImages";
28−import type { LayoutVariant } from "../../lib/layout";
30+import { CHAT_CONTENT_MAX_WIDTH, type LayoutVariant } from "../../lib/layout";
2931 import { scopedThreadKey } from "../../lib/scopedEntities";
3032 import type {
3133 PendingApproval,
@@ -60,6 +62,8 @@ export interface ThreadDetailScreenProps { · 28 unmodified lines
6062 readonly draftMessage: string;
6163 readonly draftAttachments: ReadonlyArray<DraftComposerImageAttachment>;
6264 readonly connectionStateLabel: EnvironmentConnectionPhase;
65+ /** Message sync status for the selected thread (drives the composer status pill). */
66+ readonly threadSyncStatus?: EnvironmentThreadStatus;
6367 readonly activeThreadBusy: boolean;
6468 readonly environmentId: EnvironmentId;
6569 readonly projectWorkspaceRoot: string | null;
@@ -67,6 +71,9 @@ export interface ThreadDetailScreenProps { · 1 unmodified line
6771 readonly selectedThreadQueueCount: number;
6872 readonly serverConfig: T3ServerConfig | null;
6973 readonly layoutVariant?: LayoutVariant;
74+ readonly usesAutomaticContentInsets?: boolean;
75+ readonly onHeaderMaterialVisibilityChange?: (visible: boolean) => void;
76+ readonly onOpenDrawer: () => void;
7077 readonly onOpenConnectionEditor: () => void;
7178 readonly onChangeDraftMessage: (value: string) => void;
7279 readonly onPickDraftImages: () => Promise<void>;
@@ -201,8 +208,9 @@ const WorkingDurationPill = memo(function WorkingDurationPill(props: { · 128 unmodified lines
201208 });
202209
203210 export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: ThreadDetailScreenProps) {
211+ const { onOpenDrawer } = props;
212+
204213 const insets = useSafeAreaInsets();
205− const headerHeight = useHeaderHeight();
206214 const agentLabel = `${props.selectedThread.modelSelection.instanceId} agent`;
207215 const selectedThreadKey = scopedThreadKey(props.environmentId, props.selectedThread.id);
208216 const composerEditorRef = useRef<ComposerEditorHandle>(null);
@@ -215,6 +223,22 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread · 6 unmodified lines
215223 const [anchorMessageId, setAnchorMessageId] = useState<MessageId | null>(null);
216224 const composerBottomInset = composerExpanded ? 0 : Math.max(insets.bottom, 12);
217225 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+ })();
218242 const selectedThreadFeed = props.selectedThreadFeed;
219243 const composerChrome = composerExpanded ? COMPOSER_EXPANDED_CHROME : COMPOSER_COLLAPSED_CHROME;
220244 const composerOverlapHeight = composerChrome + composerBottomInset;
@@ -228,6 +252,8 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread · 7 unmodified lines
228252 const { freeze, scrollMessageToEnd } = useKeyboardScrollToEnd({ listRef });
229253 const showContent = props.showContent ?? true;
230254 const layoutVariant = props.layoutVariant ?? "compact";
255+ const isSplitLayout = layoutVariant === "split";
256+ const contentMaxWidth = isSplitLayout ? CHAT_CONTENT_MAX_WIDTH : undefined;
231257 const selectedInstanceId = props.selectedThread.modelSelection.instanceId;
232258 useStreamingHaptics(props.selectedThread.id, props.selectedThreadFeed);
233259 const selectedProviderSkills = useMemo(
@@ -237,6 +263,28 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread · 3 unmodified lines
237263 [props.serverConfig, selectedInstanceId],
238264 );
239265
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+
240288 useLayoutEffect(() => {
241289 selectedThreadKeyRef.current = selectedThreadKey;
242290 }, [selectedThreadKey]);
@@ -346,102 +394,115 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread · 103 unmodified lines
346394 }, []);
347395
348396 return (
349− <View className="flex-1">
350− {showContent ? (
351− <View
352− style={{ flex: 1 }}
353− onTouchStart={handleFeedTouchStart}
354− onTouchMove={handleFeedTouchMove}
355− onTouchEnd={handleFeedTouchEnd}
356− onTouchCancel={handleFeedTouchCancel}
357− >
358− <ThreadFeed
359− key={props.selectedThread.id}
360− environmentId={props.environmentId}
361− threadId={props.selectedThread.id}
362− workspaceRoot={props.threadCwd}
363− feed={props.selectedThreadFeed}
364− contentPresentation={props.contentPresentation}
365− agentLabel={agentLabel}
366− latestTurn={props.selectedThread.latestTurn}
367− listRef={listRef}
368− freeze={freeze}
369− anchorMessageId={anchorMessageId}
370− contentInsetEndAdjustment={contentInsetEndAdjustment}
371− contentTopInset={headerHeight}
372− contentBottomInset={estimatedOverlayHeight}
373− layoutVariant={layoutVariant}
374− skills={selectedProviderSkills}
375− />
376− </View>
377− ) : (
378− <View style={{ flex: 1 }} />
379− )}
380−
381− {/* Floating composer — sticks to keyboard via KeyboardStickyView */}
382− {showContent ? (
383− <KeyboardStickyView
384− style={{ position: "absolute", bottom: 0, left: 0, right: 0 }}
385− offset={{ closed: 0, opened: 0 }}
386− >
387− <View ref={composerOverlayRef} onLayout={onComposerLayout} style={{ paddingTop: 8 }}>
388− {props.activeWorkStartedAt ? (
389− <WorkingDurationPill startedAt={props.activeWorkStartedAt} />
390− ) : null}
391−
392− {props.activePendingApproval || props.activePendingUserInput ? (
393− <View className="gap-3 px-4 pb-3" style={{ flexShrink: 0 }}>
394− {props.activePendingApproval ? (
395− <PendingApprovalCard
396− approval={props.activePendingApproval}
397− respondingApprovalId={props.respondingApprovalId}
398− onRespond={props.onRespondToApproval}
399− />
400− ) : null}
401− {props.activePendingUserInput ? (
402− <PendingUserInputCard
403− pendingUserInput={props.activePendingUserInput}
404− drafts={props.activePendingUserInputDrafts}
405− answers={props.activePendingUserInputAnswers}
406− respondingUserInputId={props.respondingUserInputId}
407− onSelectOption={props.onSelectUserInputOption}
408− onChangeCustomAnswer={props.onChangeUserInputCustomAnswer}
409− onSubmit={props.onSubmitUserInput}
410− />
411− ) : null}
412− </View>
413− ) : null}
414−
415− <ThreadComposer
416− editorRef={composerEditorRef}
417− draftMessage={props.draftMessage}
418− draftAttachments={props.draftAttachments}
419− placeholder="Ask the repo agent, or run a command…"
420− connectionState={props.connectionStateLabel}
421− connectionError={props.connectionError}
422− environmentLabel={props.environmentLabel}
423− selectedThread={props.selectedThread}
424− serverConfig={props.serverConfig}
425− queueCount={props.selectedThreadQueueCount}
426− activeThreadBusy={props.activeThreadBusy}
397+ <GestureDetector gesture={headerDrawerGesture}>
398+ <View className="flex-1">
399+ {showContent ? (
400+ <View
401+ style={{ flex: 1 }}
402+ onTouchStart={handleFeedTouchStart}
403+ onTouchMove={handleFeedTouchMove}
404+ onTouchEnd={handleFeedTouchEnd}
405+ onTouchCancel={handleFeedTouchCancel}
406+ >
407+ <ThreadFeed
408+ key={props.selectedThread.id}
427409 environmentId={props.environmentId}
428− projectCwd={props.projectWorkspaceRoot}
429− bottomInset={composerBottomInset}
430− onChangeDraftMessage={props.onChangeDraftMessage}
431− onPickDraftImages={props.onPickDraftImages}
432− onNativePasteImages={props.onNativePasteImages}
433− onRemoveDraftImage={props.onRemoveDraftImage}
434− onStopThread={props.onStopThread}
435− onSendMessage={handleSendMessage}
436− onReconnectEnvironment={props.onReconnectEnvironment}
437− onUpdateModelSelection={props.onUpdateThreadModelSelection}
438− onUpdateRuntimeMode={props.onUpdateThreadRuntimeMode}
439− onUpdateInteractionMode={props.onUpdateThreadInteractionMode}
440− onExpandedChange={setComposerExpanded}
410+ threadId={props.selectedThread.id}
411+ workspaceRoot={props.threadCwd}
412+ feed={props.selectedThreadFeed}
413+ contentPresentation={props.contentPresentation}
414+ agentLabel={agentLabel}
415+ latestTurn={props.selectedThread.latestTurn}
416+ listRef={listRef}
417+ freeze={freeze}
418+ anchorMessageId={anchorMessageId}
419+ contentInsetEndAdjustment={contentInsetEndAdjustment}
420+ contentTopInset={0}
421+ contentBottomInset={estimatedOverlayHeight}
422+ contentMaxWidth={contentMaxWidth}
423+ layoutVariant={layoutVariant}
424+ usesAutomaticContentInsets={props.usesAutomaticContentInsets}
425+ onHeaderMaterialVisibilityChange={props.onHeaderMaterialVisibilityChange}
426+ skills={selectedProviderSkills}
441427 />
442428 </View>
443− </KeyboardStickyView>
444− ) : null}
445− </View>
429+ ) : (
430+ <View style={{ flex: 1 }} />
431+ )}
432+
433+ {/* Floating composer — sticks to keyboard via KeyboardStickyView */}
434+ {showContent ? (
435+ <KeyboardStickyView
436+ style={{ position: "absolute", bottom: 0, left: 0, right: 0 }}
437+ offset={{ closed: 0, opened: 0 }}
438+ >
439+ <View
440+ ref={composerOverlayRef}
441+ onLayout={onComposerLayout}
442+ style={{ width: "100%", paddingTop: 8 }}
443+ >
444+ <View style={{ alignSelf: "center", maxWidth: contentMaxWidth, width: "100%" }}>
445+ {props.activeWorkStartedAt ? (
446+ <WorkingDurationPill startedAt={props.activeWorkStartedAt} />
447+ ) : null}
448+
449+ {props.activePendingApproval || props.activePendingUserInput ? (
450+ <View className="gap-3 px-4 pb-3" style={{ flexShrink: 0 }}>
451+ {props.activePendingApproval ? (
452+ <PendingApprovalCard
453+ approval={props.activePendingApproval}
454+ respondingApprovalId={props.respondingApprovalId}
455+ onRespond={props.onRespondToApproval}
456+ />
457+ ) : null}
458+ {props.activePendingUserInput ? (
459+ <PendingUserInputCard
460+ pendingUserInput={props.activePendingUserInput}
461+ drafts={props.activePendingUserInputDrafts}
462+ answers={props.activePendingUserInputAnswers}
463+ respondingUserInputId={props.respondingUserInputId}
464+ onSelectOption={props.onSelectUserInputOption}
465+ onChangeCustomAnswer={props.onChangeUserInputCustomAnswer}
466+ onSubmit={props.onSubmitUserInput}
467+ />
468+ ) : null}
469+ </View>
470+ ) : null}
471+ </View>
472+
473+ <ThreadComposer
474+ editorRef={composerEditorRef}
475+ draftMessage={props.draftMessage}
476+ draftAttachments={props.draftAttachments}
477+ placeholder="Ask the repo agent, or run a command…"
478+ contentMaxWidth={contentMaxWidth}
479+ connectionState={props.connectionStateLabel}
480+ connectionError={props.connectionError}
481+ environmentLabel={props.environmentLabel}
482+ threadSyncPhase={threadSyncPhase}
483+ selectedThread={props.selectedThread}
484+ serverConfig={props.serverConfig}
485+ queueCount={props.selectedThreadQueueCount}
486+ activeThreadBusy={props.activeThreadBusy}
487+ environmentId={props.environmentId}
488+ projectCwd={props.projectWorkspaceRoot}
489+ bottomInset={composerBottomInset}
490+ onChangeDraftMessage={props.onChangeDraftMessage}
491+ onPickDraftImages={props.onPickDraftImages}
492+ onNativePasteImages={props.onNativePasteImages}
493+ onRemoveDraftImage={props.onRemoveDraftImage}
494+ onStopThread={props.onStopThread}
495+ onSendMessage={handleSendMessage}
496+ onReconnectEnvironment={props.onReconnectEnvironment}
497+ onUpdateModelSelection={props.onUpdateThreadModelSelection}
498+ onUpdateRuntimeMode={props.onUpdateThreadRuntimeMode}
499+ onUpdateInteractionMode={props.onUpdateThreadInteractionMode}
500+ onExpandedChange={setComposerExpanded}
501+ />
502+ </View>
503+ </KeyboardStickyView>
504+ ) : null}
505+ </View>
506+ </GestureDetector>
446507 );
447508 });
apps/mobile/src/features/threads/ThreadFeed.tsx+180−111
@@ -4,7 +4,7 @@ import { type LegendListRef } from "@legendapp/list/react-native"; · 3 unmodified lines
44 import type { EnvironmentId, MessageId, ThreadId, TurnId } from "@t3tools/contracts";
55 import { CHAT_LIST_ANCHOR_OFFSET, resolveChatListAnchoredEndSpace } from "@t3tools/shared/chatList";
66 import { SymbolView } from "expo-symbols";
7−import { useRouter } from "expo-router";
7+import { useNavigation } from "@react-navigation/native";
88 import {
99 memo,
1010 useCallback,
@@ -25,6 +25,10 @@ import { · 14 unmodified lines
2525 ActivityIndicator,
2626 Image,
2727 Linking,
28+ Platform,
29+ type LayoutChangeEvent,
30+ type NativeScrollEvent,
31+ type NativeSyntheticEvent,
2832 Pressable,
2933 ScrollView,
3034 StyleSheet,
@@ -37,7 +41,7 @@ import { · 6 unmodified lines
3741 import { TouchableOpacity } from "react-native-gesture-handler";
3842 import ImageViewing from "react-native-image-viewing";
3943 import { useSafeAreaInsets } from "react-native-safe-area-context";
40−import type { SharedValue } from "react-native-reanimated";
44+import { type SharedValue } from "react-native-reanimated";
4145 import { useThemeColor } from "../../lib/useThemeColor";
4246 import { copyTextWithHaptic } from "../../lib/copyTextWithHaptic";
4347 import {
@@ -58,14 +62,16 @@ import { · 14 unmodified lines
5862 buildNativeReviewDiffData,
5963 createNativeReviewDiffTheme,
6064 NATIVE_REVIEW_DIFF_CONTENT_WIDTH,
61− NATIVE_REVIEW_DIFF_ROW_HEIGHT,
62− NATIVE_REVIEW_DIFF_STYLE,
6365 } from "../review/nativeReviewDiffAdapter";
6466 import { buildReviewParsedDiff } from "../review/reviewModel";
6567 import { cn } from "../../lib/cn";
66−import type { LayoutVariant } from "../../lib/layout";
67−import { buildThreadFilesNavigation } from "../../lib/routes";
68−import { MOBILE_CODE_SURFACE, MOBILE_TYPOGRAPHY } from "../../lib/typography";
68+import { deriveCenteredContentHorizontalPadding, type LayoutVariant } from "../../lib/layout";
69+import {
70+ resolveMarkdownFontSizes,
71+ resolveNativeMarkdownTypography,
72+} from "../../lib/appearancePreferences";
73+import { useAppearancePreferences } from "../settings/appearance/AppearancePreferencesProvider";
74+import { useAppearanceCodeSurface } from "../settings/appearance/useAppearanceCodeSurface";
6975 import { markdownFileIconSource } from "@t3tools/mobile-markdown-text/file-icons";
7076 import { resolveMarkdownLinkPresentation } from "@t3tools/mobile-markdown-text/links";
7177 import {
@@ -82,7 +88,6 @@ const MESSAGE_TIME_FORMATTER = new Intl.DateTimeFormat(undefined, { · 10 unmodified lines
8288 hour: "numeric",
8389 minute: "2-digit",
8490 });
85−
8691 function formatMessageTime(input: string): string {
8792 const timestamp = Date.parse(input);
8893 if (Number.isNaN(timestamp)) {
@@ -105,7 +110,10 @@ export interface ThreadFeedProps { · 16 unmodified lines
105110 readonly contentInsetEndAdjustment: SharedValue<number>;
106111 readonly contentTopInset?: number;
107112 readonly contentBottomInset?: number;
113+ readonly contentMaxWidth?: number;
108114 readonly layoutVariant?: LayoutVariant;
115+ readonly usesAutomaticContentInsets?: boolean;
116+ readonly onHeaderMaterialVisibilityChange?: (visible: boolean) => void;
109117 readonly skills?: ReadonlyArray<SelectableMarkdownSkill>;
110118 }
111119
@@ -273,6 +281,15 @@ function useReviewCommentColors(): ReviewCommentColors { · 161 unmodified lines
273281
274282 function useMarkdownStyles(onLinkPress: (href: string) => void): MarkdownStyleSets {
275283 const colorScheme = useColorScheme();
284+ const { appearance } = useAppearancePreferences();
285+ const markdownFontSizes = useMemo(
286+ () => resolveMarkdownFontSizes(appearance.baseFontSize),
287+ [appearance.baseFontSize],
288+ );
289+ const nativeMarkdownTypography = useMemo(
290+ () => resolveNativeMarkdownTypography(appearance.baseFontSize),
291+ [appearance.baseFontSize],
292+ );
276293 const colors = MARKDOWN_COLORS[colorScheme === "dark" ? "dark" : "light"];
277294 const inlineSkillForeground = String(useThemeColor("--color-inline-skill-foreground"));
278295
@@ -316,14 +333,14 @@ function useMarkdownStyles(onLinkPress: (href: string) => void): MarkdownStyleSe · 37 unmodified lines
316333 xl: 16,
317334 },
318335 fontSizes: {
319− s: 13,
320− m: 15,
321− h1: 20,
322− h2: 18,
323− h3: 16,
324− h4: 14,
325− h5: 14,
326− h6: 14,
336+ s: markdownFontSizes.s,
337+ m: markdownFontSizes.m,
338+ h1: markdownFontSizes.h1,
339+ h2: markdownFontSizes.h2,
340+ h3: markdownFontSizes.h3,
341+ h4: markdownFontSizes.h4,
342+ h5: markdownFontSizes.h5,
343+ h6: markdownFontSizes.h6,
327344 },
328345 fontFamilies: {
329346 regular: "DMSans_400Regular",
@@ -345,7 +362,7 @@ function useMarkdownStyles(onLinkPress: (href: string) => void): MarkdownStyleSe · 15 unmodified lines
345362 list: { marginTop: 4, marginBottom: 8 },
346363 list_item: { marginTop: 0, marginBottom: 4 },
347364 task_list_item: { marginTop: 0, marginBottom: 4 },
348− text: { lineHeight: 22 },
365+ text: { lineHeight: markdownFontSizes.bodyLineHeight },
349366 bold: {
350367 fontWeight: "700",
351368 color: markdownStrongColor,
@@ -454,7 +471,8 @@ function useMarkdownStyles(onLinkPress: (href: string) => void): MarkdownStyleSe · 102 unmodified lines
454471 marginRight: 5,
455472 color: inlineTextColor,
456473 fontFamily: "DMSans_400Regular",
457− ...MOBILE_TYPOGRAPHY.body,
474+ fontSize: markdownFontSizes.m,
475+ lineHeight: markdownFontSizes.bodyLineHeight,
458476 textAlign: ordered ? "right" : "center",
459477 }}
460478 >
@@ -475,8 +493,8 @@ function useMarkdownStyles(onLinkPress: (href: string) => void): MarkdownStyleSe · 14 unmodified lines
475493 style={{
476494 color: inlineCodeTextColor,
477495 fontFamily: "ui-monospace",
478− fontSize: MOBILE_TYPOGRAPHY.label.fontSize,
479− lineHeight: 22,
496+ fontSize: markdownFontSizes.codeBlockFontSize,
497+ lineHeight: markdownFontSizes.bodyLineHeight,
480498 }}
481499 >
482500 {value}
@@ -512,7 +530,7 @@ function useMarkdownStyles(onLinkPress: (href: string) => void): MarkdownStyleSe · 29 unmodified lines
512530 style={{
513531 color: markdownBodyColor,
514532 fontFamily: "ui-monospace",
515− fontSize: MOBILE_TYPOGRAPHY.label.fontSize,
533+ fontSize: markdownFontSizes.codeBlockFontSize,
516534 opacity: 0.7,
517535 textTransform: "uppercase",
518536 }}
@@ -532,8 +550,8 @@ function useMarkdownStyles(onLinkPress: (href: string) => void): MarkdownStyleSe · 13 unmodified lines
532550 style={{
533551 color: blockTextColor,
534552 fontFamily: "ui-monospace",
535− fontSize: MOBILE_TYPOGRAPHY.label.fontSize,
536− lineHeight: 18,
553+ fontSize: markdownFontSizes.codeBlockFontSize,
554+ lineHeight: markdownFontSizes.codeBlockLineHeight,
537555 }}
538556 >
539557 {content}
@@ -612,7 +630,9 @@ function useMarkdownStyles(onLinkPress: (href: string) => void): MarkdownStyleSe · 72 unmodified lines
612630 skillTextColor: "#f0abfc",
613631 quoteMarkerColor: markdownUserBodyColor,
614632 dividerColor: markdownUserBodyColor,
615− ...MOBILE_TYPOGRAPHY.body,
633+ fontSize: nativeMarkdownTypography.fontSize,
634+ lineHeight: nativeMarkdownTypography.lineHeight,
635+ headingFontSizes: nativeMarkdownTypography.headingFontSizes,
616636 fontFamily: "DMSans_400Regular",
617637 headingFontFamily: "DMSans_700Bold",
618638 boldFontFamily: "DMSans_700Bold",
@@ -641,14 +661,16 @@ function useMarkdownStyles(onLinkPress: (href: string) => void): MarkdownStyleSe · 22 unmodified lines
641661 skillTextColor: inlineSkillForeground,
642662 quoteMarkerColor: markdownBlockquoteBorder,
643663 dividerColor: markdownHrColor,
644− ...MOBILE_TYPOGRAPHY.body,
664+ fontSize: nativeMarkdownTypography.fontSize,
665+ lineHeight: nativeMarkdownTypography.lineHeight,
666+ headingFontSizes: nativeMarkdownTypography.headingFontSizes,
645667 fontFamily: "DMSans_400Regular",
646668 headingFontFamily: "DMSans_700Bold",
647669 boldFontFamily: "DMSans_700Bold",
648670 },
649671 },
650672 };
651− }, [colors, inlineSkillForeground, onLinkPress]);
673+ }, [colors, inlineSkillForeground, markdownFontSizes, nativeMarkdownTypography, onLinkPress]);
652674 }
653675
654676 function renderFeedEntry(
@@ -924,6 +946,7 @@ const ReviewCommentCard = memo(function ReviewCommentCard(props: { · 269 unmodified lines
924946 readonly comment: ReviewInlineComment;
925947 readonly colors: ReviewCommentColors;
926948 }) {
949+ const { codeSurface, nativeReviewDiffStyle } = useAppearanceCodeSurface();
927950 const colorScheme = useColorScheme();
928951 const appearanceScheme = colorScheme === "light" ? "light" : "dark";
929952 const NativeReviewDiffView = resolveNativeReviewDiffView();
@@ -946,18 +969,21 @@ const ReviewCommentCard = memo(function ReviewCommentCard(props: { · 16 unmodified lines
946969 () => JSON.stringify(nativeReviewDiffTheme),
947970 [nativeReviewDiffTheme],
948971 );
949− const nativeStyleJson = useMemo(() => JSON.stringify(NATIVE_REVIEW_DIFF_STYLE), []);
972+ const nativeStyleJson = useMemo(
973+ () => JSON.stringify(nativeReviewDiffStyle),
974+ [nativeReviewDiffStyle],
975+ );
950976 const nativeDiffHeight = useMemo(
951977 () =>
952978 Math.min(
953979 360,
954980 Math.max(
955981 112,
956− compactNativeRows.length * NATIVE_REVIEW_DIFF_ROW_HEIGHT +
957− NATIVE_REVIEW_DIFF_STYLE.fileHeaderVerticalMargin,
982+ compactNativeRows.length * nativeReviewDiffStyle.rowHeight +
983+ nativeReviewDiffStyle.fileHeaderVerticalMargin,
958984 ),
959985 ),
960− [compactNativeRows.length],
986+ [compactNativeRows.length, nativeReviewDiffStyle],
961987 );
962988 const shouldRenderNativeDiff = NativeReviewDiffView != null && compactNativeRows.length > 0;
963989
@@ -987,7 +1013,7 @@ const ReviewCommentCard = memo(function ReviewCommentCard(props: { · 23 unmodified lines
9871013 </View>
9881014 <View className="min-w-0 flex-1">
9891015 <Text
990− className="font-mono text-xs leading-[16px]"
1016+ className="font-mono text-xs"
9911017 numberOfLines={1}
9921018 style={{ color: props.colors.text }}
9931019 >
@@ -1010,7 +1036,7 @@ const ReviewCommentCard = memo(function ReviewCommentCard(props: { · 16 unmodified lines
10101036 style={StyleSheet.absoluteFill}
10111037 appearanceScheme={appearanceScheme}
10121038 contentWidth={NATIVE_REVIEW_DIFF_CONTENT_WIDTH}
1013− rowHeight={NATIVE_REVIEW_DIFF_ROW_HEIGHT}
1039+ rowHeight={nativeReviewDiffStyle.rowHeight}
10141040 rowsJson={nativeRowsJson}
10151041 styleJson={nativeStyleJson}
10161042 themeJson={nativeThemeJson}
@@ -1032,8 +1058,8 @@ const ReviewCommentCard = memo(function ReviewCommentCard(props: { · 15 unmodified lines
10321058 style={{
10331059 color: props.colors.text,
10341060 fontFamily: "ui-monospace",
1035− fontSize: MOBILE_CODE_SURFACE.fontSize,
1036− lineHeight: MOBILE_CODE_SURFACE.rowHeight,
1061+ fontSize: codeSurface.fontSize,
1062+ lineHeight: codeSurface.rowHeight,
10371063 }}
10381064 >
10391065 {props.comment.diff.trim()}
@@ -1042,11 +1068,7 @@ const ReviewCommentCard = memo(function ReviewCommentCard(props: { · 2 unmodified lines
10421068 ) : null}
10431069 {props.comment.text.length > 0 ? (
10441070 <View className="border-t px-3 py-3" style={{ borderColor: props.colors.border }}>
1045− <Text
1046− selectable
1047− className="text-base leading-[21px]"
1048− style={{ color: props.colors.text }}
1049− >
1071+ <Text selectable className="text-base leading-snug" style={{ color: props.colors.text }}>
10501072 {props.comment.text}
10511073 </Text>
10521074 </View>
@@ -1087,7 +1109,6 @@ function ThreadFeedPlaceholder(props: { · 34 unmodified lines
10871109 readonly bottomInset: number;
10881110 readonly detail: string;
10891111 readonly horizontalPadding: number;
1090− readonly loading?: boolean;
10911112 readonly title: string;
10921113 readonly topInset: number;
10931114 }) {
@@ -1104,9 +1125,8 @@ function ThreadFeedPlaceholder(props: { · 10 unmodified lines
11041125 }}
11051126 >
11061127 <View className="max-w-[320px] items-center gap-2">
1107− {props.loading ? <ActivityIndicator style={{ marginBottom: 6 }} /> : null}
11081128 <Text className="text-center font-t3-bold text-lg text-foreground">{props.title}</Text>
1109− <Text className="text-center text-sm leading-5 text-foreground-secondary">
1129+ <Text className="text-center text-sm leading-normal text-foreground-secondary">
11101130 {props.detail}
11111131 </Text>
11121132 </View>
@@ -1115,13 +1135,17 @@ function ThreadFeedPlaceholder(props: { · 2 unmodified lines
11151135 }
11161136
11171137 export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) {
1118− const router = useRouter();
1138+ const navigation = useNavigation();
11191139 const copyFeedbackTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
11201140 const foldSettleFrameRef = useRef<number | null>(null);
11211141 const foldSettleSecondFrameRef = useRef<number | null>(null);
11221142 const disclosureAnchorKeyRef = useRef<string | null>(null);
1143+ const headerMaterialVisibleRef = useRef(false);
11231144 const previousLatestTurnRef = useRef(props.latestTurn);
1124− const { width: viewportWidth } = useWindowDimensions();
1145+ const { width: windowWidth } = useWindowDimensions();
1146+ const [viewportWidth, setViewportWidth] = useState(() =>
1147+ props.layoutVariant === "split" ? 0 : windowWidth,
1148+ );
11251149 const [disclosureToggleSettling, setDisclosureToggleSettling] = useState(false);
11261150 const [interactionState, setInteractionState] = useState<{
11271151 readonly copiedRowId: string | null;
@@ -1140,12 +1164,19 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { · 12 unmodified lines
11401164 headers?: Record<string, string>;
11411165 } | null>(null);
11421166 const horizontalPadding = props.layoutVariant === "split" ? 20 : 16;
1143− const contentWidth = Math.max(0, viewportWidth - horizontalPadding * 2);
1167+ const contentHorizontalPadding = deriveCenteredContentHorizontalPadding({
1168+ viewportWidth,
1169+ maxContentWidth: props.contentMaxWidth ?? null,
1170+ minimumPadding: horizontalPadding,
1171+ });
1172+ const contentWidth = Math.max(0, viewportWidth - contentHorizontalPadding * 2);
11441173 const userBubbleMaxWidth = contentWidth * 0.85;
11451174 const reviewCommentBubbleWidth = Math.min(Math.max(280, contentWidth * 0.85), contentWidth);
11461175 const insets = useSafeAreaInsets();
11471176 const topContentInset = props.contentTopInset ?? insets.top + 44;
11481177 const bottomContentInset = props.contentBottomInset ?? 18;
1178+ const usesNativeAutomaticInsets =
1179+ props.usesAutomaticContentInsets === true && Platform.OS === "ios";
11491180
11501181 const iconSubtleColor = useThemeColor("--color-icon-subtle");
11511182 const userBubbleColor = useThemeColor("--color-user-bubble");
@@ -1159,13 +1190,12 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { · 7 unmodified lines
11591190 );
11601191 if (relativePath) {
11611192 void Haptics.selectionAsync();
1162− router.push(
1163− buildThreadFilesNavigation(
1164− { environmentId: props.environmentId, threadId: props.threadId },
1165− relativePath,
1166− presentation.line,
1167− ),
1168− );
1193+ navigation.navigate("ThreadFile", {
1194+ environmentId: String(props.environmentId),
1195+ threadId: String(props.threadId),
1196+ path: relativePath.split("/").filter((segment) => segment.length > 0),
1197+ ...(presentation.line ? { line: String(presentation.line) } : {}),
1198+ });
11691199 }
11701200 return;
11711201 }
@@ -1174,7 +1204,7 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { · 2 unmodified lines
11741204 void Linking.openURL(presentation.href);
11751205 }
11761206 },
1177− [props.environmentId, props.threadId, props.workspaceRoot, router],
1207+ [props.environmentId, props.threadId, props.workspaceRoot, navigation],
11781208 );
11791209 const markdownStyles = useMarkdownStyles(onMarkdownLinkPress);
11801210 const reviewCommentColors = useReviewCommentColors();
@@ -1188,6 +1218,7 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { · 7 unmodified lines
11881218 markdownStyles,
11891219 reviewCommentColors,
11901220 userBubbleColor,
1221+ viewportWidth,
11911222 }),
11921223 [
11931224 copiedRowId,
@@ -1196,8 +1227,34 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { · 2 unmodified lines
11961227 markdownStyles,
11971228 reviewCommentColors,
11981229 userBubbleColor,
1230+ viewportWidth,
11991231 ],
12001232 );
1233+ const reportHeaderMaterialVisibility = useCallback(
1234+ (visible: boolean) => {
1235+ if (headerMaterialVisibleRef.current === visible) {
1236+ return;
1237+ }
1238+ headerMaterialVisibleRef.current = visible;
1239+ props.onHeaderMaterialVisibilityChange?.(visible);
1240+ },
1241+ [props.onHeaderMaterialVisibilityChange],
1242+ );
1243+ const handleScroll = useCallback(
1244+ (event: NativeSyntheticEvent<NativeScrollEvent>) => {
1245+ reportHeaderMaterialVisibility(event.nativeEvent.contentOffset.y + topContentInset > 6);
1246+ },
1247+ [reportHeaderMaterialVisibility, topContentInset],
1248+ );
1249+ const handleViewportLayout = useCallback((event: LayoutChangeEvent) => {
1250+ const nextWidth = Math.round(event.nativeEvent.layout.width);
1251+ setViewportWidth((current) => (Math.abs(current - nextWidth) > 1 ? nextWidth : current));
1252+ }, []);
1253+
1254+ useEffect(() => {
1255+ reportHeaderMaterialVisibility(false);
1256+ }, [props.threadId, reportHeaderMaterialVisibility]);
1257+
12011258 const expandedWorkGroupIds = useMemo(() => {
12021259 const ids = new Set<string>();
12031260 for (const [groupId, expanded] of Object.entries(expandedWorkGroups)) {
@@ -1217,6 +1274,7 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { · 13 unmodified lines
12171274 ),
12181275 [expandedTurnIds, expandedWorkGroupIds, props.feed, props.latestTurn],
12191276 );
1277+
12201278 const anchoredEndSpace = useMemo(
12211279 () =>
12221280 resolveChatListAnchoredEndSpace(
@@ -1424,19 +1482,6 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { · 201 unmodified lines
14241482 ],
14251483 );
14261484
1427− if (props.contentPresentation.kind === "loading") {
1428− return (
1429− <ThreadFeedPlaceholder
1430− title="Loading conversation"
1431− detail="Fetching the latest messages from this environment."
1432− loading
1433− topInset={topContentInset}
1434− bottomInset={bottomContentInset}
1435− horizontalPadding={horizontalPadding}
1436− />
1437− );
1438− }
1439−
14401485 if (props.contentPresentation.kind === "unavailable") {
14411486 return (
14421487 <ThreadFeedPlaceholder
@@ -1451,52 +1496,76 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { · 8 unmodified lines
14511496
14521497 return (
14531498 <>
1454− <View style={{ flex: 1 }}>
1455− <KeyboardAwareLegendList
1456− ref={props.listRef}
1457− key={props.threadId}
1458− style={{ flex: 1 }}
1459− // RN 0.81+ drops touches inside the contentInset area
1460− // (facebook/react-native#54123); the anchored end space after a send
1461− // is pure inset, so without this the blank region can't be scrolled.
1462− applyWorkaroundForContentInsetHitTestBug
1463− automaticallyAdjustsScrollIndicatorInsets={false}
1464− scrollIndicatorInsets={{ top: topContentInset, bottom: 0 }}
1465− {...(anchoredEndSpace ? { anchoredEndSpace } : {})}
1466− contentInsetEndAdjustment={props.contentInsetEndAdjustment}
1467− freeze={props.freeze}
1468− maintainScrollAtEnd={
1469− disclosureToggleSettling
1470− ? false
1471− : {
1472− animated: false,
1473− on: {
1474− dataChange: true,
1475− itemLayout: true,
1476− layout: true,
1477− },
1499+ <View style={{ flex: 1 }} onLayout={handleViewportLayout}>
1500+ <View style={{ flex: 1 }}>
1501+ <KeyboardAwareLegendList
1502+ ref={props.listRef}
1503+ // The empty↔filled key remounts the list when messages first
1504+ // arrive. LegendList's maintainScrollAtEnd calls scrollToEnd(),
1505+ // which is blind to UIKit's adjustedContentInset — inserting into
1506+ // an already-attached list under a transparent header can pin
1507+ // short content at offset 0 (one header-height too high). A fresh
1508+ // mount positions during attach, where UIKit applies the inset.
1509+ key={`${props.threadId}:${props.feed.length === 0 ? "empty" : "filled"}`}
1510+ style={{ flex: 1 }}
1511+ // RN 0.81+ drops touches inside the contentInset area
1512+ // (facebook/react-native#54123); the anchored end space after a send
1513+ // is pure inset, so without this the blank region can't be scrolled.
1514+ applyWorkaroundForContentInsetHitTestBug
1515+ contentInsetAdjustmentBehavior={usesNativeAutomaticInsets ? "automatic" : "never"}
1516+ automaticallyAdjustsScrollIndicatorInsets={usesNativeAutomaticInsets}
1517+ {...(usesNativeAutomaticInsets
1518+ ? {
1519+ // Do NOT pass a manual `contentInset` here. Like the Home
1520+ // ScrollView, we rely purely on `contentInsetAdjustmentBehavior:
1521+ // "automatic"` so UIKit derives the top inset from the transparent
1522+ // header. A manual contentInset (which LegendList consumes into its
1523+ // own layout math) collapses the scroll view's adjustedContentInset
1524+ // top to 0, leaving the iOS 26/27 scroll-edge effect no region to
1525+ // render into — which is why the header blur was missing on threads.
1526+ scrollIndicatorInsets: { top: 0, left: 0, right: 0, bottom: 0 },
14781527 }
1479− }
1480− maintainVisibleContentPosition={maintainVisibleContentPosition}
1481− data={presentedFeed}
1482− extraData={listAppearanceData}
1483− renderItem={renderItem}
1484− keyExtractor={(entry) => entry.id}
1485− getItemType={(entry) =>
1486− entry.type === "message" ? `message:${entry.message.role}` : entry.type
1487− }
1488− keyboardShouldPersistTaps="always"
1489− keyboardDismissMode="none"
1490− keyboardLiftBehavior="whenAtEnd"
1491− estimatedItemSize={180}
1492− initialScrollAtEnd
1493− ListHeaderComponent={<View style={{ height: topContentInset }} />}
1494− contentContainerStyle={{
1495− paddingTop: 12,
1496− paddingHorizontal: horizontalPadding,
1497− }}
1498− />
1499− {props.feed.length === 0 ? (
1528+ : { scrollIndicatorInsets: { top: topContentInset, bottom: 0 } })}
1529+ {...(anchoredEndSpace ? { anchoredEndSpace } : {})}
1530+ contentInsetEndAdjustment={props.contentInsetEndAdjustment}
1531+ freeze={props.freeze}
1532+ maintainScrollAtEnd={
1533+ disclosureToggleSettling
1534+ ? false
1535+ : {
1536+ animated: false,
1537+ on: {
1538+ dataChange: true,
1539+ itemLayout: true,
1540+ layout: true,
1541+ },
1542+ }
1543+ }
1544+ maintainVisibleContentPosition={maintainVisibleContentPosition}
1545+ data={presentedFeed}
1546+ extraData={listAppearanceData}
1547+ renderItem={renderItem}
1548+ keyExtractor={(entry) => entry.id}
1549+ getItemType={(entry) =>
1550+ entry.type === "message" ? `message:${entry.message.role}` : entry.type
1551+ }
1552+ keyboardShouldPersistTaps="always"
1553+ keyboardDismissMode="none"
1554+ keyboardLiftBehavior="whenAtEnd"
1555+ estimatedItemSize={180}
1556+ initialScrollAtEnd
1557+ onScroll={handleScroll}
1558+ scrollEventThrottle={16}
1559+ ListHeaderComponent={
1560+ usesNativeAutomaticInsets ? null : <View style={{ height: topContentInset }} />
1561+ }
1562+ contentContainerStyle={{
1563+ paddingTop: 12,
1564+ paddingHorizontal: contentHorizontalPadding,
1565+ }}
1566+ />
1567+ </View>
1568+ {props.feed.length === 0 && props.contentPresentation.kind === "ready" ? (
15001569 <View pointerEvents="none" style={StyleSheet.absoluteFill}>
15011570 <ThreadFeedPlaceholder
15021571 title="No conversation yet"
apps/mobile/src/features/threads/ThreadGitControls.tsx+382−122
@@ -1,20 +1,19 @@
1−import type {
1+import {
22 EnvironmentId,
3− GitRunStackedActionResult,
4− ProjectScript,
3+ type GitRunStackedActionResult,
4+ type ProjectScript,
55 ThreadId,
6− VcsStatusResult,
6+ type VcsStatusResult,
77 } from "@t3tools/contracts";
88 import {
99 type GitActionRequestInput,
1010 requiresDefaultBranchConfirmation,
1111 resolveQuickAction,
1212 } from "@t3tools/client-runtime/state/vcs";
13−import { useLocalSearchParams, useRouter } from "expo-router";
14−import Stack from "expo-router/stack";
13+import { useNavigation } from "@react-navigation/native";
14+import { NativeHeaderToolbar } from "../../native/StackHeader";
1515 import { useCallback, useMemo } from "react";
1616 import { Alert } from "react-native";
17−import { buildThreadFilesNavigation, buildThreadReviewRoutePath } from "../../lib/routes";
1817 import { tryOpenExternalUrl } from "../../lib/openExternalUrl";
1918 import {
2019 basename,
@@ -65,25 +64,52 @@ function compactMenuStatus(gitStatus: VcsStatusResult | null): string { · 44 unmodified lines
6564 return parts.join(" · ");
6665 }
6766
68−export function ThreadGitControls(props: {
67+type HeaderItem = Record<string, unknown>;
68+type HeaderItems = HeaderItem[];
69+type ThreadGitHeaderActionItems = {
70+ readonly terminal: HeaderItem;
71+ readonly files: HeaderItem;
72+ readonly git: HeaderItem;
73+};
74+type QuickActionIcon =
75+ | "arrow.down.circle"
76+ | "arrow.up.right.circle"
77+ | "checkmark.circle"
78+ | "arrow.up.circle";
79+
80+/** The subset of git-control wiring the standalone git menu needs. */
81+export type ThreadGitMenuProps = {
82+ readonly environmentId: EnvironmentId | string;
83+ readonly threadId: ThreadId | string;
6984 readonly currentBranch: string | null;
7085 readonly gitStatus: VcsStatusResult | null;
7186 readonly gitOperationLabel: string | null;
87+ readonly onOpenFilesInspector?: () => void;
88+ readonly onOpenGitInspector?: () => void;
89+ readonly onPull: () => Promise<void>;
90+ readonly onRunAction: (input: GitActionRequestInput) => Promise<GitRunStackedActionResult | null>;
91+};
92+
93+type ThreadGitControlsProps = ThreadGitMenuProps & {
94+ readonly auxiliaryPaneControl?: {
95+ readonly accessibilityLabel: string;
96+ readonly onPress: () => void;
97+ };
7298 readonly canOpenTerminal: boolean;
7399 readonly canOpenFiles: boolean;
74100 readonly projectScripts: ReadonlyArray<ProjectScript>;
75101 readonly terminalSessions: ReadonlyArray<TerminalMenuSession>;
102+ readonly showActionControls?: boolean;
103+ readonly showDirectFileControl?: boolean;
76104 readonly onOpenTerminal: (terminalId?: string | null) => void;
77105 readonly onOpenNewTerminal: () => void;
78106 readonly onRunProjectScript: (script: ProjectScript) => Promise<void>;
79− readonly onPull: () => Promise<void>;
80− readonly onRunAction: (input: GitActionRequestInput) => Promise<GitRunStackedActionResult | null>;
81−}) {
82− const router = useRouter();
83− const { environmentId, threadId } = useLocalSearchParams<{
84− environmentId: EnvironmentId;
85− threadId: ThreadId;
86− }>();
107+};
108+
109+function useThreadGitControlModel(props: ThreadGitMenuProps) {
110+ const navigation = useNavigation();
111+ const environmentId = props.environmentId;
112+ const threadId = props.threadId;
87113 const { gitStatus, gitOperationLabel, onPull, onRunAction } = props;
88114
89115 const currentBranchLabel = gitStatus?.refName ?? props.currentBranch ?? "Detached HEAD";
@@ -109,7 +135,7 @@ export function ThreadGitControls(props: { · 19 unmodified lines
109135 ? (quickAction.hint ?? "This action is unavailable.")
110136 : null;
111137
112− const quickActionIcon = (() => {
138+ const quickActionIcon: QuickActionIcon = (() => {
113139 if (quickAction.kind === "run_pull") return "arrow.down.circle";
114140 if (quickAction.kind === "open_pr") return "arrow.up.right.circle";
115141 if (quickAction.kind === "run_action") {
@@ -147,24 +173,21 @@ export function ThreadGitControls(props: { · 31 unmodified lines
147173 !input.featureBranch &&
148174 requiresDefaultBranchConfirmation(input.action, isDefaultRef)
149175 ) {
150− router.push({
151− pathname: "/threads/[environmentId]/[threadId]/git-confirm",
152− params: {
153− environmentId,
154− threadId,
155− confirmAction: confirmableAction,
156− branchName,
157− includesCommit: String(
158− input.action === "commit_push" || input.action === "commit_push_pr",
159− ),
160− },
176+ navigation.navigate("GitConfirm", {
177+ environmentId: String(environmentId),
178+ threadId: String(threadId),
179+ confirmAction: confirmableAction,
180+ branchName,
181+ includesCommit: String(
182+ input.action === "commit_push" || input.action === "commit_push_pr",
183+ ),
161184 });
162185 return;
163186 }
164187
165188 await onRunAction(input);
166189 },
167− [environmentId, gitStatus, isDefaultRef, onRunAction, router, threadId],
190+ [environmentId, gitStatus, isDefaultRef, onRunAction, navigation, threadId],
168191 );
169192
170193 const runQuickAction = useCallback(async () => {
@@ -181,102 +204,339 @@ export function ThreadGitControls(props: { · 10 unmodified lines
181204 }
182205 }, [onPull, openExistingPr, quickAction, runActionWithPrompt]);
183206
207+ const openFiles = useCallback(() => {
208+ if (props.onOpenFilesInspector) {
209+ props.onOpenFilesInspector();
210+ return;
211+ }
212+ navigation.navigate("ThreadFiles", {
213+ environmentId: String(environmentId),
214+ threadId: String(threadId),
215+ });
216+ }, [environmentId, props.onOpenFilesInspector, navigation, threadId]);
217+
218+ const openReview = useCallback(() => {
219+ navigation.navigate("ThreadReview", {
220+ environmentId: EnvironmentId.make(String(environmentId)),
221+ threadId: ThreadId.make(String(threadId)),
222+ });
223+ }, [environmentId, navigation, threadId]);
224+
225+ const openGitInspector = useCallback(() => {
226+ if (props.onOpenGitInspector) {
227+ props.onOpenGitInspector();
228+ return;
229+ }
230+ navigation.navigate("GitOverview", {
231+ environmentId: String(environmentId),
232+ threadId: String(threadId),
233+ });
234+ }, [environmentId, props.onOpenGitInspector, navigation, threadId]);
235+
236+ return {
237+ currentBranchLabel,
238+ isRepo,
239+ openFiles,
240+ openGitInspector,
241+ openReview,
242+ quickAction,
243+ quickActionHint,
244+ quickActionIcon,
245+ runQuickAction,
246+ };
247+}
248+
249+function useThreadGitHeaderActionItems(props: ThreadGitControlsProps): ThreadGitHeaderActionItems {
250+ const model = useThreadGitControlModel(props);
251+
252+ return useMemo(
253+ () => ({
254+ terminal: {
255+ accessibilityLabel: "Open terminal",
256+ disabled: !props.canOpenTerminal,
257+ icon: { name: "terminal", type: "sfSymbol" },
258+ identifier: "thread-right-terminal",
259+ label: "Terminal",
260+ menu: {
261+ items: [
262+ ...props.projectScripts.map((script) => ({
263+ description: script.command,
264+ icon: { name: projectScriptMenuIcon(script.icon), type: "sfSymbol" as const },
265+ label: projectScriptMenuLabel(script),
266+ onPress: () => void props.onRunProjectScript(script),
267+ type: "action" as const,
268+ })),
269+ ...(props.projectScripts.length === 0
270+ ? [
271+ {
272+ description: "This project has no saved scripts yet",
273+ disabled: true,
274+ icon: { name: "play", type: "sfSymbol" as const },
275+ label: "No project scripts",
276+ onPress: () => {},
277+ type: "action" as const,
278+ },
279+ ]
280+ : []),
281+ ...props.terminalSessions.map((session) => ({
282+ description: [
283+ getTerminalStatusLabel({
284+ status: session.status,
285+ hasRunningSubprocess: session.hasRunningSubprocess,
286+ }),
287+ basename(session.cwd),
288+ ]
289+ .filter(Boolean)
290+ .join(" · "),
291+ icon: { name: "terminal", type: "sfSymbol" as const },
292+ label: session.displayLabel,
293+ onPress: () => props.onOpenTerminal(session.terminalId),
294+ type: "action" as const,
295+ })),
296+ {
297+ description: "Start another shell for this thread",
298+ icon: { name: "plus", type: "sfSymbol" },
299+ label: "Open new terminal",
300+ onPress: props.onOpenNewTerminal,
301+ type: "action",
302+ },
303+ ],
304+ title: "Terminal",
305+ },
306+ sharesBackground: true,
307+ type: "menu",
308+ variant: "plain",
309+ },
310+ files: {
311+ accessibilityLabel: "Open files",
312+ disabled: !props.canOpenFiles,
313+ icon: { name: "folder", type: "sfSymbol" },
314+ identifier: "thread-right-files",
315+ label: "Files",
316+ onPress: model.openFiles,
317+ sharesBackground: true,
318+ type: "button",
319+ variant: "plain",
320+ },
321+ git: {
322+ accessibilityLabel: "Git actions",
323+ icon: { name: "point.topleft.down.curvedto.point.bottomright.up", type: "sfSymbol" },
324+ identifier: "thread-right-git",
325+ label: "Git",
326+ menu: {
327+ items: [
328+ {
329+ description: compactMenuStatus(props.gitStatus),
330+ disabled: true,
331+ icon: {
332+ name: "point.topleft.down.curvedto.point.bottomright.up",
333+ type: "sfSymbol",
334+ },
335+ label: compactMenuBranchLabel(model.currentBranchLabel),
336+ onPress: (): void => {},
337+ type: "action",
338+ },
339+ {
340+ description: model.quickActionHint ?? undefined,
341+ disabled: model.quickAction.disabled,
342+ icon: { name: model.quickActionIcon, type: "sfSymbol" },
343+ label: model.quickAction.label,
344+ onPress: (): void => void model.runQuickAction(),
345+ type: "action",
346+ },
347+ {
348+ description: "Turn diffs and worktree changes",
349+ disabled: !model.isRepo,
350+ icon: { name: "text.bubble", type: "sfSymbol" },
351+ label: "Review changes",
352+ onPress: model.openReview,
353+ type: "action",
354+ },
355+ {
356+ description: "Commit, files, branches",
357+ icon: { name: "ellipsis", type: "sfSymbol" },
358+ label: "More",
359+ onPress: model.openGitInspector,
360+ type: "action",
361+ },
362+ ],
363+ title: "Git",
364+ },
365+ sharesBackground: true,
366+ type: "menu",
367+ variant: "plain",
368+ },
369+ }),
370+ [
371+ model.currentBranchLabel,
372+ model.isRepo,
373+ model.openFiles,
374+ model.openGitInspector,
375+ model.openReview,
376+ model.quickAction.disabled,
377+ model.quickAction.label,
378+ model.quickActionHint,
379+ model.quickActionIcon,
380+ model.runQuickAction,
381+ props.canOpenFiles,
382+ props.canOpenTerminal,
383+ props.gitStatus,
384+ props.onOpenNewTerminal,
385+ props.onOpenTerminal,
386+ props.onRunProjectScript,
387+ props.projectScripts,
388+ props.terminalSessions,
389+ ],
390+ );
391+}
392+
393+export function useThreadGitRightHeaderItems(props: ThreadGitControlsProps): HeaderItems {
394+ const actionItems = useThreadGitHeaderActionItems(props);
395+ return useMemo(
396+ () => [actionItems.git, actionItems.files, actionItems.terminal] as HeaderItems,
397+ [actionItems],
398+ );
399+}
400+
401+export function useThreadGitCenterHeaderItems(props: ThreadGitControlsProps): HeaderItems {
402+ const actionItems = useThreadGitHeaderActionItems(props);
403+ return useMemo(
404+ () => [actionItems.files, actionItems.git, actionItems.terminal] as HeaderItems,
405+ [actionItems],
406+ );
407+}
408+
409+export function ThreadGitControls(props: ThreadGitControlsProps) {
410+ const model = useThreadGitControlModel(props);
411+ const showActionControls = props.showActionControls ?? true;
412+
413+ if (!showActionControls) {
414+ return null;
415+ }
416+
184417 return (
185− <Stack.Toolbar placement="right">
186− <Stack.Toolbar.Menu icon="terminal" disabled={!props.canOpenTerminal} separateBackground>
187− {props.projectScripts.length > 0 ? (
188− props.projectScripts.map((script) => (
189− <Stack.Toolbar.MenuAction
190− key={script.id}
191− icon={projectScriptMenuIcon(script.icon)}
192− onPress={() => void props.onRunProjectScript(script)}
193− subtitle={script.command}
418+ <NativeHeaderToolbar placement="right">
419+ {showActionControls && props.auxiliaryPaneControl ? (
420+ <NativeHeaderToolbar.Button
421+ accessibilityLabel={props.auxiliaryPaneControl.accessibilityLabel}
422+ icon="sidebar.right"
423+ onPress={props.auxiliaryPaneControl.onPress}
424+ separateBackground
425+ />
426+ ) : null}
427+ {showActionControls ? (
428+ <NativeHeaderToolbar.Menu
429+ icon="terminal"
430+ disabled={!props.canOpenTerminal}
431+ separateBackground
432+ >
433+ {props.projectScripts.length > 0 ? (
434+ props.projectScripts.map((script) => (
435+ <NativeHeaderToolbar.MenuAction
436+ key={script.id}
437+ icon={projectScriptMenuIcon(script.icon)}
438+ onPress={() => void props.onRunProjectScript(script)}
439+ subtitle={script.command}
440+ >
441+ <NativeHeaderToolbar.Label>
442+ {projectScriptMenuLabel(script)}
443+ </NativeHeaderToolbar.Label>
444+ </NativeHeaderToolbar.MenuAction>
445+ ))
446+ ) : (
447+ <NativeHeaderToolbar.MenuAction
448+ icon="play"
449+ disabled
450+ onPress={() => {}}
451+ subtitle="This project has no saved scripts yet"
194452 >
195− <Stack.Toolbar.Label>{projectScriptMenuLabel(script)}</Stack.Toolbar.Label>
196− </Stack.Toolbar.MenuAction>
197− ))
198− ) : (
199− <Stack.Toolbar.MenuAction
200− icon="play"
201− disabled
202− onPress={() => {}}
203− subtitle="This project has no saved scripts yet"
204− >
205− <Stack.Toolbar.Label>No project scripts</Stack.Toolbar.Label>
206− </Stack.Toolbar.MenuAction>
207− )}
208− {props.terminalSessions.map((session) => (
209− <Stack.Toolbar.MenuAction
210− key={session.terminalId}
211− icon="terminal"
212− onPress={() => props.onOpenTerminal(session.terminalId)}
213− subtitle={[
214− getTerminalStatusLabel({
215− status: session.status,
216− hasRunningSubprocess: session.hasRunningSubprocess,
217− }),
218− basename(session.cwd),
219− ]
220− .filter(Boolean)
221− .join(" · ")}
453+ <NativeHeaderToolbar.Label>No project scripts</NativeHeaderToolbar.Label>
454+ </NativeHeaderToolbar.MenuAction>
455+ )}
456+ {props.terminalSessions.map((session) => (
457+ <NativeHeaderToolbar.MenuAction
458+ key={session.terminalId}
459+ icon="terminal"
460+ onPress={() => props.onOpenTerminal(session.terminalId)}
461+ subtitle={[
462+ getTerminalStatusLabel({
463+ status: session.status,
464+ hasRunningSubprocess: session.hasRunningSubprocess,
465+ }),
466+ basename(session.cwd),
467+ ]
468+ .filter(Boolean)
469+ .join(" · ")}
470+ >
471+ <NativeHeaderToolbar.Label>{session.displayLabel}</NativeHeaderToolbar.Label>
472+ </NativeHeaderToolbar.MenuAction>
473+ ))}
474+ <NativeHeaderToolbar.MenuAction
475+ icon="plus"
476+ onPress={props.onOpenNewTerminal}
477+ subtitle="Start another shell for this thread"
222478 >
223− <Stack.Toolbar.Label>{session.displayLabel}</Stack.Toolbar.Label>
224− </Stack.Toolbar.MenuAction>
225− ))}
226− <Stack.Toolbar.MenuAction
227− icon="plus"
228− onPress={props.onOpenNewTerminal}
229− subtitle="Start another shell for this thread"
230− >
231− <Stack.Toolbar.Label>Open new terminal</Stack.Toolbar.Label>
232− </Stack.Toolbar.MenuAction>
233− </Stack.Toolbar.Menu>
234− <Stack.Toolbar.Menu icon="point.topleft.down.curvedto.point.bottomright.up">
235− <Stack.Toolbar.MenuAction
236− icon="point.topleft.down.curvedto.point.bottomright.up"
237− disabled
238− onPress={() => {}}
239− subtitle={compactMenuStatus(gitStatus)}
240− >
241− <Stack.Toolbar.Label>{compactMenuBranchLabel(currentBranchLabel)}</Stack.Toolbar.Label>
242− </Stack.Toolbar.MenuAction>
243− <Stack.Toolbar.MenuAction
244− icon={quickActionIcon}
245− disabled={quickAction.disabled}
246− onPress={() => void runQuickAction()}
247− subtitle={quickActionHint ?? undefined}
248− >
249− <Stack.Toolbar.Label>{quickAction.label}</Stack.Toolbar.Label>
250− </Stack.Toolbar.MenuAction>
251− <Stack.Toolbar.MenuAction
252− icon="text.bubble"
253− disabled={!isRepo}
254− onPress={() => router.push(buildThreadReviewRoutePath({ environmentId, threadId }))}
255− subtitle="Turn diffs and worktree changes"
256− >
257− <Stack.Toolbar.Label>Review changes</Stack.Toolbar.Label>
258− </Stack.Toolbar.MenuAction>
259− <Stack.Toolbar.MenuAction
260− icon="folder"
479+ <NativeHeaderToolbar.Label>Open new terminal</NativeHeaderToolbar.Label>
480+ </NativeHeaderToolbar.MenuAction>
481+ </NativeHeaderToolbar.Menu>
482+ ) : null}
483+ {showActionControls && props.showDirectFileControl ? (
484+ <NativeHeaderToolbar.Button
485+ accessibilityLabel="Open files"
261486 disabled={!props.canOpenFiles}
262− onPress={() => router.push(buildThreadFilesNavigation({ environmentId, threadId }))}
263− subtitle="Browse this workspace"
264− >
265− <Stack.Toolbar.Label>Files</Stack.Toolbar.Label>
266− </Stack.Toolbar.MenuAction>
267− <Stack.Toolbar.MenuAction
268− icon="ellipsis.circle"
269− onPress={() =>
270− router.push({
271− pathname: "/threads/[environmentId]/[threadId]/git",
272− params: { environmentId, threadId },
273− })
274− }
275− subtitle="Commit, files, branches"
276− >
277− <Stack.Toolbar.Label>More</Stack.Toolbar.Label>
278− </Stack.Toolbar.MenuAction>
279− </Stack.Toolbar.Menu>
280− </Stack.Toolbar>
487+ icon="folder"
488+ onPress={model.openFiles}
489+ separateBackground
490+ />
491+ ) : null}
492+ {showActionControls ? <ThreadGitMenu {...props} /> : null}
493+ </NativeHeaderToolbar>
494+ );
495+}
496+
497+/**
498+ * The standalone git actions menu (branch status, quick commit/push action,
499+ * review, more). Rendered inside a NativeHeaderToolbar by both the thread
500+ * chat header and the review screen's toolbar.
501+ */
502+export function ThreadGitMenu(props: ThreadGitMenuProps) {
503+ const model = useThreadGitControlModel(props);
504+
505+ return (
506+ <NativeHeaderToolbar.Menu icon="point.topleft.down.curvedto.point.bottomright.up">
507+ <NativeHeaderToolbar.MenuAction
508+ icon="point.topleft.down.curvedto.point.bottomright.up"
509+ disabled
510+ onPress={() => {}}
511+ subtitle={compactMenuStatus(props.gitStatus)}
512+ >
513+ <NativeHeaderToolbar.Label>
514+ {compactMenuBranchLabel(model.currentBranchLabel)}
515+ </NativeHeaderToolbar.Label>
516+ </NativeHeaderToolbar.MenuAction>
517+ <NativeHeaderToolbar.MenuAction
518+ icon={model.quickActionIcon}
519+ disabled={model.quickAction.disabled}
520+ onPress={() => void model.runQuickAction()}
521+ subtitle={model.quickActionHint ?? undefined}
522+ >
523+ <NativeHeaderToolbar.Label>{model.quickAction.label}</NativeHeaderToolbar.Label>
524+ </NativeHeaderToolbar.MenuAction>
525+ <NativeHeaderToolbar.MenuAction
526+ icon="text.bubble"
527+ disabled={!model.isRepo}
528+ onPress={model.openReview}
529+ subtitle="Turn diffs and worktree changes"
530+ >
531+ <NativeHeaderToolbar.Label>Review changes</NativeHeaderToolbar.Label>
532+ </NativeHeaderToolbar.MenuAction>
533+ <NativeHeaderToolbar.MenuAction
534+ icon="ellipsis"
535+ onPress={model.openGitInspector}
536+ subtitle="Commit, files, branches"
537+ >
538+ <NativeHeaderToolbar.Label>More</NativeHeaderToolbar.Label>
539+ </NativeHeaderToolbar.MenuAction>
540+ </NativeHeaderToolbar.Menu>
281541 );
282542 }
apps/mobile/src/features/threads/ThreadNavigationDrawer.tsx+7−34
@@ -8,8 +8,6 @@ import { · 7 unmodified lines
88 useWindowDimensions,
99 View,
1010 } from "react-native";
11−import * as Arr from "effect/Array";
12−import * as Order from "effect/Order";
1311 import { Gesture, GestureDetector } from "react-native-gesture-handler";
1412 import { useSafeAreaInsets } from "react-native-safe-area-context";
1513 import Animated, {
@@ -23,22 +21,11 @@ import { useThemeColor } from "../../lib/useThemeColor"; · 7 unmodified lines
2321 import { AppText as Text } from "../../components/AppText";
2422 import { StatusPill } from "../../components/StatusPill";
2523 import { useProjects, useThreadShells } from "../../state/entities";
26−import { groupProjectsByRepository } from "../../lib/repositoryGroups";
2724 import { scopedThreadKey } from "../../lib/scopedEntities";
2825 import { relativeTime } from "../../lib/time";
29−import { threadStatusTone } from "./threadPresentation";
30−import { EnvironmentThreadShell } from "@t3tools/client-runtime/state/shell";
31−
32−const threadActivityOrder = Order.mapInput(
33− Order.Struct({
34− activityAt: Order.flip(Order.Number),
35− title: Order.String,
36− }),
37− (thread: EnvironmentThreadShell) => ({
38− activityAt: new Date(thread.updatedAt ?? thread.createdAt).getTime(),
39− title: thread.title,
40− }),
41−);
26+import { resolveThreadStatus } from "./threadPresentation";
27+import type { EnvironmentThreadShell } from "@t3tools/client-runtime/state/shell";
28+import { buildThreadNavigationGroups } from "./thread-navigation-groups";
4229
4330 export function ThreadNavigationDrawer(props: {
4431 readonly visible: boolean;
@@ -192,24 +179,9 @@ function ThreadNavigationDrawerContent(props: { · 147 unmodified lines
192179 }) {
193180 const projects = useProjects();
194181 const threads = useThreadShells();
195− const repositoryGroups = useMemo(
196− () => groupProjectsByRepository({ projects, threads }),
197− [projects, threads],
198− );
199182 const groupedThreads = useMemo(
200− () =>
201− repositoryGroups.map((group) => {
202− const threads: EnvironmentThreadShell[] = [];
203− for (const projectGroup of group.projects) {
204− threads.push(...projectGroup.threads);
205− }
206− return {
207− key: group.key,
208− title: group.projects[0]?.project.title ?? group.title,
209− threads: Arr.sort(threads, threadActivityOrder),
210− };
211− }),
212− [repositoryGroups],
183+ () => buildThreadNavigationGroups({ projects, threads }),
184+ [projects, threads],
213185 );
214186
215187 return (
@@ -239,6 +211,7 @@ function ThreadNavigationDrawerContent(props: { · 23 unmodified lines
239211 group.threads.map((thread, index) => {
240212 const threadKey = scopedThreadKey(thread.environmentId, thread.id);
241213 const selected = props.selectedThreadKey === threadKey;
214+ const status = resolveThreadStatus(thread);
242215
243216 return (
244217 <Pressable
@@ -268,7 +241,7 @@ function ThreadNavigationDrawerContent(props: { · 23 unmodified lines
268241 {relativeTime(thread.updatedAt ?? thread.createdAt)}
269242 </Text>
270243 </View>
271− <StatusPill {...threadStatusTone(thread)} />
244+ {status ? <StatusPill {...status} /> : null}
272245 </View>
273246 </Pressable>
274247 );
apps/mobile/src/features/threads/ThreadNavigationSidebar.tsx+820−0
@@ -0,0 +1,820 @@
1+import { isLiquidGlassSupported, LiquidGlassView } from "@callstack/liquid-glass";
2+import type { EnvironmentThreadShell } from "@t3tools/client-runtime/state/shell";
3+import { LegendList } from "@legendapp/list/react-native";
4+import type { MenuAction } from "@react-native-menu/menu";
5+import { SymbolView } from "expo-symbols";
6+import { useCallback, useMemo, useRef, useState, type ReactNode } from "react";
7+import type { LayoutChangeEvent, NativeScrollEvent, NativeSyntheticEvent } from "react-native";
8+import { Platform, StyleSheet, TextInput, View, useColorScheme } from "react-native";
9+import { Gesture, GestureDetector } from "react-native-gesture-handler";
10+import type { SwipeableMethods } from "react-native-gesture-handler/ReanimatedSwipeable";
11+import { useSafeAreaInsets } from "react-native-safe-area-context";
12+import type { SearchBarCommands } from "react-native-screens";
13+import Svg, { Defs, LinearGradient, Rect, Stop } from "react-native-svg";
14+
15+import { AppText as Text } from "../../components/AppText";
16+import { ControlPillMenu } from "../../components/ControlPill";
17+import { NativeStackScreenOptions } from "../../native/StackHeader";
18+import { scopedProjectKey, scopedThreadKey } from "../../lib/scopedEntities";
19+import { useThemeColor } from "../../lib/useThemeColor";
20+import { useProjects, useThreadShells } from "../../state/entities";
21+import { usePendingNewTasks } from "../../state/use-pending-new-tasks";
22+import { useWorkspaceState } from "../../state/workspace";
23+import { useSavedRemoteConnections } from "../../state/use-remote-environment-registry";
24+import { useHardwareKeyboardCommand } from "../keyboard/hardwareKeyboardCommands";
25+import {
26+ hasCustomHomeListOptions,
27+ PROJECT_GROUPING_OPTIONS,
28+ PROJECT_SORT_OPTIONS,
29+ THREAD_SORT_OPTIONS,
30+ useHomeListOptions,
31+} from "../home/home-list-options";
32+import { buildHomeListFilterMenu } from "../home/home-list-filter-menu";
33+import {
34+ buildHomeListLayout,
35+ DEFAULT_GROUP_DISPLAY_STATE,
36+ homeListItemsAreEqual,
37+ nextGroupDisplayState,
38+ type HomeGroupDisplayAction,
39+ type HomeGroupDisplayState,
40+ type HomeListItem,
41+} from "../home/homeListItems";
42+import { buildHomeThreadGroups } from "../home/homeThreadList";
43+import { SwipeableScrollGateProvider, useSwipeableScrollGate } from "../home/thread-swipe-actions";
44+import { usePendingTaskListActions } from "../home/usePendingTaskListActions";
45+import { useThreadListActions } from "../home/useThreadListActions";
46+import { WorkspaceConnectionStatus } from "../home/WorkspaceConnectionStatus";
47+import { shouldShowWorkspaceConnectionStatus } from "../home/workspace-connection-status";
48+import { SidebarHeaderActions } from "./sidebar-header-actions";
49+import { SidebarFilterButton } from "./sidebar-filter-button";
50+import { createSidebarHeaderItems } from "./sidebar-native-header-items";
51+import { SidebarNavigationShell } from "./sidebar-navigation-shell";
52+import {
53+ PendingTaskListRow,
54+ ThreadListGroupHeader,
55+ ThreadListRow,
56+ ThreadListShowMoreRow,
57+} from "./thread-list-items";
58+
59+/**
60+ * Shared capsule behind the sidebar header buttons — a native liquid-glass
61+ * surface on iOS 26+, a tinted pill everywhere else.
62+ */
63+function SidebarHeaderButtonGroup(props: {
64+ readonly children: ReactNode;
65+ readonly colorScheme: "light" | "dark";
66+}) {
67+ if (isLiquidGlassSupported) {
68+ return (
69+ <LiquidGlassView
70+ colorScheme={props.colorScheme}
71+ effect="regular"
72+ interactive
73+ style={styles.headerButtonGroup}
74+ >
75+ {props.children}
76+ </LiquidGlassView>
77+ );
78+ }
79+
80+ return (
81+ <View
82+ style={[
83+ styles.headerButtonGroup,
84+ props.colorScheme === "dark"
85+ ? { backgroundColor: "rgba(118,118,128,0.24)", borderColor: "rgba(255,255,255,0.08)" }
86+ : { backgroundColor: "rgba(255,255,255,0.72)", borderColor: "rgba(0,0,0,0.08)" },
87+ { borderWidth: StyleSheet.hairlineWidth },
88+ ]}
89+ >
90+ {props.children}
91+ </View>
92+ );
93+}
94+
95+const SIDEBAR_STICKY_HEADER_HEIGHT = 106;
96+const SIDEBAR_STICKY_HEADER_FADE_HEIGHT = 44;
97+const IOS_SEARCH_FILL_DARK = "rgba(118, 118, 128, 0.24)";
98+const IOS_SEARCH_FILL_LIGHT = "rgba(118, 118, 128, 0.12)";
99+const SIDEBAR_HEADER_WASH_OPACITY = {
100+ dark: [0.22, 0.14, 0.04],
101+ light: [0.46, 0.3, 0.08],
102+} as const;
103+
104+interface ThreadNavigationSidebarProps {
105+ readonly width: number;
106+ readonly visible: boolean;
107+ readonly selectedThreadKey: string | null;
108+ readonly onOpenSettings: () => void;
109+ readonly onOpenEnvironmentSettings: () => void;
110+ readonly onSearchQueryChange: (query: string) => void;
111+ readonly onSelectThread: (thread: EnvironmentThreadShell) => void;
112+ readonly onRequestVisibility: () => void;
113+ readonly searchQuery: string;
114+}
115+
116+/**
117+ * iPad/large-width sidebar column.
118+ *
119+ * On iOS the pane is hosted inside its own navigation-inert single-screen
120+ * native stack (SidebarNavigationShell) so the header is a real
121+ * UINavigationBar: large title, native bar-button items, and a
122+ * UISearchController search field — the same chrome a UISplitViewController
123+ * column gets. Other platforms keep the custom header chrome.
124+ */
125+export function ThreadNavigationSidebar(props: ThreadNavigationSidebarProps) {
126+ if (Platform.OS !== "ios") {
127+ return <ThreadNavigationSidebarPane {...props} nativeChrome={false} />;
128+ }
129+ return <NativeSidebarContainer {...props} />;
130+}
131+
132+function NativeSidebarContainer(props: ThreadNavigationSidebarProps) {
133+ const backgroundColor = useThemeColor("--color-drawer");
134+ const borderColor = useThemeColor("--color-border");
135+
136+ return (
137+ <View
138+ testID="thread-navigation-sidebar"
139+ style={[
140+ styles.container,
141+ {
142+ width: props.width,
143+ backgroundColor,
144+ borderRightColor: borderColor,
145+ borderRightWidth: StyleSheet.hairlineWidth,
146+ },
147+ ]}
148+ >
149+ <SidebarNavigationShell>
150+ <ThreadNavigationSidebarPane {...props} nativeChrome />
151+ </SidebarNavigationShell>
152+ </View>
153+ );
154+}
155+
156+function ThreadNavigationSidebarPane(
157+ props: ThreadNavigationSidebarProps & { readonly nativeChrome: boolean },
158+) {
159+ const insets = useSafeAreaInsets();
160+ const colorScheme = useColorScheme() === "dark" ? "dark" : "light";
161+ const projects = useProjects();
162+ const threads = useThreadShells();
163+ const { state: catalogState } = useWorkspaceState();
164+ const { savedConnectionsById } = useSavedRemoteConnections();
165+ const [headerIsOverContent, setHeaderIsOverContent] = useState(false);
166+ const searchInputRef = useRef<TextInput>(null);
167+ const searchBarRef = useRef<SearchBarCommands>(null);
168+ const openSwipeableRef = useRef<SwipeableMethods | null>(null);
169+ const headerIsOverContentRef = useRef(false);
170+ const sidebarScrollGesture = useMemo(() => Gesture.Native(), []);
171+ const { archiveThread, confirmDeleteThread } = useThreadListActions();
172+ const pendingTasks = usePendingNewTasks();
173+ const { openPendingTask, confirmDeletePendingTask } = usePendingTaskListActions();
174+ const environments = useMemo(
175+ () =>
176+ Object.values(savedConnectionsById)
177+ .map((connection) => ({
178+ environmentId: connection.environmentId,
179+ label: connection.environmentLabel,
180+ }))
181+ .sort((left, right) => left.label.localeCompare(right.label)),
182+ [savedConnectionsById],
183+ );
184+ const availableEnvironmentIds = useMemo(
185+ () => new Set(environments.map((environment) => environment.environmentId)),
186+ [environments],
187+ );
188+ const {
189+ options,
190+ setSelectedEnvironmentId,
191+ setProjectGroupingMode,
192+ setProjectSortOrder,
193+ setThreadSortOrder,
194+ } = useHomeListOptions(availableEnvironmentIds);
195+ const groups = useMemo(
196+ () =>
197+ buildHomeThreadGroups({
198+ projects,
199+ threads,
200+ pendingTasks,
201+ environmentId: options.selectedEnvironmentId,
202+ searchQuery: props.searchQuery,
203+ projectSortOrder: options.projectSortOrder,
204+ threadSortOrder: options.threadSortOrder,
205+ projectGroupingMode: options.projectGroupingMode,
206+ }),
207+ [options, pendingTasks, projects, props.searchQuery, threads],
208+ );
209+ const [groupDisplayStates, setGroupDisplayStates] = useState<
210+ ReadonlyMap<string, HomeGroupDisplayState>
211+ >(() => new Map());
212+ const updateGroupDisplay = useCallback((key: string, action: HomeGroupDisplayAction) => {
213+ setGroupDisplayStates((previous) => {
214+ const next = new Map(previous);
215+ next.set(
216+ key,
217+ nextGroupDisplayState(previous.get(key) ?? DEFAULT_GROUP_DISPLAY_STATE, action),
218+ );
219+ return next;
220+ });
221+ }, []);
222+ const hasSearchQuery = props.searchQuery.trim().length > 0;
223+ const listLayout = useMemo(
224+ () =>
225+ buildHomeListLayout({
226+ groups,
227+ displayStates: groupDisplayStates,
228+ showAllThreads: hasSearchQuery,
229+ }),
230+ [groups, groupDisplayStates, hasSearchQuery],
231+ );
232+ const projectCwdByKey = useMemo(() => {
233+ const map = new Map<string, string>();
234+ for (const project of projects) {
235+ map.set(scopedProjectKey(project.environmentId, project.id), project.workspaceRoot);
236+ }
237+ return map;
238+ }, [projects]);
239+ const showsConnectionStatus = shouldShowWorkspaceConnectionStatus(catalogState);
240+ const listMenuActions = useMemo<MenuAction[]>(
241+ () => [
242+ {
243+ id: "environment",
244+ title: "Environment",
245+ subactions: [
246+ {
247+ id: "environment:all",
248+ title: "All environments",
249+ subtitle: "Show threads from every environment",
250+ state: options.selectedEnvironmentId === null ? "on" : "off",
251+ },
252+ ...environments.map((environment) => ({
253+ id: `environment:${environment.environmentId}`,
254+ title: environment.label,
255+ state:
256+ options.selectedEnvironmentId === environment.environmentId
257+ ? ("on" as const)
258+ : ("off" as const),
259+ })),
260+ ],
261+ },
262+ {
263+ id: "project-sort",
264+ title: "Sort projects",
265+ subactions: PROJECT_SORT_OPTIONS.map((option) => ({
266+ id: `project-sort:${option.value}`,
267+ title: option.label,
268+ state: options.projectSortOrder === option.value ? "on" : "off",
269+ })),
270+ },
271+ {
272+ id: "thread-sort",
273+ title: "Sort threads",
274+ subactions: THREAD_SORT_OPTIONS.map((option) => ({
275+ id: `thread-sort:${option.value}`,
276+ title: option.label,
277+ state: options.threadSortOrder === option.value ? "on" : "off",
278+ })),
279+ },
280+ {
281+ id: "project-grouping",
282+ title: "Group projects",
283+ subactions: PROJECT_GROUPING_OPTIONS.map((option) => ({
284+ id: `project-grouping:${option.value}`,
285+ title: option.label,
286+ subtitle: option.subtitle,
287+ state: options.projectGroupingMode === option.value ? "on" : "off",
288+ })),
289+ },
290+ ],
291+ [environments, options],
292+ );
293+ const handleListMenuAction = useCallback(
294+ ({ nativeEvent }: { readonly nativeEvent: { readonly event: string } }) => {
295+ const event = nativeEvent.event;
296+ if (event === "environment:all") {
297+ setSelectedEnvironmentId(null);
298+ return;
299+ }
300+ if (event.startsWith("environment:")) {
301+ const environment = environments.find(
302+ (candidate) => String(candidate.environmentId) === event.slice("environment:".length),
303+ );
304+ if (environment) setSelectedEnvironmentId(environment.environmentId);
305+ return;
306+ }
307+ const projectSort = PROJECT_SORT_OPTIONS.find(
308+ (option) => `project-sort:${option.value}` === event,
309+ );
310+ if (projectSort) {
311+ setProjectSortOrder(projectSort.value);
312+ return;
313+ }
314+ const threadSort = THREAD_SORT_OPTIONS.find(
315+ (option) => `thread-sort:${option.value}` === event,
316+ );
317+ if (threadSort) {
318+ setThreadSortOrder(threadSort.value);
319+ return;
320+ }
321+ const grouping = PROJECT_GROUPING_OPTIONS.find(
322+ (option) => `project-grouping:${option.value}` === event,
323+ );
324+ if (grouping) setProjectGroupingMode(grouping.value);
325+ },
326+ [
327+ environments,
328+ setProjectGroupingMode,
329+ setProjectSortOrder,
330+ setSelectedEnvironmentId,
331+ setThreadSortOrder,
332+ ],
333+ );
334+
335+ const backgroundColor = useThemeColor("--color-drawer");
336+ const borderColor = useThemeColor("--color-border");
337+ const foregroundColor = useThemeColor("--color-foreground");
338+ const mutedColor = useThemeColor("--color-foreground-muted");
339+ const placeholderColor = useThemeColor("--color-placeholder");
340+ const searchBackgroundColor =
341+ colorScheme === "dark" ? IOS_SEARCH_FILL_DARK : IOS_SEARCH_FILL_LIGHT;
342+ const headerFadeColor = String(backgroundColor);
343+ const headerWashOpacity = SIDEBAR_HEADER_WASH_OPACITY[colorScheme];
344+ const [measuredHeaderHeight, setMeasuredHeaderHeight] = useState<number | null>(null);
345+ // The sticky header (title row, search field, optional connection status)
346+ // is measured so the list inset always matches its real height — no
347+ // hardcoded per-variant constants.
348+ const stickyHeaderHeight = measuredHeaderHeight ?? insets.top + SIDEBAR_STICKY_HEADER_HEIGHT;
349+ const topListInset = stickyHeaderHeight + 6;
350+ const handleStickyHeaderLayout = useCallback((event: LayoutChangeEvent) => {
351+ const nextHeight = Math.round(event.nativeEvent.layout.height);
352+ setMeasuredHeaderHeight((current) => (current === nextHeight ? current : nextHeight));
353+ }, []);
354+ const handleSwipeableWillOpen = useCallback((methods: SwipeableMethods) => {
355+ if (openSwipeableRef.current !== methods) {
356+ openSwipeableRef.current?.close();
357+ openSwipeableRef.current = methods;
358+ }
359+ }, []);
360+ const handleSwipeableClose = useCallback((methods: SwipeableMethods) => {
361+ if (openSwipeableRef.current === methods) {
362+ openSwipeableRef.current = null;
363+ }
364+ }, []);
365+ const handleSelectThread = useCallback(
366+ (thread: EnvironmentThreadShell) => {
367+ props.onSelectThread(thread);
368+ openSwipeableRef.current?.close();
369+ },
370+ [props.onSelectThread],
371+ );
372+ const handleScroll = useCallback((event: NativeSyntheticEvent<NativeScrollEvent>) => {
373+ const next = event.nativeEvent.contentOffset.y > 6;
374+ if (headerIsOverContentRef.current === next) {
375+ return;
376+ }
377+ headerIsOverContentRef.current = next;
378+ setHeaderIsOverContent(next);
379+ }, []);
380+ const handleScrollBeginDrag = useCallback(() => {
381+ openSwipeableRef.current?.close();
382+ }, []);
383+ const { swipeEnabled, scrollGateHandlers } = useSwipeableScrollGate({
384+ onScroll: handleScroll,
385+ onScrollBeginDrag: handleScrollBeginDrag,
386+ });
387+ const listExtraData = props.selectedThreadKey ?? "";
388+ const focusSearch = useCallback(() => {
389+ const focus = () => {
390+ if (props.nativeChrome) {
391+ searchBarRef.current?.focus();
392+ return;
393+ }
394+ searchInputRef.current?.focus();
395+ };
396+ if (!props.visible) {
397+ props.onRequestVisibility();
398+ setTimeout(focus, 240);
399+ } else {
400+ focus();
401+ }
402+ return true;
403+ }, [props.nativeChrome, props.onRequestVisibility, props.visible]);
404+ useHardwareKeyboardCommand("focusSearch", focusSearch);
405+ const renderListItem = useCallback(
406+ ({ item }: { readonly item: HomeListItem }) => {
407+ switch (item.type) {
408+ case "header":
409+ return (
410+ <ThreadListGroupHeader
411+ variant="sidebar"
412+ collapsed={item.collapsed}
413+ isFirst={item.isFirst}
414+ groupKey={item.group.key}
415+ onGroupAction={updateGroupDisplay}
416+ project={item.group.representative}
417+ threadCount={item.group.threads.length + item.group.pendingTasks.length}
418+ title={item.group.title}
419+ />
420+ );
421+ case "pending-task":
422+ return (
423+ <PendingTaskListRow
424+ variant="sidebar"
425+ pendingTask={item.pendingTask}
426+ environmentLabel={
427+ savedConnectionsById[item.pendingTask.message.environmentId]?.environmentLabel ??
428+ null
429+ }
430+ isLast={item.isLast}
431+ onSelectPendingTask={openPendingTask}
432+ onDeletePendingTask={confirmDeletePendingTask}
433+ />
434+ );
435+ case "thread": {
436+ const thread = item.thread;
437+ return (
438+ <ThreadListRow
439+ variant="sidebar"
440+ thread={thread}
441+ environmentLabel={
442+ savedConnectionsById[thread.environmentId]?.environmentLabel ?? null
443+ }
444+ projectCwd={
445+ projectCwdByKey.get(scopedProjectKey(thread.environmentId, thread.projectId)) ??
446+ null
447+ }
448+ isLast={item.isLast}
449+ selected={
450+ scopedThreadKey(thread.environmentId, thread.id) === props.selectedThreadKey
451+ }
452+ fullSwipeWidth={props.width - 20}
453+ onArchiveThread={archiveThread}
454+ onDeleteThread={confirmDeleteThread}
455+ onSelectThread={handleSelectThread}
456+ onSwipeableClose={handleSwipeableClose}
457+ onSwipeableWillOpen={handleSwipeableWillOpen}
458+ simultaneousSwipeGesture={sidebarScrollGesture}
459+ />
460+ );
461+ }
462+ case "show-more":
463+ return (
464+ <ThreadListShowMoreRow
465+ variant="sidebar"
466+ hiddenCount={item.hiddenCount}
467+ canShowLess={item.canShowLess}
468+ groupKey={item.groupKey}
469+ onGroupAction={updateGroupDisplay}
470+ />
471+ );
472+ }
473+ },
474+ [
475+ archiveThread,
476+ confirmDeletePendingTask,
477+ confirmDeleteThread,
478+ handleSelectThread,
479+ handleSwipeableClose,
480+ handleSwipeableWillOpen,
481+ openPendingTask,
482+ projectCwdByKey,
483+ props.selectedThreadKey,
484+ props.width,
485+ savedConnectionsById,
486+ sidebarScrollGesture,
487+ updateGroupDisplay,
488+ ],
489+ );
490+ const filterIcon = hasCustomHomeListOptions(options)
491+ ? "line.3.horizontal.decrease.circle.fill"
492+ : "line.3.horizontal.decrease.circle";
493+ const filterMenu = useMemo(
494+ () =>
495+ buildHomeListFilterMenu({
496+ environments,
497+ selectedEnvironmentId: options.selectedEnvironmentId,
498+ projectSortOrder: options.projectSortOrder,
499+ threadSortOrder: options.threadSortOrder,
500+ projectGroupingMode: options.projectGroupingMode,
501+ onEnvironmentChange: setSelectedEnvironmentId,
502+ onProjectSortOrderChange: setProjectSortOrder,
503+ onThreadSortOrderChange: setThreadSortOrder,
504+ onProjectGroupingModeChange: setProjectGroupingMode,
505+ }),
506+ [
507+ environments,
508+ options,
509+ setProjectGroupingMode,
510+ setProjectSortOrder,
511+ setSelectedEnvironmentId,
512+ setThreadSortOrder,
513+ ],
514+ );
515+ const nativeHeaderItems = useMemo(
516+ () =>
517+ createSidebarHeaderItems({
518+ filterIcon,
519+ filterMenu,
520+ onOpenSettings: props.onOpenSettings,
521+ }),
522+ [filterIcon, filterMenu, props.onOpenSettings],
523+ );
524+ const listEmpty = (
525+ <Text className="px-2 py-4 text-sm" style={{ color: mutedColor }}>
526+ {catalogState.isLoadingConnections
527+ ? "Loading threads…"
528+ : props.searchQuery.trim().length > 0
529+ ? "No matching threads"
530+ : "No threads yet"}
531+ </Text>
532+ );
533+
534+ if (props.nativeChrome) {
535+ return (
536+ <>
537+ <NativeStackScreenOptions
538+ options={{
539+ headerSearchBarOptions: {
540+ ref: searchBarRef,
541+ autoCapitalize: "none",
542+ hideNavigationBar: false,
543+ // Keep the search bar pinned under the title — UIKit's default
544+ // hidesSearchBarWhenScrolling collapses it on scroll.
545+ hideWhenScrolling: false,
546+ obscureBackground: false,
547+ placeholder: "Search",
548+ placement: "stacked",
549+ onCancelButtonPress: () => {
550+ props.onSearchQueryChange("");
551+ },
552+ onChangeText: (event) => {
553+ props.onSearchQueryChange(event.nativeEvent.text);
554+ },
555+ },
556+ unstable_headerRightItems: () => nativeHeaderItems,
557+ }}
558+ />
559+ <View style={styles.container}>
560+ <SwipeableScrollGateProvider enabled={swipeEnabled}>
561+ <GestureDetector gesture={sidebarScrollGesture}>
562+ <LegendList
563+ data={listLayout.items}
564+ drawDistance={500}
565+ estimatedItemSize={64}
566+ extraData={listExtraData}
567+ getItemType={(item) => item.type}
568+ itemsAreEqual={homeListItemsAreEqual}
569+ keyExtractor={(item) => item.key}
570+ renderItem={renderListItem}
571+ automaticallyAdjustsScrollIndicatorInsets
572+ contentInsetAdjustmentBehavior="automatic"
573+ contentContainerStyle={[
574+ styles.threadListContent,
575+ {
576+ paddingBottom: Math.max(insets.bottom, 16) + 16,
577+ paddingTop: 6,
578+ },
579+ ]}
580+ keyboardDismissMode="on-drag"
581+ keyboardShouldPersistTaps="handled"
582+ {...scrollGateHandlers}
583+ recycleItems
584+ scrollEventThrottle={16}
585+ showsVerticalScrollIndicator={false}
586+ style={styles.threadList}
587+ ListHeaderComponent={
588+ showsConnectionStatus ? (
589+ <View style={styles.connectionStatusNative}>
590+ <WorkspaceConnectionStatus
591+ onPress={props.onOpenEnvironmentSettings}
592+ state={catalogState}
593+ variant="sidebar"
594+ />
595+ </View>
596+ ) : null
597+ }
598+ ListEmptyComponent={listEmpty}
599+ />
600+ </GestureDetector>
601+ </SwipeableScrollGateProvider>
602+ </View>
603+ </>
604+ );
605+ }
606+
607+ return (
608+ <View
609+ testID="thread-navigation-sidebar"
610+ style={[
611+ styles.container,
612+ {
613+ width: props.width,
614+ backgroundColor,
615+ borderRightColor: borderColor,
616+ borderRightWidth: StyleSheet.hairlineWidth,
617+ },
618+ ]}
619+ >
620+ <View style={{ flex: 1, paddingBottom: insets.bottom }}>
621+ <SwipeableScrollGateProvider enabled={swipeEnabled}>
622+ <GestureDetector gesture={sidebarScrollGesture}>
623+ <LegendList
624+ data={listLayout.items}
625+ drawDistance={500}
626+ estimatedItemSize={64}
627+ extraData={listExtraData}
628+ getItemType={(item) => item.type}
629+ itemsAreEqual={homeListItemsAreEqual}
630+ keyExtractor={(item) => item.key}
631+ renderItem={renderListItem}
632+ contentContainerStyle={[
633+ styles.threadListContent,
634+ {
635+ paddingBottom: 16 + insets.bottom,
636+ paddingTop: topListInset,
637+ },
638+ ]}
639+ keyboardDismissMode="on-drag"
640+ keyboardShouldPersistTaps="handled"
641+ {...scrollGateHandlers}
642+ recycleItems
643+ scrollEventThrottle={16}
644+ showsVerticalScrollIndicator={false}
645+ style={styles.threadList}
646+ ListEmptyComponent={listEmpty}
647+ />
648+ </GestureDetector>
649+ </SwipeableScrollGateProvider>
650+ </View>
651+
652+ <View
653+ onLayout={handleStickyHeaderLayout}
654+ pointerEvents="box-none"
655+ style={[
656+ styles.stickyHeader,
657+ {
658+ paddingTop: insets.top,
659+ },
660+ ]}
661+ >
662+ <View
663+ pointerEvents="none"
664+ accessibilityElementsHidden
665+ importantForAccessibility="no-hide-descendants"
666+ style={[
667+ styles.stickyHeaderWash,
668+ {
669+ height: stickyHeaderHeight + SIDEBAR_STICKY_HEADER_FADE_HEIGHT,
670+ },
671+ ]}
672+ >
673+ <Svg width="100%" height="100%">
674+ <Defs>
675+ <LinearGradient id="sidebar-header-wash" x1="0%" x2="0%" y1="0%" y2="100%">
676+ <Stop
677+ offset="0%"
678+ stopColor={headerFadeColor}
679+ stopOpacity={headerIsOverContent ? headerWashOpacity[0] : 0}
680+ />
681+ <Stop
682+ offset="58%"
683+ stopColor={headerFadeColor}
684+ stopOpacity={headerIsOverContent ? headerWashOpacity[1] : 0}
685+ />
686+ <Stop
687+ offset="88%"
688+ stopColor={headerFadeColor}
689+ stopOpacity={headerIsOverContent ? headerWashOpacity[2] : 0}
690+ />
691+ <Stop offset="100%" stopColor={headerFadeColor} stopOpacity={0} />
692+ </LinearGradient>
693+ </Defs>
694+ <Rect width="100%" height="100%" fill="url(#sidebar-header-wash)" />
695+ </Svg>
696+ </View>
697+ <View style={styles.header}>
698+ <Text
699+ className="flex-1 text-[34px] font-t3-bold"
700+ numberOfLines={1}
701+ style={{ color: foregroundColor }}
702+ >
703+ Threads
704+ </Text>
705+ <SidebarHeaderButtonGroup colorScheme={colorScheme}>
706+ <ControlPillMenu actions={listMenuActions} onPressAction={handleListMenuAction}>
707+ <SidebarFilterButton
708+ grouped
709+ accessibilityLabel="Filter and sort threads"
710+ icon={filterIcon}
711+ />
712+ </ControlPillMenu>
713+ <SidebarHeaderActions grouped onOpenSettings={props.onOpenSettings} />
714+ </SidebarHeaderButtonGroup>
715+ </View>
716+
717+ <View
718+ style={[
719+ styles.searchField,
720+ {
721+ backgroundColor: searchBackgroundColor,
722+ },
723+ ]}
724+ >
725+ <SymbolView name="magnifyingglass" size={15} tintColor={mutedColor} type="monochrome" />
726+ <TextInput
727+ ref={searchInputRef}
728+ accessibilityLabel="Search threads"
729+ autoCapitalize="none"
730+ autoCorrect={false}
731+ clearButtonMode="while-editing"
732+ onChangeText={props.onSearchQueryChange}
733+ placeholder="Search"
734+ placeholderTextColor={placeholderColor}
735+ returnKeyType="search"
736+ className="text-base"
737+ style={[styles.searchInput, { color: foregroundColor }]}
738+ value={props.searchQuery}
739+ />
740+ </View>
741+
742+ {showsConnectionStatus ? (
743+ <View style={styles.connectionStatus}>
744+ <WorkspaceConnectionStatus
745+ onPress={props.onOpenEnvironmentSettings}
746+ state={catalogState}
747+ variant="sidebar"
748+ />
749+ </View>
750+ ) : null}
751+ </View>
752+ </View>
753+ );
754+}
755+
756+const styles = StyleSheet.create({
757+ container: {
758+ flex: 1,
759+ },
760+ stickyHeader: {
761+ left: 0,
762+ position: "absolute",
763+ right: 0,
764+ top: 0,
765+ zIndex: 4,
766+ },
767+ stickyHeaderWash: {
768+ left: 0,
769+ position: "absolute",
770+ right: 0,
771+ top: 0,
772+ },
773+ header: {
774+ height: 50,
775+ paddingLeft: 20,
776+ paddingRight: 8,
777+ flexDirection: "row",
778+ alignItems: "flex-end",
779+ gap: 2,
780+ },
781+ headerButtonGroup: {
782+ alignItems: "center",
783+ borderRadius: 22,
784+ flexDirection: "row",
785+ overflow: "hidden",
786+ },
787+ connectionStatus: {
788+ paddingTop: 10,
789+ paddingHorizontal: 14,
790+ },
791+ connectionStatusNative: {
792+ paddingBottom: 8,
793+ paddingHorizontal: 6,
794+ paddingTop: 2,
795+ },
796+ searchField: {
797+ height: 38,
798+ marginTop: 9,
799+ marginHorizontal: 16,
800+ paddingLeft: 11,
801+ paddingRight: 10,
802+ borderRadius: 12,
803+ flexDirection: "row",
804+ alignItems: "center",
805+ gap: 6,
806+ },
807+ searchInput: {
808+ flex: 1,
809+ height: 34,
810+ paddingVertical: 0,
811+ paddingHorizontal: 0,
812+ fontFamily: "DMSans_400Regular",
813+ },
814+ threadList: {
815+ flex: 1,
816+ },
817+ threadListContent: {
818+ paddingHorizontal: 8,
819+ },
820+});
apps/mobile/src/features/threads/ThreadRouteScreen.tsx+469−166
@@ -1,11 +1,17 @@
1−import { Stack, useLocalSearchParams, useRouter } from "expo-router";
2−import { useCallback, useMemo, useState } from "react";
1+import { NativeStackScreenOptions } from "../../native/StackHeader";
2+import {
3+ StackActions,
4+ useFocusEffect,
5+ useNavigation,
6+ type StaticScreenProps,
7+} from "@react-navigation/native";
8+import { useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from "react";
39 import * as Option from "effect/Option";
4−import { EnvironmentId, type ProjectScript } from "@t3tools/contracts";
10+import { EnvironmentId, ThreadId, type ProjectScript } from "@t3tools/contracts";
511 import { projectScriptCwd, projectScriptRuntimeEnv } from "@t3tools/shared/projectScripts";
612 import * as Haptics from "expo-haptics";
7−import { SymbolView } from "expo-symbols";
8−import { Pressable, ScrollView, Text as RNText, View } from "react-native";
13+import { Platform, Pressable, ScrollView, Text as RNText, View } from "react-native";
14+import { useSafeAreaInsets } from "react-native-safe-area-context";
915 import { useWorkspaceState } from "../../state/workspace";
1016 import { useThemeColor } from "../../lib/useThemeColor";
1117 import { useEnvironmentQuery } from "../../state/query";
@@ -14,7 +20,6 @@ import { vcsEnvironment } from "../../state/vcs"; · 2 unmodified lines
1420
1521 import { EmptyState } from "../../components/EmptyState";
1622 import { LoadingScreen } from "../../components/LoadingScreen";
17−import { buildThreadRoutePath, buildThreadTerminalNavigation } from "../../lib/routes";
1823 import { scopedThreadKey } from "../../lib/scopedEntities";
1924 import { MOBILE_TYPOGRAPHY } from "../../lib/typography";
2025 import { connectionTone } from "../connection/connectionTone";
@@ -39,7 +44,12 @@ import { · 18 unmodified lines
3944 } from "../terminal/terminalLaunchContext";
4045 import { terminalDebugLog } from "../terminal/terminalDebugLog";
4146 import { ThreadDetailScreen } from "./ThreadDetailScreen";
42−import { ThreadGitControls } from "./ThreadGitControls";
47+import {
48+ ThreadGitControls,
49+ useThreadGitCenterHeaderItems,
50+ useThreadGitRightHeaderItems,
51+} from "./ThreadGitControls";
52+import { GitOverviewSheet } from "./git/GitOverviewSheet";
4353 import { ThreadNavigationDrawer } from "./ThreadNavigationDrawer";
4454 import { useAtomCommand } from "../../state/use-atom-command";
4555 import { useSelectedThreadGitActions } from "../../state/use-selected-thread-git-actions";
@@ -49,6 +59,30 @@ import { useSelectedThreadWorktree } from "../../state/use-selected-thread-workt · 3 unmodified lines
4959 import { useThreadComposerState } from "../../state/use-thread-composer-state";
5060 import { threadEnvironment } from "../../state/threads";
5161 import { projectThreadContentPresentation } from "./threadContentPresentation";
62+import {
63+ useAdaptiveWorkspaceLayout,
64+ useAdaptiveWorkspacePaneRole,
65+ useRegisterWorkspaceInspector,
66+} from "../layout/AdaptiveWorkspaceLayout";
67+import { withNativeGlassHeaderItem } from "../layout/native-glass-header-items";
68+import { ThreadFileNavigatorPane } from "../files/thread-file-navigator-pane";
69+import {
70+ ThreadInspectorContentStack,
71+ type ThreadInspectorMode,
72+} from "./thread-inspector-content-stack";
73+import { useHardwareKeyboardCommand } from "../keyboard/hardwareKeyboardCommands";
74+
75+interface ThreadInspectorSelection {
76+ readonly routeThreadIdentity: string | null;
77+ readonly mode: ThreadInspectorMode;
78+}
79+
80+type NativeHeaderItems = ReadonlyArray<Record<string, unknown>>;
81+
82+function InspectorPaneRoleActivation() {
83+ useAdaptiveWorkspacePaneRole("inspector");
84+ return null;
85+}
5286
5387 function firstRouteParam(value: string | string[] | undefined): string | null {
5488 if (Array.isArray(value)) {
@@ -62,13 +96,99 @@ function OpeningThreadLoadingScreen() { · 7 unmodified lines
6296 return <LoadingScreen message="Opening thread…" messagePlacement="above-spinner" />;
6397 }
6498
65−export function ThreadRouteScreen() {
99+type ThreadRouteScreenRouteProps = StaticScreenProps<{
100+ readonly environmentId: string;
101+ readonly threadId: string;
102+}>;
103+
104+interface ThreadRouteScreenProps extends ThreadRouteScreenRouteProps {
105+ readonly onReturnToThread?: () => void;
106+ readonly renderInspector?: (headerInset: number) => ReactNode;
107+}
108+
109+function ThreadUnavailableScreen() {
110+ return (
111+ <ScrollView
112+ contentInsetAdjustmentBehavior="automatic"
113+ contentContainerStyle={{
114+ flexGrow: 1,
115+ justifyContent: "center",
116+ paddingHorizontal: 24,
117+ paddingVertical: 32,
118+ }}
119+ className="bg-screen flex-1"
120+ >
121+ <EmptyState
122+ title="Thread unavailable"
123+ detail="This thread is not available in the current mobile snapshot."
124+ />
125+ </ScrollView>
126+ );
127+}
128+
129+export function ThreadRouteScreen(props: ThreadRouteScreenProps) {
66130 const { state: workspaceState } = useWorkspaceState();
67131 const { connectionState } = useRemoteConnectionStatus();
132+ const { selectedThread } = useThreadSelection();
133+ const params = props.route.params;
134+ const environmentIdRaw = firstRouteParam(params.environmentId);
135+ const threadIdRaw = firstRouteParam(params.threadId);
136+ const environmentId = environmentIdRaw ? EnvironmentId.make(environmentIdRaw) : null;
137+ const routeEnvironmentRuntime = useRemoteEnvironmentRuntime(environmentId);
138+ const routeConnectionState =
139+ routeEnvironmentRuntime?.connectionState ?? (environmentId ? "available" : connectionState);
140+ const routeThreadKey =
141+ environmentId !== null && threadIdRaw !== null
142+ ? scopedThreadKey(environmentId, ThreadId.make(threadIdRaw))
143+ : null;
144+ const selectedThreadKey =
145+ selectedThread === null
146+ ? null
147+ : scopedThreadKey(selectedThread.environmentId, selectedThread.id);
148+ const selectedThreadDetailState = useSelectedThreadDetailState();
149+
150+ if (environmentId === null || threadIdRaw === null) {
151+ return <OpeningThreadLoadingScreen />;
152+ }
153+
154+ // Render the full thread chrome (header, feed, composer) as soon as the
155+ // thread SHELL is known — no blocking on message detail. The feed shows a
156+ // loading placeholder while messages fetch, and the composer's connection
157+ // pill reports connecting/reconnecting/syncing status.
158+ if (selectedThread !== null && selectedThreadKey === routeThreadKey) {
159+ return <ThreadRouteContent {...props} selectedThreadDetailState={selectedThreadDetailState} />;
160+ }
161+
162+ const stillHydrating =
163+ workspaceState.isLoadingConnections ||
164+ routeConnectionState === "connecting" ||
165+ routeConnectionState === "reconnecting";
166+
167+ if (stillHydrating) {
168+ return <OpeningThreadLoadingScreen />;
169+ }
170+
171+ return <ThreadUnavailableScreen />;
172+}
173+
174+function ThreadRouteContent(
175+ props: ThreadRouteScreenProps & {
176+ readonly selectedThreadDetailState: ReturnType<typeof useSelectedThreadDetailState>;
177+ },
178+) {
179+ const {
180+ fileInspector,
181+ layout,
182+ panes,
183+ showAuxiliaryPane,
184+ toggleAuxiliaryPane,
185+ togglePrimarySidebar,
186+ } = useAdaptiveWorkspaceLayout();
187+ const { connectionState } = useRemoteConnectionStatus();
68188 const { onReconnectEnvironment } = useRemoteConnections();
69189 const { selectedThread, selectedThreadProject, selectedEnvironmentConnection } =
70190 useThreadSelection();
71− const selectedThreadDetailState = useSelectedThreadDetailState();
191+ const selectedThreadDetailState = props.selectedThreadDetailState;
72192 const selectedThreadDetail = Option.getOrNull(selectedThreadDetailState.data);
73193 const { selectedThreadCwd } = useSelectedThreadWorktree();
74194 const composer = useThreadComposerState();
@@ -76,15 +196,72 @@ export function ThreadRouteScreen() { · 1 unmodified line
76196 const gitActions = useSelectedThreadGitActions();
77197 const requests = useSelectedThreadRequests();
78198 const interruptThreadTurn = useAtomCommand(threadEnvironment.interruptTurn, "thread interrupt");
79− const router = useRouter();
80− const params = useLocalSearchParams<{
81− environmentId?: string | string[];
82− threadId?: string | string[];
83− }>();
199+ const navigation = useNavigation();
200+ const params = props.route.params;
84201 const [drawerVisible, setDrawerVisible] = useState(false);
85202 const environmentIdRaw = firstRouteParam(params.environmentId);
86203 const environmentId = environmentIdRaw ? EnvironmentId.make(environmentIdRaw) : null;
87204 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+ );
88265 const routeEnvironmentRuntime = useRemoteEnvironmentRuntime(environmentId);
89266 const routeConnectionState =
90267 routeEnvironmentRuntime?.connectionState ?? (environmentId ? "available" : connectionState);
@@ -103,10 +280,14 @@ export function ThreadRouteScreen() { · 12 unmodified lines
103280 );
104281
105282 /* ─── Native header theming ──────────────────────────────────────── */
106− const iconColor = String(useThemeColor("--color-icon"));
107283 const foregroundColor = String(useThemeColor("--color-foreground"));
108− const secondaryFg = String(useThemeColor("--color-foreground-secondary"));
109−
284+ const usesNativeHeaderGlass = Platform.OS === "ios";
285+ const headerSubtitle = [
286+ selectedThreadProject?.title ?? null,
287+ selectedEnvironmentConnection?.environmentLabel ?? null,
288+ ]
289+ .filter(Boolean)
290+ .join(" · ");
110291 /* ─── Git status for native header trigger ───────────────────────── */
111292 const gitStatus = useEnvironmentQuery(
112293 selectedThread !== null && selectedThreadCwd !== null
@@ -147,13 +328,135 @@ export function ThreadRouteScreen() { · 34 unmodified lines
147328 const gitActionProgress = useGitActionProgress(gitActionProgressTarget);
148329
149330 const handleOpenDrawer = useCallback(() => {
150− void Haptics.selectionAsync();
151− setDrawerVisible(true);
331+ if (!layout.usesSplitView) {
332+ void Haptics.selectionAsync();
333+ setDrawerVisible(true);
334+ }
335+ }, [layout.usesSplitView]);
336+
337+ useEffect(() => {
338+ if (layout.usesSplitView) {
339+ setDrawerVisible(false);
340+ }
341+ }, [layout.usesSplitView]);
342+
343+ const handleOpenGitInspector = useCallback(() => {
344+ setInspectorSelection({ routeThreadIdentity, mode: "git" });
345+ showAuxiliaryPane("inspector");
346+ }, [routeThreadIdentity, showAuxiliaryPane]);
347+ const handleOpenFilesInspector = useCallback(() => {
348+ if (!fileInspector.supported || selectedThread === null || selectedThreadCwd === null) {
349+ return;
350+ }
351+ setInspectorSelection({
352+ routeThreadIdentity,
353+ mode: props.renderInspector === undefined ? "files" : "route",
354+ });
355+ showAuxiliaryPane("inspector");
356+ }, [
357+ fileInspector.supported,
358+ props.renderInspector,
359+ routeThreadIdentity,
360+ selectedThread,
361+ selectedThreadCwd,
362+ showAuxiliaryPane,
363+ ]);
364+ const inspectorToggleActionRef = useRef({
365+ inspectorMode,
366+ openFilesInspector: handleOpenFilesInspector,
367+ toggleAuxiliaryPane,
368+ });
369+ inspectorToggleActionRef.current = {
370+ inspectorMode,
371+ openFilesInspector: handleOpenFilesInspector,
372+ toggleAuxiliaryPane,
373+ };
374+ const handleToggleInspector = useCallback(() => {
375+ const action = inspectorToggleActionRef.current;
376+ if (action.inspectorMode === null) {
377+ action.openFilesInspector();
378+ return;
379+ }
380+ action.toggleAuxiliaryPane();
152381 }, []);
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);
153456
154457 const handleOpenConnectionEditor = useCallback(() => {
155− void router.push("/connections");
156− }, [router]);
458+ void navigation.navigate("Connections");
459+ }, [navigation]);
157460 const handleStopThread = useCallback(() => {
158461 if (
159462 !selectedThread ||
@@ -185,9 +488,13 @@ export function ThreadRouteScreen() { · 25 unmodified lines
185488 return;
186489 }
187490
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+ });
189496 },
190− [router, selectedThread, selectedThreadProject?.workspaceRoot],
497+ [navigation, selectedThread, selectedThreadProject?.workspaceRoot],
191498 );
192499
193500 const handleOpenNewTerminal = useCallback(() => {
@@ -204,8 +511,12 @@ export function ThreadRouteScreen() { · 10 unmodified lines
204511 const nextId = nextOpenTerminalId({
205512 listedTerminalIds: terminalMenuSessions.map((session) => session.terminalId),
206513 });
207− void router.push(buildThreadTerminalNavigation(selectedThread, nextId));
208− }, [router, selectedThread, selectedThreadProject?.workspaceRoot, terminalMenuSessions]);
514+ void navigation.navigate("ThreadTerminal", {
515+ environmentId: String(selectedThread.environmentId),
516+ threadId: String(selectedThread.id),
517+ terminalId: nextId,
518+ });
519+ }, [navigation, selectedThread, selectedThreadProject?.workspaceRoot, terminalMenuSessions]);
209520
210521 const handleRunProjectScript = useCallback(
211522 async (script: ProjectScript) => {
@@ -262,134 +573,97 @@ export function ThreadRouteScreen() { · 50 unmodified lines
262573 worktreePath: preferredWorktreePath,
263574 });
264575
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+ });
266581 },
267582 [
268− router,
583+ navigation,
269584 selectedThread,
270585 selectedThreadDetailWorktreePath,
271586 selectedThreadProject,
272587 terminalMenuSessions,
273588 ],
274589 );
275−
276− const headerSubtitle = [
277− selectedThreadProject?.title ?? null,
278− selectedEnvironmentConnection?.environmentLabel ?? null,
279− ]
280− .filter(Boolean)
281− .join(" · ");
282−
283− // Rendered in every state (loading / unavailable / ready) so the back button and
284− // title stay consistent instead of flickering as the thread hydrates. Tapping the
285− // title opens the thread switcher drawer.
286− const headerScreen = (
287− <Stack.Screen
288− options={{
289− headerShown: true,
290− headerTransparent: true,
291− headerStyle: { backgroundColor: "transparent" },
292− headerShadowVisible: false,
293− headerTintColor: iconColor,
294− headerBackTitle: "",
295− headerBackVisible: true,
296− headerBackButtonDisplayMode: "minimal",
297− headerTitle: () => (
298− <Pressable
299− accessibilityRole="button"
300− accessibilityLabel="Switch thread"
301− accessibilityHint="Opens the list of threads"
302− disabled={selectedThread === null}
303− hitSlop={12}
304− onPress={handleOpenDrawer}
305− style={{ alignItems: "center", maxWidth: 240 }}
306− >
307− <View style={{ flexDirection: "row", alignItems: "center", gap: 4, maxWidth: 240 }}>
308− <RNText
309− numberOfLines={1}
310− style={{
311− fontFamily: "DMSans_700Bold",
312− fontSize: MOBILE_TYPOGRAPHY.headline.fontSize,
313− fontWeight: "900",
314− color: foregroundColor,
315− letterSpacing: -0.4,
316− }}
317− >
318− {selectedThread?.title ?? "Opening thread…"}
319− </RNText>
320− {selectedThread ? (
321− <SymbolView
322− name="chevron.down"
323− size={11}
324− tintColor={secondaryFg}
325− type="monochrome"
326− />
327− ) : null}
328− </View>
329− {headerSubtitle ? (
330− <RNText
331− numberOfLines={1}
332− style={{
333− fontFamily: "DMSans_700Bold",
334− fontSize: MOBILE_TYPOGRAPHY.label.fontSize,
335− fontWeight: "700",
336− color: secondaryFg,
337− letterSpacing: 0.3,
338− }}
339− >
340− {headerSubtitle}
341− </RNText>
342− ) : null}
343− </Pressable>
344− ),
345− }}
346− />
590+ const threadGitControlProps = {
591+ environmentId: environmentIdRaw ?? "",
592+ threadId: threadId ?? "",
593+ auxiliaryPaneControl:
594+ !layout.usesSplitView && fileInspector.supported && selectedThreadCwd !== null
595+ ? {
596+ accessibilityLabel: "Toggle inspector",
597+ onPress: handleToggleInspector,
598+ }
599+ : undefined,
600+ onOpenFilesInspector:
601+ fileInspector.supported && selectedThreadCwd !== null ? handleOpenFilesInspector : undefined,
602+ onOpenGitInspector: fileInspector.supported ? handleOpenGitInspector : undefined,
603+ currentBranch: selectedThread?.branch ?? null,
604+ gitStatus: gitStatus.data,
605+ gitOperationLabel: gitState.gitOperationLabel,
606+ canOpenTerminal: Boolean(selectedThreadProject?.workspaceRoot),
607+ canOpenFiles: Boolean(selectedThreadProject?.workspaceRoot),
608+ projectScripts: selectedThreadProject?.scripts ?? [],
609+ terminalSessions: terminalMenuSessions,
610+ showDirectFileControl: layout.usesSplitView,
611+ onOpenTerminal: handleOpenTerminal,
612+ onOpenNewTerminal: handleOpenNewTerminal,
613+ onRunProjectScript: handleRunProjectScript,
614+ onPull: gitActions.onPullSelectedThreadBranch,
615+ onRunAction: gitActions.onRunSelectedThreadGitAction,
616+ };
617+ const threadCenterHeaderItems = useThreadGitCenterHeaderItems(threadGitControlProps);
618+ const compactRightHeaderItems = useThreadGitRightHeaderItems(threadGitControlProps);
619+ const splitLeftHeaderItems = useMemo<NativeHeaderItems>(
620+ () => [
621+ {
622+ // Match Mail's split-view detail toolbar: the first detail action sits
623+ // inside the content pane, not flush against the sidebar divider.
624+ spacing: 18,
625+ type: "spacing" as const,
626+ },
627+ ...(props.onReturnToThread
628+ ? [
629+ withNativeGlassHeaderItem({
630+ accessibilityLabel: "Return to chat",
631+ icon: { name: "chevron.left", type: "sfSymbol" as const },
632+ identifier: "thread-left-return",
633+ onPress: props.onReturnToThread,
634+ type: "button" as const,
635+ }),
636+ ]
637+ : []),
638+ withNativeGlassHeaderItem({
639+ accessibilityLabel: panes.primarySidebarVisible
640+ ? "Maximize content"
641+ : "Show thread sidebar",
642+ icon: {
643+ name: panes.primarySidebarVisible ? "arrow.up.left.and.arrow.down.right" : "sidebar.left",
644+ type: "sfSymbol" as const,
645+ },
646+ identifier: "thread-left-sidebar",
647+ onPress: togglePrimarySidebar,
648+ type: "button" as const,
649+ }),
650+ withNativeGlassHeaderItem({
651+ accessibilityLabel: "New task",
652+ icon: { name: "square.and.pencil", type: "sfSymbol" as const },
653+ identifier: "thread-left-new-task",
654+ onPress: () => navigation.navigate("NewTaskSheet", { screen: "NewTask" }),
655+ type: "button" as const,
656+ }),
657+ ],
658+ [panes.primarySidebarVisible, props.onReturnToThread, navigation, togglePrimarySidebar],
347659 );
348660
349661 if (!environmentId || !threadId) {
350− return (
351− <>
352− {headerScreen}
353− <OpeningThreadLoadingScreen />
354− </>
355− );
662+ return <OpeningThreadLoadingScreen />;
356663 }
357664
358665 if (!selectedThread) {
359− const stillHydrating =
360− workspaceState.isLoadingConnections ||
361− routeConnectionState === "connecting" ||
362− routeConnectionState === "reconnecting";
363−
364− if (stillHydrating) {
365− return (
366− <>
367− {headerScreen}
368− <OpeningThreadLoadingScreen />
369− </>
370− );
371− }
372−
373− return (
374− <>
375− {headerScreen}
376− <ScrollView
377− contentInsetAdjustmentBehavior="automatic"
378− contentContainerStyle={{
379− flexGrow: 1,
380− justifyContent: "center",
381− paddingHorizontal: 24,
382− paddingVertical: 32,
383− }}
384− className="bg-screen flex-1"
385− >
386− <EmptyState
387− title="Thread unavailable"
388− detail="This thread is not available in the current mobile snapshot."
389− />
390− </ScrollView>
391− </>
392− );
666+ return <OpeningThreadLoadingScreen />;
393667 }
394668
395669 const selectedThreadKey = scopedThreadKey(selectedThread.environmentId, selectedThread.id);
@@ -400,25 +674,9 @@ export function ThreadRouteScreen() { · 4 unmodified lines
400674 connectionState: routeConnectionState,
401675 });
402676 const serverConfig = routeEnvironmentRuntime?.serverConfig ?? null;
403−
404− return (
677+ const renderThreadRouteBody = (showActionControls: boolean) => (
405678 <>
406− {headerScreen}
407−
408− <ThreadGitControls
409− currentBranch={selectedThread.branch}
410− gitStatus={gitStatus.data}
411− gitOperationLabel={gitState.gitOperationLabel}
412− canOpenTerminal={Boolean(selectedThreadProject?.workspaceRoot)}
413− canOpenFiles={Boolean(selectedThreadProject?.workspaceRoot)}
414− projectScripts={selectedThreadProject?.scripts ?? []}
415− terminalSessions={terminalMenuSessions}
416− onOpenTerminal={handleOpenTerminal}
417− onOpenNewTerminal={handleOpenNewTerminal}
418− onRunProjectScript={handleRunProjectScript}
419− onPull={gitActions.onPullSelectedThreadBranch}
420− onRunAction={gitActions.onRunSelectedThreadGitAction}
421− />
679+ <ThreadGitControls {...threadGitControlProps} showActionControls={showActionControls} />
422680
423681 <GitActionProgressOverlay progress={gitActionProgress} onDismiss={dismissGitActionResult} />
424682
@@ -440,11 +698,15 @@ export function ThreadRouteScreen() { · 15 unmodified lines
440698 draftMessage={composer.draftMessage}
441699 draftAttachments={composer.draftAttachments}
442700 connectionStateLabel={routeConnectionState}
701+ threadSyncStatus={selectedThreadDetailState.status}
443702 activeThreadBusy={composer.activeThreadBusy}
444703 environmentId={selectedThread.environmentId}
445704 projectWorkspaceRoot={selectedThreadProject?.workspaceRoot ?? null}
446705 threadCwd={selectedThreadCwd}
447706 selectedThreadQueueCount={composer.selectedThreadQueueCount}
707+ layoutVariant={layout.variant}
708+ usesAutomaticContentInsets={usesNativeHeaderGlass}
709+ onOpenDrawer={handleOpenDrawer}
448710 onOpenConnectionEditor={handleOpenConnectionEditor}
449711 onChangeDraftMessage={composer.onChangeDraftMessage}
450712 onPickDraftImages={composer.onPickDraftImages}
@@ -463,16 +725,57 @@ export function ThreadRouteScreen() { · 12 unmodified lines
463725 onSubmitUserInput={requests.onSubmitUserInput}
464726 />
465727
466− <ThreadNavigationDrawer
467− visible={drawerVisible}
468− selectedThreadKey={selectedThreadKey}
469− onClose={() => setDrawerVisible(false)}
470− onSelectThread={(thread) => {
471− router.replace(buildThreadRoutePath(thread));
472− }}
473− onStartNewTask={() => router.push("/new")}
474− />
728+ {layout.usesSplitView ? null : (
729+ <ThreadNavigationDrawer
730+ visible={drawerVisible}
731+ selectedThreadKey={selectedThreadKey}
732+ onClose={() => setDrawerVisible(false)}
733+ onSelectThread={(thread) => {
734+ navigation.dispatch(
735+ StackActions.replace("Thread", {
736+ environmentId: String(thread.environmentId),
737+ threadId: String(thread.id),
738+ }),
739+ );
740+ }}
741+ onStartNewTask={() => navigation.navigate("NewTaskSheet", { screen: "NewTask" })}
742+ />
743+ )}
475744 </View>
476745 </>
477746 );
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+ );
478781 }
apps/mobile/src/features/threads/git/GitBranchesSheet.tsx+11−6
@@ -1,5 +1,5 @@
11 import { sanitizeFeatureBranchName } from "@t3tools/shared/git";
2−import { useRouter } from "expo-router";
2+import { useNavigation, type StaticScreenProps } from "@react-navigation/native";
33 import { useState } from "react";
44 import { Pressable, ScrollView, View } from "react-native";
55 import { useSafeAreaInsets } from "react-native-safe-area-context";
@@ -14,8 +14,13 @@ import { useSelectedThreadWorktree } from "../../../state/use-selected-thread-wo · 8 unmodified lines
1414 import { vcsEnvironment } from "../../../state/vcs";
1515 import { SheetActionButton } from "./gitSheetComponents";
1616
17−export function GitBranchesSheet() {
18− const router = useRouter();
17+type GitBranchesSheetProps = StaticScreenProps<{
18+ readonly environmentId: string;
19+ readonly threadId: string;
20+}>;
21+
22+export function GitBranchesSheet(_props: GitBranchesSheetProps) {
23+ const navigation = useNavigation();
1924 const insets = useSafeAreaInsets();
2025 const { selectedThread } = useThreadSelection();
2126 const { selectedThreadCwd, selectedThreadWorktreePath } = useSelectedThreadWorktree();
@@ -96,7 +101,7 @@ export function GitBranchesSheet() { · 74 unmodified lines
96101 if (branch.length === 0) return;
97102 void gitActions.onCreateSelectedThreadBranch(branch).then(() => {
98103 setNewBranchName("");
99− router.dismiss();
104+ navigation.goBack();
100105 });
101106 }}
102107 />
@@ -146,7 +151,7 @@ export function GitBranchesSheet() { · 43 unmodified lines
146151 if (baseBranch.length === 0 || newBranch.length === 0) return;
147152 void gitActions.onCreateSelectedThreadWorktree({ baseBranch, newBranch }).then(() => {
148153 setWorktreeBranchName("");
149− router.dismiss();
154+ navigation.goBack();
150155 });
151156 }}
152157 />
@@ -188,7 +193,7 @@ export function GitBranchesSheet() { · 35 unmodified lines
188193 }}
189194 onPress={() => {
190195 void gitActions.onCheckoutSelectedThreadBranch(branch.name).then(() => {
191− router.dismiss();
196+ navigation.goBack();
192197 });
193198 }}
194199 >
apps/mobile/src/features/threads/git/GitCommitSheet.tsx+15−10
@@ -1,4 +1,4 @@
1−import { useRouter } from "expo-router";
1+import { useNavigation, type StaticScreenProps } from "@react-navigation/native";
22 import { useCallback, useState } from "react";
33 import { Pressable, ScrollView, View, useColorScheme } from "react-native";
44 import { useSafeAreaInsets } from "react-native-safe-area-context";
@@ -13,8 +13,13 @@ import { useSelectedThreadWorktree } from "../../../state/use-selected-thread-wo · 8 unmodified lines
1313 import { vcsEnvironment } from "../../../state/vcs";
1414 import { SheetActionButton } from "./gitSheetComponents";
1515
16−export function GitCommitSheet() {
17− const router = useRouter();
16+type GitCommitSheetProps = StaticScreenProps<{
17+ readonly environmentId: string;
18+ readonly threadId: string;
19+}>;
20+
21+export function GitCommitSheet(_props: GitCommitSheetProps) {
22+ const navigation = useNavigation();
1823 const insets = useSafeAreaInsets();
1924 const isDarkMode = useColorScheme() === "dark";
2025 const { selectedThread } = useThreadSelection();
@@ -55,7 +60,7 @@ export function GitCommitSheet() { · 34 unmodified lines
5560 const runCommitAction = useCallback(
5661 async (featureBranch: boolean) => {
5762 const commitMessage = dialogCommitMessage.trim();
58− router.dismiss();
63+ navigation.goBack();
5964 await gitActions.onRunSelectedThreadGitAction({
6065 action: "commit",
6166 featureBranch,
@@ -63,7 +68,7 @@ export function GitCommitSheet() { · 1 unmodified line
6368 ...(!allSelected ? { filePaths: selectedFiles.map((file) => file.path) } : {}),
6469 });
6570 },
66− [allSelected, dialogCommitMessage, gitActions, router, selectedFiles],
71+ [allSelected, dialogCommitMessage, gitActions, navigation, selectedFiles],
6772 );
6873
6974 return (
@@ -86,7 +91,7 @@ export function GitCommitSheet() { · 16 unmodified lines
8691 </View>
8792 {isDefaultRef ? (
8893 <Text
89− className="text-xs leading-[18px]"
94+ className="text-xs leading-normal"
9095 style={{ color: isDarkMode ? "#fbbf24" : "#b45309" }}
9196 >
9297 Warning: this is the default branch.
@@ -98,7 +103,7 @@ export function GitCommitSheet() { · 5 unmodified lines
98103 <View className="flex-row items-center justify-between gap-3">
99104 <View className="gap-1">
100105 <Text className="text-foreground text-base font-t3-bold">Files</Text>
101− <Text className="text-foreground-muted text-xs leading-[18px]">
106+ <Text className="text-foreground-muted text-xs leading-normal">
102107 {selectedFiles.length} selected · +{selectedInsertions} / -{selectedDeletions}
103108 </Text>
104109 </View>
@@ -123,7 +128,7 @@ export function GitCommitSheet() { · 18 unmodified lines
123128 </View>
124129
125130 {allFiles.length === 0 ? (
126− <Text className="text-foreground-secondary text-sm leading-[19px]">
131+ <Text className="text-foreground-secondary text-sm leading-normal">
127132 No changed files are available to commit.
128133 </Text>
129134 ) : !isEditingFiles ? (
@@ -142,7 +147,7 @@ export function GitCommitSheet() { · 12 unmodified lines
142147 </View>
143148 ))}
144149 {selectedFiles.length > selectedFilePreview.length ? (
145− <Text className="text-foreground-muted text-xs leading-[17px]">
150+ <Text className="text-foreground-muted text-xs leading-snug">
146151 +{selectedFiles.length - selectedFilePreview.length} more files
147152 </Text>
148153 ) : null}
@@ -182,7 +187,7 @@ export function GitCommitSheet() { · 33 unmodified lines
182187 {file.path}
183188 </Text>
184189 {!included ? (
185− <Text className="text-foreground-muted text-2xs leading-[16px]">
190+ <Text className="text-foreground-muted text-2xs leading-normal">
186191 Excluded from this commit
187192 </Text>
188193 ) : null}
apps/mobile/src/features/threads/git/GitConfirmSheet.tsx+30−15
@@ -2,7 +2,7 @@ import { resolveDefaultBranchActionDialogCopy } from "@t3tools/client-runtime/st · 1 unmodified line
22 import { resolveAutoFeatureBranchName } from "@t3tools/shared/git";
33 import * as Arr from "effect/Array";
44 import * as Result from "effect/Result";
5−import { useLocalSearchParams, useRouter } from "expo-router";
5+import { StackActions, useNavigation, type StaticScreenProps } from "@react-navigation/native";
66 import { useCallback, useMemo } from "react";
77 import { View } from "react-native";
88 import { useSafeAreaInsets } from "react-native-safe-area-context";
@@ -12,19 +12,23 @@ import { useSelectedThreadGitActions } from "../../../state/use-selected-thread- · 3 unmodified lines
1212 import { useSelectedThreadGitState } from "../../../state/use-selected-thread-git-state";
1313 import { SheetActionButton } from "./gitSheetComponents";
1414
15−export function GitConfirmSheet() {
16− const router = useRouter();
15+type GitConfirmSheetProps = StaticScreenProps<{
16+ readonly environmentId: string;
17+ readonly threadId: string;
18+ readonly confirmAction?: string;
19+ readonly branchName?: string;
20+ readonly includesCommit?: string;
21+ readonly commitMessage?: string;
22+ readonly filePaths?: string;
23+}>;
24+
25+export function GitConfirmSheet(props: GitConfirmSheetProps) {
26+ const navigation = useNavigation();
1727 const insets = useSafeAreaInsets();
1828 const gitState = useSelectedThreadGitState();
1929 const gitActions = useSelectedThreadGitActions();
2030
21− const params = useLocalSearchParams<{
22− confirmAction?: string;
23− branchName?: string;
24− includesCommit?: string;
25− commitMessage?: string;
26− filePaths?: string;
27− }>();
31+ const params = props.route.params;
2832
2933 const confirmAction = params.confirmAction as
3034 | "push"
@@ -34,6 +38,8 @@ export function GitConfirmSheet() { · 3 unmodified lines
3438 | undefined;
3539 const branchName = params.branchName ?? "";
3640 const includesCommit = params.includesCommit === "true";
41+ const environmentId = params.environmentId ?? "";
42+ const threadId = params.threadId ?? "";
3743
3844 const copy = useMemo(
3945 () =>
@@ -49,17 +55,17 @@ export function GitConfirmSheet() { · 9 unmodified lines
4955
5056 const continuePendingAction = useCallback(async () => {
5157 if (!confirmAction) return;
52− router.dismissAll();
58+ navigation.dispatch(StackActions.replace("Thread", { environmentId, threadId }));
5359 await gitActions.onRunSelectedThreadGitAction({
5460 action: confirmAction,
5561 ...(params.commitMessage ? { commitMessage: params.commitMessage } : {}),
5662 ...(params.filePaths ? { filePaths: params.filePaths.split(",") } : {}),
5763 });
58− }, [confirmAction, gitActions, params, router]);
64+ }, [confirmAction, environmentId, gitActions, params, navigation, threadId]);
5965
6066 const movePendingActionToFeatureBranch = useCallback(async () => {
6167 if (!confirmAction) return;
62− router.dismissAll();
68+ navigation.dispatch(StackActions.replace("Thread", { environmentId, threadId }));
6369
6470 if (includesCommit) {
6571 await gitActions.onRunSelectedThreadGitAction({
@@ -82,7 +88,16 @@ export function GitConfirmSheet() { · 16 unmodified lines
8288 );
8389 await gitActions.onCreateSelectedThreadBranch(newBranchName);
8490 await gitActions.onRunSelectedThreadGitAction({ action: confirmAction });
85− }, [confirmAction, gitActions, gitState.selectedThreadBranches, includesCommit, params, router]);
91+ }, [
92+ confirmAction,
93+ gitActions,
94+ gitState.selectedThreadBranches,
95+ includesCommit,
96+ params,
97+ navigation,
98+ environmentId,
99+ threadId,
100+ ]);
86101
87102 return (
88103 <View collapsable={false} className="flex-1 bg-sheet">
@@ -98,7 +113,7 @@ export function GitConfirmSheet() { · 9 unmodified lines
98113 <Text className="text-center text-3xl font-t3-bold">
99114 {copy?.title ?? "Run action on default branch?"}
100115 </Text>
101− <Text className="text-center text-foreground-secondary text-sm font-medium leading-[19px]">
116+ <Text className="text-center text-foreground-secondary text-sm font-medium leading-normal">
102117 {copy?.description ?? "Choose how to continue."}
103118 </Text>
104119 </View>
apps/mobile/src/features/threads/git/GitOverviewSheet.tsx+246−97
@@ -4,17 +4,18 @@ import { · 3 unmodified lines
44 getGitActionDisabledReason,
55 requiresDefaultBranchConfirmation,
66 } from "@t3tools/client-runtime/state/vcs";
7−import type { EnvironmentId, ThreadId } from "@t3tools/contracts";
8−import { useLocalSearchParams, useRouter } from "expo-router";
7+import { EnvironmentId, ThreadId } from "@t3tools/contracts";
8+import { useNavigation, type StaticScreenProps } from "@react-navigation/native";
99 import { SymbolView } from "expo-symbols";
10−import { useCallback, useEffect, useMemo } from "react";
11−import { Alert, Pressable, ScrollView, View } from "react-native";
10+import { useCallback, useEffect, useMemo, useState } from "react";
11+import { Alert, Platform, Pressable, RefreshControl, ScrollView, View } from "react-native";
12+import { Screen, ScreenStack, ScreenStackHeaderConfig } from "react-native-screens";
1213 import { useSafeAreaInsets } from "react-native-safe-area-context";
1314 import { useThemeColor } from "../../../lib/useThemeColor";
1415
1516 import { AppText as Text } from "../../../components/AppText";
17+import { nativeHeaderScrollEdgeEffects } from "../../../native/StackHeader";
1618 import { tryOpenExternalUrl } from "../../../lib/openExternalUrl";
17−import { buildThreadReviewRoutePath } from "../../../lib/routes";
1819 import { useEnvironmentQuery } from "../../../state/query";
1920 import { useThreadSelection } from "../../../state/use-thread-selection";
2021 import { useSelectedThreadGitActions } from "../../../state/use-selected-thread-git-actions";
@@ -23,13 +24,23 @@ import { useSelectedThreadWorktree } from "../../../state/use-selected-thread-wo · 2 unmodified lines
2324 import { vcsEnvironment } from "../../../state/vcs";
2425 import { MetaCard, SheetListRow, menuItemIconName, statusSummary } from "./gitSheetComponents";
2526
26−export function GitOverviewSheet() {
27− const router = useRouter();
27+const HEADER_SCROLL_EDGE_EFFECTS = nativeHeaderScrollEdgeEffects(Platform.OS, Platform.Version);
28+
29+type GitOverviewSheetProps = StaticScreenProps<{
30+ readonly environmentId: string;
31+ readonly threadId: string;
32+}> & {
33+ readonly headerInset?: number;
34+ readonly presentation?: "sheet" | "inspector";
35+};
36+
37+export function GitOverviewSheet(props: GitOverviewSheetProps) {
38+ const navigation = useNavigation();
2839 const insets = useSafeAreaInsets();
29− const { environmentId, threadId } = useLocalSearchParams<{
30− environmentId: EnvironmentId;
31− threadId: ThreadId;
32− }>();
40+ const presentation = props.presentation ?? "sheet";
41+ const isInspector = presentation === "inspector";
42+ const environmentId = EnvironmentId.make(props.route.params.environmentId);
43+ const threadId = ThreadId.make(props.route.params.threadId);
3344 const { selectedThread } = useThreadSelection();
3445 const { selectedThreadCwd, selectedThreadWorktreePath } = useSelectedThreadWorktree();
3546 const gitState = useSelectedThreadGitState();
@@ -37,6 +48,8 @@ export function GitOverviewSheet() { · 1 unmodified line
3748
3849 const iconColor = useThemeColor("--color-icon");
3950 const borderColor = useThemeColor("--color-border");
51+ const foregroundColor = String(useThemeColor("--color-foreground"));
52+ const sheetColor = String(useThemeColor("--color-sheet"));
4053
4154 const gitStatus = useEnvironmentQuery(
4255 selectedThread !== null && selectedThreadCwd !== null
@@ -48,6 +61,7 @@ export function GitOverviewSheet() { · 5 unmodified lines
4861 );
4962
5063 const currentBranchLabel = gitStatus.data?.refName ?? selectedThread?.branch ?? "Detached HEAD";
64+ const currentStatusSummary = statusSummary(gitStatus.data);
5165 const currentWorktreePath = selectedThreadWorktreePath;
5266 const gitOperationLabel = gitState.gitOperationLabel;
5367 const busy = gitOperationLabel !== null;
@@ -105,25 +119,24 @@ export function GitOverviewSheet() { · 51 unmodified lines
105119 !input.featureBranch &&
106120 requiresDefaultBranchConfirmation(input.action, isDefaultRef)
107121 ) {
108− router.push({
109− pathname: "/threads/[environmentId]/[threadId]/git-confirm",
110− params: {
111− environmentId,
112− threadId,
113− confirmAction: confirmableAction,
114− branchName,
115− includesCommit: String(
116− input.action === "commit_push" || input.action === "commit_push_pr",
117− ),
118− },
122+ navigation.navigate("GitConfirm", {
123+ environmentId: String(environmentId),
124+ threadId: String(threadId),
125+ confirmAction: confirmableAction,
126+ branchName,
127+ includesCommit: String(
128+ input.action === "commit_push" || input.action === "commit_push_pr",
129+ ),
119130 });
120131 return;
121132 }
122133
123− router.dismiss();
134+ if (!isInspector) {
135+ navigation.goBack();
136+ }
124137 await gitActions.onRunSelectedThreadGitAction(input);
125138 },
126− [environmentId, gitActions, gitStatus.data, isDefaultRef, router, threadId],
139+ [environmentId, gitActions, gitStatus.data, isDefaultRef, isInspector, navigation, threadId],
127140 );
128141
129142 const onPressMenuItem = useCallback(
@@ -134,9 +147,9 @@ export function GitOverviewSheet() { · 4 unmodified lines
134147 return;
135148 }
136149 if (item.dialogAction === "commit") {
137− router.push({
138− pathname: "/threads/[environmentId]/[threadId]/git/commit",
139− params: { environmentId, threadId },
150+ navigation.navigate("GitCommit", {
151+ environmentId: String(environmentId),
152+ threadId: String(threadId),
140153 });
141154 return;
142155 }
@@ -148,14 +161,209 @@ export function GitOverviewSheet() { · 5 unmodified lines
148161 await runActionWithPrompt({ action: "create_pr" });
149162 }
150163 },
151− [environmentId, openExistingPr, router, runActionWithPrompt, threadId],
164+ [environmentId, openExistingPr, navigation, runActionWithPrompt, threadId],
165+ );
166+
167+ // Status facts live on the relevant rows instead of crowding the header
168+ // subtitle: files changed → Commit, ahead → Push, PR → View PR, behind → Pull.
169+ const rowStatusDetail = useCallback(
170+ (item: (typeof menuItems)[number]): string | undefined => {
171+ const status = gitStatus.data;
172+ if (status == null) {
173+ return undefined;
174+ }
175+ if (item.dialogAction === "commit" && status.hasWorkingTreeChanges) {
176+ const fileCount = status.workingTree?.files.length ?? 0;
177+ return `${fileCount} file${fileCount === 1 ? "" : "s"} changed`;
178+ }
179+ if (item.dialogAction === "push" && (status.aheadCount ?? 0) > 0) {
180+ const ahead = status.aheadCount ?? 0;
181+ return `${ahead} commit${ahead === 1 ? "" : "s"} ahead`;
182+ }
183+ if (item.kind === "open_pr" && status.pr?.number != null) {
184+ return `PR #${status.pr.number} ${status.pr.state ?? "open"}`;
185+ }
186+ return undefined;
187+ },
188+ [gitStatus.data, menuItems],
189+ );
190+
191+ const behindCount = gitStatus.data?.behindCount ?? 0;
192+
193+ // Deterministic pull-to-refresh state. Tying RefreshControl to the query's
194+ // isPending flag left the spinner stuck (the status query reports pending
195+ // during quiet background refreshes too).
196+ const [isPullRefreshing, setIsPullRefreshing] = useState(false);
197+ const handlePullRefresh = useCallback(async () => {
198+ setIsPullRefreshing(true);
199+ try {
200+ await gitActions.refreshSelectedThreadGitStatus();
201+ } finally {
202+ setIsPullRefreshing(false);
203+ }
204+ }, [gitActions]);
205+
206+ const content = (
207+ <ScrollView
208+ contentInsetAdjustmentBehavior={Platform.OS === "ios" ? "automatic" : "never"}
209+ showsVerticalScrollIndicator={false}
210+ style={{ flex: 1, backgroundColor: sheetColor }}
211+ contentInset={{ bottom: Math.max(insets.bottom, 18) + 18 }}
212+ contentContainerStyle={{
213+ paddingHorizontal: isInspector ? 12 : 20,
214+ paddingTop: 8,
215+ gap: 14,
216+ }}
217+ refreshControl={
218+ <RefreshControl refreshing={isPullRefreshing} onRefresh={() => void handlePullRefresh()} />
219+ }
220+ >
221+ <View
222+ className={
223+ isInspector
224+ ? "overflow-hidden rounded-2xl border border-border bg-card px-3 py-1"
225+ : "overflow-hidden rounded-[22px] border border-border bg-card px-4 py-1"
226+ }
227+ >
228+ {sheetMenuItems.map(({ item, disabledReason }, index) => (
229+ <View key={`${item.id}-${item.label}`}>
230+ {index > 0 ? (
231+ <View className="ml-12 h-px" style={{ backgroundColor: borderColor }} />
232+ ) : null}
233+ <SheetListRow
234+ icon={menuItemIconName(item.icon)}
235+ title={item.label}
236+ subtitle={disabledReason ?? rowStatusDetail(item)}
237+ disabled={item.disabled}
238+ onPress={() => void onPressMenuItem(item)}
239+ />
240+ </View>
241+ ))}
242+ {behindCount > 0 ? (
243+ <>
244+ <View className="ml-12 h-px" style={{ backgroundColor: borderColor }} />
245+ <SheetListRow
246+ icon="arrow.down.circle"
247+ title="Pull latest"
248+ subtitle={`${behindCount} commit${behindCount === 1 ? "" : "s"} behind upstream`}
249+ disabled={busy || !isRepo}
250+ onPress={() => void gitActions.onPullSelectedThreadBranch()}
251+ />
252+ </>
253+ ) : null}
254+ <View className="ml-12 h-px" style={{ backgroundColor: borderColor }} />
255+ <SheetListRow
256+ icon="text.bubble"
257+ title="Review changes"
258+ subtitle="Inspect turn diffs, worktree changes, and base branch diff"
259+ disabled={busy || !isRepo}
260+ onPress={() => navigation.navigate("ThreadReview", { environmentId, threadId })}
261+ />
262+ <View className="ml-12 h-px" style={{ backgroundColor: borderColor }} />
263+ <SheetListRow
264+ icon="point.topleft.down.curvedto.point.bottomright.up"
265+ title="Branches & worktrees"
266+ subtitle="Switch branch, create branch, or move to a worktree"
267+ disabled={busy || !isRepo}
268+ onPress={() =>
269+ navigation.navigate("GitBranches", {
270+ environmentId: String(environmentId),
271+ threadId: String(threadId),
272+ })
273+ }
274+ />
275+ </View>
276+
277+ {currentWorktreePath ? <MetaCard label="Worktree" value={currentWorktreePath} /> : null}
278+ </ScrollView>
152279 );
153280
281+ if (isInspector && Platform.OS === "ios") {
282+ return (
283+ <View collapsable={false} className="flex-1 border-l border-border bg-sheet">
284+ <ScreenStack style={{ flex: 1 }}>
285+ <Screen
286+ activityState={2}
287+ enabled
288+ isNativeStack
289+ screenId="thread-git-inspector-native"
290+ scrollEdgeEffects={HEADER_SCROLL_EDGE_EFFECTS}
291+ style={{ backgroundColor: sheetColor, flex: 1 }}
292+ >
293+ {content}
294+ <ScreenStackHeaderConfig
295+ backgroundColor="rgba(0,0,0,0)"
296+ color={foregroundColor}
297+ hideBackButton
298+ hideShadow={false}
299+ navigationItemStyle="editor"
300+ title={currentBranchLabel}
301+ titleColor={foregroundColor}
302+ titleFontSize={17}
303+ titleFontWeight="700"
304+ translucent
305+ />
306+ </Screen>
307+ </ScreenStack>
308+ </View>
309+ );
310+ }
311+
312+ if (Platform.OS === "ios") {
313+ // Compact form sheet: a plain screen presented as formSheet never renders a
314+ // stack header, so — like the Settings sheet — the header must come from a
315+ // nested native stack INSIDE the sheet. This reuses the exact structure of the
316+ // inspector branch below: branch as the title, status summary as the native
317+ // subtitle, refresh as a header button.
318+ return (
319+ <View collapsable={false} className="flex-1 bg-sheet">
320+ <ScreenStack style={{ flex: 1 }}>
321+ <Screen
322+ activityState={2}
323+ enabled
324+ isNativeStack
325+ screenId="thread-git-sheet-native"
326+ scrollEdgeEffects={HEADER_SCROLL_EDGE_EFFECTS}
327+ style={{ backgroundColor: sheetColor, flex: 1 }}
328+ >
329+ {content}
330+ <ScreenStackHeaderConfig
331+ backgroundColor="rgba(0,0,0,0)"
332+ color={foregroundColor}
333+ hideBackButton
334+ hideShadow={false}
335+ navigationItemStyle="editor"
336+ title={currentBranchLabel}
337+ titleColor={foregroundColor}
338+ titleFontSize={18}
339+ titleFontWeight="800"
340+ translucent
341+ />
342+ </Screen>
343+ </ScreenStack>
344+ </View>
345+ );
346+ }
347+
154348 return (
155− <View collapsable={false} className="flex-1 bg-sheet">
156− <View style={{ minHeight: 16, paddingTop: 8 }} />
349+ <View
350+ collapsable={false}
351+ className={isInspector ? "flex-1 border-l border-border bg-sheet" : "flex-1 bg-sheet"}
352+ >
353+ <View
354+ style={{
355+ minHeight: isInspector ? (props.headerInset ?? 0) : 16,
356+ paddingTop: isInspector ? (props.headerInset ?? 0) : 8,
357+ }}
358+ />
157359
158− <View className="items-center gap-1 px-5 pb-3 pt-4">
360+ <View
361+ className={
362+ isInspector
363+ ? "gap-1 border-b border-border px-4 pb-4 pt-3"
364+ : "items-center gap-1 px-5 pb-3 pt-4"
365+ }
366+ >
159367 <Pressable
160368 className="absolute right-3 top-4 h-9 w-9 items-center justify-center rounded-full bg-subtle"
161369 style={{ zIndex: 1, opacity: busy ? 0.45 : 1 }}
@@ -174,76 +382,17 @@ export function GitOverviewSheet() { · 12 unmodified lines
174382 className="text-xs font-t3-bold uppercase text-foreground-muted"
175383 style={{ letterSpacing: 1 }}
176384 >
177− Branch
385+ {isInspector ? "Repository" : "Branch"}
386+ </Text>
387+ <Text className={isInspector ? "pr-10 text-xl font-t3-bold" : "text-3xl font-t3-bold"}>
388+ {currentBranchLabel}
178389 </Text>
179− <Text className="text-3xl font-t3-bold">{currentBranchLabel}</Text>
180− <Text className="text-foreground-secondary text-sm font-medium leading-[19px]">
181− {statusSummary(gitStatus.data)}
390+ <Text className="text-foreground-secondary text-sm font-medium leading-normal">
391+ {currentStatusSummary}
182392 </Text>
183393 </View>
184394
185− <ScrollView
186− showsVerticalScrollIndicator={false}
187− style={{ flex: 1 }}
188− contentInset={{ bottom: Math.max(insets.bottom, 18) + 18 }}
189− contentContainerStyle={{
190− paddingHorizontal: 20,
191− paddingTop: 8,
192− gap: 14,
193− }}
194− >
195− <View className="overflow-hidden rounded-[22px] border border-border bg-card px-4 py-1">
196− {sheetMenuItems.map(({ item, disabledReason }, index) => (
197− <View key={`${item.id}-${item.label}`}>
198− {index > 0 ? (
199− <View className="ml-12 h-px" style={{ backgroundColor: borderColor }} />
200− ) : null}
201− <SheetListRow
202− icon={menuItemIconName(item.icon)}
203− title={item.label}
204− subtitle={disabledReason}
205− disabled={item.disabled}
206− onPress={() => void onPressMenuItem(item)}
207− />
208− </View>
209− ))}
210− {(gitStatus.data?.behindCount ?? 0) > 0 ? (
211− <>
212− <View className="ml-12 h-px" style={{ backgroundColor: borderColor }} />
213− <SheetListRow
214− icon="arrow.down.circle"
215− title="Pull latest"
216− subtitle="Sync this branch with upstream"
217− disabled={busy || !isRepo}
218− onPress={() => void gitActions.onPullSelectedThreadBranch()}
219− />
220− </>
221− ) : null}
222− <View className="ml-12 h-px" style={{ backgroundColor: borderColor }} />
223− <SheetListRow
224− icon="text.bubble"
225− title="Review changes"
226− subtitle="Inspect turn diffs, worktree changes, and base branch diff"
227− disabled={busy || !isRepo}
228− onPress={() => router.push(buildThreadReviewRoutePath({ environmentId, threadId }))}
229− />
230− <View className="ml-12 h-px" style={{ backgroundColor: borderColor }} />
231− <SheetListRow
232− icon="point.topleft.down.curvedto.point.bottomright.up"
233− title="Branches & worktrees"
234− subtitle="Switch branch, create branch, or move to a worktree"
235− disabled={busy || !isRepo}
236− onPress={() =>
237− router.push({
238− pathname: "/threads/[environmentId]/[threadId]/git/branches",
239− params: { environmentId, threadId },
240− })
241− }
242− />
243− </View>
244−
245− {currentWorktreePath ? <MetaCard label="Worktree" value={currentWorktreePath} /> : null}
246− </ScrollView>
395+ {content}
247396 </View>
248397 );
249398 }
apps/mobile/src/features/threads/git/gitSheetComponents.tsx+1−1
@@ -104,7 +104,7 @@ export function SheetListRow(props: { · 103 unmodified lines
104104 <View className="flex-1 gap-0.5">
105105 <Text className="text-foreground text-base font-t3-bold">{props.title}</Text>
106106 {props.subtitle ? (
107− <Text className="text-foreground-muted text-xs leading-[17px]">{props.subtitle}</Text>
107+ <Text className="text-foreground-muted text-xs leading-snug">{props.subtitle}</Text>
108108 ) : null}
109109 </View>
110110 <SymbolView name="chevron.right" size={13} tintColor={iconSubtleColor} type="monochrome" />
apps/mobile/src/features/threads/new-task-flow-provider.tsx+318−10
@@ -1,4 +1,4 @@
1−import React, { useCallback, useEffect, useMemo, useState } from "react";
1+import React, { useCallback, useEffect, useMemo, useRef, useState } from "react";
22
33 import type {
44 EnvironmentId,
@@ -8,18 +8,29 @@ import type { · 3 unmodified lines
88 RuntimeMode,
99 ServerProviderSkill,
1010 } from "@t3tools/contracts";
11−import { DEFAULT_PROVIDER_INTERACTION_MODE, DEFAULT_RUNTIME_MODE } from "@t3tools/contracts";
11+import {
12+ CommandId,
13+ DEFAULT_PROVIDER_INTERACTION_MODE,
14+ DEFAULT_RUNTIME_MODE,
15+ MessageId,
16+ ThreadId,
17+} from "@t3tools/contracts";
1218 import * as Arr from "effect/Array";
1319 import { pipe } from "effect/Function";
1420
1521 import { useEnvironmentServerConfig, useProjects, useThreadShells } from "../../state/entities";
22+import type { TurnCommandMetadata } from "../../lib/commandMetadata";
1623 import type { DraftComposerImageAttachment } from "../../lib/composerImages";
1724 import type { ModelOption, ProviderGroup } from "../../lib/modelOptions";
1825 import { buildModelOptions, groupByProvider } from "../../lib/modelOptions";
1926 import { groupProjectsByRepository } from "../../lib/repositoryGroups";
2027 import { scopedProjectKey } from "../../lib/scopedEntities";
28+import { appAtomRegistry } from "../../state/atom-registry";
2129 import {
2230 appendComposerDraftAttachments,
31+ clearComposerDraft,
32+ getComposerDraftSnapshot,
33+ isComposerDraftEmpty,
2334 removeComposerDraftAttachment,
2435 replaceComposerDraftAttachments,
2536 setComposerDraftText,
@@ -27,6 +38,17 @@ import { · 1 unmodified line
2738 useComposerDraft,
2839 } from "../../state/use-composer-drafts";
2940 import { useBranches } from "../../state/queries";
41+import {
42+ flattenQueuedThreadMessages,
43+ threadOutboxManager,
44+ updateThreadOutboxMessage,
45+ type QueuedThreadMessage,
46+} from "../../state/thread-outbox";
47+import {
48+ holdEditingQueuedMessage,
49+ releaseEditingQueuedMessage,
50+ useThreadOutboxMessages,
51+} from "../../state/use-thread-outbox";
3052 import {
3153 setPendingConnectionError,
3254 useSavedRemoteConnections,
@@ -36,6 +58,23 @@ import { type VcsRef } from "@t3tools/client-runtime/state/vcs"; · 3 unmodified lines
3658
3759 type WorkspaceMode = "local" | "worktree";
3860
61+function pendingTaskDraftKey(messageId: string): string {
62+ return `pending-task:${messageId}`;
63+}
64+
65+// The message id owned by the currently active editing session, tracked
66+// across provider instances. An in-flight flush from a dismissed session
67+// consults it so it never drops the draft or releases the drain lock out from
68+// under a newer session editing the same task.
69+let activeEditingMessageId: string | null = null;
70+
71+function findQueuedPendingTask(messageId: string): QueuedThreadMessage | null {
72+ const message = flattenQueuedThreadMessages(
73+ appAtomRegistry.get(threadOutboxManager.queuedMessagesByThreadKeyAtom),
74+ ).find((candidate) => candidate.messageId === messageId);
75+ return message?.creation !== undefined ? message : null;
76+}
77+
3978 function normalizeSelectedWorktreePath(project: EnvironmentProject, branch: VcsRef): string | null {
4079 if (!branch.worktreePath) {
4180 return null;
@@ -74,6 +113,9 @@ type NewTaskFlowContextValue = { · 32 unmodified lines
74113 readonly workspaceMode: WorkspaceMode;
75114 readonly selectedBranchName: string | null;
76115 readonly selectedWorktreePath: string | null;
116+ readonly startFromOrigin: boolean;
117+ readonly draftKey: string | null;
118+ readonly editingPendingTask: QueuedThreadMessage | null;
77119 readonly prompt: string;
78120 readonly attachments: ReadonlyArray<DraftComposerImageAttachment>;
79121 readonly submitting: boolean;
@@ -100,6 +142,11 @@ type NewTaskFlowContextValue = { · 20 unmodified lines
100142 readonly setSelectedModelKey: (key: string | null) => void;
101143 readonly setWorkspaceMode: (mode: WorkspaceMode) => void;
102144 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;
103150 readonly setPrompt: (value: string) => void;
104151 readonly replaceAttachments: (attachments: ReadonlyArray<DraftComposerImageAttachment>) => void;
105152 readonly appendAttachments: (attachments: ReadonlyArray<DraftComposerImageAttachment>) => void;
@@ -162,6 +209,10 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) { · 56 unmodified lines
162209 const [submitting, setSubmitting] = useState(false);
163210 const [branchQuery, setBranchQuery] = useState("");
164211 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);
165216
166217 const reset = useCallback(() => {
167218 setSelectedEnvironmentId(null);
@@ -169,6 +220,15 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) { · 1 unmodified line
169220 setSubmitting(false);
170221 setBranchQuery("");
171222 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+ }
172232 }, []);
173233
174234 const projectsForEnvironment = useMemo(
@@ -180,12 +240,42 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) { · 5 unmodified lines
180240 [projects, selectedEnvironmentId],
181241 );
182242
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+
183267 const selectedProject =
184268 projectsForEnvironment.find(
185269 (project) => scopedProjectKey(project.environmentId, project.id) === selectedProjectKey,
186270 ) ??
187− projectsForEnvironment[0] ??
188− null;
271+ // While editing a queued task whose project shell is absent, keep the task
272+ // pinned to its own project — falling through to an arbitrary first
273+ // project would silently retarget it (and its reused turn identifiers).
274+ (editingPendingProject !== null &&
275+ selectedProjectKey ===
276+ scopedProjectKey(editingPendingProject.environmentId, editingPendingProject.id)
277+ ? editingPendingProject
278+ : (projectsForEnvironment[0] ?? null));
189279
190280 // Only offer machines that actually host the currently selected repository, so
191281 // 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
223313 const selectedEnvironmentServerConfig = useEnvironmentServerConfig(
224314 selectedProject?.environmentId ?? null,
225315 );
226− const selectedProjectDraftKey = selectedProject
227− ? `new-task:${scopedProjectKey(selectedProject.environmentId, selectedProject.id)}`
228− : null;
316+ // While a queued pending task is being edited its draft lives under a key
317+ // scoped to the queued message, so per-project new-task drafts stay intact.
318+ const selectedProjectDraftKey = editingPendingTask
319+ ? pendingTaskDraftKey(editingPendingTask.messageId)
320+ : selectedProject
321+ ? `new-task:${scopedProjectKey(selectedProject.environmentId, selectedProject.id)}`
322+ : null;
229323 const selectedProjectDraft = useComposerDraft(selectedProjectDraftKey);
230324 const prompt = selectedProjectDraft.text;
231325 const attachments = selectedProjectDraft.attachments;
232326 const workspaceMode = selectedProjectDraft.workspaceSelection?.mode ?? "local";
233327 const selectedBranchName = selectedProjectDraft.workspaceSelection?.branch ?? null;
234328 const selectedWorktreePath = selectedProjectDraft.workspaceSelection?.worktreePath ?? null;
329+ const startFromOrigin = selectedProjectDraft.workspaceSelection?.startFromOrigin ?? false;
235330 const runtimeMode = selectedProjectDraft.runtimeMode ?? DEFAULT_RUNTIME_MODE;
236331 const interactionMode = selectedProjectDraft.interactionMode ?? DEFAULT_PROVIDER_INTERACTION_MODE;
237332
@@ -350,7 +445,8 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) { · 112 unmodified lines
350445 const branchTarget = useMemo(
351446 () => ({
352447 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,
354450 query: null,
355451 }),
356452 [selectedProject?.environmentId, selectedProject?.workspaceRoot],
@@ -411,10 +507,11 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) { · 54 unmodified lines
411507 mode,
412508 branch: selectedBranchName,
413509 worktreePath: selectedWorktreePath,
510+ startFromOrigin,
414511 },
415512 });
416513 },
417− [selectedBranchName, selectedProjectDraftKey, selectedWorktreePath],
514+ [selectedBranchName, selectedProjectDraftKey, selectedWorktreePath, startFromOrigin],
418515 );
419516
420517 const selectBranch = useCallback(
@@ -427,10 +524,28 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) { · 6 unmodified lines
427524 mode: workspaceMode,
428525 branch: branch.name,
429526 worktreePath: normalizeSelectedWorktreePath(selectedProject, branch),
527+ startFromOrigin,
430528 },
431529 });
432530 },
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],
434549 );
435550
436551 const refreshBranches = branchState.refresh;
@@ -472,6 +587,183 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) { · 35 unmodified lines
472587 [selectedProjectDraftKey],
473588 );
474589
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+
475767 const value = useMemo<NewTaskFlowContextValue>(
476768 () => ({
477769 logicalProjects,
@@ -481,6 +773,9 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) { · 3 unmodified lines
481773 workspaceMode,
482774 selectedBranchName,
483775 selectedWorktreePath,
776+ startFromOrigin,
777+ draftKey: selectedProjectDraftKey,
778+ editingPendingTask,
484779 prompt,
485780 attachments,
486781 submitting,
@@ -504,6 +799,11 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) { · 17 unmodified lines
504799 setSelectedModelKey,
505800 setWorkspaceMode,
506801 selectBranch,
802+ setStartFromOrigin,
803+ beginEditingPendingTask,
804+ finishEditingPendingTask,
805+ cancelEditingPendingTask,
806+ buildPendingTaskMessage,
507807 setPrompt,
508808 replaceAttachments,
509809 appendAttachments,
@@ -520,11 +820,16 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) { · 10 unmodified lines
520820 [
521821 attachments,
522822 availableBranches,
823+ beginEditingPendingTask,
523824 branchQuery,
524825 branchesLoading,
826+ buildPendingTaskMessage,
827+ cancelEditingPendingTask,
828+ editingPendingTask,
525829 environments,
526830 expandedProvider,
527831 filteredBranches,
832+ finishEditingPendingTask,
528833 interactionMode,
529834 loadBranches,
530835 logicalProjects,
@@ -539,6 +844,7 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) { · 8 unmodified lines
539844 selectedModel,
540845 selectedModelKey,
541846 selectedModelOption,
847+ selectedProjectDraftKey,
542848 selectedProviderSkills,
543849 setSelectedModelOptions,
544850 selectedProject,
@@ -551,7 +857,9 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) { · 6 unmodified lines
551857 setPrompt,
552858 setRuntimeMode,
553859 setSelectedModelKey,
860+ setStartFromOrigin,
554861 setWorkspaceMode,
862+ startFromOrigin,
555863 submitting,
556864 workspaceMode,
557865 appendAttachments,
apps/mobile/src/features/threads/sidebar-filter-button.tsx+55−0
@@ -0,0 +1,55 @@
1+import { SymbolView } from "expo-symbols";
2+import { Pressable, StyleSheet, useColorScheme } from "react-native";
3+
4+import { useThemeColor } from "../../lib/useThemeColor";
5+
6+export type SidebarFilterButtonIcon =
7+ | "line.3.horizontal.decrease.circle"
8+ | "line.3.horizontal.decrease.circle.fill";
9+
10+export function SidebarFilterButton(props: {
11+ readonly accessibilityLabel: string;
12+ readonly icon: SidebarFilterButtonIcon;
13+ /** Rendered inside a shared capsule group — no own background/border. */
14+ readonly grouped?: boolean;
15+}) {
16+ const iconColor = useThemeColor("--color-foreground");
17+ const pressedBackgroundColor = useThemeColor("--color-subtle");
18+ const colorScheme = useColorScheme() === "dark" ? "dark" : "light";
19+ const idleBackgroundColor =
20+ colorScheme === "dark" ? "rgba(118,118,128,0.24)" : "rgba(255,255,255,0.72)";
21+ const borderColor = colorScheme === "dark" ? "rgba(255,255,255,0.08)" : "rgba(0,0,0,0.08)";
22+
23+ return (
24+ <Pressable
25+ accessibilityLabel={props.accessibilityLabel}
26+ accessibilityRole="button"
27+ hitSlop={4}
28+ style={({ pressed }) => [
29+ styles.button,
30+ props.grouped
31+ ? { backgroundColor: pressed ? pressedBackgroundColor : "transparent", borderWidth: 0 }
32+ : {
33+ backgroundColor: pressed ? pressedBackgroundColor : idleBackgroundColor,
34+ borderColor,
35+ },
36+ ]}
37+ >
38+ <SymbolView name={props.icon} size={20} tintColor={iconColor} type="monochrome" />
39+ </Pressable>
40+ );
41+}
42+
43+const styles = StyleSheet.create({
44+ button: {
45+ // Match the native glass UIBarButtonItem group metrics (~50pt slots,
46+ // 44pt bar height, label-colored ~20pt glyphs).
47+ width: 50,
48+ height: 44,
49+ borderRadius: 22,
50+ borderWidth: StyleSheet.hairlineWidth,
51+ alignItems: "center",
52+ justifyContent: "center",
53+ cursor: "pointer",
54+ },
55+});
apps/mobile/src/features/threads/sidebar-header-actions.tsx+74−0
@@ -0,0 +1,74 @@
1+import { SymbolView } from "expo-symbols";
2+import { Pressable, StyleSheet, View, useColorScheme } from "react-native";
3+
4+import { useThemeColor } from "../../lib/useThemeColor";
5+
6+export interface SidebarHeaderActionsProps {
7+ readonly onOpenSettings: () => void;
8+ /** Rendered inside a shared capsule group — buttons drop their own chrome. */
9+ readonly grouped?: boolean;
10+}
11+
12+function FallbackHeaderButton(props: {
13+ readonly accessibilityLabel: string;
14+ readonly icon: "gearshape" | "square.and.pencil";
15+ readonly grouped?: boolean;
16+ readonly onPress: () => void;
17+}) {
18+ const iconColor = useThemeColor("--color-foreground");
19+ const pressedBackgroundColor = useThemeColor("--color-subtle");
20+ const colorScheme = useColorScheme() === "dark" ? "dark" : "light";
21+ const idleBackgroundColor =
22+ colorScheme === "dark" ? "rgba(118,118,128,0.24)" : "rgba(255,255,255,0.72)";
23+ const borderColor = colorScheme === "dark" ? "rgba(255,255,255,0.08)" : "rgba(0,0,0,0.08)";
24+
25+ return (
26+ <Pressable
27+ accessibilityLabel={props.accessibilityLabel}
28+ accessibilityRole="button"
29+ hitSlop={4}
30+ onPress={props.onPress}
31+ style={({ pressed }) => [
32+ styles.button,
33+ props.grouped
34+ ? { backgroundColor: pressed ? pressedBackgroundColor : "transparent", borderWidth: 0 }
35+ : {
36+ backgroundColor: pressed ? pressedBackgroundColor : idleBackgroundColor,
37+ borderColor,
38+ },
39+ ]}
40+ >
41+ <SymbolView name={props.icon} size={20} tintColor={iconColor} type="monochrome" />
42+ </Pressable>
43+ );
44+}
45+
46+export function SidebarHeaderActions(props: SidebarHeaderActionsProps) {
47+ return (
48+ <View style={styles.actions}>
49+ <FallbackHeaderButton
50+ accessibilityLabel="Open settings"
51+ grouped={props.grouped}
52+ icon="gearshape"
53+ onPress={props.onOpenSettings}
54+ />
55+ </View>
56+ );
57+}
58+
59+const styles = StyleSheet.create({
60+ actions: {
61+ flexDirection: "row",
62+ alignItems: "center",
63+ gap: 2,
64+ },
65+ button: {
66+ // Match the native glass UIBarButtonItem group metrics.
67+ width: 50,
68+ height: 44,
69+ borderRadius: 22,
70+ borderWidth: StyleSheet.hairlineWidth,
71+ alignItems: "center",
72+ justifyContent: "center",
73+ },
74+});
apps/mobile/src/features/threads/sidebar-native-header-items.ts+63−0
@@ -0,0 +1,63 @@
1+import type {
2+ NativeStackHeaderItem,
3+ NativeStackHeaderItemMenu,
4+} from "@react-navigation/native-stack";
5+
6+import type { HomeListFilterMenu } from "../home/home-list-filter-menu";
7+import { withNativeGlassHeaderItem } from "../layout/native-glass-header-items";
8+
9+type NativeHeaderMenuItems = NativeStackHeaderItemMenu["menu"]["items"];
10+type NativeHeaderIcon = NonNullable<Extract<NativeStackHeaderItem, { type: "button" }>["icon"]>;
11+
12+function sfSymbolIcon(name: string): NativeHeaderIcon {
13+ return { type: "sfSymbol", name: name as never };
14+}
15+
16+function toNativeHeaderMenuItems(items: HomeListFilterMenu["items"]): NativeHeaderMenuItems {
17+ return items.map((item) =>
18+ item.type === "action"
19+ ? {
20+ type: "action" as const,
21+ label: item.title,
22+ description: item.subtitle,
23+ onPress: item.onPress,
24+ state: item.state === "on" ? ("on" as const) : undefined,
25+ }
26+ : {
27+ type: "submenu" as const,
28+ label: item.title,
29+ items: toNativeHeaderMenuItems(item.items),
30+ },
31+ );
32+}
33+
34+/**
35+ * Right-side UINavigationBar items for the sidebar column: the thread list
36+ * filter/sort menu plus the settings button, sharing one glass capsule —
37+ * the Messages-style grouped header buttons.
38+ */
39+export function createSidebarHeaderItems(input: {
40+ readonly filterIcon: string;
41+ readonly filterMenu: HomeListFilterMenu;
42+ readonly onOpenSettings: () => void;
43+}): NativeStackHeaderItem[] {
44+ return [
45+ withNativeGlassHeaderItem({
46+ type: "menu",
47+ label: "",
48+ accessibilityLabel: "Filter and sort threads",
49+ icon: sfSymbolIcon(input.filterIcon),
50+ menu: {
51+ title: input.filterMenu.title,
52+ items: toNativeHeaderMenuItems(input.filterMenu.items),
53+ },
54+ }),
55+ withNativeGlassHeaderItem({
56+ type: "button",
57+ label: "",
58+ accessibilityLabel: "Open settings",
59+ icon: sfSymbolIcon("gearshape"),
60+ onPress: input.onOpenSettings,
61+ }),
62+ ];
63+}
apps/mobile/src/features/threads/sidebar-navigation-shell.tsx+72−0
@@ -0,0 +1,72 @@
1+import {
2+ DarkTheme,
3+ DefaultTheme,
4+ NavigationContainer,
5+ NavigationIndependentTree,
6+} from "@react-navigation/native";
7+import {
8+ createNativeStackNavigator,
9+ type NativeStackNavigationOptions,
10+} from "@react-navigation/native-stack";
11+import type { ReactNode } from "react";
12+import { Platform, useColorScheme } from "react-native";
13+
14+import { nativeHeaderScrollEdgeEffects } from "../../native/StackHeader";
15+
16+const SCROLL_EDGE_EFFECTS = nativeHeaderScrollEdgeEffects(Platform.OS, Platform.Version);
17+
18+type SidebarScreenOptions = NativeStackNavigationOptions & {
19+ // Same patched RNS option the GLASS/SOLID presets in Stack.tsx use — the
20+ // iOS 26 "editor" navigation-item style leading-aligns the inline title.
21+ readonly unstable_navigationItemStyle?: "editor";
22+};
23+
24+/**
25+ * Static chrome for the sidebar column: a real UINavigationBar with a fixed
26+ * inline title (no large title — saves vertical space, left-aligned via the
27+ * editor item style) and the search bar pinned below it, scroll-edge blur
28+ * sampling the list. Only genuinely dynamic values (search callbacks, header
29+ * items) are set by the screen content via NativeStackScreenOptions.
30+ */
31+const SIDEBAR_SCREEN_OPTIONS: SidebarScreenOptions = {
32+ contentStyle: { backgroundColor: "transparent" },
33+ headerLargeTitle: false,
34+ headerShadowVisible: false,
35+ headerShown: true,
36+ headerStyle: { backgroundColor: "transparent" },
37+ headerTitleStyle: { fontSize: 18, fontWeight: "800" },
38+ headerTransparent: true,
39+ scrollEdgeEffects: SCROLL_EDGE_EFFECTS,
40+ title: "Threads",
41+ unstable_navigationItemStyle: "editor",
42+};
43+
44+const SidebarStack = createNativeStackNavigator();
45+
46+/**
47+ * Hosts the iPad sidebar pane inside its own single-screen native stack.
48+ *
49+ * The stack is navigation-inert — nothing is ever pushed onto it. It exists so
50+ * the sidebar column owns a real UINavigationBar (large title, native bar
51+ * button items, UISearchController), mirroring how each column of a
52+ * UISplitViewController has its own UINavigationController. All real
53+ * navigation still flows through the root stack via callbacks minted in
54+ * AdaptiveWorkspaceLayout; NavigationIndependentTree only isolates the
55+ * navigation hooks used for header configuration inside the pane.
56+ */
57+export function SidebarNavigationShell(props: { readonly children: ReactNode }) {
58+ const colorScheme = useColorScheme();
59+
60+ return (
61+ <NavigationIndependentTree>
62+ <NavigationContainer theme={colorScheme === "dark" ? DarkTheme : DefaultTheme}>
63+ <SidebarStack.Navigator
64+ screenOptions={SIDEBAR_SCREEN_OPTIONS}
65+ initialRouteName="SidebarThreads"
66+ >
67+ <SidebarStack.Screen name="SidebarThreads">{() => props.children}</SidebarStack.Screen>
68+ </SidebarStack.Navigator>
69+ </NavigationContainer>
70+ </NavigationIndependentTree>
71+ );
72+}
apps/mobile/src/features/threads/thread-inspector-content-stack.tsx+101−0
@@ -0,0 +1,101 @@
1+import { useEffect, useState, type ComponentType, type ReactNode } from "react";
2+import { View } from "react-native";
3+
4+export type ThreadInspectorMode = "route" | "git" | "files";
5+
6+const INSPECTOR_PREWARM_DELAY_MS = 350;
7+
8+function InspectorContentPane(props: {
9+ readonly children: ReactNode;
10+ readonly mounted: boolean;
11+ readonly visible: boolean;
12+}) {
13+ if (!props.mounted) {
14+ return null;
15+ }
16+
17+ return (
18+ <View
19+ accessibilityElementsHidden={!props.visible}
20+ focusable={props.visible}
21+ importantForAccessibility={props.visible ? "auto" : "no-hide-descendants"}
22+ pointerEvents={props.visible ? "auto" : "none"}
23+ style={{
24+ position: "absolute",
25+ inset: 0,
26+ opacity: props.visible ? 1 : 0,
27+ zIndex: props.visible ? 1 : 0,
28+ }}
29+ >
30+ {props.children}
31+ </View>
32+ );
33+}
34+
35+export function ThreadInspectorContentStack(props: {
36+ readonly Files: ComponentType;
37+ readonly Git: ComponentType;
38+ readonly mode: ThreadInspectorMode;
39+ readonly Route?: ComponentType;
40+}) {
41+ const [mountedModes, setMountedModes] = useState<ReadonlySet<ThreadInspectorMode>>(
42+ () => new Set([props.mode]),
43+ );
44+
45+ useEffect(() => {
46+ setMountedModes((current) => {
47+ if (current.has(props.mode)) {
48+ return current;
49+ }
50+ return new Set([...current, props.mode]);
51+ });
52+
53+ if (props.mode === "route") {
54+ return;
55+ }
56+
57+ // The file tree is expensive to detach because UIKit rebuilds its focus
58+ // graph. Keep both chat inspectors alive after the opening animation so a
59+ // later Files/Git switch only changes visibility.
60+ const alternateMode = props.mode === "files" ? "git" : "files";
61+ const timeout = setTimeout(() => {
62+ setMountedModes((current) => {
63+ if (current.has(alternateMode)) {
64+ return current;
65+ }
66+ return new Set([...current, alternateMode]);
67+ });
68+ }, INSPECTOR_PREWARM_DELAY_MS);
69+
70+ return () => clearTimeout(timeout);
71+ }, [props.mode]);
72+
73+ const Files = props.Files;
74+ const Git = props.Git;
75+ const Route = props.Route;
76+
77+ return (
78+ <View style={{ flex: 1 }}>
79+ <InspectorContentPane
80+ mounted={mountedModes.has("files") || props.mode === "files"}
81+ visible={props.mode === "files"}
82+ >
83+ <Files />
84+ </InspectorContentPane>
85+ <InspectorContentPane
86+ mounted={mountedModes.has("git") || props.mode === "git"}
87+ visible={props.mode === "git"}
88+ >
89+ <Git />
90+ </InspectorContentPane>
91+ {Route ? (
92+ <InspectorContentPane
93+ mounted={mountedModes.has("route") || props.mode === "route"}
94+ visible={props.mode === "route"}
95+ >
96+ <Route />
97+ </InspectorContentPane>
98+ ) : null}
99+ </View>
100+ );
101+}
apps/mobile/src/features/threads/thread-list-items.tsx+632−0
@@ -0,0 +1,632 @@
1+import { useRecyclingState } from "@legendapp/list/react-native";
2+import type {
3+ EnvironmentProject,
4+ EnvironmentThreadShell,
5+} from "@t3tools/client-runtime/state/shell";
6+import type { MenuAction } from "@react-native-menu/menu";
7+import { SymbolView } from "expo-symbols";
8+import { memo, useCallback, useMemo, type ComponentProps } from "react";
9+import { Pressable, useWindowDimensions, View } from "react-native";
10+import type { SwipeableMethods } from "react-native-gesture-handler/ReanimatedSwipeable";
11+
12+import { AppText as Text } from "../../components/AppText";
13+import { ControlPillMenu } from "../../components/ControlPill";
14+import { ProjectFavicon } from "../../components/ProjectFavicon";
15+import { relativeTime } from "../../lib/time";
16+import { useThemeColor } from "../../lib/useThemeColor";
17+import type { PendingNewTask } from "../../state/use-pending-new-tasks";
18+import { useThreadPr } from "../../state/use-thread-pr";
19+import type { HomeGroupDisplayAction } from "../home/homeListItems";
20+import { ThreadSwipeable } from "../home/thread-swipe-actions";
21+import { resolveThreadStatus } from "./threadPresentation";
22+
23+/**
24+ * Shared presentation for the thread lists: the compact (phone) Home list and
25+ * the iPad sidebar render the SAME items — group headers with collapse,
26+ * thread rows with status/PR/subtitle, and show-more rows — differing only in
27+ * metrics and chrome via `variant`.
28+ */
29+export type ThreadListVariant = "compact" | "sidebar";
30+
31+/** Left inset that aligns compact secondary rows with the title column. */
32+export const THREAD_LIST_COMPACT_INSET = 20;
33+const SIDEBAR_ROW_RADIUS = 12;
34+
35+/* ─── Project group header ───────────────────────────────────────────── */
36+
37+export const ThreadListGroupHeader = memo(function ThreadListGroupHeader(props: {
38+ readonly variant: ThreadListVariant;
39+ readonly project: EnvironmentProject;
40+ readonly title: string;
41+ readonly threadCount: number;
42+ readonly collapsed: boolean;
43+ readonly isFirst: boolean;
44+ readonly groupKey: string;
45+ readonly onGroupAction: (key: string, action: HomeGroupDisplayAction) => void;
46+ readonly onNewThread?: (project: EnvironmentProject) => void;
47+}) {
48+ const iconSubtleColor = useThemeColor("--color-icon-subtle");
49+ const iconMutedColor = useThemeColor("--color-icon-muted");
50+ const { groupKey, onGroupAction, onNewThread, project } = props;
51+ const compact = props.variant === "compact";
52+ const handleToggle = useCallback(
53+ () => onGroupAction(groupKey, "toggle-collapsed"),
54+ [groupKey, onGroupAction],
55+ );
56+ const handleNewThread = useCallback(() => onNewThread?.(project), [onNewThread, project]);
57+
58+ return (
59+ <Pressable
60+ accessibilityRole="button"
61+ accessibilityState={{ expanded: !props.collapsed }}
62+ accessibilityLabel={`${props.title}, ${props.threadCount} threads`}
63+ accessibilityHint={props.collapsed ? "Expands the project" : "Collapses the project"}
64+ className={compact ? "bg-screen" : undefined}
65+ onPress={handleToggle}
66+ >
67+ <View
68+ className={
69+ compact
70+ ? `flex-row items-center gap-2.5 px-5 pb-3 ${props.isFirst ? "pt-2" : "pt-6"}`
71+ : `flex-row items-center gap-2 px-3 pb-2 ${props.isFirst ? "pt-1" : "pt-5"}`
72+ }
73+ style={{ minHeight: compact ? 44 : 36 }}
74+ >
75+ <ProjectFavicon
76+ environmentId={props.project.environmentId}
77+ size={compact ? 22 : 18}
78+ projectTitle={props.project.title}
79+ workspaceRoot={props.project.workspaceRoot}
80+ />
81+ <Text
82+ className={
83+ compact
84+ ? "flex-shrink text-base font-t3-bold text-foreground-muted"
85+ : "flex-shrink text-sm font-t3-bold text-foreground-muted"
86+ }
87+ style={{ letterSpacing: 0.2 }}
88+ numberOfLines={1}
89+ >
90+ {props.title}
91+ </Text>
92+ <Text
93+ className={
94+ compact
95+ ? "flex-1 text-sm font-t3-medium text-foreground-tertiary"
96+ : "flex-1 text-xs font-t3-medium text-foreground-tertiary"
97+ }
98+ >
99+ {props.threadCount}
100+ </Text>
101+ {onNewThread ? (
102+ <Pressable
103+ accessibilityLabel={`Create new thread in ${props.title}`}
104+ accessibilityRole="button"
105+ hitSlop={8}
106+ onPress={handleNewThread}
107+ style={({ pressed }) => ({ opacity: pressed ? 0.5 : 1, marginRight: compact ? 10 : 8 })}
108+ >
109+ <SymbolView
110+ name="square.and.pencil"
111+ size={compact ? 16 : 14}
112+ tintColor={iconMutedColor}
113+ type="monochrome"
114+ weight="medium"
115+ />
116+ </Pressable>
117+ ) : null}
118+ <SymbolView
119+ name={props.collapsed ? "chevron.right" : "chevron.down"}
120+ size={compact ? 13 : 11}
121+ tintColor={iconSubtleColor}
122+ type="monochrome"
123+ weight="semibold"
124+ />
125+ </View>
126+ </Pressable>
127+ );
128+});
129+
130+/* ─── Show more / show less row ──────────────────────────────────────── */
131+
132+export const ThreadListShowMoreRow = memo(function ThreadListShowMoreRow(props: {
133+ readonly variant: ThreadListVariant;
134+ readonly hiddenCount: number;
135+ readonly canShowLess: boolean;
136+ readonly groupKey: string;
137+ readonly onGroupAction: (key: string, action: HomeGroupDisplayAction) => void;
138+}) {
139+ const iconSubtleColor = useThemeColor("--color-icon-subtle");
140+ const showsMore = props.hiddenCount > 0;
141+ const compact = props.variant === "compact";
142+ const { groupKey, onGroupAction } = props;
143+ const handleShowMore = useCallback(
144+ () => onGroupAction(groupKey, "show-more"),
145+ [groupKey, onGroupAction],
146+ );
147+ const handleShowLess = useCallback(
148+ () => onGroupAction(groupKey, "show-less"),
149+ [groupKey, onGroupAction],
150+ );
151+
152+ const button = (label: string, icon: "chevron.down" | "chevron.up", onPress: () => void) => (
153+ <Pressable
154+ accessibilityRole="button"
155+ accessibilityLabel={label === "Show more" ? "Show more threads" : "Show fewer threads"}
156+ className="rounded-full bg-subtle"
157+ hitSlop={6}
158+ onPress={onPress}
159+ style={({ pressed }) => ({
160+ opacity: pressed ? 0.6 : 1,
161+ paddingHorizontal: compact ? 14 : 12,
162+ paddingVertical: compact ? 7 : 6,
163+ borderCurve: "continuous",
164+ })}
165+ >
166+ <View className="flex-row items-center gap-1.5">
167+ <SymbolView
168+ name={icon}
169+ size={10}
170+ tintColor={iconSubtleColor}
171+ type="monochrome"
172+ weight="semibold"
173+ />
174+ <Text
175+ className={
176+ compact
177+ ? "text-sm font-t3-medium text-foreground-muted"
178+ : "text-xs font-t3-medium text-foreground-muted"
179+ }
180+ >
181+ {label}
182+ </Text>
183+ </View>
184+ </Pressable>
185+ );
186+
187+ return (
188+ <View
189+ className={
190+ compact ? "flex-row items-center gap-2.5 bg-screen" : "flex-row items-center gap-2"
191+ }
192+ style={{
193+ paddingLeft: compact ? THREAD_LIST_COMPACT_INSET : 12,
194+ paddingRight: compact ? 18 : 12,
195+ paddingVertical: compact ? 12 : 8,
196+ }}
197+ >
198+ {showsMore ? button("Show more", "chevron.down", handleShowMore) : null}
199+ {props.canShowLess ? button("Show less", "chevron.up", handleShowLess) : null}
200+ </View>
201+ );
202+});
203+
204+/* ─── Pending task row ───────────────────────────────────────────────── */
205+
206+const PENDING_TASK_MENU_ACTIONS: MenuAction[] = [
207+ { id: "delete", title: "Delete", image: "trash", attributes: { destructive: true } },
208+];
209+
210+/**
211+ * A queued new task waiting in the outbox for its environment to reconnect.
212+ * Tapping reopens the new-task composer with everything prefilled; the row
213+ * disappears once the task is delivered and the real thread arrives.
214+ */
215+export const PendingTaskListRow = memo(function PendingTaskListRow(props: {
216+ readonly variant: ThreadListVariant;
217+ readonly pendingTask: PendingNewTask;
218+ readonly environmentLabel: string | null;
219+ readonly isLast: boolean;
220+ readonly onSelectPendingTask: (pendingTask: PendingNewTask) => void;
221+ readonly onDeletePendingTask: (pendingTask: PendingNewTask) => void;
222+}) {
223+ const compact = props.variant === "compact";
224+ const separatorColor = useThemeColor("--color-separator");
225+ const iconSubtleColor = useThemeColor("--color-icon-subtle");
226+ const foregroundColor = useThemeColor("--color-foreground");

406 more lines aren’t shown here.

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.