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 @@
11 module.exports = function (api) {
22 api.cache(true);
33 return {
4− presets: [["babel-preset-expo", { unstable_transformImportMeta: true }], "nativewind/babel"],
4+ presets: [["babel-preset-expo", { unstable_transformImportMeta: true }]],
55 };
66 };
apps/mobile/global.css+181−6
@@ -1,17 +1,192 @@
1−@import "tailwindcss/theme.css" layer(theme);
2−@import "tailwindcss/preflight.css" layer(base);
3−@import "tailwindcss/utilities.css" layer(utilities);
1+@import "tailwindcss";
2+@import "uniwind";
43
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);
611
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 ──────────────────────────────────────────────── */
7181 @utility font-t3-medium {
8− font-weight: 500;
182+ font-family: "DMSans_500Medium";
9183 }
10184
11185 @utility font-t3-bold {
12− font-weight: 700;
186+ font-family: "DMSans_700Bold";
13187 }
14188
15189 @utility font-t3-extrabold {
190+ font-family: "DMSans_700Bold";
16191 font-weight: 800;
17192 }
apps/mobile/metro.config.js+4−4
@@ -1,6 +1,6 @@
11 const path = require("node:path");
22 const { getDefaultConfig } = require("expo/metro-config");
3−const { withNativewind } = require("nativewind/metro");
3+const { withUniwindConfig } = require("uniwind/metro");
44
55 /** @type {import("expo/metro-config").MetroConfig} */
66 const config = getDefaultConfig(__dirname);
@@ -8,7 +8,7 @@ const workspaceRoot = path.resolve(__dirname, "../.."); · 1 unmodified line
88
99 config.watchFolders = [...new Set([...(config.watchFolders ?? []), workspaceRoot])];
1010
11−module.exports = withNativewind(config, {
12− input: "./global.css",
13− globalClassNamePolyfill: true,
11+module.exports = withUniwindConfig(config, {
12+ cssEntryFile: "./global.css",
13+ polyfills: { rem: 14 },
1414 });
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−
5−declare module "*.css";
apps/mobile/package.json+4−5
@@ -13,6 +13,7 @@ · 12 unmodified lines
1313 },
1414 "dependencies": {
1515 "@callstack/liquid-glass": "^0.7.1",
16+ "@effect/atom-react": "catalog:",
1617 "@expo-google-fonts/dm-sans": "^0.4.2",
1718 "@gorhom/bottom-sheet": "^5",
1819 "@legendapp/list": "^3.0.0-beta.44",
@@ -22,6 +23,7 @@ · 3 unmodified lines
2223 "@t3tools/client-runtime": "workspace:*",
2324 "@t3tools/contracts": "workspace:*",
2425 "@t3tools/shared": "workspace:*",
26+ "clsx": "^2.1.1",
2527 "effect": "catalog:",
2628 "expo": "~55.0.14",
2729 "expo-camera": "~55.0.15",
@@ -41,12 +43,10 @@ · 13 unmodified lines
4143 "expo-splash-screen": "~55.0.13",
4244 "expo-symbols": "~55.0.5",
4345 "expo-updates": "~55.0.16",
44− "nativewind": "5.0.0-preview.3",
4546 "punycode": "^2.3.1",
4647 "react": "19.2.0",
4748 "react-dom": "19.2.0",
4849 "react-native": "0.83.4",
49− "react-native-css": "3.0.7",
5050 "react-native-gesture-handler": "~2.30.0",
5151 "react-native-image-viewing": "^0.2.2",
5252 "react-native-keyboard-controller": "1.20.7",
@@ -56,14 +56,13 @@ · 3 unmodified lines
5656 "react-native-screens": "~4.23.0",
5757 "react-native-svg": "15.15.3",
5858 "react-native-worklets": "0.7.2",
59+ "tailwind-merge": "^3.5.0",
60+ "uniwind": "^1.6.2",
5961 "zustand": "^5.0.12"
6062 },
6163 "devDependencies": {
62− "@tailwindcss/postcss": "4.1.12",
6364 "@types/react": "~19.2.10",
6465 "babel-preset-expo": "~55.0.8",
65− "lightningcss": "1.30.1",
66− "postcss": "^8.5.8",
6766 "tailwindcss": "4.1.12",
6867 "typescript": "~5.9.2"
6968 }
apps/mobile/postcss.config.mjs+0−5
@@ -1,5 +0,0 @@
1−export default {
2− plugins: {
3− "@tailwindcss/postcss": {},
4− },
5−};
apps/mobile/src/app/+not-found.tsx+15−15
@@ -1,12 +1,12 @@
11 import { Link } from "expo-router";
2−import { Pressable, ScrollView, useColorScheme } from "react-native";
2+import { Pressable, ScrollView, StyleSheet, View } from "react-native";
3+import { useResolveClassNames } from "uniwind";
34
45 import { AppText as Text } from "../components/AppText";
5−import { makeAppPalette } from "../lib/theme";
66
77 export default function NotFoundRoute() {
8− const isDarkMode = useColorScheme() === "dark";
9− const palette = makeAppPalette(isDarkMode);
8+ const screenBgStyle = StyleSheet.flatten(useResolveClassNames("bg-screen"));
9+ const primaryBgStyle = StyleSheet.flatten(useResolveClassNames("bg-primary"));
1010
1111 return (
1212 <ScrollView
@@ -19,23 +19,23 @@ export default function NotFoundRoute() { · 6 unmodified lines
1919 paddingHorizontal: 24,
2020 paddingVertical: 32,
2121 }}
22− style={{ flex: 1, backgroundColor: palette.screenBackground }}
22+ style={[{ flex: 1 }, screenBgStyle]}
2323 >
24− <Text className="text-[28px] font-t3-bold" selectable style={{ color: palette.text }}>
24+ <Text className="text-[28px] font-t3-bold text-foreground" selectable>
2525 Route not found
2626 </Text>
2727 <Link href="/" asChild>
2828 <Pressable
29− style={{
30− backgroundColor: palette.primaryButton,
31− borderRadius: 999,
32− paddingHorizontal: 20,
33− paddingVertical: 14,
34− }}
29+ style={[
30+ {
31+ borderRadius: 999,
32+ paddingHorizontal: 20,
33+ paddingVertical: 14,
34+ },
35+ primaryBgStyle,
36+ ]}
3537 >
36− <Text className="text-[16px] font-t3-bold" style={{ color: palette.primaryButtonText }}>
37− Return home
38− </Text>
38+ <Text className="text-[16px] font-t3-bold text-primary-foreground">Return home</Text>
3939 </Pressable>
4040 </Link>
4141 </ScrollView>
apps/mobile/src/app/_layout.tsx+31−16
@@ -6,15 +6,15 @@ import { · 5 unmodified lines
66 DMSans_700Bold,
77 useFonts,
88 } from "@expo-google-fonts/dm-sans";
9−import { BottomSheetModalProvider } from "@gorhom/bottom-sheet";
109 import Stack from "expo-router/stack";
1110 import { StatusBar, useColorScheme } from "react-native";
1211 import { GestureHandlerRootView } from "react-native-gesture-handler";
1312 import { KeyboardProvider } from "react-native-keyboard-controller";
1413 import { SafeAreaProvider } from "react-native-safe-area-context";
14+import { useCSSVariable, useResolveClassNames } from "uniwind";
1515
1616 import { LoadingScreen } from "../components/LoadingScreen";
17−import { makeAppPalette } from "../lib/theme";
17+import { AppAtomRegistryProvider } from "../state/atomRegistry";
1818 import {
1919 useRemoteEnvironmentBootstrap,
2020 useRemoteEnvironmentState,
@@ -22,11 +22,13 @@ import { · 1 unmodified line
2222
2323 function AppNavigator() {
2424 const { isLoadingSavedConnection } = useRemoteEnvironmentState();
25− const isDarkMode = useColorScheme() !== "light";
26− const palette = makeAppPalette(isDarkMode);
25+ const colorScheme = useColorScheme();
26+ const statusBarBg = useCSSVariable("--color-status-bar");
27+ const sheetStyle = useResolveClassNames("bg-sheet");
28+ const screenStyle = useResolveClassNames("bg-screen");
2729
2830 const newTaskScreenOptions = {
29− contentStyle: { backgroundColor: palette.sheetBackground },
31+ contentStyle: sheetStyle,
3032 gestureEnabled: true,
3133 headerShown: false,
3234 presentation: "formSheet" as const,
@@ -35,7 +37,7 @@ function AppNavigator() { · 2 unmodified lines
3537 };
3638
3739 const connectionSheetScreenOptions = {
38− contentStyle: { backgroundColor: palette.sheetBackground },
40+ contentStyle: sheetStyle,
3941 gestureEnabled: true,
4042 headerShown: false,
4143 presentation: "formSheet" as const,
@@ -50,18 +52,27 @@ function AppNavigator() { · 8 unmodified lines
5052 return (
5153 <>
5254 <StatusBar
53− barStyle={palette.statusBarStyle}
54− backgroundColor={palette.statusBarBackground}
55+ barStyle={colorScheme === "dark" ? "light-content" : "dark-content"}
56+ backgroundColor={statusBarBg as string}
5557 translucent
5658 />
5759 <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+ />
5869 <Stack.Screen name="connections" options={connectionSheetScreenOptions} />
5970 <Stack.Screen name="new" options={newTaskScreenOptions} />
6071 <Stack.Screen
6172 name="threads/[environmentId]/[threadId]"
6273 options={{
6374 animation: "slide_from_right",
64− contentStyle: { backgroundColor: palette.screenBackground },
75+ contentStyle: { backgroundColor: "transparent" },
6576 gestureEnabled: true,
6677 headerShown: false,
6778 }}
@@ -80,14 +91,18 @@ export default function RootLayout() { · 12 unmodified lines
8091 useRemoteEnvironmentBootstrap();
8192
8293 return (
83− <GestureHandlerRootView style={{ flex: 1 }}>
84− <KeyboardProvider statusBarTranslucent>
85− <BottomSheetModalProvider>
94+ <AppAtomRegistryProvider>
95+ <GestureHandlerRootView style={{ flex: 1 }}>
96+ <KeyboardProvider statusBarTranslucent>
8697 <SafeAreaProvider>
87− {fontsLoaded ? <AppNavigator /> : <LoadingScreen message="Loading remote workspace…" />}
98+ {fontsLoaded ? (
99+ <AppNavigator />
100+ ) : (
101+ <LoadingScreen message="Loading remote workspace…" />
102+ )}
88103 </SafeAreaProvider>
89− </BottomSheetModalProvider>
90− </KeyboardProvider>
91− </GestureHandlerRootView>
104+ </KeyboardProvider>
105+ </GestureHandlerRootView>
106+ </AppAtomRegistryProvider>
92107 );
93108 }
apps/mobile/src/app/connections/_layout.tsx+13−1
@@ -1,14 +1,26 @@
11 import Stack from "expo-router/stack";
2+import { useResolveClassNames } from "uniwind";
3+
4+import { useThemeColor } from "../../lib/useThemeColor";
25
36 export const unstable_settings = {
47 anchor: "index",
58 };
69
710 export default function ConnectionsLayout() {
11+ const contentStyle = useResolveClassNames("bg-sheet");
12+ const connSheetBg = String(useThemeColor("--color-sheet"));
13+ const headerTint = String(useThemeColor("--color-icon"));
14+
815 return (
916 <Stack
1017 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",
1224 }}
1325 >
1426 <Stack.Screen name="index" options={{ animation: "none" }} />
apps/mobile/src/app/new/_layout.tsx+3−5
@@ -1,7 +1,6 @@
11 import Stack from "expo-router/stack";
2−import { useColorScheme } from "react-native";
2+import { useResolveClassNames } from "uniwind";
33
4−import { makeAppPalette } from "../../lib/theme";
54 import { NewTaskFlowProvider } from "../../features/threads/new-task-flow-provider";
65
76 export const unstable_settings = {
@@ -9,14 +8,13 @@ export const unstable_settings = { · 1 unmodified line
98 };
109
1110 export default function NewTaskLayout() {
12− const isDarkMode = useColorScheme() === "dark";
13− const palette = makeAppPalette(isDarkMode);
11+ const sheetStyle = useResolveClassNames("bg-sheet");
1412
1513 return (
1614 <NewTaskFlowProvider>
1715 <Stack
1816 screenOptions={{
19− contentStyle: { backgroundColor: palette.sheetBackground },
17+ contentStyle: sheetStyle,
2018 }}
2119 >
2220 <Stack.Screen name="index" options={{ animation: "none" }} />
apps/mobile/src/app/new/index.tsx+39−26
@@ -1,19 +1,22 @@
11 import { Link, Stack } from "expo-router";
22 import { SymbolView } from "expo-symbols";
33 import { useMemo } from "react";
4−import { Pressable, ScrollView, useColorScheme, View } from "react-native";
4+import { Pressable, ScrollView, View } from "react-native";
55 import { useSafeAreaInsets } from "react-native-safe-area-context";
6+import { useThemeColor } from "../../lib/useThemeColor";
67
78 import { AppText as Text } from "../../components/AppText";
9+import { ProjectFavicon } from "../../components/ProjectFavicon";
810 import { groupProjectsByRepository } from "../../lib/repositoryGroups";
9−import { makeAppPalette } from "../../lib/theme";
1011 import { useRemoteCatalog } from "../../state/use-remote-catalog";
12+import { useRemoteEnvironmentState } from "../../state/use-remote-environment-registry";
1113
1214 export default function NewTaskRoute() {
1315 const { projects, threads } = useRemoteCatalog();
14− const isDarkMode = useColorScheme() === "dark";
15− const palette = makeAppPalette(isDarkMode);
16+ const { savedConnectionsById } = useRemoteEnvironmentState();
1617 const insets = useSafeAreaInsets();
18+ const chevronColor = useThemeColor("--color-chevron");
19+ const borderSubtleColor = useThemeColor("--color-border-subtle");
1720 const repositoryGroups = useMemo(
1821 () => groupProjectsByRepository({ projects, threads }),
1922 [projects, threads],
@@ -32,6 +35,7 @@ export default function NewTaskRoute() { · 12 unmodified lines
3235 id: project.id,
3336 key: group.key,
3437 title: project.title,
38+ workspaceRoot: project.workspaceRoot,
3539 };
3640 })
3741 .filter((entry) => entry !== null),
@@ -39,34 +43,38 @@ export default function NewTaskRoute() { · 1 unmodified line
3943 );
4044
4145 return (
42− <View collapsable={false} style={{ flex: 1, backgroundColor: palette.sheetBackground }}>
43− <Stack.Screen options={{ title: "Choose Project" }} />
46+ <View collapsable={false} className="flex-1 bg-sheet">
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+
4461 <ScrollView
45− contentInsetAdjustmentBehavior="automatic"
4662 showsVerticalScrollIndicator={false}
4763 style={{ flex: 1 }}
4864 contentContainerStyle={{
4965 paddingHorizontal: 20,
50− paddingTop: 16,
66+ paddingTop: 8,
5167 paddingBottom: Math.max(insets.bottom, 18) + 18,
5268 }}
5369 >
5470 {items.length === 0 ? (
55− <View
56− collapsable={false}
57− className="items-center rounded-[24px] px-6 py-8"
58− style={{ backgroundColor: palette.card }}
59− >
60− <Text className="text-[16px] font-medium" style={{ color: palette.textMuted }}>
61− Loading projects…
71+ <View collapsable={false} className="items-center rounded-[24px] bg-card px-6 py-8">
72+ <Text className="text-[16px] font-medium text-foreground-muted">
73+ Loading projects...
6274 </Text>
6375 </View>
6476 ) : (
65− <View
66− collapsable={false}
67− className="overflow-hidden rounded-[24px]"
68− style={{ backgroundColor: palette.card }}
69− >
77+ <View collapsable={false} className="overflow-hidden rounded-[24px] bg-card">
7078 {items.map((item, index) => {
7179 const isFirst = index === 0;
7280 const isLast = index === items.length - 1;
@@ -85,12 +93,12 @@ export default function NewTaskRoute() { · 12 unmodified lines
8593 asChild
8694 >
8795 <Pressable
96+ className="bg-card"
8897 style={{
8998 paddingHorizontal: 16,
9099 paddingVertical: 18,
91100 borderTopWidth: isFirst ? 0 : 1,
92− borderTopColor: palette.borderSubtle,
93− backgroundColor: palette.card,
101+ borderTopColor: borderSubtleColor,
94102 borderTopLeftRadius: isFirst ? 24 : 0,
95103 borderTopRightRadius: isFirst ? 24 : 0,
96104 borderBottomLeftRadius: isLast ? 24 : 0,
@@ -98,15 +106,20 @@ export default function NewTaskRoute() { · 1 unmodified line
98106 }}
99107 >
100108 <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+ />
101116 <View className="flex-1">
102− <Text className="text-[18px] font-t3-bold" style={{ color: palette.text }}>
103− {item.title}
104− </Text>
117+ <Text className="text-[18px] font-t3-bold">{item.title}</Text>
105118 </View>
106119 <SymbolView
107120 name="chevron.right"
108121 size={14}
109− tintColor={palette.chevronColor}
122+ tintColor={chevronColor}
110123 type="monochrome"
111124 />
112125 </View>
apps/mobile/src/app/threads/[environmentId]/[threadId].tsx+0−5
@@ -1,5 +0,0 @@
1−import { ThreadRouteScreen } from "../../../features/threads/ThreadRouteScreen";
2−
3−export default function ThreadRoute() {
4− return <ThreadRouteScreen />;
5−}
apps/mobile/src/app/threads/[environmentId]/[threadId]/_layout.tsx+43−0
@@ -0,0 +1,43 @@
1+import Stack from "expo-router/stack";
2+import { StyleSheet } from "react-native";
3+import { useResolveClassNames } from "uniwind";
4+
5+export default function ThreadLayout() {
6+ const sheetStyle = StyleSheet.flatten(useResolveClassNames("bg-sheet"));
7+
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 @@
1+import { GitConfirmSheet } from "../../../../features/threads/git/GitConfirmSheet";
2+
3+export default function GitConfirmRoute() {
4+ return <GitConfirmSheet />;
5+}
apps/mobile/src/app/threads/[environmentId]/[threadId]/git/_layout.tsx+45−0
@@ -0,0 +1,45 @@
1+import Stack from "expo-router/stack";
2+import { StyleSheet } from "react-native";
3+import { useResolveClassNames } from "uniwind";
4+
5+export const unstable_settings = {
6+ anchor: "index",
7+};
8+
9+export default function GitSheetLayout() {
10+ const sheetStyle = StyleSheet.flatten(useResolveClassNames("bg-sheet"));
11+ const headerBg = { 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 @@
1+import { GitBranchesSheet } from "../../../../../features/threads/git/GitBranchesSheet";
2+
3+export default function GitBranchesRoute() {
4+ return <GitBranchesSheet />;
5+}
apps/mobile/src/app/threads/[environmentId]/[threadId]/git/commit.tsx+5−0
@@ -0,0 +1,5 @@
1+import { GitCommitSheet } from "../../../../../features/threads/git/GitCommitSheet";
2+
3+export default function GitCommitRoute() {
4+ return <GitCommitSheet />;
5+}
apps/mobile/src/app/threads/[environmentId]/[threadId]/git/index.tsx+5−0
@@ -0,0 +1,5 @@
1+import { GitOverviewSheet } from "../../../../../features/threads/git/GitOverviewSheet";
2+
3+export default function GitRoute() {
4+ return <GitOverviewSheet />;
5+}
apps/mobile/src/app/threads/[environmentId]/[threadId]/index.tsx+5−0
@@ -0,0 +1,5 @@
1+import { ThreadRouteScreen } from "../../../../features/threads/ThreadRouteScreen";
2+
3+export default function ThreadRoute() {
4+ return <ThreadRouteScreen />;
5+}
apps/mobile/src/components/AppText.tsx+21−299
@@ -1,318 +1,40 @@
11 import {
22 Text as RNText,
3− type StyleProp,
43 TextInput as RNTextInput,
54 type TextInputProps as RNTextInputProps,
65 type TextProps as RNTextProps,
7− type TextStyle,
8− useColorScheme,
9− type ViewStyle,
106 } from "react-native";
7+import { useThemeColor } from "../lib/useThemeColor";
118
12−type ClassNameProp = {
13− readonly className?: string;
14−};
9+import { cn } from "../lib/cn";
1510
16−const colorMap = {
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>;
11+export type AppTextProps = RNTextProps & { readonly className?: string };
3512
36−const backgroundColorMap = {
37− "bg-white": "#ffffff",
38− "bg-neutral-900": "#171717",
39− "bg-neutral-950/70": "rgba(10, 10, 10, 0.7)",
40−} satisfies Record<string, string>;
41−
42−const borderColorMap = {
43− "border-neutral-200": "#e5e5e5",
44− "border-white/8": "rgba(255, 255, 255, 0.08)",
45−} satisfies Record<string, string>;
46−
47−function activeToken(token: string, isDarkMode: boolean) {
48− if (!token.startsWith("dark:")) {
49− return token;
50− }
51−
52− return isDarkMode ? token.slice(5) : null;
53−}
54−
55−function 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−
188−function 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−
293−export type AppTextProps = RNTextProps & ClassNameProp;
294−
295−export function AppText({ className, style, ...props }: AppTextProps) {
296− const isDarkMode = useColorScheme() === "dark";
297− return <RNText {...props} style={[resolveTextStyle(className, isDarkMode), style]} />;
13+/**
14+ * Thin wrapper around RN Text with default font-family and foreground color.
15+ * Uses Uniwind className — no manual style parsing.
16+ */
17+export function AppText({ className, ...props }: AppTextProps) {
18+ return <RNText className={cn("font-sans text-foreground", className)} {...props} />;
29819 }
29920
300−export type AppTextInputProps = RNTextInputProps & ClassNameProp;
21+export type AppTextInputProps = RNTextInputProps & { readonly className?: string };
30122
302−export function AppTextInput({
303− className,
304− placeholderTextColor,
305− style,
306− ...props
307−}: AppTextInputProps) {
308− const isDarkMode = useColorScheme() === "dark";
309− const resolvedStyle = resolveTextInputStyle(className, isDarkMode);
23+/**
24+ * Thin wrapper around RN TextInput with default input styling.
25+ * Uses Uniwind className — no manual style parsing.
26+ */
27+export function AppTextInput({ className, placeholderTextColor, ...props }: AppTextInputProps) {
28+ const placeholderColor = useThemeColor("--color-placeholder");
31029
31130 return (
31231 <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}
31337 {...props}
314− placeholderTextColor={placeholderTextColor ?? (isDarkMode ? "#737373" : "#a3a3a3")}
315− style={[resolvedStyle as StyleProp<TextStyle>, style]}
31638 />
31739 );
31840 }
apps/mobile/src/components/BrandMark.tsx+7−15
@@ -1,13 +1,10 @@
1−import { Image, View, useColorScheme } from "react-native";
1+import { Image, View } from "react-native";
22
3−import { makeAppPalette } from "../lib/theme";
43 import { AppText as Text } from "./AppText";
54
65 const BRAND_MARK_SOURCE = require("../../../../assets/dev/blueprint-ios-1024.png");
76
87 export function BrandMark(props: { readonly compact?: boolean; readonly stageLabel?: string }) {
9− const isDarkMode = useColorScheme() === "dark";
10− const palette = makeAppPalette(isDarkMode);
118 const compact = props.compact ?? false;
129 const iconSize = compact ? 32 : 44;
1310 const stageLabel = props.stageLabel ?? "Alpha";
@@ -26,27 +23,22 @@ export function BrandMark(props: { readonly compact?: boolean; readonly stageLab · 12 unmodified lines
2623 <View className="gap-1">
2724 <View className="flex-row items-center gap-2">
2825 <Text
29− className="text-[17px] font-t3-bold"
30− style={{ color: palette.text, letterSpacing: -0.4 }}
26+ className="text-[17px] font-t3-bold text-foreground"
27+ style={{ letterSpacing: -0.4 }}
3128 >
3229 T3 Code
3330 </Text>
34− <View
35− className="rounded-full px-2 py-1"
36− style={{
37− backgroundColor: palette.subtleBg,
38− }}
39− >
31+ <View className="rounded-full bg-subtle px-2 py-1">
4032 <Text
41− className="text-[10px] font-t3-bold uppercase"
42− style={{ color: palette.textMuted, letterSpacing: 1.1 }}
33+ className="text-[10px] font-t3-bold uppercase text-foreground-muted"
34+ style={{ letterSpacing: 1.1 }}
4335 >
4436 {stageLabel}
4537 </Text>
4638 </View>
4739 </View>
4840 {!compact ? (
49− <Text className="text-[12px] font-medium" style={{ color: palette.textMuted }}>
41+ <Text className="text-[12px] font-medium text-foreground-muted">
5042 Mobile control surface for your live coding environments
5143 </Text>
5244 ) : null}
apps/mobile/src/components/ComposerAttachmentStrip.tsx+4−5
@@ -1,8 +1,8 @@
11 import { SymbolView } from "expo-symbols";
2−import { Image, Pressable, ScrollView, useColorScheme, View } from "react-native";
2+import { Image, Pressable, ScrollView, View } from "react-native";
3+import { useThemeColor } from "../lib/useThemeColor";
34
45 import type { DraftComposerImageAttachment } from "../lib/composerImages";
5−import { makeAppPalette } from "../lib/theme";
66
77 export interface ComposerAttachmentStripProps {
88 /** Attachment images to display. */
@@ -22,8 +22,7 @@ export interface ComposerAttachmentStripProps { · 13 unmodified lines
2222 * buttons. Used by both the thread composer and the new-task draft screen.
2323 */
2424 export function ComposerAttachmentStrip(props: ComposerAttachmentStripProps) {
25− const isDarkMode = useColorScheme() === "dark";
26− const palette = makeAppPalette(isDarkMode);
25+ const subtleBg = useThemeColor("--color-subtle");
2726 const size = props.imageSize ?? 72;
2827 const radius = props.imageBorderRadius ?? 16;
2928
@@ -50,7 +49,7 @@ export function ComposerAttachmentStrip(props: ComposerAttachmentStripProps) { · 20 unmodified lines
5049 width: size,
5150 height: size,
5251 borderRadius: radius,
53− backgroundColor: palette.subtleBg,
52+ backgroundColor: subtleBg,
5453 }}
5554 resizeMode="cover"
5655 />
apps/mobile/src/components/ControlPill.tsx+24−19
@@ -1,9 +1,9 @@
11 import type { ComponentProps, ReactNode } from "react";
2−import { Pressable, useColorScheme, View } from "react-native";
2+import { Pressable, View } from "react-native";
33 import { SymbolView } from "expo-symbols";
4+import { useThemeColor } from "../lib/useThemeColor";
45
56 import { AppText as Text } from "./AppText";
6−import { makeAppPalette } from "../lib/theme";
77
88 export function ControlPill(props: {
99 readonly icon?: ComponentProps<typeof SymbolView>["name"];
@@ -13,31 +13,36 @@ export function ControlPill(props: { · 3 unmodified lines
1313 readonly variant?: "circle" | "pill" | "primary" | "danger";
1414 readonly disabled?: boolean;
1515 }) {
16− const isDarkMode = useColorScheme() === "dark";
17− const palette = makeAppPalette(isDarkMode);
1816 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+
1929 const backgroundColor =
2030 variant === "primary"
2131 ? props.disabled
22− ? palette.subtleBgStrong
23− : palette.primaryButton
32+ ? subtleBgStrong
33+ : primaryBg
2434 : variant === "danger"
25− ? palette.dangerButton
26− : palette.subtleBg;
35+ ? dangerBg
36+ : subtleBg;
2737 const iconTintColor =
2838 variant === "primary"
2939 ? props.disabled
30− ? palette.iconSubtle
31− : palette.primaryButtonText
40+ ? iconSubtle
41+ : primaryFg
3242 : variant === "danger"
33− ? palette.dangerText
34− : palette.icon;
35− const textColor =
36− variant === "primary"
37− ? props.disabled
38− ? palette.textMuted
39− : palette.primaryButtonText
40− : palette.text;
43+ ? dangerFg
44+ : iconColor;
45+ const labelColor = variant === "primary" ? (props.disabled ? textMuted : primaryFg) : textColor;
4146
4247 const isCircle =
4348 variant === "circle" || variant === "danger" || (variant === "primary" && !props.label);
@@ -61,7 +66,7 @@ export function ControlPill(props: { · 17 unmodified lines
6166 <SymbolView name={props.icon} size={16} tintColor={iconTintColor} type="monochrome" />
6267 ) : null}
6368 {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 }}>
6570 {props.label}
6671 </Text>
6772 ) : null}
apps/mobile/src/components/EmptyState.tsx+4−5
@@ -1,13 +1,12 @@
11 import { View } from "react-native";
22
33 import { AppText as Text } from "./AppText";
4+
45 export function EmptyState(props: { readonly title: string; readonly detail: string }) {
56 return (
6− <View className="rounded-[22px] border border-neutral-200 bg-white p-5 dark:border-white/6 dark:bg-neutral-900">
7− <Text className="font-t3-bold text-lg text-neutral-950 dark:text-neutral-50">
8− {props.title}
9− </Text>
10− <Text className="mt-2 font-sans text-sm leading-[21px] text-neutral-600 dark:text-neutral-400">
7+ <View className="rounded-[22px] border border-border bg-card p-5">
8+ <Text className="font-t3-bold text-lg text-foreground">{props.title}</Text>
9+ <Text className="mt-2 font-sans text-sm leading-[21px] text-foreground-muted">
1110 {props.detail}
1211 </Text>
1312 </View>
apps/mobile/src/components/LoadingScreen.tsx+7−9
@@ -1,28 +1,26 @@
11 import { ActivityIndicator, StatusBar, View, useColorScheme } from "react-native";
22 import { useSafeAreaInsets } from "react-native-safe-area-context";
3+import { useThemeColor } from "../lib/useThemeColor";
34
4−import { makeAppPalette } from "../lib/theme";
55 import { AppText as Text } from "./AppText";
66 import { BrandMark } from "./BrandMark";
77
88 export function LoadingScreen(props: { readonly message: string }) {
9− const isDarkMode = useColorScheme() === "dark";
10− const palette = makeAppPalette(isDarkMode);
9+ const colorScheme = useColorScheme();
10+ const screenBg = useThemeColor("--color-screen");
1111 const insets = useSafeAreaInsets();
1212
1313 return (
14− <View style={{ flex: 1, backgroundColor: palette.screenBackground, paddingTop: insets.top }}>
14+ <View className="flex-1 bg-screen" style={{ paddingTop: insets.top }}>
1515 <StatusBar
16− barStyle={palette.statusBarStyle}
17− backgroundColor={palette.screenBackground}
16+ barStyle={colorScheme === "dark" ? "light-content" : "dark-content"}
17+ backgroundColor={screenBg as string}
1818 translucent
1919 />
2020 <View className="flex-1 items-center justify-center gap-5 px-6">
2121 <BrandMark compact />
2222 <ActivityIndicator size="large" />
23− <Text className="font-t3-bold text-lg text-neutral-950 dark:text-neutral-50">
24− {props.message}
25− </Text>
23+ <Text className="font-t3-bold text-lg text-foreground">{props.message}</Text>
2624 </View>
2725 </View>
2826 );
apps/mobile/src/components/ProjectFavicon.tsx+70−0
@@ -0,0 +1,70 @@
1+import { SymbolView } from "expo-symbols";
2+import { useState } from "react";
3+import { Image, View } from "react-native";
4+import { useThemeColor } from "../lib/useThemeColor";
5+
6+/* ─── Favicon cache (matches web pattern) ────────────────────────────── */
7+const loadedFaviconUrls = new Set<string>();
8+
9+/* ─── Component ──────────────────────────────────────────────────────── */
10+export 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 @@
11 import { View } from "react-native";
22
33 import { AppText as Text } from "./AppText";
4−import { cx } from "../lib/classNames";
4+import { cn } from "../lib/cn";
55
66 export interface StatusTone {
77 readonly label: string;
@@ -17,14 +17,14 @@ export function StatusPill( · 9 unmodified lines
1717 const size = props.size ?? "default";
1818 return (
1919 <View
20− className={cx(
20+ className={cn(
2121 "rounded-full",
2222 size === "compact" ? "px-2.5 py-1" : "px-3 py-1.5",
2323 props.pillClassName,
2424 )}
2525 >
2626 <Text
27− className={cx(
27+ className={cn(
2828 "font-t3-bold",
2929 size === "compact" ? "text-[11px]" : "text-xs",
3030 props.textClassName,
apps/mobile/src/features/connection/ConnectionSheet.tsx+0−415
@@ -1,415 +0,0 @@
1−import { BottomSheetBackdrop, BottomSheetModal, BottomSheetView } from "@gorhom/bottom-sheet";
2−import { useRouter } from "expo-router";
3−import { SymbolView } from "expo-symbols";
4−import { useCallback, useEffect, useRef, useState } from "react";
5−import {
6− ActionSheetIOS,
7− Platform,
8− Pressable,
9− ScrollView,
10− View,
11− useColorScheme,
12−} from "react-native";
13−import { useSafeAreaInsets } from "react-native-safe-area-context";
14−
15−import type { ConnectedEnvironmentSummary } from "../../state/remote-runtime-types";
16−import { AppText as Text, AppTextInput as TextInput } from "../../components/AppText";
17−import { ConnectionStatusDot } from "./ConnectionStatusDot";
18−import {
19− ConnectionSheetButton as SheetButton,
20− makeConnectionSheetPalette as makePalette,
21− type ConnectionSheetPalette,
22−} from "./connection-sheet-shared";
23−
24−export 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−// ---------------------------------------------------------------------------
36−function 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−
97−function 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−
259−export 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 @@
11 import { Link, Stack } from "expo-router";
22 import { SymbolView } from "expo-symbols";
33 import { useCallback, useState } from "react";
4−import { Pressable, ScrollView, View, useColorScheme } from "react-native";
4+import { Pressable, ScrollView, View } from "react-native";
55 import Animated, { FadeIn, FadeOut, LinearTransition } from "react-native-reanimated";
66 import { useSafeAreaInsets } from "react-native-safe-area-context";
7+import { useThemeColor } from "../../lib/useThemeColor";
78
89 import { AppText as Text, AppTextInput as TextInput } from "../../components/AppText";
10+import { cn } from "../../lib/cn";
911 import { useRemoteConnections } from "../../state/use-remote-environment-registry";
1012 import type { ConnectedEnvironmentSummary } from "../../state/remote-runtime-types";
1113 import { ConnectionStatusDot } from "./ConnectionStatusDot";
12−import { makeConnectionSheetPalette as makePalette } from "./connection-sheet-shared";
1314
1415 function EnvironmentRow(props: {
1516 readonly environment: ConnectedEnvironmentSummary;
@@ -20,11 +21,15 @@ function EnvironmentRow(props: { · 4 unmodified lines
2021 environmentId: string,
2122 updates: { readonly label: string; readonly displayUrl: string },
2223 ) => void;
23− readonly palette: ReturnType<typeof makePalette>;
2424 }) {
2525 const [label, setLabel] = useState(props.environment.environmentLabel);
2626 const [url, setUrl] = useState(props.environment.displayUrl);
2727
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+
2833 const handleSave = useCallback(() => {
2934 props.onUpdate(props.environment.environmentId, {
3035 label: label.trim(),
@@ -34,26 +39,18 @@ function EnvironmentRow(props: { · 3 unmodified lines
3439 }, [label, url, props]);
3540
3641 return (
37− <Animated.View
38− layout={LinearTransition.duration(250)}
39− style={{ backgroundColor: props.palette.card }}
40− >
42+ <Animated.View layout={LinearTransition.duration(250)} className="bg-card">
4143 <Pressable className="flex-row items-center gap-3 px-4 py-3.5" onPress={props.onToggle}>
4244 <ConnectionStatusDot state={props.environment.connectionState} pulse={false} size={8} />
4345
4446 <View className="flex-1 gap-0.5">
4547 <Text
46− className="text-[16px] font-t3-bold leading-[21px]"
47− style={{ color: props.palette.text }}
48+ className="text-[16px] font-t3-bold leading-[21px] text-foreground"
4849 numberOfLines={1}
4950 >
5051 {props.environment.environmentLabel}
5152 </Text>
52− <Text
53− className="text-[12px] leading-[16px]"
54− style={{ color: props.palette.muted }}
55− numberOfLines={1}
56− >
53+ <Text className="text-[12px] leading-[16px] text-foreground-muted" numberOfLines={1}>
5754 {props.environment.displayUrl}
5855 </Text>
5956 </View>
@@ -61,7 +58,7 @@ function EnvironmentRow(props: { · 1 unmodified line
6158 <SymbolView
6259 name="chevron.down"
6360 size={12}
64− tintColor={props.palette.muted}
61+ tintColor={mutedColor}
6562 type="monochrome"
6663 style={{
6764 transform: [{ rotate: props.expanded ? "180deg" : "0deg" }],
@@ -77,8 +74,8 @@ function EnvironmentRow(props: { · 9 unmodified lines
7774 >
7875 <View className="gap-1.5">
7976 <Text
80− className="text-[11px] font-t3-bold uppercase"
81− style={{ color: props.palette.muted, letterSpacing: 0.8 }}
77+ className="text-[11px] font-t3-bold uppercase text-foreground-muted"
78+ style={{ letterSpacing: 0.8 }}
8279 >
8380 Label
8481 </Text>
@@ -86,23 +83,17 @@ function EnvironmentRow(props: { · 1 unmodified line
8683 autoCapitalize="words"
8784 autoCorrect={false}
8885 placeholder="My MacBook"
89− placeholderTextColor={props.palette.placeholder}
86+ placeholderTextColor={placeholderColor}
9087 value={label}
9188 onChangeText={setLabel}
92− className="rounded-[14px] px-4 py-3 text-[15px]"
93− style={{
94− backgroundColor: props.palette.inputBackground,
95− borderWidth: 1,
96− borderColor: props.palette.inputBorder,
97− color: props.palette.text,
98− }}
89+ className="rounded-[14px] border border-input-border bg-input px-4 py-3 text-[15px] text-foreground"
9990 />
10091 </View>
10192
10293 <View className="gap-1.5">
10394 <Text
104− className="text-[11px] font-t3-bold uppercase"
105− style={{ color: props.palette.muted, letterSpacing: 0.8 }}
95+ className="text-[11px] font-t3-bold uppercase text-foreground-muted"
96+ style={{ letterSpacing: 0.8 }}
10697 >
10798 URL
10899 </Text>
@@ -111,59 +102,32 @@ function EnvironmentRow(props: { · 2 unmodified lines
111102 autoCorrect={false}
112103 keyboardType="url"
113104 placeholder="192.168.1.100:8080"
114− placeholderTextColor={props.palette.placeholder}
105+ placeholderTextColor={placeholderColor}
115106 value={url}
116107 onChangeText={setUrl}
117− className="rounded-[14px] px-4 py-3 text-[15px]"
118− style={{
119− backgroundColor: props.palette.inputBackground,
120− borderWidth: 1,
121− borderColor: props.palette.inputBorder,
122− color: props.palette.text,
123− }}
108+ className="rounded-[14px] border border-input-border bg-input px-4 py-3 text-[15px] text-foreground"
124109 />
125110 </View>
126111
127112 <View className="flex-row gap-2">
128113 <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"
130115 onPress={handleSave}
131− style={{
132− backgroundColor: props.palette.primaryButton,
133− }}
134116 >
135− <SymbolView
136− name="checkmark"
137− size={13}
138− tintColor={props.palette.primaryButtonText}
139− type="monochrome"
140− />
117+ <SymbolView name="checkmark" size={13} tintColor={primaryFg} type="monochrome" />
141118 <Text
142− className="text-[12px] font-t3-bold uppercase"
143− style={{
144− color: props.palette.primaryButtonText,
145− letterSpacing: 0.8,
146− }}
119+ className="text-[12px] font-t3-bold uppercase text-primary-foreground"
120+ style={{ letterSpacing: 0.8 }}
147121 >
148122 Save
149123 </Text>
150124 </Pressable>
151125
152126 <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"
154128 onPress={() => props.onRemove(props.environment.environmentId)}
155− style={{
156− backgroundColor: props.palette.dangerButton,
157− borderWidth: 1,
158− borderColor: props.palette.dangerBorder,
159− }}
160129 >
161− <SymbolView
162− name="trash"
163− size={14}
164− tintColor={props.palette.dangerText}
165− type="monochrome"
166− />
130+ <SymbolView name="trash" size={14} tintColor={dangerFg} type="monochrome" />
167131 </Pressable>
168132 </View>
169133 </Animated.View>
@@ -176,30 +140,29 @@ export function ConnectionsRouteScreen() { · 6 unmodified lines
176140 const { connectedEnvironments, onRemoveEnvironmentPress, onUpdateEnvironment } =
177141 useRemoteConnections();
178142 const insets = useSafeAreaInsets();
179− const isDarkMode = useColorScheme() === "dark";
180− const palette = makePalette(isDarkMode);
181143 const hasEnvironments = connectedEnvironments.length > 0;
182144 const [expandedId, setExpandedId] = useState<string | null>(null);
183145
146+ const primaryFg = useThemeColor("--color-primary-foreground");
147+ const accentColor = useThemeColor("--color-icon-muted");
148+ const mutedColor = useThemeColor("--color-icon-subtle");
149+
184150 const handleToggle = useCallback((environmentId: string) => {
185151 setExpandedId((prev) => (prev === environmentId ? null : environmentId));
186152 }, []);
187153
188154 return (
189− <View collapsable={false} style={{ flex: 1, backgroundColor: palette.sheet }}>
155+ <View collapsable={false} className="flex-1 bg-sheet">
190156 <Stack.Screen
191157 options={{
192158 title: "Backends",
193159 headerRight: () => (
194160 <Link href="/connections/new" asChild>
195− <Pressable
196− className="h-10 w-10 items-center justify-center rounded-full"
197− style={{ backgroundColor: palette.primaryButton }}
198− >
161+ <Pressable className="h-10 w-10 items-center justify-center rounded-full bg-primary">
199162 <SymbolView
200163 name="plus"
201164 size={18}
202− tintColor={palette.primaryButtonText}
165+ tintColor={primaryFg}
203166 type="monochrome"
204167 weight="semibold"
205168 />
@@ -219,19 +182,15 @@ export function ConnectionsRouteScreen() { · 13 unmodified lines
219182 }}
220183 >
221184 {hasEnvironments ? (
222− <View
223− collapsable={false}
224− className="overflow-hidden rounded-[24px]"
225− style={{ backgroundColor: palette.card }}
226− >
185+ <View collapsable={false} className="overflow-hidden rounded-[24px] bg-card">
227186 {connectedEnvironments.map((environment, index) => (
228187 <View
229188 key={environment.environmentId}
230189 collapsable={false}
231190 style={{
232191 borderTopWidth: index === 0 ? 0 : 1,
233− borderTopColor: palette.border,
234192 }}
193+ className={cn(index !== 0 && "border-border")}
235194 >
236195 <EnvironmentRow
237196 environment={environment}
@@ -239,37 +198,23 @@ export function ConnectionsRouteScreen() { · 1 unmodified line
239198 onToggle={() => handleToggle(environment.environmentId)}
240199 onRemove={onRemoveEnvironmentPress}
241200 onUpdate={onUpdateEnvironment}
242− palette={palette}
243201 />
244202 </View>
245203 ))}
246204 </View>
247205 ) : (
248− <View
249− collapsable={false}
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− >
206+ <View collapsable={false} className="items-center gap-3 rounded-[24px] bg-card px-6 py-8">
207+ <View className="h-12 w-12 items-center justify-center rounded-[16px] bg-subtle">
257208 <SymbolView
258209 name="point.3.connected.trianglepath.dotted"
259210 size={20}
260− tintColor={palette.accent}
211+ tintColor={accentColor}
261212 type="monochrome"
262213 />
263214 </View>
264− <Text
265− className="text-center text-[14px] leading-[20px]"
266− style={{ color: palette.muted }}
267− >
215+ <Text className="text-center text-[14px] leading-[20px] text-foreground-muted">
268216 No backends connected yet.{"\n"}Tap{" "}
269− <Text className="font-t3-bold" style={{ color: palette.text }}>
270− +
271− </Text>{" "}
272− to add one.
217+ <Text className="font-t3-bold text-foreground">+</Text> to add one.
273218 </Text>
274219 </View>
275220 )}
apps/mobile/src/features/connection/NewConnectionRouteScreen.tsx+22−47
@@ -2,18 +2,18 @@ import { CameraView, useCameraPermissions } from "expo-camera"; · 1 unmodified line
22 import { Stack, useLocalSearchParams, useRouter } from "expo-router";
33 import { SymbolView } from "expo-symbols";
44 import { useCallback, useEffect, useState } from "react";
5−import { Alert, Pressable, ScrollView, View, useColorScheme } from "react-native";
5+import { Alert, Pressable, ScrollView, View } from "react-native";
66 import { useSafeAreaInsets } from "react-native-safe-area-context";
7+import { useThemeColor } from "../../lib/useThemeColor";
78
89 import { AppText as Text, AppTextInput as TextInput } from "../../components/AppText";
910 import { ErrorBanner } from "../../components/ErrorBanner";
1011 import { dismissRoute } from "../../lib/routes";
11−import { extractPairingUrlFromQrPayload } from "../../lib/pairingQr";
12+import { extractPairingUrlFromQrPayload } from "./pairingQr";
1213 import { useRemoteConnections } from "../../state/use-remote-environment-registry";
1314 import {
1415 buildPairingUrl,
1516 ConnectionSheetButton as SheetButton,
16− makeConnectionSheetPalette as makePalette,
1717 parsePairingUrl,
1818 } from "./connection-sheet-shared";
1919
@@ -28,8 +28,6 @@ export function NewConnectionRouteScreen() { · 8 unmodified lines
2828 const router = useRouter();
2929 const params = useLocalSearchParams<{ mode?: string }>();
3030 const insets = useSafeAreaInsets();
31− const isDarkMode = useColorScheme() === "dark";
32− const palette = makePalette(isDarkMode);
3331 const [hostInput, setHostInput] = useState("");
3432 const [codeInput, setCodeInput] = useState("");
3533 const [isSubmitting, setIsSubmitting] = useState(false);
@@ -37,6 +35,9 @@ export function NewConnectionRouteScreen() { · 1 unmodified line
3735 const [cameraPermission, requestCameraPermission] = useCameraPermissions();
3836 const [scannerLocked, setScannerLocked] = useState(false);
3937
38+ const textColor = useThemeColor("--color-icon");
39+ const placeholderColor = useThemeColor("--color-placeholder");
40+
4041 const connectDisabled =
4142 isSubmitting || connectionState === "connecting" || hostInput.trim().length === 0;
4243
@@ -125,13 +126,13 @@ export function NewConnectionRouteScreen() { · 82 unmodified lines
125126 }, [codeInput, hostInput, onChangeConnectionPairingUrl, onConnectPress, router]);
126127
127128 return (
128− <View collapsable={false} style={{ flex: 1, backgroundColor: palette.sheet }}>
129+ <View collapsable={false} className="flex-1 bg-sheet">
129130 <Stack.Screen
130131 options={{
131132 title: showScanner ? "Scan QR Code" : "Add Backend",
132133 headerRight: () => (
133134 <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"
135136 onPress={() => {
136137 if (showScanner) {
137138 closeScanner();
@@ -139,16 +140,11 @@ export function NewConnectionRouteScreen() { · 1 unmodified line
139140 void openScanner();
140141 }
141142 }}
142− style={{
143− backgroundColor: palette.secondaryButton,
144− borderWidth: 1,
145− borderColor: palette.border,
146− }}
147143 >
148144 <SymbolView
149145 name={showScanner ? "xmark" : "qrcode.viewfinder"}
150146 size={showScanner ? 14 : 18}
151− tintColor={palette.text}
147+ tintColor={textColor}
152148 type="monochrome"
153149 weight="semibold"
154150 />
@@ -182,20 +178,16 @@ export function NewConnectionRouteScreen() { · 27 unmodified lines
182178 </View>
183179 ) : (
184180 <View
185− className="items-center gap-3 rounded-[24px] px-5 py-8"
186− style={{ backgroundColor: palette.card, borderCurve: "continuous" }}
181+ className="items-center gap-3 rounded-[24px] bg-card px-5 py-8"
182+ style={{ borderCurve: "continuous" }}
187183 >
188− <Text
189− className="text-center text-[14px] leading-[20px]"
190− style={{ color: palette.muted }}
191− >
184+ <Text className="text-center text-[14px] leading-[20px] text-foreground-muted">
192185 Camera permission is required to scan a QR code.
193186 </Text>
194187 <SheetButton
195188 compact
196189 icon="camera"
197190 label="Allow camera"
198− palette={palette}
199191 tone="secondary"
200192 onPress={() => {
201193 void openScanner();
@@ -204,15 +196,11 @@ export function NewConnectionRouteScreen() { · 2 unmodified lines
204196 </View>
205197 )
206198 ) : (
207− <View
208− collapsable={false}
209− className="gap-4 rounded-[24px] p-4"
210− style={{ backgroundColor: palette.card }}
211− >
199+ <View collapsable={false} className="gap-4 rounded-[24px] bg-card p-4">
212200 <View collapsable={false} className="gap-1.5">
213201 <Text
214− className="text-[11px] font-t3-bold uppercase"
215− style={{ color: palette.muted, letterSpacing: 0.8 }}
202+ className="text-[11px] font-t3-bold uppercase text-foreground-muted"
203+ style={{ letterSpacing: 0.8 }}
216204 >
217205 Host
218206 </Text>
@@ -221,23 +209,17 @@ export function NewConnectionRouteScreen() { · 2 unmodified lines
221209 autoCorrect={false}
222210 keyboardType="url"
223211 placeholder="192.168.1.100:8080"
224− placeholderTextColor={palette.placeholder}
212+ placeholderTextColor={placeholderColor}
225213 value={hostInput}
226214 onChangeText={handleHostChange}
227− className="rounded-[14px] px-4 py-3.5 text-[15px]"
228− style={{
229− backgroundColor: palette.inputBackground,
230− borderWidth: 1,
231− borderColor: palette.inputBorder,
232− color: palette.text,
233− }}
215+ className="rounded-[14px] border border-input-border bg-input px-4 py-3.5 text-[15px] text-foreground"
234216 />
235217 </View>
236218
237219 <View collapsable={false} className="gap-1.5">
238220 <Text
239− className="text-[11px] font-t3-bold uppercase"
240− style={{ color: palette.muted, letterSpacing: 0.8 }}
221+ className="text-[11px] font-t3-bold uppercase text-foreground-muted"
222+ style={{ letterSpacing: 0.8 }}
241223 >
242224 Pairing code
243225 </Text>
@@ -245,16 +227,10 @@ export function NewConnectionRouteScreen() { · 1 unmodified line
245227 autoCapitalize="none"
246228 autoCorrect={false}
247229 placeholder="abc-123-xyz"
248− placeholderTextColor={palette.placeholder}
230+ placeholderTextColor={placeholderColor}
249231 value={codeInput}
250232 onChangeText={handleCodeChange}
251− className="rounded-[14px] px-4 py-3.5 text-[15px]"
252− style={{
253− backgroundColor: palette.inputBackground,
254− borderWidth: 1,
255− borderColor: palette.inputBorder,
256− color: palette.text,
257− }}
233+ className="rounded-[14px] border border-input-border bg-input px-4 py-3.5 text-[15px] text-foreground"
258234 />
259235 </View>
260236
@@ -263,10 +239,9 @@ export function NewConnectionRouteScreen() { · 2 unmodified lines
263239 <SheetButton
264240 icon="plus"
265241 label={
266− isSubmitting || connectionState === "connecting" ? "Pairing…" : "Add backend"
242+ isSubmitting || connectionState === "connecting" ? "Pairing..." : "Add backend"
267243 }
268244 disabled={connectDisabled}
269− palette={palette}
270245 tone="primary"
271246 onPress={() => {
272247 void handleSubmit();
apps/mobile/src/features/connection/connection-sheet-shared.tsx+42−77
@@ -1,7 +1,9 @@
11 import { SymbolView } from "expo-symbols";
22 import { Platform, Pressable } from "react-native";
3+import { useThemeColor } from "../../lib/useThemeColor";
34
45 import { AppText as Text } from "../../components/AppText";
6+import { cn } from "../../lib/cn";
57
68 export function parsePairingUrl(url: string): { host: string; code: string } {
79 const trimmed = url.trim();
@@ -38,73 +40,27 @@ export function buildPairingUrl(host: string, code: string): string { · 30 unmodified lines
3840 }
3941 }
4042
41−export function makeConnectionSheetPalette(isDarkMode: boolean) {
42− if (isDarkMode) {
43− return {
44− sheet: "rgba(18,20,23,0.98)",
45− card: "#1f2329",
46− cardAlt: "#242a31",
47− border: "rgba(255,255,255,0.06)",
48− text: "#f8fafc",
49− muted: "#94a3b8",
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− }
43+const CARD_SHADOW = Platform.select({
44+ ios: {
45+ shadowColor: "rgba(23,23,23,0.08)",
46+ shadowOffset: { width: 0, height: 4 },
47+ shadowOpacity: 1,
48+ shadowRadius: 16,
49+ },
50+ android: { elevation: 3 },
51+});
7452
75− return {
76− sheet: "rgba(246,244,239,0.98)",
77− card: "#fffdf9",
78− cardAlt: "#f8f4ec",
79− border: "rgba(23,23,23,0.06)",
80− text: "#171717",
81− muted: "#78716c",
82− subtle: "#57534e",
83− accent: "#a16207",
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−}
53+const CARD_SHADOW_DARK = Platform.select({
54+ ios: {
55+ shadowColor: "#000",
56+ shadowOffset: { width: 0, height: 2 },
57+ shadowOpacity: 0.18,
58+ shadowRadius: 8,
59+ },
60+ android: { elevation: 4 },
61+});
10662
107−export type ConnectionSheetPalette = ReturnType<typeof makeConnectionSheetPalette>;
63+export { CARD_SHADOW, CARD_SHADOW_DARK };
10864
10965 export function ConnectionSheetButton(props: {
11066 readonly icon: React.ComponentProps<typeof SymbolView>["name"];
@@ -112,27 +68,36 @@ export function ConnectionSheetButton(props: { · 1 unmodified line
11268 readonly disabled?: boolean;
11369 readonly tone?: "primary" | "secondary" | "danger";
11470 readonly compact?: boolean;
115− readonly palette: ConnectionSheetPalette;
11671 readonly onPress: () => void;
11772 }) {
11873 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+
11984 const colors =
12085 tone === "primary"
12186 ? {
122− backgroundColor: props.palette.primaryButton,
87+ backgroundColor: primaryBg,
12388 borderColor: "transparent",
124− textColor: props.palette.primaryButtonText,
89+ textColor: primaryFg,
12590 }
12691 : tone === "danger"
12792 ? {
128− backgroundColor: props.palette.dangerButton,
129− borderColor: props.palette.dangerBorder,
130− textColor: props.palette.dangerText,
93+ backgroundColor: dangerBg,
94+ borderColor: dangerBorderColor,
95+ textColor: dangerFg,
13196 }
13297 : {
133− backgroundColor: props.palette.secondaryButton,
134− borderColor: props.palette.border,
135− textColor: props.palette.secondaryButtonText,
98+ backgroundColor: secondaryBg,
99+ borderColor: borderColor,
100+ textColor: secondaryFg,
136101 };
137102
138103 const primaryShadow =
@@ -150,11 +115,11 @@ export function ConnectionSheetButton(props: { · 11 unmodified lines
150115
151116 return (
152117 <Pressable
153− className={
118+ className={cn(
154119 props.compact
155120 ? "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"
157− }
121+ : "min-h-[48px] flex-row items-center justify-center gap-2 rounded-[16px] px-4 py-3",
122+ )}
158123 disabled={props.disabled}
159124 onPress={props.onPress}
160125 style={[
apps/mobile/src/features/home/HomeRouteScreen.tsx+127−16
@@ -1,15 +1,37 @@
1−import { useFocusEffect, useRouter } from "expo-router";
2−import { useCallback } from "react";
1+import { Stack, useFocusEffect, useRouter } from "expo-router";
2+import { useCallback, useState } from "react";
3+import { Pressable, Text as RNText, View, useColorScheme } from "react-native";
4+import { useThemeColor } from "../../lib/useThemeColor";
35
46 import { buildThreadRoutePath } from "../../lib/routes";
7+import { ConnectionStatusDot } from "../connection/ConnectionStatusDot";
58 import { useRemoteCatalog } from "../../state/use-remote-catalog";
9+import { useRemoteEnvironmentState } from "../../state/use-remote-environment-registry";
610 import { useThreadSelection } from "../../state/use-thread-selection";
711 import { HomeScreen } from "./HomeScreen";
812
13+/* ─── Connection pill label ──────────────────────────────────────────── */
14+
15+const 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+
925 export function HomeRouteScreen() {
1026 const { connectionState, hasRemoteActivity, projects, threads } = useRemoteCatalog();
27+ const { savedConnectionsById } = useRemoteEnvironmentState();
1128 const { onBackFromThread, onSelectThread } = useThreadSelection();
1229 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";
1335
1436 useFocusEffect(
1537 useCallback(() => {
@@ -18,19 +40,108 @@ export function HomeRouteScreen() { · 2 unmodified lines
1840 );
1941
2042 return (
21− <HomeScreen
22− projects={projects}
23− threads={threads}
24− connectionState={connectionState}
25− connectionPulse={hasRemoteActivity}
26− onOpenConnectionEditor={() => router.push("/connections")}
27− onOpenNewTask={() => router.push("/new")}
28− onSelectThread={(thread) => {
29− onSelectThread(thread);
30− router.push(buildThreadRoutePath(thread));
31− }}
32− showFloatingConnectionButton
33− showFloatingNewTaskButton
34− />
43+ <>
44+ <Stack.Screen
45+ options={{
46+ headerShown: true,
47+ headerTransparent: true,
48+ headerStyle: { backgroundColor: "transparent" },
49+ headerShadowVisible: false,
50+ headerTintColor: iconColor,
51+ headerTitle: "",
52+ headerRight: () => (
53+ <Pressable
54+ onPress={() => router.push("/connections")}
55+ hitSlop={8}
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+ </>
35146 );
36147 }
apps/mobile/src/features/home/HomeScreen.tsx+315−181
@@ -1,215 +1,349 @@
1−import * as Haptics from "expo-haptics";
1+import type { GitStatusState } from "@t3tools/client-runtime";
22 import { SymbolView } from "expo-symbols";
3−import { useMemo, useState } from "react";
4−import { Pressable, ScrollView, useColorScheme, View } from "react-native";
5−import { useSafeAreaInsets } from "react-native-safe-area-context";
3+import { useCallback, useMemo, useState } from "react";
4+import { Pressable, ScrollView, View } from "react-native";
5+import { useThemeColor } from "../../lib/useThemeColor";
66
77 import { AppText as Text } from "../../components/AppText";
88 import { EmptyState } from "../../components/EmptyState";
9−import { StatusPill } from "../../components/StatusPill";
10−import { groupProjectsByRepository } from "../../lib/repositoryGroups";
9+import { ProjectFavicon } from "../../components/ProjectFavicon";
10+import type { SavedRemoteConnection } from "../../lib/connection";
1111 import type { ScopedMobileProject, ScopedMobileThread } from "../../lib/scopedEntities";
1212 import { scopedProjectKey } from "../../lib/scopedEntities";
1313 import { relativeTime } from "../../lib/time";
14−import { makeAppPalette } from "../../lib/theme";
15−import { ConnectionStatusDot } from "../connection/ConnectionStatusDot";
16−import { threadStatusTone } from "../threads/threadPresentation";
14+import { useGitStatus } from "../../state/use-git-status";
15+import { lastConversationLine, threadStatusTone } from "../threads/threadPresentation";
1716
18−export function HomeScreen(props: {
17+/* ─── Types ──────────────────────────────────────────────────────────── */
18+
19+interface HomeScreenProps {
1920 readonly projects: ReadonlyArray<ScopedMobileProject>;
2021 readonly threads: ReadonlyArray<ScopedMobileThread>;
21− readonly connectionState: "ready" | "connecting" | "reconnecting" | "disconnected" | "idle";
22− readonly connectionPulse: boolean;
23− readonly onOpenConnectionEditor: () => void;
24− readonly onOpenNewTask: () => void;
22+ readonly savedConnectionsById: Readonly<Record<string, SavedRemoteConnection>>;
23+ readonly searchQuery: string;
2524 readonly onSelectThread: (thread: ScopedMobileThread) => void;
26− readonly showFloatingConnectionButton: boolean;
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− }
25+}
5526
56− return map;
57− }, [repositoryGroups]);
27+interface ProjectGroup {
28+ readonly key: string;
29+ readonly project: ScopedMobileProject;
30+ readonly threads: ReadonlyArray<ScopedMobileThread>;
31+}
32+
33+/* ─── Status indicator colors ────────────────────────────────────────── */
34+
35+function 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+
50+const COLLAPSED_THREAD_LIMIT = 6;
51+
52+/* ─── Project group header ───────────────────────────────────────────── */
53+
54+function 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;
5863
5964 return (
60− <View style={{ flex: 1, backgroundColor: palette.screenBackground }}>
61− <ScrollView
62− showsVerticalScrollIndicator={false}
63− contentContainerStyle={{
64− paddingBottom: Math.max(insets.bottom, 24) + (props.showFloatingNewTaskButton ? 92 : 24),
65− paddingHorizontal: 20,
66− paddingTop: insets.top + 6,
67− gap: 16,
68− }}
65+ <View className="flex-row items-center gap-2.5 px-1 pb-2">
66+ <ProjectFavicon
67+ size={18}
68+ projectTitle={props.project.title}
69+ httpBaseUrl={props.httpBaseUrl}
70+ workspaceRoot={props.project.workspaceRoot}
71+ bearerToken={props.bearerToken}
72+ />
73+ <Text
74+ className="flex-1 text-[13px] font-t3-bold uppercase text-foreground-muted"
75+ style={{ letterSpacing: 0.6 }}
76+ numberOfLines={1}
6977 >
70− <View className="flex-row items-center justify-between">
71− <Text className="text-[34px] font-t3-bold" style={{ color: palette.text }}>
72− Recents
78+ {props.project.title}
79+ </Text>
80+
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`}
7388 </Text>
89+ </Pressable>
90+ ) : null}
91+ </View>
92+ );
93+}
7494
75− {props.showFloatingConnectionButton ? (
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>
95+/* ─── Git summary line ──────────────────────────────────────────────── */
8996
90− {props.threads.length === 0 ? (
91− <EmptyState
92− title="No threads yet"
93− detail="Create a task to start a new coding session in one of your connected projects."
94− />
95− ) : (
96− <View
97− className="overflow-hidden rounded-[24px]"
98− style={{ backgroundColor: palette.card }}
99− >
100− {props.threads.map((thread, index) => {
101− const projectKey = scopedProjectKey(thread.environmentId, thread.projectId);
102− const projectLabel = projectLabelsByKey.get(projectKey);
103−
104− return (
105− <Pressable
106− key={`${thread.environmentId}:${thread.id}`}
107− onPress={() => props.onSelectThread(thread)}
108− style={{
109− paddingHorizontal: 16,
110− paddingVertical: 13,
111− borderTopWidth: index === 0 ? 0 : 1,
112− borderTopColor: palette.borderSubtle,
113− }}
114− >
115− <View className="flex-row items-start justify-between gap-3">
116− <View className="flex-1 gap-1.5">
117− <Text
118− className="text-[17px] font-t3-bold leading-[24px]"
119− numberOfLines={2}
120− style={{ color: palette.text }}
121− >
122− {thread.title}
123− </Text>
124− </View>
125− <StatusPill {...threadStatusTone(thread)} size="compact" />
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>
97+function gitSummaryParts(gitStatus: GitStatusState): ReadonlyArray<string> {
98+ if (!gitStatus.data) return [];
99+ const { data } = gitStatus;
100+ const parts: string[] = [];
101+ if (data.hasWorkingTreeChanges) {
102+ parts.push(`${data.workingTree.files.length} changed`);
103+ }
104+ if (data.aheadCount > 0) parts.push(`${data.aheadCount} ahead`);
105+ if (data.behindCount > 0) parts.push(`${data.behindCount} behind`);
106+ if (data.pr?.state === "open") parts.push(`PR #${data.pr.number}`);
107+ return parts;
108+}
109+
110+/* ─── Thread row ─────────────────────────────────────────────────────── */
111+
112+function ThreadRow(props: {
113+ readonly thread: ScopedMobileThread;
114+ readonly projectCwd: string | null;
115+ readonly onPress: () => void;
116+ readonly isLast: boolean;
117+}) {
118+ const separatorColor = useThemeColor("--color-separator");
119+ const { bg, fg } = statusColors(props.thread);
120+ const tone = threadStatusTone(props.thread);
121+ const preview = lastConversationLine(props.thread);
122+ const timestamp = relativeTime(props.thread.updatedAt ?? props.thread.createdAt);
123+ const branch = props.thread.branch;
124+
125+ // Subscribe to live git status — only when thread has a branch set.
126+ // Threads sharing the same cwd share one WS subscription via ref-counting.
127+ const cwd = branch ? (props.thread.worktreePath ?? props.projectCwd) : null;
128+ const gitStatus = useGitStatus({
129+ environmentId: cwd ? props.thread.environmentId : null,
130+ cwd,
131+ });
132+ const gitParts = gitSummaryParts(gitStatus);
162133
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 */}
164148 <View
165− pointerEvents="box-none"
166149 style={{
167− position: "absolute",
168− right: 20,
169− bottom: Math.max(insets.bottom, 18),
150+ width: 30,
151+ height: 30,
152+ borderRadius: 9,
153+ backgroundColor: bg,
154+ alignItems: "center",
155+ justifyContent: "center",
156+ marginTop: 2,
170157 }}
171158 >
172− <Pressable
173− accessibilityRole="button"
174− hitSlop={6}
175− onPressIn={() => {
176− setNewTaskPressed(true);
177− void Haptics.selectionAsync();
178− }}
179− onPressOut={() => {
180− setNewTaskPressed(false);
181− }}
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− }}
159+ <SymbolView name="arrow.triangle.branch" size={13} tintColor={fg} type="monochrome" />
160+ </View>
161+
162+ {/* Content */}
163+ <View style={{ flex: 1, gap: 3 }}>
164+ {/* Title + Status + Timestamp */}
165+ <View className="flex-row items-center justify-between gap-2">
166+ <Text
167+ className="flex-1 text-[15px] font-t3-bold leading-[20px] text-foreground"
168+ numberOfLines={1}
196169 >
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 }}>
197198 <SymbolView
198− name="square.and.pencil"
199− size={20}
200− tintColor={palette.primaryButtonText}
199+ name="arrow.triangle.branch"
200+ size={10}
201+ tintColor="#737373"
201202 type="monochrome"
202203 />
203204 <Text
204− className="text-[18px] font-t3-bold"
205− style={{ color: palette.primaryButtonText }}
205+ className="text-[11px] text-foreground-tertiary"
206+ numberOfLines={1}
207+ style={{ fontFamily: "monospace" }}
206208 >
207− New task
209+ {branch}
208210 </Text>
211+ {gitParts.length > 0 ? (
212+ <Text className="text-[11px] text-foreground-tertiary">
213+ {" · " + gitParts.join(" · ")}
214+ </Text>
215+ ) : null}
209216 </View>
210− </Pressable>
217+ ) : null}
211218 </View>
212− ) : null}
213− </View>
219+ </View>
220+ </Pressable>
221+ );
222+}
223+
224+/* ─── Main screen ────────────────────────────────────────────────────── */
225+
226+export 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>
214348 );
215349 }
apps/mobile/src/features/threads/NewTaskDraftScreen.tsx+23−31
@@ -3,9 +3,10 @@ import { useRouter } from "expo-router"; · 2 unmodified lines
33 import { SymbolView } from "expo-symbols";
44 import { TextInputWrapper } from "expo-paste-input";
55 import { useCallback, useEffect, useMemo } from "react";
6−import { Pressable, useColorScheme, View } from "react-native";
6+import { Pressable, View, useColorScheme } from "react-native";
77 import Animated, { useAnimatedKeyboard, useAnimatedStyle } from "react-native-reanimated";
88 import { useSafeAreaInsets } from "react-native-safe-area-context";
9+import { useThemeColor } from "../../lib/useThemeColor";
910
1011 import type { ModelSelection } from "@t3tools/contracts";
1112 import { CLAUDE_CODE_EFFORT_OPTIONS } from "@t3tools/contracts";
@@ -17,7 +18,6 @@ import { ProviderIcon } from "../../components/ProviderIcon"; · 5 unmodified lines
1718
1819 import { convertPastedImagesToAttachments, pickComposerImages } from "../../lib/composerImages";
1920 import { buildThreadRoutePath } from "../../lib/routes";
20−import { makeAppPalette } from "../../lib/theme";
2121 import { useProjectActions } from "../../state/use-project-actions";
2222 import { useRemoteCatalog } from "../../state/use-remote-catalog";
2323 import { useNativePaste } from "../../hooks/useNativePaste";
@@ -33,9 +33,8 @@ export function NewTaskDraftScreen(props: { · 9 unmodified lines
3333 const { onCreateThreadWithOptions } = useProjectActions();
3434 const flow = useNewTaskFlow();
3535 const router = useRouter();
36− const isDarkMode = useColorScheme() === "dark";
37− const palette = makeAppPalette(isDarkMode);
3836 const insets = useSafeAreaInsets();
37+ const isDarkMode = useColorScheme() === "dark";
3938 const keyboard = useAnimatedKeyboard();
4039 const containerAnimatedStyle = useAnimatedStyle(() => ({
4140 paddingBottom: keyboard.height.value,
@@ -45,6 +44,9 @@ export function NewTaskDraftScreen(props: { · 3 unmodified lines
4544 }));
4645 const { logicalProjects, selectedProject, setProject } = flow;
4746
47+ const iconColor = useThemeColor("--color-icon");
48+ const borderColor = useThemeColor("--color-border");
49+
4850 useEffect(() => {
4951 if (props.initialProjectRef?.environmentId && props.initialProjectRef?.projectId) {
5052 const directProject =
@@ -384,54 +386,48 @@ export function NewTaskDraftScreen(props: { · 333 unmodified lines
384386
385387 if (!selectedProject) {
386388 return (
387− <View style={{ flex: 1, backgroundColor: palette.sheetBackground }}>
389+ <View className="flex-1 bg-sheet">
388390 <View style={{ minHeight: 16, paddingTop: 8 }} />
389391 <View className="items-center gap-1 px-5 pb-3 pt-4">
390392 <Text
391− className="text-[12px] font-t3-bold uppercase"
392− style={{ color: palette.textMuted, letterSpacing: 1 }}
393+ className="text-[12px] font-t3-bold uppercase text-foreground-muted"
394+ style={{ letterSpacing: 1 }}
393395 >
394396 New task
395397 </Text>
396− <Text className="text-[28px] font-t3-bold" style={{ color: palette.text }}>
397− Loading task
398− </Text>
398+ <Text className="text-[28px] font-t3-bold">Loading task</Text>
399399 </View>
400400 </View>
401401 );
402402 }
403403
404404 return (
405− <Animated.View
406− style={[{ flex: 1, backgroundColor: palette.sheetBackground }, containerAnimatedStyle]}
407− >
405+ <Animated.View className="flex-1 bg-sheet" style={containerAnimatedStyle}>
408406 <View style={{ minHeight: 16, paddingTop: 8 }} />
409407
410408 <View className="items-center gap-1 px-5 pb-3 pt-4">
411409 {flow.logicalProjects.length > 1 ? (
412410 <Pressable
413− className="absolute left-3 top-4 h-9 w-9 items-center justify-center rounded-full"
414− style={{ backgroundColor: palette.subtleBg, zIndex: 1 }}
411+ className="absolute left-3 top-4 h-9 w-9 items-center justify-center rounded-full bg-subtle"
412+ style={{ zIndex: 1 }}
415413 onPress={() => router.back()}
416414 >
417415 <SymbolView
418416 name="chevron.left"
419417 size={16}
420− tintColor={palette.icon}
418+ tintColor={iconColor}
421419 type="monochrome"
422420 weight="medium"
423421 />
424422 </Pressable>
425423 ) : null}
426424 <Text
427− className="text-[12px] font-t3-bold uppercase"
428− style={{ color: palette.textMuted, letterSpacing: 1 }}
425+ className="text-[12px] font-t3-bold uppercase text-foreground-muted"
426+ style={{ letterSpacing: 1 }}
429427 >
430428 New task
431429 </Text>
432− <Text className="text-[28px] font-t3-bold" style={{ color: palette.text }}>
433− {selectedProject.title}
434− </Text>
430+ <Text className="text-[28px] font-t3-bold">{selectedProject.title}</Text>
435431 </View>
436432
437433 <View style={{ flex: 1, paddingHorizontal: 20, paddingTop: 8 }}>
@@ -440,17 +436,9 @@ export function NewTaskDraftScreen(props: { · 2 unmodified lines
440436 multiline
441437 value={flow.prompt}
442438 onChangeText={flow.setPrompt}
443− placeholderTextColor={palette.placeholder}
444439 placeholder={`Describe a coding task in ${selectedProject.title}`}
445440 textAlignVertical="top"
446− style={{
447− flex: 1,
448− borderWidth: 0,
449− backgroundColor: "transparent",
450− color: palette.text,
451− fontSize: 18,
452− lineHeight: 28,
453− }}
441+ className="flex-1 border-0 bg-transparent text-[18px] leading-[28px]"
454442 />
455443 </TextInputWrapper>
456444 </View>
@@ -459,7 +447,7 @@ export function NewTaskDraftScreen(props: { · 2 unmodified lines
459447 style={[
460448 {
461449 borderTopWidth: 1,
462− borderTopColor: palette.border,
450+ borderTopColor: borderColor,
463451 },
464452 controlsBottomPadding,
465453 ]}
@@ -479,6 +467,7 @@ export function NewTaskDraftScreen(props: { · 13 unmodified lines
479467 <MenuView
480468 actions={modelMenuActions}
481469 onPressAction={({ nativeEvent }) => handleModelMenuAction(nativeEvent.event)}
470+ themeVariant={isDarkMode ? "dark" : "light"}
482471 >
483472 <ControlPill
484473 iconNode={<ProviderIcon provider={flow.selectedModel?.provider} size={16} />}
@@ -487,18 +476,21 @@ export function NewTaskDraftScreen(props: { · 2 unmodified lines
487476 <MenuView
488477 actions={optionsMenuActions}
489478 onPressAction={({ nativeEvent }) => handleOptionsMenuAction(nativeEvent.event)}
479+ themeVariant={isDarkMode ? "dark" : "light"}
490480 >
491481 <ControlPill icon="slider.horizontal.3" />
492482 </MenuView>
493483 <MenuView
494484 actions={environmentMenuActions}
495485 onPressAction={({ nativeEvent }) => handleEnvironmentMenuAction(nativeEvent.event)}
486+ themeVariant={isDarkMode ? "dark" : "light"}
496487 >
497488 <ControlPill icon="desktopcomputer" />
498489 </MenuView>
499490 <MenuView
500491 actions={workspaceMenuActions}
501492 onPressAction={({ nativeEvent }) => handleWorkspaceMenuAction(nativeEvent.event)}
493+ themeVariant={isDarkMode ? "dark" : "light"}
502494 >
503495 <ControlPill icon="point.topleft.down.curvedto.point.bottomright.up" />
504496 </MenuView>
apps/mobile/src/features/threads/PendingUserInputCard.tsx+4−4
@@ -2,7 +2,7 @@ import type { ApprovalRequestId } from "@t3tools/contracts"; · 1 unmodified line
22 import { Pressable, View } from "react-native";
33
44 import { AppText as Text, AppTextInput as TextInput } from "../../components/AppText";
5−import { cx } from "../../lib/classNames";
5+import { cn } from "../../lib/cn";
66 import type { PendingUserInput, PendingUserInputDraftAnswer } from "../../lib/threadActivity";
77
88 export interface PendingUserInputCardProps {
@@ -45,7 +45,7 @@ export function PendingUserInputCard(props: PendingUserInputCardProps) { · 36 unmodified lines
4545 return (
4646 <Pressable
4747 key={option.label}
48− className={cx(
48+ className={cn(
4949 "rounded-full border px-3 py-2.5 ",
5050 selected
5151 ? "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
6060 }
6161 >
6262 <Text
63− className={cx(
63+ className={cn(
6464 "font-t3-bold text-[13px]",
6565 selected
6666 ? "text-sky-700 dark:text-sky-300"
@@ -85,7 +85,7 @@ export function PendingUserInputCard(props: PendingUserInputCardProps) { · 18 unmodified lines
8585 );
8686 })}
8787 <Pressable
88− className={cx(
88+ className={cn(
8989 "items-center justify-center rounded-2xl px-4 py-3.5",
9090 props.answers ? "bg-blue-500" : "bg-neutral-200 dark:bg-neutral-700/60",
9191 )}
apps/mobile/src/features/threads/ProjectPickerSheet.tsx+0−135
@@ -1,135 +0,0 @@
1−import {
2− BottomSheetBackdrop,
3− BottomSheetModal,
4− BottomSheetScrollView,
5− BottomSheetView,
6−} from "@gorhom/bottom-sheet";
7−import { useCallback, useEffect, useMemo, useRef } from "react";
8−import { Pressable, useColorScheme, View } from "react-native";
9−import { useSafeAreaInsets } from "react-native-safe-area-context";
10−
11−import { AppText as Text } from "../../components/AppText";
12−import { groupProjectsByRepository } from "../../lib/repositoryGroups";
13−import { makeAppPalette } from "../../lib/theme";
14−import type { ScopedMobileProject, ScopedMobileThread } from "../../lib/scopedEntities";
15−
16−export 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
2020 type ViewStyle,
2121 } from "react-native";
2222 import ImageViewing from "react-native-image-viewing";
23+import { useThemeColor } from "../../lib/useThemeColor";
2324
2425 import { AppText as Text } from "../../components/AppText";
2526 import { ComposerAttachmentStrip } from "../../components/ComposerAttachmentStrip";
@@ -29,7 +30,6 @@ import type { DraftComposerImageAttachment } from "../../lib/composerImages"; · 3 unmodified lines
2930 import type { MobileLayoutVariant } from "../../lib/mobileLayout";
3031 import { buildModelOptions, groupByProvider } from "../../lib/modelOptions";
3132 import type { RemoteClientConnectionState } from "../../lib/remoteClient";
32−import { makeAppPalette } from "../../lib/theme";
3333 import { useNativePaste } from "../../hooks/useNativePaste";
3434
3535 /**
@@ -109,7 +109,8 @@ function ComposerSurface(props: { · 73 unmodified lines
109109
110110 export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposerProps) {
111111 const isDarkMode = useColorScheme() === "dark";
112− const palette = makeAppPalette(isDarkMode);
112+ const placeholderColor = useThemeColor("--color-placeholder");
113+ const foregroundColor = useThemeColor("--color-foreground");
113114 const inputRef = useRef<RNTextInput>(null);
114115 const [isFocused, setIsFocused] = useState(false);
115116 const wasExpandedBeforePreviewRef = useRef(false);
@@ -337,14 +338,11 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer · 221 unmodified lines
337338
338339 return (
339340 <View
341+ className="bg-screen"
340342 style={{
341343 paddingHorizontal: 16,
342344 paddingTop: isExpanded ? 12 : 10,
343345 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,
348346 }}
349347 >
350348 <View
@@ -395,7 +393,7 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer · 44 unmodified lines
395393 value={props.draftMessage}
396394 onChangeText={props.onChangeDraftMessage}
397395 placeholder={props.placeholder}
398− placeholderTextColor={palette.placeholder}
396+ placeholderTextColor={placeholderColor}
399397 editable={props.connectionState === "ready"}
400398 onFocus={() => setIsFocused(true)}
401399 onBlur={() => setIsFocused(false)}
@@ -409,7 +407,7 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer · 7 unmodified lines
409407 paddingVertical: 4,
410408 fontSize: 15,
411409 lineHeight: 22,
412− color: palette.text,
410+ color: foregroundColor,
413411 fontFamily: "DMSans_400Regular",
414412 }
415413 : {
@@ -417,7 +415,7 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer · 1 unmodified line
417415 paddingVertical: 6,
418416 fontSize: 15,
419417 lineHeight: 20,
420− color: palette.text,
418+ color: foregroundColor,
421419 fontFamily: "DMSans_400Regular",
422420 }
423421 }
@@ -430,11 +428,11 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer · 6 unmodified lines
430428 <Pressable key={image.id} onPress={() => onPressImage(image.previewUri)}>
431429 <Image
432430 source={{ uri: image.previewUri }}
431+ className="bg-subtle"
433432 style={{
434433 width: 30,
435434 height: 30,
436435 borderRadius: 8,
437− backgroundColor: palette.subtleBg,
438436 }}
439437 resizeMode="cover"
440438 />
@@ -442,22 +440,16 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer · 1 unmodified line
442440 ))}
443441 {props.draftAttachments.length > 3 ? (
444442 <View
443+ className="bg-subtle-strong"
445444 style={{
446445 width: 30,
447446 height: 30,
448447 borderRadius: 8,
449− backgroundColor: palette.subtleBgStrong,
450448 alignItems: "center",
451449 justifyContent: "center",
452450 }}
453451 >
454− <Text
455− style={{
456− fontSize: 11,
457− fontFamily: "DMSans_700Bold",
458− color: palette.textMuted,
459− }}
460− >
452+ <Text className="text-foreground-muted text-[11px] font-t3-bold">
461453 +{props.draftAttachments.length - 3}
462454 </Text>
463455 </View>
@@ -497,12 +489,14 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer · 33 unmodified lines
497489 <MenuView
498490 actions={modelMenuActions}
499491 onPressAction={({ nativeEvent }) => handleModelMenuAction(nativeEvent.event)}
492+ themeVariant={isDarkMode ? "dark" : "light"}
500493 >
501494 <ControlPill iconNode={<ProviderIcon provider={modelProvider} size={16} />} />
502495 </MenuView>
503496 <MenuView
504497 actions={optionsMenuActions}
505498 onPressAction={({ nativeEvent }) => handleOptionsMenuAction(nativeEvent.event)}
499+ themeVariant={isDarkMode ? "dark" : "light"}
506500 >
507501 <ControlPill icon="slider.horizontal.3" />
508502 </MenuView>
@@ -527,8 +521,8 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer · 18 unmodified lines
527521 {/* Queue count */}
528522 {props.queueCount > 0 ? (
529523 <Text
524+ className="text-foreground-muted"
530525 style={{
531− color: palette.textMuted,
532526 fontSize: 12,
533527 lineHeight: 18,
534528 paddingTop: 8,
apps/mobile/src/features/threads/ThreadDetailScreen.tsx+10−227
@@ -1,31 +1,23 @@
11 import type {
22 ApprovalRequestId,
3− GitBranch,
4− GitRunStackedActionResult,
5− GitStatusResult,
63 ModelSelection,
74 ProviderApprovalDecision,
85 ProviderInteractionMode,
96 RuntimeMode,
107 ServerConfig as T3ServerConfig,
118 } from "@t3tools/contracts";
12−import type { GitActionRequestInput } from "@t3tools/client-runtime";
139 import * as Haptics from "expo-haptics";
14−import { SymbolView } from "expo-symbols";
1510 import { useCallback, useEffect, useMemo, useRef, useState } from "react";
16−import { Modal, Pressable, useColorScheme, View } from "react-native";
11+import { View } from "react-native";
1712 import { Gesture, GestureDetector } from "react-native-gesture-handler";
1813 import { KeyboardAvoidingView, KeyboardStickyView } from "react-native-keyboard-controller";
1914 import { useSafeAreaInsets } from "react-native-safe-area-context";
20−import Animated, { runOnJS } from "react-native-reanimated";
15+import { runOnJS } from "react-native-reanimated";
2116
22−import { AppText as Text, AppTextInput as TextInput } from "../../components/AppText";
23−import { GlassSafeAreaView } from "../../components/GlassSafeAreaView";
2417 import type { StatusTone } from "../../components/StatusPill";
2518 import type { DraftComposerImageAttachment } from "../../lib/composerImages";
2619 import type { MobileLayoutVariant } from "../../lib/mobileLayout";
2720 import type { ScopedMobileThread } from "../../lib/scopedEntities";
28−import { makeAppPalette } from "../../lib/theme";
2921 import type {
3022 PendingApproval,
3123 PendingUserInput,
@@ -33,7 +25,6 @@ import type { · 1 unmodified line
3325 ThreadFeedEntry,
3426 } from "../../lib/threadActivity";
3527 import { PendingApprovalCard } from "./PendingApprovalCard";
36−import { ThreadGitControls } from "./ThreadGitControls";
3728 import { PendingUserInputCard } from "./PendingUserInputCard";
3829 import {
3930 COMPOSER_COLLAPSED_CHROME,
@@ -41,7 +32,6 @@ import { · 1 unmodified line
4132 ThreadComposer,
4233 } from "./ThreadComposer";
4334 import { ThreadFeed } from "./ThreadFeed";
44−import { screenTitle } from "./threadPresentation";
4535
4636 export interface ThreadDetailScreenProps {
4737 readonly selectedThread: ScopedMobileThread;
@@ -61,12 +51,10 @@ export interface ThreadDetailScreenProps { · 13 unmodified lines
6151 readonly draftAttachments: ReadonlyArray<DraftComposerImageAttachment>;
6252 readonly connectionStateLabel: "ready" | "connecting" | "reconnecting" | "disconnected" | "idle";
6353 readonly activeThreadBusy: boolean;
64− readonly selectedThreadGitStatus: GitStatusResult | null;
65− readonly gitOperationLabel: string | null;
54+ readonly projectWorkspaceRoot: string | null;
6655 readonly selectedThreadQueueCount: number;
6756 readonly serverConfig: T3ServerConfig | null;
6857 readonly layoutVariant?: MobileLayoutVariant;
69− readonly onBack: () => void;
7058 readonly onOpenDrawer: () => void;
7159 readonly onOpenConnectionEditor: () => void;
7260 readonly onChangeDraftMessage: (value: string) => void;
@@ -74,21 +62,6 @@ export interface ThreadDetailScreenProps { · 1 unmodified line
7462 readonly onNativePasteImages: (uris: ReadonlyArray<string>) => Promise<void>;
7563 readonly onRemoveDraftImage: (imageId: string) => void;
7664 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>;
9265 readonly onStopThread: () => Promise<void>;
9366 readonly onSendMessage: () => void;
9467 readonly onUpdateThreadModelSelection: (modelSelection: ModelSelection) => Promise<void>;
@@ -107,7 +80,6 @@ export interface ThreadDetailScreenProps { · 12 unmodified lines
10780 customAnswer: string,
10881 ) => void;
10982 readonly onSubmitUserInput: () => Promise<void>;
110− readonly showHeader?: boolean;
11183 readonly showContent?: boolean;
11284 }
11385
@@ -182,20 +154,15 @@ function useStreamingHaptics(threadId: string, feed: ReadonlyArray<ThreadFeedEnt · 68 unmodified lines
182154 }
183155
184156 export function ThreadDetailScreen(props: ThreadDetailScreenProps) {
185− const { onOpenDrawer, onRefresh, onRefreshSelectedThreadGitStatus } = props;
186− const isDarkMode = useColorScheme() === "dark";
187− const palette = makeAppPalette(isDarkMode);
157+ const { onOpenDrawer, onRefresh } = props;
158+
188159 const insets = useSafeAreaInsets();
189160 const agentLabel = `${props.selectedThread.modelSelection.provider} agent`;
190− const headerOverlayHeight = insets.top + 118;
191161 const composerBottomInset = Math.max(insets.bottom, 12);
192162 const [composerExpanded, setComposerExpanded] = useState(false);
193163 const composerChrome = composerExpanded ? COMPOSER_EXPANDED_CHROME : COMPOSER_COLLAPSED_CHROME;
194164 const composerOverlapHeight = composerChrome + composerBottomInset;
195− const [renameVisible, setRenameVisible] = useState(false);
196165 const [refreshing, setRefreshing] = useState(false);
197− const [renameDraft, setRenameDraft] = useState(props.selectedThread.title);
198− const showHeader = props.showHeader ?? true;
199166 const showContent = props.showContent ?? true;
200167 const layoutVariant = props.layoutVariant ?? "compact";
201168 const isSplitLayout = layoutVariant === "split";
@@ -214,12 +181,12 @@ export function ThreadDetailScreen(props: ThreadDetailScreenProps) { · 12 unmodified lines
214181 setRefreshing(true);
215182 try {
216183 await onRefresh();
217− await onRefreshSelectedThreadGitStatus();
218184 } finally {
219185 setRefreshing(false);
220186 }
221− }, [onRefresh, onRefreshSelectedThreadGitStatus, refreshing]);
187+ }, [onRefresh, refreshing]);
222188
189+ const drawerGestureThreshold = 80;
223190 const headerDrawerGesture = useMemo(
224191 () =>
225192 Gesture.Pan()
@@ -229,110 +196,16 @@ export function ThreadDetailScreen(props: ThreadDetailScreenProps) { · 3 unmodified lines
229196 .failOffsetY([-24, 24])
230197 .onEnd((event) => {
231198 const translationX = Math.max(event.translationX, 0);
232− if (event.y < headerOverlayHeight && translationX > 56) {
199+ if (event.y < drawerGestureThreshold && translationX > 56) {
233200 runOnJS(completeDrawerGesture)();
234201 }
235202 }),
236− [completeDrawerGesture, headerOverlayHeight, isSplitLayout],
203+ [completeDrawerGesture, isSplitLayout],
237204 );
238205
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−
249206 return (
250207 <GestureDetector gesture={headerDrawerGesture}>
251− <View style={{ flex: 1, backgroundColor: palette.screenBackground }}>
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−
208+ <View className="flex-1">
336209 {/* Feed area — KAV shrinks this when keyboard opens */}
337210 <KeyboardAvoidingView behavior="padding" style={{ flex: 1 }}>
338211 {showContent ? (
@@ -343,7 +216,6 @@ export function ThreadDetailScreen(props: ThreadDetailScreenProps) { · 4 unmodified lines
343216 httpBaseUrl={props.httpBaseUrl}
344217 bearerToken={props.bearerToken}
345218 agentLabel={agentLabel}
346− contentTopInset={headerOverlayHeight + 20}
347219 contentBottomInset={composerOverlapHeight + 8}
348220 layoutVariant={layoutVariant}
349221 composerExpanded={composerExpanded}
@@ -410,95 +282,6 @@ export function ThreadDetailScreen(props: ThreadDetailScreenProps) { · 60 unmodified lines
410282 />
411283 </KeyboardStickyView>
412284 ) : 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>
502285 </View>
503286 </GestureDetector>
504287 );
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
44 import { SymbolView } from "expo-symbols";
55 import { memo, useCallback, useEffect, useState, useRef } from "react";
66 import Markdown from "react-native-markdown-display";
7−import { Image, Pressable, ScrollView, useColorScheme, View } from "react-native";
7+import { Image, Pressable, ScrollView, View } from "react-native";
88 import { TouchableOpacity } from "react-native-gesture-handler";
99 import ImageViewing from "react-native-image-viewing";
10+import { useThemeColor } from "../../lib/useThemeColor";
1011
1112 import { AppText as Text } from "../../components/AppText";
1213 import { EmptyState } from "../../components/EmptyState";
13−import { cx } from "../../lib/classNames";
14+import { cn } from "../../lib/cn";
1415 import type { MobileLayoutVariant } from "../../lib/mobileLayout";
15−import { makeAppPalette } from "../../lib/theme";
1616 import type { ThreadFeedEntry } from "../../lib/threadActivity";
1717 import { relativeTime } from "../../lib/time";
1818 import { messageImageUrl } from "./threadPresentation";
@@ -23,7 +23,6 @@ export interface ThreadFeedProps { · 4 unmodified lines
2323 readonly httpBaseUrl: string | null;
2424 readonly bearerToken: string | null;
2525 readonly agentLabel: string;
26− readonly contentTopInset?: number;
2726 readonly contentBottomInset?: number;
2827 readonly refreshing?: boolean;
2928 readonly onRefresh?: () => void;
@@ -99,18 +98,26 @@ function buildActivityRows( · 69 unmodified lines
9998
10099 const MAX_VISIBLE_WORK_LOG_ENTRIES = 6;
101100
102−function makeMarkdownStyles(isDarkMode: boolean) {
103− const bodyColor = isDarkMode ? "#e5e5e5" : "#262626";
104− const strongColor = isDarkMode ? "#f5f5f5" : "#171717";
105− const linkColor = isDarkMode ? "#60a5fa" : "#2563eb";
106− const blockquoteBg = isDarkMode ? "rgba(255,255,255,0.03)" : "rgba(0,0,0,0.02)";
107− const blockquoteBorder = isDarkMode ? "rgba(255,255,255,0.10)" : "rgba(0,0,0,0.10)";
108− const codeBg = isDarkMode ? "rgba(255,255,255,0.06)" : "rgba(0,0,0,0.04)";
109− const codeText = isDarkMode ? "#e5e5e5" : "#262626";
110− const hrColor = isDarkMode ? "rgba(255,255,255,0.08)" : "rgba(0,0,0,0.08)";
111− const userCodeBg = isDarkMode ? "rgba(255,255,255,0.10)" : "rgba(0,0,0,0.05)";
112− const userCodeText = isDarkMode ? "#e5e5e5" : "#1e3a5f";
113− const userFenceBg = isDarkMode ? "rgba(255,255,255,0.08)" : "rgba(0,0,0,0.04)";
101+interface MarkdownStyleSets {
102+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
103+ readonly user: Record<string, any>;
104+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
105+ readonly assistant: Record<string, any>;
106+}
107+
108+function useMarkdownStyles(): MarkdownStyleSets {
109+ const bodyColor = useThemeColor("--color-md-body");
110+ const strongColor = useThemeColor("--color-md-strong");
111+ const linkColor = useThemeColor("--color-md-link");
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");
114121
115122 const base = {
116123 body: {
@@ -193,7 +200,7 @@ function makeMarkdownStyles(isDarkMode: boolean) { · 76 unmodified lines
193200 },
194201 code_block: {
195202 backgroundColor: userFenceBg,
196− color: userCodeText,
203+ color: userFenceText,
197204 borderRadius: 12,
198205 padding: 12,
199206 fontFamily: "ui-monospace",
@@ -202,7 +209,7 @@ function makeMarkdownStyles(isDarkMode: boolean) { · 2 unmodified lines
202209 },
203210 fence: {
204211 backgroundColor: userFenceBg,
205− color: userCodeText,
212+ color: userFenceText,
206213 borderRadius: 12,
207214 padding: 12,
208215 fontFamily: "ui-monospace",
@@ -253,12 +260,12 @@ function renderFeedEntry( · 44 unmodified lines
253260 readonly onCopyWorkRow: (rowId: string, value: string) => void;
254261 readonly onToggleWorkGroup: (groupId: string) => void;
255262 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;
257265 },
258266 ) {
259267 const entry = info.item;
260− const markdownStyles = makeMarkdownStyles(props.isDarkMode);
261− const palette = makeAppPalette(props.isDarkMode);
268+ const { markdownStyles, iconSubtleColor } = props;
262269
263270 if (entry.type === "message") {
264271 const { message } = entry;
@@ -389,18 +396,13 @@ function renderFeedEntry( · 124 unmodified lines
389396 {visibleRows.map((row, index) => (
390397 <View
391398 key={row.id}
392− className={cx(
399+ className={cn(
393400 "flex-row items-center gap-2 rounded-lg px-1 py-1",
394401 index > 0 && "border-t border-neutral-200/80 dark:border-white/[0.06]",
395402 )}
396403 >
397404 <View className="items-center justify-center pt-0.5">
398− <SymbolView
399− name="terminal"
400− size={13}
401− tintColor={palette.iconSubtle}
402− type="monochrome"
403− />
405+ <SymbolView name="terminal" size={13} tintColor={iconSubtleColor} type="monochrome" />
404406 </View>
405407 <ScrollView
406408 horizontal
@@ -439,7 +441,6 @@ function renderFeedEntry( · 32 unmodified lines
439441 }
440442
441443 export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) {
442− const isDarkMode = useColorScheme() === "dark";
443444 const listRef = useRef<LegendListRef>(null);
444445 const copyFeedbackTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
445446 const [copiedRowId, setCopiedRowId] = useState<string | null>(null);
@@ -450,6 +451,9 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { · 4 unmodified lines
450451 } | null>(null);
451452 const horizontalPadding = props.layoutVariant === "split" ? 20 : 16;
452453
454+ const iconSubtleColor = useThemeColor("--color-icon-subtle");
455+ const markdownStyles = useMarkdownStyles();
456+
453457 useEffect(() => {
454458 setCopiedRowId(null);
455459 setExpandedWorkGroups({});
@@ -509,12 +513,14 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { · 53 unmodified lines
509513 onCopyWorkRow,
510514 onToggleWorkGroup,
511515 onPressImage,
512− isDarkMode,
516+ iconSubtleColor,
517+ markdownStyles,
513518 }),
514519 [
515520 copiedRowId,
516521 expandedWorkGroups,
517− isDarkMode,
522+ iconSubtleColor,
523+ markdownStyles,
518524 onCopyWorkRow,
519525 onPressImage,
520526 onToggleWorkGroup,
@@ -525,12 +531,12 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { · 4 unmodified lines
525531
526532 if (props.feed.length === 0) {
527533 return (
528− <View
529− className="flex-1"
530− style={{
531− minHeight: 0,
534+ <ScrollView
535+ style={{ flex: 1 }}
536+ contentInsetAdjustmentBehavior="automatic"
537+ contentContainerStyle={{
538+ flexGrow: 1,
532539 paddingHorizontal: horizontalPadding,
533− paddingTop: props.contentTopInset ?? 18,
534540 paddingBottom: props.contentBottomInset ?? 18,
535541 }}
536542 >
@@ -538,7 +544,7 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { · 1 unmodified line
538544 title="No conversation yet"
539545 detail="Ask the agent to inspect the repo, run a command, or continue the active thread."
540546 />
541− </View>
547+ </ScrollView>
542548 );
543549 }
544550
@@ -548,6 +554,7 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { · 3 unmodified lines
548554 ref={listRef}
549555 key={props.threadId}
550556 style={{ flex: 1 }}
557+ contentInsetAdjustmentBehavior="automatic"
551558 data={props.feed as ThreadFeedEntry[]}
552559 renderItem={renderItem}
553560 keyExtractor={(entry) => `${entry.type}:${entry.id}`}
@@ -561,7 +568,7 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { · 7 unmodified lines
561568 onRefresh={props.onRefresh}
562569 contentContainerStyle={{
563570 paddingHorizontal: horizontalPadding,
564− paddingTop: props.contentTopInset ?? 18,
571+ paddingTop: 8,
565572 paddingBottom: props.contentBottomInset ?? 18,
566573 }}
567574 />
apps/mobile/src/features/threads/ThreadGitControls.tsx+66−1172
@@ -1,93 +1,13 @@
1+import type { GitRunStackedActionResult, GitStatusResult } from "@t3tools/contracts";
12 import {
2− BottomSheetBackdrop,
3− BottomSheetModal,
4− BottomSheetScrollView,
5− BottomSheetView,
6−} from "@gorhom/bottom-sheet";
7−import { MenuView } from "@react-native-menu/menu";
8−import type { GitBranch, GitRunStackedActionResult, GitStatusResult } from "@t3tools/contracts";
9−import {
10− buildMenuItems,
11− getGitActionDisabledReason,
123 type GitActionRequestInput,
134 requiresDefaultBranchConfirmation,
14− resolveDefaultBranchActionDialogCopy,
155 resolveQuickAction,
166 } from "@t3tools/client-runtime";
17−import { resolveAutoFeatureBranchName, sanitizeFeatureBranchName } from "@t3tools/shared/git";
18−import { SymbolView } from "expo-symbols";
19−import type { ComponentProps, RefObject } from "react";
20−import { useCallback, useEffect, useMemo, useRef, useState } from "react";
21−import { Alert, Linking, Pressable, useColorScheme, View } from "react-native";
22−import { useSafeAreaInsets } from "react-native-safe-area-context";
23−
24−import { AppText as Text, AppTextInput as TextInput } from "../../components/AppText";
25−import { StatusPill } from "../../components/StatusPill";
26−import { makeAppPalette } from "../../lib/theme";
27−
28−type 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−
39−function 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−
55−function 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−
85−function deriveAvailableBranchLabel(
86− currentBranch: string | null,
87− gitStatus: GitStatusResult | null,
88−): string {
89− return gitStatus?.branch ?? currentBranch ?? "Detached HEAD";
90−}
7+import { useLocalSearchParams, useRouter } from "expo-router";
8+import Stack from "expo-router/stack";
9+import { useCallback, useMemo } from "react";
10+import { Alert, Linking } from "react-native";
9111
9212 function truncateMiddle(value: string, maxLength: number): string {
9313 if (value.length <= maxLength) {
@@ -130,211 +50,26 @@ function compactMenuStatus(gitStatus: GitStatusResult | null): string { · 36 unmodified lines
13050 return parts.join(" · ");
13151 }
13252
133−function 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−
161−function 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−
169−function 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−
221−function 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−
247−function 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−
28453 export function ThreadGitControls(props: {
28554 readonly currentBranch: string | null;
286− readonly currentWorktreePath: string | null;
28755 readonly gitStatus: GitStatusResult | null;
28856 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>;
29757 readonly onPull: () => Promise<void>;
29858 readonly onRunAction: (input: GitActionRequestInput) => Promise<GitRunStackedActionResult | null>;
29959 }) {
300− const isDarkMode = useColorScheme() === "dark";
301− const palette = makeAppPalette(isDarkMode);
302− const insets = useSafeAreaInsets();
303− const {
304− currentWorktreePath,
305− gitOperationLabel,
306− gitStatus,
307− onCheckoutBranch,
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);
60+ const router = useRouter();
61+ const { environmentId, threadId } = useLocalSearchParams<{
62+ environmentId: string;
63+ threadId: string;
64+ }>();
65+ const { gitStatus, gitOperationLabel, onPull, onRunAction } = props;
66+
67+ const currentBranchLabel = gitStatus?.branch ?? props.currentBranch ?? "Detached HEAD";
33468 const busy = gitOperationLabel !== null;
33569 const isRepo = gitStatus?.isRepo ?? true;
33670 const hasOriginRemote = gitStatus?.hasOriginRemote ?? false;
33771 const isDefaultBranch = gitStatus?.isDefaultBranch ?? false;
72+
33873 const quickAction = useMemo(
33974 () =>
34075 isRepo
@@ -347,163 +82,21 @@ export function ThreadGitControls(props: { · 6 unmodified lines
34782 },
34883 [busy, gitStatus, hasOriginRemote, isDefaultBranch, isRepo],
34984 );
350− const menuItems = useMemo(
351− () => (isRepo ? buildMenuItems(gitStatus, busy, hasOriginRemote) : []),
352− [busy, gitStatus, hasOriginRemote, isRepo],
353− );
85+
35486 const quickActionHint = quickAction.disabled
35587 ? (quickAction.hint ?? "This action is unavailable.")
35688 : 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− );
47089
471− useSheetVisibility(gitSheetRef, gitSheetVisible);
472− useSheetVisibility(branchSheetRef, branchSheetVisible);
473− useSheetVisibility(commitSheetRef, commitSheetVisible);
474− useSheetVisibility(defaultBranchSheetRef, defaultBranchSheetVisible);
475−
476− useEffect(() => {
477− if (!gitSheetVisible) {
478− return;
479− }
480− void onRefreshStatus({ quiet: true });
481− }, [gitSheetVisible, onRefreshStatus]);
482−
483− useEffect(() => {
484− if (!branchSheetVisible) {
485− return;
90+ const quickActionIcon = (() => {
91+ if (quickAction.kind === "run_pull") return "arrow.down.circle";
92+ if (quickAction.kind === "open_pr") return "arrow.up.right.circle";
93+ if (quickAction.kind === "run_action") {
94+ if (quickAction.action === "commit") return "checkmark.circle";
95+ if (quickAction.action === "push" || quickAction.action === "commit_push")
96+ return "arrow.up.circle";
48697 }
487−
488− setWorktreeBaseBranch(currentBranchLabel === "Detached HEAD" ? "main" : currentBranchLabel);
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− }, []);
98+ return "arrow.up.right.circle";
99+ })();
507100
508101 const openExistingPr = useCallback(async () => {
509102 const prUrl = gitStatus?.pr?.state === "open" ? gitStatus.pr.url : null;
@@ -537,64 +130,26 @@ export function ThreadGitControls(props: { · 27 unmodified lines
537130 !input.featureBranch &&
538131 requiresDefaultBranchConfirmation(input.action, isDefaultBranch)
539132 ) {
540− setPendingDefaultBranchAction({
541− action: confirmableAction,
542− branchName,
543− includesCommit: input.action === "commit_push" || input.action === "commit_push_pr",
544− ...(input.commitMessage ? { commitMessage: input.commitMessage } : {}),
545− ...(input.filePaths ? { filePaths: input.filePaths } : {}),
133+ router.push({
134+ pathname: "/threads/[environmentId]/[threadId]/git-confirm",
135+ params: {
136+ environmentId,
137+ threadId,
138+ confirmAction: confirmableAction,
139+ branchName,
140+ includesCommit: String(
141+ input.action === "commit_push" || input.action === "commit_push_pr",
142+ ),
143+ },
546144 });
547− setDefaultBranchSheetVisible(true);
548145 return;
549146 }
550147
551− closeTransientSheets();
552148 await onRunAction(input);
553149 },
554− [closeTransientSheets, gitStatus, isDefaultBranch, onRunAction],
150+ [environmentId, gitStatus, isDefaultBranch, onRunAction, router, threadId],
555151 );
556152
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−
598153 const runQuickAction = useCallback(async () => {
599154 if (quickAction.kind === "open_pr") {
600155 await openExistingPr();
@@ -609,699 +164,38 @@ export function ThreadGitControls(props: { · 8 unmodified lines
609164 }
610165 }, [onPull, openExistingPr, quickAction, runActionWithPrompt]);
611166
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−
666167 return (
667− <>
668− <MenuView actions={nativeMenuActions} onPressAction={onPressNativeMenuAction}>
669− <Pressable
670− className="flex-row items-center gap-2 rounded-full px-3 py-2"
671− style={{ backgroundColor: palette.subtleBg }}
168+ <Stack.Toolbar placement="right">
169+ <Stack.Toolbar.Menu icon="point.topleft.down.curvedto.point.bottomright.up">
170+ <Stack.Toolbar.MenuAction
171+ icon="point.topleft.down.curvedto.point.bottomright.up"
172+ disabled
173+ onPress={() => {}}
174+ subtitle={compactMenuStatus(gitStatus)}
672175 >
673− <SymbolView
674− name="point.topleft.down.curvedto.point.bottomright.up"
675− size={14}
676− tintColor={palette.icon}
677− type="monochrome"
678− />
679− <View className="shrink">
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 }}
176+ <Stack.Toolbar.Label>{compactMenuBranchLabel(currentBranchLabel)}</Stack.Toolbar.Label>
177+ </Stack.Toolbar.MenuAction>
178+ <Stack.Toolbar.MenuAction
179+ icon={quickActionIcon}
180+ disabled={quickAction.disabled}
181+ onPress={() => void runQuickAction()}
182+ subtitle={quickActionHint ?? undefined}
1079183 >
1080− <View className="gap-1">
1081− <Text className="text-[22px] font-t3-bold" style={{ color: palette.text }}>
1082− {pendingDefaultBranchActionCopy?.title ?? "Run action on default branch?"}
1083− </Text>
1084− <Text
1085− className="text-[13px] font-medium leading-[19px]"
1086− style={{ color: palette.textSecondary }}
1087− >
1088− {pendingDefaultBranchActionCopy?.description ?? "Choose how to continue."}
1089− </Text>
1090− </View>
1091−
1092− <View className="gap-3">
1093− <SheetActionButton
1094− icon="arrow.right.circle"
1095− label={pendingDefaultBranchActionCopy?.continueLabel ?? "Continue"}
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− </>
184+ <Stack.Toolbar.Label>{quickAction.label}</Stack.Toolbar.Label>
185+ </Stack.Toolbar.MenuAction>
186+ <Stack.Toolbar.MenuAction
187+ icon="ellipsis.circle"
188+ onPress={() =>
189+ router.push({
190+ pathname: "/threads/[environmentId]/[threadId]/git",
191+ params: { environmentId, threadId },
192+ })
193+ }
194+ subtitle="Commit, files, branches"
195+ >
196+ <Stack.Toolbar.Label>More</Stack.Toolbar.Label>
197+ </Stack.Toolbar.MenuAction>
198+ </Stack.Toolbar.Menu>
199+ </Stack.Toolbar>
1306200 );
1307201 }
apps/mobile/src/features/threads/ThreadListScreen.tsx+66−129
@@ -10,14 +10,15 @@ import { · 9 unmodified lines
1010 type View as RNView,
1111 } from "react-native";
1212 import Animated, { FadeInDown, LinearTransition } from "react-native-reanimated";
13+import { useThemeColor } from "../../lib/useThemeColor";
1314
1415 import { BrandMark } from "../../components/BrandMark";
1516 import { AppText as Text } from "../../components/AppText";
1617 import { EmptyState } from "../../components/EmptyState";
1718 import { GlassSafeAreaView } from "../../components/GlassSafeAreaView";
1819 import { StatusPill } from "../../components/StatusPill";
20+import { cn } from "../../lib/cn";
1921 import type { MobileLayout } from "../../lib/mobileLayout";
20−import { makeAppPalette } from "../../lib/theme";
2122 import {
2223 scopedThreadKey,
2324 type ScopedMobileProject,
@@ -70,32 +71,31 @@ function ActionButton(props: { · 46 unmodified lines
7071 readonly primary?: boolean;
7172 readonly onPress: () => void;
7273 }) {
73− const isDarkMode = useColorScheme() === "dark";
74− const palette = makeAppPalette(isDarkMode);
74+ const primaryForeground = useThemeColor("--color-primary-foreground");
75+ const secondaryForeground = useThemeColor("--color-secondary-foreground");
76+ const tintColor = props.primary ? primaryForeground : secondaryForeground;
7577
7678 return (
7779 <Pressable
78− className="min-h-[50px] flex-1 flex-row items-center justify-center gap-2 rounded-[16px] px-3 py-3"
79− style={{
80− backgroundColor: props.primary ? palette.primaryButton : palette.secondaryButton,
81− borderWidth: props.primary ? 0 : 1,
82− borderColor: props.primary ? "transparent" : palette.secondaryButtonBorder,
83− }}
80+ className={cn(
81+ "min-h-[50px] flex-1 flex-row items-center justify-center gap-2 rounded-[16px] px-3 py-3",
82+ props.primary ? "bg-primary" : "border border-secondary-border bg-secondary",
83+ )}
8484 onPress={props.onPress}
8585 >
8686 <SymbolView
8787 name={props.icon}
8888 size={18}
89− tintColor={props.primary ? palette.primaryButtonText : palette.secondaryButtonText}
89+ tintColor={tintColor}
9090 type="monochrome"
9191 weight="medium"
9292 />
9393 <Text
94− className="text-[13px] font-t3-bold uppercase"
95− style={{
96− color: props.primary ? palette.primaryButtonText : palette.secondaryButtonText,
97− letterSpacing: 0.9,
98− }}
94+ className={cn(
95+ "text-[13px] font-t3-bold uppercase",
96+ props.primary ? "text-primary-foreground" : "text-secondary-foreground",
97+ )}
98+ style={{ letterSpacing: 0.9 }}
9999 >
100100 {props.label}
101101 </Text>
@@ -108,35 +108,20 @@ function StatCard(props: { · 6 unmodified lines
108108 readonly value: string;
109109 readonly icon: React.ComponentProps<typeof SymbolView>["name"];
110110 }) {
111− const isDarkMode = useColorScheme() === "dark";
112− const palette = makeAppPalette(isDarkMode);
111+ const foregroundSecondary = useThemeColor("--color-foreground-secondary");
113112
114113 return (
115− <View
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− >
114+ <View className="flex-1 gap-2 rounded-[18px] border border-border bg-card px-4 py-4">
123115 <View className="flex-row items-center gap-2">
124− <SymbolView
125− name={props.icon}
126− size={15}
127− tintColor={palette.textSecondary}
128− type="monochrome"
129− />
116+ <SymbolView name={props.icon} size={15} tintColor={foregroundSecondary} type="monochrome" />
130117 <Text
131− className="text-[11px] font-t3-bold uppercase"
132− style={{ color: palette.textSecondary, letterSpacing: 1 }}
118+ className="text-[11px] font-t3-bold uppercase text-foreground-secondary"
119+ style={{ letterSpacing: 1 }}
133120 >
134121 {props.label}
135122 </Text>
136123 </View>
137− <Text className="text-[28px] font-t3-bold" style={{ color: palette.text }}>
138− {props.value}
139− </Text>
124+ <Text className="text-[28px] font-t3-bold text-foreground">{props.value}</Text>
140125 </View>
141126 );
142127 }
@@ -148,19 +133,20 @@ function ThreadRow(props: { · 5 unmodified lines
148133 readonly onPress: (sourceFrame: TransitionSourceFrame | null) => void;
149134 }) {
150135 const isDarkMode = useColorScheme() === "dark";
151− const palette = makeAppPalette(isDarkMode);
136+ const cardColor = useThemeColor("--color-card");
137+ const borderColor = useThemeColor("--color-border");
152138 const containerRef = useRef<RNView>(null);
153139 const tone = threadStatusTone(props.thread);
154140 const backgroundColor = props.selected
155141 ? isDarkMode
156142 ? "rgba(249,115,22,0.12)"
157143 : "rgba(249,115,22,0.09)"
158− : palette.card;
159− const borderColor = props.selected
144+ : cardColor;
145+ const resolvedBorderColor = props.selected
160146 ? isDarkMode
161147 ? "rgba(249,115,22,0.32)"
162148 : "rgba(249,115,22,0.28)"
163− : palette.border;
149+ : borderColor;
164150
165151 return (
166152 <Pressable
@@ -168,7 +154,7 @@ function ThreadRow(props: { · 1 unmodified line
168154 className="gap-3 rounded-[18px] border px-4 py-4"
169155 style={{
170156 backgroundColor,
171− borderColor,
157+ borderColor: resolvedBorderColor,
172158 opacity: props.hidden ? 0 : 1,
173159 }}
174160 onPress={() => {
@@ -183,13 +169,8 @@ function ThreadRow(props: { · 8 unmodified lines
183169 >
184170 <View className="flex-row items-start justify-between gap-3">
185171 <View className="flex-1 gap-2">
186− <Text className="text-[17px] font-t3-bold" style={{ color: palette.text }}>
187− {props.thread.title}
188− </Text>
189− <Text
190− className="text-[13px] font-medium leading-[19px]"
191− style={{ color: palette.textSecondary }}
192− >
172+ <Text className="text-[17px] font-t3-bold text-foreground">{props.thread.title}</Text>
173+ <Text className="text-[13px] font-medium leading-[19px] text-foreground-secondary">
193174 {lastConversationLine(props.thread)}
194175 </Text>
195176 </View>
@@ -197,8 +178,8 @@ function ThreadRow(props: { · 1 unmodified line
197178 </View>
198179
199180 <Text
200− className="text-[11px] font-t3-bold uppercase"
201− style={{ color: palette.textMuted, letterSpacing: 0.9 }}
181+ className="text-[11px] font-t3-bold uppercase text-foreground-muted"
182+ style={{ letterSpacing: 0.9 }}
202183 >
203184 {props.thread.environmentLabel} · {props.thread.modelSelection.provider} ·{" "}
204185 {relativeTime(props.thread.updatedAt ?? props.thread.createdAt)}
@@ -218,34 +199,28 @@ function ProjectSection(props: { · 13 unmodified lines
218199 sourceFrame: TransitionSourceFrame | null,
219200 ) => void;
220201 }) {
221− const isDarkMode = useColorScheme() === "dark";
222− const palette = makeAppPalette(isDarkMode);
223202 const [expanded, setExpanded] = useState(false);
224203 const visibleThreads = expanded ? props.threads : props.threads.slice(0, 2);
225204 const hiddenCount = Math.max(props.threads.length - visibleThreads.length, 0);
226205
227206 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">
229208 <View className="flex-row items-start justify-between gap-3">
230209 <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">
232211 {props.project.environmentLabel}
233212 </Text>
234− <Text
235− className="text-[12px] font-medium leading-[18px]"
236− style={{ color: palette.textSecondary }}
237− >
213+ <Text className="text-[12px] font-medium leading-[18px] text-foreground-secondary">
238214 {props.project.workspaceRoot}
239215 </Text>
240216 </View>
241217 <Pressable
242− className="rounded-full px-3 py-2"
243− style={{ backgroundColor: palette.subtleBg }}
218+ className="rounded-full bg-subtle px-3 py-2"
244219 onPress={() => void props.onCreateThread(props.project)}
245220 >
246221 <Text
247− className="text-[11px] font-t3-bold uppercase"
248− style={{ color: palette.text, letterSpacing: 0.9 }}
222+ className="text-[11px] font-t3-bold uppercase text-foreground"
223+ style={{ letterSpacing: 0.9 }}
249224 >
250225 New thread
251226 </Text>
@@ -273,13 +248,12 @@ function ProjectSection(props: { · 21 unmodified lines
273248
274249 {hiddenCount > 0 ? (
275250 <Pressable
276− className="items-center rounded-[16px] px-3 py-3"
277− style={{ backgroundColor: palette.separator }}
251+ className="items-center rounded-[16px] bg-separator px-3 py-3"
278252 onPress={() => setExpanded((current) => !current)}
279253 >
280254 <Text
281− className="text-[11px] font-t3-bold uppercase"
282− style={{ color: palette.textSecondary, letterSpacing: 1 }}
255+ className="text-[11px] font-t3-bold uppercase text-foreground-secondary"
256+ style={{ letterSpacing: 1 }}
283257 >
284258 {expanded ? "Show less" : `Show ${hiddenCount} more`}
285259 </Text>
@@ -297,28 +271,19 @@ function CreateThreadModal(props: { · 11 unmodified lines
297271 readonly onClose: () => void;
298272 readonly onSelectProject: (project: ScopedMobileProject) => Promise<void>;
299273 }) {
300− const isDarkMode = useColorScheme() === "dark";
301− const palette = makeAppPalette(isDarkMode);
302−
303274 if (!props.group) {
304275 return null;
305276 }
306277
307278 return (
308279 <Modal transparent animationType="slide" visible={props.visible} onRequestClose={props.onClose}>
309− <View className="flex-1 justify-end" style={{ backgroundColor: palette.backdrop }}>
310− <View
311− className="gap-4 rounded-t-[28px] px-5 pb-8 pt-5"
312− style={{ backgroundColor: palette.screenBackground }}
313− >
280+ <View className="flex-1 justify-end bg-backdrop">
281+ <View className="gap-4 rounded-t-[28px] bg-screen px-5 pb-8 pt-5">
314282 <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">
316284 New thread in {props.group.title}
317285 </Text>
318− <Text
319− className="text-[13px] font-medium leading-[19px]"
320− style={{ color: palette.textSecondary }}
321− >
286+ <Text className="text-[13px] font-medium leading-[19px] text-foreground-secondary">
322287 Choose which environment should own the new thread.
323288 </Text>
324289 </View>
@@ -327,31 +292,24 @@ function CreateThreadModal(props: { · 2 unmodified lines
327292 {props.group.projects.map(({ project, threads }) => (
328293 <Pressable
329294 key={project.environmentId + project.id}
330− className="gap-2 rounded-[20px] border px-4 py-4"
331− style={{ backgroundColor: palette.card, borderColor: palette.border }}
295+ className="gap-2 rounded-[20px] border border-border bg-card px-4 py-4"
332296 onPress={() => {
333297 void props.onSelectProject(project).then(props.onClose);
334298 }}
335299 >
336300 <View className="flex-row items-start justify-between gap-3">
337301 <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">
339303 {project.environmentLabel}
340304 </Text>
341− <Text
342− className="text-[12px] font-medium leading-[18px]"
343− style={{ color: palette.textSecondary }}
344− >
305+ <Text className="text-[12px] font-medium leading-[18px] text-foreground-secondary">
345306 {project.workspaceRoot}
346307 </Text>
347308 </View>
348− <View
349− className="rounded-full px-3 py-2"
350− style={{ backgroundColor: palette.subtleBg }}
351− >
309+ <View className="rounded-full bg-subtle px-3 py-2">
352310 <Text
353− className="text-[11px] font-t3-bold uppercase"
354− style={{ color: palette.text, letterSpacing: 0.9 }}
311+ className="text-[11px] font-t3-bold uppercase text-foreground"
312+ style={{ letterSpacing: 0.9 }}
355313 >
356314 {threads.length} thread{threads.length === 1 ? "" : "s"}
357315 </Text>
@@ -369,8 +327,7 @@ function CreateThreadModal(props: { · 11 unmodified lines
369327 }
370328
371329 export function ThreadListScreen(props: ThreadListScreenProps) {
372− const isDarkMode = useColorScheme() === "dark";
373− const palette = makeAppPalette(isDarkMode);
330+ const borderColorValue = useThemeColor("--color-border");
374331 const repositoryGroups = useMemo(
375332 () => groupProjectsByRepository({ projects: props.projects, threads: props.threads }),
376333 [props.projects, props.threads],
@@ -379,7 +336,7 @@ export function ThreadListScreen(props: ThreadListScreenProps) { · 2 unmodified lines
379336 const [refreshing, setRefreshing] = useState(false);
380337 const isSplitLayout = props.layout.usesSplitView;
381338 const contentHorizontalPadding = isSplitLayout ? 18 : 20;
382− const panelBorderColor = isSplitLayout ? palette.border : "transparent";
339+ const panelBorderColor = isSplitLayout ? borderColorValue : "transparent";
383340 const refreshThreads = props.onRefresh;
384341
385342 const handleRefresh = useCallback(async (): Promise<void> => {
@@ -397,9 +354,8 @@ export function ThreadListScreen(props: ThreadListScreenProps) { · 11 unmodified lines
397354
398355 return (
399356 <View
400− className="flex-1"
357+ className="flex-1 bg-screen"
401358 style={{
402− backgroundColor: palette.screenBackground,
403359 borderRadius: isSplitLayout ? 32 : 0,
404360 borderWidth: isSplitLayout ? 1 : 0,
405361 borderColor: panelBorderColor,
@@ -426,28 +382,20 @@ export function ThreadListScreen(props: ThreadListScreenProps) { · 20 unmodified lines
426382 <Animated.View
427383 entering={FadeInDown.duration(260)}
428384 layout={LinearTransition.springify().damping(18).stiffness(180)}
429− className="gap-4 rounded-[26px] px-4 py-4"
430− style={{
431− backgroundColor: palette.cardTranslucent,
432− borderWidth: 1,
433− borderColor: palette.border,
434− }}
385+ className="gap-4 rounded-[26px] border border-border bg-card-translucent px-4 py-4"
435386 >
436387 <View className="flex-row items-start justify-between gap-4">
437388 <View className="flex-1 gap-2">
438389 <Text
439− className="text-[11px] font-t3-bold uppercase"
440− style={{ color: palette.textMuted, letterSpacing: 1.1 }}
390+ className="text-[11px] font-t3-bold uppercase text-foreground-muted"
391+ style={{ letterSpacing: 1.1 }}
441392 >
442393 {props.heroTitle}
443394 </Text>
444− <Text className="text-[26px] font-t3-bold" style={{ color: palette.text }}>
395+ <Text className="text-[26px] font-t3-bold text-foreground">
445396 {isSplitLayout ? "Native workspace" : "Repo board"}
446397 </Text>
447− <Text
448− className="text-[13px] font-medium leading-[19px]"
449− style={{ color: palette.textSecondary }}
450− >
398+ <Text className="text-[13px] font-medium leading-[19px] text-foreground-secondary">
451399 {isSplitLayout
452400 ? "Keep your repositories visible while you move between active threads."
453401 : "Your connected repositories, grouped by identity across environments."}
@@ -497,29 +445,19 @@ export function ThreadListScreen(props: ThreadListScreenProps) { · 43 unmodified lines
497445 key={group.key}
498446 entering={FadeInDown.duration(260).delay(40 * Math.min(index, 6))}
499447 layout={LinearTransition.springify().damping(18).stiffness(180)}
500− className="gap-4 rounded-[26px] px-4 py-4"
501− style={{
502− backgroundColor: palette.cardTranslucent,
503− borderWidth: 1,
504− borderColor: palette.border,
505− }}
448+ className="gap-4 rounded-[26px] border border-border bg-card-translucent px-4 py-4"
506449 >
507450 <View className="flex-row items-start justify-between gap-4">
508451 <View className="flex-1 gap-1">
509− <Text className="text-[22px] font-t3-bold" style={{ color: palette.text }}>
510− {group.title}
511− </Text>
452+ <Text className="text-[22px] font-t3-bold text-foreground">{group.title}</Text>
512453 {group.subtitle ? (
513− <Text
514− className="text-[13px] font-medium leading-[19px]"
515− style={{ color: palette.textSecondary }}
516− >
454+ <Text className="text-[13px] font-medium leading-[19px] text-foreground-secondary">
517455 {group.subtitle}
518456 </Text>
519457 ) : null}
520458 <Text
521− className="text-[11px] font-t3-bold uppercase"
522− style={{ color: palette.textMuted, letterSpacing: 0.9 }}
459+ className="text-[11px] font-t3-bold uppercase text-foreground-muted"
460+ style={{ letterSpacing: 0.9 }}
523461 >
524462 {group.projectCount} environment{group.projectCount === 1 ? "" : "s"} ·{" "}
525463 {group.threadCount} active thread{group.threadCount === 1 ? "" : "s"}
@@ -527,8 +465,7 @@ export function ThreadListScreen(props: ThreadListScreenProps) { · 1 unmodified line
527465 </View>
528466
529467 <Pressable
530− className="rounded-full px-3 py-2"
531− style={{ backgroundColor: palette.primaryButton }}
468+ className="rounded-full bg-primary px-3 py-2"
532469 onPress={() => {
533470 if (group.projects.length === 1) {
534471 void props.onCreateThread(group.projects[0]!.project);
@@ -538,8 +475,8 @@ export function ThreadListScreen(props: ThreadListScreenProps) { · 3 unmodified lines
538475 }}
539476 >
540477 <Text
541− className="text-[11px] font-t3-bold uppercase"
542− style={{ color: palette.primaryButtonText, letterSpacing: 0.9 }}
478+ className="text-[11px] font-t3-bold uppercase text-primary-foreground"
479+ style={{ letterSpacing: 0.9 }}
543480 >
544481 New thread
545482 </Text>
apps/mobile/src/features/threads/ThreadNavigationDrawer.tsx+24−39
@@ -1,13 +1,6 @@
11 import { SymbolView } from "expo-symbols";
22 import { useCallback, useEffect, useMemo, useState } from "react";
3−import {
4− Modal,
5− Pressable,
6− ScrollView,
7− useColorScheme,
8− useWindowDimensions,
9− View,
10−} from "react-native";
3+import { Modal, Pressable, ScrollView, useWindowDimensions, View } from "react-native";
114 import { Gesture, GestureDetector } from "react-native-gesture-handler";
125 import { useSafeAreaInsets } from "react-native-safe-area-context";
136 import Animated, {
@@ -16,6 +9,7 @@ import Animated, { · 2 unmodified lines
169 useSharedValue,
1710 withTiming,
1811 } from "react-native-reanimated";
12+import { useThemeColor } from "../../lib/useThemeColor";
1913
2014 import { AppText as Text } from "../../components/AppText";
2115 import { StatusPill } from "../../components/StatusPill";
@@ -23,7 +17,6 @@ import { groupProjectsByRepository } from "../../lib/repositoryGroups"; · 1 unmodified line
2317 import type { ScopedMobileProject, ScopedMobileThread } from "../../lib/scopedEntities";
2418 import { scopedThreadKey } from "../../lib/scopedEntities";
2519 import { relativeTime } from "../../lib/time";
26−import { makeAppPalette } from "../../lib/theme";
2720 import { threadStatusTone } from "./threadPresentation";
2821
2922 function compareThreadActivity(left: ScopedMobileThread, right: ScopedMobileThread): number {
@@ -42,14 +35,19 @@ export function ThreadNavigationDrawer(props: { · 12 unmodified lines
4235 readonly onSelectThread: (thread: ScopedMobileThread) => void;
4336 readonly onStartNewTask: () => void;
4437 }) {
45− const isDarkMode = useColorScheme() === "dark";
46− const palette = makeAppPalette(isDarkMode);
4738 const insets = useSafeAreaInsets();
4839 const { width } = useWindowDimensions();
4940 const drawerWidth = Math.min(width * 0.84, 360);
5041 const [mounted, setMounted] = useState(props.visible);
5142 const translateX = useSharedValue(-drawerWidth);
5243 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+
5351 const repositoryGroups = useMemo(
5452 () => groupProjectsByRepository({ projects: props.projects, threads: props.threads }),
5553 [props.projects, props.threads],
@@ -130,7 +128,7 @@ export function ThreadNavigationDrawer(props: { · 74 unmodified lines
130128 right: 0,
131129 top: 0,
132130 bottom: 0,
133− backgroundColor: palette.backdrop,
131+ backgroundColor: backdropColor,
134132 },
135133 backdropStyle,
136134 ]}
@@ -149,30 +147,27 @@ export function ThreadNavigationDrawer(props: { · 12 unmodified lines
149147 top: 0,
150148 bottom: 0,
151149 width: drawerWidth,
152− backgroundColor: palette.drawerBackground,
150+ backgroundColor: drawerBg,
153151 paddingTop: insets.top + 10,
154152 paddingBottom: Math.max(insets.bottom, 18),
155− boxShadow: `20px 0 36px ${palette.drawerShadow}`,
153+ boxShadow: `20px 0 36px ${String(drawerShadow)}`,
156154 },
157155 drawerStyle,
158156 ]}
159157 >
160158 <View className="flex-row items-center justify-between px-4 pb-5">
161− <Text className="text-[26px] font-t3-bold" style={{ color: palette.text }}>
162− Threads
163− </Text>
159+ <Text className="text-[26px] font-t3-bold">Threads</Text>
164160 <Pressable
165161 onPress={() => {
166162 props.onClose();
167163 props.onStartNewTask();
168164 }}
169− className="h-11 w-11 items-center justify-center rounded-full"
170− style={{ backgroundColor: palette.primaryButton }}
165+ className="h-11 w-11 items-center justify-center rounded-full bg-primary"
171166 >
172167 <SymbolView
173168 name="square.and.pencil"
174169 size={17}
175− tintColor={palette.primaryButtonText}
170+ tintColor={primaryForeground}
176171 type="monochrome"
177172 />
178173 </Pressable>
@@ -189,22 +184,16 @@ export function ThreadNavigationDrawer(props: { · 10 unmodified lines
189184 {groupedThreads.map((group) => (
190185 <View key={group.key} className="gap-3">
191186 <Text
192− className="px-1 text-[15px] font-t3-bold"
193− style={{ color: palette.textMuted, letterSpacing: -0.2 }}
187+ className="px-1 text-[15px] font-t3-bold text-foreground-muted"
188+ style={{ letterSpacing: -0.2 }}
194189 >
195190 {group.title}
196191 </Text>
197192
198− <View
199− className="overflow-hidden rounded-[22px]"
200− style={{ backgroundColor: palette.card }}
201− >
193+ <View className="overflow-hidden rounded-[22px] bg-card">
202194 {group.threads.length === 0 ? (
203195 <View className="px-4 py-4">
204− <Text
205− className="text-[14px] font-medium"
206− style={{ color: palette.textTertiary }}
207− >
196+ <Text className="text-[14px] font-medium text-foreground-tertiary">
208197 No threads yet
209198 </Text>
210199 </View>
@@ -224,23 +213,19 @@ export function ThreadNavigationDrawer(props: { · 13 unmodified lines
224213 paddingHorizontal: 16,
225214 paddingVertical: 15,
226215 borderTopWidth: index === 0 ? 0 : 1,
227− borderTopColor: palette.borderSubtle,
228− backgroundColor: selected ? palette.subtleBg : "transparent",
216+ borderTopColor: borderSubtleColor,
217+ backgroundColor: selected ? undefined : "transparent",
229218 }}
219+ className={selected ? "bg-subtle" : undefined}
230220 >
231221 <View className="flex-row items-start justify-between gap-3">
232222 <View className="flex-1 gap-1">
233− <Text
234− className="text-[16px] font-t3-bold"
235− numberOfLines={1}
236− style={{ color: palette.text }}
237− >
223+ <Text className="text-[16px] font-t3-bold" numberOfLines={1}>
238224 {thread.title}
239225 </Text>
240226 <Text
241− className="text-[13px] font-medium"
227+ className="text-[13px] font-medium text-foreground-muted"
242228 numberOfLines={1}
243− style={{ color: palette.textMuted }}
244229 >
245230 {relativeTime(thread.updatedAt ?? thread.createdAt)}
246231 </Text>
apps/mobile/src/features/threads/ThreadRouteScreen.tsx+148−72
@@ -1,12 +1,13 @@
1−import { useLocalSearchParams, useRouter } from "expo-router";
2−import { useEffect, useMemo, useState } from "react";
3−import { ScrollView, useColorScheme, View } from "react-native";
1+import { Stack, useLocalSearchParams, useRouter } from "expo-router";
2+import { useCallback, useEffect, useMemo, useState } from "react";
3+import { Pressable, ScrollView, Text as RNText, View, useColorScheme } from "react-native";
4+import { useThemeColor } from "../../lib/useThemeColor";
5+import { useGitStatus, gitStatusManager } from "../../state/use-git-status";
46
57 import { EmptyState } from "../../components/EmptyState";
68 import { LoadingScreen } from "../../components/LoadingScreen";
7−import { buildThreadRoutePath, dismissRoute } from "../../lib/routes";
9+import { buildThreadRoutePath } from "../../lib/routes";
810 import { scopedThreadKey } from "../../lib/scopedEntities";
9−import { makeAppPalette } from "../../lib/theme";
1011 import { connectionTone } from "../connection/connectionTone";
1112 import { firstNonNull } from "../../state/remote-runtime-types";
1213 import { useRemoteCatalog } from "../../state/use-remote-catalog";
@@ -14,12 +15,15 @@ import { · 1 unmodified line
1415 useRemoteConnectionStatus,
1516 useRemoteEnvironmentState,
1617 } from "../../state/use-remote-environment-registry";
18+import { useSelectedThreadGitActions } from "../../state/use-selected-thread-git-actions";
19+import { useSelectedThreadGitState } from "../../state/use-selected-thread-git-state";
1720 import { useSelectedThreadCommands } from "../../state/use-selected-thread-commands";
18−import { useSelectedThreadGit } from "../../state/use-selected-thread-git";
1921 import { useThreadComposerState } from "../../state/use-thread-composer-state";
2022 import { useThreadSelection } from "../../state/use-thread-selection";
2123 import { ThreadDetailScreen } from "./ThreadDetailScreen";
24+import { ThreadGitControls } from "./ThreadGitControls";
2225 import { ThreadNavigationDrawer } from "./ThreadNavigationDrawer";
26+import { screenTitle } from "./threadPresentation";
2327
2428 function firstRouteParam(value: string | string[] | undefined): string | null {
2529 if (Array.isArray(value)) {
@@ -36,19 +40,19 @@ export function ThreadRouteScreen() { · 10 unmodified lines
3640 const {
3741 onSelectThread,
3842 selectedThread,
43+ selectedThreadProject,
3944 selectedEnvironmentConnection,
4045 selectedEnvironmentRuntime,
4146 } = useThreadSelection();
4247 const composer = useThreadComposerState();
43− const git = useSelectedThreadGit();
48+ const gitState = useSelectedThreadGitState();
49+ const gitActions = useSelectedThreadGitActions();
4450 const commands = useSelectedThreadCommands({
4551 activePendingUserInput: composer.activePendingUserInput,
4652 activePendingUserInputAnswers: composer.activePendingUserInputAnswers,
47− refreshSelectedThreadGitStatus: git.refreshSelectedThreadGitStatus,
53+ refreshSelectedThreadGitStatus: gitActions.refreshSelectedThreadGitStatus,
4854 });
4955 const router = useRouter();
50− const colorScheme = useColorScheme();
51− const isDarkMode = colorScheme !== "light";
5256 const params = useLocalSearchParams<{
5357 environmentId?: string | string[];
5458 threadId?: string | string[];
@@ -57,6 +61,32 @@ export function ThreadRouteScreen() { · 2 unmodified lines
5761 const environmentId = firstRouteParam(params.environmentId);
5862 const threadId = firstRouteParam(params.threadId);
5963
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+
6090 const routeThread = useMemo(() => {
6191 if (!environmentId || !threadId) {
6292 return null;
@@ -106,7 +136,7 @@ export function ThreadRouteScreen() { · 43 unmodified lines
106136 paddingHorizontal: 24,
107137 paddingVertical: 32,
108138 }}
109− style={{ flex: 1, backgroundColor: makeAppPalette(isDarkMode).screenBackground }}
139+ className="bg-screen flex-1"
110140 >
111141 <EmptyState
112142 title="Thread unavailable"
@@ -125,71 +155,117 @@ export function ThreadRouteScreen() { · 12 unmodified lines
125155 selectedEnvironmentRuntime?.serverConfig ??
126156 firstNonNull(Object.values(environmentStateById).map((runtime) => runtime.serverConfig));
127157
158+ const headerSubtitle = [screenTitle(serverConfig, null), selectedThread.environmentLabel]
159+ .filter(Boolean)
160+ .join(" · ");
161+
128162 return (
129− <View style={{ flex: 1 }}>
130− <ThreadDetailScreen
131− selectedThread={selectedThread}
132− screenTone={connectionTone(connectionState)}
133− connectionError={connectionError}
134− httpBaseUrl={selectedEnvironmentConnection?.httpBaseUrl ?? null}
135− bearerToken={selectedEnvironmentConnection?.bearerToken ?? null}
136− selectedThreadFeed={composer.selectedThreadFeed}
137− activeWorkDurationLabel={composer.activeWorkDurationLabel}
138− activePendingApproval={composer.activePendingApproval}
139− respondingApprovalId={commands.respondingApprovalId}
140− activePendingUserInput={composer.activePendingUserInput}
141− activePendingUserInputDrafts={composer.activePendingUserInputDrafts}
142− activePendingUserInputAnswers={composer.activePendingUserInputAnswers}
143− respondingUserInputId={commands.respondingUserInputId}
144− draftMessage={composer.draftMessage}
145− draftAttachments={composer.draftAttachments}
146− connectionStateLabel={connectionState}
147− activeThreadBusy={composer.activeThreadBusy}
148− selectedThreadGitStatus={git.selectedThreadGitStatus}
149− gitOperationLabel={git.gitOperationLabel}
150− selectedThreadQueueCount={composer.selectedThreadQueueCount}
151− onBack={() => dismissRoute(router)}
152− onOpenDrawer={() => setDrawerVisible(true)}
153− onOpenConnectionEditor={() => router.push("/connections")}
154− onChangeDraftMessage={composer.onChangeDraftMessage}
155− onPickDraftImages={composer.onPickDraftImages}
156− onNativePasteImages={composer.onNativePasteImages}
157− onRemoveDraftImage={composer.onRemoveDraftImage}
158− onRefresh={commands.onRefresh}
159− onRefreshSelectedThreadGitStatus={async (options) => {
160− await git.refreshSelectedThreadGitStatus(options);
163+ <>
164+ <Stack.Screen
165+ options={{
166+ headerShown: true,
167+ headerTransparent: true,
168+ headerStyle: { backgroundColor: "transparent" },
169+ headerShadowVisible: false,
170+ headerTintColor: iconColor,
171+ headerBackTitle: "",
172+ headerTitle: () => (
173+ <Pressable
174+ style={{ alignItems: "center", maxWidth: 200 }}
175+ onLongPress={() => {
176+ // TODO: trigger rename modal
177+ }}
178+ >
179+ <RNText
180+ numberOfLines={1}
181+ style={{
182+ fontFamily: "DMSans_700Bold",
183+ fontSize: 18,
184+ fontWeight: "900",
185+ color: foregroundColor,
186+ letterSpacing: -0.4,
187+ }}
188+ >
189+ {selectedThread.title}
190+ </RNText>
191+ <RNText
192+ numberOfLines={1}
193+ style={{
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+ ),
161205 }}
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}
179206 />
180207
181− <ThreadNavigationDrawer
182− visible={drawerVisible}
183− projects={projects}
184− threads={threads}
185− selectedThreadKey={selectedThreadKey}
186− onClose={() => setDrawerVisible(false)}
187− onSelectThread={(thread) => {
188− onSelectThread(thread);
189− router.replace(buildThreadRoutePath(thread));
190− }}
191− onStartNewTask={() => router.push("/new")}
208+ <ThreadGitControls
209+ currentBranch={selectedThread.branch}
210+ gitStatus={gitStatus.data}
211+ gitOperationLabel={gitState.gitOperationLabel}
212+ onPull={gitActions.onPullSelectedThreadBranch}
213+ onRunAction={gitActions.onRunSelectedThreadGitAction}
192214 />
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+ </>
194270 );
195271 }
apps/mobile/src/features/threads/git/GitBranchesSheet.tsx+205−0
@@ -0,0 +1,205 @@
1+import { sanitizeFeatureBranchName } from "@t3tools/shared/git";
2+import { useRouter } from "expo-router";
3+import { useEffect, useState } from "react";
4+import { Pressable, ScrollView, View } from "react-native";
5+import { useSafeAreaInsets } from "react-native-safe-area-context";
6+import { useThemeColor } from "../../../lib/useThemeColor";
7+
8+import { AppText as Text, AppTextInput as TextInput } from "../../../components/AppText";
9+import { useGitStatus } from "../../../state/use-git-status";
10+import { useSelectedThreadGitActions } from "../../../state/use-selected-thread-git-actions";
11+import { useSelectedThreadGitState } from "../../../state/use-selected-thread-git-state";
12+import { useThreadSelection } from "../../../state/use-thread-selection";
13+import { SheetActionButton } from "./gitSheetComponents";
14+
15+export 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 @@
1+import { useRouter } from "expo-router";
2+import { useCallback, useState } from "react";
3+import { Pressable, ScrollView, View, useColorScheme } from "react-native";
4+import { useSafeAreaInsets } from "react-native-safe-area-context";
5+import { useThemeColor } from "../../../lib/useThemeColor";
6+
7+import { AppText as Text, AppTextInput as TextInput } from "../../../components/AppText";
8+import { useGitStatus } from "../../../state/use-git-status";
9+import { useSelectedThreadGitActions } from "../../../state/use-selected-thread-git-actions";
10+import { useSelectedThreadGitState } from "../../../state/use-selected-thread-git-state";
11+import { useThreadSelection } from "../../../state/use-thread-selection";
12+import { SheetActionButton } from "./gitSheetComponents";
13+
14+export 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 @@
1+import { resolveDefaultBranchActionDialogCopy } from "@t3tools/client-runtime";
2+import { resolveAutoFeatureBranchName } from "@t3tools/shared/git";
3+import { useLocalSearchParams, useRouter } from "expo-router";
4+import { useCallback, useMemo } from "react";
5+import { View } from "react-native";
6+import { useSafeAreaInsets } from "react-native-safe-area-context";
7+
8+import { AppText as Text } from "../../../components/AppText";
9+import { useSelectedThreadGitActions } from "../../../state/use-selected-thread-git-actions";
10+import { useSelectedThreadGitState } from "../../../state/use-selected-thread-git-state";
11+import { SheetActionButton } from "./gitSheetComponents";
12+
13+export 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 @@
1+import type { GitRunStackedActionResult } from "@t3tools/contracts";
2+import {
3+ type GitActionRequestInput,
4+ buildMenuItems,
5+ getGitActionDisabledReason,
6+ requiresDefaultBranchConfirmation,
7+} from "@t3tools/client-runtime";
8+import { useLocalSearchParams, useRouter } from "expo-router";
9+import { SymbolView } from "expo-symbols";
10+import { useCallback, useEffect, useMemo } from "react";
11+import { Alert, Linking, Pressable, ScrollView, View } from "react-native";
12+import { useSafeAreaInsets } from "react-native-safe-area-context";
13+import { useThemeColor } from "../../../lib/useThemeColor";
14+
15+import { AppText as Text } from "../../../components/AppText";
16+import { StatusPill } from "../../../components/StatusPill";
17+import { useGitStatus } from "../../../state/use-git-status";
18+import { useSelectedThreadGitActions } from "../../../state/use-selected-thread-git-actions";
19+import { useSelectedThreadGitState } from "../../../state/use-selected-thread-git-state";
20+import { useThreadSelection } from "../../../state/use-thread-selection";
21+import { MetaCard, SheetListRow, menuItemIconName, statusSummary } from "./gitSheetComponents";
22+
23+export 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 @@
1+import { SymbolView } from "expo-symbols";
2+import type { ComponentProps } from "react";
3+import { Pressable, View } from "react-native";
4+import { useThemeColor } from "../../../lib/useThemeColor";
5+import { AppText as Text } from "../../../components/AppText";
6+
7+/* ─── Shared sheet components ──────────────────────────────────────── */
8+
9+export 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+
68+export 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+
84+export 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+
117+export 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+
125+export 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
1515 import { groupProjectsByRepository } from "../../lib/repositoryGroups";
1616 import type { ScopedMobileProject } from "../../lib/scopedEntities";
1717 import { scopedProjectKey } from "../../lib/scopedEntities";
18−import { useProjectActions } from "../../state/use-project-actions";
18+import { useRemoteEnvironmentStore } from "../../state/remote-environment-store";
19+import { gitBranchManager, useGitBranches } from "../../state/use-git-branches";
1920 import { useRemoteCatalog } from "../../state/use-remote-catalog";
2021
2122 export type { ModelOption, ProviderGroup };
@@ -110,7 +111,9 @@ const NewTaskFlowContext = React.createContext<NewTaskFlowContextValue | null>(n · 88 unmodified lines
110111
111112 export function NewTaskFlowProvider(props: React.PropsWithChildren) {
112113 const { projects, serverConfigByEnvironmentId, threads } = useRemoteCatalog();
113− const { onListProjectBranches } = useProjectActions();
114+ const setPendingConnectionError = useRemoteEnvironmentStore(
115+ (state) => state.setPendingConnectionError,
116+ );
114117
115118 const repositoryGroups = useMemo(
116119 () => groupProjectsByRepository({ projects, threads }),
@@ -142,8 +145,6 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) { · 25 unmodified lines
142145 const [attachments, setAttachments] = useState<ReadonlyArray<DraftComposerImageAttachment>>([]);
143146 const [submitting, setSubmitting] = useState(false);
144147 const [branchQuery, setBranchQuery] = useState("");
145− const [branchesLoading, setBranchesLoading] = useState(false);
146− const [availableBranches, setAvailableBranches] = useState<ReadonlyArray<GitBranch>>([]);
147148 const [runtimeMode, setRuntimeMode] = useState<RuntimeMode>(DEFAULT_RUNTIME_MODE);
148149 const [interactionMode, setInteractionMode] = useState<ProviderInteractionMode>(
149150 DEFAULT_PROVIDER_INTERACTION_MODE,
@@ -190,8 +191,6 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) { · 40 unmodified lines
190191 clearAttachments();
191192 setSubmitting(false);
192193 setBranchQuery("");
193− setBranchesLoading(false);
194− setAvailableBranches([]);
195194 setRuntimeMode(DEFAULT_RUNTIME_MODE);
196195 setInteractionMode(DEFAULT_PROVIDER_INTERACTION_MODE);
197196 setEffort("high");
@@ -262,6 +261,20 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) { · 64 unmodified lines
262261 ) ?? null;
263262
264263 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+ );
265278
266279 const filteredBranches = useMemo(() => {
267280 const query = branchQuery.trim().toLowerCase();
@@ -299,10 +312,14 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) { · 31 unmodified lines
299312 return;
300313 }
301314
302− setBranchesLoading(true);
303315 try {
304− const branches = await onListProjectBranches(selectedProject);
305− setAvailableBranches(branches);
316+ const result = await gitBranchManager.load({
317+ environmentId: selectedProject.environmentId,
318+ cwd: selectedProject.workspaceRoot,
319+ query: null,
320+ });
321+ setPendingConnectionError(null);
322+ const branches = (result?.branches ?? []).filter((branch) => !branch.isRemote);
306323
307324 if (workspaceMode === "worktree" && !selectedBranchName) {
308325 const preferredBranch =
@@ -313,10 +330,10 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) { · 4 unmodified lines
313330 setSelectedBranchName(preferredBranch);
314331 }
315332 }
316− } finally {
317− setBranchesLoading(false);
333+ } catch {
334+ setPendingConnectionError("Failed to load branches.");
318335 }
319− }, [onListProjectBranches, selectedBranchName, selectedProject, workspaceMode]);
336+ }, [selectedBranchName, selectedProject, setPendingConnectionError, workspaceMode]);
320337
321338 const value = useMemo<NewTaskFlowContextValue>(
322339 () => ({
apps/mobile/src/lib/arrayCompat.ts+0−42
@@ -1,42 +0,0 @@
1−export 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−
30−export 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 @@
1−export 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 @@
1−import * as Effect from "effect/Effect";
2−import * as Random from "effect/Random";
3−
4−export 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 @@
1+import { type ClassValue, clsx } from "clsx";
2+import { twMerge } from "tailwind-merge";
3+
4+export 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
33 PROVIDER_SEND_TURN_MAX_IMAGE_BYTES,
44 type UploadChatImageAttachment,
55 } from "@t3tools/contracts";
6−
7−import { newClientId } from "./clientId";
6+import { uuidv4 } from "./uuid";
87
98 export interface DraftComposerImageAttachment extends UploadChatImageAttachment {
109 readonly id: string;
@@ -101,7 +100,7 @@ export async function pickComposerImages(input: { readonly existingCount: number · 90 unmodified lines
101100 }
102101
103102 nextImages.push({
104− id: newClientId("attachment"),
103+ id: uuidv4(),
105104 type: "image",
106105 name: asset.fileName ?? "image",
107106 mimeType,
@@ -165,7 +164,7 @@ export async function pasteComposerClipboard(input: { readonly existingCount: nu · 57 unmodified lines
165164 return {
166165 images: [
167166 {
168− id: newClientId("attachment"),
167+ id: uuidv4(),
169168 type: "image",
170169 name: "pasted-image.png",
171170 mimeType: "image/png",
@@ -233,7 +232,7 @@ export async function convertPastedImagesToAttachments(input: { · 61 unmodified lines
233232 }
234233 const mimeType = mimeTypeFromUri(uri);
235234 results.push({
236− id: newClientId("attachment"),
235+ id: uuidv4(),
237236 type: "image",
238237 name: `pasted-image.${mimeType.split("/")[1] ?? "png"}`,
239238 mimeType,
apps/mobile/src/lib/mobileLayout.ts+37−0
@@ -0,0 +1,37 @@
1+function clamp(value: number, min: number, max: number): number {
2+ return Math.min(Math.max(value, min), max);
3+}
4+
5+export type MobileLayoutVariant = "compact" | "split";
6+
7+export interface MobileLayout {
8+ readonly variant: MobileLayoutVariant;
9+ readonly usesSplitView: boolean;
10+ readonly listPaneWidth: number | null;
11+ readonly shellPadding: number;
12+}
13+
14+export 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
22
33 import type { SavedRemoteConnection } from "./connection";
44
5−const CONNECTIONS_KEY = "t3code:connections";
6−
7−type SavedRemoteConnectionMetadata = Omit<SavedRemoteConnection, "bearerToken">;
5+const CONNECTIONS_KEY = "t3code.connections";
86
97 async function readStorageItem(key: string): Promise<string | null> {
108 return await SecureStore.getItemAsync(key);
@@ -14,31 +12,7 @@ async function writeStorageItem(key: string, value: string): Promise<void> { · 3 unmodified lines
1412 await SecureStore.setItemAsync(key, value);
1513 }
1614
17−async function removeStorageItem(key: string): Promise<void> {
18− await SecureStore.deleteItemAsync(key);
19−}
20−
21−function connectionTokenKey(environmentId: string): string {
22− return `t3code:bearer-token:${environmentId}`;
23−}
24−
25−async function loadSecureToken(environmentId: string): Promise<string> {
26− return (await readStorageItem(connectionTokenKey(environmentId))) ?? "";
27−}
28−
29−async 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−
39−async function loadSavedConnectionMetadata(): Promise<
40− ReadonlyArray<SavedRemoteConnectionMetadata>
41−> {
15+export async function loadSavedConnections(): Promise<ReadonlyArray<SavedRemoteConnection>> {
4216 const raw = (await readStorageItem(CONNECTIONS_KEY)) ?? "";
4317 if (!raw.trim()) {
4418 return [];
@@ -46,64 +20,27 @@ async function loadSavedConnectionMetadata(): Promise< · 1 unmodified line
4620
4721 try {
4822 const parsed = JSON.parse(raw) as {
49− readonly connections?: ReadonlyArray<SavedRemoteConnectionMetadata>;
23+ readonly connections?: ReadonlyArray<SavedRemoteConnection>;
5024 };
51− return parsed.connections ?? [];
25+ return (parsed.connections ?? []).filter((c) => c.environmentId && c.bearerToken?.trim());
5226 } catch {
5327 return [];
5428 }
5529 }
5630
57−async function saveSavedConnectionMetadata(
58− connections: ReadonlyArray<SavedRemoteConnectionMetadata>,
59−): Promise<void> {
60− await writeStorageItem(CONNECTIONS_KEY, JSON.stringify({ connections }));
61−}
62−
63−export 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−
8131 export async function saveConnection(connection: SavedRemoteConnection): Promise<void> {
82− const current = await loadSavedConnectionMetadata();
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− };
32+ const current = await loadSavedConnections();
9133 const next = current.some((entry) => entry.environmentId === connection.environmentId)
9234 ? current.map((entry) =>
93− entry.environmentId === connection.environmentId ? nextConnection : entry,
35+ entry.environmentId === connection.environmentId ? connection : entry,
9436 )
95− : [...current, nextConnection];
37+ : [...current, connection];
9638
97− await Promise.all([
98− saveSavedConnectionMetadata(next),
99− storeSecureToken(connection.environmentId, connection.bearerToken.trim()),
100− ]);
39+ await writeStorageItem(CONNECTIONS_KEY, JSON.stringify({ connections: next }));
10140 }
10241
10342 export async function clearSavedConnection(environmentId: string): Promise<void> {
104− const current = await loadSavedConnectionMetadata();
105− await Promise.all([
106− saveSavedConnectionMetadata(current.filter((entry) => entry.environmentId !== environmentId)),
107− storeSecureToken(environmentId, ""),
108− ]);
43+ const current = await loadSavedConnections();
44+ const next = current.filter((entry) => entry.environmentId !== environmentId);
45+ await writeStorageItem(CONNECTIONS_KEY, JSON.stringify({ connections: next }));
10946 }
apps/mobile/src/lib/theme.ts+0−187
@@ -1,187 +0,0 @@
1−import { 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− */
11−export 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−
181−export type AppPalette = ReturnType<typeof makeAppPalette>;
182−
183−/** Convenience hook that returns the palette for the current color scheme. */
184−export 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 @@
11 import type {
22 ApprovalRequestId,
3+ CommandId,
4+ MessageId,
35 OrchestrationThread,
46 OrchestrationThreadActivity,
57 ThreadId,
@@ -29,11 +31,10 @@ export interface PendingUserInputDraftAnswer { · 23 unmodified lines
2931 }
3032
3133 export interface QueuedThreadMessage {
32− readonly id: string;
3334 readonly environmentId: string;
3435 readonly threadId: ThreadId;
35− readonly messageId: string;
36− readonly commandId: string;
36+ readonly messageId: MessageId;
37+ readonly commandId: CommandId;
3738 readonly text: string;
3839 readonly attachments: ReadonlyArray<DraftComposerImageAttachment>;
3940 readonly createdAt: string;
@@ -380,7 +381,7 @@ export function buildPendingUserInputAnswers( · 340 unmodified lines
380381 export function buildThreadFeed(
381382 thread: OrchestrationThread,
382383 queuedMessages: ReadonlyArray<QueuedThreadMessage>,
383− dispatchingQueuedMessageId: string | null,
384+ dispatchingQueuedMessageId: MessageId | null,
384385 options?: {
385386 readonly loadedMessages?: ReadonlyArray<OrchestrationThread["messages"][number]>;
386387 },
@@ -398,10 +399,10 @@ export function buildThreadFeed( · 11 unmodified lines
398399 })),
399400 ...queuedMessages.map<RawThreadFeedEntry>((queuedMessage) => ({
400401 type: "queued-message",
401− id: queuedMessage.id,
402+ id: queuedMessage.messageId,
402403 createdAt: queuedMessage.createdAt,
403404 queuedMessage,
404− sending: queuedMessage.id === dispatchingQueuedMessageId,
405+ sending: queuedMessage.messageId === dispatchingQueuedMessageId,
405406 })),
406407 ...thread.activities
407408 .filter((activity) => {
apps/mobile/src/lib/useThemeColor.ts+12−0
@@ -0,0 +1,12 @@
1+import type { ColorValue } from "react-native";
2+import { 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+ */
10+export 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 @@
1+import * as Effect from "effect/Effect";
2+import * as Random from "effect/Random";
3+
4+export const uuidv4 = () => Effect.runSync(Random.nextUUIDv4);
apps/mobile/src/state/atomRegistry.tsx+9−0
@@ -0,0 +1,9 @@
1+import { RegistryContext } from "@effect/atom-react";
2+import { AtomRegistry } from "effect/unstable/reactivity";
3+import type { ReactNode } from "react";
4+
5+export const appAtomRegistry = AtomRegistry.make();
6+
7+export 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 @@
1−import type { ThreadId } from "@t3tools/contracts";
1+import type { MessageId, ThreadId } from "@t3tools/contracts";
22 import { useStore } from "zustand";
33 import { createStore } from "zustand/vanilla";
44
@@ -10,7 +10,7 @@ interface ThreadComposerStoreState { · 5 unmodified lines
1010 readonly nowTick: number;
1111 readonly draftMessageByThreadKey: Record<string, string>;
1212 readonly draftAttachmentsByThreadKey: Record<string, ReadonlyArray<DraftComposerImageAttachment>>;
13− readonly dispatchingQueuedMessageId: string | null;
13+ readonly dispatchingQueuedMessageId: MessageId | null;
1414 readonly queuedMessagesByThreadKey: Record<string, ReadonlyArray<QueuedThreadMessage>>;
1515
1616 readonly setNowTick: (tick: number) => void;
@@ -22,13 +22,13 @@ interface ThreadComposerStoreState { · 5 unmodified lines
2222 readonly appendDraftMessage: (threadKey: string, value: string) => void;
2323 readonly clearDraft: (threadKey: string) => void;
2424 readonly removeDraftImage: (threadKey: string, imageId: string) => void;
25− readonly beginDispatchingQueuedMessage: (queuedMessageId: string) => void;
26− readonly finishDispatchingQueuedMessage: (queuedMessageId: string) => void;
25+ readonly beginDispatchingQueuedMessage: (queuedMessageId: MessageId) => void;
26+ readonly finishDispatchingQueuedMessage: (queuedMessageId: MessageId) => void;
2727 readonly enqueueQueuedMessage: (message: QueuedThreadMessage) => void;
2828 readonly removeQueuedMessage: (
2929 environmentId: string,
3030 threadId: ThreadId,
31− queuedMessageId: string,
31+ queuedMessageId: MessageId,
3232 ) => void;
3333 }
3434
@@ -107,7 +107,7 @@ export const threadComposerStore = createStore<ThreadComposerStoreState>()((set) · 72 unmodified lines
107107 if (!existing) {
108108 return state;
109109 }
110− const nextQueue = existing.filter((entry) => entry.id !== queuedMessageId);
110+ const nextQueue = existing.filter((entry) => entry.messageId !== queuedMessageId);
111111 const next = { ...state.queuedMessagesByThreadKey };
112112 if (nextQueue.length === 0) {
113113 delete next[threadKey];
apps/mobile/src/state/thread-git-store.ts+0−45
@@ -1,45 +0,0 @@
1−import type { GitStatusResult } from "@t3tools/contracts";
2−import { useStore } from "zustand";
3−import { createStore } from "zustand/vanilla";
4−
5−interface 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−
13−export 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−
43−export 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 @@
1+import { useAtomValue } from "@effect/atom-react";
2+import {
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+
12+import { uuidv4 } from "../lib/uuid";
13+import { appAtomRegistry } from "./atomRegistry";
14+import { getEnvironmentClient } from "./use-remote-environment-registry";
15+
16+export 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+
25+export 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 @@
1+import { useAtomValue } from "@effect/atom-react";
2+import {
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+
12+import { appAtomRegistry } from "./atomRegistry";
13+import { getEnvironmentClient } from "./use-remote-environment-registry";
14+
15+export const gitBranchManager = createGitBranchManager({
16+ getRegistry: () => appAtomRegistry,
17+ getClient: (environmentId) => {
18+ const client = getEnvironmentClient(environmentId);
19+ return client ? client.git : null;
20+ },
21+});
22+
23+export 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 @@
1+import { useAtomValue } from "@effect/atom-react";
2+import {
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";
11+import { useEffect } from "react";
12+
13+import { appAtomRegistry } from "./atomRegistry";
14+import {
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+ */
31+export 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+ */
50+export 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
1414 import { sanitizeFeatureBranchName } from "@t3tools/shared/git";
1515
1616 import type { DraftComposerImageAttachment } from "../lib/composerImages";
17−import { newClientId } from "../lib/clientId";
17+import { uuidv4 } from "../lib/uuid";
1818 import type { ScopedMobileProject } from "../lib/scopedEntities";
1919 import { buildTemporaryWorktreeBranchName } from "../lib/worktrees";
2020 import { deriveThreadTitleFromPrompt } from "./remote-runtime-types";
2121 import { useRemoteEnvironmentStore } from "./remote-environment-store";
22+import { gitBranchManager } from "./use-git-branches";
2223 import { useRemoteCatalog } from "./use-remote-catalog";
2324 import { getEnvironmentClient, useRemoteEnvironmentState } from "./use-remote-environment-registry";
2425 import { useThreadSelectionStore } from "./thread-selection-store";
@@ -90,7 +91,7 @@ export function useProjectActions() { · 65 unmodified lines
9091 return null;
9192 }
9293
93− const threadId = ThreadId.makeUnsafe(newClientId("thread"));
94+ const threadId = ThreadId.makeUnsafe(uuidv4());
9495 const createdAt = new Date().toISOString();
9596 const initialMessageText = input.initialMessageText.trim();
9697 const nextTitle = deriveThreadTitleFromPrompt(input.initialMessageText);
@@ -102,10 +103,10 @@ export function useProjectActions() { · 5 unmodified lines
102103
103104 await client.orchestration.dispatchCommand({
104105 type: "thread.turn.start",
105− commandId: CommandId.makeUnsafe(newClientId("command")),
106+ commandId: CommandId.makeUnsafe(uuidv4()),
106107 threadId,
107108 message: {
108− messageId: MessageId.makeUnsafe(newClientId("message")),
109+ messageId: MessageId.makeUnsafe(uuidv4()),
109110 role: "user",
110111 text: initialMessageText,
111112 attachments: input.initialAttachments,
@@ -137,7 +138,7 @@ export function useProjectActions() { · 25 unmodified lines
137138 } else {
138139 await client.orchestration.dispatchCommand({
139140 type: "thread.create",
140− commandId: CommandId.makeUnsafe(newClientId("command")),
141+ commandId: CommandId.makeUnsafe(uuidv4()),
141142 threadId,
142143 projectId: input.project.id,
143144 title: nextTitle,
@@ -152,10 +153,10 @@ export function useProjectActions() { · 8 unmodified lines
152153 if (initialMessageText.length > 0 || input.initialAttachments.length > 0) {
153154 await client.orchestration.dispatchCommand({
154155 type: "thread.turn.start",
155− commandId: CommandId.makeUnsafe(newClientId("command")),
156+ commandId: CommandId.makeUnsafe(uuidv4()),
156157 threadId,
157158 message: {
158− messageId: MessageId.makeUnsafe(newClientId("message")),
159+ messageId: MessageId.makeUnsafe(uuidv4()),
159160 role: "user",
160161 text: initialMessageText,
161162 attachments: input.initialAttachments,
@@ -217,11 +218,12 @@ export function useProjectActions() { · 55 unmodified lines
217218 }
218219
219220 try {
220− const result = await client.git.listBranches({
221− cwd: project.workspaceRoot,
222− limit: 100,
223− });
224− return result.branches.filter((branch) => !branch.isRemote);
221+ const result = await gitBranchManager.load(
222+ { environmentId: project.environmentId, cwd: project.workspaceRoot, query: null },
223+ client.git,
224+ { limit: 100 },
225+ );
226+ return (result?.branches ?? []).filter((branch) => !branch.isRemote);
225227 } catch (error) {
226228 setPendingConnectionError(
227229 error instanceof Error ? error.message : "Failed to load branches.",
@@ -255,6 +257,11 @@ export function useProjectActions() { · 27 unmodified lines
255257 newBranch: sanitizeFeatureBranchName(nextWorktree.newBranch),
256258 path: null,
257259 });
260+ gitBranchManager.invalidate({
261+ environmentId: project.environmentId,
262+ cwd: project.workspaceRoot,
263+ query: null,
264+ });
258265 return {
259266 branch: result.worktree.branch,
260267 worktreePath: result.worktree.path,
apps/mobile/src/state/use-remote-catalog.ts+19−11
@@ -1,8 +1,8 @@
11 import { useMemo } from "react";
22
33 import type { ServerConfig as T3ServerConfig } from "@t3tools/contracts";
4−
5−import { sortCopy } from "../lib/arrayCompat";
4+import * as Order from "effect/Order";
5+import * as Arr from "effect/Array";
66 import type { ScopedMobileProject, ScopedMobileThread } from "../lib/scopedEntities";
77 import {
88 deriveOverallConnectionState,
@@ -13,13 +13,26 @@ import { · 4 unmodified lines
1313 useRemoteEnvironmentState,
1414 } from "./use-remote-environment-registry";
1515
16+const 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+
22+const 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+
1629 export function useRemoteCatalog() {
1730 const { connectedEnvironments, connectionState } = useRemoteConnectionStatus();
1831 const { environmentStateById, savedConnectionsById } = useRemoteEnvironmentState();
1932
2033 const projects = useMemo<ReadonlyArray<ScopedMobileProject>>(
2134 () =>
22− sortCopy(
35+ Arr.sort(
2336 Object.values(savedConnectionsById).flatMap((connection) =>
2437 (environmentStateById[connection.environmentId]?.snapshot?.projects ?? [])
2538 .filter((project) => project.deletedAt === null)
@@ -30,16 +43,14 @@ export function useRemoteCatalog() { · 4 unmodified lines
3043 }),
3144 ),
3245 ),
33− (left, right) =>
34− left.title.localeCompare(right.title) ||
35− left.environmentLabel.localeCompare(right.environmentLabel),
46+ projectsSortOrder,
3647 ),
3748 [environmentStateById, savedConnectionsById],
3849 );
3950
4051 const threads = useMemo<ReadonlyArray<ScopedMobileThread>>(
4152 () =>
42− sortCopy(
53+ Arr.sort(
4354 Object.values(savedConnectionsById).flatMap((connection) =>
4455 (environmentStateById[connection.environmentId]?.snapshot?.threads ?? [])
4556 .filter((thread) => thread.deletedAt === null)
@@ -50,10 +61,7 @@ export function useRemoteCatalog() { · 4 unmodified lines
5061 }),
5162 ),
5263 ),
53− (left, right) =>
54− new Date(right.updatedAt ?? right.createdAt).getTime() -
55− new Date(left.updatedAt ?? left.createdAt).getTime() ||
56− right.environmentLabel.localeCompare(left.environmentLabel),
64+ threadsSortOrder,
5765 ),
5866 [environmentStateById, savedConnectionsById],
5967 );
apps/mobile/src/state/use-remote-environment-registry.ts+27−4
@@ -10,8 +10,8 @@ import { · 9 unmodified lines
1010 import { EnvironmentId, type OrchestrationReadModel } from "@t3tools/contracts";
1111 import { resolveRemoteWebSocketConnectionUrl } from "@t3tools/shared/remote";
1212 import { useShallow } from "zustand/react/shallow";
13−
14−import { sortCopy } from "../lib/arrayCompat";
13+import * as Arr from "effect/Array";
14+import * as Order from "effect/Order";
1515 import { type SavedRemoteConnection, bootstrapRemoteConnection } from "../lib/connection";
1616 import { applyRealtimeEvent, requiresSnapshotRefresh } from "../lib/orchestration";
1717 import { clearSavedConnection, loadSavedConnections, saveConnection } from "../lib/storage";
@@ -25,6 +25,22 @@ import { remoteEnvironmentStore, useRemoteEnvironmentStore } from "./remote-envi · 7 unmodified lines
2525 import { useThreadSelectionStore } from "./thread-selection-store";
2626
2727 const environmentSessions = new Map<string, EnvironmentSession>();
28+const environmentConnectionListeners = new Set<() => void>();
29+
30+function 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+ */
38+export function subscribeEnvironmentConnections(listener: () => void): () => void {
39+ environmentConnectionListeners.add(listener);
40+ return () => {
41+ environmentConnectionListeners.delete(listener);
42+ };
43+}
2844
2945 function setEnvironmentConnectionStatus(
3046 environmentId: string,
@@ -48,6 +64,7 @@ export async function disconnectEnvironment( · 17 unmodified lines
4864 ) {
4965 const session = environmentSessions.get(environmentId);
5066 environmentSessions.delete(environmentId);
67+ notifyEnvironmentConnectionListeners();
5168 await session?.connection.dispose();
5269 remoteEnvironmentStore.getState().removeEnvironmentRuntimeState(environmentId);
5370
@@ -176,6 +193,7 @@ export async function connectSavedEnvironment( · 122 unmodified lines
176193 client,
177194 connection: environmentConnection,
178195 });
196+ notifyEnvironmentConnectionListeners();
179197
180198 try {
181199 await environmentConnection.ensureBootstrapped();
@@ -188,11 +206,15 @@ export async function connectSavedEnvironment( · 6 unmodified lines
188206 }
189207 }
190208
209+const environmentsSortOrder = Order.make<ConnectedEnvironmentSummary>(
210+ (left, right) => left.environmentLabel.localeCompare(right.environmentLabel) as -1 | 0 | 1,
211+);
212+
191213 function deriveConnectedEnvironments(
192214 savedConnectionsById: Record<string, SavedRemoteConnection>,
193215 environmentStateById: ReturnType<typeof remoteEnvironmentStore.getState>["environmentStateById"],
194216 ): ReadonlyArray<ConnectedEnvironmentSummary> {
195− return sortCopy(
217+ return Arr.sort(
196218 Object.values(savedConnectionsById).map((connection) => {
197219 const runtime = environmentStateById[connection.environmentId];
198220 return {
@@ -203,7 +225,7 @@ function deriveConnectedEnvironments( · 4 unmodified lines
203225 connectionError: runtime?.connectionError ?? null,
204226 };
205227 }),
206− (left, right) => left.environmentLabel.localeCompare(right.environmentLabel),
228+ environmentsSortOrder,
207229 );
208230 }
209231
@@ -250,6 +272,7 @@ export function useRemoteEnvironmentBootstrap() { · 40 unmodified lines
250272 void session.connection.dispose();
251273 }
252274 environmentSessions.clear();
275+ notifyEnvironmentConnectionListeners();
253276 };
254277 }, [replaceSavedConnections, setIsLoadingSavedConnection]);
255278 }
apps/mobile/src/state/use-selected-thread-commands.ts+8−8
@@ -9,7 +9,7 @@ import { · 8 unmodified lines
99 type RuntimeMode,
1010 } from "@t3tools/contracts";
1111
12−import { newClientId } from "../lib/clientId";
12+import { uuidv4 } from "../lib/uuid";
1313 import { useThreadSelection } from "./use-thread-selection";
1414 import { useRemoteEnvironmentStore } from "./remote-environment-store";
1515 import { getEnvironmentClient, useRemoteEnvironmentState } from "./use-remote-environment-registry";
@@ -102,7 +102,7 @@ export function useSelectedThreadCommands(input: { · 86 unmodified lines
102102
103103 await client.orchestration.dispatchCommand({
104104 type: "thread.meta.update",
105− commandId: CommandId.makeUnsafe(newClientId("command")),
105+ commandId: CommandId.makeUnsafe(uuidv4()),
106106 threadId: selectedThread.id,
107107 modelSelection,
108108 });
@@ -123,7 +123,7 @@ export function useSelectedThreadCommands(input: { · 14 unmodified lines
123123
124124 await client.orchestration.dispatchCommand({
125125 type: "thread.runtime-mode.set",
126− commandId: CommandId.makeUnsafe(newClientId("command")),
126+ commandId: CommandId.makeUnsafe(uuidv4()),
127127 threadId: selectedThread.id,
128128 runtimeMode,
129129 createdAt: new Date().toISOString(),
@@ -145,7 +145,7 @@ export function useSelectedThreadCommands(input: { · 15 unmodified lines
145145
146146 await client.orchestration.dispatchCommand({
147147 type: "thread.interaction-mode.set",
148− commandId: CommandId.makeUnsafe(newClientId("command")),
148+ commandId: CommandId.makeUnsafe(uuidv4()),
149149 threadId: selectedThread.id,
150150 interactionMode,
151151 createdAt: new Date().toISOString(),
@@ -173,7 +173,7 @@ export function useSelectedThreadCommands(input: { · 21 unmodified lines
173173
174174 await client.orchestration.dispatchCommand({
175175 type: "thread.turn.interrupt",
176− commandId: CommandId.makeUnsafe(newClientId("command")),
176+ commandId: CommandId.makeUnsafe(uuidv4()),
177177 threadId: selectedThread.id,
178178 ...(selectedThread.session?.activeTurnId
179179 ? { turnId: selectedThread.session.activeTurnId }
@@ -200,7 +200,7 @@ export function useSelectedThreadCommands(input: { · 20 unmodified lines
200200
201201 await client.orchestration.dispatchCommand({
202202 type: "thread.meta.update",
203− commandId: CommandId.makeUnsafe(newClientId("command")),
203+ commandId: CommandId.makeUnsafe(uuidv4()),
204204 threadId: selectedThread.id,
205205 title: trimmed,
206206 });
@@ -223,7 +223,7 @@ export function useSelectedThreadCommands(input: { · 16 unmodified lines
223223 try {
224224 await client.orchestration.dispatchCommand({
225225 type: "thread.approval.respond",
226− commandId: CommandId.makeUnsafe(newClientId("command")),
226+ commandId: CommandId.makeUnsafe(uuidv4()),
227227 threadId: selectedThread.id,
228228 requestId,
229229 decision,
@@ -250,7 +250,7 @@ export function useSelectedThreadCommands(input: { · 20 unmodified lines
250250 try {
251251 await client.orchestration.dispatchCommand({
252252 type: "thread.user-input.respond",
253− commandId: CommandId.makeUnsafe(newClientId("command")),
253+ commandId: CommandId.makeUnsafe(uuidv4()),
254254 threadId: selectedThread.id,
255255 requestId: activePendingUserInput.requestId,
256256 answers: activePendingUserInputAnswers,
apps/mobile/src/state/use-selected-thread-git-actions.ts+321−0
@@ -0,0 +1,321 @@
1+import { useCallback, useEffect } from "react";
2+
3+import { type GitActionRequestInput } from "@t3tools/client-runtime";
4+import {
5+ CommandId,
6+ type GitBranch,
7+ type GitRunStackedActionResult,
8+ ThreadId,
9+} from "@t3tools/contracts";
10+import {
11+ dedupeRemoteBranchesWithLocalMatches,
12+ sanitizeFeatureBranchName,
13+} from "@t3tools/shared/git";
14+
15+import { uuidv4 } from "../lib/uuid";
16+import { type ScopedMobileProject, type ScopedMobileThread } from "../lib/scopedEntities";
17+import { useRemoteEnvironmentStore } from "./remote-environment-store";
18+import { getEnvironmentClient } from "./use-remote-environment-registry";
19+import { gitActionManager } from "./use-git-action-state";
20+import { gitBranchManager } from "./use-git-branches";
21+import { gitStatusManager } from "./use-git-status";
22+import { useThreadSelection } from "./use-thread-selection";
23+
24+export 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 @@
1+import { useMemo } from "react";
2+
3+import { dedupeRemoteBranchesWithLocalMatches } from "@t3tools/shared/git";
4+
5+import { useGitActionState } from "./use-git-action-state";
6+import { useGitBranches } from "./use-git-branches";
7+import { useThreadSelection } from "./use-thread-selection";
8+
9+export 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 @@
1−import { useCallback, useEffect } from "react";
2−
3−import {
4− buildGitActionProgressStages,
5− type GitActionRequestInput,
6− type WsRpcClient,
7−} from "@t3tools/client-runtime";
8−import {
9− CommandId,
10− type GitBranch,
11− type GitRunStackedActionResult,
12− ThreadId,
13−} from "@t3tools/contracts";
14−import {
15− dedupeRemoteBranchesWithLocalMatches,
16− sanitizeFeatureBranchName,
17−} from "@t3tools/shared/git";
18−
19−import { newClientId } from "../lib/clientId";
20−import {
21− scopedThreadKey,
22− type ScopedMobileProject,
23− type ScopedMobileThread,
24−} from "../lib/scopedEntities";
25−import { useRemoteEnvironmentStore } from "./remote-environment-store";
26−import { useThreadSelection } from "./use-thread-selection";
27−import { getEnvironmentClient } from "./use-remote-environment-registry";
28−import { useThreadGitStore } from "./thread-git-store";
29−
30−export 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
1818 derivePendingUserInputs,
1919 type QueuedThreadMessage,
2020 } from "../lib/threadActivity";
21−import { newClientId } from "../lib/clientId";
21+import { uuidv4 } from "../lib/uuid";
2222 import type { ConnectedEnvironmentSummary } from "./remote-runtime-types";
2323 import { useRemoteEnvironmentStore } from "./remote-environment-store";
2424 import { getEnvironmentClient, useRemoteConnectionStatus } from "./use-remote-environment-registry";
@@ -238,7 +238,7 @@ export function useThreadComposerState() { · 213 unmodified lines
238238 return;
239239 }
240240
241− beginDispatchingQueuedMessage(queuedMessage.id);
241+ beginDispatchingQueuedMessage(queuedMessage.messageId);
242242 try {
243243 await client.orchestration.dispatchCommand({
244244 type: "thread.turn.start",
@@ -255,7 +255,11 @@ export function useThreadComposerState() { · 10 unmodified lines
255255 createdAt: queuedMessage.createdAt,
256256 });
257257
258− removeQueuedMessage(queuedMessage.environmentId, queuedMessage.threadId, queuedMessage.id);
258+ removeQueuedMessage(
259+ queuedMessage.environmentId,
260+ queuedMessage.threadId,
261+ queuedMessage.messageId,
262+ );
259263 patchEnvironmentRuntimeState(queuedMessage.environmentId, (runtime) => ({
260264 ...runtime,
261265 snapshot: runtime.snapshot
@@ -269,12 +273,16 @@ export function useThreadComposerState() { · 7 unmodified lines
269273 : runtime.snapshot,
270274 }));
271275 } catch (error) {
272− removeQueuedMessage(queuedMessage.environmentId, queuedMessage.threadId, queuedMessage.id);
276+ removeQueuedMessage(
277+ queuedMessage.environmentId,
278+ queuedMessage.threadId,
279+ queuedMessage.messageId,
280+ );
273281 setPendingConnectionError(
274282 error instanceof Error ? error.message : "Failed to send message.",
275283 );
276284 } finally {
277− finishDispatchingQueuedMessage(queuedMessage.id);
285+ finishDispatchingQueuedMessage(queuedMessage.messageId);
278286 }
279287 },
280288 [
@@ -309,11 +317,10 @@ export function useThreadComposerState() { · 28 unmodified lines
309317
310318 const createdAt = new Date().toISOString();
311319 enqueueQueuedMessage({
312− id: newClientId("queued-message"),
313320 environmentId: selectedThread.environmentId,
314321 threadId: selectedThread.id,
315− messageId: newClientId("message"),
316− commandId: newClientId("command"),
322+ messageId: MessageId.makeUnsafe(uuidv4()),
323+ commandId: CommandId.makeUnsafe(uuidv4()),
317324 text,
318325 attachments,
319326 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+
4+declare module 'uniwind' {
5+ export interface UniwindConfig {
6+ themes: readonly ['light', 'dark']
7+ }
8+}
9+
10+export {}
apps/web/src/lib/gitStatusState.test.ts+0−325
@@ -1,325 +0,0 @@
1−import { EnvironmentId, type GitStatusResult } from "@t3tools/contracts";
2−import { afterEach, describe, expect, it, vi } from "vitest";
3−
4−import type { WsRpcClient } from "../rpc/wsRpcClient";
5−import { resetAppAtomRegistryForTests } from "../rpc/atomRegistry";
6−import {
7− getGitStatusSnapshot,
8− resetGitStatusStateForTests,
9− refreshGitStatus,
10− watchGitStatus,
11−} from "./gitStatusState";
12−
13−const serviceHarness = vi.hoisted(() => ({
14− connections: new Map<string, any>(),
15− listeners: new Set<() => void>(),
16−}));
17−
18−vi.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−
29−function registerListener<T>(listeners: Set<(event: T) => void>, listener: (event: T) => void) {
30− listeners.add(listener);
31− return () => {
32− listeners.delete(listener);
33− };
34−}
35−
36−const gitStatusListeners = new Set<(event: GitStatusResult) => void>();
37−const ENVIRONMENT_ID = EnvironmentId.make("environment-local");
38−const OTHER_ENVIRONMENT_ID = EnvironmentId.make("environment-remote");
39−const TARGET = { environmentId: ENVIRONMENT_ID, cwd: "/repo" } as const;
40−const FRESH_TARGET = { environmentId: ENVIRONMENT_ID, cwd: "/fresh" } as const;
41−
42−const 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−
55−const 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−
65−function emitGitStatus(event: GitStatusResult) {
66− for (const listener of gitStatusListeners) {
67− listener(event);
68− }
69−}
70−
71−function 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−
166−afterEach(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−
176−describe("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 @@
11 import { useAtomValue } from "@effect/atom-react";
22 import {
3− type EnvironmentId,
4− type GitManagerServiceError,
5− type GitStatusResult,
6−} from "@t3tools/contracts";
7−import { Cause } from "effect";
8−import { Atom } from "effect/unstable/reactivity";
3+ type GitStatusClient,
4+ type GitStatusState,
5+ type GitStatusTarget,
6+ EMPTY_GIT_STATUS_ATOM,
7+ EMPTY_GIT_STATUS_STATE,
8+ createGitStatusManager,
9+ getGitStatusTargetKey,
10+ gitStatusStateAtom,
11+} from "@t3tools/client-runtime";
12+import { type EnvironmentId } from "@t3tools/contracts";
913 import { useEffect } from "react";
1014
1115 import { appAtomRegistry } from "../rpc/atomRegistry";
@@ -13,159 +17,38 @@ import { · 1 unmodified line
1317 readEnvironmentConnection,
1418 subscribeEnvironmentConnections,
1519 } from "../environments/runtime";
16−import type { WsRpcClient } from "~/rpc/wsRpcClient";
1720
18−interface GitStatusState {
19− readonly data: GitStatusResult | null;
20− readonly error: GitManagerServiceError | null;
21− readonly cause: Cause.Cause<GitManagerServiceError> | null;
22− readonly isPending: boolean;
23−}
24−
25−type GitStatusClient = Pick<WsRpcClient["git"], "onStatus" | "refreshStatus">;
26−interface ResolvedGitStatusClient {
27− readonly clientIdentity: string;
28− readonly client: GitStatusClient;
29−}
30−
31−interface WatchedGitStatus {
32− refCount: number;
33− unsubscribe: () => void;
34−}
35−
36−interface GitStatusTarget {
37− readonly environmentId: EnvironmentId | null;
38− readonly cwd: string | null;
39−}
40−
41−const EMPTY_GIT_STATUS_STATE = Object.freeze<GitStatusState>({
42− data: null,
43− error: null,
44− cause: null,
45− isPending: false,
46−});
47−const INITIAL_GIT_STATUS_STATE = Object.freeze<GitStatusState>({
48− ...EMPTY_GIT_STATUS_STATE,
49− isPending: true,
50−});
51−const EMPTY_GIT_STATUS_ATOM = Atom.make(EMPTY_GIT_STATUS_STATE).pipe(
52− Atom.keepAlive,
53− Atom.withLabel("git-status:null"),
54−);
55−
56−const NOOP: () => void = () => undefined;
57−const watchedGitStatuses = new Map<string, WatchedGitStatus>();
58−const knownGitStatusKeys = new Set<string>();
59−const gitStatusRefreshInFlight = new Map<string, Promise<GitStatusResult>>();
60−const gitStatusLastRefreshAtByKey = new Map<string, number>();
61−
62−const GIT_STATUS_REFRESH_DEBOUNCE_MS = 1_000;
63−
64−const 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− );
21+export type { GitStatusState, GitStatusTarget };
22+
23+/* ─── Manager singleton ─────────────────────────────────────────────── */
24+
25+const manager = createGitStatusManager({
26+ getRegistry: () => appAtomRegistry,
27+ getClient: (environmentId) => {
28+ const connection = readEnvironmentConnection(environmentId as EnvironmentId);
29+ return connection ? connection.client.git : null;
30+ },
31+ getClientIdentity: (environmentId) => {
32+ const connection = readEnvironmentConnection(environmentId as EnvironmentId);
33+ return connection ? connection.environmentId : null;
34+ },
35+ subscribeClientChanges: subscribeEnvironmentConnections,
7036 });
7137
72−function getGitStatusTargetKey(target: GitStatusTarget): string | null {
73− if (target.environmentId === null || target.cwd === null) {
74− return null;
75− }
76−
77− return `${target.environmentId}:${target.cwd}`;
78−}
79−
80−function 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−
90−export 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−
99−export 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−
119−export 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− }
38+/* ─── Public API (preserves existing call-sites) ────────────────────── */
13239
133− const currentInFlight = gitStatusRefreshInFlight.get(targetKey);
134− if (currentInFlight) {
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;
40+export function refreshGitStatus(target: GitStatusTarget, client?: GitStatusClient) {
41+ return manager.refresh(target, client);
14942 }
15043
15144 export function resetGitStatusStateForTests(): void {
152− for (const watched of watchedGitStatuses.values()) {
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();
45+ manager.reset();
16346 }
16447
16548 export function useGitStatus(target: GitStatusTarget): GitStatusState {
16649 const targetKey = getGitStatusTargetKey(target);
16750 useEffect(
168− () => watchGitStatus({ environmentId: target.environmentId, cwd: target.cwd }),
51+ () => manager.watch({ environmentId: target.environmentId, cwd: target.cwd }),
16952 [target.environmentId, target.cwd],
17053 );
17154
@@ -174,114 +57,3 @@ export function useGitStatus(target: GitStatusTarget): GitStatusState { · 2 unmodified lines
17457 );
17558 return targetKey === null ? EMPTY_GIT_STATUS_STATE : state;
17659 }
177−
178−function 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−
193−function 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−
244−function 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−
264−function 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
4545 "version": "0.0.0",
4646 "dependencies": {
4747 "@callstack/liquid-glass": "^0.7.1",
48+ "@effect/atom-react": "catalog:",
4849 "@expo-google-fonts/dm-sans": "^0.4.2",
4950 "@gorhom/bottom-sheet": "^5",
5051 "@legendapp/list": "^3.0.0-beta.44",
@@ -54,6 +55,7 @@ · 3 unmodified lines
5455 "@t3tools/client-runtime": "workspace:*",
5556 "@t3tools/contracts": "workspace:*",
5657 "@t3tools/shared": "workspace:*",
58+ "clsx": "^2.1.1",
5759 "effect": "catalog:",
5860 "expo": "~55.0.14",
5961 "expo-camera": "~55.0.15",
@@ -73,12 +75,10 @@ · 13 unmodified lines
7375 "expo-splash-screen": "~55.0.13",
7476 "expo-symbols": "~55.0.5",
7577 "expo-updates": "~55.0.16",
76− "nativewind": "5.0.0-preview.3",
7778 "punycode": "^2.3.1",
7879 "react": "19.2.0",
7980 "react-dom": "19.2.0",
8081 "react-native": "0.83.4",
81− "react-native-css": "3.0.7",
8282 "react-native-gesture-handler": "~2.30.0",
8383 "react-native-image-viewing": "^0.2.2",
8484 "react-native-keyboard-controller": "1.20.7",
@@ -88,14 +88,13 @@ · 3 unmodified lines
8888 "react-native-screens": "~4.23.0",
8989 "react-native-svg": "15.15.3",
9090 "react-native-worklets": "0.7.2",
91+ "tailwind-merge": "^3.5.0",
92+ "uniwind": "^1.6.2",
9193 "zustand": "^5.0.12",
9294 },
9395 "devDependencies": {
94− "@tailwindcss/postcss": "4.1.12",
9596 "@types/react": "~19.2.10",
9697 "babel-preset-expo": "~55.0.8",
97− "lightningcss": "1.30.1",
98− "postcss": "^8.5.8",
9998 "tailwindcss": "4.1.12",
10099 "typescript": "~5.9.2",
101100 },
@@ -272,8 +271,6 @@ · 170 unmodified lines
272271 "vitest": "^4.0.0",
273272 },
274273 "packages": {
275− "@alloc/quick-lru": ["@alloc/quick-lru@5.2.0", "", {}, "sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw=="],
276−
277274 "@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=="],
278275
279276 "@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
738735
739736 "@ioredis/commands": ["@ioredis/commands@1.5.1", "", {}, "sha512-JH8ZL/ywcJyR9MmJ5BNqZllXNZQqQbnVZOqpPQqE1vHiFgAw4NHbvE0FOduNU8IX9babitBT46571OnPTT0Zcw=="],
740737
741− "@isaacs/fs-minipass": ["@isaacs/fs-minipass@4.0.1", "", { "dependencies": { "minipass": "^7.0.4" } }, "sha512-wgm9Ehl2jpeqP3zw/7mo3kRHFp5MEDhqAdwy1fTGkHAwnkGOVsgpvQhL8B5n1qlb01jV3n/bI0ZfZp5lWA1k4w=="],
742−
743738 "@isaacs/ttlcache": ["@isaacs/ttlcache@1.4.1", "", {}, "sha512-RQgQ4uQ+pLbqXfOmieB91ejmLwvSgv9nLx6sT6sD83s7umBypgg+OIBOBbEUiJXrfpnp9j0mRhYYdzp9uqq3lA=="],
744739
745740 "@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
10961091
10971092 "@t3tools/web": ["@t3tools/web@workspace:apps/web"],
10981093
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=="],
11001095
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=="],
11021097
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=="],
11041099
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=="],
11061101
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=="],
11081103
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=="],
11101105
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=="],
11121107
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=="],
11141109
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=="],
11161111
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=="],
11181113
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=="],
11201115
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=="],
11221117
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=="],
11241119
1125− "@tailwindcss/oxide-win32-x64-msvc": ["@tailwindcss/oxide-win32-x64-msvc@4.1.12", "", { "os": "win32", "cpu": "x64" }, "sha512-NKIh5rzw6CpEodv/++r0hGLlfgT/gFN+5WNdZtvh6wpU2BpGNgdjvj6H2oFc8nCM839QM1YOhjpgbAONUb4IxA=="],
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=="],
1120+ "@tailwindcss/oxide-win32-x64-msvc": ["@tailwindcss/oxide-win32-x64-msvc@4.2.1", "", { "os": "win32", "cpu": "x64" }, "sha512-rbO34G5sMWWyrN/idLeVxAZgAKWrn5LiR3/I90Q9MkA67s6T1oB0xtTe+0heoBvHSpbU9Mk7i6uwJnpo4u21XQ=="],
11281121
11291122 "@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=="],
11301123
@@ -1320,8 +1313,6 @@ · 189 unmodified lines
13201313
13211314 "array-iterate": ["array-iterate@2.0.1", "", {}, "sha512-I1jXZMjAgCMmxT4qxXfPXa6SthSoE8h6gkSI9BGGNv8mP8G/v0blc+qFnZu6K42vTOiuME596QaLO0TP3Lk0xg=="],
13221315
1323− "array-timsort": ["array-timsort@1.0.3", "", {}, "sha512-/+3GRL7dDAGEfM6TseQk/U+mi18TU2Ms9I3UlLdUMhz2hbvGNTKdj9xniwXfUqgYhHxRx0+8UnKkvlNwVU+cWQ=="],
1324−
13251316 "asap": ["asap@2.0.6", "", {}, "sha512-BSHWgDSAiKs50o2Re8ppvp3seVHXSRM44cdSsT9FfNEUUZLOGWVCsiWaRPWM1Znn+mqZ1OfVZ3z3DWEzSp7hRA=="],
13261317
13271318 "assertion-error": ["assertion-error@2.0.1", "", {}, "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA=="],
@@ -1440,8 +1431,6 @@ · 112 unmodified lines
14401431
14411432 "chokidar": ["chokidar@4.0.3", "", { "dependencies": { "readdirp": "^4.0.1" } }, "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA=="],
14421433
1443− "chownr": ["chownr@3.0.0", "", {}, "sha512-+IxzY9BZOQd/XuYPRmrvEVjF/nqj5kgT4kEq7VofrDoM1MxoRjEWkrCC3EtLi59TVawxTAn+orJwFQcrqEN1+g=="],
1444−
14451434 "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=="],
14461435
14471436 "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
14761465
14771466 "color-string": ["color-string@1.9.1", "", { "dependencies": { "color-name": "^1.0.0", "simple-swizzle": "^0.2.2" } }, "sha512-shrVawQFojnZv6xM40anx4CkoDP+fZsw/ZerEMsW/pyzsRbElpsL/DBVW7q3ExxwusdNXI3lXpuhEZkzs8p5Eg=="],
14781467
1479− "colorjs.io": ["colorjs.io@0.6.0-alpha.1", "", {}, "sha512-c/h/8uAmPydQcriRdX8UTAFHj6SpSHFHBA8LvMikvYWAVApPTwg/pyOXNsGmaCBd6L/EeDlRHSNhTtnIFp/qsg=="],
1480−
14811468 "comma-separated-tokens": ["comma-separated-tokens@2.0.3", "", {}, "sha512-Fu4hJdvzeylCfQPp9SGWidpzrMs7tTrlu6Vb8XGaRGck8QSNZJJp538Wrb60Lax4fPwR64ViY468OIUTbRlGZg=="],
14821469
14831470 "commander": ["commander@12.1.0", "", {}, "sha512-Vw8qHK3bZM9y/P10u3Vib8o/DdkvA2OtPtZvD871QKjy74Wj1WSKFILMPRPSdUSx5RFK1arlJzEtA4PkFgnbuA=="],
14841471
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−
14871472 "common-ancestor-path": ["common-ancestor-path@2.0.0", "", {}, "sha512-dnN3ibLeoRf2HNC+OlCiNc5d2zxbLJXOtiZUudNFSXZrNSydxcCsSpRzXwfu7BBWCIfHPw+xTayeBvJCP/D8Ng=="],
14881473
14891474 "compressible": ["compressible@2.0.18", "", { "dependencies": { "mime-db": ">= 1.43.0 < 2" } }, "sha512-AF3r7P5dWxL8MxyITRMlORQNaOA2IkAFaTr4k7BUumjPtRpGDTZpl0Pb1XCO6JeDCBdp126Cgs9sMxqSjgYyRg=="],
@@ -1528,6 +1513,8 @@ · 38 unmodified lines
15281513
15291514 "csstype": ["csstype@3.2.3", "", {}, "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ=="],
15301515
1516+ "culori": ["culori@4.0.2", "", {}, "sha512-1+BhOB8ahCn4O0cep0Sh2l9KCOfOdY+BXJnKMHFFzDEouSr/el18QwXEMRlOj9UY5nCeA8UN3a/82rUWRBeyBw=="],
1517+
15311518 "date-fns": ["date-fns@4.1.0", "", {}, "sha512-Ukq0owbQXxa/U3EGtsdVBkR1w7KOQ5gIBqdH2hkvknzZPYvBxb/aa6E8L7tmjFtkwZBu3UXBbjIgPo/Ez4xaNg=="],
15321519
15331520 "debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="],
@@ -2240,8 +2227,6 @@ · 706 unmodified lines
22402227
22412228 "minipass": ["minipass@7.1.3", "", {}, "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A=="],
22422229
2243− "minizlib": ["minizlib@3.1.0", "", { "dependencies": { "minipass": "^7.1.2" } }, "sha512-KZxYo1BUkWD2TVFLr0MQoM8vUUigWD3LlD83a/75BqC+4qE0Hb1Vo5v1FgcfaNXvfXzr+5EhQ6ing/CaBijTlw=="],
2244−
22452230 "mkdirp": ["mkdirp@1.0.4", "", { "bin": { "mkdirp": "bin/cmd.js" } }, "sha512-vVqVZQyf3WLx2Shd0qJ9xuvqgAyKPLAiqITEtqW0oIUjzo3PePDd6fW9iFz30ef7Ysp/oiWqbhszeGWW2T6Gzw=="],
22462231
22472232 "mrmime": ["mrmime@2.0.1", "", {}, "sha512-Y3wQdFg2Va6etvQ5I82yUhGdsKrcYox6p7FfL1LbK2J4V01F9TGlepTIhnK24t7koZibmg82KGglhA1XK5IsLQ=="],
@@ -2264,8 +2249,6 @@ · 16 unmodified lines
22642249
22652250 "nanoid": ["nanoid@3.3.11", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w=="],
22662251
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−
22692252 "negotiator": ["negotiator@0.6.3", "", {}, "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg=="],
22702253
22712254 "neotraverse": ["neotraverse@0.6.18", "", {}, "sha512-Z4SmBUweYa09+o6pG+eASabEpP6QkQ70yHj351pQoEXIs8uHbaU2DWVmzBANKgflPa47A50PtB2+NgRpQvr7vA=="],
@@ -2458,8 +2441,6 @@ · 186 unmodified lines
24582441
24592442 "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=="],
24602443
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−
24632444 "react-native-fit-image": ["react-native-fit-image@1.5.5", "", { "dependencies": { "prop-types": "^15.5.10" } }, "sha512-Wl3Vq2DQzxgsWKuW4USfck9zS7YzhvLNPpkwUUCF90bL32e1a0zOVQ3WsJILJOwzmPdHfzZmWasiiAUNBkhNkg=="],
24642445
24652446 "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
27062687
27072688 "tailwindcss": ["tailwindcss@4.1.12", "", {}, "sha512-DzFtxOi+7NsFf7DBtI3BJsynR+0Yp6etH+nRPTbpWnS2pZBaSksv/JGctNwSWzbFjp0vxSqknaUylseZqMDGrA=="],
27082689
2709− "tailwindcss-safe-area": ["tailwindcss-safe-area@1.3.0", "", { "peerDependencies": { "tailwindcss": "^4.0.0" } }, "sha512-RoxnW1zAjBWC3XK+row7Qj5toRMRlKNN/p3FLXb6fTGKxDGWT6JP/mcNX1yf09xRficQ308hbwiedgniepSp1Q=="],
2710−
27112690 "tapable": ["tapable@2.3.2", "", {}, "sha512-1MOpMXuhGzGL5TTCZFItxCc0AARf1EZFQkGqMm7ERKj8+Hgr5oLvJOVFcC+lRmR8hCe2S3jC4T5D7Vg/d7/fhA=="],
27122691
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−
27152692 "terminal-link": ["terminal-link@2.1.1", "", { "dependencies": { "ansi-escapes": "^4.2.1", "supports-hyperlinks": "^2.0.0" } }, "sha512-un0FmiRUQNr5PJqy9kP7c40F5BOfpGlYTrxonDChEZB7pzZxRNp/bt+ymiy9/npwXya9KH99nJ/GXFIiUkYGFQ=="],
27162693
27172694 "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
28302807
28312808 "universalify": ["universalify@2.0.1", "", {}, "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw=="],
28322809
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+
28332812 "unpipe": ["unpipe@1.0.0", "", {}, "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ=="],
28342813
28352814 "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
29502929
29512930 "y18n": ["y18n@5.0.8", "", {}, "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA=="],
29522931
2953− "yallist": ["yallist@5.0.0", "", {}, "sha512-YgvUTfwqyc7UXVMrB+SImsVYSmTS8X/tSrtdNZMImM+n7+QTriRXyXim0mBrTXNeqzVF0KWGgHPeiyViFFrNDw=="],
2932+ "yallist": ["yallist@3.1.1", "", {}, "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g=="],
29542933
29552934 "yaml": ["yaml@2.8.3", "", { "bin": { "yaml": "bin.mjs" } }, "sha512-AvbaCLOO2Otw/lW5bmh9d/WEdcDFdQp2Z2ZUH3pX9U2ihyUY0nvLv7J6TrWowklRGPYbB/IuIMfYgxaCPg5Bpg=="],
29562935
@@ -3056,13 +3035,15 @@ · 99 unmodified lines
30563035
30573036 "@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=="],
30583037
3038+ "@tailwindcss/node/tailwindcss": ["tailwindcss@4.2.1", "", {}, "sha512-/tBrSQ36vCleJkAOsy9kbNTgaxvGbyOamC30PRePTQe/o1MFwEKHQk4Cn7BNGaPtjp+PuUrByJehM1hgxfq4sw=="],
3039+
30593040 "@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=="],
30603041
30613042 "@tailwindcss/oxide-wasm32-wasi/@emnapi/runtime": ["@emnapi/runtime@1.9.2", "", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-3U4+MIWHImeyu1wnmVygh5WlgfYDtyf0k8AbLhMFxOipihf6nrWC4syIm/SwEeec0mNSafiiNnMJwbza/Is6Lw=="],
30623043
30633044 "@tailwindcss/oxide-wasm32-wasi/@emnapi/wasi-threads": ["@emnapi/wasi-threads@1.2.1", "", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w=="],
30643045
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=="],
30663047
30673048 "@tailwindcss/oxide-wasm32-wasi/@tybys/wasm-util": ["@tybys/wasm-util@0.10.1", "", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg=="],
30683049
@@ -3212,8 +3193,6 @@ · 143 unmodified lines
32123193
32133194 "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=="],
32143195
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−
32173196 "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=="],
32183197
32193198 "react-native-reanimated/semver": ["semver@7.7.3", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q=="],
@@ -3292,8 +3271,6 @@ · 72 unmodified lines
32923271
32933272 "yargs/yargs-parser": ["yargs-parser@21.1.1", "", {}, "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw=="],
32943273
3295− "@babel/helper-compilation-targets/lru-cache/yallist": ["yallist@3.1.1", "", {}, "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g=="],
3296−
32973274 "@electron/get/fs-extra/jsonfile": ["jsonfile@4.0.0", "", { "optionalDependencies": { "graceful-fs": "^4.1.6" } }, "sha512-m6F1R3z8jjlf2imQHS2Qez5sjKWQzbuuhuJ/FKYFRZvPE3PuHcSMVZzfsLhGVOkfd20obL5SWEBew5ShlquNxg=="],
32983275
32993276 "@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
66 ServerLifecycleWelcomePayload,
77 TerminalEvent,
88 } from "@t3tools/contracts";
9+import * as Arr from "effect/Array";
10+import * as Order from "effect/Order";
11+import * as Option from "effect/Option";
12+import { pipe } from "effect/Function";
913
1014 import type { KnownEnvironment } from "./knownEnvironment";
1115 import type { WsRpcClient } from "./wsRpcClient";
@@ -137,14 +141,20 @@ function createOrchestrationRecoveryCoordinator() { · 125 unmodified lines
137141 markEventBatchApplied<T extends Readonly<{ sequence: number }>>(
138142 events: ReadonlyArray<T>,
139143 ): ReadonlyArray<T> {
140− const nextEvents = events
141− .filter((event) => event.sequence > state.latestSequence)
142− .toSorted((left, right) => left.sequence - right.sequence);
144+ const nextEvents = pipe(
145+ events,
146+ Arr.filter((event) => event.sequence > state.latestSequence),
147+ Arr.sortWith((event) => event.sequence, Order.Number),
148+ );
143149 if (nextEvents.length === 0) {
144150 return [];
145151 }
146152
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+
148158 state = {
149159 ...state,
150160 latestSequence,
packages/client-runtime/src/gitActionState.test.ts+264−0
@@ -0,0 +1,264 @@
1+import type {
2+ GitActionProgressEvent,
3+ GitCreateBranchResult,
4+ GitCreateWorktreeResult,
5+ GitCheckoutResult,
6+ GitPullResult,
7+ GitRunStackedActionResult,
8+ GitStatusResult,
9+} from "@t3tools/contracts";
10+import { AtomRegistry } from "effect/unstable/reactivity";
11+import { afterEach, describe, expect, it, vi } from "vitest";
12+
13+import {
14+ type GitActionClient,
15+ createGitActionManager,
16+ EMPTY_GIT_ACTION_STATE,
17+} from "./gitActionState";
18+
19+let atomRegistry = AtomRegistry.make();
20+
21+function resetAtomRegistry() {
22+ atomRegistry.dispose();
23+ atomRegistry = AtomRegistry.make();
24+}
25+
26+function 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+
36+const TARGET = { environmentId: "env-local", cwd: "/repo" } as const;
37+
38+const 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+
51+function 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+
62+function 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+
72+function 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+
84+function 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+
96+function 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+
121+function 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+
159+describe("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 @@
1+import 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";
16+import { Atom, type AtomRegistry } from "effect/unstable/reactivity";
17+
18+import { buildGitActionProgressStages } from "./gitActions";
19+import type { WsRpcClient } from "./wsRpcClient";
20+
21+export type GitActionOperation =
22+ | "refresh_status"
23+ | "run_stacked_action"
24+ | "pull"
25+ | "checkout"
26+ | "create_branch"
27+ | "create_worktree"
28+ | "init";
29+
30+export 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+
44+export interface GitActionTarget {
45+ readonly environmentId: string | null;
46+ readonly cwd: string | null;
47+}
48+
49+export type GitActionClient = Pick<
50+ WsRpcClient["git"],
51+ | "refreshStatus"
52+ | "runStackedAction"
53+ | "pull"
54+ | "checkout"
55+ | "createBranch"
56+ | "createWorktree"
57+ | "init"
58+>;
59+
60+export 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+
74+const knownGitActionKeys = new Set<string>();
75+
76+export 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+
84+export const EMPTY_GIT_ACTION_ATOM = Atom.make(EMPTY_GIT_ACTION_STATE).pipe(
85+ Atom.keepAlive,
86+ Atom.withLabel("git-action:null"),
87+);
88+
89+export 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+
96+export 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+
187+export interface GitActionManagerConfig {
188+ readonly getRegistry: () => AtomRegistry.AtomRegistry;
189+ readonly getClient: (environmentId: string) => GitActionClient | null;
190+ readonly getActionId?: () => string;
191+}
192+
193+export 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 @@
1+import type { GitListBranchesResult } from "@t3tools/contracts";
2+import { AtomRegistry } from "effect/unstable/reactivity";
3+import { afterEach, describe, expect, it, vi } from "vitest";
4+
5+import {
6+ createGitBranchManager,
7+ EMPTY_GIT_BRANCH_STATE,
8+ gitBranchStateAtom,
9+ type GitBranchClient,
10+} from "./gitBranchState";
11+
12+let atomRegistry = AtomRegistry.make();
13+
14+function resetAtomRegistry() {
15+ atomRegistry.dispose();
16+ atomRegistry = AtomRegistry.make();
17+}
18+
19+const TARGET = { environmentId: "env-local", cwd: "/repo" } as const;
20+
21+const 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+
32+const 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+
40+function 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+
64+describe("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 @@
1+import type { GitBranch, GitListBranchesInput, GitListBranchesResult } from "@t3tools/contracts";
2+import { Atom, type AtomRegistry } from "effect/unstable/reactivity";
3+
4+import type { WsRpcClient } from "./wsRpcClient";
5+
6+export interface GitBranchTarget {
7+ readonly environmentId: string | null;
8+ readonly cwd: string | null;
9+ readonly query?: string | null;
10+}
11+
12+export interface GitBranchState {
13+ readonly data: GitListBranchesResult | null;
14+ readonly isPending: boolean;
15+ readonly error: string | null;
16+}
17+
18+export type GitBranchClient = Pick<WsRpcClient["git"], "listBranches">;
19+
20+export const EMPTY_GIT_BRANCH_STATE = Object.freeze<GitBranchState>({
21+ data: null,
22+ isPending: false,
23+ error: null,
24+});
25+
26+const INITIAL_GIT_BRANCH_STATE = Object.freeze<GitBranchState>({
27+ data: null,
28+ isPending: true,
29+ error: null,
30+});
31+
32+const knownGitBranchKeys = new Set<string>();
33+
34+export 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+
42+export const EMPTY_GIT_BRANCH_ATOM = Atom.make(EMPTY_GIT_BRANCH_STATE).pipe(
43+ Atom.keepAlive,
44+ Atom.withLabel("git-branches:null"),
45+);
46+
47+function normalizeQuery(query: string | null | undefined): string {
48+ return query?.trim() ?? "";
49+}
50+
51+export 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+
59+function toErrorMessage(error: unknown): string {
60+ return error instanceof Error ? error.message : "Failed to load branches.";
61+}
62+
63+function 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+
77+export interface GitBranchManagerConfig {
78+ readonly getRegistry: () => AtomRegistry.AtomRegistry;
79+ readonly getClient: (environmentId: string) => GitBranchClient | null;
80+}
81+
82+export 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 @@
1+import type { GitStatusResult } from "@t3tools/contracts";
2+import { AtomRegistry } from "effect/unstable/reactivity";
3+import { afterEach, describe, expect, it, vi } from "vitest";
4+
5+import { type GitStatusClient, createGitStatusManager } from "./gitStatusState";
6+
7+/* ─── Test helpers ──────────────────────────────────────────────────── */
8+
9+let atomRegistry = AtomRegistry.make();
10+
11+function resetAtomRegistry() {
12+ atomRegistry.dispose();
13+ atomRegistry = AtomRegistry.make();
14+}
15+
16+function registerListener<T>(listeners: Set<(event: T) => void>, listener: (event: T) => void) {
17+ listeners.add(listener);
18+ return () => {
19+ listeners.delete(listener);
20+ };
21+}
22+
23+const 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+
36+function 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+
60+const PENDING = { data: null, error: null, cause: null, isPending: true };
61+const EMPTY = { data: null, error: null, cause: null, isPending: false };
62+
63+const TARGET = { environmentId: "env-local", cwd: "/repo" } as const;
64+const FRESH_TARGET = { environmentId: "env-local", cwd: "/fresh" } as const;
65+const OTHER_ENV_TARGET = { environmentId: "env-remote", cwd: "/repo" } as const;
66+
67+/* ─── Tests ─────────────────────────────────────────────────────────── */
68+
69+describe("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 @@
1+import type { GitManagerServiceError, GitStatusResult } from "@t3tools/contracts";
2+import type { Cause } from "effect";
3+import { Atom, type AtomRegistry } from "effect/unstable/reactivity";
4+import type { WsRpcClient } from "./wsRpcClient";
5+
6+/* ─── Types ─────────────────────────────────────────────────────────── */
7+
8+export 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+
15+export interface GitStatusTarget {
16+ readonly environmentId: string | null;
17+ readonly cwd: string | null;
18+}
19+
20+export type GitStatusClient = Pick<WsRpcClient["git"], "onStatus" | "refreshStatus">;
21+
22+interface WatchedEntry {
23+ refCount: number;
24+ teardown: () => void;
25+}
26+
27+/* ─── Constants ─────────────────────────────────────────────────────── */
28+
29+const NOOP: () => void = () => undefined;
30+
31+export const EMPTY_GIT_STATUS_STATE = Object.freeze<GitStatusState>({
32+ data: null,
33+ error: null,
34+ cause: null,
35+ isPending: false,
36+});
37+
38+const INITIAL_GIT_STATUS_STATE = Object.freeze<GitStatusState>({
39+ data: null,
40+ error: null,
41+ cause: null,
42+ isPending: true,
43+});
44+
45+/* ─── Atoms ─────────────────────────────────────────────────────────── */
46+
47+const knownGitStatusKeys = new Set<string>();
48+
49+export 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+
57+export 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+
64+export 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+
73+export 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+
94+const GIT_STATUS_REFRESH_DEBOUNCE_MS = 1_000;
95+
96+export 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 @@
11 export * from "./knownEnvironment";
22 export * from "./scoped";
33 export * from "./gitActions";
4+export * from "./gitActionState";
5+export * from "./gitBranchState";
6+export * from "./gitStatusState";
47 export * from "./wsRpcProtocol";
58 export * from "./wsTransport";
69 export * from "./wsRpcClient";