History

Migrate mobile UI to Uniwind and sheet-based thread routing

- Replace NativeWind styling with Uniwind tokens and helpers - Split thread and git flows into nested sheet routes - Add shared git state hooks and project favicon rendering

Julius Marminge committed 7 months ago

Commit db30cdd1 parent 87d12d786 files · +4961 −4620Browse files

Unified · Split

86 files
  1. apps/mobile/babel.config.js +1 −1
  2. apps/mobile/global.css +181 −6
  3. apps/mobile/metro.config.js +4 −4
  4. apps/mobile/nativewind-env.d.ts +0 −5
  5. apps/mobile/package.json +4 −5
  6. apps/mobile/postcss.config.mjs +0 −5
  7. apps/mobile/src/app/+not-found.tsx +15 −15
  8. apps/mobile/src/app/_layout.tsx +31 −16
  9. apps/mobile/src/app/connections/_layout.tsx +13 −1
  10. apps/mobile/src/app/new/_layout.tsx +3 −5
  11. apps/mobile/src/app/new/index.tsx +39 −26
  12. apps/mobile/src/app/threads/[environmentId]/[threadId].tsx +0 −5
  13. apps/mobile/src/app/threads/[environmentId]/[threadId]/_layout.tsx +43 −0
  14. apps/mobile/src/app/threads/[environmentId]/[threadId]/git-confirm.tsx +5 −0
  15. apps/mobile/src/app/threads/[environmentId]/[threadId]/git/_layout.tsx +45 −0
  16. apps/mobile/src/app/threads/[environmentId]/[threadId]/git/branches.tsx +5 −0
  17. apps/mobile/src/app/threads/[environmentId]/[threadId]/git/commit.tsx +5 −0
  18. apps/mobile/src/app/threads/[environmentId]/[threadId]/git/index.tsx +5 −0
  19. apps/mobile/src/app/threads/[environmentId]/[threadId]/index.tsx +5 −0
  20. apps/mobile/src/components/AppText.tsx +21 −299
  21. apps/mobile/src/components/BrandMark.tsx +7 −15
  22. apps/mobile/src/components/ComposerAttachmentStrip.tsx +4 −5
  23. apps/mobile/src/components/ControlPill.tsx +24 −19
  24. apps/mobile/src/components/EmptyState.tsx +4 −5
  25. apps/mobile/src/components/LoadingScreen.tsx +7 −9
  26. apps/mobile/src/components/ProjectFavicon.tsx +70 −0
  27. apps/mobile/src/components/StatusPill.tsx +3 −3
  28. apps/mobile/src/features/connection/ConnectionSheet.tsx +0 −415
  29. apps/mobile/src/features/connection/ConnectionsRouteScreen.tsx +40 −95
  30. apps/mobile/src/features/connection/NewConnectionRouteScreen.tsx +22 −47
  31. apps/mobile/src/features/connection/connection-sheet-shared.tsx +42 −77
  32. apps/mobile/src/features/home/HomeRouteScreen.tsx +127 −16
  33. apps/mobile/src/features/home/HomeScreen.tsx +315 −181
  34. apps/mobile/src/features/threads/NewTaskDraftScreen.tsx +23 −31
  35. apps/mobile/src/features/threads/PendingUserInputCard.tsx +4 −4
  36. apps/mobile/src/features/threads/ProjectPickerSheet.tsx +0 −135
  37. apps/mobile/src/features/threads/ThreadComposer.tsx +13 −19
  38. apps/mobile/src/features/threads/ThreadDetailScreen.tsx +10 −227
  39. apps/mobile/src/features/threads/ThreadFeed.tsx +45 −38
  40. apps/mobile/src/features/threads/ThreadGitControls.tsx +66 −1172
  41. apps/mobile/src/features/threads/ThreadListScreen.tsx +66 −129
  42. apps/mobile/src/features/threads/ThreadNavigationDrawer.tsx +24 −39
  43. apps/mobile/src/features/threads/ThreadRouteScreen.tsx +148 −72
  44. apps/mobile/src/features/threads/git/GitBranchesSheet.tsx +205 −0
  45. apps/mobile/src/features/threads/git/GitCommitSheet.tsx +242 −0
  46. apps/mobile/src/features/threads/git/GitConfirmSheet.tsx +123 −0
  47. apps/mobile/src/features/threads/git/GitOverviewSheet.tsx +249 −0
  48. apps/mobile/src/features/threads/git/gitSheetComponents.tsx +152 −0
  49. apps/mobile/src/features/threads/new-task-flow-provider.tsx +29 −12
  50. apps/mobile/src/lib/arrayCompat.ts +0 −42
  51. apps/mobile/src/lib/classNames.ts +0 −3
  52. apps/mobile/src/lib/clientId.ts +0 −6
  53. apps/mobile/src/lib/cn.ts +6 −0
  54. apps/mobile/src/lib/composerImages.ts +4 −5
  55. apps/mobile/src/lib/mobileLayout.ts +37 −0
  56. apps/mobile/src/lib/storage.ts +11 −74
  57. apps/mobile/src/lib/theme.ts +0 −187
  58. apps/mobile/src/lib/threadActivity.ts +7 −6
  59. apps/mobile/src/lib/useThemeColor.ts +12 −0
  60. apps/mobile/src/lib/uuid.ts +4 −0
  61. apps/mobile/src/state/atomRegistry.tsx +9 −0
  62. apps/mobile/src/state/thread-composer-store.ts +6 −6
  63. apps/mobile/src/state/thread-git-store.ts +0 −45
  64. apps/mobile/src/state/use-git-action-state.ts +31 −0
  65. apps/mobile/src/state/use-git-branches.ts +29 −0
  66. apps/mobile/src/state/use-git-status.ts +62 −0
  67. apps/mobile/src/state/use-project-actions.ts +19 −12
  68. apps/mobile/src/state/use-remote-catalog.ts +19 −11
  69. apps/mobile/src/state/use-remote-environment-registry.ts +27 −4
  70. apps/mobile/src/state/use-selected-thread-commands.ts +8 −8
  71. apps/mobile/src/state/use-selected-thread-git-actions.ts +321 −0
  72. apps/mobile/src/state/use-selected-thread-git-state.ts +47 −0
  73. apps/mobile/src/state/use-selected-thread-git.ts +0 −409
  74. apps/mobile/src/state/use-thread-composer-state.ts +15 −8
  75. apps/mobile/uniwind-types.d.ts +10 −0
  76. apps/web/src/lib/gitStatusState.test.ts +0 −325
  77. apps/web/src/lib/gitStatusState.ts +30 −258
  78. bun.lock +26 −49
  79. packages/client-runtime/src/environmentConnection.ts +14 −4
  80. packages/client-runtime/src/gitActionState.test.ts +264 −0
  81. packages/client-runtime/src/gitActionState.ts +444 −0
  82. packages/client-runtime/src/gitBranchState.test.ts +161 −0
  83. packages/client-runtime/src/gitBranchState.ts +227 −0
  84. packages/client-runtime/src/gitStatusState.test.ts +330 −0
  85. packages/client-runtime/src/gitStatusState.ts +289 −0
  86. packages/client-runtime/src/index.ts +3 −0
apps/mobile/babel.config.js+1−1
@@ -1,6 +1,6 @@
1module.exports = function (api) {1module.exports = function (api) {
2 api.cache(true);2 api.cache(true);
3 return {3 return {
4 presets: [["babel-preset-expo", { unstable_transformImportMeta: true }], "nativewind/babel"],4 presets: [["babel-preset-expo", { unstable_transformImportMeta: true }]],
5 };5 };
6};6};
apps/mobile/global.css+181−6
@@ -1,17 +1,192 @@
1@import "tailwindcss/theme.css" layer(theme);1@import "tailwindcss";
2@import "tailwindcss/preflight.css" layer(base);2@import "uniwind";
3@import "tailwindcss/utilities.css" layer(utilities);
4 3
5@import "nativewind/theme";4/* ─── Theme tokens ──────────────────────────────────────────────────── */
5@layer theme {
6 :root {
7 @variant light {
8 /* Page backgrounds */
9 --color-screen: #f2f2f7;
10 --color-sheet: rgba(242, 242, 247, 0.98);
6 11
12 /* Card / surface */
13 --color-card: #ffffff;
14 --color-card-alt: #f5f5f5;
15 --color-card-translucent: rgba(255, 255, 255, 0.94);
16
17 /* Text */
18 --color-foreground: #262626;
19 --color-foreground-secondary: #525252;
20 --color-foreground-muted: #737373;
21 --color-foreground-tertiary: #a3a3a3;
22
23 /* Borders & separators */
24 --color-border: rgba(0, 0, 0, 0.08);
25 --color-border-subtle: rgba(0, 0, 0, 0.06);
26 --color-separator: rgba(0, 0, 0, 0.04);
27
28 /* Subtle backgrounds (badges, pills, overlays) */
29 --color-subtle: rgba(0, 0, 0, 0.04);
30 --color-subtle-strong: rgba(0, 0, 0, 0.08);
31
32 /* Primary action */
33 --color-primary: #262626;
34 --color-primary-foreground: #ffffff;
35 --color-primary-shadow: rgba(0, 0, 0, 0.18);
36
37 /* Secondary action */
38 --color-secondary: #ffffff;
39 --color-secondary-foreground: #262626;
40 --color-secondary-border: rgba(0, 0, 0, 0.08);
41
42 /* Danger */
43 --color-danger: #fef2f2;
44 --color-danger-border: rgba(239, 68, 68, 0.12);
45 --color-danger-foreground: #dc2626;
46
47 /* Inputs */
48 --color-input: #ffffff;
49 --color-input-border: rgba(0, 0, 0, 0.1);
50 --color-placeholder: #a3a3a3;
51
52 /* Icons */
53 --color-icon: #262626;
54 --color-icon-muted: #525252;
55 --color-icon-subtle: #a3a3a3;
56
57 /* Header / glass chrome */
58 --color-header: rgba(255, 255, 255, 0.97);
59 --color-header-border: rgba(0, 0, 0, 0.06);
60
61 /* StatusBar */
62 --color-status-bar: #f2f2f7;
63
64 /* Markdown */
65 --color-md-body: #262626;
66 --color-md-strong: #171717;
67 --color-md-link: #2563eb;
68 --color-md-blockquote-border: rgba(0, 0, 0, 0.08);
69 --color-md-blockquote-bg: rgba(0, 0, 0, 0.02);
70 --color-md-code-bg: rgba(0, 0, 0, 0.04);
71 --color-md-code-text: #262626;
72 --color-md-user-code-bg: rgba(255, 255, 255, 0.55);
73 --color-md-user-code-text: #262626;
74 --color-md-user-fence-bg: rgba(0, 0, 0, 0.04);
75 --color-md-user-fence-text: #1e3a5f;
76 --color-md-hr: rgba(0, 0, 0, 0.08);
77
78 /* Drawer / modal backdrop */
79 --color-backdrop: rgba(0, 0, 0, 0.22);
80 --color-drawer: rgba(255, 255, 255, 0.99);
81 --color-drawer-shadow: rgba(0, 0, 0, 0.12);
82
83 /* Misc */
84 --color-dot-separator: rgba(0, 0, 0, 0.2);
85 --color-wordmark: #262626;
86 --color-chevron: rgba(0, 0, 0, 0.2);
87 }
88
89 @variant dark {
90 /* Page backgrounds */
91 --color-screen: #0a0a0a;
92 --color-sheet: rgba(14, 14, 14, 0.98);
93
94 /* Card / surface */
95 --color-card: #171717;
96 --color-card-alt: #1c1c1c;
97 --color-card-translucent: rgba(17, 17, 17, 0.94);
98
99 /* Text */
100 --color-foreground: #f5f5f5;
101 --color-foreground-secondary: #a3a3a3;
102 --color-foreground-muted: #737373;
103 --color-foreground-tertiary: #525252;
104
105 /* Borders & separators */
106 --color-border: rgba(255, 255, 255, 0.06);
107 --color-border-subtle: rgba(255, 255, 255, 0.04);
108 --color-separator: rgba(255, 255, 255, 0.03);
109
110 /* Subtle backgrounds (badges, pills, overlays) */
111 --color-subtle: rgba(255, 255, 255, 0.04);
112 --color-subtle-strong: rgba(255, 255, 255, 0.08);
113
114 /* Primary action */
115 --color-primary: #f5f5f5;
116 --color-primary-foreground: #0a0a0a;
117 --color-primary-shadow: rgba(0, 0, 0, 0.22);
118
119 /* Secondary action */
120 --color-secondary: rgba(255, 255, 255, 0.04);
121 --color-secondary-foreground: #f5f5f5;
122 --color-secondary-border: rgba(255, 255, 255, 0.06);
123
124 /* Danger */
125 --color-danger: rgba(239, 68, 68, 0.14);
126 --color-danger-border: rgba(248, 113, 113, 0.18);
127 --color-danger-foreground: #fca5a5;
128
129 /* Inputs */
130 --color-input: #141414;
131 --color-input-border: rgba(255, 255, 255, 0.08);
132 --color-placeholder: #737373;
133
134 /* Icons */
135 --color-icon: #f5f5f5;
136 --color-icon-muted: #a3a3a3;
137 --color-icon-subtle: #737373;
138
139 /* Header / glass chrome */
140 --color-header: rgba(10, 10, 10, 0.97);
141 --color-header-border: rgba(255, 255, 255, 0.06);
142
143 /* StatusBar */
144 --color-status-bar: #0a0a0a;
145
146 /* Markdown */
147 --color-md-body: #e5e5e5;
148 --color-md-strong: #f5f5f5;
149 --color-md-link: #60a5fa;
150 --color-md-blockquote-border: rgba(255, 255, 255, 0.1);
151 --color-md-blockquote-bg: rgba(255, 255, 255, 0.03);
152 --color-md-code-bg: rgba(255, 255, 255, 0.06);
153 --color-md-code-text: #e5e5e5;
154 --color-md-user-code-bg: rgba(255, 255, 255, 0.1);
155 --color-md-user-code-text: #e5e5e5;
156 --color-md-user-fence-bg: rgba(255, 255, 255, 0.08);
157 --color-md-user-fence-text: #e5e5e5;
158 --color-md-hr: rgba(255, 255, 255, 0.08);
159
160 /* Drawer / modal backdrop */
161 --color-backdrop: rgba(0, 0, 0, 0.48);
162 --color-drawer: rgba(14, 14, 14, 0.99);
163 --color-drawer-shadow: rgba(0, 0, 0, 0.32);
164
165 /* Misc */
166 --color-dot-separator: rgba(255, 255, 255, 0.2);
167 --color-wordmark: #f5f5f5;
168 --color-chevron: rgba(255, 255, 255, 0.2);
169 }
170 }
171}
172
173/* ─── Font family ───────────────────────────────────────────────────── */
174@theme {
175 --font-sans: "DMSans_400Regular";
176 --font-medium: "DMSans_500Medium";
177 --font-bold: "DMSans_700Bold";
178}
179
180/* ─── Custom utilities ──────────────────────────────────────────────── */
7@utility font-t3-medium {181@utility font-t3-medium {
8 font-weight: 500;182 font-family: "DMSans_500Medium";
9}183}
10 184
11@utility font-t3-bold {185@utility font-t3-bold {
12 font-weight: 700;186 font-family: "DMSans_700Bold";
13}187}
14 188
15@utility font-t3-extrabold {189@utility font-t3-extrabold {
190 font-family: "DMSans_700Bold";
16 font-weight: 800;191 font-weight: 800;
17}192}
apps/mobile/metro.config.js+4−4
@@ -1,6 +1,6 @@
1const path = require("node:path");1const path = require("node:path");
2const { getDefaultConfig } = require("expo/metro-config");2const { getDefaultConfig } = require("expo/metro-config");
3const { withNativewind } = require("nativewind/metro");3const { withUniwindConfig } = require("uniwind/metro");
4 4
5/** @type {import("expo/metro-config").MetroConfig} */5/** @type {import("expo/metro-config").MetroConfig} */
6const config = getDefaultConfig(__dirname);6const config = getDefaultConfig(__dirname);
@@ -8,7 +8,7 @@ const workspaceRoot = path.resolve(__dirname, "../.."); · 1 unmodified line
8 8
9config.watchFolders = [...new Set([...(config.watchFolders ?? []), workspaceRoot])];9config.watchFolders = [...new Set([...(config.watchFolders ?? []), workspaceRoot])];
10 10
11module.exports = withNativewind(config, {11module.exports = withUniwindConfig(config, {
12 input: "./global.css",12 cssEntryFile: "./global.css",
13 globalClassNamePolyfill: true,13 polyfills: { rem: 14 },
14});14});
apps/mobile/nativewind-env.d.ts+0−5
@@ -1,5 +0,0 @@
1/// <reference types="react-native-css/types" />
2
3// NOTE: This file is generated by react-native-css / NativeWind integration.
4
5declare module "*.css";
apps/mobile/package.json+4−5
@@ -13,6 +13,7 @@ · 12 unmodified lines
13 },13 },
14 "dependencies": {14 "dependencies": {
15 "@callstack/liquid-glass": "^0.7.1",15 "@callstack/liquid-glass": "^0.7.1",
16 "@effect/atom-react": "catalog:",
16 "@expo-google-fonts/dm-sans": "^0.4.2",17 "@expo-google-fonts/dm-sans": "^0.4.2",
17 "@gorhom/bottom-sheet": "^5",18 "@gorhom/bottom-sheet": "^5",
18 "@legendapp/list": "^3.0.0-beta.44",19 "@legendapp/list": "^3.0.0-beta.44",
@@ -22,6 +23,7 @@ · 3 unmodified lines
22 "@t3tools/client-runtime": "workspace:*",23 "@t3tools/client-runtime": "workspace:*",
23 "@t3tools/contracts": "workspace:*",24 "@t3tools/contracts": "workspace:*",
24 "@t3tools/shared": "workspace:*",25 "@t3tools/shared": "workspace:*",
26 "clsx": "^2.1.1",
25 "effect": "catalog:",27 "effect": "catalog:",
26 "expo": "~55.0.14",28 "expo": "~55.0.14",
27 "expo-camera": "~55.0.15",29 "expo-camera": "~55.0.15",
@@ -41,12 +43,10 @@ · 13 unmodified lines
41 "expo-splash-screen": "~55.0.13",43 "expo-splash-screen": "~55.0.13",
42 "expo-symbols": "~55.0.5",44 "expo-symbols": "~55.0.5",
43 "expo-updates": "~55.0.16",45 "expo-updates": "~55.0.16",
44 "nativewind": "5.0.0-preview.3",
45 "punycode": "^2.3.1",46 "punycode": "^2.3.1",
46 "react": "19.2.0",47 "react": "19.2.0",
47 "react-dom": "19.2.0",48 "react-dom": "19.2.0",
48 "react-native": "0.83.4",49 "react-native": "0.83.4",
49 "react-native-css": "3.0.7",
50 "react-native-gesture-handler": "~2.30.0",50 "react-native-gesture-handler": "~2.30.0",
51 "react-native-image-viewing": "^0.2.2",51 "react-native-image-viewing": "^0.2.2",
52 "react-native-keyboard-controller": "1.20.7",52 "react-native-keyboard-controller": "1.20.7",
@@ -56,14 +56,13 @@ · 3 unmodified lines
56 "react-native-screens": "~4.23.0",56 "react-native-screens": "~4.23.0",
57 "react-native-svg": "15.15.3",57 "react-native-svg": "15.15.3",
58 "react-native-worklets": "0.7.2",58 "react-native-worklets": "0.7.2",
59 "tailwind-merge": "^3.5.0",
60 "uniwind": "^1.6.2",
59 "zustand": "^5.0.12"61 "zustand": "^5.0.12"
60 },62 },
61 "devDependencies": {63 "devDependencies": {
62 "@tailwindcss/postcss": "4.1.12",
63 "@types/react": "~19.2.10",64 "@types/react": "~19.2.10",
64 "babel-preset-expo": "~55.0.8",65 "babel-preset-expo": "~55.0.8",
65 "lightningcss": "1.30.1",
66 "postcss": "^8.5.8",
67 "tailwindcss": "4.1.12",66 "tailwindcss": "4.1.12",
68 "typescript": "~5.9.2"67 "typescript": "~5.9.2"
69 }68 }
apps/mobile/postcss.config.mjs+0−5
@@ -1,5 +0,0 @@
1export default {
2 plugins: {
3 "@tailwindcss/postcss": {},
4 },
5};
apps/mobile/src/app/+not-found.tsx+15−15
@@ -1,12 +1,12 @@
1import { Link } from "expo-router";1import { Link } from "expo-router";
2import { Pressable, ScrollView, useColorScheme } from "react-native";2import { Pressable, ScrollView, StyleSheet, View } from "react-native";
3import { useResolveClassNames } from "uniwind";
3 4
4import { AppText as Text } from "../components/AppText";5import { AppText as Text } from "../components/AppText";
5import { makeAppPalette } from "../lib/theme";
6 6
7export default function NotFoundRoute() {7export default function NotFoundRoute() {
8 const isDarkMode = useColorScheme() === "dark";8 const screenBgStyle = StyleSheet.flatten(useResolveClassNames("bg-screen"));
9 const palette = makeAppPalette(isDarkMode);9 const primaryBgStyle = StyleSheet.flatten(useResolveClassNames("bg-primary"));
10 10
11 return (11 return (
12 <ScrollView12 <ScrollView
@@ -19,23 +19,23 @@ export default function NotFoundRoute() { · 6 unmodified lines
19 paddingHorizontal: 24,19 paddingHorizontal: 24,
20 paddingVertical: 32,20 paddingVertical: 32,
21 }}21 }}
22 style={{ flex: 1, backgroundColor: palette.screenBackground }}22 style={[{ flex: 1 }, screenBgStyle]}
23 >23 >
24 <Text className="text-[28px] font-t3-bold" selectable style={{ color: palette.text }}>24 <Text className="text-[28px] font-t3-bold text-foreground" selectable>
25 Route not found25 Route not found
26 </Text>26 </Text>
27 <Link href="/" asChild>27 <Link href="/" asChild>
28 <Pressable28 <Pressable
29 style={{29 style={[
30 backgroundColor: palette.primaryButton,30 {
31 borderRadius: 999,31 borderRadius: 999,
32 paddingHorizontal: 20,32 paddingHorizontal: 20,
33 paddingVertical: 14,33 paddingVertical: 14,
34 }}34 },
35 primaryBgStyle,
36 ]}
35 >37 >
36 <Text className="text-[16px] font-t3-bold" style={{ color: palette.primaryButtonText }}>38 <Text className="text-[16px] font-t3-bold text-primary-foreground">Return home</Text>
37 Return home
38 </Text>
39 </Pressable>39 </Pressable>
40 </Link>40 </Link>
41 </ScrollView>41 </ScrollView>
apps/mobile/src/app/_layout.tsx+31−16
@@ -6,15 +6,15 @@ import { · 5 unmodified lines
6 DMSans_700Bold,6 DMSans_700Bold,
7 useFonts,7 useFonts,
8} from "@expo-google-fonts/dm-sans";8} from "@expo-google-fonts/dm-sans";
9import { BottomSheetModalProvider } from "@gorhom/bottom-sheet";
10import Stack from "expo-router/stack";9import Stack from "expo-router/stack";
11import { StatusBar, useColorScheme } from "react-native";10import { StatusBar, useColorScheme } from "react-native";
12import { GestureHandlerRootView } from "react-native-gesture-handler";11import { GestureHandlerRootView } from "react-native-gesture-handler";
13import { KeyboardProvider } from "react-native-keyboard-controller";12import { KeyboardProvider } from "react-native-keyboard-controller";
14import { SafeAreaProvider } from "react-native-safe-area-context";13import { SafeAreaProvider } from "react-native-safe-area-context";
14import { useCSSVariable, useResolveClassNames } from "uniwind";
15 15
16import { LoadingScreen } from "../components/LoadingScreen";16import { LoadingScreen } from "../components/LoadingScreen";
17import { makeAppPalette } from "../lib/theme";17import { AppAtomRegistryProvider } from "../state/atomRegistry";
18import {18import {
19 useRemoteEnvironmentBootstrap,19 useRemoteEnvironmentBootstrap,
20 useRemoteEnvironmentState,20 useRemoteEnvironmentState,
@@ -22,11 +22,13 @@ import { · 1 unmodified line
22 22
23function AppNavigator() {23function AppNavigator() {
24 const { isLoadingSavedConnection } = useRemoteEnvironmentState();24 const { isLoadingSavedConnection } = useRemoteEnvironmentState();
25 const isDarkMode = useColorScheme() !== "light";25 const colorScheme = useColorScheme();
26 const palette = makeAppPalette(isDarkMode);26 const statusBarBg = useCSSVariable("--color-status-bar");
27 const sheetStyle = useResolveClassNames("bg-sheet");
28 const screenStyle = useResolveClassNames("bg-screen");
27 29
28 const newTaskScreenOptions = {30 const newTaskScreenOptions = {
29 contentStyle: { backgroundColor: palette.sheetBackground },31 contentStyle: sheetStyle,
30 gestureEnabled: true,32 gestureEnabled: true,
31 headerShown: false,33 headerShown: false,
32 presentation: "formSheet" as const,34 presentation: "formSheet" as const,
@@ -35,7 +37,7 @@ function AppNavigator() { · 2 unmodified lines
35 };37 };
36 38
37 const connectionSheetScreenOptions = {39 const connectionSheetScreenOptions = {
38 contentStyle: { backgroundColor: palette.sheetBackground },40 contentStyle: sheetStyle,
39 gestureEnabled: true,41 gestureEnabled: true,
40 headerShown: false,42 headerShown: false,
41 presentation: "formSheet" as const,43 presentation: "formSheet" as const,
@@ -50,18 +52,27 @@ function AppNavigator() { · 8 unmodified lines
50 return (52 return (
51 <>53 <>
52 <StatusBar54 <StatusBar
53 barStyle={palette.statusBarStyle}55 barStyle={colorScheme === "dark" ? "light-content" : "dark-content"}
54 backgroundColor={palette.statusBarBackground}56 backgroundColor={statusBarBg as string}
55 translucent57 translucent
56 />58 />
57 <Stack screenOptions={{ headerShown: false }}>59 <Stack screenOptions={{ headerShown: false }}>
60 <Stack.Screen
61 name="index"
62 options={{
63 contentStyle: { backgroundColor: "transparent" },
64 headerShown: true,
65 headerTransparent: true,
66 headerShadowVisible: false,
67 }}
68 />
58 <Stack.Screen name="connections" options={connectionSheetScreenOptions} />69 <Stack.Screen name="connections" options={connectionSheetScreenOptions} />
59 <Stack.Screen name="new" options={newTaskScreenOptions} />70 <Stack.Screen name="new" options={newTaskScreenOptions} />
60 <Stack.Screen71 <Stack.Screen
61 name="threads/[environmentId]/[threadId]"72 name="threads/[environmentId]/[threadId]"
62 options={{73 options={{
63 animation: "slide_from_right",74 animation: "slide_from_right",
64 contentStyle: { backgroundColor: palette.screenBackground },75 contentStyle: { backgroundColor: "transparent" },
65 gestureEnabled: true,76 gestureEnabled: true,
66 headerShown: false,77 headerShown: false,
67 }}78 }}
@@ -80,14 +91,18 @@ export default function RootLayout() { · 12 unmodified lines
80 useRemoteEnvironmentBootstrap();91 useRemoteEnvironmentBootstrap();
81 92
82 return (93 return (
83 <GestureHandlerRootView style={{ flex: 1 }}>94 <AppAtomRegistryProvider>
84 <KeyboardProvider statusBarTranslucent>95 <GestureHandlerRootView style={{ flex: 1 }}>
85 <BottomSheetModalProvider>96 <KeyboardProvider statusBarTranslucent>
86 <SafeAreaProvider>97 <SafeAreaProvider>
87 {fontsLoaded ? <AppNavigator /> : <LoadingScreen message="Loading remote workspace…" />}98 {fontsLoaded ? (
99 <AppNavigator />
100 ) : (
101 <LoadingScreen message="Loading remote workspace…" />
102 )}
88 </SafeAreaProvider>103 </SafeAreaProvider>
89 </BottomSheetModalProvider>104 </KeyboardProvider>
90 </KeyboardProvider>105 </GestureHandlerRootView>
91 </GestureHandlerRootView>106 </AppAtomRegistryProvider>
92 );107 );
93}108}
apps/mobile/src/app/connections/_layout.tsx+13−1
@@ -1,14 +1,26 @@
1import Stack from "expo-router/stack";1import Stack from "expo-router/stack";
2import { useResolveClassNames } from "uniwind";
3
4import { useThemeColor } from "../../lib/useThemeColor";
2 5
3export const unstable_settings = {6export const unstable_settings = {
4 anchor: "index",7 anchor: "index",
5};8};
6 9
7export default function ConnectionsLayout() {10export default function ConnectionsLayout() {
11 const contentStyle = useResolveClassNames("bg-sheet");
12 const connSheetBg = String(useThemeColor("--color-sheet"));
13 const headerTint = String(useThemeColor("--color-icon"));
14
8 return (15 return (
9 <Stack16 <Stack
10 screenOptions={{17 screenOptions={{
11 contentStyle: { backgroundColor: "rgba(246,244,239,0.98)" },18 contentStyle,
19 headerStyle: { backgroundColor: connSheetBg },
20 headerTintColor: headerTint,
21 headerTitleStyle: { fontFamily: "DMSans_700Bold" },
22 headerShadowVisible: false,
23 headerBackButtonDisplayMode: "minimal",
12 }}24 }}
13 >25 >
14 <Stack.Screen name="index" options={{ animation: "none" }} />26 <Stack.Screen name="index" options={{ animation: "none" }} />
apps/mobile/src/app/new/_layout.tsx+3−5
@@ -1,7 +1,6 @@
1import Stack from "expo-router/stack";1import Stack from "expo-router/stack";
2import { useColorScheme } from "react-native";2import { useResolveClassNames } from "uniwind";
3 3
4import { makeAppPalette } from "../../lib/theme";
5import { NewTaskFlowProvider } from "../../features/threads/new-task-flow-provider";4import { NewTaskFlowProvider } from "../../features/threads/new-task-flow-provider";
6 5
7export const unstable_settings = {6export const unstable_settings = {
@@ -9,14 +8,13 @@ export const unstable_settings = { · 1 unmodified line
9};8};
10 9
11export default function NewTaskLayout() {10export default function NewTaskLayout() {
12 const isDarkMode = useColorScheme() === "dark";11 const sheetStyle = useResolveClassNames("bg-sheet");
13 const palette = makeAppPalette(isDarkMode);
14 12
15 return (13 return (
16 <NewTaskFlowProvider>14 <NewTaskFlowProvider>
17 <Stack15 <Stack
18 screenOptions={{16 screenOptions={{
19 contentStyle: { backgroundColor: palette.sheetBackground },17 contentStyle: sheetStyle,
20 }}18 }}
21 >19 >
22 <Stack.Screen name="index" options={{ animation: "none" }} />20 <Stack.Screen name="index" options={{ animation: "none" }} />
apps/mobile/src/app/new/index.tsx+39−26
@@ -1,19 +1,22 @@
1import { Link, Stack } from "expo-router";1import { Link, Stack } from "expo-router";
2import { SymbolView } from "expo-symbols";2import { SymbolView } from "expo-symbols";
3import { useMemo } from "react";3import { useMemo } from "react";
4import { Pressable, ScrollView, useColorScheme, View } from "react-native";4import { Pressable, ScrollView, View } from "react-native";
5import { useSafeAreaInsets } from "react-native-safe-area-context";5import { useSafeAreaInsets } from "react-native-safe-area-context";
6import { useThemeColor } from "../../lib/useThemeColor";
6 7
7import { AppText as Text } from "../../components/AppText";8import { AppText as Text } from "../../components/AppText";
9import { ProjectFavicon } from "../../components/ProjectFavicon";
8import { groupProjectsByRepository } from "../../lib/repositoryGroups";10import { groupProjectsByRepository } from "../../lib/repositoryGroups";
9import { makeAppPalette } from "../../lib/theme";
10import { useRemoteCatalog } from "../../state/use-remote-catalog";11import { useRemoteCatalog } from "../../state/use-remote-catalog";
12import { useRemoteEnvironmentState } from "../../state/use-remote-environment-registry";
11 13
12export default function NewTaskRoute() {14export default function NewTaskRoute() {
13 const { projects, threads } = useRemoteCatalog();15 const { projects, threads } = useRemoteCatalog();
14 const isDarkMode = useColorScheme() === "dark";16 const { savedConnectionsById } = useRemoteEnvironmentState();
15 const palette = makeAppPalette(isDarkMode);
16 const insets = useSafeAreaInsets();17 const insets = useSafeAreaInsets();
18 const chevronColor = useThemeColor("--color-chevron");
19 const borderSubtleColor = useThemeColor("--color-border-subtle");
17 const repositoryGroups = useMemo(20 const repositoryGroups = useMemo(
18 () => groupProjectsByRepository({ projects, threads }),21 () => groupProjectsByRepository({ projects, threads }),
19 [projects, threads],22 [projects, threads],
@@ -32,6 +35,7 @@ export default function NewTaskRoute() { · 12 unmodified lines
32 id: project.id,35 id: project.id,
33 key: group.key,36 key: group.key,
34 title: project.title,37 title: project.title,
38 workspaceRoot: project.workspaceRoot,
35 };39 };
36 })40 })
37 .filter((entry) => entry !== null),41 .filter((entry) => entry !== null),
@@ -39,34 +43,38 @@ export default function NewTaskRoute() { · 1 unmodified line
39 );43 );
40 44
41 return (45 return (
42 <View collapsable={false} style={{ flex: 1, backgroundColor: palette.sheetBackground }}>46 <View collapsable={false} className="flex-1 bg-sheet">
43 <Stack.Screen options={{ title: "Choose Project" }} />47 <Stack.Screen options={{ headerShown: false }} />
48
49 <View style={{ minHeight: 16, paddingTop: 8 }} />
50
51 <View className="items-center gap-1 px-5 pb-3 pt-4">
52 <Text
53 className="text-[12px] font-t3-bold uppercase text-foreground-muted"
54 style={{ letterSpacing: 1 }}
55 >
56 New task
57 </Text>
58 <Text className="text-[28px] font-t3-bold">Choose project</Text>
59 </View>
60
44 <ScrollView61 <ScrollView
45 contentInsetAdjustmentBehavior="automatic"
46 showsVerticalScrollIndicator={false}62 showsVerticalScrollIndicator={false}
47 style={{ flex: 1 }}63 style={{ flex: 1 }}
48 contentContainerStyle={{64 contentContainerStyle={{
49 paddingHorizontal: 20,65 paddingHorizontal: 20,
50 paddingTop: 16,66 paddingTop: 8,
51 paddingBottom: Math.max(insets.bottom, 18) + 18,67 paddingBottom: Math.max(insets.bottom, 18) + 18,
52 }}68 }}
53 >69 >
54 {items.length === 0 ? (70 {items.length === 0 ? (
55 <View71 <View collapsable={false} className="items-center rounded-[24px] bg-card px-6 py-8">
56 collapsable={false}72 <Text className="text-[16px] font-medium text-foreground-muted">
57 className="items-center rounded-[24px] px-6 py-8"73 Loading projects...
58 style={{ backgroundColor: palette.card }}
59 >
60 <Text className="text-[16px] font-medium" style={{ color: palette.textMuted }}>
61 Loading projects…
62 </Text>74 </Text>
63 </View>75 </View>
64 ) : (76 ) : (
65 <View77 <View collapsable={false} className="overflow-hidden rounded-[24px] bg-card">
66 collapsable={false}
67 className="overflow-hidden rounded-[24px]"
68 style={{ backgroundColor: palette.card }}
69 >
70 {items.map((item, index) => {78 {items.map((item, index) => {
71 const isFirst = index === 0;79 const isFirst = index === 0;
72 const isLast = index === items.length - 1;80 const isLast = index === items.length - 1;
@@ -85,12 +93,12 @@ export default function NewTaskRoute() { · 12 unmodified lines
85 asChild93 asChild
86 >94 >
87 <Pressable95 <Pressable
96 className="bg-card"
88 style={{97 style={{
89 paddingHorizontal: 16,98 paddingHorizontal: 16,
90 paddingVertical: 18,99 paddingVertical: 18,
91 borderTopWidth: isFirst ? 0 : 1,100 borderTopWidth: isFirst ? 0 : 1,
92 borderTopColor: palette.borderSubtle,101 borderTopColor: borderSubtleColor,
93 backgroundColor: palette.card,
94 borderTopLeftRadius: isFirst ? 24 : 0,102 borderTopLeftRadius: isFirst ? 24 : 0,
95 borderTopRightRadius: isFirst ? 24 : 0,103 borderTopRightRadius: isFirst ? 24 : 0,
96 borderBottomLeftRadius: isLast ? 24 : 0,104 borderBottomLeftRadius: isLast ? 24 : 0,
@@ -98,15 +106,20 @@ export default function NewTaskRoute() { · 1 unmodified line
98 }}106 }}
99 >107 >
100 <View className="flex-row items-center justify-between gap-3">108 <View className="flex-row items-center justify-between gap-3">
109 <ProjectFavicon
110 size={22}
111 projectTitle={item.title}
112 httpBaseUrl={savedConnectionsById[item.environmentId]?.httpBaseUrl ?? null}
113 workspaceRoot={item.workspaceRoot}
114 bearerToken={savedConnectionsById[item.environmentId]?.bearerToken ?? null}
115 />
101 <View className="flex-1">116 <View className="flex-1">
102 <Text className="text-[18px] font-t3-bold" style={{ color: palette.text }}>117 <Text className="text-[18px] font-t3-bold">{item.title}</Text>
103 {item.title}
104 </Text>
105 </View>118 </View>
106 <SymbolView119 <SymbolView
107 name="chevron.right"120 name="chevron.right"
108 size={14}121 size={14}
109 tintColor={palette.chevronColor}122 tintColor={chevronColor}
110 type="monochrome"123 type="monochrome"
111 />124 />
112 </View>125 </View>
apps/mobile/src/app/threads/[environmentId]/[threadId].tsx+0−5
@@ -1,5 +0,0 @@
1import { ThreadRouteScreen } from "../../../features/threads/ThreadRouteScreen";
2
3export default function ThreadRoute() {
4 return <ThreadRouteScreen />;
5}
apps/mobile/src/app/threads/[environmentId]/[threadId]/_layout.tsx+43−0
@@ -0,0 +1,43 @@
1import Stack from "expo-router/stack";
2import { StyleSheet } from "react-native";
3import { useResolveClassNames } from "uniwind";
4
5export default function ThreadLayout() {
6 const sheetStyle = StyleSheet.flatten(useResolveClassNames("bg-sheet"));
7
8 return (
9 <Stack screenOptions={{ headerShown: false }}>
10 <Stack.Screen
11 name="index"
12 options={{
13 contentStyle: { backgroundColor: "transparent" },
14 headerShown: true,
15 headerTransparent: true,
16 headerShadowVisible: false,
17 }}
18 />
19 <Stack.Screen
20 name="git"
21 options={{
22 contentStyle: sheetStyle,
23 gestureEnabled: true,
24 headerShown: false,
25 presentation: "formSheet" as const,
26 sheetAllowedDetents: [0.85],
27 sheetGrabberVisible: true,
28 }}
29 />
30 <Stack.Screen
31 name="git-confirm"
32 options={{
33 contentStyle: sheetStyle,
34 gestureEnabled: true,
35 headerShown: false,
36 presentation: "formSheet" as const,
37 sheetAllowedDetents: [0.4],
38 sheetGrabberVisible: true,
39 }}
40 />
41 </Stack>
42 );
43}
apps/mobile/src/app/threads/[environmentId]/[threadId]/git-confirm.tsx+5−0
@@ -0,0 +1,5 @@
1import { GitConfirmSheet } from "../../../../features/threads/git/GitConfirmSheet";
2
3export default function GitConfirmRoute() {
4 return <GitConfirmSheet />;
5}
apps/mobile/src/app/threads/[environmentId]/[threadId]/git/_layout.tsx+45−0
@@ -0,0 +1,45 @@
1import Stack from "expo-router/stack";
2import { StyleSheet } from "react-native";
3import { useResolveClassNames } from "uniwind";
4
5export const unstable_settings = {
6 anchor: "index",
7};
8
9export default function GitSheetLayout() {
10 const sheetStyle = StyleSheet.flatten(useResolveClassNames("bg-sheet"));
11 const headerBg = { backgroundColor: (sheetStyle as { backgroundColor?: string })?.backgroundColor };
12
13 return (
14 <Stack
15 screenOptions={{
16 contentStyle: sheetStyle,
17 headerShown: false,
18 }}
19 >
20 <Stack.Screen name="index" options={{ animation: "none" }} />
21 <Stack.Screen
22 name="commit"
23 options={{
24 animation: "slide_from_right",
25 headerShown: true,
26 headerTitle: "Commit changes",
27 headerBackTitle: "",
28 headerShadowVisible: false,
29 headerStyle: headerBg,
30 }}
31 />
32 <Stack.Screen
33 name="branches"
34 options={{
35 animation: "slide_from_right",
36 headerShown: true,
37 headerTitle: "Branches & worktrees",
38 headerBackTitle: "",
39 headerShadowVisible: false,
40 headerStyle: headerBg,
41 }}
42 />
43 </Stack>
44 );
45}
apps/mobile/src/app/threads/[environmentId]/[threadId]/git/branches.tsx+5−0
@@ -0,0 +1,5 @@
1import { GitBranchesSheet } from "../../../../../features/threads/git/GitBranchesSheet";
2
3export default function GitBranchesRoute() {
4 return <GitBranchesSheet />;
5}
apps/mobile/src/app/threads/[environmentId]/[threadId]/git/commit.tsx+5−0
@@ -0,0 +1,5 @@
1import { GitCommitSheet } from "../../../../../features/threads/git/GitCommitSheet";
2
3export default function GitCommitRoute() {
4 return <GitCommitSheet />;
5}
apps/mobile/src/app/threads/[environmentId]/[threadId]/git/index.tsx+5−0
@@ -0,0 +1,5 @@
1import { GitOverviewSheet } from "../../../../../features/threads/git/GitOverviewSheet";
2
3export default function GitRoute() {
4 return <GitOverviewSheet />;
5}
apps/mobile/src/app/threads/[environmentId]/[threadId]/index.tsx+5−0
@@ -0,0 +1,5 @@
1import { ThreadRouteScreen } from "../../../../features/threads/ThreadRouteScreen";
2
3export default function ThreadRoute() {
4 return <ThreadRouteScreen />;
5}
apps/mobile/src/components/AppText.tsx+21−299
@@ -1,318 +1,40 @@
1import {1import {
2 Text as RNText,2 Text as RNText,
3 type StyleProp,
4 TextInput as RNTextInput,3 TextInput as RNTextInput,
5 type TextInputProps as RNTextInputProps,4 type TextInputProps as RNTextInputProps,
6 type TextProps as RNTextProps,5 type TextProps as RNTextProps,
7 type TextStyle,
8 useColorScheme,
9 type ViewStyle,
10} from "react-native";6} from "react-native";
7import { useThemeColor } from "../lib/useThemeColor";
11 8
12type ClassNameProp = {9import { cn } from "../lib/cn";
13 readonly className?: string;
14};
15 10
16const colorMap = {11export type AppTextProps = RNTextProps & { readonly className?: string };
17 "text-white": "#ffffff",
18 "text-neutral-50": "#fafafa",
19 "text-neutral-300": "#d4d4d4",
20 "text-neutral-400": "#a3a3a3",
21 "text-neutral-500": "#737373",
22 "text-neutral-600": "#525252",
23 "text-neutral-700": "#404040",
24 "text-neutral-950": "#0a0a0a",
25 "text-orange-300": "#fdba74",
26 "text-orange-600": "#ea580c",
27 "text-orange-700": "#c2410c",
28 "text-emerald-300": "#6ee7b7",
29 "text-emerald-700": "#047857",
30 "text-rose-300": "#fda4af",
31 "text-rose-700": "#be123c",
32 "text-sky-300": "#7dd3fc",
33 "text-sky-700": "#0369a1",
34} satisfies Record<string, string>;
35 12
36const backgroundColorMap = {13/**
37 "bg-white": "#ffffff",14 * Thin wrapper around RN Text with default font-family and foreground color.
38 "bg-neutral-900": "#171717",15 * Uses Uniwind className — no manual style parsing.
39 "bg-neutral-950/70": "rgba(10, 10, 10, 0.7)",16 */
40} satisfies Record<string, string>;17export function AppText({ className, ...props }: AppTextProps) {
41 18 return <RNText className={cn("font-sans text-foreground", className)} {...props} />;
42const borderColorMap = {
43 "border-neutral-200": "#e5e5e5",
44 "border-white/8": "rgba(255, 255, 255, 0.08)",
45} satisfies Record<string, string>;
46
47function activeToken(token: string, isDarkMode: boolean) {
48 if (!token.startsWith("dark:")) {
49 return token;
50 }
51
52 return isDarkMode ? token.slice(5) : null;
53}
54
55function resolveTextStyle(className: string | undefined, isDarkMode: boolean): TextStyle {
56 const style: TextStyle = {
57 color: isDarkMode ? "#f5f5f5" : "#262626",
58 fontFamily: "DMSans_400Regular",
59 };
60
61 let hasLeadingNone = false;
62 for (const rawToken of className?.split(/\s+/) ?? []) {
63 const token = activeToken(rawToken, isDarkMode);
64 if (!token) {
65 continue;
66 }
67
68 if (token in colorMap) {
69 style.color = colorMap[token as keyof typeof colorMap];
70 continue;
71 }
72
73 if (token === "font-sans") {
74 style.fontFamily = "DMSans_400Regular";
75 continue;
76 }
77
78 if (token === "font-medium") {
79 style.fontFamily = "DMSans_500Medium";
80 continue;
81 }
82
83 if (token === "font-bold") {
84 style.fontFamily = "DMSans_700Bold";
85 continue;
86 }
87
88 if (token === "font-extrabold") {
89 style.fontFamily = "DMSans_700Bold";
90 continue;
91 }
92
93 if (token === "font-t3-medium") {
94 style.fontFamily = "DMSans_500Medium";
95 continue;
96 }
97
98 if (token === "font-t3-bold") {
99 style.fontFamily = "DMSans_700Bold";
100 continue;
101 }
102
103 if (token === "font-t3-extrabold") {
104 style.fontFamily = "DMSans_700Bold";
105 continue;
106 }
107
108 if (token === "text-xs") {
109 style.fontSize = 12;
110 continue;
111 }
112
113 if (token === "text-sm") {
114 style.fontSize = 14;
115 continue;
116 }
117
118 if (token === "text-lg") {
119 style.fontSize = 18;
120 continue;
121 }
122
123 if (token === "leading-none") {
124 hasLeadingNone = true;
125 continue;
126 }
127
128 if (token === "leading-5") {
129 style.lineHeight = 20;
130 continue;
131 }
132
133 if (token === "uppercase") {
134 style.textTransform = "uppercase";
135 continue;
136 }
137
138 if (token === "capitalize") {
139 style.textTransform = "capitalize";
140 continue;
141 }
142
143 if (token === "mt-2") {
144 style.marginTop = 8;
145 continue;
146 }
147
148 if (token === "pt-1.5") {
149 style.paddingTop = 6;
150 continue;
151 }
152
153 if (token === "pb-1") {
154 style.paddingBottom = 4;
155 continue;
156 }
157
158 if (token === "max-w-[640px]") {
159 style.maxWidth = 640;
160 continue;
161 }
162
163 const pxValue = token.match(/^text-\[(\d+)px\]$/);
164 if (pxValue) {
165 style.fontSize = Number(pxValue[1]);
166 continue;
167 }
168
169 const lineHeightValue = token.match(/^leading-\[(\d+)px\]$/);
170 if (lineHeightValue) {
171 style.lineHeight = Number(lineHeightValue[1]);
172 continue;
173 }
174
175 const trackingValue = token.match(/^tracking-\[([0-9.]+)px\]$/);
176 if (trackingValue) {
177 style.letterSpacing = Number(trackingValue[1]);
178 }
179 }
180
181 if (hasLeadingNone) {
182 style.lineHeight = style.fontSize ?? 16;
183 }
184
185 return style;
186}
187
188function resolveTextInputStyle(
189 className: string | undefined,
190 isDarkMode: boolean,
191): ViewStyle & TextStyle {
192 const style: ViewStyle & TextStyle = {
193 color: isDarkMode ? "#f5f5f5" : "#262626",
194 backgroundColor: isDarkMode ? "#141414" : "#ffffff",
195 borderColor: isDarkMode ? "rgba(255, 255, 255, 0.08)" : "#e5e5e5",
196 borderWidth: 1,
197 borderRadius: 16,
198 paddingHorizontal: 14,
199 paddingVertical: 12,
200 fontSize: 15,
201 fontFamily: "DMSans_400Regular",
202 minHeight: 54,
203 };
204
205 for (const rawToken of className?.split(/\s+/) ?? []) {
206 const token = activeToken(rawToken, isDarkMode);
207 if (!token) {
208 continue;
209 }
210
211 if (token in colorMap) {
212 style.color = colorMap[token as keyof typeof colorMap];
213 continue;
214 }
215
216 if (token in backgroundColorMap) {
217 style.backgroundColor = backgroundColorMap[token as keyof typeof backgroundColorMap];
218 continue;
219 }
220
221 if (token in borderColorMap) {
222 style.borderColor = borderColorMap[token as keyof typeof borderColorMap];
223 continue;
224 }
225
226 if (token === "border") {
227 style.borderWidth = 1;
228 continue;
229 }
230
231 if (token === "rounded-2xl") {
232 style.borderRadius = 16;
233 continue;
234 }
235
236 if (token === "rounded-[18px]") {
237 style.borderRadius = 18;
238 continue;
239 }
240
241 if (token === "px-3.5") {
242 style.paddingHorizontal = 14;
243 continue;
244 }
245
246 if (token === "py-3") {
247 style.paddingVertical = 12;
248 continue;
249 }
250
251 if (token === "py-3.5") {
252 style.paddingVertical = 14;
253 continue;
254 }
255
256 if (token === "font-sans") {
257 style.fontFamily = "DMSans_400Regular";
258 continue;
259 }
260
261 if (token === "text-sm") {
262 style.fontSize = 14;
263 continue;
264 }
265
266 const minHeightValue = token.match(/^min-h-\[(\d+)px\]$/);
267 if (minHeightValue) {
268 style.minHeight = Number(minHeightValue[1]);
269 continue;
270 }
271
272 const maxHeightValue = token.match(/^max-h-\[(\d+)px\]$/);
273 if (maxHeightValue) {
274 style.maxHeight = Number(maxHeightValue[1]);
275 continue;
276 }
277
278 const textPxValue = token.match(/^text-\[(\d+)px\]$/);
279 if (textPxValue) {
280 style.fontSize = Number(textPxValue[1]);
281 continue;
282 }
283
284 const lineHeightValue = token.match(/^leading-\[(\d+)px\]$/);
285 if (lineHeightValue) {
286 style.lineHeight = Number(lineHeightValue[1]);
287 }
288 }
289
290 return style;
291}
292
293export type AppTextProps = RNTextProps & ClassNameProp;
294
295export function AppText({ className, style, ...props }: AppTextProps) {
296 const isDarkMode = useColorScheme() === "dark";
297 return <RNText {...props} style={[resolveTextStyle(className, isDarkMode), style]} />;
298}19}
299 20
300export type AppTextInputProps = RNTextInputProps & ClassNameProp;21export type AppTextInputProps = RNTextInputProps & { readonly className?: string };
301 22
302export function AppTextInput({23/**
303 className,24 * Thin wrapper around RN TextInput with default input styling.
304 placeholderTextColor,25 * Uses Uniwind className — no manual style parsing.
305 style,26 */
306 ...props27export function AppTextInput({ className, placeholderTextColor, ...props }: AppTextInputProps) {
307}: AppTextInputProps) {28 const placeholderColor = useThemeColor("--color-placeholder");
308 const isDarkMode = useColorScheme() === "dark";
309 const resolvedStyle = resolveTextInputStyle(className, isDarkMode);
310 29
311 return (30 return (
312 <RNTextInput31 <RNTextInput
32 className={cn(
33 "min-h-[54px] rounded-2xl border border-input-border bg-input px-3.5 py-3 font-sans text-[15px] text-foreground",
34 className,
35 )}
36 placeholderTextColor={placeholderTextColor ?? placeholderColor}
313 {...props}37 {...props}
314 placeholderTextColor={placeholderTextColor ?? (isDarkMode ? "#737373" : "#a3a3a3")}
315 style={[resolvedStyle as StyleProp<TextStyle>, style]}
316 />38 />
317 );39 );
318}40}
apps/mobile/src/components/BrandMark.tsx+7−15
@@ -1,13 +1,10 @@
1import { Image, View, useColorScheme } from "react-native";1import { Image, View } from "react-native";
2 2
3import { makeAppPalette } from "../lib/theme";
4import { AppText as Text } from "./AppText";3import { AppText as Text } from "./AppText";
5 4
6const BRAND_MARK_SOURCE = require("../../../../assets/dev/blueprint-ios-1024.png");5const BRAND_MARK_SOURCE = require("../../../../assets/dev/blueprint-ios-1024.png");
7 6
8export function BrandMark(props: { readonly compact?: boolean; readonly stageLabel?: string }) {7export function BrandMark(props: { readonly compact?: boolean; readonly stageLabel?: string }) {
9 const isDarkMode = useColorScheme() === "dark";
10 const palette = makeAppPalette(isDarkMode);
11 const compact = props.compact ?? false;8 const compact = props.compact ?? false;
12 const iconSize = compact ? 32 : 44;9 const iconSize = compact ? 32 : 44;
13 const stageLabel = props.stageLabel ?? "Alpha";10 const stageLabel = props.stageLabel ?? "Alpha";
@@ -26,27 +23,22 @@ export function BrandMark(props: { readonly compact?: boolean; readonly stageLab · 12 unmodified lines
26 <View className="gap-1">23 <View className="gap-1">
27 <View className="flex-row items-center gap-2">24 <View className="flex-row items-center gap-2">
28 <Text25 <Text
29 className="text-[17px] font-t3-bold"26 className="text-[17px] font-t3-bold text-foreground"
30 style={{ color: palette.text, letterSpacing: -0.4 }}27 style={{ letterSpacing: -0.4 }}
31 >28 >
32 T3 Code29 T3 Code
33 </Text>30 </Text>
34 <View31 <View className="rounded-full bg-subtle px-2 py-1">
35 className="rounded-full px-2 py-1"
36 style={{
37 backgroundColor: palette.subtleBg,
38 }}
39 >
40 <Text32 <Text
41 className="text-[10px] font-t3-bold uppercase"33 className="text-[10px] font-t3-bold uppercase text-foreground-muted"
42 style={{ color: palette.textMuted, letterSpacing: 1.1 }}34 style={{ letterSpacing: 1.1 }}
43 >35 >
44 {stageLabel}36 {stageLabel}
45 </Text>37 </Text>
46 </View>38 </View>
47 </View>39 </View>
48 {!compact ? (40 {!compact ? (
49 <Text className="text-[12px] font-medium" style={{ color: palette.textMuted }}>41 <Text className="text-[12px] font-medium text-foreground-muted">
50 Mobile control surface for your live coding environments42 Mobile control surface for your live coding environments
51 </Text>43 </Text>
52 ) : null}44 ) : null}
apps/mobile/src/components/ComposerAttachmentStrip.tsx+4−5
@@ -1,8 +1,8 @@
1import { SymbolView } from "expo-symbols";1import { SymbolView } from "expo-symbols";
2import { Image, Pressable, ScrollView, useColorScheme, View } from "react-native";2import { Image, Pressable, ScrollView, View } from "react-native";
3import { useThemeColor } from "../lib/useThemeColor";
3 4
4import type { DraftComposerImageAttachment } from "../lib/composerImages";5import type { DraftComposerImageAttachment } from "../lib/composerImages";
5import { makeAppPalette } from "../lib/theme";
6 6
7export interface ComposerAttachmentStripProps {7export interface ComposerAttachmentStripProps {
8 /** Attachment images to display. */8 /** Attachment images to display. */
@@ -22,8 +22,7 @@ export interface ComposerAttachmentStripProps { · 13 unmodified lines
22 * buttons. Used by both the thread composer and the new-task draft screen.22 * buttons. Used by both the thread composer and the new-task draft screen.
23 */23 */
24export function ComposerAttachmentStrip(props: ComposerAttachmentStripProps) {24export function ComposerAttachmentStrip(props: ComposerAttachmentStripProps) {
25 const isDarkMode = useColorScheme() === "dark";25 const subtleBg = useThemeColor("--color-subtle");
26 const palette = makeAppPalette(isDarkMode);
27 const size = props.imageSize ?? 72;26 const size = props.imageSize ?? 72;
28 const radius = props.imageBorderRadius ?? 16;27 const radius = props.imageBorderRadius ?? 16;
29 28
@@ -50,7 +49,7 @@ export function ComposerAttachmentStrip(props: ComposerAttachmentStripProps) { · 20 unmodified lines
50 width: size,49 width: size,
51 height: size,50 height: size,
52 borderRadius: radius,51 borderRadius: radius,
53 backgroundColor: palette.subtleBg,52 backgroundColor: subtleBg,
54 }}53 }}
55 resizeMode="cover"54 resizeMode="cover"
56 />55 />
apps/mobile/src/components/ControlPill.tsx+24−19
@@ -1,9 +1,9 @@
1import type { ComponentProps, ReactNode } from "react";1import type { ComponentProps, ReactNode } from "react";
2import { Pressable, useColorScheme, View } from "react-native";2import { Pressable, View } from "react-native";
3import { SymbolView } from "expo-symbols";3import { SymbolView } from "expo-symbols";
4import { useThemeColor } from "../lib/useThemeColor";
4 5
5import { AppText as Text } from "./AppText";6import { AppText as Text } from "./AppText";
6import { makeAppPalette } from "../lib/theme";
7 7
8export function ControlPill(props: {8export function ControlPill(props: {
9 readonly icon?: ComponentProps<typeof SymbolView>["name"];9 readonly icon?: ComponentProps<typeof SymbolView>["name"];
@@ -13,31 +13,36 @@ export function ControlPill(props: { · 3 unmodified lines
13 readonly variant?: "circle" | "pill" | "primary" | "danger";13 readonly variant?: "circle" | "pill" | "primary" | "danger";
14 readonly disabled?: boolean;14 readonly disabled?: boolean;
15}) {15}) {
16 const isDarkMode = useColorScheme() === "dark";
17 const palette = makeAppPalette(isDarkMode);
18 const variant = props.variant ?? "circle";16 const variant = props.variant ?? "circle";
17
18 const subtleBg = useThemeColor("--color-subtle");
19 const subtleBgStrong = useThemeColor("--color-subtle-strong");
20 const primaryBg = useThemeColor("--color-primary");
21 const dangerBg = useThemeColor("--color-danger");
22 const iconColor = useThemeColor("--color-icon");
23 const iconSubtle = useThemeColor("--color-icon-subtle");
24 const primaryFg = useThemeColor("--color-primary-foreground");
25 const dangerFg = useThemeColor("--color-danger-foreground");
26 const textColor = useThemeColor("--color-foreground");
27 const textMuted = useThemeColor("--color-foreground-muted");
28
19 const backgroundColor =29 const backgroundColor =
20 variant === "primary"30 variant === "primary"
21 ? props.disabled31 ? props.disabled
22 ? palette.subtleBgStrong32 ? subtleBgStrong
23 : palette.primaryButton33 : primaryBg
24 : variant === "danger"34 : variant === "danger"
25 ? palette.dangerButton35 ? dangerBg
26 : palette.subtleBg;36 : subtleBg;
27 const iconTintColor =37 const iconTintColor =
28 variant === "primary"38 variant === "primary"
29 ? props.disabled39 ? props.disabled
30 ? palette.iconSubtle40 ? iconSubtle
31 : palette.primaryButtonText41 : primaryFg
32 : variant === "danger"42 : variant === "danger"
33 ? palette.dangerText43 ? dangerFg
34 : palette.icon;44 : iconColor;
35 const textColor =45 const labelColor = variant === "primary" ? (props.disabled ? textMuted : primaryFg) : textColor;
36 variant === "primary"
37 ? props.disabled
38 ? palette.textMuted
39 : palette.primaryButtonText
40 : palette.text;
41 46
42 const isCircle =47 const isCircle =
43 variant === "circle" || variant === "danger" || (variant === "primary" && !props.label);48 variant === "circle" || variant === "danger" || (variant === "primary" && !props.label);
@@ -61,7 +66,7 @@ export function ControlPill(props: { · 17 unmodified lines
61 <SymbolView name={props.icon} size={16} tintColor={iconTintColor} type="monochrome" />66 <SymbolView name={props.icon} size={16} tintColor={iconTintColor} type="monochrome" />
62 ) : null}67 ) : null}
63 {props.label ? (68 {props.label ? (
64 <Text className="text-center text-[12px] font-t3-bold" style={{ color: textColor }}>69 <Text className="text-center text-[12px] font-t3-bold" style={{ color: labelColor }}>
65 {props.label}70 {props.label}
66 </Text>71 </Text>
67 ) : null}72 ) : null}
apps/mobile/src/components/EmptyState.tsx+4−5
@@ -1,13 +1,12 @@
1import { View } from "react-native";1import { View } from "react-native";
2 2
3import { AppText as Text } from "./AppText";3import { AppText as Text } from "./AppText";
4
4export function EmptyState(props: { readonly title: string; readonly detail: string }) {5export function EmptyState(props: { readonly title: string; readonly detail: string }) {
5 return (6 return (
6 <View className="rounded-[22px] border border-neutral-200 bg-white p-5 dark:border-white/6 dark:bg-neutral-900">7 <View className="rounded-[22px] border border-border bg-card p-5">
7 <Text className="font-t3-bold text-lg text-neutral-950 dark:text-neutral-50">8 <Text className="font-t3-bold text-lg text-foreground">{props.title}</Text>
8 {props.title}9 <Text className="mt-2 font-sans text-sm leading-[21px] text-foreground-muted">
9 </Text>
10 <Text className="mt-2 font-sans text-sm leading-[21px] text-neutral-600 dark:text-neutral-400">
11 {props.detail}10 {props.detail}
12 </Text>11 </Text>
13 </View>12 </View>
apps/mobile/src/components/LoadingScreen.tsx+7−9
@@ -1,28 +1,26 @@
1import { ActivityIndicator, StatusBar, View, useColorScheme } from "react-native";1import { ActivityIndicator, StatusBar, View, useColorScheme } from "react-native";
2import { useSafeAreaInsets } from "react-native-safe-area-context";2import { useSafeAreaInsets } from "react-native-safe-area-context";
3import { useThemeColor } from "../lib/useThemeColor";
3 4
4import { makeAppPalette } from "../lib/theme";
5import { AppText as Text } from "./AppText";5import { AppText as Text } from "./AppText";
6import { BrandMark } from "./BrandMark";6import { BrandMark } from "./BrandMark";
7 7
8export function LoadingScreen(props: { readonly message: string }) {8export function LoadingScreen(props: { readonly message: string }) {
9 const isDarkMode = useColorScheme() === "dark";9 const colorScheme = useColorScheme();
10 const palette = makeAppPalette(isDarkMode);10 const screenBg = useThemeColor("--color-screen");
11 const insets = useSafeAreaInsets();11 const insets = useSafeAreaInsets();
12 12
13 return (13 return (
14 <View style={{ flex: 1, backgroundColor: palette.screenBackground, paddingTop: insets.top }}>14 <View className="flex-1 bg-screen" style={{ paddingTop: insets.top }}>
15 <StatusBar15 <StatusBar
16 barStyle={palette.statusBarStyle}16 barStyle={colorScheme === "dark" ? "light-content" : "dark-content"}
17 backgroundColor={palette.screenBackground}17 backgroundColor={screenBg as string}
18 translucent18 translucent
19 />19 />
20 <View className="flex-1 items-center justify-center gap-5 px-6">20 <View className="flex-1 items-center justify-center gap-5 px-6">
21 <BrandMark compact />21 <BrandMark compact />
22 <ActivityIndicator size="large" />22 <ActivityIndicator size="large" />
23 <Text className="font-t3-bold text-lg text-neutral-950 dark:text-neutral-50">23 <Text className="font-t3-bold text-lg text-foreground">{props.message}</Text>
24 {props.message}
25 </Text>
26 </View>24 </View>
27 </View>25 </View>
28 );26 );
apps/mobile/src/components/ProjectFavicon.tsx+70−0
@@ -0,0 +1,70 @@
1import { SymbolView } from "expo-symbols";
2import { useState } from "react";
3import { Image, View } from "react-native";
4import { useThemeColor } from "../lib/useThemeColor";
5
6/* ─── Favicon cache (matches web pattern) ────────────────────────────── */
7const loadedFaviconUrls = new Set<string>();
8
9/* ─── Component ──────────────────────────────────────────────────────── */
10export function ProjectFavicon(props: {
11 readonly size?: number;
12 readonly projectTitle: string;
13 readonly httpBaseUrl?: string | null;
14 readonly workspaceRoot?: string | null;
15 readonly bearerToken?: string | null;
16}) {
17 const size = props.size ?? 42;
18 const iconMuted = useThemeColor("--color-icon-subtle");
19
20 const faviconUrl =
21 props.httpBaseUrl && props.workspaceRoot
22 ? `${props.httpBaseUrl}/api/project-favicon?cwd=${encodeURIComponent(props.workspaceRoot)}`
23 : null;
24
25 const [status, setStatus] = useState<"loading" | "loaded" | "error">(() =>
26 faviconUrl && loadedFaviconUrls.has(faviconUrl) ? "loaded" : "loading",
27 );
28
29 const showImage = faviconUrl && status === "loaded";
30
31 return (
32 <View
33 style={{
34 width: size,
35 height: size,
36 alignItems: "center",
37 justifyContent: "center",
38 }}
39 >
40 {/* Folder icon fallback (matches web's FolderIcon) */}
41 {!showImage ? (
42 <SymbolView name="folder.fill" size={size * 0.78} tintColor={iconMuted} type="monochrome" />
43 ) : null}
44
45 {/* Favicon image (hidden until loaded) */}
46 {faviconUrl ? (
47 <Image
48 source={{
49 uri: faviconUrl,
50 ...(props.bearerToken
51 ? { headers: { Authorization: `Bearer ${props.bearerToken}` } }
52 : {}),
53 }}
54 style={{
55 width: size,
56 height: size,
57 borderRadius: size * 0.16,
58 ...(showImage ? {} : { position: "absolute" as const, opacity: 0 }),
59 }}
60 resizeMode="contain"
61 onLoad={() => {
62 if (faviconUrl) loadedFaviconUrls.add(faviconUrl);
63 setStatus("loaded");
64 }}
65 onError={() => setStatus("error")}
66 />
67 ) : null}
68 </View>
69 );
70}
apps/mobile/src/components/StatusPill.tsx+3−3
@@ -1,7 +1,7 @@
1import { View } from "react-native";1import { View } from "react-native";
2 2
3import { AppText as Text } from "./AppText";3import { AppText as Text } from "./AppText";
4import { cx } from "../lib/classNames";4import { cn } from "../lib/cn";
5 5
6export interface StatusTone {6export interface StatusTone {
7 readonly label: string;7 readonly label: string;
@@ -17,14 +17,14 @@ export function StatusPill( · 9 unmodified lines
17 const size = props.size ?? "default";17 const size = props.size ?? "default";
18 return (18 return (
19 <View19 <View
20 className={cx(20 className={cn(
21 "rounded-full",21 "rounded-full",
22 size === "compact" ? "px-2.5 py-1" : "px-3 py-1.5",22 size === "compact" ? "px-2.5 py-1" : "px-3 py-1.5",
23 props.pillClassName,23 props.pillClassName,
24 )}24 )}
25 >25 >
26 <Text26 <Text
27 className={cx(27 className={cn(
28 "font-t3-bold",28 "font-t3-bold",
29 size === "compact" ? "text-[11px]" : "text-xs",29 size === "compact" ? "text-[11px]" : "text-xs",
30 props.textClassName,30 props.textClassName,
apps/mobile/src/features/connection/ConnectionSheet.tsx+0−415
@@ -1,415 +0,0 @@
1import { BottomSheetBackdrop, BottomSheetModal, BottomSheetView } from "@gorhom/bottom-sheet";
2import { useRouter } from "expo-router";
3import { SymbolView } from "expo-symbols";
4import { useCallback, useEffect, useRef, useState } from "react";
5import {
6 ActionSheetIOS,
7 Platform,
8 Pressable,
9 ScrollView,
10 View,
11 useColorScheme,
12} from "react-native";
13import { useSafeAreaInsets } from "react-native-safe-area-context";
14
15import type { ConnectedEnvironmentSummary } from "../../state/remote-runtime-types";
16import { AppText as Text, AppTextInput as TextInput } from "../../components/AppText";
17import { ConnectionStatusDot } from "./ConnectionStatusDot";
18import {
19 ConnectionSheetButton as SheetButton,
20 makeConnectionSheetPalette as makePalette,
21 type ConnectionSheetPalette,
22} from "./connection-sheet-shared";
23
24export interface ConnectionSheetProps {
25 readonly visible: boolean;
26 readonly connectedEnvironments: ReadonlyArray<ConnectedEnvironmentSummary>;
27 readonly onRequestClose: () => void;
28 readonly onUpdateEnvironment: (
29 environmentId: string,
30 updates: { readonly label: string; readonly displayUrl: string },
31 ) => Promise<void>;
32 readonly onRemoveEnvironment: (environmentId: string) => void;
33}
34
35// ---------------------------------------------------------------------------
36function EnvironmentCard(props: {
37 readonly environment: ConnectedEnvironmentSummary;
38 readonly palette: ConnectionSheetPalette;
39 readonly onPress: () => void;
40 readonly onRemove: (environmentId: string) => void;
41}) {
42 return (
43 <Pressable
44 className="flex-row items-center gap-3 rounded-[18px] px-4 py-3.5"
45 style={[
46 {
47 backgroundColor: props.palette.card,
48 borderWidth: 1,
49 borderColor: props.palette.border,
50 },
51 props.palette.cardShadow,
52 ]}
53 onPress={props.onPress}
54 >
55 <ConnectionStatusDot state={props.environment.connectionState} pulse={false} size={8} />
56
57 <View className="flex-1 gap-0.5">
58 <Text
59 className="text-[16px] font-t3-bold leading-[21px]"
60 style={{ color: props.palette.text }}
61 numberOfLines={1}
62 >
63 {props.environment.environmentLabel}
64 </Text>
65 <Text
66 className="text-[12px] leading-[16px]"
67 style={{ color: props.palette.muted }}
68 numberOfLines={1}
69 >
70 {props.environment.displayUrl}
71 </Text>
72 </View>
73
74 <Pressable
75 className="h-9 w-9 items-center justify-center rounded-[12px]"
76 style={{
77 backgroundColor: props.palette.dangerButton,
78 borderWidth: 1,
79 borderColor: props.palette.dangerBorder,
80 }}
81 onPress={(e) => {
82 e.stopPropagation();
83 props.onRemove(props.environment.environmentId);
84 }}
85 hitSlop={4}
86 >
87 <SymbolView name="trash" size={13} tintColor={props.palette.dangerText} type="monochrome" />
88 </Pressable>
89 </Pressable>
90 );
91}
92
93// ---------------------------------------------------------------------------
94// Edit backend (nested sheet)
95// ---------------------------------------------------------------------------
96
97function EnvironmentDetailSheet(props: {
98 readonly environment: ConnectedEnvironmentSummary | null;
99 readonly palette: ConnectionSheetPalette;
100 readonly bottomInset: number;
101 readonly onDismiss: () => void;
102 readonly onSave: (
103 environmentId: string,
104 updates: { readonly label: string; readonly displayUrl: string },
105 ) => void;
106 readonly onRemove: (environmentId: string) => void;
107}) {
108 const sheetRef = useRef<BottomSheetModal>(null);
109 const [label, setLabel] = useState("");
110 const [displayUrl, setDisplayUrl] = useState("");
111 const env = props.environment;
112
113 useEffect(() => {
114 const sheet = sheetRef.current;
115 if (!sheet) return;
116 if (env) {
117 setLabel(env.environmentLabel);
118 setDisplayUrl(env.displayUrl);
119 sheet.present();
120 } else {
121 sheet.dismiss();
122 }
123 }, [env]);
124
125 const renderBackdrop = useCallback(
126 (backdropProps: React.ComponentProps<typeof BottomSheetBackdrop>) => (
127 <BottomSheetBackdrop
128 {...backdropProps}
129 appearsOnIndex={0}
130 disappearsOnIndex={-1}
131 opacity={0.24}
132 pressBehavior="close"
133 />
134 ),
135 [],
136 );
137
138 const hasChanges =
139 env !== null && (label.trim() !== env.environmentLabel || displayUrl.trim() !== env.displayUrl);
140
141 return (
142 <BottomSheetModal
143 ref={sheetRef}
144 stackBehavior="push"
145 index={0}
146 snapPoints={["52%"]}
147 enableDynamicSizing={false}
148 enablePanDownToClose
149 backdropComponent={renderBackdrop}
150 onDismiss={props.onDismiss}
151 backgroundStyle={{ backgroundColor: props.palette.sheet }}
152 handleIndicatorStyle={{ backgroundColor: "rgba(120,113,108,0.32)" }}
153 >
154 <BottomSheetView style={{ flex: 1 }}>
155 <View className="gap-5 px-5 pt-1">
156 <View className="flex-row items-center gap-3">
157 {env ? (
158 <ConnectionStatusDot state={env.connectionState} pulse={false} size={8} />
159 ) : null}
160 <Text
161 className="text-[24px] font-t3-bold"
162 style={{ color: props.palette.text, letterSpacing: -0.2 }}
163 >
164 Edit backend
165 </Text>
166 </View>
167
168 <View className="gap-2">
169 <Text
170 className="text-[11px] font-t3-bold uppercase"
171 style={{ color: props.palette.muted, letterSpacing: 0.8 }}
172 >
173 Label
174 </Text>
175 <TextInput
176 autoCapitalize="words"
177 autoCorrect={false}
178 placeholder="My MacBook Pro"
179 placeholderTextColor={props.palette.placeholder}
180 value={label}
181 onChangeText={setLabel}
182 className="rounded-[14px] px-4 py-3.5 text-[15px]"
183 style={{
184 backgroundColor: props.palette.inputBackground,
185 borderWidth: 1,
186 borderColor: props.palette.inputBorder,
187 color: props.palette.text,
188 }}
189 />
190 </View>
191
192 <View className="gap-2">
193 <Text
194 className="text-[11px] font-t3-bold uppercase"
195 style={{ color: props.palette.muted, letterSpacing: 0.8 }}
196 >
197 Host
198 </Text>
199 <TextInput
200 autoCapitalize="none"
201 autoCorrect={false}
202 keyboardType="url"
203 placeholder="http://192.168.1.100:8080/"
204 placeholderTextColor={props.palette.placeholder}
205 value={displayUrl}
206 onChangeText={setDisplayUrl}
207 className="rounded-[14px] px-4 py-3.5 text-[15px]"
208 style={{
209 backgroundColor: props.palette.inputBackground,
210 borderWidth: 1,
211 borderColor: props.palette.inputBorder,
212 color: props.palette.text,
213 }}
214 />
215 </View>
216
217 <View
218 className="flex-row gap-3"
219 style={{ paddingBottom: Math.max(props.bottomInset, 16) }}
220 >
221 <View className="flex-1">
222 <SheetButton
223 icon="checkmark"
224 label="Save"
225 disabled={!hasChanges}
226 palette={props.palette}
227 tone="primary"
228 onPress={() => {
229 if (env) {
230 props.onSave(env.environmentId, {
231 label: label.trim(),
232 displayUrl: displayUrl.trim(),
233 });
234 props.onDismiss();
235 }
236 }}
237 />
238 </View>
239 <View className="flex-1">
240 <SheetButton
241 icon="xmark"
242 label="Cancel"
243 palette={props.palette}
244 tone="secondary"
245 onPress={props.onDismiss}
246 />
247 </View>
248 </View>
249 </View>
250 </BottomSheetView>
251 </BottomSheetModal>
252 );
253}
254
255// ---------------------------------------------------------------------------
256// Main connection sheet
257// ---------------------------------------------------------------------------
258
259export function ConnectionSheet(props: ConnectionSheetProps) {
260 const router = useRouter();
261 const insets = useSafeAreaInsets();
262 const isDarkMode = useColorScheme() === "dark";
263 const palette = makePalette(isDarkMode);
264
265 const [editingEnvironment, setEditingEnvironment] = useState<ConnectedEnvironmentSummary | null>(
266 null,
267 );
268
269 const hasEnvironments = props.connectedEnvironments.length > 0;
270
271 useEffect(() => {
272 if (!props.visible) {
273 setEditingEnvironment(null);
274 }
275 }, [props.visible]);
276
277 const handleAddPress = useCallback(() => {
278 if (Platform.OS === "ios") {
279 ActionSheetIOS.showActionSheetWithOptions(
280 {
281 cancelButtonIndex: 2,
282 options: ["Scan QR Code", "Enter Manually", "Cancel"],
283 title: "Add backend",
284 },
285 (buttonIndex) => {
286 console.log("[connections sheet] add action", buttonIndex);
287
288 if (buttonIndex === 0) {
289 router.push("/connections/new?mode=scan_qr");
290 return;
291 }
292
293 if (buttonIndex === 1) {
294 router.push("/connections/new");
295 }
296 },
297 );
298 return;
299 }
300
301 router.push("/connections/new");
302 }, [router]);
303
304 return (
305 <View style={{ flex: 1, backgroundColor: palette.sheet }}>
306 <ScrollView
307 contentInsetAdjustmentBehavior="automatic"
308 keyboardShouldPersistTaps="handled"
309 showsVerticalScrollIndicator={false}
310 contentContainerStyle={{
311 paddingHorizontal: 20,
312 paddingTop: 4,
313 paddingBottom: Math.max(insets.bottom, 16) + 12,
314 gap: 10,
315 }}
316 >
317 {/* Header */}
318 <View className="flex-row items-center justify-between pb-4 pt-1">
319 <View className="flex-1 gap-1">
320 <Text
321 className="text-[28px] font-t3-bold"
322 style={{ color: palette.text, letterSpacing: -0.3 }}
323 >
324 Backends
325 </Text>
326 <Text className="text-[14px] leading-[20px]" style={{ color: palette.muted }}>
327 Manage your connected environments.
328 </Text>
329 </View>
330
331 <Pressable
332 className="h-11 w-11 items-center justify-center rounded-full"
333 style={{
334 backgroundColor: palette.primaryButton,
335 ...Platform.select({
336 ios: {
337 shadowColor: "#000",
338 shadowOffset: { width: 0, height: 2 },
339 shadowOpacity: 0.12,
340 shadowRadius: 4,
341 },
342 android: { elevation: 3 },
343 }),
344 }}
345 onPress={handleAddPress}
346 >
347 <SymbolView
348 name="plus"
349 size={18}
350 tintColor={palette.primaryButtonText}
351 type="monochrome"
352 weight="semibold"
353 />
354 </Pressable>
355 </View>
356
357 {/* Backend list */}
358 {hasEnvironments ? (
359 props.connectedEnvironments.map((environment) => (
360 <EnvironmentCard
361 key={environment.environmentId}
362 environment={environment}
363 palette={palette}
364 onPress={() => setEditingEnvironment(environment)}
365 onRemove={props.onRemoveEnvironment}
366 />
367 ))
368 ) : (
369 <View
370 className="items-center gap-3 rounded-[20px] px-6 py-8"
371 style={{
372 backgroundColor: palette.card,
373 borderWidth: 1,
374 borderColor: palette.border,
375 }}
376 >
377 <View
378 className="h-12 w-12 items-center justify-center rounded-[16px]"
379 style={{ backgroundColor: palette.iconBg }}
380 >
381 <SymbolView
382 name="point.3.connected.trianglepath.dotted"
383 size={20}
384 tintColor={palette.accent}
385 type="monochrome"
386 />
387 </View>
388 <Text
389 className="text-center text-[14px] leading-[20px]"
390 style={{ color: palette.muted }}
391 >
392 No backends connected yet.{"\n"}Tap{" "}
393 <Text className="font-t3-bold" style={{ color: palette.text }}>
394 +
395 </Text>{" "}
396 to add one.
397 </Text>
398 </View>
399 )}
400 </ScrollView>
401
402 {/* Nested: Edit backend */}
403 <EnvironmentDetailSheet
404 environment={editingEnvironment}
405 palette={palette}
406 bottomInset={insets.bottom}
407 onDismiss={() => setEditingEnvironment(null)}
408 onSave={(environmentId, updates) => {
409 void props.onUpdateEnvironment(environmentId, updates);
410 }}
411 onRemove={props.onRemoveEnvironment}
412 />
413 </View>
414 );
415}
apps/mobile/src/features/connection/ConnectionsRouteScreen.tsx+40−95
@@ -1,15 +1,16 @@
1import { Link, Stack } from "expo-router";1import { Link, Stack } from "expo-router";
2import { SymbolView } from "expo-symbols";2import { SymbolView } from "expo-symbols";
3import { useCallback, useState } from "react";3import { useCallback, useState } from "react";
4import { Pressable, ScrollView, View, useColorScheme } from "react-native";4import { Pressable, ScrollView, View } from "react-native";
5import Animated, { FadeIn, FadeOut, LinearTransition } from "react-native-reanimated";5import Animated, { FadeIn, FadeOut, LinearTransition } from "react-native-reanimated";
6import { useSafeAreaInsets } from "react-native-safe-area-context";6import { useSafeAreaInsets } from "react-native-safe-area-context";
7import { useThemeColor } from "../../lib/useThemeColor";
7 8
8import { AppText as Text, AppTextInput as TextInput } from "../../components/AppText";9import { AppText as Text, AppTextInput as TextInput } from "../../components/AppText";
10import { cn } from "../../lib/cn";
9import { useRemoteConnections } from "../../state/use-remote-environment-registry";11import { useRemoteConnections } from "../../state/use-remote-environment-registry";
10import type { ConnectedEnvironmentSummary } from "../../state/remote-runtime-types";12import type { ConnectedEnvironmentSummary } from "../../state/remote-runtime-types";
11import { ConnectionStatusDot } from "./ConnectionStatusDot";13import { ConnectionStatusDot } from "./ConnectionStatusDot";
12import { makeConnectionSheetPalette as makePalette } from "./connection-sheet-shared";
13 14
14function EnvironmentRow(props: {15function EnvironmentRow(props: {
15 readonly environment: ConnectedEnvironmentSummary;16 readonly environment: ConnectedEnvironmentSummary;
@@ -20,11 +21,15 @@ function EnvironmentRow(props: { · 4 unmodified lines
20 environmentId: string,21 environmentId: string,
21 updates: { readonly label: string; readonly displayUrl: string },22 updates: { readonly label: string; readonly displayUrl: string },
22 ) => void;23 ) => void;
23 readonly palette: ReturnType<typeof makePalette>;
24}) {24}) {
25 const [label, setLabel] = useState(props.environment.environmentLabel);25 const [label, setLabel] = useState(props.environment.environmentLabel);
26 const [url, setUrl] = useState(props.environment.displayUrl);26 const [url, setUrl] = useState(props.environment.displayUrl);
27 27
28 const mutedColor = useThemeColor("--color-icon-subtle");
29 const placeholderColor = useThemeColor("--color-placeholder");
30 const primaryFg = useThemeColor("--color-primary-foreground");
31 const dangerFg = useThemeColor("--color-danger-foreground");
32
28 const handleSave = useCallback(() => {33 const handleSave = useCallback(() => {
29 props.onUpdate(props.environment.environmentId, {34 props.onUpdate(props.environment.environmentId, {
30 label: label.trim(),35 label: label.trim(),
@@ -34,26 +39,18 @@ function EnvironmentRow(props: { · 3 unmodified lines
34 }, [label, url, props]);39 }, [label, url, props]);
35 40
36 return (41 return (
37 <Animated.View42 <Animated.View layout={LinearTransition.duration(250)} className="bg-card">
38 layout={LinearTransition.duration(250)}
39 style={{ backgroundColor: props.palette.card }}
40 >
41 <Pressable className="flex-row items-center gap-3 px-4 py-3.5" onPress={props.onToggle}>43 <Pressable className="flex-row items-center gap-3 px-4 py-3.5" onPress={props.onToggle}>
42 <ConnectionStatusDot state={props.environment.connectionState} pulse={false} size={8} />44 <ConnectionStatusDot state={props.environment.connectionState} pulse={false} size={8} />
43 45
44 <View className="flex-1 gap-0.5">46 <View className="flex-1 gap-0.5">
45 <Text47 <Text
46 className="text-[16px] font-t3-bold leading-[21px]"48 className="text-[16px] font-t3-bold leading-[21px] text-foreground"
47 style={{ color: props.palette.text }}
48 numberOfLines={1}49 numberOfLines={1}
49 >50 >
50 {props.environment.environmentLabel}51 {props.environment.environmentLabel}
51 </Text>52 </Text>
52 <Text53 <Text className="text-[12px] leading-[16px] text-foreground-muted" numberOfLines={1}>
53 className="text-[12px] leading-[16px]"
54 style={{ color: props.palette.muted }}
55 numberOfLines={1}
56 >
57 {props.environment.displayUrl}54 {props.environment.displayUrl}
58 </Text>55 </Text>
59 </View>56 </View>
@@ -61,7 +58,7 @@ function EnvironmentRow(props: { · 1 unmodified line
61 <SymbolView58 <SymbolView
62 name="chevron.down"59 name="chevron.down"
63 size={12}60 size={12}
64 tintColor={props.palette.muted}61 tintColor={mutedColor}
65 type="monochrome"62 type="monochrome"
66 style={{63 style={{
67 transform: [{ rotate: props.expanded ? "180deg" : "0deg" }],64 transform: [{ rotate: props.expanded ? "180deg" : "0deg" }],
@@ -77,8 +74,8 @@ function EnvironmentRow(props: { · 9 unmodified lines
77 >74 >
78 <View className="gap-1.5">75 <View className="gap-1.5">
79 <Text76 <Text
80 className="text-[11px] font-t3-bold uppercase"77 className="text-[11px] font-t3-bold uppercase text-foreground-muted"
81 style={{ color: props.palette.muted, letterSpacing: 0.8 }}78 style={{ letterSpacing: 0.8 }}
82 >79 >
83 Label80 Label
84 </Text>81 </Text>
@@ -86,23 +83,17 @@ function EnvironmentRow(props: { · 1 unmodified line
86 autoCapitalize="words"83 autoCapitalize="words"
87 autoCorrect={false}84 autoCorrect={false}
88 placeholder="My MacBook"85 placeholder="My MacBook"
89 placeholderTextColor={props.palette.placeholder}86 placeholderTextColor={placeholderColor}
90 value={label}87 value={label}
91 onChangeText={setLabel}88 onChangeText={setLabel}
92 className="rounded-[14px] px-4 py-3 text-[15px]"89 className="rounded-[14px] border border-input-border bg-input px-4 py-3 text-[15px] text-foreground"
93 style={{
94 backgroundColor: props.palette.inputBackground,
95 borderWidth: 1,
96 borderColor: props.palette.inputBorder,
97 color: props.palette.text,
98 }}
99 />90 />
100 </View>91 </View>
101 92
102 <View className="gap-1.5">93 <View className="gap-1.5">
103 <Text94 <Text
104 className="text-[11px] font-t3-bold uppercase"95 className="text-[11px] font-t3-bold uppercase text-foreground-muted"
105 style={{ color: props.palette.muted, letterSpacing: 0.8 }}96 style={{ letterSpacing: 0.8 }}
106 >97 >
107 URL98 URL
108 </Text>99 </Text>
@@ -111,59 +102,32 @@ function EnvironmentRow(props: { · 2 unmodified lines
111 autoCorrect={false}102 autoCorrect={false}
112 keyboardType="url"103 keyboardType="url"
113 placeholder="192.168.1.100:8080"104 placeholder="192.168.1.100:8080"
114 placeholderTextColor={props.palette.placeholder}105 placeholderTextColor={placeholderColor}
115 value={url}106 value={url}
116 onChangeText={setUrl}107 onChangeText={setUrl}
117 className="rounded-[14px] px-4 py-3 text-[15px]"108 className="rounded-[14px] border border-input-border bg-input px-4 py-3 text-[15px] text-foreground"
118 style={{
119 backgroundColor: props.palette.inputBackground,
120 borderWidth: 1,
121 borderColor: props.palette.inputBorder,
122 color: props.palette.text,
123 }}
124 />109 />
125 </View>110 </View>
126 111
127 <View className="flex-row gap-2">112 <View className="flex-row gap-2">
128 <Pressable113 <Pressable
129 className="min-h-[42px] flex-1 flex-row items-center justify-center gap-1.5 rounded-[14px] px-3.5 py-2.5"114 className="min-h-[42px] flex-1 flex-row items-center justify-center gap-1.5 rounded-[14px] bg-primary px-3.5 py-2.5"
130 onPress={handleSave}115 onPress={handleSave}
131 style={{
132 backgroundColor: props.palette.primaryButton,
133 }}
134 >116 >
135 <SymbolView117 <SymbolView name="checkmark" size={13} tintColor={primaryFg} type="monochrome" />
136 name="checkmark"
137 size={13}
138 tintColor={props.palette.primaryButtonText}
139 type="monochrome"
140 />
141 <Text118 <Text
142 className="text-[12px] font-t3-bold uppercase"119 className="text-[12px] font-t3-bold uppercase text-primary-foreground"
143 style={{120 style={{ letterSpacing: 0.8 }}
144 color: props.palette.primaryButtonText,
145 letterSpacing: 0.8,
146 }}
147 >121 >
148 Save122 Save
149 </Text>123 </Text>
150 </Pressable>124 </Pressable>
151 125
152 <Pressable126 <Pressable
153 className="h-[42px] w-[42px] items-center justify-center rounded-[14px]"127 className="h-[42px] w-[42px] items-center justify-center rounded-[14px] border border-danger-border bg-danger"
154 onPress={() => props.onRemove(props.environment.environmentId)}128 onPress={() => props.onRemove(props.environment.environmentId)}
155 style={{
156 backgroundColor: props.palette.dangerButton,
157 borderWidth: 1,
158 borderColor: props.palette.dangerBorder,
159 }}
160 >129 >
161 <SymbolView130 <SymbolView name="trash" size={14} tintColor={dangerFg} type="monochrome" />
162 name="trash"
163 size={14}
164 tintColor={props.palette.dangerText}
165 type="monochrome"
166 />
167 </Pressable>131 </Pressable>
168 </View>132 </View>
169 </Animated.View>133 </Animated.View>
@@ -176,30 +140,29 @@ export function ConnectionsRouteScreen() { · 6 unmodified lines
176 const { connectedEnvironments, onRemoveEnvironmentPress, onUpdateEnvironment } =140 const { connectedEnvironments, onRemoveEnvironmentPress, onUpdateEnvironment } =
177 useRemoteConnections();141 useRemoteConnections();
178 const insets = useSafeAreaInsets();142 const insets = useSafeAreaInsets();
179 const isDarkMode = useColorScheme() === "dark";
180 const palette = makePalette(isDarkMode);
181 const hasEnvironments = connectedEnvironments.length > 0;143 const hasEnvironments = connectedEnvironments.length > 0;
182 const [expandedId, setExpandedId] = useState<string | null>(null);144 const [expandedId, setExpandedId] = useState<string | null>(null);
183 145
146 const primaryFg = useThemeColor("--color-primary-foreground");
147 const accentColor = useThemeColor("--color-icon-muted");
148 const mutedColor = useThemeColor("--color-icon-subtle");
149
184 const handleToggle = useCallback((environmentId: string) => {150 const handleToggle = useCallback((environmentId: string) => {
185 setExpandedId((prev) => (prev === environmentId ? null : environmentId));151 setExpandedId((prev) => (prev === environmentId ? null : environmentId));
186 }, []);152 }, []);
187 153
188 return (154 return (
189 <View collapsable={false} style={{ flex: 1, backgroundColor: palette.sheet }}>155 <View collapsable={false} className="flex-1 bg-sheet">
190 <Stack.Screen156 <Stack.Screen
191 options={{157 options={{
192 title: "Backends",158 title: "Backends",
193 headerRight: () => (159 headerRight: () => (
194 <Link href="/connections/new" asChild>160 <Link href="/connections/new" asChild>
195 <Pressable161 <Pressable className="h-10 w-10 items-center justify-center rounded-full bg-primary">
196 className="h-10 w-10 items-center justify-center rounded-full"
197 style={{ backgroundColor: palette.primaryButton }}
198 >
199 <SymbolView162 <SymbolView
200 name="plus"163 name="plus"
201 size={18}164 size={18}
202 tintColor={palette.primaryButtonText}165 tintColor={primaryFg}
203 type="monochrome"166 type="monochrome"
204 weight="semibold"167 weight="semibold"
205 />168 />
@@ -219,19 +182,15 @@ export function ConnectionsRouteScreen() { · 13 unmodified lines
219 }}182 }}
220 >183 >
221 {hasEnvironments ? (184 {hasEnvironments ? (
222 <View185 <View collapsable={false} className="overflow-hidden rounded-[24px] bg-card">
223 collapsable={false}
224 className="overflow-hidden rounded-[24px]"
225 style={{ backgroundColor: palette.card }}
226 >
227 {connectedEnvironments.map((environment, index) => (186 {connectedEnvironments.map((environment, index) => (
228 <View187 <View
229 key={environment.environmentId}188 key={environment.environmentId}
230 collapsable={false}189 collapsable={false}
231 style={{190 style={{
232 borderTopWidth: index === 0 ? 0 : 1,191 borderTopWidth: index === 0 ? 0 : 1,
233 borderTopColor: palette.border,
234 }}192 }}
193 className={cn(index !== 0 && "border-border")}
235 >194 >
236 <EnvironmentRow195 <EnvironmentRow
237 environment={environment}196 environment={environment}
@@ -239,37 +198,23 @@ export function ConnectionsRouteScreen() { · 1 unmodified line
239 onToggle={() => handleToggle(environment.environmentId)}198 onToggle={() => handleToggle(environment.environmentId)}
240 onRemove={onRemoveEnvironmentPress}199 onRemove={onRemoveEnvironmentPress}
241 onUpdate={onUpdateEnvironment}200 onUpdate={onUpdateEnvironment}
242 palette={palette}
243 />201 />
244 </View>202 </View>
245 ))}203 ))}
246 </View>204 </View>
247 ) : (205 ) : (
248 <View206 <View collapsable={false} className="items-center gap-3 rounded-[24px] bg-card px-6 py-8">
249 collapsable={false}207 <View className="h-12 w-12 items-center justify-center rounded-[16px] bg-subtle">
250 className="items-center gap-3 rounded-[24px] px-6 py-8"
251 style={{ backgroundColor: palette.card }}
252 >
253 <View
254 className="h-12 w-12 items-center justify-center rounded-[16px]"
255 style={{ backgroundColor: palette.iconBg }}
256 >
257 <SymbolView208 <SymbolView
258 name="point.3.connected.trianglepath.dotted"209 name="point.3.connected.trianglepath.dotted"
259 size={20}210 size={20}
260 tintColor={palette.accent}211 tintColor={accentColor}
261 type="monochrome"212 type="monochrome"
262 />213 />
263 </View>214 </View>
264 <Text215 <Text className="text-center text-[14px] leading-[20px] text-foreground-muted">
265 className="text-center text-[14px] leading-[20px]"
266 style={{ color: palette.muted }}
267 >
268 No backends connected yet.{"\n"}Tap{" "}216 No backends connected yet.{"\n"}Tap{" "}
269 <Text className="font-t3-bold" style={{ color: palette.text }}>217 <Text className="font-t3-bold text-foreground">+</Text> to add one.
270 +
271 </Text>{" "}
272 to add one.
273 </Text>218 </Text>
274 </View>219 </View>
275 )}220 )}
apps/mobile/src/features/connection/NewConnectionRouteScreen.tsx+22−47
@@ -2,18 +2,18 @@ import { CameraView, useCameraPermissions } from "expo-camera"; · 1 unmodified line
2import { Stack, useLocalSearchParams, useRouter } from "expo-router";2import { Stack, useLocalSearchParams, useRouter } from "expo-router";
3import { SymbolView } from "expo-symbols";3import { SymbolView } from "expo-symbols";
4import { useCallback, useEffect, useState } from "react";4import { useCallback, useEffect, useState } from "react";
5import { Alert, Pressable, ScrollView, View, useColorScheme } from "react-native";5import { Alert, Pressable, ScrollView, View } from "react-native";
6import { useSafeAreaInsets } from "react-native-safe-area-context";6import { useSafeAreaInsets } from "react-native-safe-area-context";
7import { useThemeColor } from "../../lib/useThemeColor";
7 8
8import { AppText as Text, AppTextInput as TextInput } from "../../components/AppText";9import { AppText as Text, AppTextInput as TextInput } from "../../components/AppText";
9import { ErrorBanner } from "../../components/ErrorBanner";10import { ErrorBanner } from "../../components/ErrorBanner";
10import { dismissRoute } from "../../lib/routes";11import { dismissRoute } from "../../lib/routes";
11import { extractPairingUrlFromQrPayload } from "../../lib/pairingQr";12import { extractPairingUrlFromQrPayload } from "./pairingQr";
12import { useRemoteConnections } from "../../state/use-remote-environment-registry";13import { useRemoteConnections } from "../../state/use-remote-environment-registry";
13import {14import {
14 buildPairingUrl,15 buildPairingUrl,
15 ConnectionSheetButton as SheetButton,16 ConnectionSheetButton as SheetButton,
16 makeConnectionSheetPalette as makePalette,
17 parsePairingUrl,17 parsePairingUrl,
18} from "./connection-sheet-shared";18} from "./connection-sheet-shared";
19 19
@@ -28,8 +28,6 @@ export function NewConnectionRouteScreen() { · 8 unmodified lines
28 const router = useRouter();28 const router = useRouter();
29 const params = useLocalSearchParams<{ mode?: string }>();29 const params = useLocalSearchParams<{ mode?: string }>();
30 const insets = useSafeAreaInsets();30 const insets = useSafeAreaInsets();
31 const isDarkMode = useColorScheme() === "dark";
32 const palette = makePalette(isDarkMode);
33 const [hostInput, setHostInput] = useState("");31 const [hostInput, setHostInput] = useState("");
34 const [codeInput, setCodeInput] = useState("");32 const [codeInput, setCodeInput] = useState("");
35 const [isSubmitting, setIsSubmitting] = useState(false);33 const [isSubmitting, setIsSubmitting] = useState(false);
@@ -37,6 +35,9 @@ export function NewConnectionRouteScreen() { · 1 unmodified line
37 const [cameraPermission, requestCameraPermission] = useCameraPermissions();35 const [cameraPermission, requestCameraPermission] = useCameraPermissions();
38 const [scannerLocked, setScannerLocked] = useState(false);36 const [scannerLocked, setScannerLocked] = useState(false);
39 37
38 const textColor = useThemeColor("--color-icon");
39 const placeholderColor = useThemeColor("--color-placeholder");
40
40 const connectDisabled =41 const connectDisabled =
41 isSubmitting || connectionState === "connecting" || hostInput.trim().length === 0;42 isSubmitting || connectionState === "connecting" || hostInput.trim().length === 0;
42 43
@@ -125,13 +126,13 @@ export function NewConnectionRouteScreen() { · 82 unmodified lines
125 }, [codeInput, hostInput, onChangeConnectionPairingUrl, onConnectPress, router]);126 }, [codeInput, hostInput, onChangeConnectionPairingUrl, onConnectPress, router]);
126 127
127 return (128 return (
128 <View collapsable={false} style={{ flex: 1, backgroundColor: palette.sheet }}>129 <View collapsable={false} className="flex-1 bg-sheet">
129 <Stack.Screen130 <Stack.Screen
130 options={{131 options={{
131 title: showScanner ? "Scan QR Code" : "Add Backend",132 title: showScanner ? "Scan QR Code" : "Add Backend",
132 headerRight: () => (133 headerRight: () => (
133 <Pressable134 <Pressable
134 className="h-10 w-10 items-center justify-center rounded-full"135 className="h-10 w-10 items-center justify-center rounded-full border border-border bg-secondary"
135 onPress={() => {136 onPress={() => {
136 if (showScanner) {137 if (showScanner) {
137 closeScanner();138 closeScanner();
@@ -139,16 +140,11 @@ export function NewConnectionRouteScreen() { · 1 unmodified line
139 void openScanner();140 void openScanner();
140 }141 }
141 }}142 }}
142 style={{
143 backgroundColor: palette.secondaryButton,
144 borderWidth: 1,
145 borderColor: palette.border,
146 }}
147 >143 >
148 <SymbolView144 <SymbolView
149 name={showScanner ? "xmark" : "qrcode.viewfinder"}145 name={showScanner ? "xmark" : "qrcode.viewfinder"}
150 size={showScanner ? 14 : 18}146 size={showScanner ? 14 : 18}
151 tintColor={palette.text}147 tintColor={textColor}
152 type="monochrome"148 type="monochrome"
153 weight="semibold"149 weight="semibold"
154 />150 />
@@ -182,20 +178,16 @@ export function NewConnectionRouteScreen() { · 27 unmodified lines
182 </View>178 </View>
183 ) : (179 ) : (
184 <View180 <View
185 className="items-center gap-3 rounded-[24px] px-5 py-8"181 className="items-center gap-3 rounded-[24px] bg-card px-5 py-8"
186 style={{ backgroundColor: palette.card, borderCurve: "continuous" }}182 style={{ borderCurve: "continuous" }}
187 >183 >
188 <Text184 <Text className="text-center text-[14px] leading-[20px] text-foreground-muted">
189 className="text-center text-[14px] leading-[20px]"
190 style={{ color: palette.muted }}
191 >
192 Camera permission is required to scan a QR code.185 Camera permission is required to scan a QR code.
193 </Text>186 </Text>
194 <SheetButton187 <SheetButton
195 compact188 compact
196 icon="camera"189 icon="camera"
197 label="Allow camera"190 label="Allow camera"
198 palette={palette}
199 tone="secondary"191 tone="secondary"
200 onPress={() => {192 onPress={() => {
201 void openScanner();193 void openScanner();
@@ -204,15 +196,11 @@ export function NewConnectionRouteScreen() { · 2 unmodified lines
204 </View>196 </View>
205 )197 )
206 ) : (198 ) : (
207 <View199 <View collapsable={false} className="gap-4 rounded-[24px] bg-card p-4">
208 collapsable={false}
209 className="gap-4 rounded-[24px] p-4"
210 style={{ backgroundColor: palette.card }}
211 >
212 <View collapsable={false} className="gap-1.5">200 <View collapsable={false} className="gap-1.5">
213 <Text201 <Text
214 className="text-[11px] font-t3-bold uppercase"202 className="text-[11px] font-t3-bold uppercase text-foreground-muted"
215 style={{ color: palette.muted, letterSpacing: 0.8 }}203 style={{ letterSpacing: 0.8 }}
216 >204 >
217 Host205 Host
218 </Text>206 </Text>
@@ -221,23 +209,17 @@ export function NewConnectionRouteScreen() { · 2 unmodified lines
221 autoCorrect={false}209 autoCorrect={false}
222 keyboardType="url"210 keyboardType="url"
223 placeholder="192.168.1.100:8080"211 placeholder="192.168.1.100:8080"
224 placeholderTextColor={palette.placeholder}212 placeholderTextColor={placeholderColor}
225 value={hostInput}213 value={hostInput}
226 onChangeText={handleHostChange}214 onChangeText={handleHostChange}
227 className="rounded-[14px] px-4 py-3.5 text-[15px]"215 className="rounded-[14px] border border-input-border bg-input px-4 py-3.5 text-[15px] text-foreground"
228 style={{
229 backgroundColor: palette.inputBackground,
230 borderWidth: 1,
231 borderColor: palette.inputBorder,
232 color: palette.text,
233 }}
234 />216 />
235 </View>217 </View>
236 218
237 <View collapsable={false} className="gap-1.5">219 <View collapsable={false} className="gap-1.5">
238 <Text220 <Text
239 className="text-[11px] font-t3-bold uppercase"221 className="text-[11px] font-t3-bold uppercase text-foreground-muted"
240 style={{ color: palette.muted, letterSpacing: 0.8 }}222 style={{ letterSpacing: 0.8 }}
241 >223 >
242 Pairing code224 Pairing code
243 </Text>225 </Text>
@@ -245,16 +227,10 @@ export function NewConnectionRouteScreen() { · 1 unmodified line
245 autoCapitalize="none"227 autoCapitalize="none"
246 autoCorrect={false}228 autoCorrect={false}
247 placeholder="abc-123-xyz"229 placeholder="abc-123-xyz"
248 placeholderTextColor={palette.placeholder}230 placeholderTextColor={placeholderColor}
249 value={codeInput}231 value={codeInput}
250 onChangeText={handleCodeChange}232 onChangeText={handleCodeChange}
251 className="rounded-[14px] px-4 py-3.5 text-[15px]"233 className="rounded-[14px] border border-input-border bg-input px-4 py-3.5 text-[15px] text-foreground"
252 style={{
253 backgroundColor: palette.inputBackground,
254 borderWidth: 1,
255 borderColor: palette.inputBorder,
256 color: palette.text,
257 }}
258 />234 />
259 </View>235 </View>
260 236
@@ -263,10 +239,9 @@ export function NewConnectionRouteScreen() { · 2 unmodified lines
263 <SheetButton239 <SheetButton
264 icon="plus"240 icon="plus"
265 label={241 label={
266 isSubmitting || connectionState === "connecting" ? "Pairing…" : "Add backend"242 isSubmitting || connectionState === "connecting" ? "Pairing..." : "Add backend"
267 }243 }
268 disabled={connectDisabled}244 disabled={connectDisabled}
269 palette={palette}
270 tone="primary"245 tone="primary"
271 onPress={() => {246 onPress={() => {
272 void handleSubmit();247 void handleSubmit();
apps/mobile/src/features/connection/connection-sheet-shared.tsx+42−77
@@ -1,7 +1,9 @@
1import { SymbolView } from "expo-symbols";1import { SymbolView } from "expo-symbols";
2import { Platform, Pressable } from "react-native";2import { Platform, Pressable } from "react-native";
3import { useThemeColor } from "../../lib/useThemeColor";
3 4
4import { AppText as Text } from "../../components/AppText";5import { AppText as Text } from "../../components/AppText";
6import { cn } from "../../lib/cn";
5 7
6export function parsePairingUrl(url: string): { host: string; code: string } {8export function parsePairingUrl(url: string): { host: string; code: string } {
7 const trimmed = url.trim();9 const trimmed = url.trim();
@@ -38,73 +40,27 @@ export function buildPairingUrl(host: string, code: string): string { · 30 unmodified lines
38 }40 }
39}41}
40 42
41export function makeConnectionSheetPalette(isDarkMode: boolean) {43const CARD_SHADOW = Platform.select({
42 if (isDarkMode) {44 ios: {
43 return {45 shadowColor: "rgba(23,23,23,0.08)",
44 sheet: "rgba(18,20,23,0.98)",46 shadowOffset: { width: 0, height: 4 },
45 card: "#1f2329",47 shadowOpacity: 1,
46 cardAlt: "#242a31",48 shadowRadius: 16,
47 border: "rgba(255,255,255,0.06)",49 },
48 text: "#f8fafc",50 android: { elevation: 3 },
49 muted: "#94a3b8",51});
50 subtle: "#cbd5e1",
51 accent: "#f59e0b",
52 accentSubtle: "rgba(245,158,11,0.12)",
53 iconBg: "rgba(245,158,11,0.10)",
54 inputBackground: "#171b20",
55 inputBorder: "rgba(255,255,255,0.06)",
56 primaryButton: "#f8fafc",
57 primaryButtonText: "#171717",
58 secondaryButton: "rgba(255,255,255,0.05)",
59 secondaryButtonText: "#f8fafc",
60 dangerButton: "rgba(190,24,93,0.14)",
61 dangerBorder: "rgba(244,114,182,0.18)",
62 dangerText: "#fda4af",
63 placeholder: "#64748b",
64 cardShadow: {
65 shadowColor: "#000",
66 shadowOffset: { width: 0, height: 2 },
67 shadowOpacity: 0.18,
68 shadowRadius: 8,
69 elevation: 4,
70 } as const,
71 separator: "rgba(255,255,255,0.04)",
72 };
73 }
74 52
75 return {53const CARD_SHADOW_DARK = Platform.select({
76 sheet: "rgba(246,244,239,0.98)",54 ios: {
77 card: "#fffdf9",55 shadowColor: "#000",
78 cardAlt: "#f8f4ec",56 shadowOffset: { width: 0, height: 2 },
79 border: "rgba(23,23,23,0.06)",57 shadowOpacity: 0.18,
80 text: "#171717",58 shadowRadius: 8,
81 muted: "#78716c",59 },
82 subtle: "#57534e",60 android: { elevation: 4 },
83 accent: "#a16207",61});
84 accentSubtle: "rgba(161,98,7,0.08)",
85 iconBg: "rgba(161,98,7,0.07)",
86 inputBackground: "#ffffff",
87 inputBorder: "rgba(23,23,23,0.06)",
88 primaryButton: "#171717",
89 primaryButtonText: "#fafaf9",
90 secondaryButton: "#f1ece3",
91 secondaryButtonText: "#171717",
92 dangerButton: "#fff1f2",
93 dangerBorder: "rgba(225,29,72,0.10)",
94 dangerText: "#be123c",
95 placeholder: "#94a3b8",
96 cardShadow: {
97 shadowColor: "rgba(23,23,23,0.08)",
98 shadowOffset: { width: 0, height: 4 },
99 shadowOpacity: 1,
100 shadowRadius: 16,
101 elevation: 3,
102 } as const,
103 separator: "rgba(23,23,23,0.04)",
104 };
105}
106 62
107export type ConnectionSheetPalette = ReturnType<typeof makeConnectionSheetPalette>;63export { CARD_SHADOW, CARD_SHADOW_DARK };
108 64
109export function ConnectionSheetButton(props: {65export function ConnectionSheetButton(props: {
110 readonly icon: React.ComponentProps<typeof SymbolView>["name"];66 readonly icon: React.ComponentProps<typeof SymbolView>["name"];
@@ -112,27 +68,36 @@ export function ConnectionSheetButton(props: { · 1 unmodified line
112 readonly disabled?: boolean;68 readonly disabled?: boolean;
113 readonly tone?: "primary" | "secondary" | "danger";69 readonly tone?: "primary" | "secondary" | "danger";
114 readonly compact?: boolean;70 readonly compact?: boolean;
115 readonly palette: ConnectionSheetPalette;
116 readonly onPress: () => void;71 readonly onPress: () => void;
117}) {72}) {
118 const tone = props.tone ?? "secondary";73 const tone = props.tone ?? "secondary";
74
75 const primaryBg = useThemeColor("--color-primary");
76 const primaryFg = useThemeColor("--color-primary-foreground");
77 const dangerBg = useThemeColor("--color-danger");
78 const dangerBorderColor = useThemeColor("--color-danger-border");
79 const dangerFg = useThemeColor("--color-danger-foreground");
80 const secondaryBg = useThemeColor("--color-secondary");
81 const secondaryFg = useThemeColor("--color-secondary-foreground");
82 const borderColor = useThemeColor("--color-border");
83
119 const colors =84 const colors =
120 tone === "primary"85 tone === "primary"
121 ? {86 ? {
122 backgroundColor: props.palette.primaryButton,87 backgroundColor: primaryBg,
123 borderColor: "transparent",88 borderColor: "transparent",
124 textColor: props.palette.primaryButtonText,89 textColor: primaryFg,
125 }90 }
126 : tone === "danger"91 : tone === "danger"
127 ? {92 ? {
128 backgroundColor: props.palette.dangerButton,93 backgroundColor: dangerBg,
129 borderColor: props.palette.dangerBorder,94 borderColor: dangerBorderColor,
130 textColor: props.palette.dangerText,95 textColor: dangerFg,
131 }96 }
132 : {97 : {
133 backgroundColor: props.palette.secondaryButton,98 backgroundColor: secondaryBg,
134 borderColor: props.palette.border,99 borderColor: borderColor,
135 textColor: props.palette.secondaryButtonText,100 textColor: secondaryFg,
136 };101 };
137 102
138 const primaryShadow =103 const primaryShadow =
@@ -150,11 +115,11 @@ export function ConnectionSheetButton(props: { · 11 unmodified lines
150 115
151 return (116 return (
152 <Pressable117 <Pressable
153 className={118 className={cn(
154 props.compact119 props.compact
155 ? "min-h-[42px] flex-row items-center justify-center gap-1.5 rounded-[14px] px-3.5 py-2.5"120 ? "min-h-[42px] flex-row items-center justify-center gap-1.5 rounded-[14px] px-3.5 py-2.5"
156 : "min-h-[48px] flex-row items-center justify-center gap-2 rounded-[16px] px-4 py-3"121 : "min-h-[48px] flex-row items-center justify-center gap-2 rounded-[16px] px-4 py-3",
157 }122 )}
158 disabled={props.disabled}123 disabled={props.disabled}
159 onPress={props.onPress}124 onPress={props.onPress}
160 style={[125 style={[
apps/mobile/src/features/home/HomeRouteScreen.tsx+127−16
@@ -1,15 +1,37 @@
1import { useFocusEffect, useRouter } from "expo-router";1import { Stack, useFocusEffect, useRouter } from "expo-router";
2import { useCallback } from "react";2import { useCallback, useState } from "react";
3import { Pressable, Text as RNText, View, useColorScheme } from "react-native";
4import { useThemeColor } from "../../lib/useThemeColor";
3 5
4import { buildThreadRoutePath } from "../../lib/routes";6import { buildThreadRoutePath } from "../../lib/routes";
7import { ConnectionStatusDot } from "../connection/ConnectionStatusDot";
5import { useRemoteCatalog } from "../../state/use-remote-catalog";8import { useRemoteCatalog } from "../../state/use-remote-catalog";
9import { useRemoteEnvironmentState } from "../../state/use-remote-environment-registry";
6import { useThreadSelection } from "../../state/use-thread-selection";10import { useThreadSelection } from "../../state/use-thread-selection";
7import { HomeScreen } from "./HomeScreen";11import { HomeScreen } from "./HomeScreen";
8 12
13/* ─── Connection pill label ──────────────────────────────────────────── */
14
15const CONNECTION_LABEL: Record<string, string> = {
16 ready: "Connected",
17 connecting: "Connecting",
18 reconnecting: "Reconnecting",
19 disconnected: "Offline",
20 idle: "No backends",
21};
22
23/* ─── Route screen ───────────────────────────────────────────────────── */
24
9export function HomeRouteScreen() {25export function HomeRouteScreen() {
10 const { connectionState, hasRemoteActivity, projects, threads } = useRemoteCatalog();26 const { connectionState, hasRemoteActivity, projects, threads } = useRemoteCatalog();
27 const { savedConnectionsById } = useRemoteEnvironmentState();
11 const { onBackFromThread, onSelectThread } = useThreadSelection();28 const { onBackFromThread, onSelectThread } = useThreadSelection();
12 const router = useRouter();29 const router = useRouter();
30 const [searchQuery, setSearchQuery] = useState("");
31
32 const isDark = useColorScheme() === "dark";
33 const iconColor = String(useThemeColor("--color-icon"));
34 const secondaryFg = isDark ? "#a3a3a3" : "#525252";
13 35
14 useFocusEffect(36 useFocusEffect(
15 useCallback(() => {37 useCallback(() => {
@@ -18,19 +40,108 @@ export function HomeRouteScreen() { · 2 unmodified lines
18 );40 );
19 41
20 return (42 return (
21 <HomeScreen43 <>
22 projects={projects}44 <Stack.Screen
23 threads={threads}45 options={{
24 connectionState={connectionState}46 headerShown: true,
25 connectionPulse={hasRemoteActivity}47 headerTransparent: true,
26 onOpenConnectionEditor={() => router.push("/connections")}48 headerStyle: { backgroundColor: "transparent" },
27 onOpenNewTask={() => router.push("/new")}49 headerShadowVisible: false,
28 onSelectThread={(thread) => {50 headerTintColor: iconColor,
29 onSelectThread(thread);51 headerTitle: "",
30 router.push(buildThreadRoutePath(thread));52 headerRight: () => (
31 }}53 <Pressable
32 showFloatingConnectionButton54 onPress={() => router.push("/connections")}
33 showFloatingNewTaskButton55 hitSlop={8}
34 />56 style={{
57 flexDirection: "row",
58 alignItems: "center",
59 gap: 8,
60 paddingHorizontal: 4,
61 paddingVertical: 4,
62 }}
63 >
64 <ConnectionStatusDot state={connectionState} pulse={hasRemoteActivity} size={7} />
65 <RNText
66 style={{
67 fontFamily: "DMSans_700Bold",
68 fontSize: 12,
69 color: secondaryFg,
70 }}
71 >
72 {CONNECTION_LABEL[connectionState] ?? "Unknown"}
73 </RNText>
74 </Pressable>
75 ),
76 headerSearchBarOptions: {
77 placeholder: "Search threads",
78 onChangeText: (event) => {
79 setSearchQuery(event.nativeEvent.text);
80 },
81 allowToolbarIntegration: true,
82 },
83 }}
84 />
85
86 {/* Header left: plain text, no Liquid Glass button chrome */}
87 <Stack.Toolbar placement="left">
88 <Stack.Toolbar.View hidesSharedBackground>
89 <View style={{ flexDirection: "row", alignItems: "center", gap: 8 }}>
90 <RNText
91 style={{
92 fontFamily: "DMSans_700Bold",
93 fontSize: 17,
94 color: iconColor,
95 letterSpacing: -0.4,
96 }}
97 >
98 T3 Code
99 </RNText>
100 <View
101 style={{
102 backgroundColor: isDark ? "rgba(255,255,255,0.04)" : "rgba(0,0,0,0.04)",
103 borderRadius: 99,
104 paddingHorizontal: 8,
105 paddingVertical: 3,
106 }}
107 >
108 <RNText
109 style={{
110 fontFamily: "DMSans_700Bold",
111 fontSize: 10,
112 color: "#737373",
113 letterSpacing: 1.1,
114 textTransform: "uppercase",
115 }}
116 >
117 Alpha
118 </RNText>
119 </View>
120 </View>
121 </Stack.Toolbar.View>
122 </Stack.Toolbar>
123
124 {/* Bottom toolbar: search + compose, visually split like iMessage */}
125 <Stack.Toolbar placement="bottom">
126 <Stack.Toolbar.SearchBarSlot />
127 <Stack.Toolbar.Spacer width={8} sharesBackground={false} />
128 <Stack.Toolbar.Button
129 icon="square.and.pencil"
130 onPress={() => router.push("/new")}
131 separateBackground
132 />
133 </Stack.Toolbar>
134
135 <HomeScreen
136 projects={projects}
137 threads={threads}
138 savedConnectionsById={savedConnectionsById}
139 searchQuery={searchQuery}
140 onSelectThread={(thread) => {
141 onSelectThread(thread);
142 router.push(buildThreadRoutePath(thread));
143 }}
144 />
145 </>
35 );146 );
36}147}
apps/mobile/src/features/home/HomeScreen.tsx+315−181
@@ -1,215 +1,349 @@
1import * as Haptics from "expo-haptics";1import type { GitStatusState } from "@t3tools/client-runtime";
2import { SymbolView } from "expo-symbols";2import { SymbolView } from "expo-symbols";
3import { useMemo, useState } from "react";3import { useCallback, useMemo, useState } from "react";
4import { Pressable, ScrollView, useColorScheme, View } from "react-native";4import { Pressable, ScrollView, View } from "react-native";
5import { useSafeAreaInsets } from "react-native-safe-area-context";5import { useThemeColor } from "../../lib/useThemeColor";
6 6
7import { AppText as Text } from "../../components/AppText";7import { AppText as Text } from "../../components/AppText";
8import { EmptyState } from "../../components/EmptyState";8import { EmptyState } from "../../components/EmptyState";
9import { StatusPill } from "../../components/StatusPill";9import { ProjectFavicon } from "../../components/ProjectFavicon";
10import { groupProjectsByRepository } from "../../lib/repositoryGroups";10import type { SavedRemoteConnection } from "../../lib/connection";
11import type { ScopedMobileProject, ScopedMobileThread } from "../../lib/scopedEntities";11import type { ScopedMobileProject, ScopedMobileThread } from "../../lib/scopedEntities";
12import { scopedProjectKey } from "../../lib/scopedEntities";12import { scopedProjectKey } from "../../lib/scopedEntities";
13import { relativeTime } from "../../lib/time";13import { relativeTime } from "../../lib/time";
14import { makeAppPalette } from "../../lib/theme";14import { useGitStatus } from "../../state/use-git-status";
15import { ConnectionStatusDot } from "../connection/ConnectionStatusDot";15import { lastConversationLine, threadStatusTone } from "../threads/threadPresentation";
16import { threadStatusTone } from "../threads/threadPresentation";
17 16
18export function HomeScreen(props: {17/* ─── Types ──────────────────────────────────────────────────────────── */
18
19interface HomeScreenProps {
19 readonly projects: ReadonlyArray<ScopedMobileProject>;20 readonly projects: ReadonlyArray<ScopedMobileProject>;
20 readonly threads: ReadonlyArray<ScopedMobileThread>;21 readonly threads: ReadonlyArray<ScopedMobileThread>;
21 readonly connectionState: "ready" | "connecting" | "reconnecting" | "disconnected" | "idle";22 readonly savedConnectionsById: Readonly<Record<string, SavedRemoteConnection>>;
22 readonly connectionPulse: boolean;23 readonly searchQuery: string;
23 readonly onOpenConnectionEditor: () => void;
24 readonly onOpenNewTask: () => void;
25 readonly onSelectThread: (thread: ScopedMobileThread) => void;24 readonly onSelectThread: (thread: ScopedMobileThread) => void;
26 readonly showFloatingConnectionButton: boolean;25}
27 readonly showFloatingNewTaskButton: boolean;
28}) {
29 const isDarkMode = useColorScheme() === "dark";
30 const palette = makeAppPalette(isDarkMode);
31 const insets = useSafeAreaInsets();
32 const [newTaskPressed, setNewTaskPressed] = useState(false);
33 const repositoryGroups = useMemo(
34 () => groupProjectsByRepository({ projects: props.projects, threads: props.threads }),
35 [props.projects, props.threads],
36 );
37 const projectLabelsByKey = useMemo(() => {
38 const map = new Map<
39 string,
40 {
41 readonly projectTitle: string;
42 readonly environmentLabel: string;
43 }
44 >();
45
46 for (const group of repositoryGroups) {
47 const primaryProjectTitle = group.projects[0]?.project.title ?? group.title;
48 for (const projectGroup of group.projects) {
49 map.set(scopedProjectKey(projectGroup.project.environmentId, projectGroup.project.id), {
50 projectTitle: primaryProjectTitle,
51 environmentLabel: projectGroup.project.environmentLabel,
52 });
53 }
54 }
55 26
56 return map;27interface ProjectGroup {
57 }, [repositoryGroups]);28 readonly key: string;
29 readonly project: ScopedMobileProject;
30 readonly threads: ReadonlyArray<ScopedMobileThread>;
31}
32
33/* ─── Status indicator colors ────────────────────────────────────────── */
34
35function statusColors(thread: ScopedMobileThread): { bg: string; fg: string } {
36 switch (thread.session?.status) {
37 case "running":
38 return { bg: "rgba(249,115,22,0.14)", fg: "#f97316" };
39 case "ready":
40 return { bg: "rgba(34,197,94,0.14)", fg: "#22c55e" };
41 case "starting":
42 return { bg: "rgba(59,130,246,0.14)", fg: "#3b82f6" };
43 case "error":
44 return { bg: "rgba(239,68,68,0.14)", fg: "#ef4444" };
45 default:
46 return { bg: "rgba(163,163,163,0.10)", fg: "#a3a3a3" };
47 }
48}
49
50const COLLAPSED_THREAD_LIMIT = 6;
51
52/* ─── Project group header ───────────────────────────────────────────── */
53
54function ProjectGroupLabel(props: {
55 readonly project: ScopedMobileProject;
56 readonly totalThreadCount: number;
57 readonly httpBaseUrl: string | null;
58 readonly bearerToken: string | null;
59 readonly isExpanded: boolean;
60 readonly onToggleExpand: () => void;
61}) {
62 const hiddenCount = props.totalThreadCount - COLLAPSED_THREAD_LIMIT;
58 63
59 return (64 return (
60 <View style={{ flex: 1, backgroundColor: palette.screenBackground }}>65 <View className="flex-row items-center gap-2.5 px-1 pb-2">
61 <ScrollView66 <ProjectFavicon
62 showsVerticalScrollIndicator={false}67 size={18}
63 contentContainerStyle={{68 projectTitle={props.project.title}
64 paddingBottom: Math.max(insets.bottom, 24) + (props.showFloatingNewTaskButton ? 92 : 24),69 httpBaseUrl={props.httpBaseUrl}
65 paddingHorizontal: 20,70 workspaceRoot={props.project.workspaceRoot}
66 paddingTop: insets.top + 6,71 bearerToken={props.bearerToken}
67 gap: 16,72 />
68 }}73 <Text
74 className="flex-1 text-[13px] font-t3-bold uppercase text-foreground-muted"
75 style={{ letterSpacing: 0.6 }}
76 numberOfLines={1}
69 >77 >
70 <View className="flex-row items-center justify-between">78 {props.project.title}
71 <Text className="text-[34px] font-t3-bold" style={{ color: palette.text }}>79 </Text>
72 Recents80
81 {hiddenCount > 0 ? (
82 <Pressable onPress={props.onToggleExpand} hitSlop={8}>
83 <Text
84 className="text-[13px] font-t3-bold text-foreground-muted"
85 style={{ letterSpacing: 0.6 }}
86 >
87 {props.isExpanded ? "Show less" : `${hiddenCount} more`}
73 </Text>88 </Text>
89 </Pressable>
90 ) : null}
91 </View>
92 );
93}
74 94
75 {props.showFloatingConnectionButton ? (95/* ─── Git summary line ──────────────────────────────────────────────── */
76 <Pressable
77 onPress={props.onOpenConnectionEditor}
78 className="h-14 min-w-[56px] items-center justify-center rounded-full px-4"
79 style={{
80 backgroundColor: isDarkMode ? "rgba(255,255,255,0.08)" : "rgba(255,255,255,0.9)",
81 borderWidth: isDarkMode ? 1 : 0,
82 borderColor: isDarkMode ? palette.border : "transparent",
83 }}
84 >
85 <ConnectionStatusDot state={props.connectionState} pulse={props.connectionPulse} />
86 </Pressable>
87 ) : null}
88 </View>
89 96
90 {props.threads.length === 0 ? (97function gitSummaryParts(gitStatus: GitStatusState): ReadonlyArray<string> {
91 <EmptyState98 if (!gitStatus.data) return [];
92 title="No threads yet"99 const { data } = gitStatus;
93 detail="Create a task to start a new coding session in one of your connected projects."100 const parts: string[] = [];
94 />101 if (data.hasWorkingTreeChanges) {
95 ) : (102 parts.push(`${data.workingTree.files.length} changed`);
96 <View103 }
97 className="overflow-hidden rounded-[24px]"104 if (data.aheadCount > 0) parts.push(`${data.aheadCount} ahead`);
98 style={{ backgroundColor: palette.card }}105 if (data.behindCount > 0) parts.push(`${data.behindCount} behind`);
99 >106 if (data.pr?.state === "open") parts.push(`PR #${data.pr.number}`);
100 {props.threads.map((thread, index) => {107 return parts;
101 const projectKey = scopedProjectKey(thread.environmentId, thread.projectId);108}
102 const projectLabel = projectLabelsByKey.get(projectKey);109
103 110/* ─── Thread row ─────────────────────────────────────────────────────── */
104 return (111
105 <Pressable112function ThreadRow(props: {
106 key={`${thread.environmentId}:${thread.id}`}113 readonly thread: ScopedMobileThread;
107 onPress={() => props.onSelectThread(thread)}114 readonly projectCwd: string | null;
108 style={{115 readonly onPress: () => void;
109 paddingHorizontal: 16,116 readonly isLast: boolean;
110 paddingVertical: 13,117}) {
111 borderTopWidth: index === 0 ? 0 : 1,118 const separatorColor = useThemeColor("--color-separator");
112 borderTopColor: palette.borderSubtle,119 const { bg, fg } = statusColors(props.thread);
113 }}120 const tone = threadStatusTone(props.thread);
114 >121 const preview = lastConversationLine(props.thread);
115 <View className="flex-row items-start justify-between gap-3">122 const timestamp = relativeTime(props.thread.updatedAt ?? props.thread.createdAt);
116 <View className="flex-1 gap-1.5">123 const branch = props.thread.branch;
117 <Text124
118 className="text-[17px] font-t3-bold leading-[24px]"125 // Subscribe to live git status — only when thread has a branch set.
119 numberOfLines={2}126 // Threads sharing the same cwd share one WS subscription via ref-counting.
120 style={{ color: palette.text }}127 const cwd = branch ? (props.thread.worktreePath ?? props.projectCwd) : null;
121 >128 const gitStatus = useGitStatus({
122 {thread.title}129 environmentId: cwd ? props.thread.environmentId : null,
123 </Text>130 cwd,
124 </View>131 });
125 <StatusPill {...threadStatusTone(thread)} size="compact" />132 const gitParts = gitSummaryParts(gitStatus);
126 </View>
127
128 <View className="mt-2 flex-row items-center justify-between gap-3">
129 <View className="flex-1 flex-row items-center gap-2">
130 <Text
131 className="text-[13px] font-medium leading-[18px]"
132 numberOfLines={1}
133 style={{ color: palette.textSecondary, flexShrink: 1 }}
134 >
135 {projectLabel?.projectTitle ?? thread.title}
136 </Text>
137 <View
138 className="h-1 w-1 rounded-full"
139 style={{ backgroundColor: palette.dotSeparator }}
140 />
141 <Text
142 className="text-[12px] font-t3-bold uppercase tracking-[0.4px]"
143 numberOfLines={1}
144 style={{ color: palette.textMuted, flexShrink: 1 }}
145 >
146 {projectLabel?.environmentLabel ?? "Local"}
147 </Text>
148 </View>
149 <Text
150 className="text-[12px] font-t3-bold"
151 style={{ color: palette.textMuted, fontVariant: ["tabular-nums"] }}
152 >
153 {relativeTime(thread.updatedAt ?? thread.createdAt)}
154 </Text>
155 </View>
156 </Pressable>
157 );
158 })}
159 </View>
160 )}
161 </ScrollView>
162 133
163 {props.showFloatingNewTaskButton ? (134 return (
135 <Pressable onPress={props.onPress} style={({ pressed }) => ({ opacity: pressed ? 0.7 : 1 })}>
136 <View
137 style={{
138 flexDirection: "row",
139 paddingLeft: 16,
140 paddingRight: 16,
141 paddingVertical: 10,
142 gap: 12,
143 borderBottomWidth: props.isLast ? 0 : 1,
144 borderBottomColor: separatorColor,
145 }}
146 >
147 {/* Git status indicator */}
164 <View148 <View
165 pointerEvents="box-none"
166 style={{149 style={{
167 position: "absolute",150 width: 30,
168 right: 20,151 height: 30,
169 bottom: Math.max(insets.bottom, 18),152 borderRadius: 9,
153 backgroundColor: bg,
154 alignItems: "center",
155 justifyContent: "center",
156 marginTop: 2,
170 }}157 }}
171 >158 >
172 <Pressable159 <SymbolView name="arrow.triangle.branch" size={13} tintColor={fg} type="monochrome" />
173 accessibilityRole="button"160 </View>
174 hitSlop={6}161
175 onPressIn={() => {162 {/* Content */}
176 setNewTaskPressed(true);163 <View style={{ flex: 1, gap: 3 }}>
177 void Haptics.selectionAsync();164 {/* Title + Status + Timestamp */}
178 }}165 <View className="flex-row items-center justify-between gap-2">
179 onPressOut={() => {166 <Text
180 setNewTaskPressed(false);167 className="flex-1 text-[15px] font-t3-bold leading-[20px] text-foreground"
181 }}168 numberOfLines={1}
182 onPress={props.onOpenNewTask}
183 >
184 <View
185 className="flex-row items-center gap-3 rounded-full px-6 py-4"
186 style={{
187 backgroundColor: palette.primaryButton,
188 borderWidth: isDarkMode ? 1 : 0,
189 borderColor: isDarkMode ? "rgba(255,255,255,0.08)" : "transparent",
190 boxShadow: newTaskPressed
191 ? `0 12px 24px ${palette.primaryButtonShadow}`
192 : `0 20px 36px ${palette.primaryButtonShadow}`,
193 opacity: newTaskPressed ? 0.96 : 1,
194 transform: [{ scale: newTaskPressed ? 0.985 : 1 }],
195 }}
196 >169 >
170 {props.thread.title}
171 </Text>
172 <View className="flex-row items-center gap-2">
173 <View
174 className={tone.pillClassName}
175 style={{ borderRadius: 99, paddingHorizontal: 6, paddingVertical: 2 }}
176 >
177 <Text className={`text-[10px] font-t3-bold ${tone.textClassName}`}>
178 {tone.label}
179 </Text>
180 </View>
181 <Text
182 className="text-[12px] text-foreground-tertiary"
183 style={{ fontVariant: ["tabular-nums"] }}
184 >
185 {timestamp}
186 </Text>
187 </View>
188 </View>
189
190 {/* Message preview */}
191 <Text className="text-[13px] leading-[18px] text-foreground-muted" numberOfLines={2}>
192 {preview}
193 </Text>
194
195 {/* Branch + git info */}
196 {branch ? (
197 <View className="flex-row items-center gap-1.5" style={{ marginTop: 1 }}>
197 <SymbolView198 <SymbolView
198 name="square.and.pencil"199 name="arrow.triangle.branch"
199 size={20}200 size={10}
200 tintColor={palette.primaryButtonText}201 tintColor="#737373"
201 type="monochrome"202 type="monochrome"
202 />203 />
203 <Text204 <Text
204 className="text-[18px] font-t3-bold"205 className="text-[11px] text-foreground-tertiary"
205 style={{ color: palette.primaryButtonText }}206 numberOfLines={1}
207 style={{ fontFamily: "monospace" }}
206 >208 >
207 New task209 {branch}
208 </Text>210 </Text>
211 {gitParts.length > 0 ? (
212 <Text className="text-[11px] text-foreground-tertiary">
213 {" · " + gitParts.join(" · ")}
214 </Text>
215 ) : null}
209 </View>216 </View>
210 </Pressable>217 ) : null}
211 </View>218 </View>
212 ) : null}219 </View>
213 </View>220 </Pressable>
221 );
222}
223
224/* ─── Main screen ────────────────────────────────────────────────────── */
225
226export function HomeScreen(props: HomeScreenProps) {
227 const [expandedProjects, setExpandedProjects] = useState<Set<string>>(() => new Set());
228
229 const toggleExpanded = useCallback((key: string) => {
230 setExpandedProjects((prev) => {
231 const next = new Set(prev);
232 if (next.has(key)) next.delete(key);
233 else next.add(key);
234 return next;
235 });
236 }, []);
237
238 /* Build project title lookup for search */
239 const projectTitleByKey = useMemo(() => {
240 const map = new Map<string, string>();
241 for (const p of props.projects) {
242 map.set(scopedProjectKey(p.environmentId, p.id), p.title);
243 }
244 return map;
245 }, [props.projects]);
246
247 /* Filter threads by search query */
248 const filteredThreads = useMemo(() => {
249 const q = props.searchQuery.trim().toLowerCase();
250 if (!q) return props.threads;
251 return props.threads.filter((t) => {
252 if (t.title.toLowerCase().includes(q)) return true;
253 const key = scopedProjectKey(t.environmentId, t.projectId);
254 return projectTitleByKey.get(key)?.toLowerCase().includes(q) ?? false;
255 });
256 }, [props.threads, props.searchQuery, projectTitleByKey]);
257
258 /* Group filtered threads by project */
259 const projectGroups = useMemo<ReadonlyArray<ProjectGroup>>(() => {
260 const byProject = new Map<string, ScopedMobileThread[]>();
261 for (const thread of filteredThreads) {
262 const key = scopedProjectKey(thread.environmentId, thread.projectId);
263 const existing = byProject.get(key);
264 if (existing) existing.push(thread);
265 else byProject.set(key, [thread]);
266 }
267
268 const groups: ProjectGroup[] = [];
269 for (const project of props.projects) {
270 const key = scopedProjectKey(project.environmentId, project.id);
271 const threads = byProject.get(key);
272 if (threads && threads.length > 0) {
273 groups.push({ key, project, threads });
274 }
275 }
276
277 groups.sort((a, b) => {
278 const aTime = new Date(a.threads[0]!.updatedAt ?? a.threads[0]!.createdAt).getTime();
279 const bTime = new Date(b.threads[0]!.updatedAt ?? b.threads[0]!.createdAt).getTime();
280 return bTime - aTime;
281 });
282
283 return groups;
284 }, [props.projects, filteredThreads]);
285
286 /* Empty states */
287 const hasAnyThreads = props.threads.length > 0;
288 const hasResults = filteredThreads.length > 0;
289
290 return (
291 <ScrollView
292 contentInsetAdjustmentBehavior="automatic"
293 showsVerticalScrollIndicator={false}
294 keyboardDismissMode="on-drag"
295 keyboardShouldPersistTaps="handled"
296 className="flex-1 bg-screen"
297 contentContainerStyle={{
298 paddingHorizontal: 16,
299 paddingTop: 8,
300 paddingBottom: 24,
301 gap: 20,
302 }}
303 >
304 {!hasAnyThreads ? (
305 <EmptyState
306 title="No threads yet"
307 detail="Create a task to start a new coding session in one of your connected projects."
308 />
309 ) : !hasResults ? (
310 <EmptyState title="No results" detail={`No threads matching "${props.searchQuery}".`} />
311 ) : (
312 projectGroups.map((group) => {
313 const connection = props.savedConnectionsById[group.project.environmentId];
314 const isExpanded = expandedProjects.has(group.key);
315 const visibleThreads = isExpanded
316 ? group.threads
317 : group.threads.slice(0, COLLAPSED_THREAD_LIMIT);
318
319 return (
320 <View key={group.key}>
321 <ProjectGroupLabel
322 project={group.project}
323 totalThreadCount={group.threads.length}
324 httpBaseUrl={connection?.httpBaseUrl ?? null}
325 bearerToken={connection?.bearerToken ?? null}
326 isExpanded={isExpanded}
327 onToggleExpand={() => toggleExpanded(group.key)}
328 />
329 <View
330 className="overflow-hidden rounded-[20px] bg-card"
331 style={{ borderCurve: "continuous" }}
332 >
333 {visibleThreads.map((thread, i) => (
334 <ThreadRow
335 key={`${thread.environmentId}:${thread.id}`}
336 thread={thread}
337 projectCwd={group.project.workspaceRoot}
338 onPress={() => props.onSelectThread(thread)}
339 isLast={i === visibleThreads.length - 1}
340 />
341 ))}
342 </View>
343 </View>
344 );
345 })
346 )}
347 </ScrollView>
214 );348 );
215}349}
apps/mobile/src/features/threads/NewTaskDraftScreen.tsx+23−31
@@ -3,9 +3,10 @@ import { useRouter } from "expo-router"; · 2 unmodified lines
3import { SymbolView } from "expo-symbols";3import { SymbolView } from "expo-symbols";
4import { TextInputWrapper } from "expo-paste-input";4import { TextInputWrapper } from "expo-paste-input";
5import { useCallback, useEffect, useMemo } from "react";5import { useCallback, useEffect, useMemo } from "react";
6import { Pressable, useColorScheme, View } from "react-native";6import { Pressable, View, useColorScheme } from "react-native";
7import Animated, { useAnimatedKeyboard, useAnimatedStyle } from "react-native-reanimated";7import Animated, { useAnimatedKeyboard, useAnimatedStyle } from "react-native-reanimated";
8import { useSafeAreaInsets } from "react-native-safe-area-context";8import { useSafeAreaInsets } from "react-native-safe-area-context";
9import { useThemeColor } from "../../lib/useThemeColor";
9 10
10import type { ModelSelection } from "@t3tools/contracts";11import type { ModelSelection } from "@t3tools/contracts";
11import { CLAUDE_CODE_EFFORT_OPTIONS } from "@t3tools/contracts";12import { CLAUDE_CODE_EFFORT_OPTIONS } from "@t3tools/contracts";
@@ -17,7 +18,6 @@ import { ProviderIcon } from "../../components/ProviderIcon"; · 5 unmodified lines
17 18
18import { convertPastedImagesToAttachments, pickComposerImages } from "../../lib/composerImages";19import { convertPastedImagesToAttachments, pickComposerImages } from "../../lib/composerImages";
19import { buildThreadRoutePath } from "../../lib/routes";20import { buildThreadRoutePath } from "../../lib/routes";
20import { makeAppPalette } from "../../lib/theme";
21import { useProjectActions } from "../../state/use-project-actions";21import { useProjectActions } from "../../state/use-project-actions";
22import { useRemoteCatalog } from "../../state/use-remote-catalog";22import { useRemoteCatalog } from "../../state/use-remote-catalog";
23import { useNativePaste } from "../../hooks/useNativePaste";23import { useNativePaste } from "../../hooks/useNativePaste";
@@ -33,9 +33,8 @@ export function NewTaskDraftScreen(props: { · 9 unmodified lines
33 const { onCreateThreadWithOptions } = useProjectActions();33 const { onCreateThreadWithOptions } = useProjectActions();
34 const flow = useNewTaskFlow();34 const flow = useNewTaskFlow();
35 const router = useRouter();35 const router = useRouter();
36 const isDarkMode = useColorScheme() === "dark";
37 const palette = makeAppPalette(isDarkMode);
38 const insets = useSafeAreaInsets();36 const insets = useSafeAreaInsets();
37 const isDarkMode = useColorScheme() === "dark";
39 const keyboard = useAnimatedKeyboard();38 const keyboard = useAnimatedKeyboard();
40 const containerAnimatedStyle = useAnimatedStyle(() => ({39 const containerAnimatedStyle = useAnimatedStyle(() => ({
41 paddingBottom: keyboard.height.value,40 paddingBottom: keyboard.height.value,
@@ -45,6 +44,9 @@ export function NewTaskDraftScreen(props: { · 3 unmodified lines
45 }));44 }));
46 const { logicalProjects, selectedProject, setProject } = flow;45 const { logicalProjects, selectedProject, setProject } = flow;
47 46
47 const iconColor = useThemeColor("--color-icon");
48 const borderColor = useThemeColor("--color-border");
49
48 useEffect(() => {50 useEffect(() => {
49 if (props.initialProjectRef?.environmentId && props.initialProjectRef?.projectId) {51 if (props.initialProjectRef?.environmentId && props.initialProjectRef?.projectId) {
50 const directProject =52 const directProject =
@@ -384,54 +386,48 @@ export function NewTaskDraftScreen(props: { · 333 unmodified lines
384 386
385 if (!selectedProject) {387 if (!selectedProject) {
386 return (388 return (
387 <View style={{ flex: 1, backgroundColor: palette.sheetBackground }}>389 <View className="flex-1 bg-sheet">
388 <View style={{ minHeight: 16, paddingTop: 8 }} />390 <View style={{ minHeight: 16, paddingTop: 8 }} />
389 <View className="items-center gap-1 px-5 pb-3 pt-4">391 <View className="items-center gap-1 px-5 pb-3 pt-4">
390 <Text392 <Text
391 className="text-[12px] font-t3-bold uppercase"393 className="text-[12px] font-t3-bold uppercase text-foreground-muted"
392 style={{ color: palette.textMuted, letterSpacing: 1 }}394 style={{ letterSpacing: 1 }}
393 >395 >
394 New task396 New task
395 </Text>397 </Text>
396 <Text className="text-[28px] font-t3-bold" style={{ color: palette.text }}>398 <Text className="text-[28px] font-t3-bold">Loading task</Text>
397 Loading task
398 </Text>
399 </View>399 </View>
400 </View>400 </View>
401 );401 );
402 }402 }
403 403
404 return (404 return (
405 <Animated.View405 <Animated.View className="flex-1 bg-sheet" style={containerAnimatedStyle}>
406 style={[{ flex: 1, backgroundColor: palette.sheetBackground }, containerAnimatedStyle]}
407 >
408 <View style={{ minHeight: 16, paddingTop: 8 }} />406 <View style={{ minHeight: 16, paddingTop: 8 }} />
409 407
410 <View className="items-center gap-1 px-5 pb-3 pt-4">408 <View className="items-center gap-1 px-5 pb-3 pt-4">
411 {flow.logicalProjects.length > 1 ? (409 {flow.logicalProjects.length > 1 ? (
412 <Pressable410 <Pressable
413 className="absolute left-3 top-4 h-9 w-9 items-center justify-center rounded-full"411 className="absolute left-3 top-4 h-9 w-9 items-center justify-center rounded-full bg-subtle"
414 style={{ backgroundColor: palette.subtleBg, zIndex: 1 }}412 style={{ zIndex: 1 }}
415 onPress={() => router.back()}413 onPress={() => router.back()}
416 >414 >
417 <SymbolView415 <SymbolView
418 name="chevron.left"416 name="chevron.left"
419 size={16}417 size={16}
420 tintColor={palette.icon}418 tintColor={iconColor}
421 type="monochrome"419 type="monochrome"
422 weight="medium"420 weight="medium"
423 />421 />
424 </Pressable>422 </Pressable>
425 ) : null}423 ) : null}
426 <Text424 <Text
427 className="text-[12px] font-t3-bold uppercase"425 className="text-[12px] font-t3-bold uppercase text-foreground-muted"
428 style={{ color: palette.textMuted, letterSpacing: 1 }}426 style={{ letterSpacing: 1 }}
429 >427 >
430 New task428 New task
431 </Text>429 </Text>
432 <Text className="text-[28px] font-t3-bold" style={{ color: palette.text }}>430 <Text className="text-[28px] font-t3-bold">{selectedProject.title}</Text>
433 {selectedProject.title}
434 </Text>
435 </View>431 </View>
436 432
437 <View style={{ flex: 1, paddingHorizontal: 20, paddingTop: 8 }}>433 <View style={{ flex: 1, paddingHorizontal: 20, paddingTop: 8 }}>
@@ -440,17 +436,9 @@ export function NewTaskDraftScreen(props: { · 2 unmodified lines
440 multiline436 multiline
441 value={flow.prompt}437 value={flow.prompt}
442 onChangeText={flow.setPrompt}438 onChangeText={flow.setPrompt}
443 placeholderTextColor={palette.placeholder}
444 placeholder={`Describe a coding task in ${selectedProject.title}`}439 placeholder={`Describe a coding task in ${selectedProject.title}`}
445 textAlignVertical="top"440 textAlignVertical="top"
446 style={{441 className="flex-1 border-0 bg-transparent text-[18px] leading-[28px]"
447 flex: 1,
448 borderWidth: 0,
449 backgroundColor: "transparent",
450 color: palette.text,
451 fontSize: 18,
452 lineHeight: 28,
453 }}
454 />442 />
455 </TextInputWrapper>443 </TextInputWrapper>
456 </View>444 </View>
@@ -459,7 +447,7 @@ export function NewTaskDraftScreen(props: { · 2 unmodified lines
459 style={[447 style={[
460 {448 {
461 borderTopWidth: 1,449 borderTopWidth: 1,
462 borderTopColor: palette.border,450 borderTopColor: borderColor,
463 },451 },
464 controlsBottomPadding,452 controlsBottomPadding,
465 ]}453 ]}
@@ -479,6 +467,7 @@ export function NewTaskDraftScreen(props: { · 13 unmodified lines
479 <MenuView467 <MenuView
480 actions={modelMenuActions}468 actions={modelMenuActions}
481 onPressAction={({ nativeEvent }) => handleModelMenuAction(nativeEvent.event)}469 onPressAction={({ nativeEvent }) => handleModelMenuAction(nativeEvent.event)}
470 themeVariant={isDarkMode ? "dark" : "light"}
482 >471 >
483 <ControlPill472 <ControlPill
484 iconNode={<ProviderIcon provider={flow.selectedModel?.provider} size={16} />}473 iconNode={<ProviderIcon provider={flow.selectedModel?.provider} size={16} />}
@@ -487,18 +476,21 @@ export function NewTaskDraftScreen(props: { · 2 unmodified lines
487 <MenuView476 <MenuView
488 actions={optionsMenuActions}477 actions={optionsMenuActions}
489 onPressAction={({ nativeEvent }) => handleOptionsMenuAction(nativeEvent.event)}478 onPressAction={({ nativeEvent }) => handleOptionsMenuAction(nativeEvent.event)}
479 themeVariant={isDarkMode ? "dark" : "light"}
490 >480 >
491 <ControlPill icon="slider.horizontal.3" />481 <ControlPill icon="slider.horizontal.3" />
492 </MenuView>482 </MenuView>
493 <MenuView483 <MenuView
494 actions={environmentMenuActions}484 actions={environmentMenuActions}
495 onPressAction={({ nativeEvent }) => handleEnvironmentMenuAction(nativeEvent.event)}485 onPressAction={({ nativeEvent }) => handleEnvironmentMenuAction(nativeEvent.event)}
486 themeVariant={isDarkMode ? "dark" : "light"}
496 >487 >
497 <ControlPill icon="desktopcomputer" />488 <ControlPill icon="desktopcomputer" />
498 </MenuView>489 </MenuView>
499 <MenuView490 <MenuView
500 actions={workspaceMenuActions}491 actions={workspaceMenuActions}
501 onPressAction={({ nativeEvent }) => handleWorkspaceMenuAction(nativeEvent.event)}492 onPressAction={({ nativeEvent }) => handleWorkspaceMenuAction(nativeEvent.event)}
493 themeVariant={isDarkMode ? "dark" : "light"}
502 >494 >
503 <ControlPill icon="point.topleft.down.curvedto.point.bottomright.up" />495 <ControlPill icon="point.topleft.down.curvedto.point.bottomright.up" />
504 </MenuView>496 </MenuView>
apps/mobile/src/features/threads/PendingUserInputCard.tsx+4−4
@@ -2,7 +2,7 @@ import type { ApprovalRequestId } from "@t3tools/contracts"; · 1 unmodified line
2import { Pressable, View } from "react-native";2import { Pressable, View } from "react-native";
3 3
4import { AppText as Text, AppTextInput as TextInput } from "../../components/AppText";4import { AppText as Text, AppTextInput as TextInput } from "../../components/AppText";
5import { cx } from "../../lib/classNames";5import { cn } from "../../lib/cn";
6import type { PendingUserInput, PendingUserInputDraftAnswer } from "../../lib/threadActivity";6import type { PendingUserInput, PendingUserInputDraftAnswer } from "../../lib/threadActivity";
7 7
8export interface PendingUserInputCardProps {8export interface PendingUserInputCardProps {
@@ -45,7 +45,7 @@ export function PendingUserInputCard(props: PendingUserInputCardProps) { · 36 unmodified lines
45 return (45 return (
46 <Pressable46 <Pressable
47 key={option.label}47 key={option.label}
48 className={cx(48 className={cn(
49 "rounded-full border px-3 py-2.5 ",49 "rounded-full border px-3 py-2.5 ",
50 selected50 selected
51 ? "border-blue-300/50 bg-blue-50 dark:border-blue-400/28 dark:bg-blue-400/14"51 ? "border-blue-300/50 bg-blue-50 dark:border-blue-400/28 dark:bg-blue-400/14"
@@ -60,7 +60,7 @@ export function PendingUserInputCard(props: PendingUserInputCardProps) { · 8 unmodified lines
60 }60 }
61 >61 >
62 <Text62 <Text
63 className={cx(63 className={cn(
64 "font-t3-bold text-[13px]",64 "font-t3-bold text-[13px]",
65 selected65 selected
66 ? "text-sky-700 dark:text-sky-300"66 ? "text-sky-700 dark:text-sky-300"
@@ -85,7 +85,7 @@ export function PendingUserInputCard(props: PendingUserInputCardProps) { · 18 unmodified lines
85 );85 );
86 })}86 })}
87 <Pressable87 <Pressable
88 className={cx(88 className={cn(
89 "items-center justify-center rounded-2xl px-4 py-3.5",89 "items-center justify-center rounded-2xl px-4 py-3.5",
90 props.answers ? "bg-blue-500" : "bg-neutral-200 dark:bg-neutral-700/60",90 props.answers ? "bg-blue-500" : "bg-neutral-200 dark:bg-neutral-700/60",
91 )}91 )}
apps/mobile/src/features/threads/ProjectPickerSheet.tsx+0−135
@@ -1,135 +0,0 @@
1import {
2 BottomSheetBackdrop,
3 BottomSheetModal,
4 BottomSheetScrollView,
5 BottomSheetView,
6} from "@gorhom/bottom-sheet";
7import { useCallback, useEffect, useMemo, useRef } from "react";
8import { Pressable, useColorScheme, View } from "react-native";
9import { useSafeAreaInsets } from "react-native-safe-area-context";
10
11import { AppText as Text } from "../../components/AppText";
12import { groupProjectsByRepository } from "../../lib/repositoryGroups";
13import { makeAppPalette } from "../../lib/theme";
14import type { ScopedMobileProject, ScopedMobileThread } from "../../lib/scopedEntities";
15
16export function ProjectPickerSheet(props: {
17 readonly visible: boolean;
18 readonly projects: ReadonlyArray<ScopedMobileProject>;
19 readonly threads: ReadonlyArray<ScopedMobileThread>;
20 readonly onClose: () => void;
21 readonly onSelectProject: (project: ScopedMobileProject) => void;
22}) {
23 const isDarkMode = useColorScheme() === "dark";
24 const palette = makeAppPalette(isDarkMode);
25 const insets = useSafeAreaInsets();
26 const sheetRef = useRef<BottomSheetModal>(null);
27 const repositoryGroups = useMemo(
28 () => groupProjectsByRepository({ projects: props.projects, threads: props.threads }),
29 [props.projects, props.threads],
30 );
31 const logicalProjects = useMemo(
32 () =>
33 repositoryGroups
34 .map((group) => {
35 const primaryProject = group.projects[0]?.project;
36 if (!primaryProject) {
37 return null;
38 }
39
40 return {
41 key: group.key,
42 project: primaryProject,
43 };
44 })
45 .filter((entry) => entry !== null),
46 [repositoryGroups],
47 );
48 const renderBackdrop = useCallback(
49 (backdropProps: React.ComponentProps<typeof BottomSheetBackdrop>) => (
50 <BottomSheetBackdrop
51 {...backdropProps}
52 appearsOnIndex={0}
53 disappearsOnIndex={-1}
54 opacity={0.18}
55 pressBehavior="close"
56 />
57 ),
58 [],
59 );
60
61 useEffect(() => {
62 const sheet = sheetRef.current;
63 if (!sheet) {
64 return;
65 }
66
67 if (props.visible) {
68 sheet.present();
69 return;
70 }
71
72 sheet.dismiss();
73 }, [props.visible]);
74
75 return (
76 <BottomSheetModal
77 ref={sheetRef}
78 index={0}
79 snapPoints={["88%"]}
80 enableDynamicSizing={false}
81 enablePanDownToClose
82 backdropComponent={renderBackdrop}
83 onDismiss={props.onClose}
84 backgroundStyle={{ backgroundColor: palette.sheetBackground }}
85 handleIndicatorStyle={{ backgroundColor: palette.dotSeparator }}
86 >
87 <BottomSheetView className="px-4 pt-1">
88 <View className="mb-4 items-center gap-1">
89 <Text
90 className="text-[12px] font-t3-bold uppercase"
91 style={{ color: palette.textMuted, letterSpacing: 1 }}
92 >
93 New task
94 </Text>
95 <Text className="text-[28px] font-t3-bold" style={{ color: palette.text }}>
96 Choose project
97 </Text>
98 </View>
99
100 <BottomSheetScrollView
101 showsVerticalScrollIndicator={false}
102 contentContainerStyle={{
103 paddingBottom: Math.max(insets.bottom, 18) + 18,
104 }}
105 >
106 <View
107 className="overflow-hidden rounded-[24px]"
108 style={{ backgroundColor: palette.card }}
109 >
110 {logicalProjects.map((entry, index) => (
111 <Pressable
112 key={entry.key}
113 onPress={() => props.onSelectProject(entry.project)}
114 style={{
115 paddingHorizontal: 16,
116 paddingVertical: 18,
117 borderTopWidth: index === 0 ? 0 : 1,
118 borderTopColor: palette.borderSubtle,
119 }}
120 >
121 <View className="flex-row items-center justify-between gap-3">
122 <View className="flex-1">
123 <Text className="text-[18px] font-t3-bold" style={{ color: palette.text }}>
124 {entry.project.title}
125 </Text>
126 </View>
127 </View>
128 </Pressable>
129 ))}
130 </View>
131 </BottomSheetScrollView>
132 </BottomSheetView>
133 </BottomSheetModal>
134 );
135}
apps/mobile/src/features/threads/ThreadComposer.tsx+13−19
@@ -20,6 +20,7 @@ import { · 19 unmodified lines
20 type ViewStyle,20 type ViewStyle,
21} from "react-native";21} from "react-native";
22import ImageViewing from "react-native-image-viewing";22import ImageViewing from "react-native-image-viewing";
23import { useThemeColor } from "../../lib/useThemeColor";
23 24
24import { AppText as Text } from "../../components/AppText";25import { AppText as Text } from "../../components/AppText";
25import { ComposerAttachmentStrip } from "../../components/ComposerAttachmentStrip";26import { ComposerAttachmentStrip } from "../../components/ComposerAttachmentStrip";
@@ -29,7 +30,6 @@ import type { DraftComposerImageAttachment } from "../../lib/composerImages"; · 3 unmodified lines
29import type { MobileLayoutVariant } from "../../lib/mobileLayout";30import type { MobileLayoutVariant } from "../../lib/mobileLayout";
30import { buildModelOptions, groupByProvider } from "../../lib/modelOptions";31import { buildModelOptions, groupByProvider } from "../../lib/modelOptions";
31import type { RemoteClientConnectionState } from "../../lib/remoteClient";32import type { RemoteClientConnectionState } from "../../lib/remoteClient";
32import { makeAppPalette } from "../../lib/theme";
33import { useNativePaste } from "../../hooks/useNativePaste";33import { useNativePaste } from "../../hooks/useNativePaste";
34 34
35/**35/**
@@ -109,7 +109,8 @@ function ComposerSurface(props: { · 73 unmodified lines
109 109
110export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposerProps) {110export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposerProps) {
111 const isDarkMode = useColorScheme() === "dark";111 const isDarkMode = useColorScheme() === "dark";
112 const palette = makeAppPalette(isDarkMode);112 const placeholderColor = useThemeColor("--color-placeholder");
113 const foregroundColor = useThemeColor("--color-foreground");
113 const inputRef = useRef<RNTextInput>(null);114 const inputRef = useRef<RNTextInput>(null);
114 const [isFocused, setIsFocused] = useState(false);115 const [isFocused, setIsFocused] = useState(false);
115 const wasExpandedBeforePreviewRef = useRef(false);116 const wasExpandedBeforePreviewRef = useRef(false);
@@ -337,14 +338,11 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer · 221 unmodified lines
337 338
338 return (339 return (
339 <View340 <View
341 className="bg-screen"
340 style={{342 style={{
341 paddingHorizontal: 16,343 paddingHorizontal: 16,
342 paddingTop: isExpanded ? 12 : 10,344 paddingTop: isExpanded ? 12 : 10,
343 paddingBottom: (props.bottomInset ?? 0) + (isExpanded ? 4 : 10),345 paddingBottom: (props.bottomInset ?? 0) + (isExpanded ? 4 : 10),
344 // Always opaque — the glass effect lives on the ComposerSurface (pill/card),
345 // not the container. This prevents chat feed text from bleeding through
346 // the padding areas around and below the pill.
347 backgroundColor: palette.screenBackground,
348 }}346 }}
349 >347 >
350 <View348 <View
@@ -395,7 +393,7 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer · 44 unmodified lines
395 value={props.draftMessage}393 value={props.draftMessage}
396 onChangeText={props.onChangeDraftMessage}394 onChangeText={props.onChangeDraftMessage}
397 placeholder={props.placeholder}395 placeholder={props.placeholder}
398 placeholderTextColor={palette.placeholder}396 placeholderTextColor={placeholderColor}
399 editable={props.connectionState === "ready"}397 editable={props.connectionState === "ready"}
400 onFocus={() => setIsFocused(true)}398 onFocus={() => setIsFocused(true)}
401 onBlur={() => setIsFocused(false)}399 onBlur={() => setIsFocused(false)}
@@ -409,7 +407,7 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer · 7 unmodified lines
409 paddingVertical: 4,407 paddingVertical: 4,
410 fontSize: 15,408 fontSize: 15,
411 lineHeight: 22,409 lineHeight: 22,
412 color: palette.text,410 color: foregroundColor,
413 fontFamily: "DMSans_400Regular",411 fontFamily: "DMSans_400Regular",
414 }412 }
415 : {413 : {
@@ -417,7 +415,7 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer · 1 unmodified line
417 paddingVertical: 6,415 paddingVertical: 6,
418 fontSize: 15,416 fontSize: 15,
419 lineHeight: 20,417 lineHeight: 20,
420 color: palette.text,418 color: foregroundColor,
421 fontFamily: "DMSans_400Regular",419 fontFamily: "DMSans_400Regular",
422 }420 }
423 }421 }
@@ -430,11 +428,11 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer · 6 unmodified lines
430 <Pressable key={image.id} onPress={() => onPressImage(image.previewUri)}>428 <Pressable key={image.id} onPress={() => onPressImage(image.previewUri)}>
431 <Image429 <Image
432 source={{ uri: image.previewUri }}430 source={{ uri: image.previewUri }}
431 className="bg-subtle"
433 style={{432 style={{
434 width: 30,433 width: 30,
435 height: 30,434 height: 30,
436 borderRadius: 8,435 borderRadius: 8,
437 backgroundColor: palette.subtleBg,
438 }}436 }}
439 resizeMode="cover"437 resizeMode="cover"
440 />438 />
@@ -442,22 +440,16 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer · 1 unmodified line
442 ))}440 ))}
443 {props.draftAttachments.length > 3 ? (441 {props.draftAttachments.length > 3 ? (
444 <View442 <View
443 className="bg-subtle-strong"
445 style={{444 style={{
446 width: 30,445 width: 30,
447 height: 30,446 height: 30,
448 borderRadius: 8,447 borderRadius: 8,
449 backgroundColor: palette.subtleBgStrong,
450 alignItems: "center",448 alignItems: "center",
451 justifyContent: "center",449 justifyContent: "center",
452 }}450 }}
453 >451 >
454 <Text452 <Text className="text-foreground-muted text-[11px] font-t3-bold">
455 style={{
456 fontSize: 11,
457 fontFamily: "DMSans_700Bold",
458 color: palette.textMuted,
459 }}
460 >
461 +{props.draftAttachments.length - 3}453 +{props.draftAttachments.length - 3}
462 </Text>454 </Text>
463 </View>455 </View>
@@ -497,12 +489,14 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer · 33 unmodified lines
497 <MenuView489 <MenuView
498 actions={modelMenuActions}490 actions={modelMenuActions}
499 onPressAction={({ nativeEvent }) => handleModelMenuAction(nativeEvent.event)}491 onPressAction={({ nativeEvent }) => handleModelMenuAction(nativeEvent.event)}
492 themeVariant={isDarkMode ? "dark" : "light"}
500 >493 >
501 <ControlPill iconNode={<ProviderIcon provider={modelProvider} size={16} />} />494 <ControlPill iconNode={<ProviderIcon provider={modelProvider} size={16} />} />
502 </MenuView>495 </MenuView>
503 <MenuView496 <MenuView
504 actions={optionsMenuActions}497 actions={optionsMenuActions}
505 onPressAction={({ nativeEvent }) => handleOptionsMenuAction(nativeEvent.event)}498 onPressAction={({ nativeEvent }) => handleOptionsMenuAction(nativeEvent.event)}
499 themeVariant={isDarkMode ? "dark" : "light"}
506 >500 >
507 <ControlPill icon="slider.horizontal.3" />501 <ControlPill icon="slider.horizontal.3" />
508 </MenuView>502 </MenuView>
@@ -527,8 +521,8 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer · 18 unmodified lines
527 {/* Queue count */}521 {/* Queue count */}
528 {props.queueCount > 0 ? (522 {props.queueCount > 0 ? (
529 <Text523 <Text
524 className="text-foreground-muted"
530 style={{525 style={{
531 color: palette.textMuted,
532 fontSize: 12,526 fontSize: 12,
533 lineHeight: 18,527 lineHeight: 18,
534 paddingTop: 8,528 paddingTop: 8,
apps/mobile/src/features/threads/ThreadDetailScreen.tsx+10−227
@@ -1,31 +1,23 @@
1import type {1import type {
2 ApprovalRequestId,2 ApprovalRequestId,
3 GitBranch,
4 GitRunStackedActionResult,
5 GitStatusResult,
6 ModelSelection,3 ModelSelection,
7 ProviderApprovalDecision,4 ProviderApprovalDecision,
8 ProviderInteractionMode,5 ProviderInteractionMode,
9 RuntimeMode,6 RuntimeMode,
10 ServerConfig as T3ServerConfig,7 ServerConfig as T3ServerConfig,
11} from "@t3tools/contracts";8} from "@t3tools/contracts";
12import type { GitActionRequestInput } from "@t3tools/client-runtime";
13import * as Haptics from "expo-haptics";9import * as Haptics from "expo-haptics";
14import { SymbolView } from "expo-symbols";
15import { useCallback, useEffect, useMemo, useRef, useState } from "react";10import { useCallback, useEffect, useMemo, useRef, useState } from "react";
16import { Modal, Pressable, useColorScheme, View } from "react-native";11import { View } from "react-native";
17import { Gesture, GestureDetector } from "react-native-gesture-handler";12import { Gesture, GestureDetector } from "react-native-gesture-handler";
18import { KeyboardAvoidingView, KeyboardStickyView } from "react-native-keyboard-controller";13import { KeyboardAvoidingView, KeyboardStickyView } from "react-native-keyboard-controller";
19import { useSafeAreaInsets } from "react-native-safe-area-context";14import { useSafeAreaInsets } from "react-native-safe-area-context";
20import Animated, { runOnJS } from "react-native-reanimated";15import { runOnJS } from "react-native-reanimated";
21 16
22import { AppText as Text, AppTextInput as TextInput } from "../../components/AppText";
23import { GlassSafeAreaView } from "../../components/GlassSafeAreaView";
24import type { StatusTone } from "../../components/StatusPill";17import type { StatusTone } from "../../components/StatusPill";
25import type { DraftComposerImageAttachment } from "../../lib/composerImages";18import type { DraftComposerImageAttachment } from "../../lib/composerImages";
26import type { MobileLayoutVariant } from "../../lib/mobileLayout";19import type { MobileLayoutVariant } from "../../lib/mobileLayout";
27import type { ScopedMobileThread } from "../../lib/scopedEntities";20import type { ScopedMobileThread } from "../../lib/scopedEntities";
28import { makeAppPalette } from "../../lib/theme";
29import type {21import type {
30 PendingApproval,22 PendingApproval,
31 PendingUserInput,23 PendingUserInput,
@@ -33,7 +25,6 @@ import type { · 1 unmodified line
33 ThreadFeedEntry,25 ThreadFeedEntry,
34} from "../../lib/threadActivity";26} from "../../lib/threadActivity";
35import { PendingApprovalCard } from "./PendingApprovalCard";27import { PendingApprovalCard } from "./PendingApprovalCard";
36import { ThreadGitControls } from "./ThreadGitControls";
37import { PendingUserInputCard } from "./PendingUserInputCard";28import { PendingUserInputCard } from "./PendingUserInputCard";
38import {29import {
39 COMPOSER_COLLAPSED_CHROME,30 COMPOSER_COLLAPSED_CHROME,
@@ -41,7 +32,6 @@ import { · 1 unmodified line
41 ThreadComposer,32 ThreadComposer,
42} from "./ThreadComposer";33} from "./ThreadComposer";
43import { ThreadFeed } from "./ThreadFeed";34import { ThreadFeed } from "./ThreadFeed";
44import { screenTitle } from "./threadPresentation";
45 35
46export interface ThreadDetailScreenProps {36export interface ThreadDetailScreenProps {
47 readonly selectedThread: ScopedMobileThread;37 readonly selectedThread: ScopedMobileThread;
@@ -61,12 +51,10 @@ export interface ThreadDetailScreenProps { · 13 unmodified lines
61 readonly draftAttachments: ReadonlyArray<DraftComposerImageAttachment>;51 readonly draftAttachments: ReadonlyArray<DraftComposerImageAttachment>;
62 readonly connectionStateLabel: "ready" | "connecting" | "reconnecting" | "disconnected" | "idle";52 readonly connectionStateLabel: "ready" | "connecting" | "reconnecting" | "disconnected" | "idle";
63 readonly activeThreadBusy: boolean;53 readonly activeThreadBusy: boolean;
64 readonly selectedThreadGitStatus: GitStatusResult | null;54 readonly projectWorkspaceRoot: string | null;
65 readonly gitOperationLabel: string | null;
66 readonly selectedThreadQueueCount: number;55 readonly selectedThreadQueueCount: number;
67 readonly serverConfig: T3ServerConfig | null;56 readonly serverConfig: T3ServerConfig | null;
68 readonly layoutVariant?: MobileLayoutVariant;57 readonly layoutVariant?: MobileLayoutVariant;
69 readonly onBack: () => void;
70 readonly onOpenDrawer: () => void;58 readonly onOpenDrawer: () => void;
71 readonly onOpenConnectionEditor: () => void;59 readonly onOpenConnectionEditor: () => void;
72 readonly onChangeDraftMessage: (value: string) => void;60 readonly onChangeDraftMessage: (value: string) => void;
@@ -74,21 +62,6 @@ export interface ThreadDetailScreenProps { · 1 unmodified line
74 readonly onNativePasteImages: (uris: ReadonlyArray<string>) => Promise<void>;62 readonly onNativePasteImages: (uris: ReadonlyArray<string>) => Promise<void>;
75 readonly onRemoveDraftImage: (imageId: string) => void;63 readonly onRemoveDraftImage: (imageId: string) => void;
76 readonly onRefresh: () => Promise<void>;64 readonly onRefresh: () => Promise<void>;
77 readonly onRefreshSelectedThreadGitStatus: (options?: {
78 readonly quiet?: boolean;
79 }) => Promise<void>;
80 readonly onListSelectedThreadBranches: () => Promise<ReadonlyArray<GitBranch>>;
81 readonly onCheckoutSelectedThreadBranch: (branch: string) => Promise<void>;
82 readonly onCreateSelectedThreadBranch: (branch: string) => Promise<void>;
83 readonly onCreateSelectedThreadWorktree: (input: {
84 readonly baseBranch: string;
85 readonly newBranch: string;
86 }) => Promise<void>;
87 readonly onPullSelectedThreadBranch: () => Promise<void>;
88 readonly onRunSelectedThreadGitAction: (
89 input: GitActionRequestInput,
90 ) => Promise<GitRunStackedActionResult | null>;
91 readonly onRenameThread: (title: string) => Promise<void>;
92 readonly onStopThread: () => Promise<void>;65 readonly onStopThread: () => Promise<void>;
93 readonly onSendMessage: () => void;66 readonly onSendMessage: () => void;
94 readonly onUpdateThreadModelSelection: (modelSelection: ModelSelection) => Promise<void>;67 readonly onUpdateThreadModelSelection: (modelSelection: ModelSelection) => Promise<void>;
@@ -107,7 +80,6 @@ export interface ThreadDetailScreenProps { · 12 unmodified lines
107 customAnswer: string,80 customAnswer: string,
108 ) => void;81 ) => void;
109 readonly onSubmitUserInput: () => Promise<void>;82 readonly onSubmitUserInput: () => Promise<void>;
110 readonly showHeader?: boolean;
111 readonly showContent?: boolean;83 readonly showContent?: boolean;
112}84}
113 85
@@ -182,20 +154,15 @@ function useStreamingHaptics(threadId: string, feed: ReadonlyArray<ThreadFeedEnt · 68 unmodified lines
182}154}
183 155
184export function ThreadDetailScreen(props: ThreadDetailScreenProps) {156export function ThreadDetailScreen(props: ThreadDetailScreenProps) {
185 const { onOpenDrawer, onRefresh, onRefreshSelectedThreadGitStatus } = props;157 const { onOpenDrawer, onRefresh } = props;
186 const isDarkMode = useColorScheme() === "dark";158
187 const palette = makeAppPalette(isDarkMode);
188 const insets = useSafeAreaInsets();159 const insets = useSafeAreaInsets();
189 const agentLabel = `${props.selectedThread.modelSelection.provider} agent`;160 const agentLabel = `${props.selectedThread.modelSelection.provider} agent`;
190 const headerOverlayHeight = insets.top + 118;
191 const composerBottomInset = Math.max(insets.bottom, 12);161 const composerBottomInset = Math.max(insets.bottom, 12);
192 const [composerExpanded, setComposerExpanded] = useState(false);162 const [composerExpanded, setComposerExpanded] = useState(false);
193 const composerChrome = composerExpanded ? COMPOSER_EXPANDED_CHROME : COMPOSER_COLLAPSED_CHROME;163 const composerChrome = composerExpanded ? COMPOSER_EXPANDED_CHROME : COMPOSER_COLLAPSED_CHROME;
194 const composerOverlapHeight = composerChrome + composerBottomInset;164 const composerOverlapHeight = composerChrome + composerBottomInset;
195 const [renameVisible, setRenameVisible] = useState(false);
196 const [refreshing, setRefreshing] = useState(false);165 const [refreshing, setRefreshing] = useState(false);
197 const [renameDraft, setRenameDraft] = useState(props.selectedThread.title);
198 const showHeader = props.showHeader ?? true;
199 const showContent = props.showContent ?? true;166 const showContent = props.showContent ?? true;
200 const layoutVariant = props.layoutVariant ?? "compact";167 const layoutVariant = props.layoutVariant ?? "compact";
201 const isSplitLayout = layoutVariant === "split";168 const isSplitLayout = layoutVariant === "split";
@@ -214,12 +181,12 @@ export function ThreadDetailScreen(props: ThreadDetailScreenProps) { · 12 unmodified lines
214 setRefreshing(true);181 setRefreshing(true);
215 try {182 try {
216 await onRefresh();183 await onRefresh();
217 await onRefreshSelectedThreadGitStatus();
218 } finally {184 } finally {
219 setRefreshing(false);185 setRefreshing(false);
220 }186 }
221 }, [onRefresh, onRefreshSelectedThreadGitStatus, refreshing]);187 }, [onRefresh, refreshing]);
222 188
189 const drawerGestureThreshold = 80;
223 const headerDrawerGesture = useMemo(190 const headerDrawerGesture = useMemo(
224 () =>191 () =>
225 Gesture.Pan()192 Gesture.Pan()
@@ -229,110 +196,16 @@ export function ThreadDetailScreen(props: ThreadDetailScreenProps) { · 3 unmodified lines
229 .failOffsetY([-24, 24])196 .failOffsetY([-24, 24])
230 .onEnd((event) => {197 .onEnd((event) => {
231 const translationX = Math.max(event.translationX, 0);198 const translationX = Math.max(event.translationX, 0);
232 if (event.y < headerOverlayHeight && translationX > 56) {199 if (event.y < drawerGestureThreshold && translationX > 56) {
233 runOnJS(completeDrawerGesture)();200 runOnJS(completeDrawerGesture)();
234 }201 }
235 }),202 }),
236 [completeDrawerGesture, headerOverlayHeight, isSplitLayout],203 [completeDrawerGesture, isSplitLayout],
237 );204 );
238 205
239 async function handleSubmitRename(): Promise<void> {
240 const trimmed = renameDraft.trim();
241 if (trimmed.length === 0) {
242 return;
243 }
244
245 await props.onRenameThread(trimmed);
246 setRenameVisible(false);
247 }
248
249 return (206 return (
250 <GestureDetector gesture={headerDrawerGesture}>207 <GestureDetector gesture={headerDrawerGesture}>
251 <View style={{ flex: 1, backgroundColor: palette.screenBackground }}>208 <View className="flex-1">
252 {showHeader ? (
253 <View className="absolute inset-x-0 top-0 z-20">
254 <View
255 style={{
256 backgroundColor: palette.headerBackground,
257 borderBottomWidth: 1,
258 borderBottomColor: palette.headerBorder,
259 }}
260 >
261 <GlassSafeAreaView
262 leftSlot={
263 isSplitLayout ? null : (
264 <Pressable
265 className="h-11 w-11 items-center justify-center rounded-full"
266 style={{
267 backgroundColor: palette.subtleBg,
268 }}
269 onPress={props.onBack}
270 >
271 <SymbolView
272 name="chevron.left"
273 size={18}
274 tintColor={palette.icon}
275 type="monochrome"
276 />
277 </Pressable>
278 )
279 }
280 centerSlot={
281 <View style={{ alignItems: "center", gap: 2, width: "100%" }}>
282 <Pressable
283 style={{ maxWidth: "100%" }}
284 onLongPress={() => {
285 setRenameDraft(props.selectedThread.title);
286 setRenameVisible(true);
287 }}
288 >
289 <Animated.Text
290 numberOfLines={1}
291 style={{
292 color: palette.text,
293 fontSize: 17,
294 fontWeight: "800",
295 lineHeight: 22,
296 textAlign: "center",
297 }}
298 >
299 {props.selectedThread.title}
300 </Animated.Text>
301 </Pressable>
302 <Text
303 className="text-[11px] font-t3-medium"
304 numberOfLines={1}
305 style={{ color: palette.textMuted, letterSpacing: 0.3, textAlign: "center" }}
306 >
307 {[
308 screenTitle(props.serverConfig, null),
309 props.selectedThread.environmentLabel,
310 ]
311 .filter(Boolean)
312 .join(" · ")}
313 </Text>
314 </View>
315 }
316 rightSlot={
317 <ThreadGitControls
318 currentBranch={props.selectedThread.branch}
319 currentWorktreePath={props.selectedThread.worktreePath}
320 gitStatus={props.selectedThreadGitStatus}
321 gitOperationLabel={props.gitOperationLabel}
322 onRefreshStatus={props.onRefreshSelectedThreadGitStatus}
323 onListBranches={props.onListSelectedThreadBranches}
324 onCheckoutBranch={props.onCheckoutSelectedThreadBranch}
325 onCreateBranch={props.onCreateSelectedThreadBranch}
326 onCreateWorktree={props.onCreateSelectedThreadWorktree}
327 onPull={props.onPullSelectedThreadBranch}
328 onRunAction={props.onRunSelectedThreadGitAction}
329 />
330 }
331 />
332 </View>
333 </View>
334 ) : null}
335
336 {/* Feed area — KAV shrinks this when keyboard opens */}209 {/* Feed area — KAV shrinks this when keyboard opens */}
337 <KeyboardAvoidingView behavior="padding" style={{ flex: 1 }}>210 <KeyboardAvoidingView behavior="padding" style={{ flex: 1 }}>
338 {showContent ? (211 {showContent ? (
@@ -343,7 +216,6 @@ export function ThreadDetailScreen(props: ThreadDetailScreenProps) { · 4 unmodified lines
343 httpBaseUrl={props.httpBaseUrl}216 httpBaseUrl={props.httpBaseUrl}
344 bearerToken={props.bearerToken}217 bearerToken={props.bearerToken}
345 agentLabel={agentLabel}218 agentLabel={agentLabel}
346 contentTopInset={headerOverlayHeight + 20}
347 contentBottomInset={composerOverlapHeight + 8}219 contentBottomInset={composerOverlapHeight + 8}
348 layoutVariant={layoutVariant}220 layoutVariant={layoutVariant}
349 composerExpanded={composerExpanded}221 composerExpanded={composerExpanded}
@@ -410,95 +282,6 @@ export function ThreadDetailScreen(props: ThreadDetailScreenProps) { · 60 unmodified lines
410 />282 />
411 </KeyboardStickyView>283 </KeyboardStickyView>
412 ) : null}284 ) : null}
413
414 <Modal
415 transparent
416 animationType="fade"
417 visible={renameVisible}
418 onRequestClose={() => setRenameVisible(false)}
419 >
420 <View
421 className="flex-1 items-center justify-center px-5"
422 style={{ backgroundColor: palette.backdrop }}
423 >
424 <View
425 className="w-full gap-4 px-4 py-4"
426 style={{
427 maxWidth: 420,
428 borderWidth: 1,
429 borderColor: palette.border,
430 backgroundColor: palette.card,
431 }}
432 >
433 <View className="gap-2">
434 <Text
435 className="text-[11px] font-bold uppercase"
436 style={{ color: palette.textMuted, letterSpacing: 1.2 }}
437 >
438 Thread name
439 </Text>
440 <Text
441 className="text-[20px] font-extrabold leading-[24px]"
442 style={{ color: palette.text }}
443 >
444 Rename thread
445 </Text>
446 </View>
447
448 <TextInput
449 value={renameDraft}
450 onChangeText={setRenameDraft}
451 placeholder="Thread title"
452 className="min-h-[56px] px-4 py-3 text-[15px]"
453 style={{
454 borderWidth: 1,
455 borderColor: palette.border,
456 backgroundColor: palette.inputBackground,
457 color: palette.text,
458 }}
459 onSubmitEditing={() => {
460 void handleSubmitRename();
461 }}
462 />
463
464 <View className="flex-row gap-3">
465 <Pressable
466 className="min-h-[48px] flex-1 items-center justify-center px-4 py-3"
467 style={{
468 borderWidth: 1,
469 borderColor: palette.border,
470 backgroundColor: palette.secondaryButton,
471 }}
472 onPress={() => {
473 setRenameDraft(props.selectedThread.title);
474 setRenameVisible(false);
475 }}
476 >
477 <Text
478 className="text-sm font-extrabold uppercase"
479 style={{ color: palette.text, letterSpacing: 1 }}
480 >
481 Cancel
482 </Text>
483 </Pressable>
484 <Pressable
485 className="min-h-[48px] flex-1 items-center justify-center px-4 py-3"
486 style={{ backgroundColor: palette.primaryButton }}
487 onPress={() => {
488 void handleSubmitRename();
489 }}
490 >
491 <Text
492 className="text-sm font-extrabold uppercase"
493 style={{ color: palette.primaryButtonText, letterSpacing: 1 }}
494 >
495 Save
496 </Text>
497 </Pressable>
498 </View>
499 </View>
500 </View>
501 </Modal>
502 </View>285 </View>
503 </GestureDetector>286 </GestureDetector>
504 );287 );
apps/mobile/src/features/threads/ThreadFeed.tsx+45−38
@@ -4,15 +4,15 @@ import { LegendList, type LegendListRef } from "@legendapp/list/react-native"; · 3 unmodified lines
4import { SymbolView } from "expo-symbols";4import { SymbolView } from "expo-symbols";
5import { memo, useCallback, useEffect, useState, useRef } from "react";5import { memo, useCallback, useEffect, useState, useRef } from "react";
6import Markdown from "react-native-markdown-display";6import Markdown from "react-native-markdown-display";
7import { Image, Pressable, ScrollView, useColorScheme, View } from "react-native";7import { Image, Pressable, ScrollView, View } from "react-native";
8import { TouchableOpacity } from "react-native-gesture-handler";8import { TouchableOpacity } from "react-native-gesture-handler";
9import ImageViewing from "react-native-image-viewing";9import ImageViewing from "react-native-image-viewing";
10import { useThemeColor } from "../../lib/useThemeColor";
10 11
11import { AppText as Text } from "../../components/AppText";12import { AppText as Text } from "../../components/AppText";
12import { EmptyState } from "../../components/EmptyState";13import { EmptyState } from "../../components/EmptyState";
13import { cx } from "../../lib/classNames";14import { cn } from "../../lib/cn";
14import type { MobileLayoutVariant } from "../../lib/mobileLayout";15import type { MobileLayoutVariant } from "../../lib/mobileLayout";
15import { makeAppPalette } from "../../lib/theme";
16import type { ThreadFeedEntry } from "../../lib/threadActivity";16import type { ThreadFeedEntry } from "../../lib/threadActivity";
17import { relativeTime } from "../../lib/time";17import { relativeTime } from "../../lib/time";
18import { messageImageUrl } from "./threadPresentation";18import { messageImageUrl } from "./threadPresentation";
@@ -23,7 +23,6 @@ export interface ThreadFeedProps { · 4 unmodified lines
23 readonly httpBaseUrl: string | null;23 readonly httpBaseUrl: string | null;
24 readonly bearerToken: string | null;24 readonly bearerToken: string | null;
25 readonly agentLabel: string;25 readonly agentLabel: string;
26 readonly contentTopInset?: number;
27 readonly contentBottomInset?: number;26 readonly contentBottomInset?: number;
28 readonly refreshing?: boolean;27 readonly refreshing?: boolean;
29 readonly onRefresh?: () => void;28 readonly onRefresh?: () => void;
@@ -99,18 +98,26 @@ function buildActivityRows( · 69 unmodified lines
99 98
100const MAX_VISIBLE_WORK_LOG_ENTRIES = 6;99const MAX_VISIBLE_WORK_LOG_ENTRIES = 6;
101 100
102function makeMarkdownStyles(isDarkMode: boolean) {101interface MarkdownStyleSets {
103 const bodyColor = isDarkMode ? "#e5e5e5" : "#262626";102 // eslint-disable-next-line @typescript-eslint/no-explicit-any
104 const strongColor = isDarkMode ? "#f5f5f5" : "#171717";103 readonly user: Record<string, any>;
105 const linkColor = isDarkMode ? "#60a5fa" : "#2563eb";104 // eslint-disable-next-line @typescript-eslint/no-explicit-any
106 const blockquoteBg = isDarkMode ? "rgba(255,255,255,0.03)" : "rgba(0,0,0,0.02)";105 readonly assistant: Record<string, any>;
107 const blockquoteBorder = isDarkMode ? "rgba(255,255,255,0.10)" : "rgba(0,0,0,0.10)";106}
108 const codeBg = isDarkMode ? "rgba(255,255,255,0.06)" : "rgba(0,0,0,0.04)";107
109 const codeText = isDarkMode ? "#e5e5e5" : "#262626";108function useMarkdownStyles(): MarkdownStyleSets {
110 const hrColor = isDarkMode ? "rgba(255,255,255,0.08)" : "rgba(0,0,0,0.08)";109 const bodyColor = useThemeColor("--color-md-body");
111 const userCodeBg = isDarkMode ? "rgba(255,255,255,0.10)" : "rgba(0,0,0,0.05)";110 const strongColor = useThemeColor("--color-md-strong");
112 const userCodeText = isDarkMode ? "#e5e5e5" : "#1e3a5f";111 const linkColor = useThemeColor("--color-md-link");
113 const userFenceBg = isDarkMode ? "rgba(255,255,255,0.08)" : "rgba(0,0,0,0.04)";112 const blockquoteBg = useThemeColor("--color-md-blockquote-bg");
113 const blockquoteBorder = useThemeColor("--color-md-blockquote-border");
114 const codeBg = useThemeColor("--color-md-code-bg");
115 const codeText = useThemeColor("--color-md-code-text");
116 const hrColor = useThemeColor("--color-md-hr");
117 const userCodeBg = useThemeColor("--color-md-user-code-bg");
118 const userCodeText = useThemeColor("--color-md-user-code-text");
119 const userFenceBg = useThemeColor("--color-md-user-fence-bg");
120 const userFenceText = useThemeColor("--color-md-user-fence-text");
114 121
115 const base = {122 const base = {
116 body: {123 body: {
@@ -193,7 +200,7 @@ function makeMarkdownStyles(isDarkMode: boolean) { · 76 unmodified lines
193 },200 },
194 code_block: {201 code_block: {
195 backgroundColor: userFenceBg,202 backgroundColor: userFenceBg,
196 color: userCodeText,203 color: userFenceText,
197 borderRadius: 12,204 borderRadius: 12,
198 padding: 12,205 padding: 12,
199 fontFamily: "ui-monospace",206 fontFamily: "ui-monospace",
@@ -202,7 +209,7 @@ function makeMarkdownStyles(isDarkMode: boolean) { · 2 unmodified lines
202 },209 },
203 fence: {210 fence: {
204 backgroundColor: userFenceBg,211 backgroundColor: userFenceBg,
205 color: userCodeText,212 color: userFenceText,
206 borderRadius: 12,213 borderRadius: 12,
207 padding: 12,214 padding: 12,
208 fontFamily: "ui-monospace",215 fontFamily: "ui-monospace",
@@ -253,12 +260,12 @@ function renderFeedEntry( · 44 unmodified lines
253 readonly onCopyWorkRow: (rowId: string, value: string) => void;260 readonly onCopyWorkRow: (rowId: string, value: string) => void;
254 readonly onToggleWorkGroup: (groupId: string) => void;261 readonly onToggleWorkGroup: (groupId: string) => void;
255 readonly onPressImage: (uri: string, headers?: Record<string, string>) => void;262 readonly onPressImage: (uri: string, headers?: Record<string, string>) => void;
256 readonly isDarkMode: boolean;263 readonly iconSubtleColor: string | import("react-native").ColorValue;
264 readonly markdownStyles: MarkdownStyleSets;
257 },265 },
258) {266) {
259 const entry = info.item;267 const entry = info.item;
260 const markdownStyles = makeMarkdownStyles(props.isDarkMode);268 const { markdownStyles, iconSubtleColor } = props;
261 const palette = makeAppPalette(props.isDarkMode);
262 269
263 if (entry.type === "message") {270 if (entry.type === "message") {
264 const { message } = entry;271 const { message } = entry;
@@ -389,18 +396,13 @@ function renderFeedEntry( · 124 unmodified lines
389 {visibleRows.map((row, index) => (396 {visibleRows.map((row, index) => (
390 <View397 <View
391 key={row.id}398 key={row.id}
392 className={cx(399 className={cn(
393 "flex-row items-center gap-2 rounded-lg px-1 py-1",400 "flex-row items-center gap-2 rounded-lg px-1 py-1",
394 index > 0 && "border-t border-neutral-200/80 dark:border-white/[0.06]",401 index > 0 && "border-t border-neutral-200/80 dark:border-white/[0.06]",
395 )}402 )}
396 >403 >
397 <View className="items-center justify-center pt-0.5">404 <View className="items-center justify-center pt-0.5">
398 <SymbolView405 <SymbolView name="terminal" size={13} tintColor={iconSubtleColor} type="monochrome" />
399 name="terminal"
400 size={13}
401 tintColor={palette.iconSubtle}
402 type="monochrome"
403 />
404 </View>406 </View>
405 <ScrollView407 <ScrollView
406 horizontal408 horizontal
@@ -439,7 +441,6 @@ function renderFeedEntry( · 32 unmodified lines
439}441}
440 442
441export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) {443export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) {
442 const isDarkMode = useColorScheme() === "dark";
443 const listRef = useRef<LegendListRef>(null);444 const listRef = useRef<LegendListRef>(null);
444 const copyFeedbackTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);445 const copyFeedbackTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
445 const [copiedRowId, setCopiedRowId] = useState<string | null>(null);446 const [copiedRowId, setCopiedRowId] = useState<string | null>(null);
@@ -450,6 +451,9 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { · 4 unmodified lines
450 } | null>(null);451 } | null>(null);
451 const horizontalPadding = props.layoutVariant === "split" ? 20 : 16;452 const horizontalPadding = props.layoutVariant === "split" ? 20 : 16;
452 453
454 const iconSubtleColor = useThemeColor("--color-icon-subtle");
455 const markdownStyles = useMarkdownStyles();
456
453 useEffect(() => {457 useEffect(() => {
454 setCopiedRowId(null);458 setCopiedRowId(null);
455 setExpandedWorkGroups({});459 setExpandedWorkGroups({});
@@ -509,12 +513,14 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { · 53 unmodified lines
509 onCopyWorkRow,513 onCopyWorkRow,
510 onToggleWorkGroup,514 onToggleWorkGroup,
511 onPressImage,515 onPressImage,
512 isDarkMode,516 iconSubtleColor,
517 markdownStyles,
513 }),518 }),
514 [519 [
515 copiedRowId,520 copiedRowId,
516 expandedWorkGroups,521 expandedWorkGroups,
517 isDarkMode,522 iconSubtleColor,
523 markdownStyles,
518 onCopyWorkRow,524 onCopyWorkRow,
519 onPressImage,525 onPressImage,
520 onToggleWorkGroup,526 onToggleWorkGroup,
@@ -525,12 +531,12 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { · 4 unmodified lines
525 531
526 if (props.feed.length === 0) {532 if (props.feed.length === 0) {
527 return (533 return (
528 <View534 <ScrollView
529 className="flex-1"535 style={{ flex: 1 }}
530 style={{536 contentInsetAdjustmentBehavior="automatic"
531 minHeight: 0,537 contentContainerStyle={{
538 flexGrow: 1,
532 paddingHorizontal: horizontalPadding,539 paddingHorizontal: horizontalPadding,
533 paddingTop: props.contentTopInset ?? 18,
534 paddingBottom: props.contentBottomInset ?? 18,540 paddingBottom: props.contentBottomInset ?? 18,
535 }}541 }}
536 >542 >
@@ -538,7 +544,7 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { · 1 unmodified line
538 title="No conversation yet"544 title="No conversation yet"
539 detail="Ask the agent to inspect the repo, run a command, or continue the active thread."545 detail="Ask the agent to inspect the repo, run a command, or continue the active thread."
540 />546 />
541 </View>547 </ScrollView>
542 );548 );
543 }549 }
544 550
@@ -548,6 +554,7 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { · 3 unmodified lines
548 ref={listRef}554 ref={listRef}
549 key={props.threadId}555 key={props.threadId}
550 style={{ flex: 1 }}556 style={{ flex: 1 }}
557 contentInsetAdjustmentBehavior="automatic"
551 data={props.feed as ThreadFeedEntry[]}558 data={props.feed as ThreadFeedEntry[]}
552 renderItem={renderItem}559 renderItem={renderItem}
553 keyExtractor={(entry) => `${entry.type}:${entry.id}`}560 keyExtractor={(entry) => `${entry.type}:${entry.id}`}
@@ -561,7 +568,7 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { · 7 unmodified lines
561 onRefresh={props.onRefresh}568 onRefresh={props.onRefresh}
562 contentContainerStyle={{569 contentContainerStyle={{
563 paddingHorizontal: horizontalPadding,570 paddingHorizontal: horizontalPadding,
564 paddingTop: props.contentTopInset ?? 18,571 paddingTop: 8,
565 paddingBottom: props.contentBottomInset ?? 18,572 paddingBottom: props.contentBottomInset ?? 18,
566 }}573 }}
567 />574 />
apps/mobile/src/features/threads/ThreadGitControls.tsx+66−1172
@@ -1,93 +1,13 @@
1import type { GitRunStackedActionResult, GitStatusResult } from "@t3tools/contracts";
1import {2import {
2 BottomSheetBackdrop,
3 BottomSheetModal,
4 BottomSheetScrollView,
5 BottomSheetView,
6} from "@gorhom/bottom-sheet";
7import { MenuView } from "@react-native-menu/menu";
8import type { GitBranch, GitRunStackedActionResult, GitStatusResult } from "@t3tools/contracts";
9import {
10 buildMenuItems,
11 getGitActionDisabledReason,
12 type GitActionRequestInput,3 type GitActionRequestInput,
13 requiresDefaultBranchConfirmation,4 requiresDefaultBranchConfirmation,
14 resolveDefaultBranchActionDialogCopy,
15 resolveQuickAction,5 resolveQuickAction,
16} from "@t3tools/client-runtime";6} from "@t3tools/client-runtime";
17import { resolveAutoFeatureBranchName, sanitizeFeatureBranchName } from "@t3tools/shared/git";7import { useLocalSearchParams, useRouter } from "expo-router";
18import { SymbolView } from "expo-symbols";8import Stack from "expo-router/stack";
19import type { ComponentProps, RefObject } from "react";9import { useCallback, useMemo } from "react";
20import { useCallback, useEffect, useMemo, useRef, useState } from "react";10import { Alert, Linking } from "react-native";
21import { Alert, Linking, Pressable, useColorScheme, View } from "react-native";
22import { useSafeAreaInsets } from "react-native-safe-area-context";
23
24import { AppText as Text, AppTextInput as TextInput } from "../../components/AppText";
25import { StatusPill } from "../../components/StatusPill";
26import { makeAppPalette } from "../../lib/theme";
27
28type PendingDefaultBranchAction = {
29 readonly action: Extract<
30 GitActionRequestInput["action"],
31 "push" | "create_pr" | "commit_push" | "commit_push_pr"
32 >;
33 readonly branchName: string;
34 readonly includesCommit: boolean;
35 readonly commitMessage?: string;
36 readonly filePaths?: readonly string[];
37};
38
39function useSheetVisibility(sheetRef: RefObject<BottomSheetModal | null>, visible: boolean) {
40 useEffect(() => {
41 const sheet = sheetRef.current;
42 if (!sheet) {
43 return;
44 }
45
46 if (visible) {
47 sheet.present();
48 return;
49 }
50
51 sheet.dismiss();
52 }, [sheetRef, visible]);
53}
54
55function statusSummary(gitStatus: GitStatusResult | null): string {
56 if (!gitStatus) {
57 return "Loading branch status…";
58 }
59
60 if (!gitStatus.isRepo) {
61 return "Not a git repository";
62 }
63
64 const parts: string[] = [];
65 if (gitStatus.hasWorkingTreeChanges) {
66 parts.push(
67 `${gitStatus.workingTree.files.length} file${gitStatus.workingTree.files.length === 1 ? "" : "s"} changed`,
68 );
69 } else {
70 parts.push("Clean");
71 }
72 if (gitStatus.aheadCount > 0) {
73 parts.push(`${gitStatus.aheadCount} ahead`);
74 }
75 if (gitStatus.behindCount > 0) {
76 parts.push(`${gitStatus.behindCount} behind`);
77 }
78 if (gitStatus.pr?.state === "open") {
79 parts.push(`PR #${gitStatus.pr.number} open`);
80 }
81
82 return parts.join(" · ");
83}
84
85function deriveAvailableBranchLabel(
86 currentBranch: string | null,
87 gitStatus: GitStatusResult | null,
88): string {
89 return gitStatus?.branch ?? currentBranch ?? "Detached HEAD";
90}
91 11
92function truncateMiddle(value: string, maxLength: number): string {12function truncateMiddle(value: string, maxLength: number): string {
93 if (value.length <= maxLength) {13 if (value.length <= maxLength) {
@@ -130,211 +50,26 @@ function compactMenuStatus(gitStatus: GitStatusResult | null): string { · 36 unmodified lines
130 return parts.join(" · ");50 return parts.join(" · ");
131}51}
132 52
133function triggerStatusLabel(
134 gitStatus: GitStatusResult | null,
135 gitOperationLabel: string | null,
136): string {
137 if (gitOperationLabel) {
138 return "Working";
139 }
140 if (!gitStatus) {
141 return "Checking";
142 }
143 if (!gitStatus.isRepo) {
144 return "No repo";
145 }
146 if (gitStatus.hasWorkingTreeChanges) {
147 return `${gitStatus.workingTree.files.length} changed`;
148 }
149 if (gitStatus.behindCount > 0) {
150 return `${gitStatus.behindCount} behind`;
151 }
152 if (gitStatus.aheadCount > 0) {
153 return `${gitStatus.aheadCount} ahead`;
154 }
155 if (gitStatus.pr?.state === "open") {
156 return "PR open";
157 }
158 return "Clean";
159}
160
161function menuItemIconName(
162 icon: "commit" | "push" | "pr",
163): ComponentProps<typeof SymbolView>["name"] {
164 if (icon === "commit") return "checkmark.circle";
165 if (icon === "push") return "arrow.up.circle";
166 return "arrow.up.right.circle";
167}
168
169function SheetActionButton(props: {
170 readonly icon: ComponentProps<typeof SymbolView>["name"];
171 readonly label: string;
172 readonly disabled?: boolean;
173 readonly tone?: "primary" | "secondary" | "danger";
174 readonly onPress: () => void;
175}) {
176 const isDarkMode = useColorScheme() === "dark";
177 const palette = makeAppPalette(isDarkMode);
178 const tone = props.tone ?? "secondary";
179 const colors =
180 tone === "primary"
181 ? {
182 backgroundColor: palette.primaryButton,
183 borderColor: "transparent",
184 textColor: palette.primaryButtonText,
185 }
186 : tone === "danger"
187 ? {
188 backgroundColor: palette.dangerButton,
189 borderColor: palette.dangerBorder,
190 textColor: palette.dangerText,
191 }
192 : {
193 backgroundColor: palette.secondaryButton,
194 borderColor: palette.secondaryButtonBorder,
195 textColor: palette.secondaryButtonText,
196 };
197
198 return (
199 <Pressable
200 className="min-h-[48px] flex-1 flex-row items-center justify-center gap-2 rounded-[18px] px-4 py-3"
201 disabled={props.disabled}
202 style={{
203 backgroundColor: colors.backgroundColor,
204 borderWidth: tone === "primary" ? 0 : 1,
205 borderColor: colors.borderColor,
206 opacity: props.disabled ? 0.45 : 1,
207 }}
208 onPress={props.onPress}
209 >
210 <SymbolView name={props.icon} size={16} tintColor={colors.textColor} type="monochrome" />
211 <Text
212 className="text-[12px] font-t3-bold uppercase"
213 style={{ color: colors.textColor, letterSpacing: 0.9 }}
214 >
215 {props.label}
216 </Text>
217 </Pressable>
218 );
219}
220
221function MetaCard(props: { readonly label: string; readonly value: string }) {
222 const isDarkMode = useColorScheme() === "dark";
223 const palette = makeAppPalette(isDarkMode);
224 return (
225 <View
226 className="rounded-[18px] border px-4 py-3"
227 style={{ backgroundColor: palette.card, borderColor: palette.border }}
228 >
229 <Text
230 className="text-[11px] font-t3-bold uppercase"
231 style={{ color: palette.textMuted, letterSpacing: 0.9 }}
232 >
233 {props.label}
234 </Text>
235 <Text
236 selectable
237 className="text-[13px] font-medium"
238 numberOfLines={1}
239 style={{ color: palette.text }}
240 >
241 {props.value}
242 </Text>
243 </View>
244 );
245}
246
247function SheetListRow(props: {
248 readonly icon: ComponentProps<typeof SymbolView>["name"];
249 readonly title: string;
250 readonly subtitle?: string | null;
251 readonly disabled?: boolean;
252 readonly onPress: () => void;
253}) {
254 const isDarkMode = useColorScheme() === "dark";
255 const palette = makeAppPalette(isDarkMode);
256 return (
257 <Pressable
258 className="flex-row items-center gap-3 px-1 py-3"
259 disabled={props.disabled}
260 style={{ opacity: props.disabled ? 0.45 : 1 }}
261 onPress={props.onPress}
262 >
263 <View
264 className="h-9 w-9 items-center justify-center rounded-full"
265 style={{ backgroundColor: palette.subtleBg }}
266 >
267 <SymbolView name={props.icon} size={16} tintColor={palette.icon} type="monochrome" />
268 </View>
269 <View className="flex-1 gap-0.5">
270 <Text className="text-[16px] font-t3-bold" style={{ color: palette.text }}>
271 {props.title}
272 </Text>
273 {props.subtitle ? (
274 <Text className="text-[12px] leading-[17px]" style={{ color: palette.textMuted }}>
275 {props.subtitle}
276 </Text>
277 ) : null}
278 </View>
279 <SymbolView name="chevron.right" size={13} tintColor={palette.iconSubtle} type="monochrome" />
280 </Pressable>
281 );
282}
283
284export function ThreadGitControls(props: {53export function ThreadGitControls(props: {
285 readonly currentBranch: string | null;54 readonly currentBranch: string | null;
286 readonly currentWorktreePath: string | null;
287 readonly gitStatus: GitStatusResult | null;55 readonly gitStatus: GitStatusResult | null;
288 readonly gitOperationLabel: string | null;56 readonly gitOperationLabel: string | null;
289 readonly onRefreshStatus: (options?: { readonly quiet?: boolean }) => Promise<void>;
290 readonly onListBranches: () => Promise<ReadonlyArray<GitBranch>>;
291 readonly onCheckoutBranch: (branch: string) => Promise<void>;
292 readonly onCreateBranch: (branch: string) => Promise<void>;
293 readonly onCreateWorktree: (input: {
294 readonly baseBranch: string;
295 readonly newBranch: string;
296 }) => Promise<void>;
297 readonly onPull: () => Promise<void>;57 readonly onPull: () => Promise<void>;
298 readonly onRunAction: (input: GitActionRequestInput) => Promise<GitRunStackedActionResult | null>;58 readonly onRunAction: (input: GitActionRequestInput) => Promise<GitRunStackedActionResult | null>;
299}) {59}) {
300 const isDarkMode = useColorScheme() === "dark";60 const router = useRouter();
301 const palette = makeAppPalette(isDarkMode);61 const { environmentId, threadId } = useLocalSearchParams<{
302 const insets = useSafeAreaInsets();62 environmentId: string;
303 const {63 threadId: string;
304 currentWorktreePath,64 }>();
305 gitOperationLabel,65 const { gitStatus, gitOperationLabel, onPull, onRunAction } = props;
306 gitStatus,66
307 onCheckoutBranch,67 const currentBranchLabel = gitStatus?.branch ?? props.currentBranch ?? "Detached HEAD";
308 onCreateBranch,
309 onCreateWorktree,
310 onListBranches,
311 onPull,
312 onRefreshStatus,
313 onRunAction,
314 } = props;
315 const gitSheetRef = useRef<BottomSheetModal>(null);
316 const branchSheetRef = useRef<BottomSheetModal>(null);
317 const commitSheetRef = useRef<BottomSheetModal>(null);
318 const defaultBranchSheetRef = useRef<BottomSheetModal>(null);
319 const [gitSheetVisible, setGitSheetVisible] = useState(false);
320 const [branchSheetVisible, setBranchSheetVisible] = useState(false);
321 const [commitSheetVisible, setCommitSheetVisible] = useState(false);
322 const [defaultBranchSheetVisible, setDefaultBranchSheetVisible] = useState(false);
323 const [isEditingFiles, setIsEditingFiles] = useState(false);
324 const [availableBranches, setAvailableBranches] = useState<ReadonlyArray<GitBranch>>([]);
325 const [branchesLoading, setBranchesLoading] = useState(false);
326 const [newBranchName, setNewBranchName] = useState("");
327 const [worktreeBaseBranch, setWorktreeBaseBranch] = useState("");
328 const [worktreeBranchName, setWorktreeBranchName] = useState("");
329 const [dialogCommitMessage, setDialogCommitMessage] = useState("");
330 const [excludedFiles, setExcludedFiles] = useState<ReadonlySet<string>>(new Set());
331 const [pendingDefaultBranchAction, setPendingDefaultBranchAction] =
332 useState<PendingDefaultBranchAction | null>(null);
333 const currentBranchLabel = deriveAvailableBranchLabel(props.currentBranch, gitStatus);
334 const busy = gitOperationLabel !== null;68 const busy = gitOperationLabel !== null;
335 const isRepo = gitStatus?.isRepo ?? true;69 const isRepo = gitStatus?.isRepo ?? true;
336 const hasOriginRemote = gitStatus?.hasOriginRemote ?? false;70 const hasOriginRemote = gitStatus?.hasOriginRemote ?? false;
337 const isDefaultBranch = gitStatus?.isDefaultBranch ?? false;71 const isDefaultBranch = gitStatus?.isDefaultBranch ?? false;
72
338 const quickAction = useMemo(73 const quickAction = useMemo(
339 () =>74 () =>
340 isRepo75 isRepo
@@ -347,163 +82,21 @@ export function ThreadGitControls(props: { · 6 unmodified lines
347 },82 },
348 [busy, gitStatus, hasOriginRemote, isDefaultBranch, isRepo],83 [busy, gitStatus, hasOriginRemote, isDefaultBranch, isRepo],
349 );84 );
350 const menuItems = useMemo(85
351 () => (isRepo ? buildMenuItems(gitStatus, busy, hasOriginRemote) : []),
352 [busy, gitStatus, hasOriginRemote, isRepo],
353 );
354 const quickActionHint = quickAction.disabled86 const quickActionHint = quickAction.disabled
355 ? (quickAction.hint ?? "This action is unavailable.")87 ? (quickAction.hint ?? "This action is unavailable.")
356 : null;88 : null;
357 const triggerStatus = triggerStatusLabel(gitStatus, gitOperationLabel);
358 const allFiles = gitStatus?.workingTree.files ?? [];
359 const selectedFiles = allFiles.filter((file) => !excludedFiles.has(file.path));
360 const allSelected = excludedFiles.size === 0;
361 const noneSelected = selectedFiles.length === 0;
362 const selectedInsertions = selectedFiles.reduce((sum, file) => sum + file.insertions, 0);
363 const selectedDeletions = selectedFiles.reduce((sum, file) => sum + file.deletions, 0);
364 const selectedFilePreview = selectedFiles.slice(0, 3);
365 const renderBackdrop = useCallback(
366 (backdropProps: ComponentProps<typeof BottomSheetBackdrop>) => (
367 <BottomSheetBackdrop
368 {...backdropProps}
369 appearsOnIndex={0}
370 disappearsOnIndex={-1}
371 opacity={0.18}
372 pressBehavior="close"
373 />
374 ),
375 [],
376 );
377 const gitSnapPoints = useMemo<(string | number)[]>(() => ["72%"], []);
378 const branchSnapPoints = useMemo<(string | number)[]>(() => ["86%"], []);
379 const commitSnapPoints = useMemo<(string | number)[]>(() => ["88%"], []);
380 const defaultBranchSnapPoints = useMemo<(string | number)[]>(() => ["40%"], []);
381 const pendingDefaultBranchActionCopy = pendingDefaultBranchAction
382 ? resolveDefaultBranchActionDialogCopy({
383 action: pendingDefaultBranchAction.action,
384 branchName: pendingDefaultBranchAction.branchName,
385 includesCommit: pendingDefaultBranchAction.includesCommit,
386 })
387 : null;
388 const disabledExistingBranches = useMemo(
389 () =>
390 new Set(
391 availableBranches
392 .filter(
393 (branch) => branch.worktreePath !== null && branch.worktreePath !== currentWorktreePath,
394 )
395 .map((branch) => branch.name),
396 ),
397 [availableBranches, currentWorktreePath],
398 );
399 const sheetMenuItems = useMemo(
400 () =>
401 menuItems.map((item) => ({
402 item,
403 disabledReason: getGitActionDisabledReason({
404 item,
405 gitStatus,
406 isBusy: busy,
407 hasOriginRemote,
408 }),
409 })),
410 [busy, gitStatus, hasOriginRemote, menuItems],
411 );
412 const nativeMenuActions = useMemo<
413 Array<{
414 id: string;
415 title: string;
416 subtitle?: string;
417 state?: "off";
418 image?: string;
419 preferredElementSize?: "small";
420 attributes?: {
421 disabled?: boolean;
422 };
423 }>
424 >(
425 () => [
426 {
427 id: "branch",
428 title: compactMenuBranchLabel(currentBranchLabel),
429 subtitle: compactMenuStatus(gitStatus),
430 image: "point.topleft.down.curvedto.point.bottomright.up",
431 state: "off",
432 preferredElementSize: "small",
433 attributes: { disabled: true },
434 },
435 {
436 id: "quick",
437 title: quickAction.label,
438 image:
439 quickAction.kind === "run_pull"
440 ? "arrow.down.circle"
441 : quickAction.kind === "open_pr"
442 ? "arrow.up.right.circle"
443 : quickAction.action === "commit"
444 ? "checkmark.circle"
445 : quickAction.action === "push" || quickAction.action === "commit_push"
446 ? "arrow.up.circle"
447 : "arrow.up.right.circle",
448 ...(quickActionHint ? { subtitle: quickActionHint } : {}),
449 preferredElementSize: "small",
450 ...(quickAction.disabled ? { attributes: { disabled: true } } : {}),
451 },
452 {
453 id: "more",
454 title: "More",
455 image: "ellipsis.circle",
456 subtitle: "Commit, files, branches",
457 preferredElementSize: "small",
458 },
459 ],
460 [
461 currentBranchLabel,
462 gitStatus,
463 quickAction.action,
464 quickAction.disabled,
465 quickAction.kind,
466 quickAction.label,
467 quickActionHint,
468 ],
469 );
470 89
471 useSheetVisibility(gitSheetRef, gitSheetVisible);90 const quickActionIcon = (() => {
472 useSheetVisibility(branchSheetRef, branchSheetVisible);91 if (quickAction.kind === "run_pull") return "arrow.down.circle";
473 useSheetVisibility(commitSheetRef, commitSheetVisible);92 if (quickAction.kind === "open_pr") return "arrow.up.right.circle";
474 useSheetVisibility(defaultBranchSheetRef, defaultBranchSheetVisible);93 if (quickAction.kind === "run_action") {
475 94 if (quickAction.action === "commit") return "checkmark.circle";
476 useEffect(() => {95 if (quickAction.action === "push" || quickAction.action === "commit_push")
477 if (!gitSheetVisible) {96 return "arrow.up.circle";
478 return;
479 }
480 void onRefreshStatus({ quiet: true });
481 }, [gitSheetVisible, onRefreshStatus]);
482
483 useEffect(() => {
484 if (!branchSheetVisible) {
485 return;
486 }97 }
487 98 return "arrow.up.right.circle";
488 setWorktreeBaseBranch(currentBranchLabel === "Detached HEAD" ? "main" : currentBranchLabel);99 })();
489 setBranchesLoading(true);
490 void onListBranches()
491 .then((branches) => setAvailableBranches(branches))
492 .finally(() => setBranchesLoading(false));
493 }, [branchSheetVisible, currentBranchLabel, onListBranches]);
494
495 const closeTransientSheets = useCallback(() => {
496 setGitSheetVisible(false);
497 setCommitSheetVisible(false);
498 setDefaultBranchSheetVisible(false);
499 }, []);
500
501 const openCommitSheet = useCallback(() => {
502 setExcludedFiles(new Set());
503 setIsEditingFiles(false);
504 setDialogCommitMessage("");
505 setCommitSheetVisible(true);
506 }, []);
507 100
508 const openExistingPr = useCallback(async () => {101 const openExistingPr = useCallback(async () => {
509 const prUrl = gitStatus?.pr?.state === "open" ? gitStatus.pr.url : null;102 const prUrl = gitStatus?.pr?.state === "open" ? gitStatus.pr.url : null;
@@ -537,64 +130,26 @@ export function ThreadGitControls(props: { · 27 unmodified lines
537 !input.featureBranch &&130 !input.featureBranch &&
538 requiresDefaultBranchConfirmation(input.action, isDefaultBranch)131 requiresDefaultBranchConfirmation(input.action, isDefaultBranch)
539 ) {132 ) {
540 setPendingDefaultBranchAction({133 router.push({
541 action: confirmableAction,134 pathname: "/threads/[environmentId]/[threadId]/git-confirm",
542 branchName,135 params: {
543 includesCommit: input.action === "commit_push" || input.action === "commit_push_pr",136 environmentId,
544 ...(input.commitMessage ? { commitMessage: input.commitMessage } : {}),137 threadId,
545 ...(input.filePaths ? { filePaths: input.filePaths } : {}),138 confirmAction: confirmableAction,
139 branchName,
140 includesCommit: String(
141 input.action === "commit_push" || input.action === "commit_push_pr",
142 ),
143 },
546 });144 });
547 setDefaultBranchSheetVisible(true);
548 return;145 return;
549 }146 }
550 147
551 closeTransientSheets();
552 await onRunAction(input);148 await onRunAction(input);
553 },149 },
554 [closeTransientSheets, gitStatus, isDefaultBranch, onRunAction],150 [environmentId, gitStatus, isDefaultBranch, onRunAction, router, threadId],
555 );151 );
556 152
557 const continuePendingDefaultBranchAction = useCallback(async () => {
558 if (!pendingDefaultBranchAction) {
559 return;
560 }
561 const action = pendingDefaultBranchAction;
562 setPendingDefaultBranchAction(null);
563 setDefaultBranchSheetVisible(false);
564 await onRunAction({
565 action: action.action,
566 ...(action.commitMessage ? { commitMessage: action.commitMessage } : {}),
567 ...(action.filePaths ? { filePaths: action.filePaths } : {}),
568 });
569 }, [onRunAction, pendingDefaultBranchAction]);
570
571 const movePendingActionToFeatureBranch = useCallback(async () => {
572 if (!pendingDefaultBranchAction) {
573 return;
574 }
575
576 const action = pendingDefaultBranchAction;
577 setPendingDefaultBranchAction(null);
578 setDefaultBranchSheetVisible(false);
579
580 if (action.includesCommit) {
581 await onRunAction({
582 action: action.action,
583 featureBranch: true,
584 ...(action.commitMessage ? { commitMessage: action.commitMessage } : {}),
585 ...(action.filePaths ? { filePaths: action.filePaths } : {}),
586 });
587 return;
588 }
589
590 const branches = await onListBranches();
591 const branchName = resolveAutoFeatureBranchName(
592 branches.filter((branch) => !branch.isRemote).map((branch) => branch.name),
593 );
594 await onCreateBranch(branchName);
595 await onRunAction({ action: action.action });
596 }, [onCreateBranch, onListBranches, onRunAction, pendingDefaultBranchAction]);
597
598 const runQuickAction = useCallback(async () => {153 const runQuickAction = useCallback(async () => {
599 if (quickAction.kind === "open_pr") {154 if (quickAction.kind === "open_pr") {
600 await openExistingPr();155 await openExistingPr();
@@ -609,699 +164,38 @@ export function ThreadGitControls(props: { · 8 unmodified lines
609 }164 }
610 }, [onPull, openExistingPr, quickAction, runActionWithPrompt]);165 }, [onPull, openExistingPr, quickAction, runActionWithPrompt]);
611 166
612 const runCommitAction = useCallback(
613 async (featureBranch: boolean) => {
614 const commitMessage = dialogCommitMessage.trim();
615 setGitSheetVisible(false);
616 setCommitSheetVisible(false);
617 await onRunAction({
618 action: "commit",
619 featureBranch,
620 ...(commitMessage ? { commitMessage } : {}),
621 ...(!allSelected ? { filePaths: selectedFiles.map((file) => file.path) } : {}),
622 });
623 setDialogCommitMessage("");
624 setExcludedFiles(new Set());
625 },
626 [allSelected, dialogCommitMessage, onRunAction, selectedFiles],
627 );
628
629 const onPressMenuItem = useCallback(
630 async (item: (typeof menuItems)[number]) => {
631 if (item.disabled) {
632 return;
633 }
634 if (item.kind === "open_pr") {
635 await openExistingPr();
636 return;
637 }
638 if (item.dialogAction === "commit") {
639 openCommitSheet();
640 return;
641 }
642 if (item.dialogAction === "push") {
643 await runActionWithPrompt({ action: "push" });
644 return;
645 }
646 if (item.dialogAction === "create_pr") {
647 await runActionWithPrompt({ action: "create_pr" });
648 }
649 },
650 [openCommitSheet, openExistingPr, runActionWithPrompt],
651 );
652
653 const onPressNativeMenuAction = useCallback(
654 ({ nativeEvent }: { nativeEvent: { event: string } }) => {
655 if (nativeEvent.event === "quick") {
656 void runQuickAction();
657 return;
658 }
659 if (nativeEvent.event === "more") {
660 setGitSheetVisible(true);
661 }
662 },
663 [runQuickAction],
664 );
665
666 return (167 return (
667 <>168 <Stack.Toolbar placement="right">
668 <MenuView actions={nativeMenuActions} onPressAction={onPressNativeMenuAction}>169 <Stack.Toolbar.Menu icon="point.topleft.down.curvedto.point.bottomright.up">
669 <Pressable170 <Stack.Toolbar.MenuAction
670 className="flex-row items-center gap-2 rounded-full px-3 py-2"171 icon="point.topleft.down.curvedto.point.bottomright.up"
671 style={{ backgroundColor: palette.subtleBg }}172 disabled
173 onPress={() => {}}
174 subtitle={compactMenuStatus(gitStatus)}
672 >175 >
673 <SymbolView176 <Stack.Toolbar.Label>{compactMenuBranchLabel(currentBranchLabel)}</Stack.Toolbar.Label>
674 name="point.topleft.down.curvedto.point.bottomright.up"177 </Stack.Toolbar.MenuAction>
675 size={14}178 <Stack.Toolbar.MenuAction
676 tintColor={palette.icon}179 icon={quickActionIcon}
677 type="monochrome"180 disabled={quickAction.disabled}
678 />181 onPress={() => void runQuickAction()}
679 <View className="shrink">182 subtitle={quickActionHint ?? undefined}
680 <Text
681 className="text-[11px] font-t3-bold uppercase"
682 numberOfLines={1}
683 style={{ color: palette.textMuted, letterSpacing: 0.8 }}
684 >
685 Git
686 </Text>
687 <Text
688 className="text-[12px] font-t3-bold"
689 numberOfLines={1}
690 style={{ color: palette.text }}
691 >
692 {triggerStatus}
693 </Text>
694 </View>
695 <SymbolView
696 name="chevron.down"
697 size={11}
698 tintColor={palette.textMuted}
699 type="monochrome"
700 weight="medium"
701 />
702 </Pressable>
703 </MenuView>
704
705 <BottomSheetModal
706 ref={gitSheetRef}
707 index={0}
708 snapPoints={gitSnapPoints}
709 enableDynamicSizing={false}
710 enablePanDownToClose
711 backdropComponent={renderBackdrop}
712 onDismiss={() => setGitSheetVisible(false)}
713 backgroundStyle={{ backgroundColor: palette.sheetBackground }}
714 handleIndicatorStyle={{ backgroundColor: palette.dotSeparator }}
715 >
716 <BottomSheetView style={{ flex: 1 }}>
717 <BottomSheetScrollView
718 showsVerticalScrollIndicator={false}
719 contentContainerStyle={{
720 paddingHorizontal: 20,
721 paddingTop: 8,
722 paddingBottom: Math.max(insets.bottom, 18) + 8,
723 gap: 14,
724 }}
725 >
726 <View className="flex-row items-start justify-between gap-3">
727 <View className="flex-1 gap-1">
728 <Text
729 className="text-[11px] font-t3-bold uppercase"
730 style={{ color: palette.textMuted, letterSpacing: 1 }}
731 >
732 Branch
733 </Text>
734 <Text className="text-[24px] font-t3-bold" style={{ color: palette.text }}>
735 {currentBranchLabel}
736 </Text>
737 <Text
738 className="text-[13px] font-medium leading-[19px]"
739 style={{ color: palette.textSecondary }}
740 >
741 {statusSummary(gitStatus)}
742 </Text>
743 </View>
744 <Pressable
745 className="rounded-full p-2"
746 disabled={busy}
747 style={{ backgroundColor: palette.subtleBg, opacity: busy ? 0.45 : 1 }}
748 onPress={() => void onRefreshStatus()}
749 >
750 <SymbolView
751 name="arrow.clockwise"
752 size={16}
753 tintColor={palette.icon}
754 type="monochrome"
755 weight="medium"
756 />
757 </Pressable>
758 </View>
759
760 <View className="gap-3">
761 <View
762 className="overflow-hidden rounded-[22px] border px-4 py-1"
763 style={{ backgroundColor: palette.card, borderColor: palette.border }}
764 >
765 {sheetMenuItems.map(({ item, disabledReason }, index) => (
766 <View key={`${item.id}-${item.label}`}>
767 {index > 0 ? (
768 <View className="ml-12 h-px" style={{ backgroundColor: palette.border }} />
769 ) : null}
770 <SheetListRow
771 icon={menuItemIconName(item.icon)}
772 title={item.label}
773 subtitle={disabledReason}
774 disabled={item.disabled}
775 onPress={() => {
776 void onPressMenuItem(item);
777 }}
778 />
779 </View>
780 ))}
781 {(gitStatus?.behindCount ?? 0) > 0 ? (
782 <>
783 <View className="ml-12 h-px" style={{ backgroundColor: palette.border }} />
784 <SheetListRow
785 icon="arrow.down.circle"
786 title="Pull latest"
787 subtitle="Sync this branch with upstream"
788 disabled={busy || !isRepo}
789 onPress={() => {
790 void onPull();
791 }}
792 />
793 </>
794 ) : null}
795 <View className="ml-12 h-px" style={{ backgroundColor: palette.border }} />
796 <SheetListRow
797 icon="point.topleft.down.curvedto.point.bottomright.up"
798 title="Branches & worktrees"
799 subtitle="Switch branch, create branch, or move to a worktree"
800 disabled={busy || !isRepo}
801 onPress={() => setBranchSheetVisible(true)}
802 />
803 </View>
804 </View>
805
806 {currentWorktreePath ? <MetaCard label="Worktree" value={currentWorktreePath} /> : null}
807
808 {gitOperationLabel ? (
809 <View className="self-start">
810 <StatusPill
811 label={gitOperationLabel}
812 pillClassName="bg-neutral-900/8"
813 textClassName="text-neutral-700"
814 size="compact"
815 />
816 </View>
817 ) : null}
818 </BottomSheetScrollView>
819 </BottomSheetView>
820 </BottomSheetModal>
821
822 <BottomSheetModal
823 ref={commitSheetRef}
824 stackBehavior="push"
825 index={0}
826 snapPoints={commitSnapPoints}
827 enableDynamicSizing={false}
828 enablePanDownToClose
829 backdropComponent={renderBackdrop}
830 onDismiss={() => setCommitSheetVisible(false)}
831 backgroundStyle={{ backgroundColor: palette.sheetBackground }}
832 handleIndicatorStyle={{ backgroundColor: palette.dotSeparator }}
833 >
834 <BottomSheetView style={{ flex: 1 }}>
835 <BottomSheetScrollView
836 keyboardShouldPersistTaps="handled"
837 showsVerticalScrollIndicator={false}
838 contentContainerStyle={{
839 paddingHorizontal: 20,
840 paddingTop: 8,
841 paddingBottom: Math.max(insets.bottom, 18) + 8,
842 gap: 16,
843 }}
844 >
845 <View className="gap-1">
846 <Text className="text-[24px] font-t3-bold" style={{ color: palette.text }}>
847 Commit changes
848 </Text>
849 <Text
850 className="text-[13px] font-medium leading-[19px]"
851 style={{ color: palette.textSecondary }}
852 >
853 Review the file set, optionally write a message, then commit here or on a new
854 feature branch.
855 </Text>
856 </View>
857
858 <View
859 className="gap-3 rounded-[22px] border px-4 py-4"
860 style={{ backgroundColor: palette.card, borderColor: palette.border }}
861 >
862 <View className="flex-row items-center justify-between gap-3">
863 <Text className="text-[13px] font-medium" style={{ color: palette.textMuted }}>
864 Branch
865 </Text>
866 <Text className="text-[15px] font-t3-bold" style={{ color: palette.text }}>
867 {gitStatus?.branch ?? "(detached HEAD)"}
868 </Text>
869 </View>
870 {isDefaultBranch ? (
871 <Text
872 className="text-[12px] leading-[18px]"
873 style={{ color: isDarkMode ? "#fbbf24" : "#b45309" }}
874 >
875 Warning: this is the default branch.
876 </Text>
877 ) : null}
878 </View>
879
880 <View
881 className="gap-3 rounded-[22px] border px-4 py-4"
882 style={{ backgroundColor: palette.card, borderColor: palette.border }}
883 >
884 <View className="flex-row items-center justify-between gap-3">
885 <View className="gap-1">
886 <Text className="text-[16px] font-t3-bold" style={{ color: palette.text }}>
887 Files
888 </Text>
889 <Text className="text-[12px] leading-[18px]" style={{ color: palette.textMuted }}>
890 {selectedFiles.length} selected · +{selectedInsertions} / -{selectedDeletions}
891 </Text>
892 </View>
893 <View className="flex-row items-center gap-2">
894 {!allSelected && isEditingFiles ? (
895 <Pressable
896 className="rounded-full px-3 py-2"
897 style={{ backgroundColor: palette.subtleBg }}
898 onPress={() => setExcludedFiles(new Set())}
899 >
900 <Text
901 className="text-[11px] font-t3-bold uppercase"
902 style={{ color: palette.text }}
903 >
904 Reset
905 </Text>
906 </Pressable>
907 ) : null}
908 <Pressable
909 className="rounded-full px-3 py-2"
910 style={{ backgroundColor: palette.subtleBg }}
911 onPress={() => setIsEditingFiles((current) => !current)}
912 >
913 <Text
914 className="text-[11px] font-t3-bold uppercase"
915 style={{ color: palette.text }}
916 >
917 {isEditingFiles ? "Done" : "Edit"}
918 </Text>
919 </Pressable>
920 </View>
921 </View>
922
923 {allFiles.length === 0 ? (
924 <Text
925 className="text-[13px] leading-[19px]"
926 style={{ color: palette.textSecondary }}
927 >
928 No changed files are available to commit.
929 </Text>
930 ) : !isEditingFiles ? (
931 <View className="gap-2">
932 {selectedFilePreview.map((file) => (
933 <View key={file.path} className="flex-row items-center justify-between gap-3">
934 <Text
935 className="flex-1 text-[13px] font-medium"
936 numberOfLines={1}
937 style={{ color: palette.text }}
938 >
939 {file.path}
940 </Text>
941 <Text className="text-[12px] font-t3-bold" style={{ color: "#10b981" }}>
942 +{file.insertions}
943 </Text>
944 <Text className="text-[12px] font-t3-bold" style={{ color: "#f43f5e" }}>
945 -{file.deletions}
946 </Text>
947 </View>
948 ))}
949 {selectedFiles.length > selectedFilePreview.length ? (
950 <Text
951 className="text-[12px] leading-[17px]"
952 style={{ color: palette.textMuted }}
953 >
954 +{selectedFiles.length - selectedFilePreview.length} more files
955 </Text>
956 ) : null}
957 </View>
958 ) : (
959 <View className="gap-2">
960 {allFiles.map((file) => {
961 const included = !excludedFiles.has(file.path);
962 return (
963 <Pressable
964 key={file.path}
965 className="rounded-[18px] border px-4 py-3"
966 style={{
967 backgroundColor: included ? palette.card : palette.subtleBg,
968 borderColor: included ? palette.border : palette.borderSubtle,
969 }}
970 onPress={() => {
971 setExcludedFiles((current) => {
972 const next = new Set(current);
973 if (next.has(file.path)) {
974 next.delete(file.path);
975 } else {
976 next.add(file.path);
977 }
978 return next;
979 });
980 }}
981 >
982 <View className="flex-row items-start justify-between gap-3">
983 <View className="flex-1 gap-1">
984 <Text
985 selectable
986 className="text-[13px] font-t3-bold"
987 style={{ color: included ? palette.text : palette.textMuted }}
988 >
989 {file.path}
990 </Text>
991 {!included ? (
992 <Text
993 className="text-[11px] leading-[16px]"
994 style={{ color: palette.textMuted }}
995 >
996 Excluded from this commit
997 </Text>
998 ) : null}
999 </View>
1000 <View className="items-end gap-1">
1001 <Text className="text-[12px] font-t3-bold" style={{ color: "#10b981" }}>
1002 +{file.insertions}
1003 </Text>
1004 <Text className="text-[12px] font-t3-bold" style={{ color: "#f43f5e" }}>
1005 -{file.deletions}
1006 </Text>
1007 </View>
1008 </View>
1009 </Pressable>
1010 );
1011 })}
1012 </View>
1013 )}
1014 </View>
1015
1016 <View className="gap-2">
1017 <Text className="text-[13px] font-t3-bold" style={{ color: palette.text }}>
1018 Commit message
1019 </Text>
1020 <TextInput
1021 multiline
1022 value={dialogCommitMessage}
1023 onChangeText={setDialogCommitMessage}
1024 placeholder="Leave empty to auto-generate"
1025 textAlignVertical="top"
1026 className="min-h-[128px] rounded-[20px] px-4 py-3.5 font-sans text-[15px]"
1027 style={{
1028 minHeight: 128,
1029 borderWidth: 1,
1030 borderColor: palette.inputBorder,
1031 backgroundColor: palette.inputBackground,
1032 color: palette.text,
1033 }}
1034 />
1035 </View>
1036
1037 <View className="flex-row gap-3">
1038 <View className="flex-1">
1039 <SheetActionButton
1040 icon="arrow.branch"
1041 label="Commit on new branch"
1042 disabled={noneSelected || busy}
1043 onPress={() => {
1044 void runCommitAction(true);
1045 }}
1046 />
1047 </View>
1048 <View className="flex-1">
1049 <SheetActionButton
1050 icon="checkmark.circle"
1051 label="Commit"
1052 tone="primary"
1053 disabled={noneSelected || busy}
1054 onPress={() => {
1055 void runCommitAction(false);
1056 }}
1057 />
1058 </View>
1059 </View>
1060 </BottomSheetScrollView>
1061 </BottomSheetView>
1062 </BottomSheetModal>
1063
1064 <BottomSheetModal
1065 ref={defaultBranchSheetRef}
1066 stackBehavior="push"
1067 index={0}
1068 snapPoints={defaultBranchSnapPoints}
1069 enableDynamicSizing={false}
1070 enablePanDownToClose
1071 backdropComponent={renderBackdrop}
1072 onDismiss={() => setDefaultBranchSheetVisible(false)}
1073 backgroundStyle={{ backgroundColor: palette.sheetBackground }}
1074 handleIndicatorStyle={{ backgroundColor: palette.dotSeparator }}
1075 >
1076 <BottomSheetView
1077 className="gap-4 px-5 pt-2"
1078 style={{ paddingBottom: Math.max(insets.bottom, 18) + 8 }}
1079 >183 >
1080 <View className="gap-1">184 <Stack.Toolbar.Label>{quickAction.label}</Stack.Toolbar.Label>
1081 <Text className="text-[22px] font-t3-bold" style={{ color: palette.text }}>185 </Stack.Toolbar.MenuAction>
1082 {pendingDefaultBranchActionCopy?.title ?? "Run action on default branch?"}186 <Stack.Toolbar.MenuAction
1083 </Text>187 icon="ellipsis.circle"
1084 <Text188 onPress={() =>
1085 className="text-[13px] font-medium leading-[19px]"189 router.push({
1086 style={{ color: palette.textSecondary }}190 pathname: "/threads/[environmentId]/[threadId]/git",
1087 >191 params: { environmentId, threadId },
1088 {pendingDefaultBranchActionCopy?.description ?? "Choose how to continue."}192 })
1089 </Text>193 }
1090 </View>194 subtitle="Commit, files, branches"
1091 195 >
1092 <View className="gap-3">196 <Stack.Toolbar.Label>More</Stack.Toolbar.Label>
1093 <SheetActionButton197 </Stack.Toolbar.MenuAction>
1094 icon="arrow.right.circle"198 </Stack.Toolbar.Menu>
1095 label={pendingDefaultBranchActionCopy?.continueLabel ?? "Continue"}199 </Stack.Toolbar>
1096 onPress={() => {
1097 void continuePendingDefaultBranchAction();
1098 }}
1099 />
1100 <SheetActionButton
1101 icon="arrow.branch"
1102 label="Feature branch & continue"
1103 tone="primary"
1104 onPress={() => {
1105 void movePendingActionToFeatureBranch();
1106 }}
1107 />
1108 </View>
1109 </BottomSheetView>
1110 </BottomSheetModal>
1111
1112 <BottomSheetModal
1113 ref={branchSheetRef}
1114 stackBehavior="push"
1115 index={0}
1116 snapPoints={branchSnapPoints}
1117 enableDynamicSizing={false}
1118 enablePanDownToClose
1119 backdropComponent={renderBackdrop}
1120 onDismiss={() => setBranchSheetVisible(false)}
1121 backgroundStyle={{ backgroundColor: palette.sheetBackground }}
1122 handleIndicatorStyle={{ backgroundColor: palette.dotSeparator }}
1123 >
1124 <BottomSheetView style={{ flex: 1 }}>
1125 <BottomSheetScrollView
1126 showsVerticalScrollIndicator={false}
1127 contentContainerStyle={{
1128 paddingHorizontal: 20,
1129 paddingTop: 8,
1130 paddingBottom: Math.max(insets.bottom, 18) + 8,
1131 gap: 16,
1132 }}
1133 >
1134 <View className="gap-1">
1135 <Text className="text-[22px] font-t3-bold" style={{ color: palette.text }}>
1136 Branches & worktrees
1137 </Text>
1138 <Text
1139 className="text-[13px] font-medium leading-[19px]"
1140 style={{ color: palette.textSecondary }}
1141 >
1142 Switch this thread, create a branch, or move work onto its own worktree.
1143 </Text>
1144 </View>
1145
1146 <View
1147 className="gap-2 rounded-[18px] border px-4 py-4"
1148 style={{ backgroundColor: palette.card, borderColor: palette.border }}
1149 >
1150 <Text
1151 className="text-[11px] font-t3-bold uppercase"
1152 style={{ color: palette.textSecondary, letterSpacing: 1 }}
1153 >
1154 New branch
1155 </Text>
1156 <TextInput
1157 value={newBranchName}
1158 onChangeText={setNewBranchName}
1159 placeholder="feature/mobile-polish"
1160 className="rounded-[18px] px-3.5 py-3 font-sans text-[15px]"
1161 style={{
1162 borderWidth: 1,
1163 borderColor: palette.inputBorder,
1164 backgroundColor: palette.inputBackground,
1165 color: palette.text,
1166 }}
1167 />
1168 <SheetActionButton
1169 icon="plus"
1170 label="Create & checkout"
1171 tone="primary"
1172 disabled={busy || newBranchName.trim().length === 0}
1173 onPress={() => {
1174 const branch = sanitizeFeatureBranchName(newBranchName.trim());
1175 if (branch.length === 0) {
1176 return;
1177 }
1178 void onCreateBranch(branch).then(() => {
1179 setNewBranchName("");
1180 setBranchSheetVisible(false);
1181 });
1182 }}
1183 />
1184 </View>
1185
1186 <View
1187 className="gap-2 rounded-[18px] border px-4 py-4"
1188 style={{ backgroundColor: palette.card, borderColor: palette.border }}
1189 >
1190 <Text
1191 className="text-[11px] font-t3-bold uppercase"
1192 style={{ color: palette.textSecondary, letterSpacing: 1 }}
1193 >
1194 New worktree
1195 </Text>
1196 <TextInput
1197 value={worktreeBaseBranch}
1198 onChangeText={setWorktreeBaseBranch}
1199 placeholder="main"
1200 className="rounded-[18px] px-3.5 py-3 font-sans text-[15px]"
1201 style={{
1202 borderWidth: 1,
1203 borderColor: palette.inputBorder,
1204 backgroundColor: palette.inputBackground,
1205 color: palette.text,
1206 }}
1207 />
1208 <TextInput
1209 value={worktreeBranchName}
1210 onChangeText={setWorktreeBranchName}
1211 placeholder="feature/mobile-thread"
1212 className="rounded-[18px] px-3.5 py-3 font-sans text-[15px]"
1213 style={{
1214 borderWidth: 1,
1215 borderColor: palette.inputBorder,
1216 backgroundColor: palette.inputBackground,
1217 color: palette.text,
1218 }}
1219 />
1220 <SheetActionButton
1221 icon="square.split.2x1"
1222 label="Create worktree"
1223 tone="primary"
1224 disabled={
1225 busy ||
1226 worktreeBaseBranch.trim().length === 0 ||
1227 worktreeBranchName.trim().length === 0
1228 }
1229 onPress={() => {
1230 const baseBranch = worktreeBaseBranch.trim();
1231 const newBranch = worktreeBranchName.trim();
1232 if (baseBranch.length === 0 || newBranch.length === 0) {
1233 return;
1234 }
1235 void onCreateWorktree({
1236 baseBranch,
1237 newBranch,
1238 }).then(() => {
1239 setWorktreeBranchName("");
1240 setBranchSheetVisible(false);
1241 });
1242 }}
1243 />
1244 </View>
1245
1246 <View className="gap-2">
1247 <Text
1248 className="text-[11px] font-t3-bold uppercase"
1249 style={{ color: palette.textSecondary, letterSpacing: 1 }}
1250 >
1251 Existing branches
1252 </Text>
1253 {branchesLoading ? (
1254 <Text className="text-[13px] font-medium" style={{ color: palette.textSecondary }}>
1255 Loading branches…
1256 </Text>
1257 ) : null}
1258 {!branchesLoading && availableBranches.length === 0 ? (
1259 <Text className="text-[13px] font-medium" style={{ color: palette.textSecondary }}>
1260 No local branches found.
1261 </Text>
1262 ) : null}
1263 {availableBranches.map((branch) => {
1264 const disabled = disabledExistingBranches.has(branch.name);
1265 const subtitle = branch.worktreePath
1266 ? branch.worktreePath === currentWorktreePath
1267 ? "Checked out in this thread"
1268 : "Checked out in another worktree"
1269 : branch.isDefault
1270 ? "Default branch"
1271 : "Local branch";
1272
1273 return (
1274 <Pressable
1275 key={branch.name}
1276 className="gap-1 rounded-[18px] border px-4 py-3"
1277 disabled={busy || disabled}
1278 style={{
1279 backgroundColor: palette.card,
1280 borderColor: branch.current ? palette.subtleBgStrong : palette.border,
1281 opacity: busy || disabled ? 0.45 : 1,
1282 }}
1283 onPress={() => {
1284 void onCheckoutBranch(branch.name).then(() => {
1285 setBranchSheetVisible(false);
1286 });
1287 }}
1288 >
1289 <Text className="text-[15px] font-t3-bold" style={{ color: palette.text }}>
1290 {branch.name}
1291 </Text>
1292 <Text
1293 className="text-[12px] font-medium"
1294 style={{ color: palette.textSecondary }}
1295 >
1296 {subtitle}
1297 </Text>
1298 </Pressable>
1299 );
1300 })}
1301 </View>
1302 </BottomSheetScrollView>
1303 </BottomSheetView>
1304 </BottomSheetModal>
1305 </>
1306 );200 );
1307}201}
apps/mobile/src/features/threads/ThreadListScreen.tsx+66−129
@@ -10,14 +10,15 @@ import { · 9 unmodified lines
10 type View as RNView,10 type View as RNView,
11} from "react-native";11} from "react-native";
12import Animated, { FadeInDown, LinearTransition } from "react-native-reanimated";12import Animated, { FadeInDown, LinearTransition } from "react-native-reanimated";
13import { useThemeColor } from "../../lib/useThemeColor";
13 14
14import { BrandMark } from "../../components/BrandMark";15import { BrandMark } from "../../components/BrandMark";
15import { AppText as Text } from "../../components/AppText";16import { AppText as Text } from "../../components/AppText";
16import { EmptyState } from "../../components/EmptyState";17import { EmptyState } from "../../components/EmptyState";
17import { GlassSafeAreaView } from "../../components/GlassSafeAreaView";18import { GlassSafeAreaView } from "../../components/GlassSafeAreaView";
18import { StatusPill } from "../../components/StatusPill";19import { StatusPill } from "../../components/StatusPill";
20import { cn } from "../../lib/cn";
19import type { MobileLayout } from "../../lib/mobileLayout";21import type { MobileLayout } from "../../lib/mobileLayout";
20import { makeAppPalette } from "../../lib/theme";
21import {22import {
22 scopedThreadKey,23 scopedThreadKey,
23 type ScopedMobileProject,24 type ScopedMobileProject,
@@ -70,32 +71,31 @@ function ActionButton(props: { · 46 unmodified lines
70 readonly primary?: boolean;71 readonly primary?: boolean;
71 readonly onPress: () => void;72 readonly onPress: () => void;
72}) {73}) {
73 const isDarkMode = useColorScheme() === "dark";74 const primaryForeground = useThemeColor("--color-primary-foreground");
74 const palette = makeAppPalette(isDarkMode);75 const secondaryForeground = useThemeColor("--color-secondary-foreground");
76 const tintColor = props.primary ? primaryForeground : secondaryForeground;
75 77
76 return (78 return (
77 <Pressable79 <Pressable
78 className="min-h-[50px] flex-1 flex-row items-center justify-center gap-2 rounded-[16px] px-3 py-3"80 className={cn(
79 style={{81 "min-h-[50px] flex-1 flex-row items-center justify-center gap-2 rounded-[16px] px-3 py-3",
80 backgroundColor: props.primary ? palette.primaryButton : palette.secondaryButton,82 props.primary ? "bg-primary" : "border border-secondary-border bg-secondary",
81 borderWidth: props.primary ? 0 : 1,83 )}
82 borderColor: props.primary ? "transparent" : palette.secondaryButtonBorder,
83 }}
84 onPress={props.onPress}84 onPress={props.onPress}
85 >85 >
86 <SymbolView86 <SymbolView
87 name={props.icon}87 name={props.icon}
88 size={18}88 size={18}
89 tintColor={props.primary ? palette.primaryButtonText : palette.secondaryButtonText}89 tintColor={tintColor}
90 type="monochrome"90 type="monochrome"
91 weight="medium"91 weight="medium"
92 />92 />
93 <Text93 <Text
94 className="text-[13px] font-t3-bold uppercase"94 className={cn(
95 style={{95 "text-[13px] font-t3-bold uppercase",
96 color: props.primary ? palette.primaryButtonText : palette.secondaryButtonText,96 props.primary ? "text-primary-foreground" : "text-secondary-foreground",
97 letterSpacing: 0.9,97 )}
98 }}98 style={{ letterSpacing: 0.9 }}
99 >99 >
100 {props.label}100 {props.label}
101 </Text>101 </Text>
@@ -108,35 +108,20 @@ function StatCard(props: { · 6 unmodified lines
108 readonly value: string;108 readonly value: string;
109 readonly icon: React.ComponentProps<typeof SymbolView>["name"];109 readonly icon: React.ComponentProps<typeof SymbolView>["name"];
110}) {110}) {
111 const isDarkMode = useColorScheme() === "dark";111 const foregroundSecondary = useThemeColor("--color-foreground-secondary");
112 const palette = makeAppPalette(isDarkMode);
113 112
114 return (113 return (
115 <View114 <View className="flex-1 gap-2 rounded-[18px] border border-border bg-card px-4 py-4">
116 className="flex-1 gap-2 rounded-[18px] px-4 py-4"
117 style={{
118 backgroundColor: palette.card,
119 borderWidth: 1,
120 borderColor: palette.border,
121 }}
122 >
123 <View className="flex-row items-center gap-2">115 <View className="flex-row items-center gap-2">
124 <SymbolView116 <SymbolView name={props.icon} size={15} tintColor={foregroundSecondary} type="monochrome" />
125 name={props.icon}
126 size={15}
127 tintColor={palette.textSecondary}
128 type="monochrome"
129 />
130 <Text117 <Text
131 className="text-[11px] font-t3-bold uppercase"118 className="text-[11px] font-t3-bold uppercase text-foreground-secondary"
132 style={{ color: palette.textSecondary, letterSpacing: 1 }}119 style={{ letterSpacing: 1 }}
133 >120 >
134 {props.label}121 {props.label}
135 </Text>122 </Text>
136 </View>123 </View>
137 <Text className="text-[28px] font-t3-bold" style={{ color: palette.text }}>124 <Text className="text-[28px] font-t3-bold text-foreground">{props.value}</Text>
138 {props.value}
139 </Text>
140 </View>125 </View>
141 );126 );
142}127}
@@ -148,19 +133,20 @@ function ThreadRow(props: { · 5 unmodified lines
148 readonly onPress: (sourceFrame: TransitionSourceFrame | null) => void;133 readonly onPress: (sourceFrame: TransitionSourceFrame | null) => void;
149}) {134}) {
150 const isDarkMode = useColorScheme() === "dark";135 const isDarkMode = useColorScheme() === "dark";
151 const palette = makeAppPalette(isDarkMode);136 const cardColor = useThemeColor("--color-card");
137 const borderColor = useThemeColor("--color-border");
152 const containerRef = useRef<RNView>(null);138 const containerRef = useRef<RNView>(null);
153 const tone = threadStatusTone(props.thread);139 const tone = threadStatusTone(props.thread);
154 const backgroundColor = props.selected140 const backgroundColor = props.selected
155 ? isDarkMode141 ? isDarkMode
156 ? "rgba(249,115,22,0.12)"142 ? "rgba(249,115,22,0.12)"
157 : "rgba(249,115,22,0.09)"143 : "rgba(249,115,22,0.09)"
158 : palette.card;144 : cardColor;
159 const borderColor = props.selected145 const resolvedBorderColor = props.selected
160 ? isDarkMode146 ? isDarkMode
161 ? "rgba(249,115,22,0.32)"147 ? "rgba(249,115,22,0.32)"
162 : "rgba(249,115,22,0.28)"148 : "rgba(249,115,22,0.28)"
163 : palette.border;149 : borderColor;
164 150
165 return (151 return (
166 <Pressable152 <Pressable
@@ -168,7 +154,7 @@ function ThreadRow(props: { · 1 unmodified line
168 className="gap-3 rounded-[18px] border px-4 py-4"154 className="gap-3 rounded-[18px] border px-4 py-4"
169 style={{155 style={{
170 backgroundColor,156 backgroundColor,
171 borderColor,157 borderColor: resolvedBorderColor,
172 opacity: props.hidden ? 0 : 1,158 opacity: props.hidden ? 0 : 1,
173 }}159 }}
174 onPress={() => {160 onPress={() => {
@@ -183,13 +169,8 @@ function ThreadRow(props: { · 8 unmodified lines
183 >169 >
184 <View className="flex-row items-start justify-between gap-3">170 <View className="flex-row items-start justify-between gap-3">
185 <View className="flex-1 gap-2">171 <View className="flex-1 gap-2">
186 <Text className="text-[17px] font-t3-bold" style={{ color: palette.text }}>172 <Text className="text-[17px] font-t3-bold text-foreground">{props.thread.title}</Text>
187 {props.thread.title}173 <Text className="text-[13px] font-medium leading-[19px] text-foreground-secondary">
188 </Text>
189 <Text
190 className="text-[13px] font-medium leading-[19px]"
191 style={{ color: palette.textSecondary }}
192 >
193 {lastConversationLine(props.thread)}174 {lastConversationLine(props.thread)}
194 </Text>175 </Text>
195 </View>176 </View>
@@ -197,8 +178,8 @@ function ThreadRow(props: { · 1 unmodified line
197 </View>178 </View>
198 179
199 <Text180 <Text
200 className="text-[11px] font-t3-bold uppercase"181 className="text-[11px] font-t3-bold uppercase text-foreground-muted"
201 style={{ color: palette.textMuted, letterSpacing: 0.9 }}182 style={{ letterSpacing: 0.9 }}
202 >183 >
203 {props.thread.environmentLabel} · {props.thread.modelSelection.provider} ·{" "}184 {props.thread.environmentLabel} · {props.thread.modelSelection.provider} ·{" "}
204 {relativeTime(props.thread.updatedAt ?? props.thread.createdAt)}185 {relativeTime(props.thread.updatedAt ?? props.thread.createdAt)}
@@ -218,34 +199,28 @@ function ProjectSection(props: { · 13 unmodified lines
218 sourceFrame: TransitionSourceFrame | null,199 sourceFrame: TransitionSourceFrame | null,
219 ) => void;200 ) => void;
220}) {201}) {
221 const isDarkMode = useColorScheme() === "dark";
222 const palette = makeAppPalette(isDarkMode);
223 const [expanded, setExpanded] = useState(false);202 const [expanded, setExpanded] = useState(false);
224 const visibleThreads = expanded ? props.threads : props.threads.slice(0, 2);203 const visibleThreads = expanded ? props.threads : props.threads.slice(0, 2);
225 const hiddenCount = Math.max(props.threads.length - visibleThreads.length, 0);204 const hiddenCount = Math.max(props.threads.length - visibleThreads.length, 0);
226 205
227 return (206 return (
228 <View className="gap-3 rounded-[22px] border px-4 py-4" style={{ borderColor: palette.border }}>207 <View className="gap-3 rounded-[22px] border border-border px-4 py-4">
229 <View className="flex-row items-start justify-between gap-3">208 <View className="flex-row items-start justify-between gap-3">
230 <View className="flex-1 gap-1">209 <View className="flex-1 gap-1">
231 <Text className="text-[15px] font-t3-bold" style={{ color: palette.text }}>210 <Text className="text-[15px] font-t3-bold text-foreground">
232 {props.project.environmentLabel}211 {props.project.environmentLabel}
233 </Text>212 </Text>
234 <Text213 <Text className="text-[12px] font-medium leading-[18px] text-foreground-secondary">
235 className="text-[12px] font-medium leading-[18px]"
236 style={{ color: palette.textSecondary }}
237 >
238 {props.project.workspaceRoot}214 {props.project.workspaceRoot}
239 </Text>215 </Text>
240 </View>216 </View>
241 <Pressable217 <Pressable
242 className="rounded-full px-3 py-2"218 className="rounded-full bg-subtle px-3 py-2"
243 style={{ backgroundColor: palette.subtleBg }}
244 onPress={() => void props.onCreateThread(props.project)}219 onPress={() => void props.onCreateThread(props.project)}
245 >220 >
246 <Text221 <Text
247 className="text-[11px] font-t3-bold uppercase"222 className="text-[11px] font-t3-bold uppercase text-foreground"
248 style={{ color: palette.text, letterSpacing: 0.9 }}223 style={{ letterSpacing: 0.9 }}
249 >224 >
250 New thread225 New thread
251 </Text>226 </Text>
@@ -273,13 +248,12 @@ function ProjectSection(props: { · 21 unmodified lines
273 248
274 {hiddenCount > 0 ? (249 {hiddenCount > 0 ? (
275 <Pressable250 <Pressable
276 className="items-center rounded-[16px] px-3 py-3"251 className="items-center rounded-[16px] bg-separator px-3 py-3"
277 style={{ backgroundColor: palette.separator }}
278 onPress={() => setExpanded((current) => !current)}252 onPress={() => setExpanded((current) => !current)}
279 >253 >
280 <Text254 <Text
281 className="text-[11px] font-t3-bold uppercase"255 className="text-[11px] font-t3-bold uppercase text-foreground-secondary"
282 style={{ color: palette.textSecondary, letterSpacing: 1 }}256 style={{ letterSpacing: 1 }}
283 >257 >
284 {expanded ? "Show less" : `Show ${hiddenCount} more`}258 {expanded ? "Show less" : `Show ${hiddenCount} more`}
285 </Text>259 </Text>
@@ -297,28 +271,19 @@ function CreateThreadModal(props: { · 11 unmodified lines
297 readonly onClose: () => void;271 readonly onClose: () => void;
298 readonly onSelectProject: (project: ScopedMobileProject) => Promise<void>;272 readonly onSelectProject: (project: ScopedMobileProject) => Promise<void>;
299}) {273}) {
300 const isDarkMode = useColorScheme() === "dark";
301 const palette = makeAppPalette(isDarkMode);
302
303 if (!props.group) {274 if (!props.group) {
304 return null;275 return null;
305 }276 }
306 277
307 return (278 return (
308 <Modal transparent animationType="slide" visible={props.visible} onRequestClose={props.onClose}>279 <Modal transparent animationType="slide" visible={props.visible} onRequestClose={props.onClose}>
309 <View className="flex-1 justify-end" style={{ backgroundColor: palette.backdrop }}>280 <View className="flex-1 justify-end bg-backdrop">
310 <View281 <View className="gap-4 rounded-t-[28px] bg-screen px-5 pb-8 pt-5">
311 className="gap-4 rounded-t-[28px] px-5 pb-8 pt-5"
312 style={{ backgroundColor: palette.screenBackground }}
313 >
314 <View className="gap-1">282 <View className="gap-1">
315 <Text className="text-[22px] font-t3-bold" style={{ color: palette.text }}>283 <Text className="text-[22px] font-t3-bold text-foreground">
316 New thread in {props.group.title}284 New thread in {props.group.title}
317 </Text>285 </Text>
318 <Text286 <Text className="text-[13px] font-medium leading-[19px] text-foreground-secondary">
319 className="text-[13px] font-medium leading-[19px]"
320 style={{ color: palette.textSecondary }}
321 >
322 Choose which environment should own the new thread.287 Choose which environment should own the new thread.
323 </Text>288 </Text>
324 </View>289 </View>
@@ -327,31 +292,24 @@ function CreateThreadModal(props: { · 2 unmodified lines
327 {props.group.projects.map(({ project, threads }) => (292 {props.group.projects.map(({ project, threads }) => (
328 <Pressable293 <Pressable
329 key={project.environmentId + project.id}294 key={project.environmentId + project.id}
330 className="gap-2 rounded-[20px] border px-4 py-4"295 className="gap-2 rounded-[20px] border border-border bg-card px-4 py-4"
331 style={{ backgroundColor: palette.card, borderColor: palette.border }}
332 onPress={() => {296 onPress={() => {
333 void props.onSelectProject(project).then(props.onClose);297 void props.onSelectProject(project).then(props.onClose);
334 }}298 }}
335 >299 >
336 <View className="flex-row items-start justify-between gap-3">300 <View className="flex-row items-start justify-between gap-3">
337 <View className="flex-1 gap-1">301 <View className="flex-1 gap-1">
338 <Text className="text-[16px] font-t3-bold" style={{ color: palette.text }}>302 <Text className="text-[16px] font-t3-bold text-foreground">
339 {project.environmentLabel}303 {project.environmentLabel}
340 </Text>304 </Text>
341 <Text305 <Text className="text-[12px] font-medium leading-[18px] text-foreground-secondary">
342 className="text-[12px] font-medium leading-[18px]"
343 style={{ color: palette.textSecondary }}
344 >
345 {project.workspaceRoot}306 {project.workspaceRoot}
346 </Text>307 </Text>
347 </View>308 </View>
348 <View309 <View className="rounded-full bg-subtle px-3 py-2">
349 className="rounded-full px-3 py-2"
350 style={{ backgroundColor: palette.subtleBg }}
351 >
352 <Text310 <Text
353 className="text-[11px] font-t3-bold uppercase"311 className="text-[11px] font-t3-bold uppercase text-foreground"
354 style={{ color: palette.text, letterSpacing: 0.9 }}312 style={{ letterSpacing: 0.9 }}
355 >313 >
356 {threads.length} thread{threads.length === 1 ? "" : "s"}314 {threads.length} thread{threads.length === 1 ? "" : "s"}
357 </Text>315 </Text>
@@ -369,8 +327,7 @@ function CreateThreadModal(props: { · 11 unmodified lines
369}327}
370 328
371export function ThreadListScreen(props: ThreadListScreenProps) {329export function ThreadListScreen(props: ThreadListScreenProps) {
372 const isDarkMode = useColorScheme() === "dark";330 const borderColorValue = useThemeColor("--color-border");
373 const palette = makeAppPalette(isDarkMode);
374 const repositoryGroups = useMemo(331 const repositoryGroups = useMemo(
375 () => groupProjectsByRepository({ projects: props.projects, threads: props.threads }),332 () => groupProjectsByRepository({ projects: props.projects, threads: props.threads }),
376 [props.projects, props.threads],333 [props.projects, props.threads],
@@ -379,7 +336,7 @@ export function ThreadListScreen(props: ThreadListScreenProps) { · 2 unmodified lines
379 const [refreshing, setRefreshing] = useState(false);336 const [refreshing, setRefreshing] = useState(false);
380 const isSplitLayout = props.layout.usesSplitView;337 const isSplitLayout = props.layout.usesSplitView;
381 const contentHorizontalPadding = isSplitLayout ? 18 : 20;338 const contentHorizontalPadding = isSplitLayout ? 18 : 20;
382 const panelBorderColor = isSplitLayout ? palette.border : "transparent";339 const panelBorderColor = isSplitLayout ? borderColorValue : "transparent";
383 const refreshThreads = props.onRefresh;340 const refreshThreads = props.onRefresh;
384 341
385 const handleRefresh = useCallback(async (): Promise<void> => {342 const handleRefresh = useCallback(async (): Promise<void> => {
@@ -397,9 +354,8 @@ export function ThreadListScreen(props: ThreadListScreenProps) { · 11 unmodified lines
397 354
398 return (355 return (
399 <View356 <View
400 className="flex-1"357 className="flex-1 bg-screen"
401 style={{358 style={{
402 backgroundColor: palette.screenBackground,
403 borderRadius: isSplitLayout ? 32 : 0,359 borderRadius: isSplitLayout ? 32 : 0,
404 borderWidth: isSplitLayout ? 1 : 0,360 borderWidth: isSplitLayout ? 1 : 0,
405 borderColor: panelBorderColor,361 borderColor: panelBorderColor,
@@ -426,28 +382,20 @@ export function ThreadListScreen(props: ThreadListScreenProps) { · 20 unmodified lines
426 <Animated.View382 <Animated.View
427 entering={FadeInDown.duration(260)}383 entering={FadeInDown.duration(260)}
428 layout={LinearTransition.springify().damping(18).stiffness(180)}384 layout={LinearTransition.springify().damping(18).stiffness(180)}
429 className="gap-4 rounded-[26px] px-4 py-4"385 className="gap-4 rounded-[26px] border border-border bg-card-translucent px-4 py-4"
430 style={{
431 backgroundColor: palette.cardTranslucent,
432 borderWidth: 1,
433 borderColor: palette.border,
434 }}
435 >386 >
436 <View className="flex-row items-start justify-between gap-4">387 <View className="flex-row items-start justify-between gap-4">
437 <View className="flex-1 gap-2">388 <View className="flex-1 gap-2">
438 <Text389 <Text
439 className="text-[11px] font-t3-bold uppercase"390 className="text-[11px] font-t3-bold uppercase text-foreground-muted"
440 style={{ color: palette.textMuted, letterSpacing: 1.1 }}391 style={{ letterSpacing: 1.1 }}
441 >392 >
442 {props.heroTitle}393 {props.heroTitle}
443 </Text>394 </Text>
444 <Text className="text-[26px] font-t3-bold" style={{ color: palette.text }}>395 <Text className="text-[26px] font-t3-bold text-foreground">
445 {isSplitLayout ? "Native workspace" : "Repo board"}396 {isSplitLayout ? "Native workspace" : "Repo board"}
446 </Text>397 </Text>
447 <Text398 <Text className="text-[13px] font-medium leading-[19px] text-foreground-secondary">
448 className="text-[13px] font-medium leading-[19px]"
449 style={{ color: palette.textSecondary }}
450 >
451 {isSplitLayout399 {isSplitLayout
452 ? "Keep your repositories visible while you move between active threads."400 ? "Keep your repositories visible while you move between active threads."
453 : "Your connected repositories, grouped by identity across environments."}401 : "Your connected repositories, grouped by identity across environments."}
@@ -497,29 +445,19 @@ export function ThreadListScreen(props: ThreadListScreenProps) { · 43 unmodified lines
497 key={group.key}445 key={group.key}
498 entering={FadeInDown.duration(260).delay(40 * Math.min(index, 6))}446 entering={FadeInDown.duration(260).delay(40 * Math.min(index, 6))}
499 layout={LinearTransition.springify().damping(18).stiffness(180)}447 layout={LinearTransition.springify().damping(18).stiffness(180)}
500 className="gap-4 rounded-[26px] px-4 py-4"448 className="gap-4 rounded-[26px] border border-border bg-card-translucent px-4 py-4"
501 style={{
502 backgroundColor: palette.cardTranslucent,
503 borderWidth: 1,
504 borderColor: palette.border,
505 }}
506 >449 >
507 <View className="flex-row items-start justify-between gap-4">450 <View className="flex-row items-start justify-between gap-4">
508 <View className="flex-1 gap-1">451 <View className="flex-1 gap-1">
509 <Text className="text-[22px] font-t3-bold" style={{ color: palette.text }}>452 <Text className="text-[22px] font-t3-bold text-foreground">{group.title}</Text>
510 {group.title}
511 </Text>
512 {group.subtitle ? (453 {group.subtitle ? (
513 <Text454 <Text className="text-[13px] font-medium leading-[19px] text-foreground-secondary">
514 className="text-[13px] font-medium leading-[19px]"
515 style={{ color: palette.textSecondary }}
516 >
517 {group.subtitle}455 {group.subtitle}
518 </Text>456 </Text>
519 ) : null}457 ) : null}
520 <Text458 <Text
521 className="text-[11px] font-t3-bold uppercase"459 className="text-[11px] font-t3-bold uppercase text-foreground-muted"
522 style={{ color: palette.textMuted, letterSpacing: 0.9 }}460 style={{ letterSpacing: 0.9 }}
523 >461 >
524 {group.projectCount} environment{group.projectCount === 1 ? "" : "s"} ·{" "}462 {group.projectCount} environment{group.projectCount === 1 ? "" : "s"} ·{" "}
525 {group.threadCount} active thread{group.threadCount === 1 ? "" : "s"}463 {group.threadCount} active thread{group.threadCount === 1 ? "" : "s"}
@@ -527,8 +465,7 @@ export function ThreadListScreen(props: ThreadListScreenProps) { · 1 unmodified line
527 </View>465 </View>
528 466
529 <Pressable467 <Pressable
530 className="rounded-full px-3 py-2"468 className="rounded-full bg-primary px-3 py-2"
531 style={{ backgroundColor: palette.primaryButton }}
532 onPress={() => {469 onPress={() => {
533 if (group.projects.length === 1) {470 if (group.projects.length === 1) {
534 void props.onCreateThread(group.projects[0]!.project);471 void props.onCreateThread(group.projects[0]!.project);
@@ -538,8 +475,8 @@ export function ThreadListScreen(props: ThreadListScreenProps) { · 3 unmodified lines
538 }}475 }}
539 >476 >
540 <Text477 <Text
541 className="text-[11px] font-t3-bold uppercase"478 className="text-[11px] font-t3-bold uppercase text-primary-foreground"
542 style={{ color: palette.primaryButtonText, letterSpacing: 0.9 }}479 style={{ letterSpacing: 0.9 }}
543 >480 >
544 New thread481 New thread
545 </Text>482 </Text>
apps/mobile/src/features/threads/ThreadNavigationDrawer.tsx+24−39
@@ -1,13 +1,6 @@
1import { SymbolView } from "expo-symbols";1import { SymbolView } from "expo-symbols";
2import { useCallback, useEffect, useMemo, useState } from "react";2import { useCallback, useEffect, useMemo, useState } from "react";
3import {3import { Modal, Pressable, ScrollView, useWindowDimensions, View } from "react-native";
4 Modal,
5 Pressable,
6 ScrollView,
7 useColorScheme,
8 useWindowDimensions,
9 View,
10} from "react-native";
11import { Gesture, GestureDetector } from "react-native-gesture-handler";4import { Gesture, GestureDetector } from "react-native-gesture-handler";
12import { useSafeAreaInsets } from "react-native-safe-area-context";5import { useSafeAreaInsets } from "react-native-safe-area-context";
13import Animated, {6import Animated, {
@@ -16,6 +9,7 @@ import Animated, { · 2 unmodified lines
16 useSharedValue,9 useSharedValue,
17 withTiming,10 withTiming,
18} from "react-native-reanimated";11} from "react-native-reanimated";
12import { useThemeColor } from "../../lib/useThemeColor";
19 13
20import { AppText as Text } from "../../components/AppText";14import { AppText as Text } from "../../components/AppText";
21import { StatusPill } from "../../components/StatusPill";15import { StatusPill } from "../../components/StatusPill";
@@ -23,7 +17,6 @@ import { groupProjectsByRepository } from "../../lib/repositoryGroups"; · 1 unmodified line
23import type { ScopedMobileProject, ScopedMobileThread } from "../../lib/scopedEntities";17import type { ScopedMobileProject, ScopedMobileThread } from "../../lib/scopedEntities";
24import { scopedThreadKey } from "../../lib/scopedEntities";18import { scopedThreadKey } from "../../lib/scopedEntities";
25import { relativeTime } from "../../lib/time";19import { relativeTime } from "../../lib/time";
26import { makeAppPalette } from "../../lib/theme";
27import { threadStatusTone } from "./threadPresentation";20import { threadStatusTone } from "./threadPresentation";
28 21
29function compareThreadActivity(left: ScopedMobileThread, right: ScopedMobileThread): number {22function compareThreadActivity(left: ScopedMobileThread, right: ScopedMobileThread): number {
@@ -42,14 +35,19 @@ export function ThreadNavigationDrawer(props: { · 12 unmodified lines
42 readonly onSelectThread: (thread: ScopedMobileThread) => void;35 readonly onSelectThread: (thread: ScopedMobileThread) => void;
43 readonly onStartNewTask: () => void;36 readonly onStartNewTask: () => void;
44}) {37}) {
45 const isDarkMode = useColorScheme() === "dark";
46 const palette = makeAppPalette(isDarkMode);
47 const insets = useSafeAreaInsets();38 const insets = useSafeAreaInsets();
48 const { width } = useWindowDimensions();39 const { width } = useWindowDimensions();
49 const drawerWidth = Math.min(width * 0.84, 360);40 const drawerWidth = Math.min(width * 0.84, 360);
50 const [mounted, setMounted] = useState(props.visible);41 const [mounted, setMounted] = useState(props.visible);
51 const translateX = useSharedValue(-drawerWidth);42 const translateX = useSharedValue(-drawerWidth);
52 const overlayOpacity = useSharedValue(0);43 const overlayOpacity = useSharedValue(0);
44
45 const backdropColor = useThemeColor("--color-backdrop");
46 const drawerBg = useThemeColor("--color-drawer");
47 const drawerShadow = useThemeColor("--color-drawer-shadow");
48 const primaryForeground = useThemeColor("--color-primary-foreground");
49 const borderSubtleColor = useThemeColor("--color-border-subtle");
50
53 const repositoryGroups = useMemo(51 const repositoryGroups = useMemo(
54 () => groupProjectsByRepository({ projects: props.projects, threads: props.threads }),52 () => groupProjectsByRepository({ projects: props.projects, threads: props.threads }),
55 [props.projects, props.threads],53 [props.projects, props.threads],
@@ -130,7 +128,7 @@ export function ThreadNavigationDrawer(props: { · 74 unmodified lines
130 right: 0,128 right: 0,
131 top: 0,129 top: 0,
132 bottom: 0,130 bottom: 0,
133 backgroundColor: palette.backdrop,131 backgroundColor: backdropColor,
134 },132 },
135 backdropStyle,133 backdropStyle,
136 ]}134 ]}
@@ -149,30 +147,27 @@ export function ThreadNavigationDrawer(props: { · 12 unmodified lines
149 top: 0,147 top: 0,
150 bottom: 0,148 bottom: 0,
151 width: drawerWidth,149 width: drawerWidth,
152 backgroundColor: palette.drawerBackground,150 backgroundColor: drawerBg,
153 paddingTop: insets.top + 10,151 paddingTop: insets.top + 10,
154 paddingBottom: Math.max(insets.bottom, 18),152 paddingBottom: Math.max(insets.bottom, 18),
155 boxShadow: `20px 0 36px ${palette.drawerShadow}`,153 boxShadow: `20px 0 36px ${String(drawerShadow)}`,
156 },154 },
157 drawerStyle,155 drawerStyle,
158 ]}156 ]}
159 >157 >
160 <View className="flex-row items-center justify-between px-4 pb-5">158 <View className="flex-row items-center justify-between px-4 pb-5">
161 <Text className="text-[26px] font-t3-bold" style={{ color: palette.text }}>159 <Text className="text-[26px] font-t3-bold">Threads</Text>
162 Threads
163 </Text>
164 <Pressable160 <Pressable
165 onPress={() => {161 onPress={() => {
166 props.onClose();162 props.onClose();
167 props.onStartNewTask();163 props.onStartNewTask();
168 }}164 }}
169 className="h-11 w-11 items-center justify-center rounded-full"165 className="h-11 w-11 items-center justify-center rounded-full bg-primary"
170 style={{ backgroundColor: palette.primaryButton }}
171 >166 >
172 <SymbolView167 <SymbolView
173 name="square.and.pencil"168 name="square.and.pencil"
174 size={17}169 size={17}
175 tintColor={palette.primaryButtonText}170 tintColor={primaryForeground}
176 type="monochrome"171 type="monochrome"
177 />172 />
178 </Pressable>173 </Pressable>
@@ -189,22 +184,16 @@ export function ThreadNavigationDrawer(props: { · 10 unmodified lines
189 {groupedThreads.map((group) => (184 {groupedThreads.map((group) => (
190 <View key={group.key} className="gap-3">185 <View key={group.key} className="gap-3">
191 <Text186 <Text
192 className="px-1 text-[15px] font-t3-bold"187 className="px-1 text-[15px] font-t3-bold text-foreground-muted"
193 style={{ color: palette.textMuted, letterSpacing: -0.2 }}188 style={{ letterSpacing: -0.2 }}
194 >189 >
195 {group.title}190 {group.title}
196 </Text>191 </Text>
197 192
198 <View193 <View className="overflow-hidden rounded-[22px] bg-card">
199 className="overflow-hidden rounded-[22px]"
200 style={{ backgroundColor: palette.card }}
201 >
202 {group.threads.length === 0 ? (194 {group.threads.length === 0 ? (
203 <View className="px-4 py-4">195 <View className="px-4 py-4">
204 <Text196 <Text className="text-[14px] font-medium text-foreground-tertiary">
205 className="text-[14px] font-medium"
206 style={{ color: palette.textTertiary }}
207 >
208 No threads yet197 No threads yet
209 </Text>198 </Text>
210 </View>199 </View>
@@ -224,23 +213,19 @@ export function ThreadNavigationDrawer(props: { · 13 unmodified lines
224 paddingHorizontal: 16,213 paddingHorizontal: 16,
225 paddingVertical: 15,214 paddingVertical: 15,
226 borderTopWidth: index === 0 ? 0 : 1,215 borderTopWidth: index === 0 ? 0 : 1,
227 borderTopColor: palette.borderSubtle,216 borderTopColor: borderSubtleColor,
228 backgroundColor: selected ? palette.subtleBg : "transparent",217 backgroundColor: selected ? undefined : "transparent",
229 }}218 }}
219 className={selected ? "bg-subtle" : undefined}
230 >220 >
231 <View className="flex-row items-start justify-between gap-3">221 <View className="flex-row items-start justify-between gap-3">
232 <View className="flex-1 gap-1">222 <View className="flex-1 gap-1">
233 <Text223 <Text className="text-[16px] font-t3-bold" numberOfLines={1}>
234 className="text-[16px] font-t3-bold"
235 numberOfLines={1}
236 style={{ color: palette.text }}
237 >
238 {thread.title}224 {thread.title}
239 </Text>225 </Text>
240 <Text226 <Text
241 className="text-[13px] font-medium"227 className="text-[13px] font-medium text-foreground-muted"
242 numberOfLines={1}228 numberOfLines={1}
243 style={{ color: palette.textMuted }}
244 >229 >
245 {relativeTime(thread.updatedAt ?? thread.createdAt)}230 {relativeTime(thread.updatedAt ?? thread.createdAt)}
246 </Text>231 </Text>
apps/mobile/src/features/threads/ThreadRouteScreen.tsx+148−72
@@ -1,12 +1,13 @@
1import { useLocalSearchParams, useRouter } from "expo-router";1import { Stack, useLocalSearchParams, useRouter } from "expo-router";
2import { useEffect, useMemo, useState } from "react";2import { useCallback, useEffect, useMemo, useState } from "react";
3import { ScrollView, useColorScheme, View } from "react-native";3import { Pressable, ScrollView, Text as RNText, View, useColorScheme } from "react-native";
4import { useThemeColor } from "../../lib/useThemeColor";
5import { useGitStatus, gitStatusManager } from "../../state/use-git-status";
4 6
5import { EmptyState } from "../../components/EmptyState";7import { EmptyState } from "../../components/EmptyState";
6import { LoadingScreen } from "../../components/LoadingScreen";8import { LoadingScreen } from "../../components/LoadingScreen";
7import { buildThreadRoutePath, dismissRoute } from "../../lib/routes";9import { buildThreadRoutePath } from "../../lib/routes";
8import { scopedThreadKey } from "../../lib/scopedEntities";10import { scopedThreadKey } from "../../lib/scopedEntities";
9import { makeAppPalette } from "../../lib/theme";
10import { connectionTone } from "../connection/connectionTone";11import { connectionTone } from "../connection/connectionTone";
11import { firstNonNull } from "../../state/remote-runtime-types";12import { firstNonNull } from "../../state/remote-runtime-types";
12import { useRemoteCatalog } from "../../state/use-remote-catalog";13import { useRemoteCatalog } from "../../state/use-remote-catalog";
@@ -14,12 +15,15 @@ import { · 1 unmodified line
14 useRemoteConnectionStatus,15 useRemoteConnectionStatus,
15 useRemoteEnvironmentState,16 useRemoteEnvironmentState,
16} from "../../state/use-remote-environment-registry";17} from "../../state/use-remote-environment-registry";
18import { useSelectedThreadGitActions } from "../../state/use-selected-thread-git-actions";
19import { useSelectedThreadGitState } from "../../state/use-selected-thread-git-state";
17import { useSelectedThreadCommands } from "../../state/use-selected-thread-commands";20import { useSelectedThreadCommands } from "../../state/use-selected-thread-commands";
18import { useSelectedThreadGit } from "../../state/use-selected-thread-git";
19import { useThreadComposerState } from "../../state/use-thread-composer-state";21import { useThreadComposerState } from "../../state/use-thread-composer-state";
20import { useThreadSelection } from "../../state/use-thread-selection";22import { useThreadSelection } from "../../state/use-thread-selection";
21import { ThreadDetailScreen } from "./ThreadDetailScreen";23import { ThreadDetailScreen } from "./ThreadDetailScreen";
24import { ThreadGitControls } from "./ThreadGitControls";
22import { ThreadNavigationDrawer } from "./ThreadNavigationDrawer";25import { ThreadNavigationDrawer } from "./ThreadNavigationDrawer";
26import { screenTitle } from "./threadPresentation";
23 27
24function firstRouteParam(value: string | string[] | undefined): string | null {28function firstRouteParam(value: string | string[] | undefined): string | null {
25 if (Array.isArray(value)) {29 if (Array.isArray(value)) {
@@ -36,19 +40,19 @@ export function ThreadRouteScreen() { · 10 unmodified lines
36 const {40 const {
37 onSelectThread,41 onSelectThread,
38 selectedThread,42 selectedThread,
43 selectedThreadProject,
39 selectedEnvironmentConnection,44 selectedEnvironmentConnection,
40 selectedEnvironmentRuntime,45 selectedEnvironmentRuntime,
41 } = useThreadSelection();46 } = useThreadSelection();
42 const composer = useThreadComposerState();47 const composer = useThreadComposerState();
43 const git = useSelectedThreadGit();48 const gitState = useSelectedThreadGitState();
49 const gitActions = useSelectedThreadGitActions();
44 const commands = useSelectedThreadCommands({50 const commands = useSelectedThreadCommands({
45 activePendingUserInput: composer.activePendingUserInput,51 activePendingUserInput: composer.activePendingUserInput,
46 activePendingUserInputAnswers: composer.activePendingUserInputAnswers,52 activePendingUserInputAnswers: composer.activePendingUserInputAnswers,
47 refreshSelectedThreadGitStatus: git.refreshSelectedThreadGitStatus,53 refreshSelectedThreadGitStatus: gitActions.refreshSelectedThreadGitStatus,
48 });54 });
49 const router = useRouter();55 const router = useRouter();
50 const colorScheme = useColorScheme();
51 const isDarkMode = colorScheme !== "light";
52 const params = useLocalSearchParams<{56 const params = useLocalSearchParams<{
53 environmentId?: string | string[];57 environmentId?: string | string[];
54 threadId?: string | string[];58 threadId?: string | string[];
@@ -57,6 +61,32 @@ export function ThreadRouteScreen() { · 2 unmodified lines
57 const environmentId = firstRouteParam(params.environmentId);61 const environmentId = firstRouteParam(params.environmentId);
58 const threadId = firstRouteParam(params.threadId);62 const threadId = firstRouteParam(params.threadId);
59 63
64 /* ─── Native header theming ──────────────────────────────────────── */
65 const isDark = useColorScheme() === "dark";
66 const iconColor = String(useThemeColor("--color-icon"));
67 const foregroundColor = String(useThemeColor("--color-foreground"));
68 const secondaryFg = isDark ? "#a3a3a3" : "#525252";
69
70 /* ─── Git status for native header trigger ───────────────────────── */
71 const gitStatus = useGitStatus({
72 environmentId: selectedThread?.environmentId ?? "",
73 cwd: selectedThread?.worktreePath ?? selectedThreadProject?.workspaceRoot ?? null,
74 });
75
76 const handleRefreshGitStatus = useCallback(async () => {
77 if (!selectedThread) return;
78 await gitStatusManager.refresh({
79 environmentId: selectedThread.environmentId,
80 cwd: selectedThread.worktreePath ?? selectedThreadProject?.workspaceRoot ?? null,
81 });
82 }, [selectedThread, selectedThreadProject?.workspaceRoot]);
83
84 /** Wraps thread refresh + git status refresh for pull-to-refresh */
85 const handleRefreshAll = useCallback(async () => {
86 await commands.onRefresh();
87 await handleRefreshGitStatus();
88 }, [commands, handleRefreshGitStatus]);
89
60 const routeThread = useMemo(() => {90 const routeThread = useMemo(() => {
61 if (!environmentId || !threadId) {91 if (!environmentId || !threadId) {
62 return null;92 return null;
@@ -106,7 +136,7 @@ export function ThreadRouteScreen() { · 43 unmodified lines
106 paddingHorizontal: 24,136 paddingHorizontal: 24,
107 paddingVertical: 32,137 paddingVertical: 32,
108 }}138 }}
109 style={{ flex: 1, backgroundColor: makeAppPalette(isDarkMode).screenBackground }}139 className="bg-screen flex-1"
110 >140 >
111 <EmptyState141 <EmptyState
112 title="Thread unavailable"142 title="Thread unavailable"
@@ -125,71 +155,117 @@ export function ThreadRouteScreen() { · 12 unmodified lines
125 selectedEnvironmentRuntime?.serverConfig ??155 selectedEnvironmentRuntime?.serverConfig ??
126 firstNonNull(Object.values(environmentStateById).map((runtime) => runtime.serverConfig));156 firstNonNull(Object.values(environmentStateById).map((runtime) => runtime.serverConfig));
127 157
158 const headerSubtitle = [screenTitle(serverConfig, null), selectedThread.environmentLabel]
159 .filter(Boolean)
160 .join(" · ");
161
128 return (162 return (
129 <View style={{ flex: 1 }}>163 <>
130 <ThreadDetailScreen164 <Stack.Screen
131 selectedThread={selectedThread}165 options={{
132 screenTone={connectionTone(connectionState)}166 headerShown: true,
133 connectionError={connectionError}167 headerTransparent: true,
134 httpBaseUrl={selectedEnvironmentConnection?.httpBaseUrl ?? null}168 headerStyle: { backgroundColor: "transparent" },
135 bearerToken={selectedEnvironmentConnection?.bearerToken ?? null}169 headerShadowVisible: false,
136 selectedThreadFeed={composer.selectedThreadFeed}170 headerTintColor: iconColor,
137 activeWorkDurationLabel={composer.activeWorkDurationLabel}171 headerBackTitle: "",
138 activePendingApproval={composer.activePendingApproval}172 headerTitle: () => (
139 respondingApprovalId={commands.respondingApprovalId}173 <Pressable
140 activePendingUserInput={composer.activePendingUserInput}174 style={{ alignItems: "center", maxWidth: 200 }}
141 activePendingUserInputDrafts={composer.activePendingUserInputDrafts}175 onLongPress={() => {
142 activePendingUserInputAnswers={composer.activePendingUserInputAnswers}176 // TODO: trigger rename modal
143 respondingUserInputId={commands.respondingUserInputId}177 }}
144 draftMessage={composer.draftMessage}178 >
145 draftAttachments={composer.draftAttachments}179 <RNText
146 connectionStateLabel={connectionState}180 numberOfLines={1}
147 activeThreadBusy={composer.activeThreadBusy}181 style={{
148 selectedThreadGitStatus={git.selectedThreadGitStatus}182 fontFamily: "DMSans_700Bold",
149 gitOperationLabel={git.gitOperationLabel}183 fontSize: 18,
150 selectedThreadQueueCount={composer.selectedThreadQueueCount}184 fontWeight: "900",
151 onBack={() => dismissRoute(router)}185 color: foregroundColor,
152 onOpenDrawer={() => setDrawerVisible(true)}186 letterSpacing: -0.4,
153 onOpenConnectionEditor={() => router.push("/connections")}187 }}
154 onChangeDraftMessage={composer.onChangeDraftMessage}188 >
155 onPickDraftImages={composer.onPickDraftImages}189 {selectedThread.title}
156 onNativePasteImages={composer.onNativePasteImages}190 </RNText>
157 onRemoveDraftImage={composer.onRemoveDraftImage}191 <RNText
158 onRefresh={commands.onRefresh}192 numberOfLines={1}
159 onRefreshSelectedThreadGitStatus={async (options) => {193 style={{
160 await git.refreshSelectedThreadGitStatus(options);194 fontFamily: "DMSans_700Bold",
195 fontSize: 12,
196 fontWeight: "700",
197 color: secondaryFg,
198 letterSpacing: 0.3,
199 }}
200 >
201 {headerSubtitle}
202 </RNText>
203 </Pressable>
204 ),
161 }}205 }}
162 onListSelectedThreadBranches={git.onListSelectedThreadBranches}
163 onCheckoutSelectedThreadBranch={git.onCheckoutSelectedThreadBranch}
164 onCreateSelectedThreadBranch={git.onCreateSelectedThreadBranch}
165 onCreateSelectedThreadWorktree={git.onCreateSelectedThreadWorktree}
166 onPullSelectedThreadBranch={git.onPullSelectedThreadBranch}
167 onRunSelectedThreadGitAction={git.onRunSelectedThreadGitAction}
168 serverConfig={serverConfig}
169 onRenameThread={commands.onRenameThread}
170 onStopThread={commands.onStopThread}
171 onSendMessage={composer.onSendMessage}
172 onUpdateThreadModelSelection={commands.onUpdateThreadModelSelection}
173 onUpdateThreadRuntimeMode={commands.onUpdateThreadRuntimeMode}
174 onUpdateThreadInteractionMode={commands.onUpdateThreadInteractionMode}
175 onRespondToApproval={commands.onRespondToApproval}
176 onSelectUserInputOption={composer.onSelectUserInputOption}
177 onChangeUserInputCustomAnswer={composer.onChangeUserInputCustomAnswer}
178 onSubmitUserInput={commands.onSubmitUserInput}
179 />206 />
180 207
181 <ThreadNavigationDrawer208 <ThreadGitControls
182 visible={drawerVisible}209 currentBranch={selectedThread.branch}
183 projects={projects}210 gitStatus={gitStatus.data}
184 threads={threads}211 gitOperationLabel={gitState.gitOperationLabel}
185 selectedThreadKey={selectedThreadKey}212 onPull={gitActions.onPullSelectedThreadBranch}
186 onClose={() => setDrawerVisible(false)}213 onRunAction={gitActions.onRunSelectedThreadGitAction}
187 onSelectThread={(thread) => {
188 onSelectThread(thread);
189 router.replace(buildThreadRoutePath(thread));
190 }}
191 onStartNewTask={() => router.push("/new")}
192 />214 />
193 </View>215
216 <View className="flex-1 bg-screen">
217 <ThreadDetailScreen
218 selectedThread={selectedThread}
219 screenTone={connectionTone(connectionState)}
220 connectionError={connectionError}
221 httpBaseUrl={selectedEnvironmentConnection?.httpBaseUrl ?? null}
222 bearerToken={selectedEnvironmentConnection?.bearerToken ?? null}
223 selectedThreadFeed={composer.selectedThreadFeed}
224 activeWorkDurationLabel={composer.activeWorkDurationLabel}
225 activePendingApproval={composer.activePendingApproval}
226 respondingApprovalId={commands.respondingApprovalId}
227 activePendingUserInput={composer.activePendingUserInput}
228 activePendingUserInputDrafts={composer.activePendingUserInputDrafts}
229 activePendingUserInputAnswers={composer.activePendingUserInputAnswers}
230 respondingUserInputId={commands.respondingUserInputId}
231 draftMessage={composer.draftMessage}
232 draftAttachments={composer.draftAttachments}
233 connectionStateLabel={connectionState}
234 activeThreadBusy={composer.activeThreadBusy}
235 projectWorkspaceRoot={selectedThreadProject?.workspaceRoot ?? null}
236 selectedThreadQueueCount={composer.selectedThreadQueueCount}
237 onOpenDrawer={() => setDrawerVisible(true)}
238 onOpenConnectionEditor={() => router.push("/connections")}
239 onChangeDraftMessage={composer.onChangeDraftMessage}
240 onPickDraftImages={composer.onPickDraftImages}
241 onNativePasteImages={composer.onNativePasteImages}
242 onRemoveDraftImage={composer.onRemoveDraftImage}
243 onRefresh={handleRefreshAll}
244 serverConfig={serverConfig}
245 onStopThread={commands.onStopThread}
246 onSendMessage={composer.onSendMessage}
247 onUpdateThreadModelSelection={commands.onUpdateThreadModelSelection}
248 onUpdateThreadRuntimeMode={commands.onUpdateThreadRuntimeMode}
249 onUpdateThreadInteractionMode={commands.onUpdateThreadInteractionMode}
250 onRespondToApproval={commands.onRespondToApproval}
251 onSelectUserInputOption={composer.onSelectUserInputOption}
252 onChangeUserInputCustomAnswer={composer.onChangeUserInputCustomAnswer}
253 onSubmitUserInput={commands.onSubmitUserInput}
254 />
255
256 <ThreadNavigationDrawer
257 visible={drawerVisible}
258 projects={projects}
259 threads={threads}
260 selectedThreadKey={selectedThreadKey}
261 onClose={() => setDrawerVisible(false)}
262 onSelectThread={(thread) => {
263 onSelectThread(thread);
264 router.replace(buildThreadRoutePath(thread));
265 }}
266 onStartNewTask={() => router.push("/new")}
267 />
268 </View>
269 </>
194 );270 );
195}271}
apps/mobile/src/features/threads/git/GitBranchesSheet.tsx+205−0
@@ -0,0 +1,205 @@
1import { sanitizeFeatureBranchName } from "@t3tools/shared/git";
2import { useRouter } from "expo-router";
3import { useEffect, useState } from "react";
4import { Pressable, ScrollView, View } from "react-native";
5import { useSafeAreaInsets } from "react-native-safe-area-context";
6import { useThemeColor } from "../../../lib/useThemeColor";
7
8import { AppText as Text, AppTextInput as TextInput } from "../../../components/AppText";
9import { useGitStatus } from "../../../state/use-git-status";
10import { useSelectedThreadGitActions } from "../../../state/use-selected-thread-git-actions";
11import { useSelectedThreadGitState } from "../../../state/use-selected-thread-git-state";
12import { useThreadSelection } from "../../../state/use-thread-selection";
13import { SheetActionButton } from "./gitSheetComponents";
14
15export function GitBranchesSheet() {
16 const router = useRouter();
17 const insets = useSafeAreaInsets();
18 const { selectedThread, selectedThreadProject } = useThreadSelection();
19 const gitState = useSelectedThreadGitState();
20 const gitActions = useSelectedThreadGitActions();
21
22 const borderColor = useThemeColor("--color-border");
23 const inputBorderColor = useThemeColor("--color-input-border");
24 const inputBg = useThemeColor("--color-input");
25 const foregroundColor = useThemeColor("--color-foreground");
26 const subtleStrongColor = useThemeColor("--color-subtle-strong");
27
28 const gitStatus = useGitStatus({
29 environmentId: selectedThread?.environmentId ?? "",
30 cwd: selectedThread?.worktreePath ?? selectedThreadProject?.workspaceRoot ?? null,
31 });
32
33 const currentBranchLabel = gitStatus.data?.branch ?? selectedThread?.branch ?? "Detached HEAD";
34 const currentWorktreePath = selectedThread?.worktreePath ?? null;
35 const availableBranches = gitState.selectedThreadBranches;
36 const branchesLoading = gitState.selectedThreadBranchesLoading;
37 const busy = gitState.gitOperationLabel !== null;
38
39 const [newBranchName, setNewBranchName] = useState("");
40 const [worktreeBaseBranch, setWorktreeBaseBranch] = useState(
41 currentBranchLabel === "Detached HEAD" ? "main" : currentBranchLabel,
42 );
43 const [worktreeBranchName, setWorktreeBranchName] = useState("");
44
45 const disabledExistingBranches = new Set(
46 availableBranches
47 .filter(
48 (branch) => branch.worktreePath !== null && branch.worktreePath !== currentWorktreePath,
49 )
50 .map((branch) => branch.name),
51 );
52
53 useEffect(() => {
54 void gitActions.refreshSelectedThreadBranches();
55 }, [gitActions]);
56
57 return (
58 <ScrollView
59 showsVerticalScrollIndicator={false}
60 contentContainerStyle={{
61 paddingHorizontal: 20,
62 paddingTop: 8,
63 paddingBottom: Math.max(insets.bottom, 18) + 18,
64 gap: 16,
65 }}
66 >
67 <View className="gap-2 rounded-[18px] border border-border bg-card px-4 py-4">
68 <Text
69 className="text-foreground-secondary text-[11px] font-t3-bold uppercase"
70 style={{ letterSpacing: 1 }}
71 >
72 New branch
73 </Text>
74 <TextInput
75 value={newBranchName}
76 onChangeText={setNewBranchName}
77 placeholder="feature/mobile-polish"
78 className="rounded-[18px] px-3.5 py-3 font-sans text-[15px]"
79 style={{
80 borderWidth: 1,
81 borderColor: inputBorderColor,
82 backgroundColor: inputBg,
83 color: foregroundColor,
84 }}
85 />
86 <SheetActionButton
87 icon="plus"
88 label="Create & checkout"
89 tone="primary"
90 disabled={busy || newBranchName.trim().length === 0}
91 onPress={() => {
92 const branch = sanitizeFeatureBranchName(newBranchName.trim());
93 if (branch.length === 0) return;
94 void gitActions.onCreateSelectedThreadBranch(branch).then(() => {
95 setNewBranchName("");
96 router.dismiss();
97 });
98 }}
99 />
100 </View>
101
102 <View className="gap-2 rounded-[18px] border border-border bg-card px-4 py-4">
103 <Text
104 className="text-foreground-secondary text-[11px] font-t3-bold uppercase"
105 style={{ letterSpacing: 1 }}
106 >
107 New worktree
108 </Text>
109 <TextInput
110 value={worktreeBaseBranch}
111 onChangeText={setWorktreeBaseBranch}
112 placeholder="main"
113 className="rounded-[18px] px-3.5 py-3 font-sans text-[15px]"
114 style={{
115 borderWidth: 1,
116 borderColor: inputBorderColor,
117 backgroundColor: inputBg,
118 color: foregroundColor,
119 }}
120 />
121 <TextInput
122 value={worktreeBranchName}
123 onChangeText={setWorktreeBranchName}
124 placeholder="feature/mobile-thread"
125 className="rounded-[18px] px-3.5 py-3 font-sans text-[15px]"
126 style={{
127 borderWidth: 1,
128 borderColor: inputBorderColor,
129 backgroundColor: inputBg,
130 color: foregroundColor,
131 }}
132 />
133 <SheetActionButton
134 icon="square.split.2x1"
135 label="Create worktree"
136 tone="primary"
137 disabled={
138 busy || worktreeBaseBranch.trim().length === 0 || worktreeBranchName.trim().length === 0
139 }
140 onPress={() => {
141 const baseBranch = worktreeBaseBranch.trim();
142 const newBranch = worktreeBranchName.trim();
143 if (baseBranch.length === 0 || newBranch.length === 0) return;
144 void gitActions
145 .onCreateSelectedThreadWorktree({ baseBranch, newBranch })
146 .then(() => {
147 setWorktreeBranchName("");
148 router.dismiss();
149 });
150 }}
151 />
152 </View>
153
154 <View className="gap-2">
155 <Text
156 className="text-foreground-secondary text-[11px] font-t3-bold uppercase"
157 style={{ letterSpacing: 1 }}
158 >
159 Existing branches
160 </Text>
161 {branchesLoading ? (
162 <Text className="text-foreground-secondary text-[13px] font-medium">
163 Loading branches...
164 </Text>
165 ) : null}
166 {!branchesLoading && availableBranches.length === 0 ? (
167 <Text className="text-foreground-secondary text-[13px] font-medium">
168 No local branches found.
169 </Text>
170 ) : null}
171 {availableBranches.map((branch) => {
172 const disabled = disabledExistingBranches.has(branch.name);
173 const subtitle = branch.worktreePath
174 ? branch.worktreePath === currentWorktreePath
175 ? "Checked out in this thread"
176 : "Checked out in another worktree"
177 : branch.isDefault
178 ? "Default branch"
179 : "Local branch";
180
181 return (
182 <Pressable
183 key={branch.name}
184 className="gap-1 rounded-[18px] border px-4 py-3"
185 disabled={busy || disabled}
186 style={{
187 borderColor: branch.current ? subtleStrongColor : borderColor,
188 opacity: busy || disabled ? 0.45 : 1,
189 }}
190 onPress={() => {
191 void gitActions.onCheckoutSelectedThreadBranch(branch.name).then(() => {
192 router.dismiss();
193 });
194 }}
195 >
196 <View className="absolute inset-0 rounded-[18px] bg-card" />
197 <Text className="text-foreground text-[15px] font-t3-bold">{branch.name}</Text>
198 <Text className="text-foreground-secondary text-[12px] font-medium">{subtitle}</Text>
199 </Pressable>
200 );
201 })}
202 </View>
203 </ScrollView>
204 );
205}
apps/mobile/src/features/threads/git/GitCommitSheet.tsx+242−0
@@ -0,0 +1,242 @@
1import { useRouter } from "expo-router";
2import { useCallback, useState } from "react";
3import { Pressable, ScrollView, View, useColorScheme } from "react-native";
4import { useSafeAreaInsets } from "react-native-safe-area-context";
5import { useThemeColor } from "../../../lib/useThemeColor";
6
7import { AppText as Text, AppTextInput as TextInput } from "../../../components/AppText";
8import { useGitStatus } from "../../../state/use-git-status";
9import { useSelectedThreadGitActions } from "../../../state/use-selected-thread-git-actions";
10import { useSelectedThreadGitState } from "../../../state/use-selected-thread-git-state";
11import { useThreadSelection } from "../../../state/use-thread-selection";
12import { SheetActionButton } from "./gitSheetComponents";
13
14export function GitCommitSheet() {
15 const router = useRouter();
16 const insets = useSafeAreaInsets();
17 const isDarkMode = useColorScheme() === "dark";
18 const { selectedThread, selectedThreadProject } = useThreadSelection();
19 const gitState = useSelectedThreadGitState();
20 const gitActions = useSelectedThreadGitActions();
21
22 const borderColor = useThemeColor("--color-border");
23 const borderSubtleColor = useThemeColor("--color-border-subtle");
24 const inputBorderColor = useThemeColor("--color-input-border");
25 const inputBg = useThemeColor("--color-input");
26 const foregroundColor = useThemeColor("--color-foreground");
27
28 const gitStatus = useGitStatus({
29 environmentId: selectedThread?.environmentId ?? "",
30 cwd: selectedThread?.worktreePath ?? selectedThreadProject?.workspaceRoot ?? null,
31 });
32
33 const busy = gitState.gitOperationLabel !== null;
34 const isDefaultBranch = gitStatus.data?.isDefaultBranch ?? false;
35 const allFiles = gitStatus.data?.workingTree?.files ?? [];
36
37 const [dialogCommitMessage, setDialogCommitMessage] = useState("");
38 const [excludedFiles, setExcludedFiles] = useState<ReadonlySet<string>>(new Set());
39 const [isEditingFiles, setIsEditingFiles] = useState(false);
40
41 const selectedFiles = allFiles.filter((file) => !excludedFiles.has(file.path));
42 const allSelected = excludedFiles.size === 0;
43 const noneSelected = selectedFiles.length === 0;
44 const selectedInsertions = selectedFiles.reduce((sum, file) => sum + file.insertions, 0);
45 const selectedDeletions = selectedFiles.reduce((sum, file) => sum + file.deletions, 0);
46 const selectedFilePreview = selectedFiles.slice(0, 3);
47
48 const runCommitAction = useCallback(
49 async (featureBranch: boolean) => {
50 const commitMessage = dialogCommitMessage.trim();
51 router.dismiss();
52 await gitActions.onRunSelectedThreadGitAction({
53 action: "commit",
54 featureBranch,
55 ...(commitMessage ? { commitMessage } : {}),
56 ...(!allSelected ? { filePaths: selectedFiles.map((file) => file.path) } : {}),
57 });
58 },
59 [allSelected, dialogCommitMessage, gitActions, router, selectedFiles],
60 );
61
62 return (
63 <ScrollView
64 keyboardShouldPersistTaps="handled"
65 showsVerticalScrollIndicator={false}
66 contentContainerStyle={{
67 paddingHorizontal: 20,
68 paddingTop: 8,
69 paddingBottom: Math.max(insets.bottom, 18) + 18,
70 gap: 16,
71 }}
72 >
73 <View className="gap-3 rounded-[22px] border border-border bg-card px-4 py-4">
74 <View className="flex-row items-center justify-between gap-3">
75 <Text className="text-foreground-muted text-[13px] font-medium">Branch</Text>
76 <Text className="text-foreground text-[15px] font-t3-bold">
77 {gitStatus.data?.branch ?? "(detached HEAD)"}
78 </Text>
79 </View>
80 {isDefaultBranch ? (
81 <Text
82 className="text-[12px] leading-[18px]"
83 style={{ color: isDarkMode ? "#fbbf24" : "#b45309" }}
84 >
85 Warning: this is the default branch.
86 </Text>
87 ) : null}
88 </View>
89
90 <View className="gap-3 rounded-[22px] border border-border bg-card px-4 py-4">
91 <View className="flex-row items-center justify-between gap-3">
92 <View className="gap-1">
93 <Text className="text-foreground text-[16px] font-t3-bold">Files</Text>
94 <Text className="text-foreground-muted text-[12px] leading-[18px]">
95 {selectedFiles.length} selected · +{selectedInsertions} / -{selectedDeletions}
96 </Text>
97 </View>
98 <View className="flex-row items-center gap-2">
99 {!allSelected && isEditingFiles ? (
100 <Pressable
101 className="bg-subtle rounded-full px-3 py-2"
102 onPress={() => setExcludedFiles(new Set())}
103 >
104 <Text className="text-foreground text-[11px] font-t3-bold uppercase">Reset</Text>
105 </Pressable>
106 ) : null}
107 <Pressable
108 className="bg-subtle rounded-full px-3 py-2"
109 onPress={() => setIsEditingFiles((current) => !current)}
110 >
111 <Text className="text-foreground text-[11px] font-t3-bold uppercase">
112 {isEditingFiles ? "Done" : "Edit"}
113 </Text>
114 </Pressable>
115 </View>
116 </View>
117
118 {allFiles.length === 0 ? (
119 <Text className="text-foreground-secondary text-[13px] leading-[19px]">
120 No changed files are available to commit.
121 </Text>
122 ) : !isEditingFiles ? (
123 <View className="gap-2">
124 {selectedFilePreview.map((file) => (
125 <View key={file.path} className="flex-row items-center justify-between gap-3">
126 <Text
127 className="text-foreground flex-1 text-[13px] font-medium"
128 numberOfLines={1}
129 >
130 {file.path}
131 </Text>
132 <Text className="text-[12px] font-t3-bold" style={{ color: "#10b981" }}>
133 +{file.insertions}
134 </Text>
135 <Text className="text-[12px] font-t3-bold" style={{ color: "#f43f5e" }}>
136 -{file.deletions}
137 </Text>
138 </View>
139 ))}
140 {selectedFiles.length > selectedFilePreview.length ? (
141 <Text className="text-foreground-muted text-[12px] leading-[17px]">
142 +{selectedFiles.length - selectedFilePreview.length} more files
143 </Text>
144 ) : null}
145 </View>
146 ) : (
147 <View className="gap-2">
148 {allFiles.map((file) => {
149 const included = !excludedFiles.has(file.path);
150 return (
151 <Pressable
152 key={file.path}
153 className="rounded-[18px] border px-4 py-3"
154 style={{
155 borderColor: included ? borderColor : borderSubtleColor,
156 }}
157 onPress={() => {
158 setExcludedFiles((current) => {
159 const next = new Set(current);
160 if (next.has(file.path)) {
161 next.delete(file.path);
162 } else {
163 next.add(file.path);
164 }
165 return next;
166 });
167 }}
168 >
169 <View
170 className={`absolute inset-0 rounded-[18px] ${included ? "bg-card" : "bg-subtle"}`}
171 />
172 <View className="flex-row items-start justify-between gap-3">
173 <View className="flex-1 gap-1">
174 <Text
175 selectable
176 className={`text-[13px] font-t3-bold ${included ? "text-foreground" : "text-foreground-muted"}`}
177 >
178 {file.path}
179 </Text>
180 {!included ? (
181 <Text className="text-foreground-muted text-[11px] leading-[16px]">
182 Excluded from this commit
183 </Text>
184 ) : null}
185 </View>
186 <View className="items-end gap-1">
187 <Text className="text-[12px] font-t3-bold" style={{ color: "#10b981" }}>
188 +{file.insertions}
189 </Text>
190 <Text className="text-[12px] font-t3-bold" style={{ color: "#f43f5e" }}>
191 -{file.deletions}
192 </Text>
193 </View>
194 </View>
195 </Pressable>
196 );
197 })}
198 </View>
199 )}
200 </View>
201
202 <View className="gap-2">
203 <Text className="text-foreground text-[13px] font-t3-bold">Commit message</Text>
204 <TextInput
205 multiline
206 value={dialogCommitMessage}
207 onChangeText={setDialogCommitMessage}
208 placeholder="Leave empty to auto-generate"
209 textAlignVertical="top"
210 className="min-h-[128px] rounded-[20px] px-4 py-3.5 font-sans text-[15px]"
211 style={{
212 minHeight: 128,
213 borderWidth: 1,
214 borderColor: inputBorderColor,
215 backgroundColor: inputBg,
216 color: foregroundColor,
217 }}
218 />
219 </View>
220
221 <View className="flex-row gap-3">
222 <View className="flex-1">
223 <SheetActionButton
224 icon="arrow.branch"
225 label="Commit on new branch"
226 disabled={noneSelected || busy}
227 onPress={() => void runCommitAction(true)}
228 />
229 </View>
230 <View className="flex-1">
231 <SheetActionButton
232 icon="checkmark.circle"
233 label="Commit"
234 tone="primary"
235 disabled={noneSelected || busy}
236 onPress={() => void runCommitAction(false)}
237 />
238 </View>
239 </View>
240 </ScrollView>
241 );
242}
apps/mobile/src/features/threads/git/GitConfirmSheet.tsx+123−0
@@ -0,0 +1,123 @@
1import { resolveDefaultBranchActionDialogCopy } from "@t3tools/client-runtime";
2import { resolveAutoFeatureBranchName } from "@t3tools/shared/git";
3import { useLocalSearchParams, useRouter } from "expo-router";
4import { useCallback, useMemo } from "react";
5import { View } from "react-native";
6import { useSafeAreaInsets } from "react-native-safe-area-context";
7
8import { AppText as Text } from "../../../components/AppText";
9import { useSelectedThreadGitActions } from "../../../state/use-selected-thread-git-actions";
10import { useSelectedThreadGitState } from "../../../state/use-selected-thread-git-state";
11import { SheetActionButton } from "./gitSheetComponents";
12
13export function GitConfirmSheet() {
14 const router = useRouter();
15 const insets = useSafeAreaInsets();
16 const gitState = useSelectedThreadGitState();
17 const gitActions = useSelectedThreadGitActions();
18
19 const params = useLocalSearchParams<{
20 confirmAction?: string;
21 branchName?: string;
22 includesCommit?: string;
23 commitMessage?: string;
24 filePaths?: string;
25 }>();
26
27 const confirmAction = params.confirmAction as
28 | "push"
29 | "create_pr"
30 | "commit_push"
31 | "commit_push_pr"
32 | undefined;
33 const branchName = params.branchName ?? "";
34 const includesCommit = params.includesCommit === "true";
35
36 const copy = useMemo(
37 () =>
38 confirmAction
39 ? resolveDefaultBranchActionDialogCopy({
40 action: confirmAction,
41 branchName,
42 includesCommit,
43 })
44 : null,
45 [branchName, confirmAction, includesCommit],
46 );
47
48 const continuePendingAction = useCallback(async () => {
49 if (!confirmAction) return;
50 router.dismissAll();
51 await gitActions.onRunSelectedThreadGitAction({
52 action: confirmAction,
53 ...(params.commitMessage ? { commitMessage: params.commitMessage } : {}),
54 ...(params.filePaths ? { filePaths: params.filePaths.split(",") } : {}),
55 });
56 }, [confirmAction, gitActions, params, router]);
57
58 const movePendingActionToFeatureBranch = useCallback(async () => {
59 if (!confirmAction) return;
60 router.dismissAll();
61
62 if (includesCommit) {
63 await gitActions.onRunSelectedThreadGitAction({
64 action: confirmAction,
65 featureBranch: true,
66 ...(params.commitMessage ? { commitMessage: params.commitMessage } : {}),
67 ...(params.filePaths ? { filePaths: params.filePaths.split(",") } : {}),
68 });
69 return;
70 }
71
72 const branches =
73 gitState.selectedThreadBranches.length > 0
74 ? gitState.selectedThreadBranches
75 : await gitActions.refreshSelectedThreadBranches();
76 const newBranchName = resolveAutoFeatureBranchName(
77 branches.filter((branch) => !branch.isRemote).map((branch) => branch.name),
78 );
79 await gitActions.onCreateSelectedThreadBranch(newBranchName);
80 await gitActions.onRunSelectedThreadGitAction({ action: confirmAction });
81 }, [confirmAction, gitActions, gitState.selectedThreadBranches, includesCommit, params, router]);
82
83 return (
84 <View
85 collapsable={false}
86 className="flex-1 bg-sheet"
87 >
88 <View style={{ minHeight: 16, paddingTop: 8 }} />
89
90 <View className="items-center gap-1 px-5 pb-3 pt-4">
91 <Text
92 className="text-[12px] font-t3-bold uppercase text-foreground-muted"
93 style={{ letterSpacing: 1 }}
94 >
95 Confirm
96 </Text>
97 <Text className="text-center text-[28px] font-t3-bold">
98 {copy?.title ?? "Run action on default branch?"}
99 </Text>
100 <Text className="text-center text-foreground-secondary text-[13px] font-medium leading-[19px]">
101 {copy?.description ?? "Choose how to continue."}
102 </Text>
103 </View>
104
105 <View
106 className="gap-3 px-5"
107 style={{ paddingBottom: Math.max(insets.bottom, 18) + 8, paddingTop: 8 }}
108 >
109 <SheetActionButton
110 icon="arrow.right.circle"
111 label={copy?.continueLabel ?? "Continue"}
112 onPress={() => void continuePendingAction()}
113 />
114 <SheetActionButton
115 icon="arrow.branch"
116 label="Feature branch & continue"
117 tone="primary"
118 onPress={() => void movePendingActionToFeatureBranch()}
119 />
120 </View>
121 </View>
122 );
123}
apps/mobile/src/features/threads/git/GitOverviewSheet.tsx+249−0
@@ -0,0 +1,249 @@
1import type { GitRunStackedActionResult } from "@t3tools/contracts";
2import {
3 type GitActionRequestInput,
4 buildMenuItems,
5 getGitActionDisabledReason,
6 requiresDefaultBranchConfirmation,
7} from "@t3tools/client-runtime";
8import { useLocalSearchParams, useRouter } from "expo-router";
9import { SymbolView } from "expo-symbols";
10import { useCallback, useEffect, useMemo } from "react";
11import { Alert, Linking, Pressable, ScrollView, View } from "react-native";
12import { useSafeAreaInsets } from "react-native-safe-area-context";
13import { useThemeColor } from "../../../lib/useThemeColor";
14
15import { AppText as Text } from "../../../components/AppText";
16import { StatusPill } from "../../../components/StatusPill";
17import { useGitStatus } from "../../../state/use-git-status";
18import { useSelectedThreadGitActions } from "../../../state/use-selected-thread-git-actions";
19import { useSelectedThreadGitState } from "../../../state/use-selected-thread-git-state";
20import { useThreadSelection } from "../../../state/use-thread-selection";
21import { MetaCard, SheetListRow, menuItemIconName, statusSummary } from "./gitSheetComponents";
22
23export function GitOverviewSheet() {
24 const router = useRouter();
25 const insets = useSafeAreaInsets();
26 const { environmentId, threadId } = useLocalSearchParams<{
27 environmentId: string;
28 threadId: string;
29 }>();
30 const { selectedThread, selectedThreadProject } = useThreadSelection();
31 const gitState = useSelectedThreadGitState();
32 const gitActions = useSelectedThreadGitActions();
33
34 const iconColor = useThemeColor("--color-icon");
35 const borderColor = useThemeColor("--color-border");
36
37 const gitStatus = useGitStatus({
38 environmentId: selectedThread?.environmentId ?? "",
39 cwd: selectedThread?.worktreePath ?? selectedThreadProject?.workspaceRoot ?? null,
40 });
41
42 const currentBranchLabel = gitStatus.data?.branch ?? selectedThread?.branch ?? "Detached HEAD";
43 const currentWorktreePath = selectedThread?.worktreePath ?? null;
44 const gitOperationLabel = gitState.gitOperationLabel;
45 const busy = gitOperationLabel !== null;
46 const isRepo = gitStatus.data?.isRepo ?? true;
47 const hasOriginRemote = gitStatus.data?.hasOriginRemote ?? false;
48 const isDefaultBranch = gitStatus.data?.isDefaultBranch ?? false;
49
50 const menuItems = useMemo(
51 () => (isRepo ? buildMenuItems(gitStatus.data, busy, hasOriginRemote) : []),
52 [busy, gitStatus.data, hasOriginRemote, isRepo],
53 );
54
55 const sheetMenuItems = useMemo(
56 () =>
57 menuItems.map((item) => ({
58 item,
59 disabledReason: getGitActionDisabledReason({
60 item,
61 gitStatus: gitStatus.data,
62 isBusy: busy,
63 hasOriginRemote,
64 }),
65 })),
66 [busy, gitStatus.data, hasOriginRemote, menuItems],
67 );
68
69 useEffect(() => {
70 void gitActions.refreshSelectedThreadGitStatus({ quiet: true });
71 }, [gitActions]);
72
73 const openExistingPr = useCallback(async () => {
74 const prUrl = gitStatus.data?.pr?.state === "open" ? gitStatus.data.pr.url : null;
75 if (!prUrl) {
76 Alert.alert("No open PR", "This branch does not have an open pull request.");
77 return;
78 }
79 try {
80 await Linking.openURL(prUrl);
81 } catch (error) {
82 Alert.alert(
83 "Unable to open PR",
84 error instanceof Error ? error.message : "An error occurred.",
85 );
86 }
87 }, [gitStatus.data]);
88
89 const runActionWithPrompt = useCallback(
90 async (input: GitActionRequestInput) => {
91 const confirmableAction =
92 input.action === "push" ||
93 input.action === "create_pr" ||
94 input.action === "commit_push" ||
95 input.action === "commit_push_pr"
96 ? input.action
97 : null;
98 const branchName = gitStatus.data?.branch;
99 if (
100 branchName &&
101 confirmableAction &&
102 !input.featureBranch &&
103 requiresDefaultBranchConfirmation(input.action, isDefaultBranch)
104 ) {
105 router.push({
106 pathname: "/threads/[environmentId]/[threadId]/git-confirm",
107 params: {
108 environmentId,
109 threadId,
110 confirmAction: confirmableAction,
111 branchName,
112 includesCommit: String(
113 input.action === "commit_push" || input.action === "commit_push_pr",
114 ),
115 },
116 });
117 return;
118 }
119
120 router.dismiss();
121 await gitActions.onRunSelectedThreadGitAction(input);
122 },
123 [environmentId, gitActions, gitStatus.data, isDefaultBranch, router, threadId],
124 );
125
126 const onPressMenuItem = useCallback(
127 async (item: (typeof menuItems)[number]) => {
128 if (item.disabled) return;
129 if (item.kind === "open_pr") {
130 await openExistingPr();
131 return;
132 }
133 if (item.dialogAction === "commit") {
134 router.push({
135 pathname: "/threads/[environmentId]/[threadId]/git/commit",
136 params: { environmentId, threadId },
137 });
138 return;
139 }
140 if (item.dialogAction === "push") {
141 await runActionWithPrompt({ action: "push" });
142 return;
143 }
144 if (item.dialogAction === "create_pr") {
145 await runActionWithPrompt({ action: "create_pr" });
146 }
147 },
148 [environmentId, openExistingPr, router, runActionWithPrompt, threadId],
149 );
150
151 return (
152 <View collapsable={false} className="flex-1 bg-sheet">
153 <View style={{ minHeight: 16, paddingTop: 8 }} />
154
155 <View className="items-center gap-1 px-5 pb-3 pt-4">
156 <Pressable
157 className="absolute right-3 top-4 h-9 w-9 items-center justify-center rounded-full bg-subtle"
158 style={{ zIndex: 1, opacity: busy ? 0.45 : 1 }}
159 disabled={busy}
160 onPress={() => void gitActions.refreshSelectedThreadGitStatus()}
161 >
162 <SymbolView
163 name="arrow.clockwise"
164 size={16}
165 tintColor={iconColor}
166 type="monochrome"
167 weight="medium"
168 />
169 </Pressable>
170 <Text
171 className="text-[12px] font-t3-bold uppercase text-foreground-muted"
172 style={{ letterSpacing: 1 }}
173 >
174 Branch
175 </Text>
176 <Text className="text-[28px] font-t3-bold">{currentBranchLabel}</Text>
177 <Text className="text-foreground-secondary text-[13px] font-medium leading-[19px]">
178 {statusSummary(gitStatus.data)}
179 </Text>
180 </View>
181
182 <ScrollView
183 showsVerticalScrollIndicator={false}
184 style={{ flex: 1 }}
185 contentContainerStyle={{
186 paddingHorizontal: 20,
187 paddingTop: 8,
188 paddingBottom: Math.max(insets.bottom, 18) + 18,
189 gap: 14,
190 }}
191 >
192 <View className="overflow-hidden rounded-[22px] border border-border bg-card px-4 py-1">
193 {sheetMenuItems.map(({ item, disabledReason }, index) => (
194 <View key={`${item.id}-${item.label}`}>
195 {index > 0 ? (
196 <View className="ml-12 h-px" style={{ backgroundColor: borderColor }} />
197 ) : null}
198 <SheetListRow
199 icon={menuItemIconName(item.icon)}
200 title={item.label}
201 subtitle={disabledReason}
202 disabled={item.disabled}
203 onPress={() => void onPressMenuItem(item)}
204 />
205 </View>
206 ))}
207 {(gitStatus.data?.behindCount ?? 0) > 0 ? (
208 <>
209 <View className="ml-12 h-px" style={{ backgroundColor: borderColor }} />
210 <SheetListRow
211 icon="arrow.down.circle"
212 title="Pull latest"
213 subtitle="Sync this branch with upstream"
214 disabled={busy || !isRepo}
215 onPress={() => void gitActions.onPullSelectedThreadBranch()}
216 />
217 </>
218 ) : null}
219 <View className="ml-12 h-px" style={{ backgroundColor: borderColor }} />
220 <SheetListRow
221 icon="point.topleft.down.curvedto.point.bottomright.up"
222 title="Branches & worktrees"
223 subtitle="Switch branch, create branch, or move to a worktree"
224 disabled={busy || !isRepo}
225 onPress={() =>
226 router.push({
227 pathname: "/threads/[environmentId]/[threadId]/git/branches",
228 params: { environmentId, threadId },
229 })
230 }
231 />
232 </View>
233
234 {currentWorktreePath ? <MetaCard label="Worktree" value={currentWorktreePath} /> : null}
235
236 {gitOperationLabel ? (
237 <View className="self-start">
238 <StatusPill
239 label={gitOperationLabel}
240 pillClassName="bg-neutral-900/8"
241 textClassName="text-neutral-700"
242 size="compact"
243 />
244 </View>
245 ) : null}
246 </ScrollView>
247 </View>
248 );
249}
apps/mobile/src/features/threads/git/gitSheetComponents.tsx+152−0
@@ -0,0 +1,152 @@
1import { SymbolView } from "expo-symbols";
2import type { ComponentProps } from "react";
3import { Pressable, View } from "react-native";
4import { useThemeColor } from "../../../lib/useThemeColor";
5import { AppText as Text } from "../../../components/AppText";
6
7/* ─── Shared sheet components ──────────────────────────────────────── */
8
9export function SheetActionButton(props: {
10 readonly icon: ComponentProps<typeof SymbolView>["name"];
11 readonly label: string;
12 readonly disabled?: boolean;
13 readonly tone?: "primary" | "secondary" | "danger";
14 readonly onPress: () => void;
15}) {
16 const primaryBg = useThemeColor("--color-primary");
17 const primaryFg = useThemeColor("--color-primary-foreground");
18 const dangerBg = useThemeColor("--color-danger");
19 const dangerBorder = useThemeColor("--color-danger-border");
20 const dangerFg = useThemeColor("--color-danger-foreground");
21 const secondaryBg = useThemeColor("--color-secondary");
22 const secondaryBorder = useThemeColor("--color-secondary-border");
23 const secondaryFg = useThemeColor("--color-secondary-foreground");
24
25 const tone = props.tone ?? "secondary";
26 const colors =
27 tone === "primary"
28 ? {
29 backgroundColor: primaryBg,
30 borderColor: "transparent",
31 textColor: primaryFg,
32 }
33 : tone === "danger"
34 ? {
35 backgroundColor: dangerBg,
36 borderColor: dangerBorder,
37 textColor: dangerFg,
38 }
39 : {
40 backgroundColor: secondaryBg,
41 borderColor: secondaryBorder,
42 textColor: secondaryFg,
43 };
44
45 return (
46 <Pressable
47 className="min-h-[48px] flex-1 flex-row items-center justify-center gap-2 rounded-[18px] px-4 py-3"
48 disabled={props.disabled}
49 style={{
50 backgroundColor: colors.backgroundColor,
51 borderWidth: tone === "primary" ? 0 : 1,
52 borderColor: colors.borderColor,
53 opacity: props.disabled ? 0.45 : 1,
54 }}
55 onPress={props.onPress}
56 >
57 <SymbolView name={props.icon} size={16} tintColor={colors.textColor} type="monochrome" />
58 <Text
59 className="text-[12px] font-t3-bold uppercase"
60 style={{ color: colors.textColor, letterSpacing: 0.9 }}
61 >
62 {props.label}
63 </Text>
64 </Pressable>
65 );
66}
67
68export function MetaCard(props: { readonly label: string; readonly value: string }) {
69 return (
70 <View className="rounded-[18px] border border-border bg-card px-4 py-3">
71 <Text
72 className="text-foreground-muted text-[11px] font-t3-bold uppercase"
73 style={{ letterSpacing: 0.9 }}
74 >
75 {props.label}
76 </Text>
77 <Text selectable className="text-foreground text-[13px] font-medium" numberOfLines={1}>
78 {props.value}
79 </Text>
80 </View>
81 );
82}
83
84export function SheetListRow(props: {
85 readonly icon: ComponentProps<typeof SymbolView>["name"];
86 readonly title: string;
87 readonly subtitle?: string | null;
88 readonly disabled?: boolean;
89 readonly onPress: () => void;
90}) {
91 const iconColor = useThemeColor("--color-icon");
92 const iconSubtleColor = useThemeColor("--color-icon-subtle");
93
94 return (
95 <Pressable
96 className="flex-row items-center gap-3 px-1 py-3"
97 disabled={props.disabled}
98 style={{ opacity: props.disabled ? 0.45 : 1 }}
99 onPress={props.onPress}
100 >
101 <View className="bg-subtle h-9 w-9 items-center justify-center rounded-full">
102 <SymbolView name={props.icon} size={16} tintColor={iconColor} type="monochrome" />
103 </View>
104 <View className="flex-1 gap-0.5">
105 <Text className="text-foreground text-[16px] font-t3-bold">{props.title}</Text>
106 {props.subtitle ? (
107 <Text className="text-foreground-muted text-[12px] leading-[17px]">{props.subtitle}</Text>
108 ) : null}
109 </View>
110 <SymbolView name="chevron.right" size={13} tintColor={iconSubtleColor} type="monochrome" />
111 </Pressable>
112 );
113}
114
115/* ─── Shared utilities ──────────────────────────────────────────────── */
116
117export function menuItemIconName(
118 icon: "commit" | "push" | "pr",
119): ComponentProps<typeof SymbolView>["name"] {
120 if (icon === "commit") return "checkmark.circle";
121 if (icon === "push") return "arrow.up.circle";
122 return "arrow.up.right.circle";
123}
124
125export function statusSummary(gitStatus: { readonly isRepo?: boolean; readonly hasWorkingTreeChanges?: boolean; readonly workingTree?: { readonly files: readonly { readonly path: string }[] }; readonly aheadCount?: number; readonly behindCount?: number; readonly pr?: { readonly state?: string; readonly number?: number } | null } | null): string {
126 if (!gitStatus) {
127 return "Loading branch status\u2026";
128 }
129
130 if (!gitStatus.isRepo) {
131 return "Not a git repository";
132 }
133
134 const parts: string[] = [];
135 if (gitStatus.hasWorkingTreeChanges) {
136 const fileCount = gitStatus.workingTree?.files.length ?? 0;
137 parts.push(`${fileCount} file${fileCount === 1 ? "" : "s"} changed`);
138 } else {
139 parts.push("Clean");
140 }
141 if ((gitStatus.aheadCount ?? 0) > 0) {
142 parts.push(`${gitStatus.aheadCount} ahead`);
143 }
144 if ((gitStatus.behindCount ?? 0) > 0) {
145 parts.push(`${gitStatus.behindCount} behind`);
146 }
147 if (gitStatus.pr?.state === "open") {
148 parts.push(`PR #${gitStatus.pr.number} open`);
149 }
150
151 return parts.join(" \u00b7 ");
152}
apps/mobile/src/features/threads/new-task-flow-provider.tsx+29−12
@@ -15,7 +15,8 @@ import { buildModelOptions, groupByProvider } from "../../lib/modelOptions"; · 14 unmodified lines
15import { groupProjectsByRepository } from "../../lib/repositoryGroups";15import { groupProjectsByRepository } from "../../lib/repositoryGroups";
16import type { ScopedMobileProject } from "../../lib/scopedEntities";16import type { ScopedMobileProject } from "../../lib/scopedEntities";
17import { scopedProjectKey } from "../../lib/scopedEntities";17import { scopedProjectKey } from "../../lib/scopedEntities";
18import { useProjectActions } from "../../state/use-project-actions";18import { useRemoteEnvironmentStore } from "../../state/remote-environment-store";
19import { gitBranchManager, useGitBranches } from "../../state/use-git-branches";
19import { useRemoteCatalog } from "../../state/use-remote-catalog";20import { useRemoteCatalog } from "../../state/use-remote-catalog";
20 21
21export type { ModelOption, ProviderGroup };22export type { ModelOption, ProviderGroup };
@@ -110,7 +111,9 @@ const NewTaskFlowContext = React.createContext<NewTaskFlowContextValue | null>(n · 88 unmodified lines
110 111
111export function NewTaskFlowProvider(props: React.PropsWithChildren) {112export function NewTaskFlowProvider(props: React.PropsWithChildren) {
112 const { projects, serverConfigByEnvironmentId, threads } = useRemoteCatalog();113 const { projects, serverConfigByEnvironmentId, threads } = useRemoteCatalog();
113 const { onListProjectBranches } = useProjectActions();114 const setPendingConnectionError = useRemoteEnvironmentStore(
115 (state) => state.setPendingConnectionError,
116 );
114 117
115 const repositoryGroups = useMemo(118 const repositoryGroups = useMemo(
116 () => groupProjectsByRepository({ projects, threads }),119 () => groupProjectsByRepository({ projects, threads }),
@@ -142,8 +145,6 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) { · 25 unmodified lines
142 const [attachments, setAttachments] = useState<ReadonlyArray<DraftComposerImageAttachment>>([]);145 const [attachments, setAttachments] = useState<ReadonlyArray<DraftComposerImageAttachment>>([]);
143 const [submitting, setSubmitting] = useState(false);146 const [submitting, setSubmitting] = useState(false);
144 const [branchQuery, setBranchQuery] = useState("");147 const [branchQuery, setBranchQuery] = useState("");
145 const [branchesLoading, setBranchesLoading] = useState(false);
146 const [availableBranches, setAvailableBranches] = useState<ReadonlyArray<GitBranch>>([]);
147 const [runtimeMode, setRuntimeMode] = useState<RuntimeMode>(DEFAULT_RUNTIME_MODE);148 const [runtimeMode, setRuntimeMode] = useState<RuntimeMode>(DEFAULT_RUNTIME_MODE);
148 const [interactionMode, setInteractionMode] = useState<ProviderInteractionMode>(149 const [interactionMode, setInteractionMode] = useState<ProviderInteractionMode>(
149 DEFAULT_PROVIDER_INTERACTION_MODE,150 DEFAULT_PROVIDER_INTERACTION_MODE,
@@ -190,8 +191,6 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) { · 40 unmodified lines
190 clearAttachments();191 clearAttachments();
191 setSubmitting(false);192 setSubmitting(false);
192 setBranchQuery("");193 setBranchQuery("");
193 setBranchesLoading(false);
194 setAvailableBranches([]);
195 setRuntimeMode(DEFAULT_RUNTIME_MODE);194 setRuntimeMode(DEFAULT_RUNTIME_MODE);
196 setInteractionMode(DEFAULT_PROVIDER_INTERACTION_MODE);195 setInteractionMode(DEFAULT_PROVIDER_INTERACTION_MODE);
197 setEffort("high");196 setEffort("high");
@@ -262,6 +261,20 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) { · 64 unmodified lines
262 ) ?? null;261 ) ?? null;
263 262
264 const providerGroups = useMemo(() => groupByProvider(modelOptions), [modelOptions]);263 const providerGroups = useMemo(() => groupByProvider(modelOptions), [modelOptions]);
264 const branchTarget = useMemo(
265 () => ({
266 environmentId: selectedProject?.environmentId ?? null,
267 cwd: selectedProject?.workspaceRoot ?? null,
268 query: null,
269 }),
270 [selectedProject?.environmentId, selectedProject?.workspaceRoot],
271 );
272 const branchState = useGitBranches(branchTarget);
273 const branchesLoading = branchState.isPending;
274 const availableBranches = useMemo(
275 () => (branchState.data?.branches ?? []).filter((branch) => !branch.isRemote),
276 [branchState.data?.branches],
277 );
265 278
266 const filteredBranches = useMemo(() => {279 const filteredBranches = useMemo(() => {
267 const query = branchQuery.trim().toLowerCase();280 const query = branchQuery.trim().toLowerCase();
@@ -299,10 +312,14 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) { · 31 unmodified lines
299 return;312 return;
300 }313 }
301 314
302 setBranchesLoading(true);
303 try {315 try {
304 const branches = await onListProjectBranches(selectedProject);316 const result = await gitBranchManager.load({
305 setAvailableBranches(branches);317 environmentId: selectedProject.environmentId,
318 cwd: selectedProject.workspaceRoot,
319 query: null,
320 });
321 setPendingConnectionError(null);
322 const branches = (result?.branches ?? []).filter((branch) => !branch.isRemote);
306 323
307 if (workspaceMode === "worktree" && !selectedBranchName) {324 if (workspaceMode === "worktree" && !selectedBranchName) {
308 const preferredBranch =325 const preferredBranch =
@@ -313,10 +330,10 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) { · 4 unmodified lines
313 setSelectedBranchName(preferredBranch);330 setSelectedBranchName(preferredBranch);
314 }331 }
315 }332 }
316 } finally {333 } catch {
317 setBranchesLoading(false);334 setPendingConnectionError("Failed to load branches.");
318 }335 }
319 }, [onListProjectBranches, selectedBranchName, selectedProject, workspaceMode]);336 }, [selectedBranchName, selectedProject, setPendingConnectionError, workspaceMode]);
320 337
321 const value = useMemo<NewTaskFlowContextValue>(338 const value = useMemo<NewTaskFlowContextValue>(
322 () => ({339 () => ({
apps/mobile/src/lib/arrayCompat.ts+0−42
@@ -1,42 +0,0 @@
1export function sortCopy<T>(
2 values: ReadonlyArray<T>,
3 compareFn: (left: T, right: T) => number,
4): T[] {
5 const next = [...values];
6
7 for (let index = 1; index < next.length; index += 1) {
8 const current = next[index];
9 if (current === undefined) {
10 continue;
11 }
12
13 let insertionIndex = index - 1;
14 while (insertionIndex >= 0) {
15 const candidate = next[insertionIndex];
16 if (candidate !== undefined && compareFn(candidate, current) <= 0) {
17 break;
18 }
19
20 next[insertionIndex + 1] = candidate;
21 insertionIndex -= 1;
22 }
23
24 next[insertionIndex + 1] = current;
25 }
26
27 return next;
28}
29
30export function reverseCopy<T>(values: ReadonlyArray<T>): T[] {
31 const reversed = Array.from({ length: values.length }) as T[];
32
33 for (let index = 0; index < values.length; index += 1) {
34 const value = values[index];
35 if (value === undefined) {
36 continue;
37 }
38 reversed[values.length - 1 - index] = value;
39 }
40
41 return reversed;
42}
apps/mobile/src/lib/classNames.ts+0−3
@@ -1,3 +0,0 @@
1export function cx(...parts: Array<string | false | null | undefined>): string {
2 return parts.filter(Boolean).join(" ");
3}
apps/mobile/src/lib/clientId.ts+0−6
@@ -1,6 +0,0 @@
1import * as Effect from "effect/Effect";
2import * as Random from "effect/Random";
3
4export function newClientId(prefix: string): string {
5 return `${prefix}-${Effect.runSync(Random.nextUUIDv4)}`;
6}
apps/mobile/src/lib/cn.ts+6−0
@@ -0,0 +1,6 @@
1import { type ClassValue, clsx } from "clsx";
2import { twMerge } from "tailwind-merge";
3
4export function cn(...inputs: ClassValue[]) {
5 return twMerge(clsx(inputs));
6}
apps/mobile/src/lib/composerImages.ts+4−5
@@ -3,8 +3,7 @@ import { · 2 unmodified lines
3 PROVIDER_SEND_TURN_MAX_IMAGE_BYTES,3 PROVIDER_SEND_TURN_MAX_IMAGE_BYTES,
4 type UploadChatImageAttachment,4 type UploadChatImageAttachment,
5} from "@t3tools/contracts";5} from "@t3tools/contracts";
6 6import { uuidv4 } from "./uuid";
7import { newClientId } from "./clientId";
8 7
9export interface DraftComposerImageAttachment extends UploadChatImageAttachment {8export interface DraftComposerImageAttachment extends UploadChatImageAttachment {
10 readonly id: string;9 readonly id: string;
@@ -101,7 +100,7 @@ export async function pickComposerImages(input: { readonly existingCount: number · 90 unmodified lines
101 }100 }
102 101
103 nextImages.push({102 nextImages.push({
104 id: newClientId("attachment"),103 id: uuidv4(),
105 type: "image",104 type: "image",
106 name: asset.fileName ?? "image",105 name: asset.fileName ?? "image",
107 mimeType,106 mimeType,
@@ -165,7 +164,7 @@ export async function pasteComposerClipboard(input: { readonly existingCount: nu · 57 unmodified lines
165 return {164 return {
166 images: [165 images: [
167 {166 {
168 id: newClientId("attachment"),167 id: uuidv4(),
169 type: "image",168 type: "image",
170 name: "pasted-image.png",169 name: "pasted-image.png",
171 mimeType: "image/png",170 mimeType: "image/png",
@@ -233,7 +232,7 @@ export async function convertPastedImagesToAttachments(input: { · 61 unmodified lines
233 }232 }
234 const mimeType = mimeTypeFromUri(uri);233 const mimeType = mimeTypeFromUri(uri);
235 results.push({234 results.push({
236 id: newClientId("attachment"),235 id: uuidv4(),
237 type: "image",236 type: "image",
238 name: `pasted-image.${mimeType.split("/")[1] ?? "png"}`,237 name: `pasted-image.${mimeType.split("/")[1] ?? "png"}`,
239 mimeType,238 mimeType,
apps/mobile/src/lib/mobileLayout.ts+37−0
@@ -0,0 +1,37 @@
1function clamp(value: number, min: number, max: number): number {
2 return Math.min(Math.max(value, min), max);
3}
4
5export type MobileLayoutVariant = "compact" | "split";
6
7export interface MobileLayout {
8 readonly variant: MobileLayoutVariant;
9 readonly usesSplitView: boolean;
10 readonly listPaneWidth: number | null;
11 readonly shellPadding: number;
12}
13
14export function deriveMobileLayout(input: {
15 readonly width: number;
16 readonly height: number;
17}): MobileLayout {
18 const { width, height } = input;
19 const shortestEdge = Math.min(width, height);
20 const wideEnoughForSplit = width >= 900 || (width >= 700 && shortestEdge >= 700);
21
22 if (!wideEnoughForSplit) {
23 return {
24 variant: "compact",
25 usesSplitView: false,
26 listPaneWidth: null,
27 shellPadding: 0,
28 };
29 }
30
31 return {
32 variant: "split",
33 usesSplitView: true,
34 listPaneWidth: clamp(Math.round(width * 0.34), 320, 420),
35 shellPadding: width >= 1180 ? 20 : 14,
36 };
37}
apps/mobile/src/lib/storage.ts+11−74
@@ -2,9 +2,7 @@ import * as SecureStore from "expo-secure-store"; · 1 unmodified line
2 2
3import type { SavedRemoteConnection } from "./connection";3import type { SavedRemoteConnection } from "./connection";
4 4
5const CONNECTIONS_KEY = "t3code:connections";5const CONNECTIONS_KEY = "t3code.connections";
6
7type SavedRemoteConnectionMetadata = Omit<SavedRemoteConnection, "bearerToken">;
8 6
9async function readStorageItem(key: string): Promise<string | null> {7async function readStorageItem(key: string): Promise<string | null> {
10 return await SecureStore.getItemAsync(key);8 return await SecureStore.getItemAsync(key);
@@ -14,31 +12,7 @@ async function writeStorageItem(key: string, value: string): Promise<void> { · 3 unmodified lines
14 await SecureStore.setItemAsync(key, value);12 await SecureStore.setItemAsync(key, value);
15}13}
16 14
17async function removeStorageItem(key: string): Promise<void> {15export async function loadSavedConnections(): Promise<ReadonlyArray<SavedRemoteConnection>> {
18 await SecureStore.deleteItemAsync(key);
19}
20
21function connectionTokenKey(environmentId: string): string {
22 return `t3code:bearer-token:${environmentId}`;
23}
24
25async function loadSecureToken(environmentId: string): Promise<string> {
26 return (await readStorageItem(connectionTokenKey(environmentId))) ?? "";
27}
28
29async function storeSecureToken(environmentId: string, token: string): Promise<void> {
30 const tokenKey = connectionTokenKey(environmentId);
31 if (token.trim().length === 0) {
32 await removeStorageItem(tokenKey);
33 return;
34 }
35
36 await writeStorageItem(tokenKey, token);
37}
38
39async function loadSavedConnectionMetadata(): Promise<
40 ReadonlyArray<SavedRemoteConnectionMetadata>
41> {
42 const raw = (await readStorageItem(CONNECTIONS_KEY)) ?? "";16 const raw = (await readStorageItem(CONNECTIONS_KEY)) ?? "";
43 if (!raw.trim()) {17 if (!raw.trim()) {
44 return [];18 return [];
@@ -46,64 +20,27 @@ async function loadSavedConnectionMetadata(): Promise< · 1 unmodified line
46 20
47 try {21 try {
48 const parsed = JSON.parse(raw) as {22 const parsed = JSON.parse(raw) as {
49 readonly connections?: ReadonlyArray<SavedRemoteConnectionMetadata>;23 readonly connections?: ReadonlyArray<SavedRemoteConnection>;
50 };24 };
51 return parsed.connections ?? [];25 return (parsed.connections ?? []).filter((c) => c.environmentId && c.bearerToken?.trim());
52 } catch {26 } catch {
53 return [];27 return [];
54 }28 }
55}29}
56 30
57async function saveSavedConnectionMetadata(
58 connections: ReadonlyArray<SavedRemoteConnectionMetadata>,
59): Promise<void> {
60 await writeStorageItem(CONNECTIONS_KEY, JSON.stringify({ connections }));
61}
62
63export async function loadSavedConnections(): Promise<ReadonlyArray<SavedRemoteConnection>> {
64 const metadata = await loadSavedConnectionMetadata();
65 const resolved = await Promise.all(
66 metadata.map(async (connection): Promise<SavedRemoteConnection | null> => {
67 const bearerToken = (await loadSecureToken(connection.environmentId)).trim();
68 if (!bearerToken) {
69 return null;
70 }
71
72 return Object.assign({}, connection, {
73 bearerToken,
74 });
75 }),
76 );
77
78 return resolved.filter((connection): connection is SavedRemoteConnection => connection !== null);
79}
80
81export async function saveConnection(connection: SavedRemoteConnection): Promise<void> {31export async function saveConnection(connection: SavedRemoteConnection): Promise<void> {
82 const current = await loadSavedConnectionMetadata();32 const current = await loadSavedConnections();
83 const nextConnection: SavedRemoteConnectionMetadata = {
84 environmentId: connection.environmentId,
85 environmentLabel: connection.environmentLabel,
86 pairingUrl: connection.pairingUrl.trim(),
87 displayUrl: connection.displayUrl.trim(),
88 httpBaseUrl: connection.httpBaseUrl.trim(),
89 wsBaseUrl: connection.wsBaseUrl.trim(),
90 };
91 const next = current.some((entry) => entry.environmentId === connection.environmentId)33 const next = current.some((entry) => entry.environmentId === connection.environmentId)
92 ? current.map((entry) =>34 ? current.map((entry) =>
93 entry.environmentId === connection.environmentId ? nextConnection : entry,35 entry.environmentId === connection.environmentId ? connection : entry,
94 )36 )
95 : [...current, nextConnection];37 : [...current, connection];
96 38
97 await Promise.all([39 await writeStorageItem(CONNECTIONS_KEY, JSON.stringify({ connections: next }));
98 saveSavedConnectionMetadata(next),
99 storeSecureToken(connection.environmentId, connection.bearerToken.trim()),
100 ]);
101}40}
102 41
103export async function clearSavedConnection(environmentId: string): Promise<void> {42export async function clearSavedConnection(environmentId: string): Promise<void> {
104 const current = await loadSavedConnectionMetadata();43 const current = await loadSavedConnections();
105 await Promise.all([44 const next = current.filter((entry) => entry.environmentId !== environmentId);
106 saveSavedConnectionMetadata(current.filter((entry) => entry.environmentId !== environmentId)),45 await writeStorageItem(CONNECTIONS_KEY, JSON.stringify({ connections: next }));
107 storeSecureToken(environmentId, ""),
108 ]);
109}46}
apps/mobile/src/lib/theme.ts+0−187
@@ -1,187 +0,0 @@
1import { useColorScheme } from "react-native";
2
3/**
4 * Centralized app color palette that adapts to light/dark system appearance.
5 * Modeled after the `makeConnectionSheetPalette` pattern used in connection sheets.
6 *
7 * Usage:
8 * const isDarkMode = useColorScheme() === "dark";
9 * const palette = makeAppPalette(isDarkMode);
10 */
11export function makeAppPalette(isDarkMode: boolean) {
12 if (isDarkMode) {
13 return {
14 // Page backgrounds — matches web's neutral-950 dark palette
15 screenBackground: "#0a0a0a",
16 sheetBackground: "rgba(14,14,14,0.98)",
17
18 // Card / surface
19 card: "#171717",
20 cardAlt: "#1c1c1c",
21 cardTranslucent: "rgba(17,17,17,0.94)",
22
23 // Text — neutral scale (matches web's neutral-100 / neutral-500)
24 text: "#f5f5f5",
25 textSecondary: "#a3a3a3",
26 textMuted: "#737373",
27 textTertiary: "#525252",
28
29 // Borders & separators — matches web's --border (white/6%) / --input (white/8%)
30 border: "rgba(255,255,255,0.06)",
31 borderSubtle: "rgba(255,255,255,0.04)",
32 separator: "rgba(255,255,255,0.03)",
33
34 // Subtle backgrounds (badges, pills, overlays)
35 subtleBg: "rgba(255,255,255,0.04)",
36 subtleBgStrong: "rgba(255,255,255,0.08)",
37
38 // Primary action (inverted in dark mode)
39 primaryButton: "#f5f5f5",
40 primaryButtonText: "#0a0a0a",
41 primaryButtonShadow: "rgba(0,0,0,0.22)",
42
43 // Secondary action
44 secondaryButton: "rgba(255,255,255,0.04)",
45 secondaryButtonText: "#f5f5f5",
46 secondaryButtonBorder: "rgba(255,255,255,0.06)",
47
48 // Danger
49 dangerButton: "rgba(239,68,68,0.14)",
50 dangerBorder: "rgba(248,113,113,0.18)",
51 dangerText: "#fca5a5",
52
53 // Inputs
54 inputBackground: "#141414",
55 inputBorder: "rgba(255,255,255,0.08)",
56 placeholder: "#737373",
57
58 // Icons
59 icon: "#f5f5f5",
60 iconMuted: "#a3a3a3",
61 iconSubtle: "#737373",
62
63 // Header / glass chrome — fully opaque to prevent content bleed-through
64 headerBackground: "rgba(10,10,10,0.97)",
65 headerBorder: "rgba(255,255,255,0.06)",
66
67 // StatusBar
68 statusBarBackground: "#0a0a0a",
69 statusBarStyle: "light-content" as const,
70
71 // Markdown
72 markdownBody: "#e5e5e5",
73 markdownStrong: "#f5f5f5",
74 markdownLink: "#60a5fa",
75 markdownBlockquoteBorder: "rgba(255,255,255,0.08)",
76 markdownCodeBg: "rgba(255,255,255,0.06)",
77 markdownCodeText: "#e5e5e5",
78 markdownUserCodeBg: "rgba(255,255,255,0.08)",
79 markdownUserCodeText: "#e5e5e5",
80
81 // Drawer / modal backdrop
82 backdrop: "rgba(0,0,0,0.48)",
83 drawerBackground: "rgba(14,14,14,0.99)",
84 drawerShadow: "rgba(0,0,0,0.32)",
85
86 // Dot separator
87 dotSeparator: "rgba(255,255,255,0.20)",
88
89 // SVG / wordmark fill
90 wordmarkFill: "#f5f5f5",
91
92 // Chevron / disclosure
93 chevronColor: "rgba(255,255,255,0.20)",
94 } as const;
95 }
96
97 return {
98 // Page backgrounds — matches web's white/neutral palette
99 screenBackground: "#ffffff",
100 sheetBackground: "rgba(255,255,255,0.98)",
101
102 // Card / surface
103 card: "#ffffff",
104 cardAlt: "#f5f5f5",
105 cardTranslucent: "rgba(255,255,255,0.94)",
106
107 // Text — neutral scale (matches web's neutral-800 / neutral-500)
108 text: "#262626",
109 textSecondary: "#525252",
110 textMuted: "#737373",
111 textTertiary: "#a3a3a3",
112
113 // Borders & separators — matches web's --border / --input
114 border: "rgba(0,0,0,0.08)",
115 borderSubtle: "rgba(0,0,0,0.06)",
116 separator: "rgba(0,0,0,0.04)",
117
118 // Subtle backgrounds (badges, pills, overlays)
119 subtleBg: "rgba(0,0,0,0.04)",
120 subtleBgStrong: "rgba(0,0,0,0.08)",
121
122 // Primary action
123 primaryButton: "#262626",
124 primaryButtonText: "#ffffff",
125 primaryButtonShadow: "rgba(0,0,0,0.18)",
126
127 // Secondary action
128 secondaryButton: "#ffffff",
129 secondaryButtonText: "#262626",
130 secondaryButtonBorder: "rgba(0,0,0,0.08)",
131
132 // Danger
133 dangerButton: "#fef2f2",
134 dangerBorder: "rgba(239,68,68,0.12)",
135 dangerText: "#dc2626",
136
137 // Inputs
138 inputBackground: "#ffffff",
139 inputBorder: "rgba(0,0,0,0.10)",
140 placeholder: "#a3a3a3",
141
142 // Icons
143 icon: "#262626",
144 iconMuted: "#525252",
145 iconSubtle: "#a3a3a3",
146
147 // Header / glass chrome — fully opaque to prevent content bleed-through
148 headerBackground: "rgba(255,255,255,0.97)",
149 headerBorder: "rgba(0,0,0,0.06)",
150
151 // StatusBar
152 statusBarBackground: "#ffffff",
153 statusBarStyle: "dark-content" as const,
154
155 // Markdown
156 markdownBody: "#262626",
157 markdownStrong: "#171717",
158 markdownLink: "#2563eb",
159 markdownBlockquoteBorder: "rgba(0,0,0,0.08)",
160 markdownCodeBg: "rgba(0,0,0,0.04)",
161 markdownCodeText: "#262626",
162 markdownUserCodeBg: "rgba(255,255,255,0.55)",
163 markdownUserCodeText: "#262626",
164
165 // Drawer / modal backdrop
166 backdrop: "rgba(0,0,0,0.22)",
167 drawerBackground: "rgba(255,255,255,0.99)",
168 drawerShadow: "rgba(0,0,0,0.12)",
169
170 // Dot separator
171 dotSeparator: "rgba(0,0,0,0.20)",
172
173 // SVG / wordmark fill
174 wordmarkFill: "#262626",
175
176 // Chevron / disclosure
177 chevronColor: "rgba(0,0,0,0.20)",
178 } as const;
179}
180
181export type AppPalette = ReturnType<typeof makeAppPalette>;
182
183/** Convenience hook that returns the palette for the current color scheme. */
184export function useAppPalette(): AppPalette {
185 const isDarkMode = useColorScheme() === "dark";
186 return makeAppPalette(isDarkMode);
187}
apps/mobile/src/lib/threadActivity.ts+7−6
@@ -1,5 +1,7 @@
1import type {1import type {
2 ApprovalRequestId,2 ApprovalRequestId,
3 CommandId,
4 MessageId,
3 OrchestrationThread,5 OrchestrationThread,
4 OrchestrationThreadActivity,6 OrchestrationThreadActivity,
5 ThreadId,7 ThreadId,
@@ -29,11 +31,10 @@ export interface PendingUserInputDraftAnswer { · 23 unmodified lines
29}31}
30 32
31export interface QueuedThreadMessage {33export interface QueuedThreadMessage {
32 readonly id: string;
33 readonly environmentId: string;34 readonly environmentId: string;
34 readonly threadId: ThreadId;35 readonly threadId: ThreadId;
35 readonly messageId: string;36 readonly messageId: MessageId;
36 readonly commandId: string;37 readonly commandId: CommandId;
37 readonly text: string;38 readonly text: string;
38 readonly attachments: ReadonlyArray<DraftComposerImageAttachment>;39 readonly attachments: ReadonlyArray<DraftComposerImageAttachment>;
39 readonly createdAt: string;40 readonly createdAt: string;
@@ -380,7 +381,7 @@ export function buildPendingUserInputAnswers( · 340 unmodified lines
380export function buildThreadFeed(381export function buildThreadFeed(
381 thread: OrchestrationThread,382 thread: OrchestrationThread,
382 queuedMessages: ReadonlyArray<QueuedThreadMessage>,383 queuedMessages: ReadonlyArray<QueuedThreadMessage>,
383 dispatchingQueuedMessageId: string | null,384 dispatchingQueuedMessageId: MessageId | null,
384 options?: {385 options?: {
385 readonly loadedMessages?: ReadonlyArray<OrchestrationThread["messages"][number]>;386 readonly loadedMessages?: ReadonlyArray<OrchestrationThread["messages"][number]>;
386 },387 },
@@ -398,10 +399,10 @@ export function buildThreadFeed( · 11 unmodified lines
398 })),399 })),
399 ...queuedMessages.map<RawThreadFeedEntry>((queuedMessage) => ({400 ...queuedMessages.map<RawThreadFeedEntry>((queuedMessage) => ({
400 type: "queued-message",401 type: "queued-message",
401 id: queuedMessage.id,402 id: queuedMessage.messageId,
402 createdAt: queuedMessage.createdAt,403 createdAt: queuedMessage.createdAt,
403 queuedMessage,404 queuedMessage,
404 sending: queuedMessage.id === dispatchingQueuedMessageId,405 sending: queuedMessage.messageId === dispatchingQueuedMessageId,
405 })),406 })),
406 ...thread.activities407 ...thread.activities
407 .filter((activity) => {408 .filter((activity) => {
apps/mobile/src/lib/useThemeColor.ts+12−0
@@ -0,0 +1,12 @@
1import type { ColorValue } from "react-native";
2import { useCSSVariable } from "uniwind";
3
4/**
5 * Typed wrapper around `useCSSVariable` that returns a `ColorValue` for use
6 * in React Native style props (backgroundColor, tintColor, etc.).
7 *
8 * Usage: `const color = useThemeColor("--color-icon");`
9 */
10export function useThemeColor(variable: `--color-${string}`): ColorValue {
11 return useCSSVariable(variable) as string as ColorValue;
12}
apps/mobile/src/lib/uuid.ts+4−0
@@ -0,0 +1,4 @@
1import * as Effect from "effect/Effect";
2import * as Random from "effect/Random";
3
4export const uuidv4 = () => Effect.runSync(Random.nextUUIDv4);
apps/mobile/src/state/atomRegistry.tsx+9−0
@@ -0,0 +1,9 @@
1import { RegistryContext } from "@effect/atom-react";
2import { AtomRegistry } from "effect/unstable/reactivity";
3import type { ReactNode } from "react";
4
5export const appAtomRegistry = AtomRegistry.make();
6
7export function AppAtomRegistryProvider({ children }: { readonly children: ReactNode }) {
8 return <RegistryContext.Provider value={appAtomRegistry}>{children}</RegistryContext.Provider>;
9}
apps/mobile/src/state/thread-composer-store.ts+6−6
@@ -1,4 +1,4 @@
1import type { ThreadId } from "@t3tools/contracts";1import type { MessageId, ThreadId } from "@t3tools/contracts";
2import { useStore } from "zustand";2import { useStore } from "zustand";
3import { createStore } from "zustand/vanilla";3import { createStore } from "zustand/vanilla";
4 4
@@ -10,7 +10,7 @@ interface ThreadComposerStoreState { · 5 unmodified lines
10 readonly nowTick: number;10 readonly nowTick: number;
11 readonly draftMessageByThreadKey: Record<string, string>;11 readonly draftMessageByThreadKey: Record<string, string>;
12 readonly draftAttachmentsByThreadKey: Record<string, ReadonlyArray<DraftComposerImageAttachment>>;12 readonly draftAttachmentsByThreadKey: Record<string, ReadonlyArray<DraftComposerImageAttachment>>;
13 readonly dispatchingQueuedMessageId: string | null;13 readonly dispatchingQueuedMessageId: MessageId | null;
14 readonly queuedMessagesByThreadKey: Record<string, ReadonlyArray<QueuedThreadMessage>>;14 readonly queuedMessagesByThreadKey: Record<string, ReadonlyArray<QueuedThreadMessage>>;
15 15
16 readonly setNowTick: (tick: number) => void;16 readonly setNowTick: (tick: number) => void;
@@ -22,13 +22,13 @@ interface ThreadComposerStoreState { · 5 unmodified lines
22 readonly appendDraftMessage: (threadKey: string, value: string) => void;22 readonly appendDraftMessage: (threadKey: string, value: string) => void;
23 readonly clearDraft: (threadKey: string) => void;23 readonly clearDraft: (threadKey: string) => void;
24 readonly removeDraftImage: (threadKey: string, imageId: string) => void;24 readonly removeDraftImage: (threadKey: string, imageId: string) => void;
25 readonly beginDispatchingQueuedMessage: (queuedMessageId: string) => void;25 readonly beginDispatchingQueuedMessage: (queuedMessageId: MessageId) => void;
26 readonly finishDispatchingQueuedMessage: (queuedMessageId: string) => void;26 readonly finishDispatchingQueuedMessage: (queuedMessageId: MessageId) => void;
27 readonly enqueueQueuedMessage: (message: QueuedThreadMessage) => void;27 readonly enqueueQueuedMessage: (message: QueuedThreadMessage) => void;
28 readonly removeQueuedMessage: (28 readonly removeQueuedMessage: (
29 environmentId: string,29 environmentId: string,
30 threadId: ThreadId,30 threadId: ThreadId,
31 queuedMessageId: string,31 queuedMessageId: MessageId,
32 ) => void;32 ) => void;
33}33}
34 34
@@ -107,7 +107,7 @@ export const threadComposerStore = createStore<ThreadComposerStoreState>()((set) · 72 unmodified lines
107 if (!existing) {107 if (!existing) {
108 return state;108 return state;
109 }109 }
110 const nextQueue = existing.filter((entry) => entry.id !== queuedMessageId);110 const nextQueue = existing.filter((entry) => entry.messageId !== queuedMessageId);
111 const next = { ...state.queuedMessagesByThreadKey };111 const next = { ...state.queuedMessagesByThreadKey };
112 if (nextQueue.length === 0) {112 if (nextQueue.length === 0) {
113 delete next[threadKey];113 delete next[threadKey];
apps/mobile/src/state/thread-git-store.ts+0−45
@@ -1,45 +0,0 @@
1import type { GitStatusResult } from "@t3tools/contracts";
2import { useStore } from "zustand";
3import { createStore } from "zustand/vanilla";
4
5interface ThreadGitStoreState {
6 readonly gitStatusByThreadKey: Record<string, GitStatusResult | null>;
7 readonly gitOperationLabelByThreadKey: Record<string, string | null>;
8 readonly setThreadGitStatus: (threadKey: string, status: GitStatusResult | null) => void;
9 readonly setThreadGitOperationLabel: (threadKey: string, label: string | null) => void;
10 readonly clearThreadGitState: (threadKey: string) => void;
11}
12
13export const threadGitStore = createStore<ThreadGitStoreState>()((set) => ({
14 gitStatusByThreadKey: {},
15 gitOperationLabelByThreadKey: {},
16 setThreadGitStatus: (threadKey, status) =>
17 set((state) => ({
18 gitStatusByThreadKey: {
19 ...state.gitStatusByThreadKey,
20 [threadKey]: status,
21 },
22 })),
23 setThreadGitOperationLabel: (threadKey, label) =>
24 set((state) => ({
25 gitOperationLabelByThreadKey: {
26 ...state.gitOperationLabelByThreadKey,
27 [threadKey]: label,
28 },
29 })),
30 clearThreadGitState: (threadKey) =>
31 set((state) => {
32 const nextStatus = { ...state.gitStatusByThreadKey };
33 const nextLabel = { ...state.gitOperationLabelByThreadKey };
34 delete nextStatus[threadKey];
35 delete nextLabel[threadKey];
36 return {
37 gitStatusByThreadKey: nextStatus,
38 gitOperationLabelByThreadKey: nextLabel,
39 };
40 }),
41}));
42
43export function useThreadGitStore<T>(selector: (state: ThreadGitStoreState) => T): T {
44 return useStore(threadGitStore, selector);
45}
apps/mobile/src/state/use-git-action-state.ts+31−0
@@ -0,0 +1,31 @@
1import { useAtomValue } from "@effect/atom-react";
2import {
3 type GitActionState,
4 type GitActionTarget,
5 EMPTY_GIT_ACTION_ATOM,
6 EMPTY_GIT_ACTION_STATE,
7 createGitActionManager,
8 getGitActionTargetKey,
9 gitActionStateAtom,
10} from "@t3tools/client-runtime";
11
12import { uuidv4 } from "../lib/uuid";
13import { appAtomRegistry } from "./atomRegistry";
14import { getEnvironmentClient } from "./use-remote-environment-registry";
15
16export const gitActionManager = createGitActionManager({
17 getRegistry: () => appAtomRegistry,
18 getClient: (environmentId) => {
19 const client = getEnvironmentClient(environmentId);
20 return client ? client.git : null;
21 },
22 getActionId: uuidv4,
23});
24
25export function useGitActionState(target: GitActionTarget): GitActionState {
26 const targetKey = getGitActionTargetKey(target);
27 const state = useAtomValue(
28 targetKey !== null ? gitActionStateAtom(targetKey) : EMPTY_GIT_ACTION_ATOM,
29 );
30 return targetKey === null ? EMPTY_GIT_ACTION_STATE : state;
31}
apps/mobile/src/state/use-git-branches.ts+29−0
@@ -0,0 +1,29 @@
1import { useAtomValue } from "@effect/atom-react";
2import {
3 type GitBranchState,
4 type GitBranchTarget,
5 EMPTY_GIT_BRANCH_ATOM,
6 EMPTY_GIT_BRANCH_STATE,
7 createGitBranchManager,
8 getGitBranchTargetKey,
9 gitBranchStateAtom,
10} from "@t3tools/client-runtime";
11
12import { appAtomRegistry } from "./atomRegistry";
13import { getEnvironmentClient } from "./use-remote-environment-registry";
14
15export const gitBranchManager = createGitBranchManager({
16 getRegistry: () => appAtomRegistry,
17 getClient: (environmentId) => {
18 const client = getEnvironmentClient(environmentId);
19 return client ? client.git : null;
20 },
21});
22
23export function useGitBranches(target: GitBranchTarget): GitBranchState {
24 const targetKey = getGitBranchTargetKey(target);
25 const state = useAtomValue(
26 targetKey !== null ? gitBranchStateAtom(targetKey) : EMPTY_GIT_BRANCH_ATOM,
27 );
28 return targetKey === null ? EMPTY_GIT_BRANCH_STATE : state;
29}
apps/mobile/src/state/use-git-status.ts+62−0
@@ -0,0 +1,62 @@
1import { useAtomValue } from "@effect/atom-react";
2import {
3 type GitStatusState,
4 type GitStatusTarget,
5 EMPTY_GIT_STATUS_ATOM,
6 EMPTY_GIT_STATUS_STATE,
7 createGitStatusManager,
8 getGitStatusTargetKey,
9 gitStatusStateAtom,
10} from "@t3tools/client-runtime";
11import { useEffect } from "react";
12
13import { appAtomRegistry } from "./atomRegistry";
14import {
15 getEnvironmentClient,
16 subscribeEnvironmentConnections,
17} from "./use-remote-environment-registry";
18
19/**
20 * Singleton git status manager for the mobile app.
21 *
22 * Uses ref-counted `onStatus` subscriptions (one per unique cwd)
23 * rather than one-shot `refreshStatus` RPCs. Multiple threads
24 * sharing the same cwd (i.e. same project, no worktree) share
25 * a single WS subscription.
26 *
27 * `subscribeClientChanges` ensures subscriptions are established
28 * even when the WS connection isn't ready at mount time, and
29 * re-established on reconnection.
30 */
31export const gitStatusManager = createGitStatusManager({
32 getRegistry: () => appAtomRegistry,
33 getClient: (environmentId) => {
34 const client = getEnvironmentClient(environmentId);
35 return client ? client.git : null;
36 },
37 getClientIdentity: (environmentId) => {
38 return getEnvironmentClient(environmentId) ? environmentId : null;
39 },
40 subscribeClientChanges: subscribeEnvironmentConnections,
41});
42
43/**
44 * Subscribe to live git status for a target (environmentId + cwd).
45 *
46 * Mirrors the web's `useGitStatus` hook. Automatically subscribes
47 * on mount, ref-counts shared cwds, and unsubscribes on unmount.
48 * Returns reactive `GitStatusState` via Effect atoms.
49 */
50export function useGitStatus(target: GitStatusTarget): GitStatusState {
51 const targetKey = getGitStatusTargetKey(target);
52
53 useEffect(
54 () => gitStatusManager.watch({ environmentId: target.environmentId, cwd: target.cwd }),
55 [target.environmentId, target.cwd],
56 );
57
58 const state = useAtomValue(
59 targetKey !== null ? gitStatusStateAtom(targetKey) : EMPTY_GIT_STATUS_ATOM,
60 );
61 return targetKey === null ? EMPTY_GIT_STATUS_STATE : state;
62}
apps/mobile/src/state/use-project-actions.ts+19−12
@@ -14,11 +14,12 @@ import { · 13 unmodified lines
14import { sanitizeFeatureBranchName } from "@t3tools/shared/git";14import { sanitizeFeatureBranchName } from "@t3tools/shared/git";
15 15
16import type { DraftComposerImageAttachment } from "../lib/composerImages";16import type { DraftComposerImageAttachment } from "../lib/composerImages";
17import { newClientId } from "../lib/clientId";17import { uuidv4 } from "../lib/uuid";
18import type { ScopedMobileProject } from "../lib/scopedEntities";18import type { ScopedMobileProject } from "../lib/scopedEntities";
19import { buildTemporaryWorktreeBranchName } from "../lib/worktrees";19import { buildTemporaryWorktreeBranchName } from "../lib/worktrees";
20import { deriveThreadTitleFromPrompt } from "./remote-runtime-types";20import { deriveThreadTitleFromPrompt } from "./remote-runtime-types";
21import { useRemoteEnvironmentStore } from "./remote-environment-store";21import { useRemoteEnvironmentStore } from "./remote-environment-store";
22import { gitBranchManager } from "./use-git-branches";
22import { useRemoteCatalog } from "./use-remote-catalog";23import { useRemoteCatalog } from "./use-remote-catalog";
23import { getEnvironmentClient, useRemoteEnvironmentState } from "./use-remote-environment-registry";24import { getEnvironmentClient, useRemoteEnvironmentState } from "./use-remote-environment-registry";
24import { useThreadSelectionStore } from "./thread-selection-store";25import { useThreadSelectionStore } from "./thread-selection-store";
@@ -90,7 +91,7 @@ export function useProjectActions() { · 65 unmodified lines
90 return null;91 return null;
91 }92 }
92 93
93 const threadId = ThreadId.makeUnsafe(newClientId("thread"));94 const threadId = ThreadId.makeUnsafe(uuidv4());
94 const createdAt = new Date().toISOString();95 const createdAt = new Date().toISOString();
95 const initialMessageText = input.initialMessageText.trim();96 const initialMessageText = input.initialMessageText.trim();
96 const nextTitle = deriveThreadTitleFromPrompt(input.initialMessageText);97 const nextTitle = deriveThreadTitleFromPrompt(input.initialMessageText);
@@ -102,10 +103,10 @@ export function useProjectActions() { · 5 unmodified lines
102 103
103 await client.orchestration.dispatchCommand({104 await client.orchestration.dispatchCommand({
104 type: "thread.turn.start",105 type: "thread.turn.start",
105 commandId: CommandId.makeUnsafe(newClientId("command")),106 commandId: CommandId.makeUnsafe(uuidv4()),
106 threadId,107 threadId,
107 message: {108 message: {
108 messageId: MessageId.makeUnsafe(newClientId("message")),109 messageId: MessageId.makeUnsafe(uuidv4()),
109 role: "user",110 role: "user",
110 text: initialMessageText,111 text: initialMessageText,
111 attachments: input.initialAttachments,112 attachments: input.initialAttachments,
@@ -137,7 +138,7 @@ export function useProjectActions() { · 25 unmodified lines
137 } else {138 } else {
138 await client.orchestration.dispatchCommand({139 await client.orchestration.dispatchCommand({
139 type: "thread.create",140 type: "thread.create",
140 commandId: CommandId.makeUnsafe(newClientId("command")),141 commandId: CommandId.makeUnsafe(uuidv4()),
141 threadId,142 threadId,
142 projectId: input.project.id,143 projectId: input.project.id,
143 title: nextTitle,144 title: nextTitle,
@@ -152,10 +153,10 @@ export function useProjectActions() { · 8 unmodified lines
152 if (initialMessageText.length > 0 || input.initialAttachments.length > 0) {153 if (initialMessageText.length > 0 || input.initialAttachments.length > 0) {
153 await client.orchestration.dispatchCommand({154 await client.orchestration.dispatchCommand({
154 type: "thread.turn.start",155 type: "thread.turn.start",
155 commandId: CommandId.makeUnsafe(newClientId("command")),156 commandId: CommandId.makeUnsafe(uuidv4()),
156 threadId,157 threadId,
157 message: {158 message: {
158 messageId: MessageId.makeUnsafe(newClientId("message")),159 messageId: MessageId.makeUnsafe(uuidv4()),
159 role: "user",160 role: "user",
160 text: initialMessageText,161 text: initialMessageText,
161 attachments: input.initialAttachments,162 attachments: input.initialAttachments,
@@ -217,11 +218,12 @@ export function useProjectActions() { · 55 unmodified lines
217 }218 }
218 219
219 try {220 try {
220 const result = await client.git.listBranches({221 const result = await gitBranchManager.load(
221 cwd: project.workspaceRoot,222 { environmentId: project.environmentId, cwd: project.workspaceRoot, query: null },
222 limit: 100,223 client.git,
223 });224 { limit: 100 },
224 return result.branches.filter((branch) => !branch.isRemote);225 );
226 return (result?.branches ?? []).filter((branch) => !branch.isRemote);
225 } catch (error) {227 } catch (error) {
226 setPendingConnectionError(228 setPendingConnectionError(
227 error instanceof Error ? error.message : "Failed to load branches.",229 error instanceof Error ? error.message : "Failed to load branches.",
@@ -255,6 +257,11 @@ export function useProjectActions() { · 27 unmodified lines
255 newBranch: sanitizeFeatureBranchName(nextWorktree.newBranch),257 newBranch: sanitizeFeatureBranchName(nextWorktree.newBranch),
256 path: null,258 path: null,
257 });259 });
260 gitBranchManager.invalidate({
261 environmentId: project.environmentId,
262 cwd: project.workspaceRoot,
263 query: null,
264 });
258 return {265 return {
259 branch: result.worktree.branch,266 branch: result.worktree.branch,
260 worktreePath: result.worktree.path,267 worktreePath: result.worktree.path,
apps/mobile/src/state/use-remote-catalog.ts+19−11
@@ -1,8 +1,8 @@
1import { useMemo } from "react";1import { useMemo } from "react";
2 2
3import type { ServerConfig as T3ServerConfig } from "@t3tools/contracts";3import type { ServerConfig as T3ServerConfig } from "@t3tools/contracts";
4 4import * as Order from "effect/Order";
5import { sortCopy } from "../lib/arrayCompat";5import * as Arr from "effect/Array";
6import type { ScopedMobileProject, ScopedMobileThread } from "../lib/scopedEntities";6import type { ScopedMobileProject, ScopedMobileThread } from "../lib/scopedEntities";
7import {7import {
8 deriveOverallConnectionState,8 deriveOverallConnectionState,
@@ -13,13 +13,26 @@ import { · 4 unmodified lines
13 useRemoteEnvironmentState,13 useRemoteEnvironmentState,
14} from "./use-remote-environment-registry";14} from "./use-remote-environment-registry";
15 15
16const projectsSortOrder = Order.make<ScopedMobileProject>(
17 (left, right) =>
18 (left.title.localeCompare(right.title) as -1 | 0 | 1) ||
19 (left.environmentLabel.localeCompare(right.environmentLabel) as -1 | 0 | 1),
20);
21
22const threadsSortOrder = Order.make<ScopedMobileThread>(
23 (left, right) =>
24 ((new Date(right.updatedAt ?? right.createdAt).getTime() -
25 new Date(left.updatedAt ?? left.createdAt).getTime()) as -1 | 0 | 1) ||
26 (left.environmentLabel.localeCompare(right.environmentLabel) as -1 | 0 | 1),
27);
28
16export function useRemoteCatalog() {29export function useRemoteCatalog() {
17 const { connectedEnvironments, connectionState } = useRemoteConnectionStatus();30 const { connectedEnvironments, connectionState } = useRemoteConnectionStatus();
18 const { environmentStateById, savedConnectionsById } = useRemoteEnvironmentState();31 const { environmentStateById, savedConnectionsById } = useRemoteEnvironmentState();
19 32
20 const projects = useMemo<ReadonlyArray<ScopedMobileProject>>(33 const projects = useMemo<ReadonlyArray<ScopedMobileProject>>(
21 () =>34 () =>
22 sortCopy(35 Arr.sort(
23 Object.values(savedConnectionsById).flatMap((connection) =>36 Object.values(savedConnectionsById).flatMap((connection) =>
24 (environmentStateById[connection.environmentId]?.snapshot?.projects ?? [])37 (environmentStateById[connection.environmentId]?.snapshot?.projects ?? [])
25 .filter((project) => project.deletedAt === null)38 .filter((project) => project.deletedAt === null)
@@ -30,16 +43,14 @@ export function useRemoteCatalog() { · 4 unmodified lines
30 }),43 }),
31 ),44 ),
32 ),45 ),
33 (left, right) =>46 projectsSortOrder,
34 left.title.localeCompare(right.title) ||
35 left.environmentLabel.localeCompare(right.environmentLabel),
36 ),47 ),
37 [environmentStateById, savedConnectionsById],48 [environmentStateById, savedConnectionsById],
38 );49 );
39 50
40 const threads = useMemo<ReadonlyArray<ScopedMobileThread>>(51 const threads = useMemo<ReadonlyArray<ScopedMobileThread>>(
41 () =>52 () =>
42 sortCopy(53 Arr.sort(
43 Object.values(savedConnectionsById).flatMap((connection) =>54 Object.values(savedConnectionsById).flatMap((connection) =>
44 (environmentStateById[connection.environmentId]?.snapshot?.threads ?? [])55 (environmentStateById[connection.environmentId]?.snapshot?.threads ?? [])
45 .filter((thread) => thread.deletedAt === null)56 .filter((thread) => thread.deletedAt === null)
@@ -50,10 +61,7 @@ export function useRemoteCatalog() { · 4 unmodified lines
50 }),61 }),
51 ),62 ),
52 ),63 ),
53 (left, right) =>64 threadsSortOrder,
54 new Date(right.updatedAt ?? right.createdAt).getTime() -
55 new Date(left.updatedAt ?? left.createdAt).getTime() ||
56 right.environmentLabel.localeCompare(left.environmentLabel),
57 ),65 ),
58 [environmentStateById, savedConnectionsById],66 [environmentStateById, savedConnectionsById],
59 );67 );
apps/mobile/src/state/use-remote-environment-registry.ts+27−4
@@ -10,8 +10,8 @@ import { · 9 unmodified lines
10import { EnvironmentId, type OrchestrationReadModel } from "@t3tools/contracts";10import { EnvironmentId, type OrchestrationReadModel } from "@t3tools/contracts";
11import { resolveRemoteWebSocketConnectionUrl } from "@t3tools/shared/remote";11import { resolveRemoteWebSocketConnectionUrl } from "@t3tools/shared/remote";
12import { useShallow } from "zustand/react/shallow";12import { useShallow } from "zustand/react/shallow";
13 13import * as Arr from "effect/Array";
14import { sortCopy } from "../lib/arrayCompat";14import * as Order from "effect/Order";
15import { type SavedRemoteConnection, bootstrapRemoteConnection } from "../lib/connection";15import { type SavedRemoteConnection, bootstrapRemoteConnection } from "../lib/connection";
16import { applyRealtimeEvent, requiresSnapshotRefresh } from "../lib/orchestration";16import { applyRealtimeEvent, requiresSnapshotRefresh } from "../lib/orchestration";
17import { clearSavedConnection, loadSavedConnections, saveConnection } from "../lib/storage";17import { clearSavedConnection, loadSavedConnections, saveConnection } from "../lib/storage";
@@ -25,6 +25,22 @@ import { remoteEnvironmentStore, useRemoteEnvironmentStore } from "./remote-envi · 7 unmodified lines
25import { useThreadSelectionStore } from "./thread-selection-store";25import { useThreadSelectionStore } from "./thread-selection-store";
26 26
27const environmentSessions = new Map<string, EnvironmentSession>();27const environmentSessions = new Map<string, EnvironmentSession>();
28const environmentConnectionListeners = new Set<() => void>();
29
30function notifyEnvironmentConnectionListeners() {
31 for (const listener of environmentConnectionListeners) listener();
32}
33
34/**
35 * Subscribe to environment-connection changes (connect / disconnect / reconnect).
36 * Returns an unsubscribe function.
37 */
38export function subscribeEnvironmentConnections(listener: () => void): () => void {
39 environmentConnectionListeners.add(listener);
40 return () => {
41 environmentConnectionListeners.delete(listener);
42 };
43}
28 44
29function setEnvironmentConnectionStatus(45function setEnvironmentConnectionStatus(
30 environmentId: string,46 environmentId: string,
@@ -48,6 +64,7 @@ export async function disconnectEnvironment( · 17 unmodified lines
48) {64) {
49 const session = environmentSessions.get(environmentId);65 const session = environmentSessions.get(environmentId);
50 environmentSessions.delete(environmentId);66 environmentSessions.delete(environmentId);
67 notifyEnvironmentConnectionListeners();
51 await session?.connection.dispose();68 await session?.connection.dispose();
52 remoteEnvironmentStore.getState().removeEnvironmentRuntimeState(environmentId);69 remoteEnvironmentStore.getState().removeEnvironmentRuntimeState(environmentId);
53 70
@@ -176,6 +193,7 @@ export async function connectSavedEnvironment( · 122 unmodified lines
176 client,193 client,
177 connection: environmentConnection,194 connection: environmentConnection,
178 });195 });
196 notifyEnvironmentConnectionListeners();
179 197
180 try {198 try {
181 await environmentConnection.ensureBootstrapped();199 await environmentConnection.ensureBootstrapped();
@@ -188,11 +206,15 @@ export async function connectSavedEnvironment( · 6 unmodified lines
188 }206 }
189}207}
190 208
209const environmentsSortOrder = Order.make<ConnectedEnvironmentSummary>(
210 (left, right) => left.environmentLabel.localeCompare(right.environmentLabel) as -1 | 0 | 1,
211);
212
191function deriveConnectedEnvironments(213function deriveConnectedEnvironments(
192 savedConnectionsById: Record<string, SavedRemoteConnection>,214 savedConnectionsById: Record<string, SavedRemoteConnection>,
193 environmentStateById: ReturnType<typeof remoteEnvironmentStore.getState>["environmentStateById"],215 environmentStateById: ReturnType<typeof remoteEnvironmentStore.getState>["environmentStateById"],
194): ReadonlyArray<ConnectedEnvironmentSummary> {216): ReadonlyArray<ConnectedEnvironmentSummary> {
195 return sortCopy(217 return Arr.sort(
196 Object.values(savedConnectionsById).map((connection) => {218 Object.values(savedConnectionsById).map((connection) => {
197 const runtime = environmentStateById[connection.environmentId];219 const runtime = environmentStateById[connection.environmentId];
198 return {220 return {
@@ -203,7 +225,7 @@ function deriveConnectedEnvironments( · 4 unmodified lines
203 connectionError: runtime?.connectionError ?? null,225 connectionError: runtime?.connectionError ?? null,
204 };226 };
205 }),227 }),
206 (left, right) => left.environmentLabel.localeCompare(right.environmentLabel),228 environmentsSortOrder,
207 );229 );
208}230}
209 231
@@ -250,6 +272,7 @@ export function useRemoteEnvironmentBootstrap() { · 40 unmodified lines
250 void session.connection.dispose();272 void session.connection.dispose();
251 }273 }
252 environmentSessions.clear();274 environmentSessions.clear();
275 notifyEnvironmentConnectionListeners();
253 };276 };
254 }, [replaceSavedConnections, setIsLoadingSavedConnection]);277 }, [replaceSavedConnections, setIsLoadingSavedConnection]);
255}278}
apps/mobile/src/state/use-selected-thread-commands.ts+8−8
@@ -9,7 +9,7 @@ import { · 8 unmodified lines
9 type RuntimeMode,9 type RuntimeMode,
10} from "@t3tools/contracts";10} from "@t3tools/contracts";
11 11
12import { newClientId } from "../lib/clientId";12import { uuidv4 } from "../lib/uuid";
13import { useThreadSelection } from "./use-thread-selection";13import { useThreadSelection } from "./use-thread-selection";
14import { useRemoteEnvironmentStore } from "./remote-environment-store";14import { useRemoteEnvironmentStore } from "./remote-environment-store";
15import { getEnvironmentClient, useRemoteEnvironmentState } from "./use-remote-environment-registry";15import { getEnvironmentClient, useRemoteEnvironmentState } from "./use-remote-environment-registry";
@@ -102,7 +102,7 @@ export function useSelectedThreadCommands(input: { · 86 unmodified lines
102 102
103 await client.orchestration.dispatchCommand({103 await client.orchestration.dispatchCommand({
104 type: "thread.meta.update",104 type: "thread.meta.update",
105 commandId: CommandId.makeUnsafe(newClientId("command")),105 commandId: CommandId.makeUnsafe(uuidv4()),
106 threadId: selectedThread.id,106 threadId: selectedThread.id,
107 modelSelection,107 modelSelection,
108 });108 });
@@ -123,7 +123,7 @@ export function useSelectedThreadCommands(input: { · 14 unmodified lines
123 123
124 await client.orchestration.dispatchCommand({124 await client.orchestration.dispatchCommand({
125 type: "thread.runtime-mode.set",125 type: "thread.runtime-mode.set",
126 commandId: CommandId.makeUnsafe(newClientId("command")),126 commandId: CommandId.makeUnsafe(uuidv4()),
127 threadId: selectedThread.id,127 threadId: selectedThread.id,
128 runtimeMode,128 runtimeMode,
129 createdAt: new Date().toISOString(),129 createdAt: new Date().toISOString(),
@@ -145,7 +145,7 @@ export function useSelectedThreadCommands(input: { · 15 unmodified lines
145 145
146 await client.orchestration.dispatchCommand({146 await client.orchestration.dispatchCommand({
147 type: "thread.interaction-mode.set",147 type: "thread.interaction-mode.set",
148 commandId: CommandId.makeUnsafe(newClientId("command")),148 commandId: CommandId.makeUnsafe(uuidv4()),
149 threadId: selectedThread.id,149 threadId: selectedThread.id,
150 interactionMode,150 interactionMode,
151 createdAt: new Date().toISOString(),151 createdAt: new Date().toISOString(),
@@ -173,7 +173,7 @@ export function useSelectedThreadCommands(input: { · 21 unmodified lines
173 173
174 await client.orchestration.dispatchCommand({174 await client.orchestration.dispatchCommand({
175 type: "thread.turn.interrupt",175 type: "thread.turn.interrupt",
176 commandId: CommandId.makeUnsafe(newClientId("command")),176 commandId: CommandId.makeUnsafe(uuidv4()),
177 threadId: selectedThread.id,177 threadId: selectedThread.id,
178 ...(selectedThread.session?.activeTurnId178 ...(selectedThread.session?.activeTurnId
179 ? { turnId: selectedThread.session.activeTurnId }179 ? { turnId: selectedThread.session.activeTurnId }
@@ -200,7 +200,7 @@ export function useSelectedThreadCommands(input: { · 20 unmodified lines
200 200
201 await client.orchestration.dispatchCommand({201 await client.orchestration.dispatchCommand({
202 type: "thread.meta.update",202 type: "thread.meta.update",
203 commandId: CommandId.makeUnsafe(newClientId("command")),203 commandId: CommandId.makeUnsafe(uuidv4()),
204 threadId: selectedThread.id,204 threadId: selectedThread.id,
205 title: trimmed,205 title: trimmed,
206 });206 });
@@ -223,7 +223,7 @@ export function useSelectedThreadCommands(input: { · 16 unmodified lines
223 try {223 try {
224 await client.orchestration.dispatchCommand({224 await client.orchestration.dispatchCommand({
225 type: "thread.approval.respond",225 type: "thread.approval.respond",
226 commandId: CommandId.makeUnsafe(newClientId("command")),226 commandId: CommandId.makeUnsafe(uuidv4()),
227 threadId: selectedThread.id,227 threadId: selectedThread.id,
228 requestId,228 requestId,
229 decision,229 decision,
@@ -250,7 +250,7 @@ export function useSelectedThreadCommands(input: { · 20 unmodified lines
250 try {250 try {
251 await client.orchestration.dispatchCommand({251 await client.orchestration.dispatchCommand({
252 type: "thread.user-input.respond",252 type: "thread.user-input.respond",
253 commandId: CommandId.makeUnsafe(newClientId("command")),253 commandId: CommandId.makeUnsafe(uuidv4()),
254 threadId: selectedThread.id,254 threadId: selectedThread.id,
255 requestId: activePendingUserInput.requestId,255 requestId: activePendingUserInput.requestId,
256 answers: activePendingUserInputAnswers,256 answers: activePendingUserInputAnswers,
apps/mobile/src/state/use-selected-thread-git-actions.ts+321−0
@@ -0,0 +1,321 @@
1import { useCallback, useEffect } from "react";
2
3import { type GitActionRequestInput } from "@t3tools/client-runtime";
4import {
5 CommandId,
6 type GitBranch,
7 type GitRunStackedActionResult,
8 ThreadId,
9} from "@t3tools/contracts";
10import {
11 dedupeRemoteBranchesWithLocalMatches,
12 sanitizeFeatureBranchName,
13} from "@t3tools/shared/git";
14
15import { uuidv4 } from "../lib/uuid";
16import { type ScopedMobileProject, type ScopedMobileThread } from "../lib/scopedEntities";
17import { useRemoteEnvironmentStore } from "./remote-environment-store";
18import { getEnvironmentClient } from "./use-remote-environment-registry";
19import { gitActionManager } from "./use-git-action-state";
20import { gitBranchManager } from "./use-git-branches";
21import { gitStatusManager } from "./use-git-status";
22import { useThreadSelection } from "./use-thread-selection";
23
24export function useSelectedThreadGitActions() {
25 const { selectedThread, selectedThreadProject } = useThreadSelection();
26 const setPendingConnectionError = useRemoteEnvironmentStore(
27 (state) => state.setPendingConnectionError,
28 );
29
30 const selectedThreadGitRootCwd = selectedThreadProject?.workspaceRoot ?? null;
31
32 const updateThreadGitContext = useCallback(
33 async (
34 thread: NonNullable<typeof selectedThread>,
35 nextState: {
36 readonly branch?: string | null;
37 readonly worktreePath?: string | null;
38 },
39 ) => {
40 const client = getEnvironmentClient(thread.environmentId);
41 if (!client) {
42 return;
43 }
44
45 await client.orchestration.dispatchCommand({
46 type: "thread.meta.update",
47 commandId: CommandId.makeUnsafe(uuidv4()),
48 threadId: ThreadId.makeUnsafe(thread.id),
49 ...(nextState.branch !== undefined ? { branch: nextState.branch } : {}),
50 ...(nextState.worktreePath !== undefined ? { worktreePath: nextState.worktreePath } : {}),
51 });
52 },
53 [],
54 );
55
56 const refreshSelectedThreadGitStatus = useCallback(
57 async (options?: { readonly quiet?: boolean; readonly cwd?: string | null }) => {
58 if (!selectedThread || !selectedThreadProject) {
59 return null;
60 }
61
62 const cwd =
63 options?.cwd ?? selectedThread.worktreePath ?? selectedThreadProject.workspaceRoot;
64 if (!cwd) {
65 return null;
66 }
67
68 try {
69 const client = getEnvironmentClient(selectedThread.environmentId);
70 if (!client) {
71 return null;
72 }
73
74 const status = await gitActionManager.refreshStatus(
75 { environmentId: selectedThread.environmentId, cwd },
76 client.git,
77 options,
78 );
79 setPendingConnectionError(null);
80 return status;
81 } catch (error) {
82 const message = error instanceof Error ? error.message : "Failed to refresh git status.";
83 setPendingConnectionError(message);
84 return null;
85 }
86 },
87 [selectedThread, selectedThreadProject, setPendingConnectionError],
88 );
89
90 useEffect(() => {
91 if (!selectedThread || !selectedThreadProject) {
92 return;
93 }
94
95 void refreshSelectedThreadGitStatus({ quiet: true });
96 }, [refreshSelectedThreadGitStatus, selectedThread, selectedThreadProject]);
97
98 const runSelectedThreadGitMutation = useCallback(
99 async <T>(
100 operation: (input: {
101 readonly thread: ScopedMobileThread;
102 readonly project: ScopedMobileProject;
103 readonly cwd: string;
104 }) => Promise<T>,
105 ): Promise<T | null> => {
106 if (!selectedThread || !selectedThreadProject) {
107 return null;
108 }
109
110 const cwd = selectedThread.worktreePath ?? selectedThreadProject.workspaceRoot;
111 if (!cwd) {
112 return null;
113 }
114
115 try {
116 setPendingConnectionError(null);
117 return await operation({
118 thread: selectedThread,
119 project: selectedThreadProject,
120 cwd,
121 });
122 } catch (error) {
123 setPendingConnectionError(error instanceof Error ? error.message : "Git action failed.");
124 return null;
125 }
126 },
127 [selectedThread, selectedThreadProject, setPendingConnectionError],
128 );
129
130 const refreshSelectedThreadBranches = useCallback(async (): Promise<ReadonlyArray<GitBranch>> => {
131 if (!selectedThread || !selectedThreadProject || !selectedThreadGitRootCwd) {
132 return [];
133 }
134
135 const client = getEnvironmentClient(selectedThread.environmentId);
136 if (!client) {
137 return [];
138 }
139
140 try {
141 const result = await gitBranchManager.load(
142 { environmentId: selectedThread.environmentId, cwd: selectedThreadGitRootCwd, query: null },
143 client.git,
144 { limit: 100 },
145 );
146 return dedupeRemoteBranchesWithLocalMatches(result?.branches ?? []).filter(
147 (branch) => !branch.isRemote,
148 );
149 } catch (error) {
150 setPendingConnectionError(
151 error instanceof Error ? error.message : "Failed to load branches.",
152 );
153 return [];
154 }
155 }, [selectedThread, selectedThreadGitRootCwd, selectedThreadProject, setPendingConnectionError]);
156
157 const syncSelectedThreadBranchState = useCallback(
158 async (input: {
159 readonly thread: ScopedMobileThread;
160 readonly cwd: string;
161 readonly branchRootCwd?: string | null;
162 readonly nextThreadState?: {
163 readonly branch?: string | null;
164 readonly worktreePath?: string | null;
165 };
166 }) => {
167 if (input.nextThreadState) {
168 await updateThreadGitContext(input.thread, input.nextThreadState);
169 }
170
171 const branchRootCwd = input.branchRootCwd ?? selectedThreadProject?.workspaceRoot ?? null;
172 if (branchRootCwd) {
173 gitBranchManager.invalidate({
174 environmentId: input.thread.environmentId,
175 cwd: branchRootCwd,
176 query: null,
177 });
178 await refreshSelectedThreadBranches();
179 }
180
181 await refreshSelectedThreadGitStatus({ quiet: true, cwd: input.cwd });
182 },
183 [
184 refreshSelectedThreadBranches,
185 refreshSelectedThreadGitStatus,
186 selectedThreadProject?.workspaceRoot,
187 updateThreadGitContext,
188 ],
189 );
190
191 const onCheckoutSelectedThreadBranch = useCallback(
192 async (branch: string) => {
193 await runSelectedThreadGitMutation(async ({ thread, cwd }) => {
194 const result = await gitActionManager.checkout(
195 { environmentId: thread.environmentId, cwd },
196 { branch },
197 );
198 await syncSelectedThreadBranchState({
199 thread,
200 cwd,
201 nextThreadState: {
202 branch: result?.branch ?? thread.branch,
203 worktreePath: thread.worktreePath,
204 },
205 });
206 });
207 },
208 [runSelectedThreadGitMutation, syncSelectedThreadBranchState],
209 );
210
211 const onCreateSelectedThreadBranch = useCallback(
212 async (branch: string) => {
213 await runSelectedThreadGitMutation(async ({ thread, cwd }) => {
214 const result = await gitActionManager.createBranch(
215 { environmentId: thread.environmentId, cwd },
216 {
217 branch,
218 checkout: true,
219 },
220 );
221 await syncSelectedThreadBranchState({
222 thread,
223 cwd,
224 nextThreadState: {
225 branch: result?.branch ?? thread.branch,
226 worktreePath: thread.worktreePath,
227 },
228 });
229 });
230 },
231 [runSelectedThreadGitMutation, syncSelectedThreadBranchState],
232 );
233
234 const onCreateSelectedThreadWorktree = useCallback(
235 async (nextWorktree: { readonly baseBranch: string; readonly newBranch: string }) => {
236 await runSelectedThreadGitMutation(async ({ thread, project }) => {
237 const result = await gitActionManager.createWorktree(
238 { environmentId: thread.environmentId, cwd: project.workspaceRoot },
239 {
240 branch: nextWorktree.baseBranch,
241 newBranch: sanitizeFeatureBranchName(nextWorktree.newBranch),
242 path: null,
243 },
244 );
245 if (!result) {
246 return;
247 }
248
249 await syncSelectedThreadBranchState({
250 thread,
251 cwd: result.worktree.path,
252 branchRootCwd: project.workspaceRoot,
253 nextThreadState: {
254 branch: result.worktree.branch,
255 worktreePath: result.worktree.path,
256 },
257 });
258 });
259 },
260 [runSelectedThreadGitMutation, syncSelectedThreadBranchState],
261 );
262
263 const onPullSelectedThreadBranch = useCallback(async () => {
264 await runSelectedThreadGitMutation(async ({ thread, cwd }) => {
265 await gitActionManager.pull({ environmentId: thread.environmentId, cwd });
266 await refreshSelectedThreadGitStatus({ quiet: true, cwd });
267 });
268 }, [refreshSelectedThreadGitStatus, runSelectedThreadGitMutation]);
269
270 const onRunSelectedThreadGitAction = useCallback(
271 async (input: GitActionRequestInput): Promise<GitRunStackedActionResult | null> => {
272 return await runSelectedThreadGitMutation(async ({ thread, cwd }) => {
273 const result = await gitActionManager.runStackedAction(
274 { environmentId: thread.environmentId, cwd },
275 {
276 actionId: uuidv4(),
277 action: input.action,
278 ...(input.commitMessage ? { commitMessage: input.commitMessage } : {}),
279 ...(input.featureBranch ? { featureBranch: input.featureBranch } : {}),
280 ...(input.filePaths?.length ? { filePaths: [...input.filePaths] } : {}),
281 },
282 {
283 gitStatus: gitStatusManager.getSnapshot({
284 environmentId: thread.environmentId,
285 cwd,
286 }).data,
287 },
288 );
289 if (!result) {
290 return null;
291 }
292
293 if (result.branch.status === "created" && result.branch.name) {
294 await syncSelectedThreadBranchState({
295 thread,
296 cwd,
297 nextThreadState: {
298 branch: result.branch.name,
299 worktreePath: thread.worktreePath,
300 },
301 });
302 return result;
303 }
304
305 await refreshSelectedThreadGitStatus({ quiet: true, cwd });
306 return result;
307 });
308 },
309 [refreshSelectedThreadGitStatus, runSelectedThreadGitMutation, syncSelectedThreadBranchState],
310 );
311
312 return {
313 refreshSelectedThreadGitStatus,
314 refreshSelectedThreadBranches,
315 onCheckoutSelectedThreadBranch,
316 onCreateSelectedThreadBranch,
317 onCreateSelectedThreadWorktree,
318 onPullSelectedThreadBranch,
319 onRunSelectedThreadGitAction,
320 };
321}
apps/mobile/src/state/use-selected-thread-git-state.ts+47−0
@@ -0,0 +1,47 @@
1import { useMemo } from "react";
2
3import { dedupeRemoteBranchesWithLocalMatches } from "@t3tools/shared/git";
4
5import { useGitActionState } from "./use-git-action-state";
6import { useGitBranches } from "./use-git-branches";
7import { useThreadSelection } from "./use-thread-selection";
8
9export function useSelectedThreadGitState() {
10 const { selectedThread, selectedThreadProject } = useThreadSelection();
11
12 const selectedThreadGitTarget = useMemo(
13 () => ({
14 environmentId: selectedThread?.environmentId ?? null,
15 cwd: selectedThread?.worktreePath ?? selectedThreadProject?.workspaceRoot ?? null,
16 }),
17 [
18 selectedThread?.environmentId,
19 selectedThread?.worktreePath,
20 selectedThreadProject?.workspaceRoot,
21 ],
22 );
23 const gitActionState = useGitActionState(selectedThreadGitTarget);
24
25 const selectedThreadBranchTarget = useMemo(
26 () => ({
27 environmentId: selectedThread?.environmentId ?? null,
28 cwd: selectedThreadProject?.workspaceRoot ?? null,
29 query: null,
30 }),
31 [selectedThread?.environmentId, selectedThreadProject?.workspaceRoot],
32 );
33 const selectedThreadBranchState = useGitBranches(selectedThreadBranchTarget);
34 const selectedThreadBranches = useMemo(
35 () =>
36 dedupeRemoteBranchesWithLocalMatches(selectedThreadBranchState.data?.branches ?? []).filter(
37 (branch) => !branch.isRemote,
38 ),
39 [selectedThreadBranchState.data?.branches],
40 );
41
42 return {
43 gitOperationLabel: gitActionState.currentLabel,
44 selectedThreadBranches,
45 selectedThreadBranchesLoading: selectedThreadBranchState.isPending,
46 };
47}
apps/mobile/src/state/use-selected-thread-git.ts+0−409
@@ -1,409 +0,0 @@
1import { useCallback, useEffect } from "react";
2
3import {
4 buildGitActionProgressStages,
5 type GitActionRequestInput,
6 type WsRpcClient,
7} from "@t3tools/client-runtime";
8import {
9 CommandId,
10 type GitBranch,
11 type GitRunStackedActionResult,
12 ThreadId,
13} from "@t3tools/contracts";
14import {
15 dedupeRemoteBranchesWithLocalMatches,
16 sanitizeFeatureBranchName,
17} from "@t3tools/shared/git";
18
19import { newClientId } from "../lib/clientId";
20import {
21 scopedThreadKey,
22 type ScopedMobileProject,
23 type ScopedMobileThread,
24} from "../lib/scopedEntities";
25import { useRemoteEnvironmentStore } from "./remote-environment-store";
26import { useThreadSelection } from "./use-thread-selection";
27import { getEnvironmentClient } from "./use-remote-environment-registry";
28import { useThreadGitStore } from "./thread-git-store";
29
30export function useSelectedThreadGit() {
31 const { selectedThread, selectedThreadProject } = useThreadSelection();
32 const setPendingConnectionError = useRemoteEnvironmentStore(
33 (state) => state.setPendingConnectionError,
34 );
35
36 const selectedThreadGitRootCwd = selectedThreadProject?.workspaceRoot ?? null;
37 const selectedThreadKey = selectedThread
38 ? scopedThreadKey(selectedThread.environmentId, selectedThread.id)
39 : null;
40 const selectedThreadGitStatus = useThreadGitStore((state) =>
41 selectedThreadKey ? (state.gitStatusByThreadKey[selectedThreadKey] ?? null) : null,
42 );
43 const gitOperationLabel = useThreadGitStore((state) =>
44 selectedThreadKey ? (state.gitOperationLabelByThreadKey[selectedThreadKey] ?? null) : null,
45 );
46 const setThreadGitStatus = useThreadGitStore((state) => state.setThreadGitStatus);
47 const setThreadGitOperationLabel = useThreadGitStore((state) => state.setThreadGitOperationLabel);
48 const clearThreadGitState = useThreadGitStore((state) => state.clearThreadGitState);
49
50 const updateThreadGitContext = useCallback(
51 async (
52 thread: NonNullable<typeof selectedThread>,
53 nextState: {
54 readonly branch?: string | null;
55 readonly worktreePath?: string | null;
56 },
57 ) => {
58 const client = getEnvironmentClient(thread.environmentId);
59 if (!client) {
60 return;
61 }
62
63 await client.orchestration.dispatchCommand({
64 type: "thread.meta.update",
65 commandId: CommandId.makeUnsafe(newClientId("command")),
66 threadId: ThreadId.makeUnsafe(thread.id),
67 ...(nextState.branch !== undefined ? { branch: nextState.branch } : {}),
68 ...(nextState.worktreePath !== undefined ? { worktreePath: nextState.worktreePath } : {}),
69 });
70 },
71 [],
72 );
73
74 const refreshSelectedThreadGitStatus = useCallback(
75 async (options?: { readonly quiet?: boolean; readonly cwd?: string | null }) => {
76 if (!selectedThread || !selectedThreadProject) {
77 if (selectedThreadKey) {
78 clearThreadGitState(selectedThreadKey);
79 }
80 return null;
81 }
82
83 const cwd =
84 options?.cwd ?? selectedThread.worktreePath ?? selectedThreadProject.workspaceRoot;
85 if (!cwd) {
86 if (selectedThreadKey) {
87 clearThreadGitState(selectedThreadKey);
88 }
89 return null;
90 }
91
92 if (!options?.quiet && selectedThreadKey) {
93 setThreadGitOperationLabel(selectedThreadKey, "Refreshing git status");
94 }
95
96 try {
97 const client = getEnvironmentClient(selectedThread.environmentId);
98 if (!client) {
99 return null;
100 }
101
102 const status = await client.git.refreshStatus({ cwd });
103 if (selectedThreadKey) {
104 setThreadGitStatus(selectedThreadKey, status);
105 }
106 setPendingConnectionError(null);
107 return status;
108 } catch (error) {
109 const message = error instanceof Error ? error.message : "Failed to refresh git status.";
110 setPendingConnectionError(message);
111 return null;
112 } finally {
113 if (!options?.quiet && selectedThreadKey) {
114 setThreadGitOperationLabel(selectedThreadKey, null);
115 }
116 }
117 },
118 [
119 clearThreadGitState,
120 selectedThreadKey,
121 selectedThread,
122 selectedThreadProject,
123 setPendingConnectionError,
124 setThreadGitOperationLabel,
125 setThreadGitStatus,
126 ],
127 );
128
129 useEffect(() => {
130 if (!selectedThread || !selectedThreadProject) {
131 if (selectedThreadKey) {
132 clearThreadGitState(selectedThreadKey);
133 }
134 return;
135 }
136
137 void refreshSelectedThreadGitStatus({ quiet: true });
138 }, [
139 clearThreadGitState,
140 refreshSelectedThreadGitStatus,
141 selectedThreadKey,
142 selectedThread,
143 selectedThreadProject,
144 ]);
145
146 const runSelectedThreadGitMutation = useCallback(
147 async <T>(
148 label: string,
149 operation: (input: {
150 readonly client: WsRpcClient;
151 readonly thread: ScopedMobileThread;
152 readonly project: ScopedMobileProject;
153 }) => Promise<T>,
154 ): Promise<T | null> => {
155 if (!selectedThread || !selectedThreadProject) {
156 return null;
157 }
158
159 const client = getEnvironmentClient(selectedThread.environmentId);
160 if (!client) {
161 return null;
162 }
163
164 if (selectedThreadKey) {
165 setThreadGitOperationLabel(selectedThreadKey, label);
166 }
167 try {
168 setPendingConnectionError(null);
169 return await operation({
170 client,
171 thread: selectedThread,
172 project: selectedThreadProject,
173 });
174 } catch (error) {
175 setPendingConnectionError(error instanceof Error ? error.message : "Git action failed.");
176 return null;
177 } finally {
178 if (selectedThreadKey) {
179 setThreadGitOperationLabel(selectedThreadKey, null);
180 }
181 }
182 },
183 [
184 selectedThreadKey,
185 selectedThread,
186 selectedThreadProject,
187 setPendingConnectionError,
188 setThreadGitOperationLabel,
189 ],
190 );
191
192 const onListSelectedThreadBranches = useCallback(async (): Promise<ReadonlyArray<GitBranch>> => {
193 if (!selectedThread || !selectedThreadProject || !selectedThreadGitRootCwd) {
194 return [];
195 }
196
197 const client = getEnvironmentClient(selectedThread.environmentId);
198 if (!client) {
199 return [];
200 }
201
202 try {
203 const result = await client.git.listBranches({
204 cwd: selectedThreadGitRootCwd,
205 limit: 100,
206 });
207 return dedupeRemoteBranchesWithLocalMatches(result.branches).filter(
208 (branch) => !branch.isRemote,
209 );
210 } catch (error) {
211 setPendingConnectionError(
212 error instanceof Error ? error.message : "Failed to load branches.",
213 );
214 return [];
215 }
216 }, [selectedThread, selectedThreadGitRootCwd, selectedThreadProject, setPendingConnectionError]);
217
218 const onListProjectBranches = useCallback(
219 async (project: ScopedMobileProject): Promise<ReadonlyArray<GitBranch>> => {
220 const client = getEnvironmentClient(project.environmentId);
221 if (!client) {
222 return [];
223 }
224
225 try {
226 const result = await client.git.listBranches({
227 cwd: project.workspaceRoot,
228 limit: 100,
229 });
230 return dedupeRemoteBranchesWithLocalMatches(result.branches).filter(
231 (branch) => !branch.isRemote,
232 );
233 } catch (error) {
234 setPendingConnectionError(
235 error instanceof Error ? error.message : "Failed to load branches.",
236 );
237 return [];
238 }
239 },
240 [setPendingConnectionError],
241 );
242
243 const onCreateProjectWorktree = useCallback(
244 async (
245 project: ScopedMobileProject,
246 nextWorktree: {
247 readonly baseBranch: string;
248 readonly newBranch: string;
249 },
250 ): Promise<{
251 readonly branch: string;
252 readonly worktreePath: string;
253 } | null> => {
254 const client = getEnvironmentClient(project.environmentId);
255 if (!client) {
256 return null;
257 }
258
259 try {
260 const result = await client.git.createWorktree({
261 cwd: project.workspaceRoot,
262 branch: nextWorktree.baseBranch,
263 newBranch: sanitizeFeatureBranchName(nextWorktree.newBranch),
264 path: null,
265 });
266 return {
267 branch: result.worktree.branch,
268 worktreePath: result.worktree.path,
269 };
270 } catch (error) {
271 setPendingConnectionError(
272 error instanceof Error ? error.message : "Failed to create worktree.",
273 );
274 return null;
275 }
276 },
277 [setPendingConnectionError],
278 );
279
280 const onCheckoutSelectedThreadBranch = useCallback(
281 async (branch: string) => {
282 await runSelectedThreadGitMutation(
283 "Checking out branch",
284 async ({ client, thread, project }) => {
285 const cwd = thread.worktreePath ?? project.workspaceRoot;
286 const result = await client.git.checkout({ cwd, branch });
287 await updateThreadGitContext(thread, {
288 branch: result.branch,
289 worktreePath: thread.worktreePath,
290 });
291 await refreshSelectedThreadGitStatus({ quiet: true, cwd });
292 },
293 );
294 },
295 [refreshSelectedThreadGitStatus, runSelectedThreadGitMutation, updateThreadGitContext],
296 );
297
298 const onCreateSelectedThreadBranch = useCallback(
299 async (branch: string) => {
300 await runSelectedThreadGitMutation("Creating branch", async ({ client, thread, project }) => {
301 const cwd = thread.worktreePath ?? project.workspaceRoot;
302 const result = await client.git.createBranch({
303 cwd,
304 branch,
305 checkout: true,
306 });
307 await updateThreadGitContext(thread, {
308 branch: result.branch,
309 worktreePath: thread.worktreePath,
310 });
311 await refreshSelectedThreadGitStatus({ quiet: true, cwd });
312 });
313 },
314 [refreshSelectedThreadGitStatus, runSelectedThreadGitMutation, updateThreadGitContext],
315 );
316
317 const onCreateSelectedThreadWorktree = useCallback(
318 async (nextWorktree: { readonly baseBranch: string; readonly newBranch: string }) => {
319 await runSelectedThreadGitMutation(
320 "Creating worktree",
321 async ({ client, thread, project }) => {
322 const result = await client.git.createWorktree({
323 cwd: project.workspaceRoot,
324 branch: nextWorktree.baseBranch,
325 newBranch: sanitizeFeatureBranchName(nextWorktree.newBranch),
326 path: null,
327 });
328 await updateThreadGitContext(thread, {
329 branch: result.worktree.branch,
330 worktreePath: result.worktree.path,
331 });
332 await refreshSelectedThreadGitStatus({ quiet: true, cwd: result.worktree.path });
333 },
334 );
335 },
336 [refreshSelectedThreadGitStatus, runSelectedThreadGitMutation, updateThreadGitContext],
337 );
338
339 const onPullSelectedThreadBranch = useCallback(async () => {
340 await runSelectedThreadGitMutation(
341 "Pulling latest changes",
342 async ({ client, thread, project }) => {
343 const cwd = thread.worktreePath ?? project.workspaceRoot;
344 await client.git.pull({ cwd });
345 await refreshSelectedThreadGitStatus({ quiet: true, cwd });
346 },
347 );
348 }, [refreshSelectedThreadGitStatus, runSelectedThreadGitMutation]);
349
350 const onRunSelectedThreadGitAction = useCallback(
351 async (input: GitActionRequestInput): Promise<GitRunStackedActionResult | null> => {
352 const [firstStage] = buildGitActionProgressStages({
353 action: input.action,
354 hasCustomCommitMessage: Boolean(input.commitMessage?.trim()),
355 hasWorkingTreeChanges: selectedThreadGitStatus?.hasWorkingTreeChanges ?? false,
356 featureBranch: input.featureBranch ?? false,
357 shouldPushBeforePr:
358 input.action === "create_pr" &&
359 ((selectedThreadGitStatus?.aheadCount ?? 0) > 0 ||
360 !(selectedThreadGitStatus?.hasUpstream ?? false)),
361 });
362
363 return await runSelectedThreadGitMutation(
364 firstStage ?? "Running git action",
365 async ({ client, thread, project }) => {
366 const cwd = thread.worktreePath ?? project.workspaceRoot;
367 const result = await client.git.runStackedAction({
368 actionId: newClientId("git-action"),
369 cwd,
370 action: input.action,
371 ...(input.commitMessage ? { commitMessage: input.commitMessage } : {}),
372 ...(input.featureBranch ? { featureBranch: input.featureBranch } : {}),
373 ...(input.filePaths?.length ? { filePaths: [...input.filePaths] } : {}),
374 });
375
376 if (result.branch.status === "created" && result.branch.name) {
377 await updateThreadGitContext(thread, {
378 branch: result.branch.name,
379 worktreePath: thread.worktreePath,
380 });
381 }
382
383 await refreshSelectedThreadGitStatus({ quiet: true, cwd });
384 return result;
385 },
386 );
387 },
388 [
389 refreshSelectedThreadGitStatus,
390 runSelectedThreadGitMutation,
391 selectedThreadGitStatus,
392 updateThreadGitContext,
393 ],
394 );
395
396 return {
397 selectedThreadGitStatus,
398 gitOperationLabel,
399 refreshSelectedThreadGitStatus,
400 onListProjectBranches,
401 onCreateProjectWorktree,
402 onListSelectedThreadBranches,
403 onCheckoutSelectedThreadBranch,
404 onCreateSelectedThreadBranch,
405 onCreateSelectedThreadWorktree,
406 onPullSelectedThreadBranch,
407 onRunSelectedThreadGitAction,
408 };
409}
apps/mobile/src/state/use-thread-composer-state.ts+15−8
@@ -18,7 +18,7 @@ import { · 17 unmodified lines
18 derivePendingUserInputs,18 derivePendingUserInputs,
19 type QueuedThreadMessage,19 type QueuedThreadMessage,
20} from "../lib/threadActivity";20} from "../lib/threadActivity";
21import { newClientId } from "../lib/clientId";21import { uuidv4 } from "../lib/uuid";
22import type { ConnectedEnvironmentSummary } from "./remote-runtime-types";22import type { ConnectedEnvironmentSummary } from "./remote-runtime-types";
23import { useRemoteEnvironmentStore } from "./remote-environment-store";23import { useRemoteEnvironmentStore } from "./remote-environment-store";
24import { getEnvironmentClient, useRemoteConnectionStatus } from "./use-remote-environment-registry";24import { getEnvironmentClient, useRemoteConnectionStatus } from "./use-remote-environment-registry";
@@ -238,7 +238,7 @@ export function useThreadComposerState() { · 213 unmodified lines
238 return;238 return;
239 }239 }
240 240
241 beginDispatchingQueuedMessage(queuedMessage.id);241 beginDispatchingQueuedMessage(queuedMessage.messageId);
242 try {242 try {
243 await client.orchestration.dispatchCommand({243 await client.orchestration.dispatchCommand({
244 type: "thread.turn.start",244 type: "thread.turn.start",
@@ -255,7 +255,11 @@ export function useThreadComposerState() { · 10 unmodified lines
255 createdAt: queuedMessage.createdAt,255 createdAt: queuedMessage.createdAt,
256 });256 });
257 257
258 removeQueuedMessage(queuedMessage.environmentId, queuedMessage.threadId, queuedMessage.id);258 removeQueuedMessage(
259 queuedMessage.environmentId,
260 queuedMessage.threadId,
261 queuedMessage.messageId,
262 );
259 patchEnvironmentRuntimeState(queuedMessage.environmentId, (runtime) => ({263 patchEnvironmentRuntimeState(queuedMessage.environmentId, (runtime) => ({
260 ...runtime,264 ...runtime,
261 snapshot: runtime.snapshot265 snapshot: runtime.snapshot
@@ -269,12 +273,16 @@ export function useThreadComposerState() { · 7 unmodified lines
269 : runtime.snapshot,273 : runtime.snapshot,
270 }));274 }));
271 } catch (error) {275 } catch (error) {
272 removeQueuedMessage(queuedMessage.environmentId, queuedMessage.threadId, queuedMessage.id);276 removeQueuedMessage(
277 queuedMessage.environmentId,
278 queuedMessage.threadId,
279 queuedMessage.messageId,
280 );
273 setPendingConnectionError(281 setPendingConnectionError(
274 error instanceof Error ? error.message : "Failed to send message.",282 error instanceof Error ? error.message : "Failed to send message.",
275 );283 );
276 } finally {284 } finally {
277 finishDispatchingQueuedMessage(queuedMessage.id);285 finishDispatchingQueuedMessage(queuedMessage.messageId);
278 }286 }
279 },287 },
280 [288 [
@@ -309,11 +317,10 @@ export function useThreadComposerState() { · 28 unmodified lines
309 317
310 const createdAt = new Date().toISOString();318 const createdAt = new Date().toISOString();
311 enqueueQueuedMessage({319 enqueueQueuedMessage({
312 id: newClientId("queued-message"),
313 environmentId: selectedThread.environmentId,320 environmentId: selectedThread.environmentId,
314 threadId: selectedThread.id,321 threadId: selectedThread.id,
315 messageId: newClientId("message"),322 messageId: MessageId.makeUnsafe(uuidv4()),
316 commandId: newClientId("command"),323 commandId: CommandId.makeUnsafe(uuidv4()),
317 text,324 text,
318 attachments,325 attachments,
319 createdAt,326 createdAt,
apps/mobile/uniwind-types.d.ts+10−0
@@ -0,0 +1,10 @@
1// NOTE: This file is generated by uniwind and it should not be edited manually.
2/// <reference types="uniwind/types" />
3
4declare module 'uniwind' {
5 export interface UniwindConfig {
6 themes: readonly ['light', 'dark']
7 }
8}
9
10export {}
apps/web/src/lib/gitStatusState.test.ts+0−325
@@ -1,325 +0,0 @@
1import { EnvironmentId, type GitStatusResult } from "@t3tools/contracts";
2import { afterEach, describe, expect, it, vi } from "vitest";
3
4import type { WsRpcClient } from "../rpc/wsRpcClient";
5import { resetAppAtomRegistryForTests } from "../rpc/atomRegistry";
6import {
7 getGitStatusSnapshot,
8 resetGitStatusStateForTests,
9 refreshGitStatus,
10 watchGitStatus,
11} from "./gitStatusState";
12
13const serviceHarness = vi.hoisted(() => ({
14 connections: new Map<string, any>(),
15 listeners: new Set<() => void>(),
16}));
17
18vi.mock("../environments/runtime/service", () => ({
19 readEnvironmentConnection: (environmentId: string) =>
20 serviceHarness.connections.get(environmentId) ?? null,
21 subscribeEnvironmentConnections: (listener: () => void) => {
22 serviceHarness.listeners.add(listener);
23 return () => {
24 serviceHarness.listeners.delete(listener);
25 };
26 },
27}));
28
29function registerListener<T>(listeners: Set<(event: T) => void>, listener: (event: T) => void) {
30 listeners.add(listener);
31 return () => {
32 listeners.delete(listener);
33 };
34}
35
36const gitStatusListeners = new Set<(event: GitStatusResult) => void>();
37const ENVIRONMENT_ID = EnvironmentId.make("environment-local");
38const OTHER_ENVIRONMENT_ID = EnvironmentId.make("environment-remote");
39const TARGET = { environmentId: ENVIRONMENT_ID, cwd: "/repo" } as const;
40const FRESH_TARGET = { environmentId: ENVIRONMENT_ID, cwd: "/fresh" } as const;
41
42const BASE_STATUS: GitStatusResult = {
43 isRepo: true,
44 hasOriginRemote: true,
45 isDefaultBranch: false,
46 branch: "feature/push-status",
47 hasWorkingTreeChanges: false,
48 workingTree: { files: [], insertions: 0, deletions: 0 },
49 hasUpstream: true,
50 aheadCount: 0,
51 behindCount: 0,
52 pr: null,
53};
54
55const gitClient = {
56 refreshStatus: vi.fn(async (input: { cwd: string }) => ({
57 ...BASE_STATUS,
58 branch: `${input.cwd}-refreshed`,
59 })),
60 onStatus: vi.fn((input: { cwd: string }, listener: (event: GitStatusResult) => void) =>
61 registerListener(gitStatusListeners, listener),
62 ),
63};
64
65function emitGitStatus(event: GitStatusResult) {
66 for (const listener of gitStatusListeners) {
67 listener(event);
68 }
69}
70
71function createRegisteredGitStatusClient(environmentId: EnvironmentId) {
72 const listeners = new Set<(event: GitStatusResult) => void>();
73 const client = {
74 dispose: vi.fn(async () => undefined),
75 reconnect: vi.fn(async () => undefined),
76 terminal: {
77 open: vi.fn(async () => undefined),
78 write: vi.fn(async () => undefined),
79 resize: vi.fn(async () => undefined),
80 clear: vi.fn(async () => undefined),
81 restart: vi.fn(async () => undefined),
82 close: vi.fn(async () => undefined),
83 onEvent: vi.fn(() => () => undefined),
84 },
85 projects: {
86 searchEntries: vi.fn(async () => []),
87 writeFile: vi.fn(async () => undefined),
88 },
89 shell: {
90 openInEditor: vi.fn(async () => undefined),
91 },
92 git: {
93 pull: vi.fn(async () => undefined),
94 refreshStatus: vi.fn(async (input: { cwd: string }) => ({
95 ...BASE_STATUS,
96 branch: `${input.cwd}-refreshed`,
97 })),
98 onStatus: vi.fn((_: { cwd: string }, listener: (event: GitStatusResult) => void) =>
99 registerListener(listeners, listener),
100 ),
101 runStackedAction: vi.fn(async () => ({}) as any),
102 listBranches: vi.fn(async () => []),
103 createWorktree: vi.fn(async () => undefined),
104 removeWorktree: vi.fn(async () => undefined),
105 createBranch: vi.fn(async () => undefined),
106 checkout: vi.fn(async () => undefined),
107 init: vi.fn(async () => undefined),
108 resolvePullRequest: vi.fn(async () => undefined),
109 preparePullRequestThread: vi.fn(async () => undefined),
110 },
111 server: {
112 getConfig: vi.fn(async () => ({
113 environment: {
114 environmentId,
115 },
116 })),
117 refreshProviders: vi.fn(async () => undefined),
118 upsertKeybinding: vi.fn(async () => undefined),
119 getSettings: vi.fn(async () => undefined),
120 updateSettings: vi.fn(async () => undefined),
121 subscribeConfig: vi.fn(() => () => undefined),
122 subscribeLifecycle: vi.fn(() => () => undefined),
123 subscribeAuthAccess: vi.fn(() => () => undefined),
124 },
125 orchestration: {
126 dispatchCommand: vi.fn(async () => undefined),
127 getTurnDiff: vi.fn(async () => undefined),
128 getFullThreadDiff: vi.fn(async () => undefined),
129 subscribeShell: vi.fn(() => () => undefined),
130 subscribeThread: vi.fn(() => () => undefined),
131 },
132 } as unknown as WsRpcClient;
133
134 serviceHarness.connections.set(environmentId, {
135 kind: "saved" as const,
136 knownEnvironment: {
137 id: environmentId,
138 label: `Environment ${environmentId}`,
139 source: "manual" as const,
140 environmentId,
141 target: {
142 httpBaseUrl: "http://example.test",
143 wsBaseUrl: "ws://example.test",
144 },
145 },
146 client,
147 environmentId,
148 ensureBootstrapped: async () => undefined,
149 reconnect: async () => undefined,
150 dispose: async () => undefined,
151 });
152 for (const listener of serviceHarness.listeners) {
153 listener();
154 }
155
156 return {
157 client,
158 emit: (event: GitStatusResult) => {
159 for (const listener of listeners) {
160 listener(event);
161 }
162 },
163 };
164}
165
166afterEach(async () => {
167 gitStatusListeners.clear();
168 serviceHarness.connections.clear();
169 serviceHarness.listeners.clear();
170 gitClient.onStatus.mockClear();
171 gitClient.refreshStatus.mockClear();
172 resetGitStatusStateForTests();
173 resetAppAtomRegistryForTests();
174});
175
176describe("gitStatusState", () => {
177 it("starts fresh cwd state in a pending state", () => {
178 expect(getGitStatusSnapshot(FRESH_TARGET)).toEqual({
179 data: null,
180 error: null,
181 cause: null,
182 isPending: true,
183 });
184 });
185
186 it("shares one live subscription per cwd and updates the per-cwd atom snapshot", () => {
187 const releaseA = watchGitStatus(TARGET, gitClient);
188 const releaseB = watchGitStatus(TARGET, gitClient);
189
190 expect(gitClient.onStatus).toHaveBeenCalledOnce();
191 expect(getGitStatusSnapshot(TARGET)).toEqual({
192 data: null,
193 error: null,
194 cause: null,
195 isPending: true,
196 });
197
198 emitGitStatus(BASE_STATUS);
199
200 expect(getGitStatusSnapshot(TARGET)).toEqual({
201 data: BASE_STATUS,
202 error: null,
203 cause: null,
204 isPending: false,
205 });
206
207 releaseA();
208 expect(gitStatusListeners.size).toBe(1);
209
210 releaseB();
211 expect(gitStatusListeners.size).toBe(0);
212 });
213
214 it("refreshes git status through the unary RPC without restarting the stream", async () => {
215 const release = watchGitStatus(TARGET, gitClient);
216
217 emitGitStatus(BASE_STATUS);
218 const refreshed = await refreshGitStatus(TARGET, gitClient);
219
220 expect(gitClient.onStatus).toHaveBeenCalledOnce();
221 expect(gitClient.refreshStatus).toHaveBeenCalledWith({ cwd: "/repo" });
222 expect(refreshed).toEqual({ ...BASE_STATUS, branch: "/repo-refreshed" });
223 expect(getGitStatusSnapshot(TARGET)).toEqual({
224 data: BASE_STATUS,
225 error: null,
226 cause: null,
227 isPending: false,
228 });
229
230 release();
231 });
232
233 it("keeps git status subscriptions isolated by environment when cwds match", () => {
234 const localListeners = new Set<(event: GitStatusResult) => void>();
235 const remoteListeners = new Set<(event: GitStatusResult) => void>();
236 const localClient = {
237 refreshStatus: vi.fn(),
238 onStatus: vi.fn((_: { cwd: string }, listener: (event: GitStatusResult) => void) =>
239 registerListener(localListeners, listener),
240 ),
241 };
242 const remoteClient = {
243 refreshStatus: vi.fn(),
244 onStatus: vi.fn((_: { cwd: string }, listener: (event: GitStatusResult) => void) =>
245 registerListener(remoteListeners, listener),
246 ),
247 };
248 const remoteTarget = { environmentId: OTHER_ENVIRONMENT_ID, cwd: "/repo" } as const;
249
250 const releaseLocal = watchGitStatus(TARGET, localClient);
251 const releaseRemote = watchGitStatus(remoteTarget, remoteClient);
252
253 for (const listener of localListeners) {
254 listener(BASE_STATUS);
255 }
256 for (const listener of remoteListeners) {
257 listener({ ...BASE_STATUS, branch: "remote-branch" });
258 }
259
260 expect(getGitStatusSnapshot(TARGET).data?.branch).toBe("feature/push-status");
261 expect(getGitStatusSnapshot(remoteTarget).data?.branch).toBe("remote-branch");
262
263 releaseLocal();
264 releaseRemote();
265 });
266
267 it("waits for a delayed environment client registration instead of throwing", () => {
268 const release = watchGitStatus(TARGET);
269
270 expect(getGitStatusSnapshot(TARGET)).toEqual({
271 data: null,
272 error: null,
273 cause: null,
274 isPending: true,
275 });
276
277 const registered = createRegisteredGitStatusClient(ENVIRONMENT_ID);
278 registered.emit(BASE_STATUS);
279
280 expect(getGitStatusSnapshot(TARGET)).toEqual({
281 data: BASE_STATUS,
282 error: null,
283 cause: null,
284 isPending: false,
285 });
286
287 release();
288 });
289
290 it("resubscribes after the environment client is removed and re-registered", async () => {
291 const firstClient = createRegisteredGitStatusClient(ENVIRONMENT_ID);
292 const release = watchGitStatus(TARGET);
293
294 firstClient.emit(BASE_STATUS);
295 expect(getGitStatusSnapshot(TARGET).data?.branch).toBe("feature/push-status");
296
297 serviceHarness.connections.delete(ENVIRONMENT_ID);
298 for (const listener of serviceHarness.listeners) {
299 listener();
300 }
301
302 expect(getGitStatusSnapshot(TARGET)).toEqual({
303 data: BASE_STATUS,
304 error: null,
305 cause: null,
306 isPending: true,
307 });
308
309 const secondClient = createRegisteredGitStatusClient(ENVIRONMENT_ID);
310 secondClient.emit({ ...BASE_STATUS, branch: "reconnected-branch" });
311
312 expect(getGitStatusSnapshot(TARGET)).toEqual({
313 data: { ...BASE_STATUS, branch: "reconnected-branch" },
314 error: null,
315 cause: null,
316 isPending: false,
317 });
318
319 release();
320 });
321
322 it("returns the cached snapshot when refresh is requested before the client is registered", async () => {
323 await expect(refreshGitStatus(TARGET)).resolves.toBeNull();
324 });
325});
apps/web/src/lib/gitStatusState.ts+30−258
@@ -1,11 +1,15 @@
1import { useAtomValue } from "@effect/atom-react";1import { useAtomValue } from "@effect/atom-react";
2import {2import {
3 type EnvironmentId,3 type GitStatusClient,
4 type GitManagerServiceError,4 type GitStatusState,
5 type GitStatusResult,5 type GitStatusTarget,
6} from "@t3tools/contracts";6 EMPTY_GIT_STATUS_ATOM,
7import { Cause } from "effect";7 EMPTY_GIT_STATUS_STATE,
8import { Atom } from "effect/unstable/reactivity";8 createGitStatusManager,
9 getGitStatusTargetKey,
10 gitStatusStateAtom,
11} from "@t3tools/client-runtime";
12import { type EnvironmentId } from "@t3tools/contracts";
9import { useEffect } from "react";13import { useEffect } from "react";
10 14
11import { appAtomRegistry } from "../rpc/atomRegistry";15import { appAtomRegistry } from "../rpc/atomRegistry";
@@ -13,159 +17,38 @@ import { · 1 unmodified line
13 readEnvironmentConnection,17 readEnvironmentConnection,
14 subscribeEnvironmentConnections,18 subscribeEnvironmentConnections,
15} from "../environments/runtime";19} from "../environments/runtime";
16import type { WsRpcClient } from "~/rpc/wsRpcClient";
17 20
18interface GitStatusState {21export type { GitStatusState, GitStatusTarget };
19 readonly data: GitStatusResult | null;22
20 readonly error: GitManagerServiceError | null;23/* ─── Manager singleton ─────────────────────────────────────────────── */
21 readonly cause: Cause.Cause<GitManagerServiceError> | null;24
22 readonly isPending: boolean;25const manager = createGitStatusManager({
23}26 getRegistry: () => appAtomRegistry,
24 27 getClient: (environmentId) => {
25type GitStatusClient = Pick<WsRpcClient["git"], "onStatus" | "refreshStatus">;28 const connection = readEnvironmentConnection(environmentId as EnvironmentId);
26interface ResolvedGitStatusClient {29 return connection ? connection.client.git : null;
27 readonly clientIdentity: string;30 },
28 readonly client: GitStatusClient;31 getClientIdentity: (environmentId) => {
29}32 const connection = readEnvironmentConnection(environmentId as EnvironmentId);
30 33 return connection ? connection.environmentId : null;
31interface WatchedGitStatus {34 },
32 refCount: number;35 subscribeClientChanges: subscribeEnvironmentConnections,
33 unsubscribe: () => void;
34}
35
36interface GitStatusTarget {
37 readonly environmentId: EnvironmentId | null;
38 readonly cwd: string | null;
39}
40
41const EMPTY_GIT_STATUS_STATE = Object.freeze<GitStatusState>({
42 data: null,
43 error: null,
44 cause: null,
45 isPending: false,
46});
47const INITIAL_GIT_STATUS_STATE = Object.freeze<GitStatusState>({
48 ...EMPTY_GIT_STATUS_STATE,
49 isPending: true,
50});
51const EMPTY_GIT_STATUS_ATOM = Atom.make(EMPTY_GIT_STATUS_STATE).pipe(
52 Atom.keepAlive,
53 Atom.withLabel("git-status:null"),
54);
55
56const NOOP: () => void = () => undefined;
57const watchedGitStatuses = new Map<string, WatchedGitStatus>();
58const knownGitStatusKeys = new Set<string>();
59const gitStatusRefreshInFlight = new Map<string, Promise<GitStatusResult>>();
60const gitStatusLastRefreshAtByKey = new Map<string, number>();
61
62const GIT_STATUS_REFRESH_DEBOUNCE_MS = 1_000;
63
64const gitStatusStateAtom = Atom.family((key: string) => {
65 knownGitStatusKeys.add(key);
66 return Atom.make(INITIAL_GIT_STATUS_STATE).pipe(
67 Atom.keepAlive,
68 Atom.withLabel(`git-status:${key}`),
69 );
70});36});
71 37
72function getGitStatusTargetKey(target: GitStatusTarget): string | null {38/* ─── Public API (preserves existing call-sites) ────────────────────── */
73 if (target.environmentId === null || target.cwd === null) {
74 return null;
75 }
76
77 return `${target.environmentId}:${target.cwd}`;
78}
79
80function readResolvedGitStatusClient(target: GitStatusTarget): ResolvedGitStatusClient | null {
81 if (target.environmentId === null) {
82 return null;
83 }
84 const connection = readEnvironmentConnection(target.environmentId);
85 return connection
86 ? { clientIdentity: connection.environmentId, client: connection.client.git }
87 : null;
88}
89
90export function getGitStatusSnapshot(target: GitStatusTarget): GitStatusState {
91 const targetKey = getGitStatusTargetKey(target);
92 if (targetKey === null) {
93 return EMPTY_GIT_STATUS_STATE;
94 }
95
96 return appAtomRegistry.get(gitStatusStateAtom(targetKey));
97}
98
99export function watchGitStatus(target: GitStatusTarget, client?: GitStatusClient): () => void {
100 const targetKey = getGitStatusTargetKey(target);
101 if (targetKey === null) {
102 return NOOP;
103 }
104
105 const watched = watchedGitStatuses.get(targetKey);
106 if (watched) {
107 watched.refCount += 1;
108 return () => unwatchGitStatus(targetKey);
109 }
110
111 watchedGitStatuses.set(targetKey, {
112 refCount: 1,
113 unsubscribe: subscribeToGitStatusTarget(targetKey, target, client),
114 });
115
116 return () => unwatchGitStatus(targetKey);
117}
118
119export function refreshGitStatus(
120 target: GitStatusTarget,
121 client?: GitStatusClient,
122): Promise<GitStatusResult | null> {
123 const targetKey = getGitStatusTargetKey(target);
124 if (targetKey === null || target.cwd === null) {
125 return Promise.resolve(null);
126 }
127
128 const resolvedClient = client ?? readResolvedGitStatusClient(target)?.client;
129 if (!resolvedClient) {
130 return Promise.resolve(getGitStatusSnapshot(target).data);
131 }
132 39
133 const currentInFlight = gitStatusRefreshInFlight.get(targetKey);40export function refreshGitStatus(target: GitStatusTarget, client?: GitStatusClient) {
134 if (currentInFlight) {41 return manager.refresh(target, client);
135 return currentInFlight;
136 }
137
138 const lastRequestedAt = gitStatusLastRefreshAtByKey.get(targetKey) ?? 0;
139 if (Date.now() - lastRequestedAt < GIT_STATUS_REFRESH_DEBOUNCE_MS) {
140 return Promise.resolve(getGitStatusSnapshot(target).data);
141 }
142
143 gitStatusLastRefreshAtByKey.set(targetKey, Date.now());
144 const refreshPromise = resolvedClient.refreshStatus({ cwd: target.cwd }).finally(() => {
145 gitStatusRefreshInFlight.delete(targetKey);
146 });
147 gitStatusRefreshInFlight.set(targetKey, refreshPromise);
148 return refreshPromise;
149}42}
150 43
151export function resetGitStatusStateForTests(): void {44export function resetGitStatusStateForTests(): void {
152 for (const watched of watchedGitStatuses.values()) {45 manager.reset();
153 watched.unsubscribe();
154 }
155 watchedGitStatuses.clear();
156 gitStatusRefreshInFlight.clear();
157 gitStatusLastRefreshAtByKey.clear();
158
159 for (const key of knownGitStatusKeys) {
160 appAtomRegistry.set(gitStatusStateAtom(key), INITIAL_GIT_STATUS_STATE);
161 }
162 knownGitStatusKeys.clear();
163}46}
164 47
165export function useGitStatus(target: GitStatusTarget): GitStatusState {48export function useGitStatus(target: GitStatusTarget): GitStatusState {
166 const targetKey = getGitStatusTargetKey(target);49 const targetKey = getGitStatusTargetKey(target);
167 useEffect(50 useEffect(
168 () => watchGitStatus({ environmentId: target.environmentId, cwd: target.cwd }),51 () => manager.watch({ environmentId: target.environmentId, cwd: target.cwd }),
169 [target.environmentId, target.cwd],52 [target.environmentId, target.cwd],
170 );53 );
171 54
@@ -174,114 +57,3 @@ export function useGitStatus(target: GitStatusTarget): GitStatusState { · 2 unmodified lines
174 );57 );
175 return targetKey === null ? EMPTY_GIT_STATUS_STATE : state;58 return targetKey === null ? EMPTY_GIT_STATUS_STATE : state;
176}59}
177
178function unwatchGitStatus(targetKey: string): void {
179 const watched = watchedGitStatuses.get(targetKey);
180 if (!watched) {
181 return;
182 }
183
184 watched.refCount -= 1;
185 if (watched.refCount > 0) {
186 return;
187 }
188
189 watched.unsubscribe();
190 watchedGitStatuses.delete(targetKey);
191}
192
193function subscribeToGitStatusTarget(
194 targetKey: string,
195 target: GitStatusTarget,
196 providedClient?: GitStatusClient,
197): () => void {
198 if (target.cwd === null) {
199 return NOOP;
200 }
201
202 const cwd = target.cwd;
203 let currentClientIdentity: string | null = null;
204 let currentUnsubscribe = NOOP;
205
206 const syncClientSubscription = () => {
207 const resolved = providedClient
208 ? {
209 clientIdentity: `provided:${targetKey}`,
210 client: providedClient,
211 }
212 : readResolvedGitStatusClient(target);
213
214 if (!resolved) {
215 if (currentClientIdentity !== null) {
216 currentUnsubscribe();
217 currentUnsubscribe = NOOP;
218 currentClientIdentity = null;
219 }
220 markGitStatusPending(targetKey);
221 return;
222 }
223
224 if (currentClientIdentity === resolved.clientIdentity) {
225 return;
226 }
227
228 currentUnsubscribe();
229 currentClientIdentity = resolved.clientIdentity;
230 currentUnsubscribe = subscribeToGitStatus(targetKey, cwd, resolved.client);
231 };
232
233 const unsubscribeRegistry = providedClient
234 ? NOOP
235 : subscribeEnvironmentConnections(syncClientSubscription);
236 syncClientSubscription();
237
238 return () => {
239 unsubscribeRegistry();
240 currentUnsubscribe();
241 };
242}
243
244function subscribeToGitStatus(targetKey: string, cwd: string, client: GitStatusClient): () => void {
245 markGitStatusPending(targetKey);
246 return client.onStatus(
247 { cwd },
248 (status: GitStatusResult) => {
249 appAtomRegistry.set(gitStatusStateAtom(targetKey), {
250 data: status,
251 error: null,
252 cause: null,
253 isPending: false,
254 });
255 },
256 {
257 onResubscribe: () => {
258 markGitStatusPending(targetKey);
259 },
260 },
261 );
262}
263
264function markGitStatusPending(targetKey: string): void {
265 const atom = gitStatusStateAtom(targetKey);
266 const current = appAtomRegistry.get(atom);
267 const next =
268 current.data === null
269 ? INITIAL_GIT_STATUS_STATE
270 : {
271 ...current,
272 error: null,
273 cause: null,
274 isPending: true,
275 };
276
277 if (
278 current.data === next.data &&
279 current.error === next.error &&
280 current.cause === next.cause &&
281 current.isPending === next.isPending
282 ) {
283 return;
284 }
285
286 appAtomRegistry.set(atom, next);
287}
bun.lock+26−49
@@ -45,6 +45,7 @@ · 44 unmodified lines
45 "version": "0.0.0",45 "version": "0.0.0",
46 "dependencies": {46 "dependencies": {
47 "@callstack/liquid-glass": "^0.7.1",47 "@callstack/liquid-glass": "^0.7.1",
48 "@effect/atom-react": "catalog:",
48 "@expo-google-fonts/dm-sans": "^0.4.2",49 "@expo-google-fonts/dm-sans": "^0.4.2",
49 "@gorhom/bottom-sheet": "^5",50 "@gorhom/bottom-sheet": "^5",
50 "@legendapp/list": "^3.0.0-beta.44",51 "@legendapp/list": "^3.0.0-beta.44",
@@ -54,6 +55,7 @@ · 3 unmodified lines
54 "@t3tools/client-runtime": "workspace:*",55 "@t3tools/client-runtime": "workspace:*",
55 "@t3tools/contracts": "workspace:*",56 "@t3tools/contracts": "workspace:*",
56 "@t3tools/shared": "workspace:*",57 "@t3tools/shared": "workspace:*",
58 "clsx": "^2.1.1",
57 "effect": "catalog:",59 "effect": "catalog:",
58 "expo": "~55.0.14",60 "expo": "~55.0.14",
59 "expo-camera": "~55.0.15",61 "expo-camera": "~55.0.15",
@@ -73,12 +75,10 @@ · 13 unmodified lines
73 "expo-splash-screen": "~55.0.13",75 "expo-splash-screen": "~55.0.13",
74 "expo-symbols": "~55.0.5",76 "expo-symbols": "~55.0.5",
75 "expo-updates": "~55.0.16",77 "expo-updates": "~55.0.16",
76 "nativewind": "5.0.0-preview.3",
77 "punycode": "^2.3.1",78 "punycode": "^2.3.1",
78 "react": "19.2.0",79 "react": "19.2.0",
79 "react-dom": "19.2.0",80 "react-dom": "19.2.0",
80 "react-native": "0.83.4",81 "react-native": "0.83.4",
81 "react-native-css": "3.0.7",
82 "react-native-gesture-handler": "~2.30.0",82 "react-native-gesture-handler": "~2.30.0",
83 "react-native-image-viewing": "^0.2.2",83 "react-native-image-viewing": "^0.2.2",
84 "react-native-keyboard-controller": "1.20.7",84 "react-native-keyboard-controller": "1.20.7",
@@ -88,14 +88,13 @@ · 3 unmodified lines
88 "react-native-screens": "~4.23.0",88 "react-native-screens": "~4.23.0",
89 "react-native-svg": "15.15.3",89 "react-native-svg": "15.15.3",
90 "react-native-worklets": "0.7.2",90 "react-native-worklets": "0.7.2",
91 "tailwind-merge": "^3.5.0",
92 "uniwind": "^1.6.2",
91 "zustand": "^5.0.12",93 "zustand": "^5.0.12",
92 },94 },
93 "devDependencies": {95 "devDependencies": {
94 "@tailwindcss/postcss": "4.1.12",
95 "@types/react": "~19.2.10",96 "@types/react": "~19.2.10",
96 "babel-preset-expo": "~55.0.8",97 "babel-preset-expo": "~55.0.8",
97 "lightningcss": "1.30.1",
98 "postcss": "^8.5.8",
99 "tailwindcss": "4.1.12",98 "tailwindcss": "4.1.12",
100 "typescript": "~5.9.2",99 "typescript": "~5.9.2",
101 },100 },
@@ -272,8 +271,6 @@ · 170 unmodified lines
272 "vitest": "^4.0.0",271 "vitest": "^4.0.0",
273 },272 },
274 "packages": {273 "packages": {
275 "@alloc/quick-lru": ["@alloc/quick-lru@5.2.0", "", {}, "sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw=="],
276
277 "@anthropic-ai/claude-agent-sdk": ["@anthropic-ai/claude-agent-sdk@0.2.104", "", { "dependencies": { "@anthropic-ai/sdk": "^0.81.0", "@modelcontextprotocol/sdk": "^1.29.0" }, "optionalDependencies": { "@img/sharp-darwin-arm64": "^0.34.2", "@img/sharp-darwin-x64": "^0.34.2", "@img/sharp-linux-arm": "^0.34.2", "@img/sharp-linux-arm64": "^0.34.2", "@img/sharp-linux-x64": "^0.34.2", "@img/sharp-linuxmusl-arm64": "^0.34.2", "@img/sharp-linuxmusl-x64": "^0.34.2", "@img/sharp-win32-arm64": "^0.34.2", "@img/sharp-win32-x64": "^0.34.2" }, "peerDependencies": { "zod": "^4.0.0" } }, "sha512-lVm+nS79r6WWlDnv5AgRzTtAlbP8O6M6kkWmDZAWE3nt9agmngxls9frJFvH55uzws2+6l0yyup/JYspfijkzw=="],274 "@anthropic-ai/claude-agent-sdk": ["@anthropic-ai/claude-agent-sdk@0.2.104", "", { "dependencies": { "@anthropic-ai/sdk": "^0.81.0", "@modelcontextprotocol/sdk": "^1.29.0" }, "optionalDependencies": { "@img/sharp-darwin-arm64": "^0.34.2", "@img/sharp-darwin-x64": "^0.34.2", "@img/sharp-linux-arm": "^0.34.2", "@img/sharp-linux-arm64": "^0.34.2", "@img/sharp-linux-x64": "^0.34.2", "@img/sharp-linuxmusl-arm64": "^0.34.2", "@img/sharp-linuxmusl-x64": "^0.34.2", "@img/sharp-win32-arm64": "^0.34.2", "@img/sharp-win32-x64": "^0.34.2" }, "peerDependencies": { "zod": "^4.0.0" } }, "sha512-lVm+nS79r6WWlDnv5AgRzTtAlbP8O6M6kkWmDZAWE3nt9agmngxls9frJFvH55uzws2+6l0yyup/JYspfijkzw=="],
278 275
279 "@anthropic-ai/sdk": ["@anthropic-ai/sdk@0.81.0", "", { "dependencies": { "json-schema-to-ts": "^3.1.1" }, "peerDependencies": { "zod": "^3.25.0 || ^4.0.0" }, "optionalPeers": ["zod"], "bin": { "anthropic-ai-sdk": "bin/cli" } }, "sha512-D4K5PvEV6wPiRtVlVsJHIUhHAmOZ6IT/I9rKlTf84gR7GyyAurPJK7z9BOf/AZqC5d1DhYQGJNKRmV+q8dGhgw=="],276 "@anthropic-ai/sdk": ["@anthropic-ai/sdk@0.81.0", "", { "dependencies": { "json-schema-to-ts": "^3.1.1" }, "peerDependencies": { "zod": "^3.25.0 || ^4.0.0" }, "optionalPeers": ["zod"], "bin": { "anthropic-ai-sdk": "bin/cli" } }, "sha512-D4K5PvEV6wPiRtVlVsJHIUhHAmOZ6IT/I9rKlTf84gR7GyyAurPJK7z9BOf/AZqC5d1DhYQGJNKRmV+q8dGhgw=="],
@@ -738,8 +735,6 @@ · 458 unmodified lines
738 735
739 "@ioredis/commands": ["@ioredis/commands@1.5.1", "", {}, "sha512-JH8ZL/ywcJyR9MmJ5BNqZllXNZQqQbnVZOqpPQqE1vHiFgAw4NHbvE0FOduNU8IX9babitBT46571OnPTT0Zcw=="],736 "@ioredis/commands": ["@ioredis/commands@1.5.1", "", {}, "sha512-JH8ZL/ywcJyR9MmJ5BNqZllXNZQqQbnVZOqpPQqE1vHiFgAw4NHbvE0FOduNU8IX9babitBT46571OnPTT0Zcw=="],
740 737
741 "@isaacs/fs-minipass": ["@isaacs/fs-minipass@4.0.1", "", { "dependencies": { "minipass": "^7.0.4" } }, "sha512-wgm9Ehl2jpeqP3zw/7mo3kRHFp5MEDhqAdwy1fTGkHAwnkGOVsgpvQhL8B5n1qlb01jV3n/bI0ZfZp5lWA1k4w=="],
742
743 "@isaacs/ttlcache": ["@isaacs/ttlcache@1.4.1", "", {}, "sha512-RQgQ4uQ+pLbqXfOmieB91ejmLwvSgv9nLx6sT6sD83s7umBypgg+OIBOBbEUiJXrfpnp9j0mRhYYdzp9uqq3lA=="],738 "@isaacs/ttlcache": ["@isaacs/ttlcache@1.4.1", "", {}, "sha512-RQgQ4uQ+pLbqXfOmieB91ejmLwvSgv9nLx6sT6sD83s7umBypgg+OIBOBbEUiJXrfpnp9j0mRhYYdzp9uqq3lA=="],
744 739
745 "@istanbuljs/load-nyc-config": ["@istanbuljs/load-nyc-config@1.1.0", "", { "dependencies": { "camelcase": "^5.3.1", "find-up": "^4.1.0", "get-package-type": "^0.1.0", "js-yaml": "^3.13.1", "resolve-from": "^5.0.0" } }, "sha512-VjeHSlIzpv/NyD3N0YuHfXOPDIixcA1q2ZV98wsMqcYlPmv2n3Yb2lYP9XMElnaFVXg5A7YLTeLu6V84uQDjmQ=="],740 "@istanbuljs/load-nyc-config": ["@istanbuljs/load-nyc-config@1.1.0", "", { "dependencies": { "camelcase": "^5.3.1", "find-up": "^4.1.0", "get-package-type": "^0.1.0", "js-yaml": "^3.13.1", "resolve-from": "^5.0.0" } }, "sha512-VjeHSlIzpv/NyD3N0YuHfXOPDIixcA1q2ZV98wsMqcYlPmv2n3Yb2lYP9XMElnaFVXg5A7YLTeLu6V84uQDjmQ=="],
@@ -1096,35 +1091,33 @@ · 350 unmodified lines
1096 1091
1097 "@t3tools/web": ["@t3tools/web@workspace:apps/web"],1092 "@t3tools/web": ["@t3tools/web@workspace:apps/web"],
1098 1093
1099 "@tailwindcss/node": ["@tailwindcss/node@4.1.12", "", { "dependencies": { "@jridgewell/remapping": "^2.3.4", "enhanced-resolve": "^5.18.3", "jiti": "^2.5.1", "lightningcss": "1.30.1", "magic-string": "^0.30.17", "source-map-js": "^1.2.1", "tailwindcss": "4.1.12" } }, "sha512-3hm9brwvQkZFe++SBt+oLjo4OLDtkvlE8q2WalaD/7QWaeM7KEJbAiY/LJZUaCs7Xa8aUu4xy3uoyX4q54UVdQ=="],1094 "@tailwindcss/node": ["@tailwindcss/node@4.2.1", "", { "dependencies": { "@jridgewell/remapping": "^2.3.5", "enhanced-resolve": "^5.19.0", "jiti": "^2.6.1", "lightningcss": "1.31.1", "magic-string": "^0.30.21", "source-map-js": "^1.2.1", "tailwindcss": "4.2.1" } }, "sha512-jlx6sLk4EOwO6hHe1oCGm1Q4AN/s0rSrTTPBGPM0/RQ6Uylwq17FuU8IeJJKEjtc6K6O07zsvP+gDO6MMWo7pg=="],
1100 1095
1101 "@tailwindcss/oxide": ["@tailwindcss/oxide@4.1.12", "", { "dependencies": { "detect-libc": "^2.0.4", "tar": "^7.4.3" }, "optionalDependencies": { "@tailwindcss/oxide-android-arm64": "4.1.12", "@tailwindcss/oxide-darwin-arm64": "4.1.12", "@tailwindcss/oxide-darwin-x64": "4.1.12", "@tailwindcss/oxide-freebsd-x64": "4.1.12", "@tailwindcss/oxide-linux-arm-gnueabihf": "4.1.12", "@tailwindcss/oxide-linux-arm64-gnu": "4.1.12", "@tailwindcss/oxide-linux-arm64-musl": "4.1.12", "@tailwindcss/oxide-linux-x64-gnu": "4.1.12", "@tailwindcss/oxide-linux-x64-musl": "4.1.12", "@tailwindcss/oxide-wasm32-wasi": "4.1.12", "@tailwindcss/oxide-win32-arm64-msvc": "4.1.12", "@tailwindcss/oxide-win32-x64-msvc": "4.1.12" } }, "sha512-gM5EoKHW/ukmlEtphNwaGx45fGoEmP10v51t9unv55voWh6WrOL19hfuIdo2FjxIaZzw776/BUQg7Pck++cIVw=="],1096 "@tailwindcss/oxide": ["@tailwindcss/oxide@4.2.1", "", { "optionalDependencies": { "@tailwindcss/oxide-android-arm64": "4.2.1", "@tailwindcss/oxide-darwin-arm64": "4.2.1", "@tailwindcss/oxide-darwin-x64": "4.2.1", "@tailwindcss/oxide-freebsd-x64": "4.2.1", "@tailwindcss/oxide-linux-arm-gnueabihf": "4.2.1", "@tailwindcss/oxide-linux-arm64-gnu": "4.2.1", "@tailwindcss/oxide-linux-arm64-musl": "4.2.1", "@tailwindcss/oxide-linux-x64-gnu": "4.2.1", "@tailwindcss/oxide-linux-x64-musl": "4.2.1", "@tailwindcss/oxide-wasm32-wasi": "4.2.1", "@tailwindcss/oxide-win32-arm64-msvc": "4.2.1", "@tailwindcss/oxide-win32-x64-msvc": "4.2.1" } }, "sha512-yv9jeEFWnjKCI6/T3Oq50yQEOqmpmpfzG1hcZsAOaXFQPfzWprWrlHSdGPEF3WQTi8zu8ohC9Mh9J470nT5pUw=="],
1102 1097
1103 "@tailwindcss/oxide-android-arm64": ["@tailwindcss/oxide-android-arm64@4.1.12", "", { "os": "android", "cpu": "arm64" }, "sha512-oNY5pq+1gc4T6QVTsZKwZaGpBb2N1H1fsc1GD4o7yinFySqIuRZ2E4NvGasWc6PhYJwGK2+5YT1f9Tp80zUQZQ=="],1098 "@tailwindcss/oxide-android-arm64": ["@tailwindcss/oxide-android-arm64@4.2.1", "", { "os": "android", "cpu": "arm64" }, "sha512-eZ7G1Zm5EC8OOKaesIKuw77jw++QJ2lL9N+dDpdQiAB/c/B2wDh0QPFHbkBVrXnwNugvrbJFk1gK2SsVjwWReg=="],
1104 1099
1105 "@tailwindcss/oxide-darwin-arm64": ["@tailwindcss/oxide-darwin-arm64@4.1.12", "", { "os": "darwin", "cpu": "arm64" }, "sha512-cq1qmq2HEtDV9HvZlTtrj671mCdGB93bVY6J29mwCyaMYCP/JaUBXxrQQQm7Qn33AXXASPUb2HFZlWiiHWFytw=="],1100 "@tailwindcss/oxide-darwin-arm64": ["@tailwindcss/oxide-darwin-arm64@4.2.1", "", { "os": "darwin", "cpu": "arm64" }, "sha512-q/LHkOstoJ7pI1J0q6djesLzRvQSIfEto148ppAd+BVQK0JYjQIFSK3JgYZJa+Yzi0DDa52ZsQx2rqytBnf8Hw=="],
1106 1101
1107 "@tailwindcss/oxide-darwin-x64": ["@tailwindcss/oxide-darwin-x64@4.1.12", "", { "os": "darwin", "cpu": "x64" }, "sha512-6UCsIeFUcBfpangqlXay9Ffty9XhFH1QuUFn0WV83W8lGdX8cD5/+2ONLluALJD5+yJ7k8mVtwy3zMZmzEfbLg=="],1102 "@tailwindcss/oxide-darwin-x64": ["@tailwindcss/oxide-darwin-x64@4.2.1", "", { "os": "darwin", "cpu": "x64" }, "sha512-/f/ozlaXGY6QLbpvd/kFTro2l18f7dHKpB+ieXz+Cijl4Mt9AI2rTrpq7V+t04nK+j9XBQHnSMdeQRhbGyt6fw=="],
1108 1103
1109 "@tailwindcss/oxide-freebsd-x64": ["@tailwindcss/oxide-freebsd-x64@4.1.12", "", { "os": "freebsd", "cpu": "x64" }, "sha512-JOH/f7j6+nYXIrHobRYCtoArJdMJh5zy5lr0FV0Qu47MID/vqJAY3r/OElPzx1C/wdT1uS7cPq+xdYYelny1ww=="],1104 "@tailwindcss/oxide-freebsd-x64": ["@tailwindcss/oxide-freebsd-x64@4.2.1", "", { "os": "freebsd", "cpu": "x64" }, "sha512-5e/AkgYJT/cpbkys/OU2Ei2jdETCLlifwm7ogMC7/hksI2fC3iiq6OcXwjibcIjPung0kRtR3TxEITkqgn0TcA=="],
1110 1105
1111 "@tailwindcss/oxide-linux-arm-gnueabihf": ["@tailwindcss/oxide-linux-arm-gnueabihf@4.1.12", "", { "os": "linux", "cpu": "arm" }, "sha512-v4Ghvi9AU1SYgGr3/j38PD8PEe6bRfTnNSUE3YCMIRrrNigCFtHZ2TCm8142X8fcSqHBZBceDx+JlFJEfNg5zQ=="],1106 "@tailwindcss/oxide-linux-arm-gnueabihf": ["@tailwindcss/oxide-linux-arm-gnueabihf@4.2.1", "", { "os": "linux", "cpu": "arm" }, "sha512-Uny1EcVTTmerCKt/1ZuKTkb0x8ZaiuYucg2/kImO5A5Y/kBz41/+j0gxUZl+hTF3xkWpDmHX+TaWhOtba2Fyuw=="],
1112 1107
1113 "@tailwindcss/oxide-linux-arm64-gnu": ["@tailwindcss/oxide-linux-arm64-gnu@4.1.12", "", { "os": "linux", "cpu": "arm64" }, "sha512-YP5s1LmetL9UsvVAKusHSyPlzSRqYyRB0f+Kl/xcYQSPLEw/BvGfxzbH+ihUciePDjiXwHh+p+qbSP3SlJw+6g=="],1108 "@tailwindcss/oxide-linux-arm64-gnu": ["@tailwindcss/oxide-linux-arm64-gnu@4.2.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-CTrwomI+c7n6aSSQlsPL0roRiNMDQ/YzMD9EjcR+H4f0I1SQ8QqIuPnsVp7QgMkC1Qi8rtkekLkOFjo7OlEFRQ=="],
1114 1109
1115 "@tailwindcss/oxide-linux-arm64-musl": ["@tailwindcss/oxide-linux-arm64-musl@4.1.12", "", { "os": "linux", "cpu": "arm64" }, "sha512-V8pAM3s8gsrXcCv6kCHSuwyb/gPsd863iT+v1PGXC4fSL/OJqsKhfK//v8P+w9ThKIoqNbEnsZqNy+WDnwQqCA=="],1110 "@tailwindcss/oxide-linux-arm64-musl": ["@tailwindcss/oxide-linux-arm64-musl@4.2.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-WZA0CHRL/SP1TRbA5mp9htsppSEkWuQ4KsSUumYQnyl8ZdT39ntwqmz4IUHGN6p4XdSlYfJwM4rRzZLShHsGAQ=="],
1116 1111
1117 "@tailwindcss/oxide-linux-x64-gnu": ["@tailwindcss/oxide-linux-x64-gnu@4.1.12", "", { "os": "linux", "cpu": "x64" }, "sha512-xYfqYLjvm2UQ3TZggTGrwxjYaLB62b1Wiysw/YE3Yqbh86sOMoTn0feF98PonP7LtjsWOWcXEbGqDL7zv0uW8Q=="],1112 "@tailwindcss/oxide-linux-x64-gnu": ["@tailwindcss/oxide-linux-x64-gnu@4.2.1", "", { "os": "linux", "cpu": "x64" }, "sha512-qMFzxI2YlBOLW5PhblzuSWlWfwLHaneBE0xHzLrBgNtqN6mWfs+qYbhryGSXQjFYB1Dzf5w+LN5qbUTPhW7Y5g=="],
1118 1113
1119 "@tailwindcss/oxide-linux-x64-musl": ["@tailwindcss/oxide-linux-x64-musl@4.1.12", "", { "os": "linux", "cpu": "x64" }, "sha512-ha0pHPamN+fWZY7GCzz5rKunlv9L5R8kdh+YNvP5awe3LtuXb5nRi/H27GeL2U+TdhDOptU7T6Is7mdwh5Ar3A=="],1114 "@tailwindcss/oxide-linux-x64-musl": ["@tailwindcss/oxide-linux-x64-musl@4.2.1", "", { "os": "linux", "cpu": "x64" }, "sha512-5r1X2FKnCMUPlXTWRYpHdPYUY6a1Ar/t7P24OuiEdEOmms5lyqjDRvVY1yy9Rmioh+AunQ0rWiOTPE8F9A3v5g=="],
1120 1115
1121 "@tailwindcss/oxide-wasm32-wasi": ["@tailwindcss/oxide-wasm32-wasi@4.1.12", "", { "dependencies": { "@emnapi/core": "^1.4.5", "@emnapi/runtime": "^1.4.5", "@emnapi/wasi-threads": "^1.0.4", "@napi-rs/wasm-runtime": "^0.2.12", "@tybys/wasm-util": "^0.10.0", "tslib": "^2.8.0" }, "cpu": "none" }, "sha512-4tSyu3dW+ktzdEpuk6g49KdEangu3eCYoqPhWNsZgUhyegEda3M9rG0/j1GV/JjVVsj+lG7jWAyrTlLzd/WEBg=="],1116 "@tailwindcss/oxide-wasm32-wasi": ["@tailwindcss/oxide-wasm32-wasi@4.2.1", "", { "dependencies": { "@emnapi/core": "^1.8.1", "@emnapi/runtime": "^1.8.1", "@emnapi/wasi-threads": "^1.1.0", "@napi-rs/wasm-runtime": "^1.1.1", "@tybys/wasm-util": "^0.10.1", "tslib": "^2.8.1" }, "cpu": "none" }, "sha512-MGFB5cVPvshR85MTJkEvqDUnuNoysrsRxd6vnk1Lf2tbiqNlXpHYZqkqOQalydienEWOHHFyyuTSYRsLfxFJ2Q=="],
1122 1117
1123 "@tailwindcss/oxide-win32-arm64-msvc": ["@tailwindcss/oxide-win32-arm64-msvc@4.1.12", "", { "os": "win32", "cpu": "arm64" }, "sha512-iGLyD/cVP724+FGtMWslhcFyg4xyYyM+5F4hGvKA7eifPkXHRAUDFaimu53fpNg9X8dfP75pXx/zFt/jlNF+lg=="],1118 "@tailwindcss/oxide-win32-arm64-msvc": ["@tailwindcss/oxide-win32-arm64-msvc@4.2.1", "", { "os": "win32", "cpu": "arm64" }, "sha512-YlUEHRHBGnCMh4Nj4GnqQyBtsshUPdiNroZj8VPkvTZSoHsilRCwXcVKnG9kyi0ZFAS/3u+qKHBdDc81SADTRA=="],
1124 1119
1125 "@tailwindcss/oxide-win32-x64-msvc": ["@tailwindcss/oxide-win32-x64-msvc@4.1.12", "", { "os": "win32", "cpu": "x64" }, "sha512-NKIh5rzw6CpEodv/++r0hGLlfgT/gFN+5WNdZtvh6wpU2BpGNgdjvj6H2oFc8nCM839QM1YOhjpgbAONUb4IxA=="],1120 "@tailwindcss/oxide-win32-x64-msvc": ["@tailwindcss/oxide-win32-x64-msvc@4.2.1", "", { "os": "win32", "cpu": "x64" }, "sha512-rbO34G5sMWWyrN/idLeVxAZgAKWrn5LiR3/I90Q9MkA67s6T1oB0xtTe+0heoBvHSpbU9Mk7i6uwJnpo4u21XQ=="],
1126
1127 "@tailwindcss/postcss": ["@tailwindcss/postcss@4.1.12", "", { "dependencies": { "@alloc/quick-lru": "^5.2.0", "@tailwindcss/node": "4.1.12", "@tailwindcss/oxide": "4.1.12", "postcss": "^8.4.41", "tailwindcss": "4.1.12" } }, "sha512-5PpLYhCAwf9SJEeIsSmCDLgyVfdBhdBpzX1OJ87anT9IVR0Z9pjM0FNixCAUAHGnMBGB8K99SwAheXrT0Kh6QQ=="],
1128 1121
1129 "@tailwindcss/vite": ["@tailwindcss/vite@4.2.2", "", { "dependencies": { "@tailwindcss/node": "4.2.2", "@tailwindcss/oxide": "4.2.2", "tailwindcss": "4.2.2" }, "peerDependencies": { "vite": "^5.2.0 || ^6 || ^7 || ^8" } }, "sha512-mEiF5HO1QqCLXoNEfXVA1Tzo+cYsrqV7w9Juj2wdUFyW07JRenqMG225MvPwr3ZD9N1bFQj46X7r33iHxLUW0w=="],1122 "@tailwindcss/vite": ["@tailwindcss/vite@4.2.2", "", { "dependencies": { "@tailwindcss/node": "4.2.2", "@tailwindcss/oxide": "4.2.2", "tailwindcss": "4.2.2" }, "peerDependencies": { "vite": "^5.2.0 || ^6 || ^7 || ^8" } }, "sha512-mEiF5HO1QqCLXoNEfXVA1Tzo+cYsrqV7w9Juj2wdUFyW07JRenqMG225MvPwr3ZD9N1bFQj46X7r33iHxLUW0w=="],
1130 1123
@@ -1320,8 +1313,6 @@ · 189 unmodified lines
1320 1313
1321 "array-iterate": ["array-iterate@2.0.1", "", {}, "sha512-I1jXZMjAgCMmxT4qxXfPXa6SthSoE8h6gkSI9BGGNv8mP8G/v0blc+qFnZu6K42vTOiuME596QaLO0TP3Lk0xg=="],1314 "array-iterate": ["array-iterate@2.0.1", "", {}, "sha512-I1jXZMjAgCMmxT4qxXfPXa6SthSoE8h6gkSI9BGGNv8mP8G/v0blc+qFnZu6K42vTOiuME596QaLO0TP3Lk0xg=="],
1322 1315
1323 "array-timsort": ["array-timsort@1.0.3", "", {}, "sha512-/+3GRL7dDAGEfM6TseQk/U+mi18TU2Ms9I3UlLdUMhz2hbvGNTKdj9xniwXfUqgYhHxRx0+8UnKkvlNwVU+cWQ=="],
1324
1325 "asap": ["asap@2.0.6", "", {}, "sha512-BSHWgDSAiKs50o2Re8ppvp3seVHXSRM44cdSsT9FfNEUUZLOGWVCsiWaRPWM1Znn+mqZ1OfVZ3z3DWEzSp7hRA=="],1316 "asap": ["asap@2.0.6", "", {}, "sha512-BSHWgDSAiKs50o2Re8ppvp3seVHXSRM44cdSsT9FfNEUUZLOGWVCsiWaRPWM1Znn+mqZ1OfVZ3z3DWEzSp7hRA=="],
1326 1317
1327 "assertion-error": ["assertion-error@2.0.1", "", {}, "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA=="],1318 "assertion-error": ["assertion-error@2.0.1", "", {}, "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA=="],
@@ -1440,8 +1431,6 @@ · 112 unmodified lines
1440 1431
1441 "chokidar": ["chokidar@4.0.3", "", { "dependencies": { "readdirp": "^4.0.1" } }, "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA=="],1432 "chokidar": ["chokidar@4.0.3", "", { "dependencies": { "readdirp": "^4.0.1" } }, "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA=="],
1442 1433
1443 "chownr": ["chownr@3.0.0", "", {}, "sha512-+IxzY9BZOQd/XuYPRmrvEVjF/nqj5kgT4kEq7VofrDoM1MxoRjEWkrCC3EtLi59TVawxTAn+orJwFQcrqEN1+g=="],
1444
1445 "chrome-launcher": ["chrome-launcher@0.15.2", "", { "dependencies": { "@types/node": "*", "escape-string-regexp": "^4.0.0", "is-wsl": "^2.2.0", "lighthouse-logger": "^1.0.0" }, "bin": { "print-chrome-path": "bin/print-chrome-path.js" } }, "sha512-zdLEwNo3aUVzIhKhTtXfxhdvZhUghrnmkvcAq2NoDd+LeOHKf03H5jwZ8T/STsAlzyALkBVK552iaG1fGf1xVQ=="],1434 "chrome-launcher": ["chrome-launcher@0.15.2", "", { "dependencies": { "@types/node": "*", "escape-string-regexp": "^4.0.0", "is-wsl": "^2.2.0", "lighthouse-logger": "^1.0.0" }, "bin": { "print-chrome-path": "bin/print-chrome-path.js" } }, "sha512-zdLEwNo3aUVzIhKhTtXfxhdvZhUghrnmkvcAq2NoDd+LeOHKf03H5jwZ8T/STsAlzyALkBVK552iaG1fGf1xVQ=="],
1446 1435
1447 "chromium-edge-launcher": ["chromium-edge-launcher@0.2.0", "", { "dependencies": { "@types/node": "*", "escape-string-regexp": "^4.0.0", "is-wsl": "^2.2.0", "lighthouse-logger": "^1.0.0", "mkdirp": "^1.0.4", "rimraf": "^3.0.2" } }, "sha512-JfJjUnq25y9yg4FABRRVPmBGWPZZi+AQXT4mxupb67766/0UlhG8PAZCz6xzEMXTbW3CsSoE8PcCWA49n35mKg=="],1436 "chromium-edge-launcher": ["chromium-edge-launcher@0.2.0", "", { "dependencies": { "@types/node": "*", "escape-string-regexp": "^4.0.0", "is-wsl": "^2.2.0", "lighthouse-logger": "^1.0.0", "mkdirp": "^1.0.4", "rimraf": "^3.0.2" } }, "sha512-JfJjUnq25y9yg4FABRRVPmBGWPZZi+AQXT4mxupb67766/0UlhG8PAZCz6xzEMXTbW3CsSoE8PcCWA49n35mKg=="],
@@ -1476,14 +1465,10 @@ · 28 unmodified lines
1476 1465
1477 "color-string": ["color-string@1.9.1", "", { "dependencies": { "color-name": "^1.0.0", "simple-swizzle": "^0.2.2" } }, "sha512-shrVawQFojnZv6xM40anx4CkoDP+fZsw/ZerEMsW/pyzsRbElpsL/DBVW7q3ExxwusdNXI3lXpuhEZkzs8p5Eg=="],1466 "color-string": ["color-string@1.9.1", "", { "dependencies": { "color-name": "^1.0.0", "simple-swizzle": "^0.2.2" } }, "sha512-shrVawQFojnZv6xM40anx4CkoDP+fZsw/ZerEMsW/pyzsRbElpsL/DBVW7q3ExxwusdNXI3lXpuhEZkzs8p5Eg=="],
1478 1467
1479 "colorjs.io": ["colorjs.io@0.6.0-alpha.1", "", {}, "sha512-c/h/8uAmPydQcriRdX8UTAFHj6SpSHFHBA8LvMikvYWAVApPTwg/pyOXNsGmaCBd6L/EeDlRHSNhTtnIFp/qsg=="],
1480
1481 "comma-separated-tokens": ["comma-separated-tokens@2.0.3", "", {}, "sha512-Fu4hJdvzeylCfQPp9SGWidpzrMs7tTrlu6Vb8XGaRGck8QSNZJJp538Wrb60Lax4fPwR64ViY468OIUTbRlGZg=="],1468 "comma-separated-tokens": ["comma-separated-tokens@2.0.3", "", {}, "sha512-Fu4hJdvzeylCfQPp9SGWidpzrMs7tTrlu6Vb8XGaRGck8QSNZJJp538Wrb60Lax4fPwR64ViY468OIUTbRlGZg=="],
1482 1469
1483 "commander": ["commander@12.1.0", "", {}, "sha512-Vw8qHK3bZM9y/P10u3Vib8o/DdkvA2OtPtZvD871QKjy74Wj1WSKFILMPRPSdUSx5RFK1arlJzEtA4PkFgnbuA=="],1470 "commander": ["commander@12.1.0", "", {}, "sha512-Vw8qHK3bZM9y/P10u3Vib8o/DdkvA2OtPtZvD871QKjy74Wj1WSKFILMPRPSdUSx5RFK1arlJzEtA4PkFgnbuA=="],
1484 1471
1485 "comment-json": ["comment-json@4.6.2", "", { "dependencies": { "array-timsort": "^1.0.3", "esprima": "^4.0.1" } }, "sha512-R2rze/hDX30uul4NZoIZ76ImSJLFxn/1/ZxtKC1L77y2X1k+yYu1joKbAtMA2Fg3hZrTOiw0I5mwVMo0cf250w=="],
1486
1487 "common-ancestor-path": ["common-ancestor-path@2.0.0", "", {}, "sha512-dnN3ibLeoRf2HNC+OlCiNc5d2zxbLJXOtiZUudNFSXZrNSydxcCsSpRzXwfu7BBWCIfHPw+xTayeBvJCP/D8Ng=="],1472 "common-ancestor-path": ["common-ancestor-path@2.0.0", "", {}, "sha512-dnN3ibLeoRf2HNC+OlCiNc5d2zxbLJXOtiZUudNFSXZrNSydxcCsSpRzXwfu7BBWCIfHPw+xTayeBvJCP/D8Ng=="],
1488 1473
1489 "compressible": ["compressible@2.0.18", "", { "dependencies": { "mime-db": ">= 1.43.0 < 2" } }, "sha512-AF3r7P5dWxL8MxyITRMlORQNaOA2IkAFaTr4k7BUumjPtRpGDTZpl0Pb1XCO6JeDCBdp126Cgs9sMxqSjgYyRg=="],1474 "compressible": ["compressible@2.0.18", "", { "dependencies": { "mime-db": ">= 1.43.0 < 2" } }, "sha512-AF3r7P5dWxL8MxyITRMlORQNaOA2IkAFaTr4k7BUumjPtRpGDTZpl0Pb1XCO6JeDCBdp126Cgs9sMxqSjgYyRg=="],
@@ -1528,6 +1513,8 @@ · 38 unmodified lines
1528 1513
1529 "csstype": ["csstype@3.2.3", "", {}, "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ=="],1514 "csstype": ["csstype@3.2.3", "", {}, "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ=="],
1530 1515
1516 "culori": ["culori@4.0.2", "", {}, "sha512-1+BhOB8ahCn4O0cep0Sh2l9KCOfOdY+BXJnKMHFFzDEouSr/el18QwXEMRlOj9UY5nCeA8UN3a/82rUWRBeyBw=="],
1517
1531 "date-fns": ["date-fns@4.1.0", "", {}, "sha512-Ukq0owbQXxa/U3EGtsdVBkR1w7KOQ5gIBqdH2hkvknzZPYvBxb/aa6E8L7tmjFtkwZBu3UXBbjIgPo/Ez4xaNg=="],1518 "date-fns": ["date-fns@4.1.0", "", {}, "sha512-Ukq0owbQXxa/U3EGtsdVBkR1w7KOQ5gIBqdH2hkvknzZPYvBxb/aa6E8L7tmjFtkwZBu3UXBbjIgPo/Ez4xaNg=="],
1532 1519
1533 "debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="],1520 "debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="],
@@ -2240,8 +2227,6 @@ · 706 unmodified lines
2240 2227
2241 "minipass": ["minipass@7.1.3", "", {}, "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A=="],2228 "minipass": ["minipass@7.1.3", "", {}, "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A=="],
2242 2229
2243 "minizlib": ["minizlib@3.1.0", "", { "dependencies": { "minipass": "^7.1.2" } }, "sha512-KZxYo1BUkWD2TVFLr0MQoM8vUUigWD3LlD83a/75BqC+4qE0Hb1Vo5v1FgcfaNXvfXzr+5EhQ6ing/CaBijTlw=="],
2244
2245 "mkdirp": ["mkdirp@1.0.4", "", { "bin": { "mkdirp": "bin/cmd.js" } }, "sha512-vVqVZQyf3WLx2Shd0qJ9xuvqgAyKPLAiqITEtqW0oIUjzo3PePDd6fW9iFz30ef7Ysp/oiWqbhszeGWW2T6Gzw=="],2230 "mkdirp": ["mkdirp@1.0.4", "", { "bin": { "mkdirp": "bin/cmd.js" } }, "sha512-vVqVZQyf3WLx2Shd0qJ9xuvqgAyKPLAiqITEtqW0oIUjzo3PePDd6fW9iFz30ef7Ysp/oiWqbhszeGWW2T6Gzw=="],
2246 2231
2247 "mrmime": ["mrmime@2.0.1", "", {}, "sha512-Y3wQdFg2Va6etvQ5I82yUhGdsKrcYox6p7FfL1LbK2J4V01F9TGlepTIhnK24t7koZibmg82KGglhA1XK5IsLQ=="],2232 "mrmime": ["mrmime@2.0.1", "", {}, "sha512-Y3wQdFg2Va6etvQ5I82yUhGdsKrcYox6p7FfL1LbK2J4V01F9TGlepTIhnK24t7koZibmg82KGglhA1XK5IsLQ=="],
@@ -2264,8 +2249,6 @@ · 16 unmodified lines
2264 2249
2265 "nanoid": ["nanoid@3.3.11", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w=="],2250 "nanoid": ["nanoid@3.3.11", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w=="],
2266 2251
2267 "nativewind": ["nativewind@5.0.0-preview.3", "", { "dependencies": { "tailwindcss-safe-area": "^1.1.0" }, "peerDependencies": { "react-native-css": "^3.0.1", "tailwindcss": ">4.1.11" } }, "sha512-cbl1GzzY55NL2IG35AaAVfrL4+bCh77sa39aST5/o7xy3TLPthAtzhNPstnrCn+DtIglTnXOlOJGFrX2WdhI6w=="],
2268
2269 "negotiator": ["negotiator@0.6.3", "", {}, "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg=="],2252 "negotiator": ["negotiator@0.6.3", "", {}, "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg=="],
2270 2253
2271 "neotraverse": ["neotraverse@0.6.18", "", {}, "sha512-Z4SmBUweYa09+o6pG+eASabEpP6QkQ70yHj351pQoEXIs8uHbaU2DWVmzBANKgflPa47A50PtB2+NgRpQvr7vA=="],2254 "neotraverse": ["neotraverse@0.6.18", "", {}, "sha512-Z4SmBUweYa09+o6pG+eASabEpP6QkQ70yHj351pQoEXIs8uHbaU2DWVmzBANKgflPa47A50PtB2+NgRpQvr7vA=="],
@@ -2458,8 +2441,6 @@ · 186 unmodified lines
2458 2441
2459 "react-native": ["react-native@0.83.4", "", { "dependencies": { "@jest/create-cache-key-function": "^29.7.0", "@react-native/assets-registry": "0.83.4", "@react-native/codegen": "0.83.4", "@react-native/community-cli-plugin": "0.83.4", "@react-native/gradle-plugin": "0.83.4", "@react-native/js-polyfills": "0.83.4", "@react-native/normalize-colors": "0.83.4", "@react-native/virtualized-lists": "0.83.4", "abort-controller": "^3.0.0", "anser": "^1.4.9", "ansi-regex": "^5.0.0", "babel-jest": "^29.7.0", "babel-plugin-syntax-hermes-parser": "0.32.0", "base64-js": "^1.5.1", "commander": "^12.0.0", "flow-enums-runtime": "^0.0.6", "glob": "^7.1.1", "hermes-compiler": "0.14.1", "invariant": "^2.2.4", "jest-environment-node": "^29.7.0", "memoize-one": "^5.0.0", "metro-runtime": "^0.83.3", "metro-source-map": "^0.83.3", "nullthrows": "^1.1.1", "pretty-format": "^29.7.0", "promise": "^8.3.0", "react-devtools-core": "^6.1.5", "react-refresh": "^0.14.0", "regenerator-runtime": "^0.13.2", "scheduler": "0.27.0", "semver": "^7.1.3", "stacktrace-parser": "^0.1.10", "whatwg-fetch": "^3.0.0", "ws": "^7.5.10", "yargs": "^17.6.2" }, "peerDependencies": { "@types/react": "^19.1.1", "react": "^19.2.0" }, "optionalPeers": ["@types/react"], "bin": { "react-native": "cli.js" } }, "sha512-H5Wco3UJyY6zZsjoBayY8RM9uiAEQ3FeG4G2NAt+lr9DO43QeqPlVe9xxxYEukMkEmeIhNjR70F6bhXuWArOMQ=="],2442 "react-native": ["react-native@0.83.4", "", { "dependencies": { "@jest/create-cache-key-function": "^29.7.0", "@react-native/assets-registry": "0.83.4", "@react-native/codegen": "0.83.4", "@react-native/community-cli-plugin": "0.83.4", "@react-native/gradle-plugin": "0.83.4", "@react-native/js-polyfills": "0.83.4", "@react-native/normalize-colors": "0.83.4", "@react-native/virtualized-lists": "0.83.4", "abort-controller": "^3.0.0", "anser": "^1.4.9", "ansi-regex": "^5.0.0", "babel-jest": "^29.7.0", "babel-plugin-syntax-hermes-parser": "0.32.0", "base64-js": "^1.5.1", "commander": "^12.0.0", "flow-enums-runtime": "^0.0.6", "glob": "^7.1.1", "hermes-compiler": "0.14.1", "invariant": "^2.2.4", "jest-environment-node": "^29.7.0", "memoize-one": "^5.0.0", "metro-runtime": "^0.83.3", "metro-source-map": "^0.83.3", "nullthrows": "^1.1.1", "pretty-format": "^29.7.0", "promise": "^8.3.0", "react-devtools-core": "^6.1.5", "react-refresh": "^0.14.0", "regenerator-runtime": "^0.13.2", "scheduler": "0.27.0", "semver": "^7.1.3", "stacktrace-parser": "^0.1.10", "whatwg-fetch": "^3.0.0", "ws": "^7.5.10", "yargs": "^17.6.2" }, "peerDependencies": { "@types/react": "^19.1.1", "react": "^19.2.0" }, "optionalPeers": ["@types/react"], "bin": { "react-native": "cli.js" } }, "sha512-H5Wco3UJyY6zZsjoBayY8RM9uiAEQ3FeG4G2NAt+lr9DO43QeqPlVe9xxxYEukMkEmeIhNjR70F6bhXuWArOMQ=="],
2460 2443
2461 "react-native-css": ["react-native-css@3.0.7", "", { "dependencies": { "@types/debug": "^4.1.12", "babel-plugin-react-compiler": "^19.1.0-rc.2", "colorjs.io": "0.6.0-alpha.1", "comment-json": "^4.2.5", "debug": "^4.4.1" }, "peerDependencies": { "@expo/metro-config": ">=54", "lightningcss": ">=1.27.0", "react": ">=19", "react-native": ">=0.81" } }, "sha512-xsU9T5MleHI/DDmY72A7ZikbkDfDNkMrAEichOJW2bBUMxPzAJPrFk9ys4cXYk4gLFaR11y23EccOJ+JT6Iusw=="],
2462
2463 "react-native-fit-image": ["react-native-fit-image@1.5.5", "", { "dependencies": { "prop-types": "^15.5.10" } }, "sha512-Wl3Vq2DQzxgsWKuW4USfck9zS7YzhvLNPpkwUUCF90bL32e1a0zOVQ3WsJILJOwzmPdHfzZmWasiiAUNBkhNkg=="],2444 "react-native-fit-image": ["react-native-fit-image@1.5.5", "", { "dependencies": { "prop-types": "^15.5.10" } }, "sha512-Wl3Vq2DQzxgsWKuW4USfck9zS7YzhvLNPpkwUUCF90bL32e1a0zOVQ3WsJILJOwzmPdHfzZmWasiiAUNBkhNkg=="],
2464 2445
2465 "react-native-gesture-handler": ["react-native-gesture-handler@2.30.1", "", { "dependencies": { "@egjs/hammerjs": "^2.0.17", "hoist-non-react-statics": "^3.3.0", "invariant": "^2.2.4" }, "peerDependencies": { "react": "*", "react-native": "*" } }, "sha512-xIUBDo5ktmJs++0fZlavQNvDEE4PsihWhSeJsJtoz4Q6p0MiTM9TgrTgfEgzRR36qGPytFoeq+ShLrVwGdpUdA=="],2446 "react-native-gesture-handler": ["react-native-gesture-handler@2.30.1", "", { "dependencies": { "@egjs/hammerjs": "^2.0.17", "hoist-non-react-statics": "^3.3.0", "invariant": "^2.2.4" }, "peerDependencies": { "react": "*", "react-native": "*" } }, "sha512-xIUBDo5ktmJs++0fZlavQNvDEE4PsihWhSeJsJtoz4Q6p0MiTM9TgrTgfEgzRR36qGPytFoeq+ShLrVwGdpUdA=="],
@@ -2706,12 +2687,8 @@ · 240 unmodified lines
2706 2687
2707 "tailwindcss": ["tailwindcss@4.1.12", "", {}, "sha512-DzFtxOi+7NsFf7DBtI3BJsynR+0Yp6etH+nRPTbpWnS2pZBaSksv/JGctNwSWzbFjp0vxSqknaUylseZqMDGrA=="],2688 "tailwindcss": ["tailwindcss@4.1.12", "", {}, "sha512-DzFtxOi+7NsFf7DBtI3BJsynR+0Yp6etH+nRPTbpWnS2pZBaSksv/JGctNwSWzbFjp0vxSqknaUylseZqMDGrA=="],
2708 2689
2709 "tailwindcss-safe-area": ["tailwindcss-safe-area@1.3.0", "", { "peerDependencies": { "tailwindcss": "^4.0.0" } }, "sha512-RoxnW1zAjBWC3XK+row7Qj5toRMRlKNN/p3FLXb6fTGKxDGWT6JP/mcNX1yf09xRficQ308hbwiedgniepSp1Q=="],
2710
2711 "tapable": ["tapable@2.3.2", "", {}, "sha512-1MOpMXuhGzGL5TTCZFItxCc0AARf1EZFQkGqMm7ERKj8+Hgr5oLvJOVFcC+lRmR8hCe2S3jC4T5D7Vg/d7/fhA=="],2690 "tapable": ["tapable@2.3.2", "", {}, "sha512-1MOpMXuhGzGL5TTCZFItxCc0AARf1EZFQkGqMm7ERKj8+Hgr5oLvJOVFcC+lRmR8hCe2S3jC4T5D7Vg/d7/fhA=="],
2712 2691
2713 "tar": ["tar@7.5.13", "", { "dependencies": { "@isaacs/fs-minipass": "^4.0.0", "chownr": "^3.0.0", "minipass": "^7.1.2", "minizlib": "^3.1.0", "yallist": "^5.0.0" } }, "sha512-tOG/7GyXpFevhXVh8jOPJrmtRpOTsYqUIkVdVooZYJS/z8WhfQUX8RJILmeuJNinGAMSu1veBr4asSHFt5/hng=="],
2714
2715 "terminal-link": ["terminal-link@2.1.1", "", { "dependencies": { "ansi-escapes": "^4.2.1", "supports-hyperlinks": "^2.0.0" } }, "sha512-un0FmiRUQNr5PJqy9kP7c40F5BOfpGlYTrxonDChEZB7pzZxRNp/bt+ymiy9/npwXya9KH99nJ/GXFIiUkYGFQ=="],2692 "terminal-link": ["terminal-link@2.1.1", "", { "dependencies": { "ansi-escapes": "^4.2.1", "supports-hyperlinks": "^2.0.0" } }, "sha512-un0FmiRUQNr5PJqy9kP7c40F5BOfpGlYTrxonDChEZB7pzZxRNp/bt+ymiy9/npwXya9KH99nJ/GXFIiUkYGFQ=="],
2716 2693
2717 "terser": ["terser@5.46.1", "", { "dependencies": { "@jridgewell/source-map": "^0.3.3", "acorn": "^8.15.0", "commander": "^2.20.0", "source-map-support": "~0.5.20" }, "bin": { "terser": "bin/terser" } }, "sha512-vzCjQO/rgUuK9sf8VJZvjqiqiHFaZLnOiimmUuOKODxWL8mm/xua7viT7aqX7dgPY60otQjUotzFMmCB4VdmqQ=="],2694 "terser": ["terser@5.46.1", "", { "dependencies": { "@jridgewell/source-map": "^0.3.3", "acorn": "^8.15.0", "commander": "^2.20.0", "source-map-support": "~0.5.20" }, "bin": { "terser": "bin/terser" } }, "sha512-vzCjQO/rgUuK9sf8VJZvjqiqiHFaZLnOiimmUuOKODxWL8mm/xua7viT7aqX7dgPY60otQjUotzFMmCB4VdmqQ=="],
@@ -2830,6 +2807,8 @@ · 112 unmodified lines
2830 2807
2831 "universalify": ["universalify@2.0.1", "", {}, "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw=="],2808 "universalify": ["universalify@2.0.1", "", {}, "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw=="],
2832 2809
2810 "uniwind": ["uniwind@1.6.2", "", { "dependencies": { "@tailwindcss/node": "4.2.1", "@tailwindcss/oxide": "4.2.1", "culori": "4.0.2", "lightningcss": "1.30.1" }, "peerDependencies": { "react": ">=19.0.0", "react-native": ">=0.81.0", "tailwindcss": ">=4" } }, "sha512-skT0I/PFGajygQPGJ20sYbqc3HxmcOMkfQWy51Ha6eXXJWRJh6vYiy7jUJvLFwdzWlfX1g+Z8oC/SpxgRB5R+g=="],
2811
2833 "unpipe": ["unpipe@1.0.0", "", {}, "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ=="],2812 "unpipe": ["unpipe@1.0.0", "", {}, "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ=="],
2834 2813
2835 "unplugin": ["unplugin@2.3.11", "", { "dependencies": { "@jridgewell/remapping": "^2.3.5", "acorn": "^8.15.0", "picomatch": "^4.0.3", "webpack-virtual-modules": "^0.6.2" } }, "sha512-5uKD0nqiYVzlmCRs01Fhs2BdkEgBS3SAVP6ndrBsuK42iC2+JHyxM05Rm9G8+5mkmRtzMZGY8Ct5+mliZxU/Ww=="],2814 "unplugin": ["unplugin@2.3.11", "", { "dependencies": { "@jridgewell/remapping": "^2.3.5", "acorn": "^8.15.0", "picomatch": "^4.0.3", "webpack-virtual-modules": "^0.6.2" } }, "sha512-5uKD0nqiYVzlmCRs01Fhs2BdkEgBS3SAVP6ndrBsuK42iC2+JHyxM05Rm9G8+5mkmRtzMZGY8Ct5+mliZxU/Ww=="],
@@ -2950,7 +2929,7 @@ · 114 unmodified lines
2950 2929
2951 "y18n": ["y18n@5.0.8", "", {}, "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA=="],2930 "y18n": ["y18n@5.0.8", "", {}, "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA=="],
2952 2931
2953 "yallist": ["yallist@5.0.0", "", {}, "sha512-YgvUTfwqyc7UXVMrB+SImsVYSmTS8X/tSrtdNZMImM+n7+QTriRXyXim0mBrTXNeqzVF0KWGgHPeiyViFFrNDw=="],2932 "yallist": ["yallist@3.1.1", "", {}, "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g=="],
2954 2933
2955 "yaml": ["yaml@2.8.3", "", { "bin": { "yaml": "bin.mjs" } }, "sha512-AvbaCLOO2Otw/lW5bmh9d/WEdcDFdQp2Z2ZUH3pX9U2ihyUY0nvLv7J6TrWowklRGPYbB/IuIMfYgxaCPg5Bpg=="],2934 "yaml": ["yaml@2.8.3", "", { "bin": { "yaml": "bin.mjs" } }, "sha512-AvbaCLOO2Otw/lW5bmh9d/WEdcDFdQp2Z2ZUH3pX9U2ihyUY0nvLv7J6TrWowklRGPYbB/IuIMfYgxaCPg5Bpg=="],
2956 2935
@@ -3056,13 +3035,15 @@ · 99 unmodified lines
3056 3035
3057 "@shikijs/transformers/@shikijs/types": ["@shikijs/types@3.23.0", "", { "dependencies": { "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-3JZ5HXOZfYjsYSk0yPwBrkupyYSLpAE26Qc0HLghhZNGTZg/SKxXIIgoxOpmmeQP0RRSDJTk1/vPfw9tbw+jSQ=="],3036 "@shikijs/transformers/@shikijs/types": ["@shikijs/types@3.23.0", "", { "dependencies": { "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-3JZ5HXOZfYjsYSk0yPwBrkupyYSLpAE26Qc0HLghhZNGTZg/SKxXIIgoxOpmmeQP0RRSDJTk1/vPfw9tbw+jSQ=="],
3058 3037
3038 "@tailwindcss/node/tailwindcss": ["tailwindcss@4.2.1", "", {}, "sha512-/tBrSQ36vCleJkAOsy9kbNTgaxvGbyOamC30PRePTQe/o1MFwEKHQk4Cn7BNGaPtjp+PuUrByJehM1hgxfq4sw=="],
3039
3059 "@tailwindcss/oxide-wasm32-wasi/@emnapi/core": ["@emnapi/core@1.9.2", "", { "dependencies": { "@emnapi/wasi-threads": "1.2.1", "tslib": "^2.4.0" }, "bundled": true }, "sha512-UC+ZhH3XtczQYfOlu3lNEkdW/p4dsJ1r/bP7H8+rhao3TTTMO1ATq/4DdIi23XuGoFY+Cz0JmCbdVl0hz9jZcA=="],3040 "@tailwindcss/oxide-wasm32-wasi/@emnapi/core": ["@emnapi/core@1.9.2", "", { "dependencies": { "@emnapi/wasi-threads": "1.2.1", "tslib": "^2.4.0" }, "bundled": true }, "sha512-UC+ZhH3XtczQYfOlu3lNEkdW/p4dsJ1r/bP7H8+rhao3TTTMO1ATq/4DdIi23XuGoFY+Cz0JmCbdVl0hz9jZcA=="],
3060 3041
3061 "@tailwindcss/oxide-wasm32-wasi/@emnapi/runtime": ["@emnapi/runtime@1.9.2", "", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-3U4+MIWHImeyu1wnmVygh5WlgfYDtyf0k8AbLhMFxOipihf6nrWC4syIm/SwEeec0mNSafiiNnMJwbza/Is6Lw=="],3042 "@tailwindcss/oxide-wasm32-wasi/@emnapi/runtime": ["@emnapi/runtime@1.9.2", "", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-3U4+MIWHImeyu1wnmVygh5WlgfYDtyf0k8AbLhMFxOipihf6nrWC4syIm/SwEeec0mNSafiiNnMJwbza/Is6Lw=="],
3062 3043
3063 "@tailwindcss/oxide-wasm32-wasi/@emnapi/wasi-threads": ["@emnapi/wasi-threads@1.2.1", "", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w=="],3044 "@tailwindcss/oxide-wasm32-wasi/@emnapi/wasi-threads": ["@emnapi/wasi-threads@1.2.1", "", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w=="],
3064 3045
3065 "@tailwindcss/oxide-wasm32-wasi/@napi-rs/wasm-runtime": ["@napi-rs/wasm-runtime@0.2.12", "", { "dependencies": { "@emnapi/core": "^1.4.3", "@emnapi/runtime": "^1.4.3", "@tybys/wasm-util": "^0.10.0" }, "bundled": true }, "sha512-ZVWUcfwY4E/yPitQJl481FjFo3K22D6qF0DuFH6Y/nbnE11GY5uguDxZMGXPQ8WQ0128MXQD7TnfHyK4oWoIJQ=="],3046 "@tailwindcss/oxide-wasm32-wasi/@napi-rs/wasm-runtime": ["@napi-rs/wasm-runtime@1.1.3", "", { "dependencies": { "@tybys/wasm-util": "^0.10.1" }, "peerDependencies": { "@emnapi/core": "^1.7.1", "@emnapi/runtime": "^1.7.1" }, "bundled": true }, "sha512-xK9sGVbJWYb08+mTJt3/YV24WxvxpXcXtP6B172paPZ+Ts69Re9dAr7lKwJoeIx8OoeuimEiRZ7umkiUVClmmQ=="],
3066 3047
3067 "@tailwindcss/oxide-wasm32-wasi/@tybys/wasm-util": ["@tybys/wasm-util@0.10.1", "", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg=="],3048 "@tailwindcss/oxide-wasm32-wasi/@tybys/wasm-util": ["@tybys/wasm-util@0.10.1", "", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg=="],
3068 3049
@@ -3212,8 +3193,6 @@ · 143 unmodified lines
3212 3193
3213 "react-native/glob": ["glob@7.2.3", "", { "dependencies": { "fs.realpath": "^1.0.0", "inflight": "^1.0.4", "inherits": "2", "minimatch": "^3.1.1", "once": "^1.3.0", "path-is-absolute": "^1.0.0" } }, "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q=="],3194 "react-native/glob": ["glob@7.2.3", "", { "dependencies": { "fs.realpath": "^1.0.0", "inflight": "^1.0.4", "inherits": "2", "minimatch": "^3.1.1", "once": "^1.3.0", "path-is-absolute": "^1.0.0" } }, "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q=="],
3214 3195
3215 "react-native-css/babel-plugin-react-compiler": ["babel-plugin-react-compiler@19.1.0-rc.1-rc-af1b7da-20250421", "", { "dependencies": { "@babel/types": "^7.26.0" } }, "sha512-E3kaokBhWDLf7ZD8fuYjYn0ZJHYZ+3EHtAWCdX2hl4lpu1z9S/Xr99sxhx2bTCVB41oIesz9FtM8f4INsrZaOw=="],
3216
3217 "react-native-reanimated/react-native-is-edge-to-edge": ["react-native-is-edge-to-edge@1.2.1", "", { "peerDependencies": { "react": "*", "react-native": "*" } }, "sha512-FLbPWl/MyYQWz+KwqOZsSyj2JmLKglHatd3xLZWskXOpRaio4LfEDEz8E/A6uD8QoTHW6Aobw1jbEwK7KMgR7Q=="],3196 "react-native-reanimated/react-native-is-edge-to-edge": ["react-native-is-edge-to-edge@1.2.1", "", { "peerDependencies": { "react": "*", "react-native": "*" } }, "sha512-FLbPWl/MyYQWz+KwqOZsSyj2JmLKglHatd3xLZWskXOpRaio4LfEDEz8E/A6uD8QoTHW6Aobw1jbEwK7KMgR7Q=="],
3218 3197
3219 "react-native-reanimated/semver": ["semver@7.7.3", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q=="],3198 "react-native-reanimated/semver": ["semver@7.7.3", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q=="],
@@ -3292,8 +3271,6 @@ · 72 unmodified lines
3292 3271
3293 "yargs/yargs-parser": ["yargs-parser@21.1.1", "", {}, "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw=="],3272 "yargs/yargs-parser": ["yargs-parser@21.1.1", "", {}, "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw=="],
3294 3273
3295 "@babel/helper-compilation-targets/lru-cache/yallist": ["yallist@3.1.1", "", {}, "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g=="],
3296
3297 "@electron/get/fs-extra/jsonfile": ["jsonfile@4.0.0", "", { "optionalDependencies": { "graceful-fs": "^4.1.6" } }, "sha512-m6F1R3z8jjlf2imQHS2Qez5sjKWQzbuuhuJ/FKYFRZvPE3PuHcSMVZzfsLhGVOkfd20obL5SWEBew5ShlquNxg=="],3274 "@electron/get/fs-extra/jsonfile": ["jsonfile@4.0.0", "", { "optionalDependencies": { "graceful-fs": "^4.1.6" } }, "sha512-m6F1R3z8jjlf2imQHS2Qez5sjKWQzbuuhuJ/FKYFRZvPE3PuHcSMVZzfsLhGVOkfd20obL5SWEBew5ShlquNxg=="],
3298 3275
3299 "@electron/get/fs-extra/universalify": ["universalify@0.1.2", "", {}, "sha512-rBJeI5CXAlmy1pV+617WB9J63U6XcazHHF2f2dbJix4XzpUF0RS3Zbj0FGIOCAva5P/d/GBOYaACQ1w+0azUkg=="],3276 "@electron/get/fs-extra/universalify": ["universalify@0.1.2", "", {}, "sha512-rBJeI5CXAlmy1pV+617WB9J63U6XcazHHF2f2dbJix4XzpUF0RS3Zbj0FGIOCAva5P/d/GBOYaACQ1w+0azUkg=="],
packages/client-runtime/src/environmentConnection.ts+14−4
@@ -6,6 +6,10 @@ import type { · 5 unmodified lines
6 ServerLifecycleWelcomePayload,6 ServerLifecycleWelcomePayload,
7 TerminalEvent,7 TerminalEvent,
8} from "@t3tools/contracts";8} from "@t3tools/contracts";
9import * as Arr from "effect/Array";
10import * as Order from "effect/Order";
11import * as Option from "effect/Option";
12import { pipe } from "effect/Function";
9 13
10import type { KnownEnvironment } from "./knownEnvironment";14import type { KnownEnvironment } from "./knownEnvironment";
11import type { WsRpcClient } from "./wsRpcClient";15import type { WsRpcClient } from "./wsRpcClient";
@@ -137,14 +141,20 @@ function createOrchestrationRecoveryCoordinator() { · 125 unmodified lines
137 markEventBatchApplied<T extends Readonly<{ sequence: number }>>(141 markEventBatchApplied<T extends Readonly<{ sequence: number }>>(
138 events: ReadonlyArray<T>,142 events: ReadonlyArray<T>,
139 ): ReadonlyArray<T> {143 ): ReadonlyArray<T> {
140 const nextEvents = events144 const nextEvents = pipe(
141 .filter((event) => event.sequence > state.latestSequence)145 events,
142 .toSorted((left, right) => left.sequence - right.sequence);146 Arr.filter((event) => event.sequence > state.latestSequence),
147 Arr.sortWith((event) => event.sequence, Order.Number),
148 );
143 if (nextEvents.length === 0) {149 if (nextEvents.length === 0) {
144 return [];150 return [];
145 }151 }
146 152
147 const latestSequence = nextEvents.at(-1)?.sequence ?? state.latestSequence;153 const latestSequence = Arr.last(nextEvents).pipe(
154 Option.map((event) => event.sequence),
155 Option.getOrElse(() => state.latestSequence),
156 );
157
148 state = {158 state = {
149 ...state,159 ...state,
150 latestSequence,160 latestSequence,
packages/client-runtime/src/gitActionState.test.ts+264−0
@@ -0,0 +1,264 @@
1import type {
2 GitActionProgressEvent,
3 GitCreateBranchResult,
4 GitCreateWorktreeResult,
5 GitCheckoutResult,
6 GitPullResult,
7 GitRunStackedActionResult,
8 GitStatusResult,
9} from "@t3tools/contracts";
10import { AtomRegistry } from "effect/unstable/reactivity";
11import { afterEach, describe, expect, it, vi } from "vitest";
12
13import {
14 type GitActionClient,
15 createGitActionManager,
16 EMPTY_GIT_ACTION_STATE,
17} from "./gitActionState";
18
19let atomRegistry = AtomRegistry.make();
20
21function resetAtomRegistry() {
22 atomRegistry.dispose();
23 atomRegistry = AtomRegistry.make();
24}
25
26function createDeferred<T>() {
27 let resolve!: (value: T) => void;
28 let reject!: (reason?: unknown) => void;
29 const promise = new Promise<T>((res, rej) => {
30 resolve = res;
31 reject = rej;
32 });
33 return { promise, resolve, reject };
34}
35
36const TARGET = { environmentId: "env-local", cwd: "/repo" } as const;
37
38const BASE_STATUS: GitStatusResult = {
39 isRepo: true,
40 hasOriginRemote: true,
41 isDefaultBranch: false,
42 branch: "feature/test",
43 hasWorkingTreeChanges: true,
44 workingTree: { files: [], insertions: 0, deletions: 0 },
45 hasUpstream: true,
46 aheadCount: 0,
47 behindCount: 0,
48 pr: null,
49};
50
51function createPhaseStartedEvent(): Extract<GitActionProgressEvent, { kind: "phase_started" }> {
52 return {
53 actionId: "action-123",
54 cwd: "/repo",
55 action: "commit_push",
56 kind: "phase_started",
57 phase: "commit",
58 label: "Committing...",
59 };
60}
61
62function createHookStartedEvent(): Extract<GitActionProgressEvent, { kind: "hook_started" }> {
63 return {
64 actionId: "action-123",
65 cwd: "/repo",
66 action: "commit_push",
67 kind: "hook_started",
68 hookName: "post-commit",
69 };
70}
71
72function createHookOutputEvent(): Extract<GitActionProgressEvent, { kind: "hook_output" }> {
73 return {
74 actionId: "action-123",
75 cwd: "/repo",
76 action: "commit_push",
77 kind: "hook_output",
78 hookName: "post-commit",
79 stream: "stdout",
80 text: "hook output",
81 };
82}
83
84function createHookFinishedEvent(): Extract<GitActionProgressEvent, { kind: "hook_finished" }> {
85 return {
86 actionId: "action-123",
87 cwd: "/repo",
88 action: "commit_push",
89 kind: "hook_finished",
90 hookName: "post-commit",
91 exitCode: 0,
92 durationMs: 12,
93 };
94}
95
96function createActionFinishedEvent(): Extract<GitActionProgressEvent, { kind: "action_finished" }> {
97 return {
98 actionId: "action-123",
99 cwd: "/repo",
100 action: "commit_push",
101 kind: "action_finished",
102 result: {
103 action: "commit_push",
104 branch: { status: "skipped_not_requested" },
105 commit: { status: "created", commitSha: "abc123", subject: "Test commit" },
106 push: {
107 status: "pushed",
108 branch: "feature/test",
109 upstreamBranch: "origin/feature/test",
110 },
111 pr: { status: "skipped_not_requested" },
112 toast: {
113 title: "Done",
114 description: "Action finished",
115 cta: { kind: "none" },
116 },
117 } satisfies GitRunStackedActionResult,
118 };
119}
120
121function createMockClient() {
122 const refreshDeferred = createDeferred<GitStatusResult>();
123 const pullDeferred = createDeferred<GitPullResult>();
124 const checkoutDeferred = createDeferred<GitCheckoutResult>();
125 const createBranchDeferred = createDeferred<GitCreateBranchResult>();
126 const createWorktreeDeferred = createDeferred<GitCreateWorktreeResult>();
127 const initDeferred = createDeferred<void>();
128 const runStackedActionDeferred = createDeferred<GitRunStackedActionResult>();
129 let runStackedActionProgressListener: ((event: GitActionProgressEvent) => void) | null = null;
130
131 const client: GitActionClient = {
132 refreshStatus: vi.fn(() => refreshDeferred.promise),
133 pull: vi.fn(() => pullDeferred.promise),
134 checkout: vi.fn(() => checkoutDeferred.promise),
135 createBranch: vi.fn(() => createBranchDeferred.promise),
136 createWorktree: vi.fn(() => createWorktreeDeferred.promise),
137 init: vi.fn(() => initDeferred.promise),
138 runStackedAction: vi.fn((_, options) => {
139 runStackedActionProgressListener = options?.onProgress ?? null;
140 return runStackedActionDeferred.promise;
141 }),
142 };
143
144 return {
145 client,
146 refreshDeferred,
147 pullDeferred,
148 checkoutDeferred,
149 createBranchDeferred,
150 createWorktreeDeferred,
151 initDeferred,
152 runStackedActionDeferred,
153 emitProgress(event: GitActionProgressEvent) {
154 runStackedActionProgressListener?.(event);
155 },
156 };
157}
158
159describe("createGitActionManager", () => {
160 afterEach(() => {
161 resetAtomRegistry();
162 });
163
164 it("tracks refreshStatus progress and clears state on success", async () => {
165 const mock = createMockClient();
166 const manager = createGitActionManager({
167 getRegistry: () => atomRegistry,
168 getClient: () => mock.client,
169 });
170
171 const promise = manager.refreshStatus(TARGET, mock.client);
172
173 expect(manager.getSnapshot(TARGET)).toMatchObject({
174 isRunning: true,
175 operation: "refresh_status",
176 currentLabel: "Refreshing git status",
177 error: null,
178 });
179
180 mock.refreshDeferred.resolve(BASE_STATUS);
181
182 await expect(promise).resolves.toEqual(BASE_STATUS);
183 expect(manager.getSnapshot(TARGET)).toEqual(EMPTY_GIT_ACTION_STATE);
184 });
185
186 it("tracks runStackedAction progress events", async () => {
187 const mock = createMockClient();
188 const manager = createGitActionManager({
189 getRegistry: () => atomRegistry,
190 getClient: () => mock.client,
191 getActionId: () => "action-123",
192 });
193
194 const promise = manager.runStackedAction(
195 TARGET,
196 { action: "commit_push", commitMessage: "Test commit" },
197 { client: mock.client, gitStatus: BASE_STATUS },
198 );
199
200 expect(manager.getSnapshot(TARGET)).toMatchObject({
201 isRunning: true,
202 operation: "run_stacked_action",
203 actionId: "action-123",
204 currentLabel: "Committing...",
205 error: null,
206 });
207
208 mock.emitProgress(createPhaseStartedEvent());
209 expect(manager.getSnapshot(TARGET).currentLabel).toBe("Committing...");
210
211 mock.emitProgress(createHookStartedEvent());
212 expect(manager.getSnapshot(TARGET)).toMatchObject({
213 currentLabel: "Running post-commit...",
214 hookName: "post-commit",
215 isRunning: true,
216 });
217
218 mock.emitProgress(createHookOutputEvent());
219 expect(manager.getSnapshot(TARGET).lastOutputLine).toBe("hook output");
220
221 mock.emitProgress(createHookFinishedEvent());
222 expect(manager.getSnapshot(TARGET)).toMatchObject({
223 currentLabel: "Committing...",
224 hookName: null,
225 lastOutputLine: null,
226 });
227
228 const result = createActionFinishedEvent().result;
229 mock.runStackedActionDeferred.resolve(result);
230
231 await expect(promise).resolves.toEqual(result);
232 expect(manager.getSnapshot(TARGET)).toEqual(EMPTY_GIT_ACTION_STATE);
233 });
234
235 it("stores the error when an operation fails", async () => {
236 const mock = createMockClient();
237 const manager = createGitActionManager({
238 getRegistry: () => atomRegistry,
239 getClient: () => mock.client,
240 });
241
242 const promise = manager.pull(TARGET, mock.client);
243
244 mock.pullDeferred.reject(new Error("Pull failed."));
245
246 await expect(promise).rejects.toThrow("Pull failed.");
247 expect(manager.getSnapshot(TARGET)).toMatchObject({
248 isRunning: false,
249 operation: "pull",
250 currentLabel: null,
251 error: "Pull failed.",
252 });
253 });
254
255 it("returns null when no client is available", async () => {
256 const manager = createGitActionManager({
257 getRegistry: () => atomRegistry,
258 getClient: () => null,
259 });
260
261 await expect(manager.checkout(TARGET, { branch: "main" })).resolves.toBeNull();
262 expect(manager.getSnapshot(TARGET)).toEqual(EMPTY_GIT_ACTION_STATE);
263 });
264});
packages/client-runtime/src/gitActionState.ts+444−0
@@ -0,0 +1,444 @@
1import type {
2 GitActionProgressEvent,
3 GitCreateBranchInput,
4 GitCreateBranchResult,
5 GitCreateWorktreeInput,
6 GitCreateWorktreeResult,
7 GitCheckoutInput,
8 GitCheckoutResult,
9 GitPullInput,
10 GitPullResult,
11 GitRunStackedActionInput,
12 GitRunStackedActionResult,
13 GitStackedAction,
14 GitStatusResult,
15} from "@t3tools/contracts";
16import { Atom, type AtomRegistry } from "effect/unstable/reactivity";
17
18import { buildGitActionProgressStages } from "./gitActions";
19import type { WsRpcClient } from "./wsRpcClient";
20
21export type GitActionOperation =
22 | "refresh_status"
23 | "run_stacked_action"
24 | "pull"
25 | "checkout"
26 | "create_branch"
27 | "create_worktree"
28 | "init";
29
30export interface GitActionState {
31 readonly isRunning: boolean;
32 readonly operation: GitActionOperation | null;
33 readonly actionId: string | null;
34 readonly action: GitStackedAction | null;
35 readonly currentLabel: string | null;
36 readonly currentPhaseLabel: string | null;
37 readonly hookName: string | null;
38 readonly lastOutputLine: string | null;
39 readonly phaseStartedAtMs: number | null;
40 readonly hookStartedAtMs: number | null;
41 readonly error: string | null;
42}
43
44export interface GitActionTarget {
45 readonly environmentId: string | null;
46 readonly cwd: string | null;
47}
48
49export type GitActionClient = Pick<
50 WsRpcClient["git"],
51 | "refreshStatus"
52 | "runStackedAction"
53 | "pull"
54 | "checkout"
55 | "createBranch"
56 | "createWorktree"
57 | "init"
58>;
59
60export const EMPTY_GIT_ACTION_STATE = Object.freeze<GitActionState>({
61 isRunning: false,
62 operation: null,
63 actionId: null,
64 action: null,
65 currentLabel: null,
66 currentPhaseLabel: null,
67 hookName: null,
68 lastOutputLine: null,
69 phaseStartedAtMs: null,
70 hookStartedAtMs: null,
71 error: null,
72});
73
74const knownGitActionKeys = new Set<string>();
75
76export const gitActionStateAtom = Atom.family((key: string) => {
77 knownGitActionKeys.add(key);
78 return Atom.make(EMPTY_GIT_ACTION_STATE).pipe(
79 Atom.keepAlive,
80 Atom.withLabel(`git-action:${key}`),
81 );
82});
83
84export const EMPTY_GIT_ACTION_ATOM = Atom.make(EMPTY_GIT_ACTION_STATE).pipe(
85 Atom.keepAlive,
86 Atom.withLabel("git-action:null"),
87);
88
89export function getGitActionTargetKey(target: GitActionTarget): string | null {
90 if (target.environmentId === null || target.cwd === null) {
91 return null;
92 }
93 return `${target.environmentId}:${target.cwd}`;
94}
95
96export function applyGitActionProgressEvent(
97 current: GitActionState,
98 event: GitActionProgressEvent,
99): GitActionState {
100 const now = Date.now();
101
102 switch (event.kind) {
103 case "action_started":
104 return {
105 ...current,
106 isRunning: true,
107 actionId: event.actionId,
108 action: event.action,
109 operation: "run_stacked_action",
110 phaseStartedAtMs: now,
111 hookStartedAtMs: null,
112 hookName: null,
113 lastOutputLine: null,
114 error: null,
115 };
116 case "phase_started":
117 return {
118 ...current,
119 isRunning: true,
120 actionId: event.actionId,
121 action: event.action,
122 operation: "run_stacked_action",
123 currentLabel: event.label,
124 currentPhaseLabel: event.label,
125 phaseStartedAtMs: now,
126 hookStartedAtMs: null,
127 hookName: null,
128 lastOutputLine: null,
129 error: null,
130 };
131 case "hook_started":
132 return {
133 ...current,
134 isRunning: true,
135 actionId: event.actionId,
136 action: event.action,
137 operation: "run_stacked_action",
138 currentLabel: `Running ${event.hookName}...`,
139 hookName: event.hookName,
140 hookStartedAtMs: now,
141 lastOutputLine: null,
142 error: null,
143 };
144 case "hook_output":
145 return {
146 ...current,
147 isRunning: true,
148 actionId: event.actionId,
149 action: event.action,
150 operation: "run_stacked_action",
151 lastOutputLine: event.text,
152 error: null,
153 };
154 case "hook_finished":
155 return {
156 ...current,
157 isRunning: true,
158 actionId: event.actionId,
159 action: event.action,
160 operation: "run_stacked_action",
161 currentLabel: current.currentPhaseLabel,
162 hookName: null,
163 hookStartedAtMs: null,
164 lastOutputLine: null,
165 error: null,
166 };
167 case "action_finished":
168 return {
169 ...current,
170 isRunning: false,
171 actionId: event.actionId,
172 action: event.action,
173 operation: "run_stacked_action",
174 error: null,
175 };
176 case "action_failed":
177 return {
178 ...EMPTY_GIT_ACTION_STATE,
179 actionId: event.actionId,
180 action: event.action,
181 operation: "run_stacked_action",
182 error: event.message,
183 };
184 }
185}
186
187export interface GitActionManagerConfig {
188 readonly getRegistry: () => AtomRegistry.AtomRegistry;
189 readonly getClient: (environmentId: string) => GitActionClient | null;
190 readonly getActionId?: () => string;
191}
192
193export function createGitActionManager(config: GitActionManagerConfig) {
194 function setState(targetKey: string, nextState: GitActionState): void {
195 config.getRegistry().set(gitActionStateAtom(targetKey), nextState);
196 }
197
198 function startOperation(
199 targetKey: string,
200 input: {
201 readonly operation: GitActionOperation;
202 readonly actionId?: string;
203 readonly action?: GitStackedAction;
204 readonly label: string;
205 },
206 ): void {
207 setState(targetKey, {
208 isRunning: true,
209 operation: input.operation,
210 actionId: input.actionId ?? null,
211 action: input.action ?? null,
212 currentLabel: input.label,
213 currentPhaseLabel: input.label,
214 hookName: null,
215 lastOutputLine: null,
216 phaseStartedAtMs: Date.now(),
217 hookStartedAtMs: null,
218 error: null,
219 });
220 }
221
222 function finishOperation(targetKey: string): void {
223 setState(targetKey, EMPTY_GIT_ACTION_STATE);
224 }
225
226 function failOperation(
227 targetKey: string,
228 error: unknown,
229 input: {
230 readonly operation: GitActionOperation;
231 readonly actionId?: string;
232 readonly action?: GitStackedAction;
233 },
234 ): void {
235 setState(targetKey, {
236 ...EMPTY_GIT_ACTION_STATE,
237 operation: input.operation,
238 actionId: input.actionId ?? null,
239 action: input.action ?? null,
240 error: error instanceof Error ? error.message : "Git action failed.",
241 });
242 }
243
244 async function runOperation<TResult>(
245 target: GitActionTarget,
246 input: {
247 readonly operation: GitActionOperation;
248 readonly label: string;
249 readonly actionId?: string;
250 readonly action?: GitStackedAction;
251 readonly client?: GitActionClient | undefined;
252 readonly execute: (client: GitActionClient) => Promise<TResult>;
253 },
254 ): Promise<TResult | null> {
255 const targetKey = getGitActionTargetKey(target);
256 if (targetKey === null || target.environmentId === null || target.cwd === null) {
257 return null;
258 }
259
260 const resolved = input.client ?? config.getClient(target.environmentId);
261 if (!resolved) {
262 return null;
263 }
264
265 startOperation(targetKey, input);
266 try {
267 const result = await input.execute(resolved);
268 finishOperation(targetKey);
269 return result;
270 } catch (error) {
271 failOperation(targetKey, error, input);
272 throw error;
273 }
274 }
275
276 function getSnapshot(target: GitActionTarget): GitActionState {
277 const targetKey = getGitActionTargetKey(target);
278 if (targetKey === null) {
279 return EMPTY_GIT_ACTION_STATE;
280 }
281
282 return config.getRegistry().get(gitActionStateAtom(targetKey));
283 }
284
285 async function refreshStatus(
286 target: GitActionTarget,
287 client?: GitActionClient,
288 options?: { readonly quiet?: boolean },
289 ): Promise<Awaited<ReturnType<GitActionClient["refreshStatus"]>> | null> {
290 if (options?.quiet) {
291 if (target.environmentId === null || target.cwd === null) {
292 return null;
293 }
294 const resolved = client ?? config.getClient(target.environmentId);
295 return resolved ? resolved.refreshStatus({ cwd: target.cwd }) : null;
296 }
297
298 return runOperation(target, {
299 operation: "refresh_status",
300 label: "Refreshing git status",
301 client,
302 execute: (resolved) => resolved.refreshStatus({ cwd: target.cwd! }),
303 });
304 }
305
306 async function pull(
307 target: GitActionTarget,
308 client?: GitActionClient,
309 options?: { readonly label?: string },
310 ): Promise<GitPullResult | null> {
311 return runOperation(target, {
312 operation: "pull",
313 label: options?.label ?? "Pulling latest changes",
314 client,
315 execute: (resolved) => resolved.pull({ cwd: target.cwd! } satisfies GitPullInput),
316 });
317 }
318
319 async function checkout(
320 target: GitActionTarget,
321 input: Omit<GitCheckoutInput, "cwd">,
322 client?: GitActionClient,
323 options?: { readonly label?: string },
324 ): Promise<GitCheckoutResult | null> {
325 return runOperation(target, {
326 operation: "checkout",
327 label: options?.label ?? "Checking out branch",
328 client,
329 execute: (resolved) => resolved.checkout({ cwd: target.cwd!, ...input }),
330 });
331 }
332
333 async function createBranch(
334 target: GitActionTarget,
335 input: Omit<GitCreateBranchInput, "cwd">,
336 client?: GitActionClient,
337 options?: { readonly label?: string },
338 ): Promise<GitCreateBranchResult | null> {
339 return runOperation(target, {
340 operation: "create_branch",
341 label: options?.label ?? "Creating branch",
342 client,
343 execute: (resolved) => resolved.createBranch({ cwd: target.cwd!, ...input }),
344 });
345 }
346
347 async function createWorktree(
348 target: GitActionTarget,
349 input: Omit<GitCreateWorktreeInput, "cwd">,
350 client?: GitActionClient,
351 options?: { readonly label?: string },
352 ): Promise<GitCreateWorktreeResult | null> {
353 return runOperation(target, {
354 operation: "create_worktree",
355 label: options?.label ?? "Creating worktree",
356 client,
357 execute: (resolved) => resolved.createWorktree({ cwd: target.cwd!, ...input }),
358 });
359 }
360
361 async function init(
362 target: GitActionTarget,
363 client?: GitActionClient,
364 options?: { readonly label?: string },
365 ): Promise<Awaited<ReturnType<GitActionClient["init"]>> | null> {
366 return runOperation(target, {
367 operation: "init",
368 label: options?.label ?? "Initializing git repository",
369 client,
370 execute: (resolved) => resolved.init({ cwd: target.cwd! }),
371 });
372 }
373
374 async function runStackedAction(
375 target: GitActionTarget,
376 input: Omit<GitRunStackedActionInput, "cwd" | "actionId"> & { readonly actionId?: string },
377 options?: {
378 readonly client?: GitActionClient;
379 readonly gitStatus?: GitStatusResult | null;
380 readonly onProgress?: (event: GitActionProgressEvent) => void;
381 },
382 ): Promise<GitRunStackedActionResult | null> {
383 const actionId =
384 input.actionId ??
385 config.getActionId?.() ??
386 `git-action-${Date.now()}-${Math.random().toString(36).slice(2)}`;
387 const targetKey = getGitActionTargetKey(target);
388
389 return runOperation(target, {
390 operation: "run_stacked_action",
391 label:
392 buildGitActionProgressStages({
393 action: input.action,
394 hasCustomCommitMessage: Boolean(input.commitMessage?.trim()),
395 hasWorkingTreeChanges: options?.gitStatus?.hasWorkingTreeChanges ?? false,
396 featureBranch: input.featureBranch ?? false,
397 shouldPushBeforePr:
398 input.action === "create_pr" &&
399 (!(options?.gitStatus?.hasUpstream ?? false) ||
400 (options?.gitStatus?.aheadCount ?? 0) > 0),
401 })[0] ?? "Running git action",
402 actionId,
403 action: input.action,
404 client: options?.client,
405 execute: async (resolved) => {
406 const result = await resolved.runStackedAction(
407 {
408 cwd: target.cwd!,
409 actionId,
410 ...input,
411 },
412 {
413 onProgress: (event) => {
414 if (targetKey !== null) {
415 const current = getSnapshot(target);
416 setState(targetKey, applyGitActionProgressEvent(current, event));
417 }
418 options?.onProgress?.(event);
419 },
420 },
421 );
422 return result;
423 },
424 });
425 }
426
427 function reset(): void {
428 for (const key of knownGitActionKeys) {
429 setState(key, EMPTY_GIT_ACTION_STATE);
430 }
431 }
432
433 return {
434 getSnapshot,
435 refreshStatus,
436 pull,
437 checkout,
438 createBranch,
439 createWorktree,
440 init,
441 runStackedAction,
442 reset,
443 };
444}
packages/client-runtime/src/gitBranchState.test.ts+161−0
@@ -0,0 +1,161 @@
1import type { GitListBranchesResult } from "@t3tools/contracts";
2import { AtomRegistry } from "effect/unstable/reactivity";
3import { afterEach, describe, expect, it, vi } from "vitest";
4
5import {
6 createGitBranchManager,
7 EMPTY_GIT_BRANCH_STATE,
8 gitBranchStateAtom,
9 type GitBranchClient,
10} from "./gitBranchState";
11
12let atomRegistry = AtomRegistry.make();
13
14function resetAtomRegistry() {
15 atomRegistry.dispose();
16 atomRegistry = AtomRegistry.make();
17}
18
19const TARGET = { environmentId: "env-local", cwd: "/repo" } as const;
20
21const FIRST_PAGE: GitListBranchesResult = {
22 branches: [
23 { name: "main", current: true, isDefault: true, worktreePath: null },
24 { name: "feature/a", current: false, isDefault: false, worktreePath: null },
25 ],
26 isRepo: true,
27 hasOriginRemote: true,
28 nextCursor: 2,
29 totalCount: 3,
30};
31
32const SECOND_PAGE: GitListBranchesResult = {
33 branches: [{ name: "feature/b", current: false, isDefault: false, worktreePath: null }],
34 isRepo: true,
35 hasOriginRemote: true,
36 nextCursor: null,
37 totalCount: 3,
38};
39
40function createMockClient() {
41 const listBranches = vi.fn(async (input: Parameters<GitBranchClient["listBranches"]>[0]) => {
42 if (input.query === "feature") {
43 return {
44 ...FIRST_PAGE,
45 branches: FIRST_PAGE.branches.filter((branch) => branch.name.includes("feature")),
46 nextCursor: null,
47 totalCount: 2,
48 } satisfies GitListBranchesResult;
49 }
50
51 if (input.cursor === 2) {
52 return SECOND_PAGE;
53 }
54
55 return FIRST_PAGE;
56 });
57
58 return {
59 client: { listBranches } satisfies GitBranchClient,
60 listBranches,
61 };
62}
63
64describe("createGitBranchManager", () => {
65 afterEach(() => {
66 resetAtomRegistry();
67 });
68
69 it("loads the first page and stores it in atom state", async () => {
70 const mock = createMockClient();
71 const manager = createGitBranchManager({
72 getRegistry: () => atomRegistry,
73 getClient: () => mock.client,
74 });
75
76 const promise = manager.load(TARGET, mock.client, { limit: 100 });
77
78 expect(manager.getSnapshot(TARGET)).toEqual({
79 data: null,
80 isPending: true,
81 error: null,
82 });
83
84 await expect(promise).resolves.toEqual(FIRST_PAGE);
85 expect(manager.getSnapshot(TARGET)).toEqual({
86 data: FIRST_PAGE,
87 isPending: false,
88 error: null,
89 });
90 expect(mock.listBranches).toHaveBeenCalledWith({ cwd: "/repo", limit: 100 });
91 });
92
93 it("loads the next page and appends branches", async () => {
94 const mock = createMockClient();
95 const manager = createGitBranchManager({
96 getRegistry: () => atomRegistry,
97 getClient: () => mock.client,
98 });
99
100 await manager.load(TARGET, mock.client);
101 const next = await manager.loadNext(TARGET, mock.client);
102
103 expect(next).toEqual({
104 ...SECOND_PAGE,
105 branches: [...FIRST_PAGE.branches, ...SECOND_PAGE.branches],
106 });
107 expect(manager.getSnapshot(TARGET)).toEqual({
108 data: {
109 ...SECOND_PAGE,
110 branches: [...FIRST_PAGE.branches, ...SECOND_PAGE.branches],
111 },
112 isPending: false,
113 error: null,
114 });
115 });
116
117 it("stores query-specific state independently", async () => {
118 const mock = createMockClient();
119 const manager = createGitBranchManager({
120 getRegistry: () => atomRegistry,
121 getClient: () => mock.client,
122 });
123
124 const queriedTarget = { ...TARGET, query: "feature" } as const;
125 const queried = await manager.load(queriedTarget, mock.client);
126
127 expect(queried?.branches.map((branch) => branch.name)).toEqual(["feature/a"]);
128 expect(manager.getSnapshot(TARGET).data).toBeNull();
129 expect(manager.getSnapshot(queriedTarget).data?.branches.map((branch) => branch.name)).toEqual([
130 "feature/a",
131 ]);
132 });
133
134 it("returns cached data when no client is available", async () => {
135 const manager = createGitBranchManager({
136 getRegistry: () => atomRegistry,
137 getClient: () => null,
138 });
139
140 atomRegistry.set(gitBranchStateAtom("env-local:/repo:"), {
141 data: FIRST_PAGE,
142 isPending: false,
143 error: null,
144 });
145
146 await expect(manager.load(TARGET)).resolves.toEqual(FIRST_PAGE);
147 });
148
149 it("resets state", async () => {
150 const mock = createMockClient();
151 const manager = createGitBranchManager({
152 getRegistry: () => atomRegistry,
153 getClient: () => mock.client,
154 });
155
156 await manager.load(TARGET, mock.client);
157 manager.reset();
158
159 expect(manager.getSnapshot(TARGET)).toEqual(EMPTY_GIT_BRANCH_STATE);
160 });
161});
packages/client-runtime/src/gitBranchState.ts+227−0
@@ -0,0 +1,227 @@
1import type { GitBranch, GitListBranchesInput, GitListBranchesResult } from "@t3tools/contracts";
2import { Atom, type AtomRegistry } from "effect/unstable/reactivity";
3
4import type { WsRpcClient } from "./wsRpcClient";
5
6export interface GitBranchTarget {
7 readonly environmentId: string | null;
8 readonly cwd: string | null;
9 readonly query?: string | null;
10}
11
12export interface GitBranchState {
13 readonly data: GitListBranchesResult | null;
14 readonly isPending: boolean;
15 readonly error: string | null;
16}
17
18export type GitBranchClient = Pick<WsRpcClient["git"], "listBranches">;
19
20export const EMPTY_GIT_BRANCH_STATE = Object.freeze<GitBranchState>({
21 data: null,
22 isPending: false,
23 error: null,
24});
25
26const INITIAL_GIT_BRANCH_STATE = Object.freeze<GitBranchState>({
27 data: null,
28 isPending: true,
29 error: null,
30});
31
32const knownGitBranchKeys = new Set<string>();
33
34export const gitBranchStateAtom = Atom.family((key: string) => {
35 knownGitBranchKeys.add(key);
36 return Atom.make(EMPTY_GIT_BRANCH_STATE).pipe(
37 Atom.keepAlive,
38 Atom.withLabel(`git-branches:${key}`),
39 );
40});
41
42export const EMPTY_GIT_BRANCH_ATOM = Atom.make(EMPTY_GIT_BRANCH_STATE).pipe(
43 Atom.keepAlive,
44 Atom.withLabel("git-branches:null"),
45);
46
47function normalizeQuery(query: string | null | undefined): string {
48 return query?.trim() ?? "";
49}
50
51export function getGitBranchTargetKey(target: GitBranchTarget): string | null {
52 if (target.environmentId === null || target.cwd === null) {
53 return null;
54 }
55
56 return `${target.environmentId}:${target.cwd}:${normalizeQuery(target.query)}`;
57}
58
59function toErrorMessage(error: unknown): string {
60 return error instanceof Error ? error.message : "Failed to load branches.";
61}
62
63function mergeBranches(
64 previous: ReadonlyArray<GitBranch>,
65 next: ReadonlyArray<GitBranch>,
66): ReadonlyArray<GitBranch> {
67 const merged = new Map<string, GitBranch>();
68 for (const branch of previous) {
69 merged.set(branch.name, branch);
70 }
71 for (const branch of next) {
72 merged.set(branch.name, branch);
73 }
74 return [...merged.values()];
75}
76
77export interface GitBranchManagerConfig {
78 readonly getRegistry: () => AtomRegistry.AtomRegistry;
79 readonly getClient: (environmentId: string) => GitBranchClient | null;
80}
81
82export function createGitBranchManager(config: GitBranchManagerConfig) {
83 const inFlight = new Map<string, Promise<GitListBranchesResult | null>>();
84
85 function getSnapshot(target: GitBranchTarget): GitBranchState {
86 const targetKey = getGitBranchTargetKey(target);
87 if (targetKey === null) {
88 return EMPTY_GIT_BRANCH_STATE;
89 }
90 return config.getRegistry().get(gitBranchStateAtom(targetKey));
91 }
92
93 function setState(targetKey: string, nextState: GitBranchState): void {
94 config.getRegistry().set(gitBranchStateAtom(targetKey), nextState);
95 }
96
97 function markPending(targetKey: string): void {
98 const current = config.getRegistry().get(gitBranchStateAtom(targetKey));
99 setState(
100 targetKey,
101 current.data === null
102 ? INITIAL_GIT_BRANCH_STATE
103 : { ...current, isPending: true, error: null },
104 );
105 }
106
107 function setData(targetKey: string, data: GitListBranchesResult): void {
108 setState(targetKey, {
109 data,
110 isPending: false,
111 error: null,
112 });
113 }
114
115 function setError(targetKey: string, error: unknown): void {
116 const current = config.getRegistry().get(gitBranchStateAtom(targetKey));
117 setState(targetKey, {
118 data: current.data,
119 isPending: false,
120 error: toErrorMessage(error),
121 });
122 }
123
124 async function load(
125 target: GitBranchTarget,
126 client?: GitBranchClient,
127 options?: {
128 readonly cursor?: number;
129 readonly limit?: number;
130 readonly append?: boolean;
131 },
132 ): Promise<GitListBranchesResult | null> {
133 const targetKey = getGitBranchTargetKey(target);
134 if (targetKey === null || target.environmentId === null || target.cwd === null) {
135 return null;
136 }
137
138 const resolved = client ?? config.getClient(target.environmentId);
139 if (!resolved) {
140 return getSnapshot(target).data;
141 }
142
143 const inFlightKey = `${targetKey}:${options?.cursor ?? "start"}:${options?.append ? "append" : "replace"}`;
144 const existing = inFlight.get(inFlightKey);
145 if (existing) {
146 return existing;
147 }
148
149 markPending(targetKey);
150
151 const current = getSnapshot(target).data;
152 const request: GitListBranchesInput = {
153 cwd: target.cwd,
154 ...(normalizeQuery(target.query).length > 0 ? { query: normalizeQuery(target.query) } : {}),
155 ...(options?.cursor !== undefined ? { cursor: options.cursor } : {}),
156 ...(options?.limit !== undefined ? { limit: options.limit } : {}),
157 };
158
159 const promise = resolved.listBranches(request).then(
160 (result) => {
161 const nextData =
162 options?.append && current
163 ? {
164 ...result,
165 branches: mergeBranches(current.branches, result.branches),
166 }
167 : result;
168 setData(targetKey, nextData);
169 return nextData;
170 },
171 (error) => {
172 setError(targetKey, error);
173 throw error;
174 },
175 );
176
177 inFlight.set(inFlightKey, promise);
178 try {
179 return await promise;
180 } finally {
181 inFlight.delete(inFlightKey);
182 }
183 }
184
185 async function loadNext(
186 target: GitBranchTarget,
187 client?: GitBranchClient,
188 options?: { readonly limit?: number },
189 ): Promise<GitListBranchesResult | null> {
190 const current = getSnapshot(target).data;
191 if (!current?.nextCursor && current?.nextCursor !== 0) {
192 return current ?? null;
193 }
194
195 return load(target, client, {
196 cursor: current.nextCursor,
197 append: true,
198 ...(options?.limit !== undefined ? { limit: options.limit } : {}),
199 });
200 }
201
202 function invalidate(target?: GitBranchTarget): void {
203 if (target) {
204 const targetKey = getGitBranchTargetKey(target);
205 if (targetKey !== null) {
206 setState(targetKey, EMPTY_GIT_BRANCH_STATE);
207 }
208 return;
209 }
210
211 for (const key of knownGitBranchKeys) {
212 setState(key, EMPTY_GIT_BRANCH_STATE);
213 }
214 }
215
216 function reset(): void {
217 invalidate();
218 }
219
220 return {
221 getSnapshot,
222 load,
223 loadNext,
224 invalidate,
225 reset,
226 };
227}
packages/client-runtime/src/gitStatusState.test.ts+330−0
@@ -0,0 +1,330 @@
1import type { GitStatusResult } from "@t3tools/contracts";
2import { AtomRegistry } from "effect/unstable/reactivity";
3import { afterEach, describe, expect, it, vi } from "vitest";
4
5import { type GitStatusClient, createGitStatusManager } from "./gitStatusState";
6
7/* ─── Test helpers ──────────────────────────────────────────────────── */
8
9let atomRegistry = AtomRegistry.make();
10
11function resetAtomRegistry() {
12 atomRegistry.dispose();
13 atomRegistry = AtomRegistry.make();
14}
15
16function registerListener<T>(listeners: Set<(event: T) => void>, listener: (event: T) => void) {
17 listeners.add(listener);
18 return () => {
19 listeners.delete(listener);
20 };
21}
22
23const BASE_STATUS: GitStatusResult = {
24 isRepo: true,
25 hasOriginRemote: true,
26 isDefaultBranch: false,
27 branch: "feature/push-status",
28 hasWorkingTreeChanges: false,
29 workingTree: { files: [], insertions: 0, deletions: 0 },
30 hasUpstream: true,
31 aheadCount: 0,
32 behindCount: 0,
33 pr: null,
34};
35
36function createMockClient(): {
37 client: GitStatusClient;
38 listeners: Set<(event: GitStatusResult) => void>;
39 emit: (event: GitStatusResult) => void;
40} {
41 const listeners = new Set<(event: GitStatusResult) => void>();
42 const client: GitStatusClient = {
43 refreshStatus: vi.fn(async (input: { cwd: string }) => ({
44 ...BASE_STATUS,
45 branch: `${input.cwd}-refreshed`,
46 })),
47 onStatus: vi.fn((_: { cwd: string }, listener: (event: GitStatusResult) => void) =>
48 registerListener(listeners, listener),
49 ),
50 };
51 return {
52 client,
53 listeners,
54 emit: (event) => {
55 for (const listener of listeners) listener(event);
56 },
57 };
58}
59
60const PENDING = { data: null, error: null, cause: null, isPending: true };
61const EMPTY = { data: null, error: null, cause: null, isPending: false };
62
63const TARGET = { environmentId: "env-local", cwd: "/repo" } as const;
64const FRESH_TARGET = { environmentId: "env-local", cwd: "/fresh" } as const;
65const OTHER_ENV_TARGET = { environmentId: "env-remote", cwd: "/repo" } as const;
66
67/* ─── Tests ─────────────────────────────────────────────────────────── */
68
69describe("createGitStatusManager", () => {
70 afterEach(() => {
71 resetAtomRegistry();
72 });
73
74 describe("with explicit client (no reconnection)", () => {
75 it("starts in a pending state when watching", () => {
76 const { client } = createMockClient();
77 const manager = createGitStatusManager({
78 getRegistry: () => atomRegistry,
79 getClient: () => null,
80 });
81
82 manager.watch(TARGET, client);
83 expect(manager.getSnapshot(TARGET)).toEqual(PENDING);
84 manager.reset();
85 });
86
87 it("shares one subscription per cwd and updates the snapshot", () => {
88 const { client, listeners, emit } = createMockClient();
89 const manager = createGitStatusManager({
90 getRegistry: () => atomRegistry,
91 getClient: () => null,
92 });
93
94 const releaseA = manager.watch(TARGET, client);
95 const releaseB = manager.watch(TARGET, client);
96
97 expect(client.onStatus).toHaveBeenCalledOnce();
98 expect(manager.getSnapshot(TARGET)).toEqual(PENDING);
99
100 emit(BASE_STATUS);
101 expect(manager.getSnapshot(TARGET)).toEqual({
102 data: BASE_STATUS,
103 error: null,
104 cause: null,
105 isPending: false,
106 });
107
108 releaseA();
109 expect(listeners.size).toBe(1);
110
111 releaseB();
112 expect(listeners.size).toBe(0);
113 });
114
115 it("refreshes via unary RPC without restarting the stream", async () => {
116 const { client, emit } = createMockClient();
117 const manager = createGitStatusManager({
118 getRegistry: () => atomRegistry,
119 getClient: () => null,
120 });
121
122 const release = manager.watch(TARGET, client);
123 emit(BASE_STATUS);
124
125 const refreshed = await manager.refresh(TARGET, client);
126
127 expect(client.onStatus).toHaveBeenCalledOnce();
128 expect(client.refreshStatus).toHaveBeenCalledWith({ cwd: "/repo" });
129 expect(refreshed).toEqual({ ...BASE_STATUS, branch: "/repo-refreshed" });
130
131 // Snapshot still reflects stream data, not the refresh response
132 expect(manager.getSnapshot(TARGET)).toEqual({
133 data: BASE_STATUS,
134 error: null,
135 cause: null,
136 isPending: false,
137 });
138
139 release();
140 });
141
142 it("keeps subscriptions isolated by environment when cwds match", () => {
143 const local = createMockClient();
144 const remote = createMockClient();
145 const manager = createGitStatusManager({
146 getRegistry: () => atomRegistry,
147 getClient: () => null,
148 });
149
150 const releaseLocal = manager.watch(TARGET, local.client);
151 const releaseRemote = manager.watch(OTHER_ENV_TARGET, remote.client);
152
153 local.emit(BASE_STATUS);
154 remote.emit({ ...BASE_STATUS, branch: "remote-branch" });
155
156 expect(manager.getSnapshot(TARGET).data?.branch).toBe("feature/push-status");
157 expect(manager.getSnapshot(OTHER_ENV_TARGET).data?.branch).toBe("remote-branch");
158
159 releaseLocal();
160 releaseRemote();
161 });
162
163 it("returns null from refresh when no client is available", async () => {
164 const manager = createGitStatusManager({
165 getRegistry: () => atomRegistry,
166 getClient: () => null,
167 });
168
169 await expect(manager.refresh(TARGET)).resolves.toBeNull();
170 });
171
172 it("returns empty state for null targets", () => {
173 const manager = createGitStatusManager({
174 getRegistry: () => atomRegistry,
175 getClient: () => null,
176 });
177
178 expect(manager.getSnapshot({ environmentId: null, cwd: null })).toEqual(EMPTY);
179 });
180 });
181
182 describe("with subscribeClientChanges (reconnection)", () => {
183 it("waits for a delayed client registration", () => {
184 const connectionListeners = new Set<() => void>();
185 const clients = new Map<string, ReturnType<typeof createMockClient>>();
186
187 const manager = createGitStatusManager({
188 getRegistry: () => atomRegistry,
189 getClient: (envId) => clients.get(envId)?.client ?? null,
190 getClientIdentity: (envId) => (clients.has(envId) ? envId : null),
191 subscribeClientChanges: (listener) => {
192 connectionListeners.add(listener);
193 return () => connectionListeners.delete(listener);
194 },
195 });
196
197 const release = manager.watch(TARGET);
198 expect(manager.getSnapshot(TARGET)).toEqual(PENDING);
199
200 // Register the client
201 const mock = createMockClient();
202 clients.set("env-local", mock);
203 for (const listener of connectionListeners) listener();
204
205 mock.emit(BASE_STATUS);
206 expect(manager.getSnapshot(TARGET)).toEqual({
207 data: BASE_STATUS,
208 error: null,
209 cause: null,
210 isPending: false,
211 });
212
213 release();
214 });
215
216 it("resubscribes after client is removed and re-registered", () => {
217 const connectionListeners = new Set<() => void>();
218 const clients = new Map<string, ReturnType<typeof createMockClient>>();
219
220 const manager = createGitStatusManager({
221 getRegistry: () => atomRegistry,
222 getClient: (envId) => clients.get(envId)?.client ?? null,
223 getClientIdentity: (envId) =>
224 clients.get(envId) ? `identity:${envId}:${clients.size}` : null,
225 subscribeClientChanges: (listener) => {
226 connectionListeners.add(listener);
227 return () => connectionListeners.delete(listener);
228 },
229 });
230
231 // Register first client and watch
232 const first = createMockClient();
233 clients.set("env-local", first);
234 const release = manager.watch(TARGET);
235
236 first.emit(BASE_STATUS);
237 expect(manager.getSnapshot(TARGET).data?.branch).toBe("feature/push-status");
238
239 // Remove client
240 clients.delete("env-local");
241 for (const listener of connectionListeners) listener();
242
243 expect(manager.getSnapshot(TARGET)).toEqual({
244 data: BASE_STATUS,
245 error: null,
246 cause: null,
247 isPending: true,
248 });
249
250 // Register new client (different identity)
251 const second = createMockClient();
252 clients.set("env-local", second);
253 for (const listener of connectionListeners) listener();
254
255 second.emit({ ...BASE_STATUS, branch: "reconnected-branch" });
256 expect(manager.getSnapshot(TARGET).data?.branch).toBe("reconnected-branch");
257
258 release();
259 });
260
261 it("cleans up connection listener on unwatch", () => {
262 const connectionListeners = new Set<() => void>();
263 const mock = createMockClient();
264
265 const manager = createGitStatusManager({
266 getRegistry: () => atomRegistry,
267 getClient: () => mock.client,
268 getClientIdentity: () => "id",
269 subscribeClientChanges: (listener) => {
270 connectionListeners.add(listener);
271 return () => connectionListeners.delete(listener);
272 },
273 });
274
275 const release = manager.watch(TARGET);
276 expect(connectionListeners.size).toBe(1);
277
278 release();
279 expect(connectionListeners.size).toBe(0);
280 expect(mock.listeners.size).toBe(0);
281 });
282 });
283
284 describe("with getClient config (one-shot)", () => {
285 it("resolves client from config and subscribes", () => {
286 const mock = createMockClient();
287 const manager = createGitStatusManager({
288 getRegistry: () => atomRegistry,
289 getClient: (envId) => (envId === "env-local" ? mock.client : null),
290 });
291
292 const release = manager.watch(TARGET);
293 expect(mock.client.onStatus).toHaveBeenCalledOnce();
294
295 mock.emit(BASE_STATUS);
296 expect(manager.getSnapshot(TARGET).data?.branch).toBe("feature/push-status");
297
298 release();
299 expect(mock.listeners.size).toBe(0);
300 });
301
302 it("returns noop when client is not available", () => {
303 const manager = createGitStatusManager({
304 getRegistry: () => atomRegistry,
305 getClient: () => null,
306 });
307
308 const release = manager.watch(TARGET);
309 expect(manager.getSnapshot(TARGET)).toEqual(PENDING);
310 release(); // should not throw
311 });
312 });
313
314 describe("reset", () => {
315 it("tears down all active subscriptions", () => {
316 const mock = createMockClient();
317 const manager = createGitStatusManager({
318 getRegistry: () => atomRegistry,
319 getClient: () => mock.client,
320 });
321
322 manager.watch(TARGET);
323 manager.watch(FRESH_TARGET);
324 expect(mock.listeners.size).toBe(2);
325
326 manager.reset();
327 expect(mock.listeners.size).toBe(0);
328 });
329 });
330});
packages/client-runtime/src/gitStatusState.ts+289−0
@@ -0,0 +1,289 @@
1import type { GitManagerServiceError, GitStatusResult } from "@t3tools/contracts";
2import type { Cause } from "effect";
3import { Atom, type AtomRegistry } from "effect/unstable/reactivity";
4import type { WsRpcClient } from "./wsRpcClient";
5
6/* ─── Types ─────────────────────────────────────────────────────────── */
7
8export interface GitStatusState {
9 readonly data: GitStatusResult | null;
10 readonly error: GitManagerServiceError | null;
11 readonly cause: Cause.Cause<GitManagerServiceError> | null;
12 readonly isPending: boolean;
13}
14
15export interface GitStatusTarget {
16 readonly environmentId: string | null;
17 readonly cwd: string | null;
18}
19
20export type GitStatusClient = Pick<WsRpcClient["git"], "onStatus" | "refreshStatus">;
21
22interface WatchedEntry {
23 refCount: number;
24 teardown: () => void;
25}
26
27/* ─── Constants ─────────────────────────────────────────────────────── */
28
29const NOOP: () => void = () => undefined;
30
31export const EMPTY_GIT_STATUS_STATE = Object.freeze<GitStatusState>({
32 data: null,
33 error: null,
34 cause: null,
35 isPending: false,
36});
37
38const INITIAL_GIT_STATUS_STATE = Object.freeze<GitStatusState>({
39 data: null,
40 error: null,
41 cause: null,
42 isPending: true,
43});
44
45/* ─── Atoms ─────────────────────────────────────────────────────────── */
46
47const knownGitStatusKeys = new Set<string>();
48
49export const gitStatusStateAtom = Atom.family((key: string) => {
50 knownGitStatusKeys.add(key);
51 return Atom.make(INITIAL_GIT_STATUS_STATE).pipe(
52 Atom.keepAlive,
53 Atom.withLabel(`git-status:${key}`),
54 );
55});
56
57export const EMPTY_GIT_STATUS_ATOM = Atom.make(EMPTY_GIT_STATUS_STATE).pipe(
58 Atom.keepAlive,
59 Atom.withLabel("git-status:null"),
60);
61
62/* ─── Helpers ───────────────────────────────────────────────────────── */
63
64export function getGitStatusTargetKey(target: GitStatusTarget): string | null {
65 if (target.environmentId === null || target.cwd === null) {
66 return null;
67 }
68 return `${target.environmentId}:${target.cwd}`;
69}
70
71/* ─── Subscription manager ──────────────────────────────────────────── */
72
73export interface GitStatusManagerConfig {
74 /**
75 * Get the atom registry to read/write git status atoms.
76 */
77 readonly getRegistry: () => AtomRegistry.AtomRegistry;
78 /** Resolve a git client for an environment. */
79 readonly getClient: (environmentId: string) => GitStatusClient | null;
80 /**
81 * Optional: get a stable identity for the current client.
82 * Used to detect reconnections — when the identity changes the
83 * manager tears down the old `onStatus` stream and subscribes anew.
84 */
85 readonly getClientIdentity?: (environmentId: string) => string | null;
86 /**
87 * Optional: subscribe to environment-connection changes.
88 * When provided the manager reacts to client appear / disappear /
89 * reconnect events instead of doing a one-shot resolution.
90 */
91 readonly subscribeClientChanges?: (listener: () => void) => () => void;
92}
93
94const GIT_STATUS_REFRESH_DEBOUNCE_MS = 1_000;
95
96export function createGitStatusManager(config: GitStatusManagerConfig) {
97 const watched = new Map<string, WatchedEntry>();
98 const refreshInFlight = new Map<string, Promise<GitStatusResult>>();
99 const lastRefreshAt = new Map<string, number>();
100
101 /* ── Atom helpers ───────────────────────────────────────────────── */
102
103 function markPending(targetKey: string): void {
104 const atom = gitStatusStateAtom(targetKey);
105 const current = config.getRegistry().get(atom);
106 const next: GitStatusState =
107 current.data === null
108 ? INITIAL_GIT_STATUS_STATE
109 : { ...current, error: null, cause: null, isPending: true };
110 if (
111 current.data === next.data &&
112 current.error === next.error &&
113 current.cause === next.cause &&
114 current.isPending === next.isPending
115 ) {
116 return;
117 }
118 config.getRegistry().set(atom, next);
119 }
120
121 function setData(targetKey: string, status: GitStatusResult): void {
122 config.getRegistry().set(gitStatusStateAtom(targetKey), {
123 data: status,
124 error: null,
125 cause: null,
126 isPending: false,
127 });
128 }
129
130 /* ── Core subscription ──────────────────────────────────────────── */
131
132 function subscribeStream(targetKey: string, cwd: string, client: GitStatusClient): () => void {
133 markPending(targetKey);
134 return client.onStatus({ cwd }, (status) => setData(targetKey, status), {
135 onResubscribe: () => markPending(targetKey),
136 });
137 }
138
139 /* ── Dynamic subscription (handles reconnection) ────────────────── */
140
141 function createDynamicSubscription(targetKey: string, target: GitStatusTarget): () => void {
142 const environmentId = target.environmentId!;
143 const cwd = target.cwd!;
144 let currentIdentity: string | null = null;
145 let currentUnsub = NOOP;
146
147 const sync = () => {
148 const client = config.getClient(environmentId);
149 const identity = client ? (config.getClientIdentity?.(environmentId) ?? environmentId) : null;
150
151 if (!client || identity === null) {
152 if (currentIdentity !== null) {
153 currentUnsub();
154 currentUnsub = NOOP;
155 currentIdentity = null;
156 }
157 markPending(targetKey);
158 return;
159 }
160
161 if (currentIdentity === identity) return;
162
163 currentUnsub();
164 currentIdentity = identity;
165 currentUnsub = subscribeStream(targetKey, cwd, client);
166 };
167
168 const unsubChanges = config.subscribeClientChanges!(sync);
169 sync();
170
171 return () => {
172 unsubChanges();
173 currentUnsub();
174 };
175 }
176
177 /* ── Public API ─────────────────────────────────────────────────── */
178
179 /**
180 * Begin watching git status for `target`.
181 *
182 * Multiple watchers sharing the same `environmentId:cwd` key share
183 * one `onStatus` WS subscription (ref-counted).
184 *
185 * @param target The environment + cwd to watch.
186 * @param client Optional pre-resolved client — skips `getClient`
187 * lookup and reconnection handling. Useful in tests.
188 * @returns An unwatch function.
189 */
190 function watch(target: GitStatusTarget, client?: GitStatusClient): () => void {
191 const targetKey = getGitStatusTargetKey(target);
192 if (targetKey === null || target.environmentId === null || target.cwd === null) {
193 return NOOP;
194 }
195
196 const existing = watched.get(targetKey);
197 if (existing) {
198 existing.refCount += 1;
199 return () => unwatch(targetKey);
200 }
201
202 let teardown: () => void;
203
204 if (client) {
205 // Explicit client — direct subscription, no reconnection handling.
206 teardown = subscribeStream(targetKey, target.cwd, client);
207 } else if (config.subscribeClientChanges) {
208 // Dynamic client — subscribe to connection changes for reconnection.
209 teardown = createDynamicSubscription(targetKey, target);
210 } else {
211 // One-shot client resolution.
212 const resolved = config.getClient(target.environmentId);
213 if (!resolved) return NOOP;
214 teardown = subscribeStream(targetKey, target.cwd, resolved);
215 }
216
217 watched.set(targetKey, { refCount: 1, teardown });
218 return () => unwatch(targetKey);
219 }
220
221 function unwatch(targetKey: string): void {
222 const entry = watched.get(targetKey);
223 if (!entry) return;
224
225 entry.refCount -= 1;
226 if (entry.refCount > 0) return;
227
228 entry.teardown();
229 watched.delete(targetKey);
230 }
231
232 /**
233 * Trigger a one-shot `refreshStatus` RPC for a target.
234 * Debounced (1 s) and deduplicated (in-flight).
235 * The server-side refresh pushes a new event on the existing
236 * `onStatus` stream, so the subscription picks it up automatically.
237 */
238 function refresh(
239 target: GitStatusTarget,
240 client?: GitStatusClient,
241 ): Promise<GitStatusResult | null> {
242 const targetKey = getGitStatusTargetKey(target);
243 if (targetKey === null || target.cwd === null) {
244 return Promise.resolve(null);
245 }
246
247 const resolved =
248 client ?? (target.environmentId ? config.getClient(target.environmentId) : null);
249 if (!resolved) {
250 return Promise.resolve(getSnapshot(target).data);
251 }
252
253 const existing = refreshInFlight.get(targetKey);
254 if (existing) return existing;
255
256 const last = lastRefreshAt.get(targetKey) ?? 0;
257 if (Date.now() - last < GIT_STATUS_REFRESH_DEBOUNCE_MS) {
258 return Promise.resolve(getSnapshot(target).data);
259 }
260
261 lastRefreshAt.set(targetKey, Date.now());
262 const promise = resolved
263 .refreshStatus({ cwd: target.cwd })
264 .finally(() => refreshInFlight.delete(targetKey));
265 refreshInFlight.set(targetKey, promise);
266 return promise;
267 }
268
269 function getSnapshot(target: GitStatusTarget): GitStatusState {
270 const targetKey = getGitStatusTargetKey(target);
271 if (targetKey === null) return EMPTY_GIT_STATUS_STATE;
272 return config.getRegistry().get(gitStatusStateAtom(targetKey));
273 }
274
275 function reset(): void {
276 for (const entry of watched.values()) {
277 entry.teardown();
278 }
279 watched.clear();
280 refreshInFlight.clear();
281 lastRefreshAt.clear();
282 for (const key of knownGitStatusKeys) {
283 config.getRegistry().set(gitStatusStateAtom(key), INITIAL_GIT_STATUS_STATE);
284 }
285 knownGitStatusKeys.clear();
286 }
287
288 return { watch, refresh, getSnapshot, reset };
289}
packages/client-runtime/src/index.ts+3−0
@@ -1,6 +1,9 @@
1export * from "./knownEnvironment";1export * from "./knownEnvironment";
2export * from "./scoped";2export * from "./scoped";
3export * from "./gitActions";3export * from "./gitActions";
4export * from "./gitActionState";
5export * from "./gitBranchState";
6export * from "./gitStatusState";
4export * from "./wsRpcProtocol";7export * from "./wsRpcProtocol";
5export * from "./wsTransport";8export * from "./wsTransport";
6export * from "./wsRpcClient";9export * from "./wsRpcClient";