- 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
Unified · Split
| @@ -1,6 +1,6 @@ | ||
| 1 | 1 | module.exports = function (api) { |
| 2 | 2 | api.cache(true); |
| 3 | 3 | return { |
| 4 | − presets: [["babel-preset-expo", { unstable_transformImportMeta: true }], "nativewind/babel"], | |
| 4 | + presets: [["babel-preset-expo", { unstable_transformImportMeta: true }]], | |
| 5 | 5 | }; |
| 6 | 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"; | |
| 4 | 3 | |
| 5 | −@import "nativewind/theme"; | |
| 4 | +/* ─── Theme tokens ──────────────────────────────────────────────────── */ | |
| 5 | +@layer theme { | |
| 6 | + :root { | |
| 7 | + @variant light { | |
| 8 | + /* Page backgrounds */ | |
| 9 | + --color-screen: #f2f2f7; | |
| 10 | + --color-sheet: rgba(242, 242, 247, 0.98); | |
| 6 | 11 | |
| 12 | + /* Card / surface */ | |
| 13 | + --color-card: #ffffff; | |
| 14 | + --color-card-alt: #f5f5f5; | |
| 15 | + --color-card-translucent: rgba(255, 255, 255, 0.94); | |
| 16 | + | |
| 17 | + /* Text */ | |
| 18 | + --color-foreground: #262626; | |
| 19 | + --color-foreground-secondary: #525252; | |
| 20 | + --color-foreground-muted: #737373; | |
| 21 | + --color-foreground-tertiary: #a3a3a3; | |
| 22 | + | |
| 23 | + /* Borders & separators */ | |
| 24 | + --color-border: rgba(0, 0, 0, 0.08); | |
| 25 | + --color-border-subtle: rgba(0, 0, 0, 0.06); | |
| 26 | + --color-separator: rgba(0, 0, 0, 0.04); | |
| 27 | + | |
| 28 | + /* Subtle backgrounds (badges, pills, overlays) */ | |
| 29 | + --color-subtle: rgba(0, 0, 0, 0.04); | |
| 30 | + --color-subtle-strong: rgba(0, 0, 0, 0.08); | |
| 31 | + | |
| 32 | + /* Primary action */ | |
| 33 | + --color-primary: #262626; | |
| 34 | + --color-primary-foreground: #ffffff; | |
| 35 | + --color-primary-shadow: rgba(0, 0, 0, 0.18); | |
| 36 | + | |
| 37 | + /* Secondary action */ | |
| 38 | + --color-secondary: #ffffff; | |
| 39 | + --color-secondary-foreground: #262626; | |
| 40 | + --color-secondary-border: rgba(0, 0, 0, 0.08); | |
| 41 | + | |
| 42 | + /* Danger */ | |
| 43 | + --color-danger: #fef2f2; | |
| 44 | + --color-danger-border: rgba(239, 68, 68, 0.12); | |
| 45 | + --color-danger-foreground: #dc2626; | |
| 46 | + | |
| 47 | + /* Inputs */ | |
| 48 | + --color-input: #ffffff; | |
| 49 | + --color-input-border: rgba(0, 0, 0, 0.1); | |
| 50 | + --color-placeholder: #a3a3a3; | |
| 51 | + | |
| 52 | + /* Icons */ | |
| 53 | + --color-icon: #262626; | |
| 54 | + --color-icon-muted: #525252; | |
| 55 | + --color-icon-subtle: #a3a3a3; | |
| 56 | + | |
| 57 | + /* Header / glass chrome */ | |
| 58 | + --color-header: rgba(255, 255, 255, 0.97); | |
| 59 | + --color-header-border: rgba(0, 0, 0, 0.06); | |
| 60 | + | |
| 61 | + /* StatusBar */ | |
| 62 | + --color-status-bar: #f2f2f7; | |
| 63 | + | |
| 64 | + /* Markdown */ | |
| 65 | + --color-md-body: #262626; | |
| 66 | + --color-md-strong: #171717; | |
| 67 | + --color-md-link: #2563eb; | |
| 68 | + --color-md-blockquote-border: rgba(0, 0, 0, 0.08); | |
| 69 | + --color-md-blockquote-bg: rgba(0, 0, 0, 0.02); | |
| 70 | + --color-md-code-bg: rgba(0, 0, 0, 0.04); | |
| 71 | + --color-md-code-text: #262626; | |
| 72 | + --color-md-user-code-bg: rgba(255, 255, 255, 0.55); | |
| 73 | + --color-md-user-code-text: #262626; | |
| 74 | + --color-md-user-fence-bg: rgba(0, 0, 0, 0.04); | |
| 75 | + --color-md-user-fence-text: #1e3a5f; | |
| 76 | + --color-md-hr: rgba(0, 0, 0, 0.08); | |
| 77 | + | |
| 78 | + /* Drawer / modal backdrop */ | |
| 79 | + --color-backdrop: rgba(0, 0, 0, 0.22); | |
| 80 | + --color-drawer: rgba(255, 255, 255, 0.99); | |
| 81 | + --color-drawer-shadow: rgba(0, 0, 0, 0.12); | |
| 82 | + | |
| 83 | + /* Misc */ | |
| 84 | + --color-dot-separator: rgba(0, 0, 0, 0.2); | |
| 85 | + --color-wordmark: #262626; | |
| 86 | + --color-chevron: rgba(0, 0, 0, 0.2); | |
| 87 | + } | |
| 88 | + | |
| 89 | + @variant dark { | |
| 90 | + /* Page backgrounds */ | |
| 91 | + --color-screen: #0a0a0a; | |
| 92 | + --color-sheet: rgba(14, 14, 14, 0.98); | |
| 93 | + | |
| 94 | + /* Card / surface */ | |
| 95 | + --color-card: #171717; | |
| 96 | + --color-card-alt: #1c1c1c; | |
| 97 | + --color-card-translucent: rgba(17, 17, 17, 0.94); | |
| 98 | + | |
| 99 | + /* Text */ | |
| 100 | + --color-foreground: #f5f5f5; | |
| 101 | + --color-foreground-secondary: #a3a3a3; | |
| 102 | + --color-foreground-muted: #737373; | |
| 103 | + --color-foreground-tertiary: #525252; | |
| 104 | + | |
| 105 | + /* Borders & separators */ | |
| 106 | + --color-border: rgba(255, 255, 255, 0.06); | |
| 107 | + --color-border-subtle: rgba(255, 255, 255, 0.04); | |
| 108 | + --color-separator: rgba(255, 255, 255, 0.03); | |
| 109 | + | |
| 110 | + /* Subtle backgrounds (badges, pills, overlays) */ | |
| 111 | + --color-subtle: rgba(255, 255, 255, 0.04); | |
| 112 | + --color-subtle-strong: rgba(255, 255, 255, 0.08); | |
| 113 | + | |
| 114 | + /* Primary action */ | |
| 115 | + --color-primary: #f5f5f5; | |
| 116 | + --color-primary-foreground: #0a0a0a; | |
| 117 | + --color-primary-shadow: rgba(0, 0, 0, 0.22); | |
| 118 | + | |
| 119 | + /* Secondary action */ | |
| 120 | + --color-secondary: rgba(255, 255, 255, 0.04); | |
| 121 | + --color-secondary-foreground: #f5f5f5; | |
| 122 | + --color-secondary-border: rgba(255, 255, 255, 0.06); | |
| 123 | + | |
| 124 | + /* Danger */ | |
| 125 | + --color-danger: rgba(239, 68, 68, 0.14); | |
| 126 | + --color-danger-border: rgba(248, 113, 113, 0.18); | |
| 127 | + --color-danger-foreground: #fca5a5; | |
| 128 | + | |
| 129 | + /* Inputs */ | |
| 130 | + --color-input: #141414; | |
| 131 | + --color-input-border: rgba(255, 255, 255, 0.08); | |
| 132 | + --color-placeholder: #737373; | |
| 133 | + | |
| 134 | + /* Icons */ | |
| 135 | + --color-icon: #f5f5f5; | |
| 136 | + --color-icon-muted: #a3a3a3; | |
| 137 | + --color-icon-subtle: #737373; | |
| 138 | + | |
| 139 | + /* Header / glass chrome */ | |
| 140 | + --color-header: rgba(10, 10, 10, 0.97); | |
| 141 | + --color-header-border: rgba(255, 255, 255, 0.06); | |
| 142 | + | |
| 143 | + /* StatusBar */ | |
| 144 | + --color-status-bar: #0a0a0a; | |
| 145 | + | |
| 146 | + /* Markdown */ | |
| 147 | + --color-md-body: #e5e5e5; | |
| 148 | + --color-md-strong: #f5f5f5; | |
| 149 | + --color-md-link: #60a5fa; | |
| 150 | + --color-md-blockquote-border: rgba(255, 255, 255, 0.1); | |
| 151 | + --color-md-blockquote-bg: rgba(255, 255, 255, 0.03); | |
| 152 | + --color-md-code-bg: rgba(255, 255, 255, 0.06); | |
| 153 | + --color-md-code-text: #e5e5e5; | |
| 154 | + --color-md-user-code-bg: rgba(255, 255, 255, 0.1); | |
| 155 | + --color-md-user-code-text: #e5e5e5; | |
| 156 | + --color-md-user-fence-bg: rgba(255, 255, 255, 0.08); | |
| 157 | + --color-md-user-fence-text: #e5e5e5; | |
| 158 | + --color-md-hr: rgba(255, 255, 255, 0.08); | |
| 159 | + | |
| 160 | + /* Drawer / modal backdrop */ | |
| 161 | + --color-backdrop: rgba(0, 0, 0, 0.48); | |
| 162 | + --color-drawer: rgba(14, 14, 14, 0.99); | |
| 163 | + --color-drawer-shadow: rgba(0, 0, 0, 0.32); | |
| 164 | + | |
| 165 | + /* Misc */ | |
| 166 | + --color-dot-separator: rgba(255, 255, 255, 0.2); | |
| 167 | + --color-wordmark: #f5f5f5; | |
| 168 | + --color-chevron: rgba(255, 255, 255, 0.2); | |
| 169 | + } | |
| 170 | + } | |
| 171 | +} | |
| 172 | + | |
| 173 | +/* ─── Font family ───────────────────────────────────────────────────── */ | |
| 174 | +@theme { | |
| 175 | + --font-sans: "DMSans_400Regular"; | |
| 176 | + --font-medium: "DMSans_500Medium"; | |
| 177 | + --font-bold: "DMSans_700Bold"; | |
| 178 | +} | |
| 179 | + | |
| 180 | +/* ─── Custom utilities ──────────────────────────────────────────────── */ | |
| 7 | 181 | @utility font-t3-medium { |
| 8 | − font-weight: 500; | |
| 182 | + font-family: "DMSans_500Medium"; | |
| 9 | 183 | } |
| 10 | 184 | |
| 11 | 185 | @utility font-t3-bold { |
| 12 | − font-weight: 700; | |
| 186 | + font-family: "DMSans_700Bold"; | |
| 13 | 187 | } |
| 14 | 188 | |
| 15 | 189 | @utility font-t3-extrabold { |
| 190 | + font-family: "DMSans_700Bold"; | |
| 16 | 191 | font-weight: 800; |
| 17 | 192 | } |
| @@ -1,6 +1,6 @@ | ||
| 1 | 1 | const path = require("node:path"); |
| 2 | 2 | const { getDefaultConfig } = require("expo/metro-config"); |
| 3 | −const { withNativewind } = require("nativewind/metro"); | |
| 3 | +const { withUniwindConfig } = require("uniwind/metro"); | |
| 4 | 4 | |
| 5 | 5 | /** @type {import("expo/metro-config").MetroConfig} */ |
| 6 | 6 | const config = getDefaultConfig(__dirname); |
| @@ -8,7 +8,7 @@ const workspaceRoot = path.resolve(__dirname, "../.."); · 1 unmodified line | ||
| 8 | 8 | |
| 9 | 9 | config.watchFolders = [...new Set([...(config.watchFolders ?? []), workspaceRoot])]; |
| 10 | 10 | |
| 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 }, | |
| 14 | 14 | }); |
| @@ -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"; | |
| @@ -13,6 +13,7 @@ · 12 unmodified lines | ||
| 13 | 13 | }, |
| 14 | 14 | "dependencies": { |
| 15 | 15 | "@callstack/liquid-glass": "^0.7.1", |
| 16 | + "@effect/atom-react": "catalog:", | |
| 16 | 17 | "@expo-google-fonts/dm-sans": "^0.4.2", |
| 17 | 18 | "@gorhom/bottom-sheet": "^5", |
| 18 | 19 | "@legendapp/list": "^3.0.0-beta.44", |
| @@ -22,6 +23,7 @@ · 3 unmodified lines | ||
| 22 | 23 | "@t3tools/client-runtime": "workspace:*", |
| 23 | 24 | "@t3tools/contracts": "workspace:*", |
| 24 | 25 | "@t3tools/shared": "workspace:*", |
| 26 | + "clsx": "^2.1.1", | |
| 25 | 27 | "effect": "catalog:", |
| 26 | 28 | "expo": "~55.0.14", |
| 27 | 29 | "expo-camera": "~55.0.15", |
| @@ -41,12 +43,10 @@ · 13 unmodified lines | ||
| 41 | 43 | "expo-splash-screen": "~55.0.13", |
| 42 | 44 | "expo-symbols": "~55.0.5", |
| 43 | 45 | "expo-updates": "~55.0.16", |
| 44 | − "nativewind": "5.0.0-preview.3", | |
| 45 | 46 | "punycode": "^2.3.1", |
| 46 | 47 | "react": "19.2.0", |
| 47 | 48 | "react-dom": "19.2.0", |
| 48 | 49 | "react-native": "0.83.4", |
| 49 | − "react-native-css": "3.0.7", | |
| 50 | 50 | "react-native-gesture-handler": "~2.30.0", |
| 51 | 51 | "react-native-image-viewing": "^0.2.2", |
| 52 | 52 | "react-native-keyboard-controller": "1.20.7", |
| @@ -56,14 +56,13 @@ · 3 unmodified lines | ||
| 56 | 56 | "react-native-screens": "~4.23.0", |
| 57 | 57 | "react-native-svg": "15.15.3", |
| 58 | 58 | "react-native-worklets": "0.7.2", |
| 59 | + "tailwind-merge": "^3.5.0", | |
| 60 | + "uniwind": "^1.6.2", | |
| 59 | 61 | "zustand": "^5.0.12" |
| 60 | 62 | }, |
| 61 | 63 | "devDependencies": { |
| 62 | − "@tailwindcss/postcss": "4.1.12", | |
| 63 | 64 | "@types/react": "~19.2.10", |
| 64 | 65 | "babel-preset-expo": "~55.0.8", |
| 65 | − "lightningcss": "1.30.1", | |
| 66 | − "postcss": "^8.5.8", | |
| 67 | 66 | "tailwindcss": "4.1.12", |
| 68 | 67 | "typescript": "~5.9.2" |
| 69 | 68 | } |
| @@ -1,5 +0,0 @@ | ||
| 1 | −export default { | |
| 2 | − plugins: { | |
| 3 | − "@tailwindcss/postcss": {}, | |
| 4 | − }, | |
| 5 | −}; | |
| @@ -1,12 +1,12 @@ | ||
| 1 | 1 | 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"; | |
| 3 | 4 | |
| 4 | 5 | import { AppText as Text } from "../components/AppText"; |
| 5 | −import { makeAppPalette } from "../lib/theme"; | |
| 6 | 6 | |
| 7 | 7 | 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")); | |
| 10 | 10 | |
| 11 | 11 | return ( |
| 12 | 12 | <ScrollView |
| @@ -19,23 +19,23 @@ export default function NotFoundRoute() { · 6 unmodified lines | ||
| 19 | 19 | paddingHorizontal: 24, |
| 20 | 20 | paddingVertical: 32, |
| 21 | 21 | }} |
| 22 | − style={{ flex: 1, backgroundColor: palette.screenBackground }} | |
| 22 | + style={[{ flex: 1 }, screenBgStyle]} | |
| 23 | 23 | > |
| 24 | − <Text className="text-[28px] font-t3-bold" selectable style={{ color: palette.text }}> | |
| 24 | + <Text className="text-[28px] font-t3-bold text-foreground" selectable> | |
| 25 | 25 | Route not found |
| 26 | 26 | </Text> |
| 27 | 27 | <Link href="/" asChild> |
| 28 | 28 | <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 | + ]} | |
| 35 | 37 | > |
| 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> | |
| 39 | 39 | </Pressable> |
| 40 | 40 | </Link> |
| 41 | 41 | </ScrollView> |
| @@ -6,15 +6,15 @@ import { · 5 unmodified lines | ||
| 6 | 6 | DMSans_700Bold, |
| 7 | 7 | useFonts, |
| 8 | 8 | } from "@expo-google-fonts/dm-sans"; |
| 9 | −import { BottomSheetModalProvider } from "@gorhom/bottom-sheet"; | |
| 10 | 9 | import Stack from "expo-router/stack"; |
| 11 | 10 | import { StatusBar, useColorScheme } from "react-native"; |
| 12 | 11 | import { GestureHandlerRootView } from "react-native-gesture-handler"; |
| 13 | 12 | import { KeyboardProvider } from "react-native-keyboard-controller"; |
| 14 | 13 | import { SafeAreaProvider } from "react-native-safe-area-context"; |
| 14 | +import { useCSSVariable, useResolveClassNames } from "uniwind"; | |
| 15 | 15 | |
| 16 | 16 | import { LoadingScreen } from "../components/LoadingScreen"; |
| 17 | −import { makeAppPalette } from "../lib/theme"; | |
| 17 | +import { AppAtomRegistryProvider } from "../state/atomRegistry"; | |
| 18 | 18 | import { |
| 19 | 19 | useRemoteEnvironmentBootstrap, |
| 20 | 20 | useRemoteEnvironmentState, |
| @@ -22,11 +22,13 @@ import { · 1 unmodified line | ||
| 22 | 22 | |
| 23 | 23 | function AppNavigator() { |
| 24 | 24 | 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"); | |
| 27 | 29 | |
| 28 | 30 | const newTaskScreenOptions = { |
| 29 | − contentStyle: { backgroundColor: palette.sheetBackground }, | |
| 31 | + contentStyle: sheetStyle, | |
| 30 | 32 | gestureEnabled: true, |
| 31 | 33 | headerShown: false, |
| 32 | 34 | presentation: "formSheet" as const, |
| @@ -35,7 +37,7 @@ function AppNavigator() { · 2 unmodified lines | ||
| 35 | 37 | }; |
| 36 | 38 | |
| 37 | 39 | const connectionSheetScreenOptions = { |
| 38 | − contentStyle: { backgroundColor: palette.sheetBackground }, | |
| 40 | + contentStyle: sheetStyle, | |
| 39 | 41 | gestureEnabled: true, |
| 40 | 42 | headerShown: false, |
| 41 | 43 | presentation: "formSheet" as const, |
| @@ -50,18 +52,27 @@ function AppNavigator() { · 8 unmodified lines | ||
| 50 | 52 | return ( |
| 51 | 53 | <> |
| 52 | 54 | <StatusBar |
| 53 | − barStyle={palette.statusBarStyle} | |
| 54 | − backgroundColor={palette.statusBarBackground} | |
| 55 | + barStyle={colorScheme === "dark" ? "light-content" : "dark-content"} | |
| 56 | + backgroundColor={statusBarBg as string} | |
| 55 | 57 | translucent |
| 56 | 58 | /> |
| 57 | 59 | <Stack screenOptions={{ headerShown: false }}> |
| 60 | + <Stack.Screen | |
| 61 | + name="index" | |
| 62 | + options={{ | |
| 63 | + contentStyle: { backgroundColor: "transparent" }, | |
| 64 | + headerShown: true, | |
| 65 | + headerTransparent: true, | |
| 66 | + headerShadowVisible: false, | |
| 67 | + }} | |
| 68 | + /> | |
| 58 | 69 | <Stack.Screen name="connections" options={connectionSheetScreenOptions} /> |
| 59 | 70 | <Stack.Screen name="new" options={newTaskScreenOptions} /> |
| 60 | 71 | <Stack.Screen |
| 61 | 72 | name="threads/[environmentId]/[threadId]" |
| 62 | 73 | options={{ |
| 63 | 74 | animation: "slide_from_right", |
| 64 | − contentStyle: { backgroundColor: palette.screenBackground }, | |
| 75 | + contentStyle: { backgroundColor: "transparent" }, | |
| 65 | 76 | gestureEnabled: true, |
| 66 | 77 | headerShown: false, |
| 67 | 78 | }} |
| @@ -80,14 +91,18 @@ export default function RootLayout() { · 12 unmodified lines | ||
| 80 | 91 | useRemoteEnvironmentBootstrap(); |
| 81 | 92 | |
| 82 | 93 | return ( |
| 83 | − <GestureHandlerRootView style={{ flex: 1 }}> | |
| 84 | − <KeyboardProvider statusBarTranslucent> | |
| 85 | − <BottomSheetModalProvider> | |
| 94 | + <AppAtomRegistryProvider> | |
| 95 | + <GestureHandlerRootView style={{ flex: 1 }}> | |
| 96 | + <KeyboardProvider statusBarTranslucent> | |
| 86 | 97 | <SafeAreaProvider> |
| 87 | − {fontsLoaded ? <AppNavigator /> : <LoadingScreen message="Loading remote workspace…" />} | |
| 98 | + {fontsLoaded ? ( | |
| 99 | + <AppNavigator /> | |
| 100 | + ) : ( | |
| 101 | + <LoadingScreen message="Loading remote workspace…" /> | |
| 102 | + )} | |
| 88 | 103 | </SafeAreaProvider> |
| 89 | − </BottomSheetModalProvider> | |
| 90 | − </KeyboardProvider> | |
| 91 | − </GestureHandlerRootView> | |
| 104 | + </KeyboardProvider> | |
| 105 | + </GestureHandlerRootView> | |
| 106 | + </AppAtomRegistryProvider> | |
| 92 | 107 | ); |
| 93 | 108 | } |
| @@ -1,14 +1,26 @@ | ||
| 1 | 1 | import Stack from "expo-router/stack"; |
| 2 | +import { useResolveClassNames } from "uniwind"; | |
| 3 | + | |
| 4 | +import { useThemeColor } from "../../lib/useThemeColor"; | |
| 2 | 5 | |
| 3 | 6 | export const unstable_settings = { |
| 4 | 7 | anchor: "index", |
| 5 | 8 | }; |
| 6 | 9 | |
| 7 | 10 | 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 | + | |
| 8 | 15 | return ( |
| 9 | 16 | <Stack |
| 10 | 17 | screenOptions={{ |
| 11 | − contentStyle: { backgroundColor: "rgba(246,244,239,0.98)" }, | |
| 18 | + contentStyle, | |
| 19 | + headerStyle: { backgroundColor: connSheetBg }, | |
| 20 | + headerTintColor: headerTint, | |
| 21 | + headerTitleStyle: { fontFamily: "DMSans_700Bold" }, | |
| 22 | + headerShadowVisible: false, | |
| 23 | + headerBackButtonDisplayMode: "minimal", | |
| 12 | 24 | }} |
| 13 | 25 | > |
| 14 | 26 | <Stack.Screen name="index" options={{ animation: "none" }} /> |
| @@ -1,7 +1,6 @@ | ||
| 1 | 1 | import Stack from "expo-router/stack"; |
| 2 | −import { useColorScheme } from "react-native"; | |
| 2 | +import { useResolveClassNames } from "uniwind"; | |
| 3 | 3 | |
| 4 | −import { makeAppPalette } from "../../lib/theme"; | |
| 5 | 4 | import { NewTaskFlowProvider } from "../../features/threads/new-task-flow-provider"; |
| 6 | 5 | |
| 7 | 6 | export const unstable_settings = { |
| @@ -9,14 +8,13 @@ export const unstable_settings = { · 1 unmodified line | ||
| 9 | 8 | }; |
| 10 | 9 | |
| 11 | 10 | export default function NewTaskLayout() { |
| 12 | − const isDarkMode = useColorScheme() === "dark"; | |
| 13 | − const palette = makeAppPalette(isDarkMode); | |
| 11 | + const sheetStyle = useResolveClassNames("bg-sheet"); | |
| 14 | 12 | |
| 15 | 13 | return ( |
| 16 | 14 | <NewTaskFlowProvider> |
| 17 | 15 | <Stack |
| 18 | 16 | screenOptions={{ |
| 19 | − contentStyle: { backgroundColor: palette.sheetBackground }, | |
| 17 | + contentStyle: sheetStyle, | |
| 20 | 18 | }} |
| 21 | 19 | > |
| 22 | 20 | <Stack.Screen name="index" options={{ animation: "none" }} /> |
| @@ -1,19 +1,22 @@ | ||
| 1 | 1 | import { Link, Stack } from "expo-router"; |
| 2 | 2 | import { SymbolView } from "expo-symbols"; |
| 3 | 3 | import { useMemo } from "react"; |
| 4 | −import { Pressable, ScrollView, useColorScheme, View } from "react-native"; | |
| 4 | +import { Pressable, ScrollView, View } from "react-native"; | |
| 5 | 5 | import { useSafeAreaInsets } from "react-native-safe-area-context"; |
| 6 | +import { useThemeColor } from "../../lib/useThemeColor"; | |
| 6 | 7 | |
| 7 | 8 | import { AppText as Text } from "../../components/AppText"; |
| 9 | +import { ProjectFavicon } from "../../components/ProjectFavicon"; | |
| 8 | 10 | import { groupProjectsByRepository } from "../../lib/repositoryGroups"; |
| 9 | −import { makeAppPalette } from "../../lib/theme"; | |
| 10 | 11 | import { useRemoteCatalog } from "../../state/use-remote-catalog"; |
| 12 | +import { useRemoteEnvironmentState } from "../../state/use-remote-environment-registry"; | |
| 11 | 13 | |
| 12 | 14 | export default function NewTaskRoute() { |
| 13 | 15 | const { projects, threads } = useRemoteCatalog(); |
| 14 | − const isDarkMode = useColorScheme() === "dark"; | |
| 15 | − const palette = makeAppPalette(isDarkMode); | |
| 16 | + const { savedConnectionsById } = useRemoteEnvironmentState(); | |
| 16 | 17 | const insets = useSafeAreaInsets(); |
| 18 | + const chevronColor = useThemeColor("--color-chevron"); | |
| 19 | + const borderSubtleColor = useThemeColor("--color-border-subtle"); | |
| 17 | 20 | const repositoryGroups = useMemo( |
| 18 | 21 | () => groupProjectsByRepository({ projects, threads }), |
| 19 | 22 | [projects, threads], |
| @@ -32,6 +35,7 @@ export default function NewTaskRoute() { · 12 unmodified lines | ||
| 32 | 35 | id: project.id, |
| 33 | 36 | key: group.key, |
| 34 | 37 | title: project.title, |
| 38 | + workspaceRoot: project.workspaceRoot, | |
| 35 | 39 | }; |
| 36 | 40 | }) |
| 37 | 41 | .filter((entry) => entry !== null), |
| @@ -39,34 +43,38 @@ export default function NewTaskRoute() { · 1 unmodified line | ||
| 39 | 43 | ); |
| 40 | 44 | |
| 41 | 45 | 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 | + | |
| 44 | 61 | <ScrollView |
| 45 | − contentInsetAdjustmentBehavior="automatic" | |
| 46 | 62 | showsVerticalScrollIndicator={false} |
| 47 | 63 | style={{ flex: 1 }} |
| 48 | 64 | contentContainerStyle={{ |
| 49 | 65 | paddingHorizontal: 20, |
| 50 | − paddingTop: 16, | |
| 66 | + paddingTop: 8, | |
| 51 | 67 | paddingBottom: Math.max(insets.bottom, 18) + 18, |
| 52 | 68 | }} |
| 53 | 69 | > |
| 54 | 70 | {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... | |
| 62 | 74 | </Text> |
| 63 | 75 | </View> |
| 64 | 76 | ) : ( |
| 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"> | |
| 70 | 78 | {items.map((item, index) => { |
| 71 | 79 | const isFirst = index === 0; |
| 72 | 80 | const isLast = index === items.length - 1; |
| @@ -85,12 +93,12 @@ export default function NewTaskRoute() { · 12 unmodified lines | ||
| 85 | 93 | asChild |
| 86 | 94 | > |
| 87 | 95 | <Pressable |
| 96 | + className="bg-card" | |
| 88 | 97 | style={{ |
| 89 | 98 | paddingHorizontal: 16, |
| 90 | 99 | paddingVertical: 18, |
| 91 | 100 | borderTopWidth: isFirst ? 0 : 1, |
| 92 | − borderTopColor: palette.borderSubtle, | |
| 93 | − backgroundColor: palette.card, | |
| 101 | + borderTopColor: borderSubtleColor, | |
| 94 | 102 | borderTopLeftRadius: isFirst ? 24 : 0, |
| 95 | 103 | borderTopRightRadius: isFirst ? 24 : 0, |
| 96 | 104 | borderBottomLeftRadius: isLast ? 24 : 0, |
| @@ -98,15 +106,20 @@ export default function NewTaskRoute() { · 1 unmodified line | ||
| 98 | 106 | }} |
| 99 | 107 | > |
| 100 | 108 | <View className="flex-row items-center justify-between gap-3"> |
| 109 | + <ProjectFavicon | |
| 110 | + size={22} | |
| 111 | + projectTitle={item.title} | |
| 112 | + httpBaseUrl={savedConnectionsById[item.environmentId]?.httpBaseUrl ?? null} | |
| 113 | + workspaceRoot={item.workspaceRoot} | |
| 114 | + bearerToken={savedConnectionsById[item.environmentId]?.bearerToken ?? null} | |
| 115 | + /> | |
| 101 | 116 | <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> | |
| 105 | 118 | </View> |
| 106 | 119 | <SymbolView |
| 107 | 120 | name="chevron.right" |
| 108 | 121 | size={14} |
| 109 | − tintColor={palette.chevronColor} | |
| 122 | + tintColor={chevronColor} | |
| 110 | 123 | type="monochrome" |
| 111 | 124 | /> |
| 112 | 125 | </View> |
| @@ -1,5 +0,0 @@ | ||
| 1 | −import { ThreadRouteScreen } from "../../../features/threads/ThreadRouteScreen"; | |
| 2 | − | |
| 3 | −export default function ThreadRoute() { | |
| 4 | − return <ThreadRouteScreen />; | |
| 5 | −} | |
| @@ -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 | +} | |
| @@ -0,0 +1,5 @@ | ||
| 1 | +import { GitConfirmSheet } from "../../../../features/threads/git/GitConfirmSheet"; | |
| 2 | + | |
| 3 | +export default function GitConfirmRoute() { | |
| 4 | + return <GitConfirmSheet />; | |
| 5 | +} | |
| @@ -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 | +} | |
| @@ -0,0 +1,5 @@ | ||
| 1 | +import { GitBranchesSheet } from "../../../../../features/threads/git/GitBranchesSheet"; | |
| 2 | + | |
| 3 | +export default function GitBranchesRoute() { | |
| 4 | + return <GitBranchesSheet />; | |
| 5 | +} | |
| @@ -0,0 +1,5 @@ | ||
| 1 | +import { GitCommitSheet } from "../../../../../features/threads/git/GitCommitSheet"; | |
| 2 | + | |
| 3 | +export default function GitCommitRoute() { | |
| 4 | + return <GitCommitSheet />; | |
| 5 | +} | |
| @@ -0,0 +1,5 @@ | ||
| 1 | +import { GitOverviewSheet } from "../../../../../features/threads/git/GitOverviewSheet"; | |
| 2 | + | |
| 3 | +export default function GitRoute() { | |
| 4 | + return <GitOverviewSheet />; | |
| 5 | +} | |
| @@ -0,0 +1,5 @@ | ||
| 1 | +import { ThreadRouteScreen } from "../../../../features/threads/ThreadRouteScreen"; | |
| 2 | + | |
| 3 | +export default function ThreadRoute() { | |
| 4 | + return <ThreadRouteScreen />; | |
| 5 | +} | |
| @@ -1,318 +1,40 @@ | ||
| 1 | 1 | import { |
| 2 | 2 | Text as RNText, |
| 3 | − type StyleProp, | |
| 4 | 3 | TextInput as RNTextInput, |
| 5 | 4 | type TextInputProps as RNTextInputProps, |
| 6 | 5 | type TextProps as RNTextProps, |
| 7 | − type TextStyle, | |
| 8 | − useColorScheme, | |
| 9 | − type ViewStyle, | |
| 10 | 6 | } from "react-native"; |
| 7 | +import { useThemeColor } from "../lib/useThemeColor"; | |
| 11 | 8 | |
| 12 | −type ClassNameProp = { | |
| 13 | − readonly className?: string; | |
| 14 | −}; | |
| 9 | +import { cn } from "../lib/cn"; | |
| 15 | 10 | |
| 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 }; | |
| 35 | 12 | |
| 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} />; | |
| 298 | 19 | } |
| 299 | 20 | |
| 300 | −export type AppTextInputProps = RNTextInputProps & ClassNameProp; | |
| 21 | +export type AppTextInputProps = RNTextInputProps & { readonly className?: string }; | |
| 301 | 22 | |
| 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"); | |
| 310 | 29 | |
| 311 | 30 | return ( |
| 312 | 31 | <RNTextInput |
| 32 | + className={cn( | |
| 33 | + "min-h-[54px] rounded-2xl border border-input-border bg-input px-3.5 py-3 font-sans text-[15px] text-foreground", | |
| 34 | + className, | |
| 35 | + )} | |
| 36 | + placeholderTextColor={placeholderTextColor ?? placeholderColor} | |
| 313 | 37 | {...props} |
| 314 | − placeholderTextColor={placeholderTextColor ?? (isDarkMode ? "#737373" : "#a3a3a3")} | |
| 315 | − style={[resolvedStyle as StyleProp<TextStyle>, style]} | |
| 316 | 38 | /> |
| 317 | 39 | ); |
| 318 | 40 | } |
| @@ -1,13 +1,10 @@ | ||
| 1 | −import { Image, View, useColorScheme } from "react-native"; | |
| 1 | +import { Image, View } from "react-native"; | |
| 2 | 2 | |
| 3 | −import { makeAppPalette } from "../lib/theme"; | |
| 4 | 3 | import { AppText as Text } from "./AppText"; |
| 5 | 4 | |
| 6 | 5 | const BRAND_MARK_SOURCE = require("../../../../assets/dev/blueprint-ios-1024.png"); |
| 7 | 6 | |
| 8 | 7 | export function BrandMark(props: { readonly compact?: boolean; readonly stageLabel?: string }) { |
| 9 | − const isDarkMode = useColorScheme() === "dark"; | |
| 10 | − const palette = makeAppPalette(isDarkMode); | |
| 11 | 8 | const compact = props.compact ?? false; |
| 12 | 9 | const iconSize = compact ? 32 : 44; |
| 13 | 10 | const stageLabel = props.stageLabel ?? "Alpha"; |
| @@ -26,27 +23,22 @@ export function BrandMark(props: { readonly compact?: boolean; readonly stageLab · 12 unmodified lines | ||
| 26 | 23 | <View className="gap-1"> |
| 27 | 24 | <View className="flex-row items-center gap-2"> |
| 28 | 25 | <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 }} | |
| 31 | 28 | > |
| 32 | 29 | T3 Code |
| 33 | 30 | </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"> | |
| 40 | 32 | <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 }} | |
| 43 | 35 | > |
| 44 | 36 | {stageLabel} |
| 45 | 37 | </Text> |
| 46 | 38 | </View> |
| 47 | 39 | </View> |
| 48 | 40 | {!compact ? ( |
| 49 | − <Text className="text-[12px] font-medium" style={{ color: palette.textMuted }}> | |
| 41 | + <Text className="text-[12px] font-medium text-foreground-muted"> | |
| 50 | 42 | Mobile control surface for your live coding environments |
| 51 | 43 | </Text> |
| 52 | 44 | ) : null} |
| @@ -1,8 +1,8 @@ | ||
| 1 | 1 | 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"; | |
| 3 | 4 | |
| 4 | 5 | import type { DraftComposerImageAttachment } from "../lib/composerImages"; |
| 5 | −import { makeAppPalette } from "../lib/theme"; | |
| 6 | 6 | |
| 7 | 7 | export interface ComposerAttachmentStripProps { |
| 8 | 8 | /** Attachment images to display. */ |
| @@ -22,8 +22,7 @@ export interface ComposerAttachmentStripProps { · 13 unmodified lines | ||
| 22 | 22 | * buttons. Used by both the thread composer and the new-task draft screen. |
| 23 | 23 | */ |
| 24 | 24 | export function ComposerAttachmentStrip(props: ComposerAttachmentStripProps) { |
| 25 | − const isDarkMode = useColorScheme() === "dark"; | |
| 26 | − const palette = makeAppPalette(isDarkMode); | |
| 25 | + const subtleBg = useThemeColor("--color-subtle"); | |
| 27 | 26 | const size = props.imageSize ?? 72; |
| 28 | 27 | const radius = props.imageBorderRadius ?? 16; |
| 29 | 28 | |
| @@ -50,7 +49,7 @@ export function ComposerAttachmentStrip(props: ComposerAttachmentStripProps) { · 20 unmodified lines | ||
| 50 | 49 | width: size, |
| 51 | 50 | height: size, |
| 52 | 51 | borderRadius: radius, |
| 53 | − backgroundColor: palette.subtleBg, | |
| 52 | + backgroundColor: subtleBg, | |
| 54 | 53 | }} |
| 55 | 54 | resizeMode="cover" |
| 56 | 55 | /> |
| @@ -1,9 +1,9 @@ | ||
| 1 | 1 | import type { ComponentProps, ReactNode } from "react"; |
| 2 | −import { Pressable, useColorScheme, View } from "react-native"; | |
| 2 | +import { Pressable, View } from "react-native"; | |
| 3 | 3 | import { SymbolView } from "expo-symbols"; |
| 4 | +import { useThemeColor } from "../lib/useThemeColor"; | |
| 4 | 5 | |
| 5 | 6 | import { AppText as Text } from "./AppText"; |
| 6 | −import { makeAppPalette } from "../lib/theme"; | |
| 7 | 7 | |
| 8 | 8 | export function ControlPill(props: { |
| 9 | 9 | readonly icon?: ComponentProps<typeof SymbolView>["name"]; |
| @@ -13,31 +13,36 @@ export function ControlPill(props: { · 3 unmodified lines | ||
| 13 | 13 | readonly variant?: "circle" | "pill" | "primary" | "danger"; |
| 14 | 14 | readonly disabled?: boolean; |
| 15 | 15 | }) { |
| 16 | − const isDarkMode = useColorScheme() === "dark"; | |
| 17 | − const palette = makeAppPalette(isDarkMode); | |
| 18 | 16 | const variant = props.variant ?? "circle"; |
| 17 | + | |
| 18 | + const subtleBg = useThemeColor("--color-subtle"); | |
| 19 | + const subtleBgStrong = useThemeColor("--color-subtle-strong"); | |
| 20 | + const primaryBg = useThemeColor("--color-primary"); | |
| 21 | + const dangerBg = useThemeColor("--color-danger"); | |
| 22 | + const iconColor = useThemeColor("--color-icon"); | |
| 23 | + const iconSubtle = useThemeColor("--color-icon-subtle"); | |
| 24 | + const primaryFg = useThemeColor("--color-primary-foreground"); | |
| 25 | + const dangerFg = useThemeColor("--color-danger-foreground"); | |
| 26 | + const textColor = useThemeColor("--color-foreground"); | |
| 27 | + const textMuted = useThemeColor("--color-foreground-muted"); | |
| 28 | + | |
| 19 | 29 | const backgroundColor = |
| 20 | 30 | variant === "primary" |
| 21 | 31 | ? props.disabled |
| 22 | − ? palette.subtleBgStrong | |
| 23 | − : palette.primaryButton | |
| 32 | + ? subtleBgStrong | |
| 33 | + : primaryBg | |
| 24 | 34 | : variant === "danger" |
| 25 | − ? palette.dangerButton | |
| 26 | − : palette.subtleBg; | |
| 35 | + ? dangerBg | |
| 36 | + : subtleBg; | |
| 27 | 37 | const iconTintColor = |
| 28 | 38 | variant === "primary" |
| 29 | 39 | ? props.disabled |
| 30 | − ? palette.iconSubtle | |
| 31 | − : palette.primaryButtonText | |
| 40 | + ? iconSubtle | |
| 41 | + : primaryFg | |
| 32 | 42 | : 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; | |
| 41 | 46 | |
| 42 | 47 | const isCircle = |
| 43 | 48 | variant === "circle" || variant === "danger" || (variant === "primary" && !props.label); |
| @@ -61,7 +66,7 @@ export function ControlPill(props: { · 17 unmodified lines | ||
| 61 | 66 | <SymbolView name={props.icon} size={16} tintColor={iconTintColor} type="monochrome" /> |
| 62 | 67 | ) : null} |
| 63 | 68 | {props.label ? ( |
| 64 | − <Text className="text-center text-[12px] font-t3-bold" style={{ color: textColor }}> | |
| 69 | + <Text className="text-center text-[12px] font-t3-bold" style={{ color: labelColor }}> | |
| 65 | 70 | {props.label} |
| 66 | 71 | </Text> |
| 67 | 72 | ) : null} |
| @@ -1,13 +1,12 @@ | ||
| 1 | 1 | import { View } from "react-native"; |
| 2 | 2 | |
| 3 | 3 | import { AppText as Text } from "./AppText"; |
| 4 | + | |
| 4 | 5 | export function EmptyState(props: { readonly title: string; readonly detail: string }) { |
| 5 | 6 | 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"> | |
| 11 | 10 | {props.detail} |
| 12 | 11 | </Text> |
| 13 | 12 | </View> |
| @@ -1,28 +1,26 @@ | ||
| 1 | 1 | import { ActivityIndicator, StatusBar, View, useColorScheme } from "react-native"; |
| 2 | 2 | import { useSafeAreaInsets } from "react-native-safe-area-context"; |
| 3 | +import { useThemeColor } from "../lib/useThemeColor"; | |
| 3 | 4 | |
| 4 | −import { makeAppPalette } from "../lib/theme"; | |
| 5 | 5 | import { AppText as Text } from "./AppText"; |
| 6 | 6 | import { BrandMark } from "./BrandMark"; |
| 7 | 7 | |
| 8 | 8 | 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"); | |
| 11 | 11 | const insets = useSafeAreaInsets(); |
| 12 | 12 | |
| 13 | 13 | return ( |
| 14 | − <View style={{ flex: 1, backgroundColor: palette.screenBackground, paddingTop: insets.top }}> | |
| 14 | + <View className="flex-1 bg-screen" style={{ paddingTop: insets.top }}> | |
| 15 | 15 | <StatusBar |
| 16 | − barStyle={palette.statusBarStyle} | |
| 17 | − backgroundColor={palette.screenBackground} | |
| 16 | + barStyle={colorScheme === "dark" ? "light-content" : "dark-content"} | |
| 17 | + backgroundColor={screenBg as string} | |
| 18 | 18 | translucent |
| 19 | 19 | /> |
| 20 | 20 | <View className="flex-1 items-center justify-center gap-5 px-6"> |
| 21 | 21 | <BrandMark compact /> |
| 22 | 22 | <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> | |
| 26 | 24 | </View> |
| 27 | 25 | </View> |
| 28 | 26 | ); |
| @@ -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 | +} | |
| @@ -1,7 +1,7 @@ | ||
| 1 | 1 | import { View } from "react-native"; |
| 2 | 2 | |
| 3 | 3 | import { AppText as Text } from "./AppText"; |
| 4 | −import { cx } from "../lib/classNames"; | |
| 4 | +import { cn } from "../lib/cn"; | |
| 5 | 5 | |
| 6 | 6 | export interface StatusTone { |
| 7 | 7 | readonly label: string; |
| @@ -17,14 +17,14 @@ export function StatusPill( · 9 unmodified lines | ||
| 17 | 17 | const size = props.size ?? "default"; |
| 18 | 18 | return ( |
| 19 | 19 | <View |
| 20 | − className={cx( | |
| 20 | + className={cn( | |
| 21 | 21 | "rounded-full", |
| 22 | 22 | size === "compact" ? "px-2.5 py-1" : "px-3 py-1.5", |
| 23 | 23 | props.pillClassName, |
| 24 | 24 | )} |
| 25 | 25 | > |
| 26 | 26 | <Text |
| 27 | − className={cx( | |
| 27 | + className={cn( | |
| 28 | 28 | "font-t3-bold", |
| 29 | 29 | size === "compact" ? "text-[11px]" : "text-xs", |
| 30 | 30 | props.textClassName, |
| @@ -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 | −} | |
| @@ -1,15 +1,16 @@ | ||
| 1 | 1 | import { Link, Stack } from "expo-router"; |
| 2 | 2 | import { SymbolView } from "expo-symbols"; |
| 3 | 3 | import { useCallback, useState } from "react"; |
| 4 | −import { Pressable, ScrollView, View, useColorScheme } from "react-native"; | |
| 4 | +import { Pressable, ScrollView, View } from "react-native"; | |
| 5 | 5 | import Animated, { FadeIn, FadeOut, LinearTransition } from "react-native-reanimated"; |
| 6 | 6 | import { useSafeAreaInsets } from "react-native-safe-area-context"; |
| 7 | +import { useThemeColor } from "../../lib/useThemeColor"; | |
| 7 | 8 | |
| 8 | 9 | import { AppText as Text, AppTextInput as TextInput } from "../../components/AppText"; |
| 10 | +import { cn } from "../../lib/cn"; | |
| 9 | 11 | import { useRemoteConnections } from "../../state/use-remote-environment-registry"; |
| 10 | 12 | import type { ConnectedEnvironmentSummary } from "../../state/remote-runtime-types"; |
| 11 | 13 | import { ConnectionStatusDot } from "./ConnectionStatusDot"; |
| 12 | −import { makeConnectionSheetPalette as makePalette } from "./connection-sheet-shared"; | |
| 13 | 14 | |
| 14 | 15 | function EnvironmentRow(props: { |
| 15 | 16 | readonly environment: ConnectedEnvironmentSummary; |
| @@ -20,11 +21,15 @@ function EnvironmentRow(props: { · 4 unmodified lines | ||
| 20 | 21 | environmentId: string, |
| 21 | 22 | updates: { readonly label: string; readonly displayUrl: string }, |
| 22 | 23 | ) => void; |
| 23 | − readonly palette: ReturnType<typeof makePalette>; | |
| 24 | 24 | }) { |
| 25 | 25 | const [label, setLabel] = useState(props.environment.environmentLabel); |
| 26 | 26 | const [url, setUrl] = useState(props.environment.displayUrl); |
| 27 | 27 | |
| 28 | + const mutedColor = useThemeColor("--color-icon-subtle"); | |
| 29 | + const placeholderColor = useThemeColor("--color-placeholder"); | |
| 30 | + const primaryFg = useThemeColor("--color-primary-foreground"); | |
| 31 | + const dangerFg = useThemeColor("--color-danger-foreground"); | |
| 32 | + | |
| 28 | 33 | const handleSave = useCallback(() => { |
| 29 | 34 | props.onUpdate(props.environment.environmentId, { |
| 30 | 35 | label: label.trim(), |
| @@ -34,26 +39,18 @@ function EnvironmentRow(props: { · 3 unmodified lines | ||
| 34 | 39 | }, [label, url, props]); |
| 35 | 40 | |
| 36 | 41 | 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"> | |
| 41 | 43 | <Pressable className="flex-row items-center gap-3 px-4 py-3.5" onPress={props.onToggle}> |
| 42 | 44 | <ConnectionStatusDot state={props.environment.connectionState} pulse={false} size={8} /> |
| 43 | 45 | |
| 44 | 46 | <View className="flex-1 gap-0.5"> |
| 45 | 47 | <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" | |
| 48 | 49 | numberOfLines={1} |
| 49 | 50 | > |
| 50 | 51 | {props.environment.environmentLabel} |
| 51 | 52 | </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}> | |
| 57 | 54 | {props.environment.displayUrl} |
| 58 | 55 | </Text> |
| 59 | 56 | </View> |
| @@ -61,7 +58,7 @@ function EnvironmentRow(props: { · 1 unmodified line | ||
| 61 | 58 | <SymbolView |
| 62 | 59 | name="chevron.down" |
| 63 | 60 | size={12} |
| 64 | − tintColor={props.palette.muted} | |
| 61 | + tintColor={mutedColor} | |
| 65 | 62 | type="monochrome" |
| 66 | 63 | style={{ |
| 67 | 64 | transform: [{ rotate: props.expanded ? "180deg" : "0deg" }], |
| @@ -77,8 +74,8 @@ function EnvironmentRow(props: { · 9 unmodified lines | ||
| 77 | 74 | > |
| 78 | 75 | <View className="gap-1.5"> |
| 79 | 76 | <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 }} | |
| 82 | 79 | > |
| 83 | 80 | Label |
| 84 | 81 | </Text> |
| @@ -86,23 +83,17 @@ function EnvironmentRow(props: { · 1 unmodified line | ||
| 86 | 83 | autoCapitalize="words" |
| 87 | 84 | autoCorrect={false} |
| 88 | 85 | placeholder="My MacBook" |
| 89 | − placeholderTextColor={props.palette.placeholder} | |
| 86 | + placeholderTextColor={placeholderColor} | |
| 90 | 87 | value={label} |
| 91 | 88 | 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" | |
| 99 | 90 | /> |
| 100 | 91 | </View> |
| 101 | 92 | |
| 102 | 93 | <View className="gap-1.5"> |
| 103 | 94 | <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 }} | |
| 106 | 97 | > |
| 107 | 98 | URL |
| 108 | 99 | </Text> |
| @@ -111,59 +102,32 @@ function EnvironmentRow(props: { · 2 unmodified lines | ||
| 111 | 102 | autoCorrect={false} |
| 112 | 103 | keyboardType="url" |
| 113 | 104 | placeholder="192.168.1.100:8080" |
| 114 | − placeholderTextColor={props.palette.placeholder} | |
| 105 | + placeholderTextColor={placeholderColor} | |
| 115 | 106 | value={url} |
| 116 | 107 | 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" | |
| 124 | 109 | /> |
| 125 | 110 | </View> |
| 126 | 111 | |
| 127 | 112 | <View className="flex-row gap-2"> |
| 128 | 113 | <Pressable |
| 129 | − className="min-h-[42px] flex-1 flex-row items-center justify-center gap-1.5 rounded-[14px] px-3.5 py-2.5" | |
| 114 | + className="min-h-[42px] flex-1 flex-row items-center justify-center gap-1.5 rounded-[14px] bg-primary px-3.5 py-2.5" | |
| 130 | 115 | onPress={handleSave} |
| 131 | − style={{ | |
| 132 | − backgroundColor: props.palette.primaryButton, | |
| 133 | − }} | |
| 134 | 116 | > |
| 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" /> | |
| 141 | 118 | <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 }} | |
| 147 | 121 | > |
| 148 | 122 | Save |
| 149 | 123 | </Text> |
| 150 | 124 | </Pressable> |
| 151 | 125 | |
| 152 | 126 | <Pressable |
| 153 | − className="h-[42px] w-[42px] items-center justify-center rounded-[14px]" | |
| 127 | + className="h-[42px] w-[42px] items-center justify-center rounded-[14px] border border-danger-border bg-danger" | |
| 154 | 128 | onPress={() => props.onRemove(props.environment.environmentId)} |
| 155 | − style={{ | |
| 156 | − backgroundColor: props.palette.dangerButton, | |
| 157 | − borderWidth: 1, | |
| 158 | − borderColor: props.palette.dangerBorder, | |
| 159 | − }} | |
| 160 | 129 | > |
| 161 | − <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" /> | |
| 167 | 131 | </Pressable> |
| 168 | 132 | </View> |
| 169 | 133 | </Animated.View> |
| @@ -176,30 +140,29 @@ export function ConnectionsRouteScreen() { · 6 unmodified lines | ||
| 176 | 140 | const { connectedEnvironments, onRemoveEnvironmentPress, onUpdateEnvironment } = |
| 177 | 141 | useRemoteConnections(); |
| 178 | 142 | const insets = useSafeAreaInsets(); |
| 179 | − const isDarkMode = useColorScheme() === "dark"; | |
| 180 | − const palette = makePalette(isDarkMode); | |
| 181 | 143 | const hasEnvironments = connectedEnvironments.length > 0; |
| 182 | 144 | const [expandedId, setExpandedId] = useState<string | null>(null); |
| 183 | 145 | |
| 146 | + const primaryFg = useThemeColor("--color-primary-foreground"); | |
| 147 | + const accentColor = useThemeColor("--color-icon-muted"); | |
| 148 | + const mutedColor = useThemeColor("--color-icon-subtle"); | |
| 149 | + | |
| 184 | 150 | const handleToggle = useCallback((environmentId: string) => { |
| 185 | 151 | setExpandedId((prev) => (prev === environmentId ? null : environmentId)); |
| 186 | 152 | }, []); |
| 187 | 153 | |
| 188 | 154 | return ( |
| 189 | − <View collapsable={false} style={{ flex: 1, backgroundColor: palette.sheet }}> | |
| 155 | + <View collapsable={false} className="flex-1 bg-sheet"> | |
| 190 | 156 | <Stack.Screen |
| 191 | 157 | options={{ |
| 192 | 158 | title: "Backends", |
| 193 | 159 | headerRight: () => ( |
| 194 | 160 | <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"> | |
| 199 | 162 | <SymbolView |
| 200 | 163 | name="plus" |
| 201 | 164 | size={18} |
| 202 | − tintColor={palette.primaryButtonText} | |
| 165 | + tintColor={primaryFg} | |
| 203 | 166 | type="monochrome" |
| 204 | 167 | weight="semibold" |
| 205 | 168 | /> |
| @@ -219,19 +182,15 @@ export function ConnectionsRouteScreen() { · 13 unmodified lines | ||
| 219 | 182 | }} |
| 220 | 183 | > |
| 221 | 184 | {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"> | |
| 227 | 186 | {connectedEnvironments.map((environment, index) => ( |
| 228 | 187 | <View |
| 229 | 188 | key={environment.environmentId} |
| 230 | 189 | collapsable={false} |
| 231 | 190 | style={{ |
| 232 | 191 | borderTopWidth: index === 0 ? 0 : 1, |
| 233 | − borderTopColor: palette.border, | |
| 234 | 192 | }} |
| 193 | + className={cn(index !== 0 && "border-border")} | |
| 235 | 194 | > |
| 236 | 195 | <EnvironmentRow |
| 237 | 196 | environment={environment} |
| @@ -239,37 +198,23 @@ export function ConnectionsRouteScreen() { · 1 unmodified line | ||
| 239 | 198 | onToggle={() => handleToggle(environment.environmentId)} |
| 240 | 199 | onRemove={onRemoveEnvironmentPress} |
| 241 | 200 | onUpdate={onUpdateEnvironment} |
| 242 | − palette={palette} | |
| 243 | 201 | /> |
| 244 | 202 | </View> |
| 245 | 203 | ))} |
| 246 | 204 | </View> |
| 247 | 205 | ) : ( |
| 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"> | |
| 257 | 208 | <SymbolView |
| 258 | 209 | name="point.3.connected.trianglepath.dotted" |
| 259 | 210 | size={20} |
| 260 | − tintColor={palette.accent} | |
| 211 | + tintColor={accentColor} | |
| 261 | 212 | type="monochrome" |
| 262 | 213 | /> |
| 263 | 214 | </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"> | |
| 268 | 216 | 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. | |
| 273 | 218 | </Text> |
| 274 | 219 | </View> |
| 275 | 220 | )} |
| @@ -2,18 +2,18 @@ import { CameraView, useCameraPermissions } from "expo-camera"; · 1 unmodified line | ||
| 2 | 2 | import { Stack, useLocalSearchParams, useRouter } from "expo-router"; |
| 3 | 3 | import { SymbolView } from "expo-symbols"; |
| 4 | 4 | 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"; | |
| 6 | 6 | import { useSafeAreaInsets } from "react-native-safe-area-context"; |
| 7 | +import { useThemeColor } from "../../lib/useThemeColor"; | |
| 7 | 8 | |
| 8 | 9 | import { AppText as Text, AppTextInput as TextInput } from "../../components/AppText"; |
| 9 | 10 | import { ErrorBanner } from "../../components/ErrorBanner"; |
| 10 | 11 | import { dismissRoute } from "../../lib/routes"; |
| 11 | −import { extractPairingUrlFromQrPayload } from "../../lib/pairingQr"; | |
| 12 | +import { extractPairingUrlFromQrPayload } from "./pairingQr"; | |
| 12 | 13 | import { useRemoteConnections } from "../../state/use-remote-environment-registry"; |
| 13 | 14 | import { |
| 14 | 15 | buildPairingUrl, |
| 15 | 16 | ConnectionSheetButton as SheetButton, |
| 16 | − makeConnectionSheetPalette as makePalette, | |
| 17 | 17 | parsePairingUrl, |
| 18 | 18 | } from "./connection-sheet-shared"; |
| 19 | 19 | |
| @@ -28,8 +28,6 @@ export function NewConnectionRouteScreen() { · 8 unmodified lines | ||
| 28 | 28 | const router = useRouter(); |
| 29 | 29 | const params = useLocalSearchParams<{ mode?: string }>(); |
| 30 | 30 | const insets = useSafeAreaInsets(); |
| 31 | − const isDarkMode = useColorScheme() === "dark"; | |
| 32 | − const palette = makePalette(isDarkMode); | |
| 33 | 31 | const [hostInput, setHostInput] = useState(""); |
| 34 | 32 | const [codeInput, setCodeInput] = useState(""); |
| 35 | 33 | const [isSubmitting, setIsSubmitting] = useState(false); |
| @@ -37,6 +35,9 @@ export function NewConnectionRouteScreen() { · 1 unmodified line | ||
| 37 | 35 | const [cameraPermission, requestCameraPermission] = useCameraPermissions(); |
| 38 | 36 | const [scannerLocked, setScannerLocked] = useState(false); |
| 39 | 37 | |
| 38 | + const textColor = useThemeColor("--color-icon"); | |
| 39 | + const placeholderColor = useThemeColor("--color-placeholder"); | |
| 40 | + | |
| 40 | 41 | const connectDisabled = |
| 41 | 42 | isSubmitting || connectionState === "connecting" || hostInput.trim().length === 0; |
| 42 | 43 | |
| @@ -125,13 +126,13 @@ export function NewConnectionRouteScreen() { · 82 unmodified lines | ||
| 125 | 126 | }, [codeInput, hostInput, onChangeConnectionPairingUrl, onConnectPress, router]); |
| 126 | 127 | |
| 127 | 128 | return ( |
| 128 | − <View collapsable={false} style={{ flex: 1, backgroundColor: palette.sheet }}> | |
| 129 | + <View collapsable={false} className="flex-1 bg-sheet"> | |
| 129 | 130 | <Stack.Screen |
| 130 | 131 | options={{ |
| 131 | 132 | title: showScanner ? "Scan QR Code" : "Add Backend", |
| 132 | 133 | headerRight: () => ( |
| 133 | 134 | <Pressable |
| 134 | − className="h-10 w-10 items-center justify-center rounded-full" | |
| 135 | + className="h-10 w-10 items-center justify-center rounded-full border border-border bg-secondary" | |
| 135 | 136 | onPress={() => { |
| 136 | 137 | if (showScanner) { |
| 137 | 138 | closeScanner(); |
| @@ -139,16 +140,11 @@ export function NewConnectionRouteScreen() { · 1 unmodified line | ||
| 139 | 140 | void openScanner(); |
| 140 | 141 | } |
| 141 | 142 | }} |
| 142 | − style={{ | |
| 143 | − backgroundColor: palette.secondaryButton, | |
| 144 | − borderWidth: 1, | |
| 145 | − borderColor: palette.border, | |
| 146 | − }} | |
| 147 | 143 | > |
| 148 | 144 | <SymbolView |
| 149 | 145 | name={showScanner ? "xmark" : "qrcode.viewfinder"} |
| 150 | 146 | size={showScanner ? 14 : 18} |
| 151 | − tintColor={palette.text} | |
| 147 | + tintColor={textColor} | |
| 152 | 148 | type="monochrome" |
| 153 | 149 | weight="semibold" |
| 154 | 150 | /> |
| @@ -182,20 +178,16 @@ export function NewConnectionRouteScreen() { · 27 unmodified lines | ||
| 182 | 178 | </View> |
| 183 | 179 | ) : ( |
| 184 | 180 | <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" }} | |
| 187 | 183 | > |
| 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"> | |
| 192 | 185 | Camera permission is required to scan a QR code. |
| 193 | 186 | </Text> |
| 194 | 187 | <SheetButton |
| 195 | 188 | compact |
| 196 | 189 | icon="camera" |
| 197 | 190 | label="Allow camera" |
| 198 | − palette={palette} | |
| 199 | 191 | tone="secondary" |
| 200 | 192 | onPress={() => { |
| 201 | 193 | void openScanner(); |
| @@ -204,15 +196,11 @@ export function NewConnectionRouteScreen() { · 2 unmodified lines | ||
| 204 | 196 | </View> |
| 205 | 197 | ) |
| 206 | 198 | ) : ( |
| 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"> | |
| 212 | 200 | <View collapsable={false} className="gap-1.5"> |
| 213 | 201 | <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 }} | |
| 216 | 204 | > |
| 217 | 205 | Host |
| 218 | 206 | </Text> |
| @@ -221,23 +209,17 @@ export function NewConnectionRouteScreen() { · 2 unmodified lines | ||
| 221 | 209 | autoCorrect={false} |
| 222 | 210 | keyboardType="url" |
| 223 | 211 | placeholder="192.168.1.100:8080" |
| 224 | − placeholderTextColor={palette.placeholder} | |
| 212 | + placeholderTextColor={placeholderColor} | |
| 225 | 213 | value={hostInput} |
| 226 | 214 | 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" | |
| 234 | 216 | /> |
| 235 | 217 | </View> |
| 236 | 218 | |
| 237 | 219 | <View collapsable={false} className="gap-1.5"> |
| 238 | 220 | <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 }} | |
| 241 | 223 | > |
| 242 | 224 | Pairing code |
| 243 | 225 | </Text> |
| @@ -245,16 +227,10 @@ export function NewConnectionRouteScreen() { · 1 unmodified line | ||
| 245 | 227 | autoCapitalize="none" |
| 246 | 228 | autoCorrect={false} |
| 247 | 229 | placeholder="abc-123-xyz" |
| 248 | − placeholderTextColor={palette.placeholder} | |
| 230 | + placeholderTextColor={placeholderColor} | |
| 249 | 231 | value={codeInput} |
| 250 | 232 | 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" | |
| 258 | 234 | /> |
| 259 | 235 | </View> |
| 260 | 236 | |
| @@ -263,10 +239,9 @@ export function NewConnectionRouteScreen() { · 2 unmodified lines | ||
| 263 | 239 | <SheetButton |
| 264 | 240 | icon="plus" |
| 265 | 241 | label={ |
| 266 | − isSubmitting || connectionState === "connecting" ? "Pairing…" : "Add backend" | |
| 242 | + isSubmitting || connectionState === "connecting" ? "Pairing..." : "Add backend" | |
| 267 | 243 | } |
| 268 | 244 | disabled={connectDisabled} |
| 269 | − palette={palette} | |
| 270 | 245 | tone="primary" |
| 271 | 246 | onPress={() => { |
| 272 | 247 | void handleSubmit(); |
| @@ -1,7 +1,9 @@ | ||
| 1 | 1 | import { SymbolView } from "expo-symbols"; |
| 2 | 2 | import { Platform, Pressable } from "react-native"; |
| 3 | +import { useThemeColor } from "../../lib/useThemeColor"; | |
| 3 | 4 | |
| 4 | 5 | import { AppText as Text } from "../../components/AppText"; |
| 6 | +import { cn } from "../../lib/cn"; | |
| 5 | 7 | |
| 6 | 8 | export function parsePairingUrl(url: string): { host: string; code: string } { |
| 7 | 9 | const trimmed = url.trim(); |
| @@ -38,73 +40,27 @@ export function buildPairingUrl(host: string, code: string): string { · 30 unmodified lines | ||
| 38 | 40 | } |
| 39 | 41 | } |
| 40 | 42 | |
| 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 | +}); | |
| 74 | 52 | |
| 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 | +}); | |
| 106 | 62 | |
| 107 | −export type ConnectionSheetPalette = ReturnType<typeof makeConnectionSheetPalette>; | |
| 63 | +export { CARD_SHADOW, CARD_SHADOW_DARK }; | |
| 108 | 64 | |
| 109 | 65 | export function ConnectionSheetButton(props: { |
| 110 | 66 | readonly icon: React.ComponentProps<typeof SymbolView>["name"]; |
| @@ -112,27 +68,36 @@ export function ConnectionSheetButton(props: { · 1 unmodified line | ||
| 112 | 68 | readonly disabled?: boolean; |
| 113 | 69 | readonly tone?: "primary" | "secondary" | "danger"; |
| 114 | 70 | readonly compact?: boolean; |
| 115 | − readonly palette: ConnectionSheetPalette; | |
| 116 | 71 | readonly onPress: () => void; |
| 117 | 72 | }) { |
| 118 | 73 | const tone = props.tone ?? "secondary"; |
| 74 | + | |
| 75 | + const primaryBg = useThemeColor("--color-primary"); | |
| 76 | + const primaryFg = useThemeColor("--color-primary-foreground"); | |
| 77 | + const dangerBg = useThemeColor("--color-danger"); | |
| 78 | + const dangerBorderColor = useThemeColor("--color-danger-border"); | |
| 79 | + const dangerFg = useThemeColor("--color-danger-foreground"); | |
| 80 | + const secondaryBg = useThemeColor("--color-secondary"); | |
| 81 | + const secondaryFg = useThemeColor("--color-secondary-foreground"); | |
| 82 | + const borderColor = useThemeColor("--color-border"); | |
| 83 | + | |
| 119 | 84 | const colors = |
| 120 | 85 | tone === "primary" |
| 121 | 86 | ? { |
| 122 | − backgroundColor: props.palette.primaryButton, | |
| 87 | + backgroundColor: primaryBg, | |
| 123 | 88 | borderColor: "transparent", |
| 124 | − textColor: props.palette.primaryButtonText, | |
| 89 | + textColor: primaryFg, | |
| 125 | 90 | } |
| 126 | 91 | : tone === "danger" |
| 127 | 92 | ? { |
| 128 | − backgroundColor: props.palette.dangerButton, | |
| 129 | − borderColor: props.palette.dangerBorder, | |
| 130 | − textColor: props.palette.dangerText, | |
| 93 | + backgroundColor: dangerBg, | |
| 94 | + borderColor: dangerBorderColor, | |
| 95 | + textColor: dangerFg, | |
| 131 | 96 | } |
| 132 | 97 | : { |
| 133 | − backgroundColor: props.palette.secondaryButton, | |
| 134 | − borderColor: props.palette.border, | |
| 135 | − textColor: props.palette.secondaryButtonText, | |
| 98 | + backgroundColor: secondaryBg, | |
| 99 | + borderColor: borderColor, | |
| 100 | + textColor: secondaryFg, | |
| 136 | 101 | }; |
| 137 | 102 | |
| 138 | 103 | const primaryShadow = |
| @@ -150,11 +115,11 @@ export function ConnectionSheetButton(props: { · 11 unmodified lines | ||
| 150 | 115 | |
| 151 | 116 | return ( |
| 152 | 117 | <Pressable |
| 153 | − className={ | |
| 118 | + className={cn( | |
| 154 | 119 | props.compact |
| 155 | 120 | ? "min-h-[42px] flex-row items-center justify-center gap-1.5 rounded-[14px] px-3.5 py-2.5" |
| 156 | − : "min-h-[48px] flex-row items-center justify-center gap-2 rounded-[16px] px-4 py-3" | |
| 157 | − } | |
| 121 | + : "min-h-[48px] flex-row items-center justify-center gap-2 rounded-[16px] px-4 py-3", | |
| 122 | + )} | |
| 158 | 123 | disabled={props.disabled} |
| 159 | 124 | onPress={props.onPress} |
| 160 | 125 | style={[ |
| @@ -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"; | |
| 3 | 5 | |
| 4 | 6 | import { buildThreadRoutePath } from "../../lib/routes"; |
| 7 | +import { ConnectionStatusDot } from "../connection/ConnectionStatusDot"; | |
| 5 | 8 | import { useRemoteCatalog } from "../../state/use-remote-catalog"; |
| 9 | +import { useRemoteEnvironmentState } from "../../state/use-remote-environment-registry"; | |
| 6 | 10 | import { useThreadSelection } from "../../state/use-thread-selection"; |
| 7 | 11 | import { HomeScreen } from "./HomeScreen"; |
| 8 | 12 | |
| 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 | + | |
| 9 | 25 | export function HomeRouteScreen() { |
| 10 | 26 | const { connectionState, hasRemoteActivity, projects, threads } = useRemoteCatalog(); |
| 27 | + const { savedConnectionsById } = useRemoteEnvironmentState(); | |
| 11 | 28 | const { onBackFromThread, onSelectThread } = useThreadSelection(); |
| 12 | 29 | const router = useRouter(); |
| 30 | + const [searchQuery, setSearchQuery] = useState(""); | |
| 31 | + | |
| 32 | + const isDark = useColorScheme() === "dark"; | |
| 33 | + const iconColor = String(useThemeColor("--color-icon")); | |
| 34 | + const secondaryFg = isDark ? "#a3a3a3" : "#525252"; | |
| 13 | 35 | |
| 14 | 36 | useFocusEffect( |
| 15 | 37 | useCallback(() => { |
| @@ -18,19 +40,108 @@ export function HomeRouteScreen() { · 2 unmodified lines | ||
| 18 | 40 | ); |
| 19 | 41 | |
| 20 | 42 | 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 | + </> | |
| 35 | 146 | ); |
| 36 | 147 | } |
| @@ -1,215 +1,349 @@ | ||
| 1 | −import * as Haptics from "expo-haptics"; | |
| 1 | +import type { GitStatusState } from "@t3tools/client-runtime"; | |
| 2 | 2 | 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"; | |
| 6 | 6 | |
| 7 | 7 | import { AppText as Text } from "../../components/AppText"; |
| 8 | 8 | 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"; | |
| 11 | 11 | import type { ScopedMobileProject, ScopedMobileThread } from "../../lib/scopedEntities"; |
| 12 | 12 | import { scopedProjectKey } from "../../lib/scopedEntities"; |
| 13 | 13 | 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"; | |
| 17 | 16 | |
| 18 | −export function HomeScreen(props: { | |
| 17 | +/* ─── Types ──────────────────────────────────────────────────────────── */ | |
| 18 | + | |
| 19 | +interface HomeScreenProps { | |
| 19 | 20 | readonly projects: ReadonlyArray<ScopedMobileProject>; |
| 20 | 21 | 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; | |
| 25 | 24 | 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 | +} | |
| 55 | 26 | |
| 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; | |
| 58 | 63 | |
| 59 | 64 | 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} | |
| 69 | 77 | > |
| 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`} | |
| 73 | 88 | </Text> |
| 89 | + </Pressable> | |
| 90 | + ) : null} | |
| 91 | + </View> | |
| 92 | + ); | |
| 93 | +} | |
| 74 | 94 | |
| 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 ──────────────────────────────────────────────── */ | |
| 89 | 96 | |
| 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); | |
| 162 | 133 | |
| 163 | − {props.showFloatingNewTaskButton ? ( | |
| 134 | + return ( | |
| 135 | + <Pressable onPress={props.onPress} style={({ pressed }) => ({ opacity: pressed ? 0.7 : 1 })}> | |
| 136 | + <View | |
| 137 | + style={{ | |
| 138 | + flexDirection: "row", | |
| 139 | + paddingLeft: 16, | |
| 140 | + paddingRight: 16, | |
| 141 | + paddingVertical: 10, | |
| 142 | + gap: 12, | |
| 143 | + borderBottomWidth: props.isLast ? 0 : 1, | |
| 144 | + borderBottomColor: separatorColor, | |
| 145 | + }} | |
| 146 | + > | |
| 147 | + {/* Git status indicator */} | |
| 164 | 148 | <View |
| 165 | − pointerEvents="box-none" | |
| 166 | 149 | 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, | |
| 170 | 157 | }} |
| 171 | 158 | > |
| 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} | |
| 196 | 169 | > |
| 170 | + {props.thread.title} | |
| 171 | + </Text> | |
| 172 | + <View className="flex-row items-center gap-2"> | |
| 173 | + <View | |
| 174 | + className={tone.pillClassName} | |
| 175 | + style={{ borderRadius: 99, paddingHorizontal: 6, paddingVertical: 2 }} | |
| 176 | + > | |
| 177 | + <Text className={`text-[10px] font-t3-bold ${tone.textClassName}`}> | |
| 178 | + {tone.label} | |
| 179 | + </Text> | |
| 180 | + </View> | |
| 181 | + <Text | |
| 182 | + className="text-[12px] text-foreground-tertiary" | |
| 183 | + style={{ fontVariant: ["tabular-nums"] }} | |
| 184 | + > | |
| 185 | + {timestamp} | |
| 186 | + </Text> | |
| 187 | + </View> | |
| 188 | + </View> | |
| 189 | + | |
| 190 | + {/* Message preview */} | |
| 191 | + <Text className="text-[13px] leading-[18px] text-foreground-muted" numberOfLines={2}> | |
| 192 | + {preview} | |
| 193 | + </Text> | |
| 194 | + | |
| 195 | + {/* Branch + git info */} | |
| 196 | + {branch ? ( | |
| 197 | + <View className="flex-row items-center gap-1.5" style={{ marginTop: 1 }}> | |
| 197 | 198 | <SymbolView |
| 198 | − name="square.and.pencil" | |
| 199 | − size={20} | |
| 200 | − tintColor={palette.primaryButtonText} | |
| 199 | + name="arrow.triangle.branch" | |
| 200 | + size={10} | |
| 201 | + tintColor="#737373" | |
| 201 | 202 | type="monochrome" |
| 202 | 203 | /> |
| 203 | 204 | <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" }} | |
| 206 | 208 | > |
| 207 | − New task | |
| 209 | + {branch} | |
| 208 | 210 | </Text> |
| 211 | + {gitParts.length > 0 ? ( | |
| 212 | + <Text className="text-[11px] text-foreground-tertiary"> | |
| 213 | + {" · " + gitParts.join(" · ")} | |
| 214 | + </Text> | |
| 215 | + ) : null} | |
| 209 | 216 | </View> |
| 210 | − </Pressable> | |
| 217 | + ) : null} | |
| 211 | 218 | </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> | |
| 214 | 348 | ); |
| 215 | 349 | } |
| @@ -3,9 +3,10 @@ import { useRouter } from "expo-router"; · 2 unmodified lines | ||
| 3 | 3 | import { SymbolView } from "expo-symbols"; |
| 4 | 4 | import { TextInputWrapper } from "expo-paste-input"; |
| 5 | 5 | import { useCallback, useEffect, useMemo } from "react"; |
| 6 | −import { Pressable, useColorScheme, View } from "react-native"; | |
| 6 | +import { Pressable, View, useColorScheme } from "react-native"; | |
| 7 | 7 | import Animated, { useAnimatedKeyboard, useAnimatedStyle } from "react-native-reanimated"; |
| 8 | 8 | import { useSafeAreaInsets } from "react-native-safe-area-context"; |
| 9 | +import { useThemeColor } from "../../lib/useThemeColor"; | |
| 9 | 10 | |
| 10 | 11 | import type { ModelSelection } from "@t3tools/contracts"; |
| 11 | 12 | import { CLAUDE_CODE_EFFORT_OPTIONS } from "@t3tools/contracts"; |
| @@ -17,7 +18,6 @@ import { ProviderIcon } from "../../components/ProviderIcon"; · 5 unmodified lines | ||
| 17 | 18 | |
| 18 | 19 | import { convertPastedImagesToAttachments, pickComposerImages } from "../../lib/composerImages"; |
| 19 | 20 | import { buildThreadRoutePath } from "../../lib/routes"; |
| 20 | −import { makeAppPalette } from "../../lib/theme"; | |
| 21 | 21 | import { useProjectActions } from "../../state/use-project-actions"; |
| 22 | 22 | import { useRemoteCatalog } from "../../state/use-remote-catalog"; |
| 23 | 23 | import { useNativePaste } from "../../hooks/useNativePaste"; |
| @@ -33,9 +33,8 @@ export function NewTaskDraftScreen(props: { · 9 unmodified lines | ||
| 33 | 33 | const { onCreateThreadWithOptions } = useProjectActions(); |
| 34 | 34 | const flow = useNewTaskFlow(); |
| 35 | 35 | const router = useRouter(); |
| 36 | − const isDarkMode = useColorScheme() === "dark"; | |
| 37 | − const palette = makeAppPalette(isDarkMode); | |
| 38 | 36 | const insets = useSafeAreaInsets(); |
| 37 | + const isDarkMode = useColorScheme() === "dark"; | |
| 39 | 38 | const keyboard = useAnimatedKeyboard(); |
| 40 | 39 | const containerAnimatedStyle = useAnimatedStyle(() => ({ |
| 41 | 40 | paddingBottom: keyboard.height.value, |
| @@ -45,6 +44,9 @@ export function NewTaskDraftScreen(props: { · 3 unmodified lines | ||
| 45 | 44 | })); |
| 46 | 45 | const { logicalProjects, selectedProject, setProject } = flow; |
| 47 | 46 | |
| 47 | + const iconColor = useThemeColor("--color-icon"); | |
| 48 | + const borderColor = useThemeColor("--color-border"); | |
| 49 | + | |
| 48 | 50 | useEffect(() => { |
| 49 | 51 | if (props.initialProjectRef?.environmentId && props.initialProjectRef?.projectId) { |
| 50 | 52 | const directProject = |
| @@ -384,54 +386,48 @@ export function NewTaskDraftScreen(props: { · 333 unmodified lines | ||
| 384 | 386 | |
| 385 | 387 | if (!selectedProject) { |
| 386 | 388 | return ( |
| 387 | − <View style={{ flex: 1, backgroundColor: palette.sheetBackground }}> | |
| 389 | + <View className="flex-1 bg-sheet"> | |
| 388 | 390 | <View style={{ minHeight: 16, paddingTop: 8 }} /> |
| 389 | 391 | <View className="items-center gap-1 px-5 pb-3 pt-4"> |
| 390 | 392 | <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 }} | |
| 393 | 395 | > |
| 394 | 396 | New task |
| 395 | 397 | </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> | |
| 399 | 399 | </View> |
| 400 | 400 | </View> |
| 401 | 401 | ); |
| 402 | 402 | } |
| 403 | 403 | |
| 404 | 404 | return ( |
| 405 | − <Animated.View | |
| 406 | − style={[{ flex: 1, backgroundColor: palette.sheetBackground }, containerAnimatedStyle]} | |
| 407 | − > | |
| 405 | + <Animated.View className="flex-1 bg-sheet" style={containerAnimatedStyle}> | |
| 408 | 406 | <View style={{ minHeight: 16, paddingTop: 8 }} /> |
| 409 | 407 | |
| 410 | 408 | <View className="items-center gap-1 px-5 pb-3 pt-4"> |
| 411 | 409 | {flow.logicalProjects.length > 1 ? ( |
| 412 | 410 | <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 }} | |
| 415 | 413 | onPress={() => router.back()} |
| 416 | 414 | > |
| 417 | 415 | <SymbolView |
| 418 | 416 | name="chevron.left" |
| 419 | 417 | size={16} |
| 420 | − tintColor={palette.icon} | |
| 418 | + tintColor={iconColor} | |
| 421 | 419 | type="monochrome" |
| 422 | 420 | weight="medium" |
| 423 | 421 | /> |
| 424 | 422 | </Pressable> |
| 425 | 423 | ) : null} |
| 426 | 424 | <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 }} | |
| 429 | 427 | > |
| 430 | 428 | New task |
| 431 | 429 | </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> | |
| 435 | 431 | </View> |
| 436 | 432 | |
| 437 | 433 | <View style={{ flex: 1, paddingHorizontal: 20, paddingTop: 8 }}> |
| @@ -440,17 +436,9 @@ export function NewTaskDraftScreen(props: { · 2 unmodified lines | ||
| 440 | 436 | multiline |
| 441 | 437 | value={flow.prompt} |
| 442 | 438 | onChangeText={flow.setPrompt} |
| 443 | − placeholderTextColor={palette.placeholder} | |
| 444 | 439 | placeholder={`Describe a coding task in ${selectedProject.title}`} |
| 445 | 440 | 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]" | |
| 454 | 442 | /> |
| 455 | 443 | </TextInputWrapper> |
| 456 | 444 | </View> |
| @@ -459,7 +447,7 @@ export function NewTaskDraftScreen(props: { · 2 unmodified lines | ||
| 459 | 447 | style={[ |
| 460 | 448 | { |
| 461 | 449 | borderTopWidth: 1, |
| 462 | − borderTopColor: palette.border, | |
| 450 | + borderTopColor: borderColor, | |
| 463 | 451 | }, |
| 464 | 452 | controlsBottomPadding, |
| 465 | 453 | ]} |
| @@ -479,6 +467,7 @@ export function NewTaskDraftScreen(props: { · 13 unmodified lines | ||
| 479 | 467 | <MenuView |
| 480 | 468 | actions={modelMenuActions} |
| 481 | 469 | onPressAction={({ nativeEvent }) => handleModelMenuAction(nativeEvent.event)} |
| 470 | + themeVariant={isDarkMode ? "dark" : "light"} | |
| 482 | 471 | > |
| 483 | 472 | <ControlPill |
| 484 | 473 | iconNode={<ProviderIcon provider={flow.selectedModel?.provider} size={16} />} |
| @@ -487,18 +476,21 @@ export function NewTaskDraftScreen(props: { · 2 unmodified lines | ||
| 487 | 476 | <MenuView |
| 488 | 477 | actions={optionsMenuActions} |
| 489 | 478 | onPressAction={({ nativeEvent }) => handleOptionsMenuAction(nativeEvent.event)} |
| 479 | + themeVariant={isDarkMode ? "dark" : "light"} | |
| 490 | 480 | > |
| 491 | 481 | <ControlPill icon="slider.horizontal.3" /> |
| 492 | 482 | </MenuView> |
| 493 | 483 | <MenuView |
| 494 | 484 | actions={environmentMenuActions} |
| 495 | 485 | onPressAction={({ nativeEvent }) => handleEnvironmentMenuAction(nativeEvent.event)} |
| 486 | + themeVariant={isDarkMode ? "dark" : "light"} | |
| 496 | 487 | > |
| 497 | 488 | <ControlPill icon="desktopcomputer" /> |
| 498 | 489 | </MenuView> |
| 499 | 490 | <MenuView |
| 500 | 491 | actions={workspaceMenuActions} |
| 501 | 492 | onPressAction={({ nativeEvent }) => handleWorkspaceMenuAction(nativeEvent.event)} |
| 493 | + themeVariant={isDarkMode ? "dark" : "light"} | |
| 502 | 494 | > |
| 503 | 495 | <ControlPill icon="point.topleft.down.curvedto.point.bottomright.up" /> |
| 504 | 496 | </MenuView> |
| @@ -2,7 +2,7 @@ import type { ApprovalRequestId } from "@t3tools/contracts"; · 1 unmodified line | ||
| 2 | 2 | import { Pressable, View } from "react-native"; |
| 3 | 3 | |
| 4 | 4 | import { AppText as Text, AppTextInput as TextInput } from "../../components/AppText"; |
| 5 | −import { cx } from "../../lib/classNames"; | |
| 5 | +import { cn } from "../../lib/cn"; | |
| 6 | 6 | import type { PendingUserInput, PendingUserInputDraftAnswer } from "../../lib/threadActivity"; |
| 7 | 7 | |
| 8 | 8 | export interface PendingUserInputCardProps { |
| @@ -45,7 +45,7 @@ export function PendingUserInputCard(props: PendingUserInputCardProps) { · 36 unmodified lines | ||
| 45 | 45 | return ( |
| 46 | 46 | <Pressable |
| 47 | 47 | key={option.label} |
| 48 | − className={cx( | |
| 48 | + className={cn( | |
| 49 | 49 | "rounded-full border px-3 py-2.5 ", |
| 50 | 50 | selected |
| 51 | 51 | ? "border-blue-300/50 bg-blue-50 dark:border-blue-400/28 dark:bg-blue-400/14" |
| @@ -60,7 +60,7 @@ export function PendingUserInputCard(props: PendingUserInputCardProps) { · 8 unmodified lines | ||
| 60 | 60 | } |
| 61 | 61 | > |
| 62 | 62 | <Text |
| 63 | − className={cx( | |
| 63 | + className={cn( | |
| 64 | 64 | "font-t3-bold text-[13px]", |
| 65 | 65 | selected |
| 66 | 66 | ? "text-sky-700 dark:text-sky-300" |
| @@ -85,7 +85,7 @@ export function PendingUserInputCard(props: PendingUserInputCardProps) { · 18 unmodified lines | ||
| 85 | 85 | ); |
| 86 | 86 | })} |
| 87 | 87 | <Pressable |
| 88 | − className={cx( | |
| 88 | + className={cn( | |
| 89 | 89 | "items-center justify-center rounded-2xl px-4 py-3.5", |
| 90 | 90 | props.answers ? "bg-blue-500" : "bg-neutral-200 dark:bg-neutral-700/60", |
| 91 | 91 | )} |
| @@ -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 | −} | |
| @@ -20,6 +20,7 @@ import { · 19 unmodified lines | ||
| 20 | 20 | type ViewStyle, |
| 21 | 21 | } from "react-native"; |
| 22 | 22 | import ImageViewing from "react-native-image-viewing"; |
| 23 | +import { useThemeColor } from "../../lib/useThemeColor"; | |
| 23 | 24 | |
| 24 | 25 | import { AppText as Text } from "../../components/AppText"; |
| 25 | 26 | import { ComposerAttachmentStrip } from "../../components/ComposerAttachmentStrip"; |
| @@ -29,7 +30,6 @@ import type { DraftComposerImageAttachment } from "../../lib/composerImages"; · 3 unmodified lines | ||
| 29 | 30 | import type { MobileLayoutVariant } from "../../lib/mobileLayout"; |
| 30 | 31 | import { buildModelOptions, groupByProvider } from "../../lib/modelOptions"; |
| 31 | 32 | import type { RemoteClientConnectionState } from "../../lib/remoteClient"; |
| 32 | −import { makeAppPalette } from "../../lib/theme"; | |
| 33 | 33 | import { useNativePaste } from "../../hooks/useNativePaste"; |
| 34 | 34 | |
| 35 | 35 | /** |
| @@ -109,7 +109,8 @@ function ComposerSurface(props: { · 73 unmodified lines | ||
| 109 | 109 | |
| 110 | 110 | export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposerProps) { |
| 111 | 111 | const isDarkMode = useColorScheme() === "dark"; |
| 112 | − const palette = makeAppPalette(isDarkMode); | |
| 112 | + const placeholderColor = useThemeColor("--color-placeholder"); | |
| 113 | + const foregroundColor = useThemeColor("--color-foreground"); | |
| 113 | 114 | const inputRef = useRef<RNTextInput>(null); |
| 114 | 115 | const [isFocused, setIsFocused] = useState(false); |
| 115 | 116 | const wasExpandedBeforePreviewRef = useRef(false); |
| @@ -337,14 +338,11 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer · 221 unmodified lines | ||
| 337 | 338 | |
| 338 | 339 | return ( |
| 339 | 340 | <View |
| 341 | + className="bg-screen" | |
| 340 | 342 | style={{ |
| 341 | 343 | paddingHorizontal: 16, |
| 342 | 344 | paddingTop: isExpanded ? 12 : 10, |
| 343 | 345 | paddingBottom: (props.bottomInset ?? 0) + (isExpanded ? 4 : 10), |
| 344 | − // Always opaque — the glass effect lives on the ComposerSurface (pill/card), | |
| 345 | − // not the container. This prevents chat feed text from bleeding through | |
| 346 | − // the padding areas around and below the pill. | |
| 347 | − backgroundColor: palette.screenBackground, | |
| 348 | 346 | }} |
| 349 | 347 | > |
| 350 | 348 | <View |
| @@ -395,7 +393,7 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer · 44 unmodified lines | ||
| 395 | 393 | value={props.draftMessage} |
| 396 | 394 | onChangeText={props.onChangeDraftMessage} |
| 397 | 395 | placeholder={props.placeholder} |
| 398 | − placeholderTextColor={palette.placeholder} | |
| 396 | + placeholderTextColor={placeholderColor} | |
| 399 | 397 | editable={props.connectionState === "ready"} |
| 400 | 398 | onFocus={() => setIsFocused(true)} |
| 401 | 399 | onBlur={() => setIsFocused(false)} |
| @@ -409,7 +407,7 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer · 7 unmodified lines | ||
| 409 | 407 | paddingVertical: 4, |
| 410 | 408 | fontSize: 15, |
| 411 | 409 | lineHeight: 22, |
| 412 | − color: palette.text, | |
| 410 | + color: foregroundColor, | |
| 413 | 411 | fontFamily: "DMSans_400Regular", |
| 414 | 412 | } |
| 415 | 413 | : { |
| @@ -417,7 +415,7 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer · 1 unmodified line | ||
| 417 | 415 | paddingVertical: 6, |
| 418 | 416 | fontSize: 15, |
| 419 | 417 | lineHeight: 20, |
| 420 | − color: palette.text, | |
| 418 | + color: foregroundColor, | |
| 421 | 419 | fontFamily: "DMSans_400Regular", |
| 422 | 420 | } |
| 423 | 421 | } |
| @@ -430,11 +428,11 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer · 6 unmodified lines | ||
| 430 | 428 | <Pressable key={image.id} onPress={() => onPressImage(image.previewUri)}> |
| 431 | 429 | <Image |
| 432 | 430 | source={{ uri: image.previewUri }} |
| 431 | + className="bg-subtle" | |
| 433 | 432 | style={{ |
| 434 | 433 | width: 30, |
| 435 | 434 | height: 30, |
| 436 | 435 | borderRadius: 8, |
| 437 | − backgroundColor: palette.subtleBg, | |
| 438 | 436 | }} |
| 439 | 437 | resizeMode="cover" |
| 440 | 438 | /> |
| @@ -442,22 +440,16 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer · 1 unmodified line | ||
| 442 | 440 | ))} |
| 443 | 441 | {props.draftAttachments.length > 3 ? ( |
| 444 | 442 | <View |
| 443 | + className="bg-subtle-strong" | |
| 445 | 444 | style={{ |
| 446 | 445 | width: 30, |
| 447 | 446 | height: 30, |
| 448 | 447 | borderRadius: 8, |
| 449 | − backgroundColor: palette.subtleBgStrong, | |
| 450 | 448 | alignItems: "center", |
| 451 | 449 | justifyContent: "center", |
| 452 | 450 | }} |
| 453 | 451 | > |
| 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"> | |
| 461 | 453 | +{props.draftAttachments.length - 3} |
| 462 | 454 | </Text> |
| 463 | 455 | </View> |
| @@ -497,12 +489,14 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer · 33 unmodified lines | ||
| 497 | 489 | <MenuView |
| 498 | 490 | actions={modelMenuActions} |
| 499 | 491 | onPressAction={({ nativeEvent }) => handleModelMenuAction(nativeEvent.event)} |
| 492 | + themeVariant={isDarkMode ? "dark" : "light"} | |
| 500 | 493 | > |
| 501 | 494 | <ControlPill iconNode={<ProviderIcon provider={modelProvider} size={16} />} /> |
| 502 | 495 | </MenuView> |
| 503 | 496 | <MenuView |
| 504 | 497 | actions={optionsMenuActions} |
| 505 | 498 | onPressAction={({ nativeEvent }) => handleOptionsMenuAction(nativeEvent.event)} |
| 499 | + themeVariant={isDarkMode ? "dark" : "light"} | |
| 506 | 500 | > |
| 507 | 501 | <ControlPill icon="slider.horizontal.3" /> |
| 508 | 502 | </MenuView> |
| @@ -527,8 +521,8 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer · 18 unmodified lines | ||
| 527 | 521 | {/* Queue count */} |
| 528 | 522 | {props.queueCount > 0 ? ( |
| 529 | 523 | <Text |
| 524 | + className="text-foreground-muted" | |
| 530 | 525 | style={{ |
| 531 | − color: palette.textMuted, | |
| 532 | 526 | fontSize: 12, |
| 533 | 527 | lineHeight: 18, |
| 534 | 528 | paddingTop: 8, |
| @@ -1,31 +1,23 @@ | ||
| 1 | 1 | import type { |
| 2 | 2 | ApprovalRequestId, |
| 3 | − GitBranch, | |
| 4 | − GitRunStackedActionResult, | |
| 5 | − GitStatusResult, | |
| 6 | 3 | ModelSelection, |
| 7 | 4 | ProviderApprovalDecision, |
| 8 | 5 | ProviderInteractionMode, |
| 9 | 6 | RuntimeMode, |
| 10 | 7 | ServerConfig as T3ServerConfig, |
| 11 | 8 | } from "@t3tools/contracts"; |
| 12 | −import type { GitActionRequestInput } from "@t3tools/client-runtime"; | |
| 13 | 9 | import * as Haptics from "expo-haptics"; |
| 14 | −import { SymbolView } from "expo-symbols"; | |
| 15 | 10 | import { useCallback, useEffect, useMemo, useRef, useState } from "react"; |
| 16 | −import { Modal, Pressable, useColorScheme, View } from "react-native"; | |
| 11 | +import { View } from "react-native"; | |
| 17 | 12 | import { Gesture, GestureDetector } from "react-native-gesture-handler"; |
| 18 | 13 | import { KeyboardAvoidingView, KeyboardStickyView } from "react-native-keyboard-controller"; |
| 19 | 14 | import { useSafeAreaInsets } from "react-native-safe-area-context"; |
| 20 | −import Animated, { runOnJS } from "react-native-reanimated"; | |
| 15 | +import { runOnJS } from "react-native-reanimated"; | |
| 21 | 16 | |
| 22 | −import { AppText as Text, AppTextInput as TextInput } from "../../components/AppText"; | |
| 23 | −import { GlassSafeAreaView } from "../../components/GlassSafeAreaView"; | |
| 24 | 17 | import type { StatusTone } from "../../components/StatusPill"; |
| 25 | 18 | import type { DraftComposerImageAttachment } from "../../lib/composerImages"; |
| 26 | 19 | import type { MobileLayoutVariant } from "../../lib/mobileLayout"; |
| 27 | 20 | import type { ScopedMobileThread } from "../../lib/scopedEntities"; |
| 28 | −import { makeAppPalette } from "../../lib/theme"; | |
| 29 | 21 | import type { |
| 30 | 22 | PendingApproval, |
| 31 | 23 | PendingUserInput, |
| @@ -33,7 +25,6 @@ import type { · 1 unmodified line | ||
| 33 | 25 | ThreadFeedEntry, |
| 34 | 26 | } from "../../lib/threadActivity"; |
| 35 | 27 | import { PendingApprovalCard } from "./PendingApprovalCard"; |
| 36 | −import { ThreadGitControls } from "./ThreadGitControls"; | |
| 37 | 28 | import { PendingUserInputCard } from "./PendingUserInputCard"; |
| 38 | 29 | import { |
| 39 | 30 | COMPOSER_COLLAPSED_CHROME, |
| @@ -41,7 +32,6 @@ import { · 1 unmodified line | ||
| 41 | 32 | ThreadComposer, |
| 42 | 33 | } from "./ThreadComposer"; |
| 43 | 34 | import { ThreadFeed } from "./ThreadFeed"; |
| 44 | −import { screenTitle } from "./threadPresentation"; | |
| 45 | 35 | |
| 46 | 36 | export interface ThreadDetailScreenProps { |
| 47 | 37 | readonly selectedThread: ScopedMobileThread; |
| @@ -61,12 +51,10 @@ export interface ThreadDetailScreenProps { · 13 unmodified lines | ||
| 61 | 51 | readonly draftAttachments: ReadonlyArray<DraftComposerImageAttachment>; |
| 62 | 52 | readonly connectionStateLabel: "ready" | "connecting" | "reconnecting" | "disconnected" | "idle"; |
| 63 | 53 | readonly activeThreadBusy: boolean; |
| 64 | − readonly selectedThreadGitStatus: GitStatusResult | null; | |
| 65 | − readonly gitOperationLabel: string | null; | |
| 54 | + readonly projectWorkspaceRoot: string | null; | |
| 66 | 55 | readonly selectedThreadQueueCount: number; |
| 67 | 56 | readonly serverConfig: T3ServerConfig | null; |
| 68 | 57 | readonly layoutVariant?: MobileLayoutVariant; |
| 69 | − readonly onBack: () => void; | |
| 70 | 58 | readonly onOpenDrawer: () => void; |
| 71 | 59 | readonly onOpenConnectionEditor: () => void; |
| 72 | 60 | readonly onChangeDraftMessage: (value: string) => void; |
| @@ -74,21 +62,6 @@ export interface ThreadDetailScreenProps { · 1 unmodified line | ||
| 74 | 62 | readonly onNativePasteImages: (uris: ReadonlyArray<string>) => Promise<void>; |
| 75 | 63 | readonly onRemoveDraftImage: (imageId: string) => void; |
| 76 | 64 | readonly onRefresh: () => Promise<void>; |
| 77 | − readonly onRefreshSelectedThreadGitStatus: (options?: { | |
| 78 | − readonly quiet?: boolean; | |
| 79 | − }) => Promise<void>; | |
| 80 | − readonly onListSelectedThreadBranches: () => Promise<ReadonlyArray<GitBranch>>; | |
| 81 | − readonly onCheckoutSelectedThreadBranch: (branch: string) => Promise<void>; | |
| 82 | − readonly onCreateSelectedThreadBranch: (branch: string) => Promise<void>; | |
| 83 | − readonly onCreateSelectedThreadWorktree: (input: { | |
| 84 | − readonly baseBranch: string; | |
| 85 | − readonly newBranch: string; | |
| 86 | − }) => Promise<void>; | |
| 87 | − readonly onPullSelectedThreadBranch: () => Promise<void>; | |
| 88 | − readonly onRunSelectedThreadGitAction: ( | |
| 89 | − input: GitActionRequestInput, | |
| 90 | − ) => Promise<GitRunStackedActionResult | null>; | |
| 91 | − readonly onRenameThread: (title: string) => Promise<void>; | |
| 92 | 65 | readonly onStopThread: () => Promise<void>; |
| 93 | 66 | readonly onSendMessage: () => void; |
| 94 | 67 | readonly onUpdateThreadModelSelection: (modelSelection: ModelSelection) => Promise<void>; |
| @@ -107,7 +80,6 @@ export interface ThreadDetailScreenProps { · 12 unmodified lines | ||
| 107 | 80 | customAnswer: string, |
| 108 | 81 | ) => void; |
| 109 | 82 | readonly onSubmitUserInput: () => Promise<void>; |
| 110 | − readonly showHeader?: boolean; | |
| 111 | 83 | readonly showContent?: boolean; |
| 112 | 84 | } |
| 113 | 85 | |
| @@ -182,20 +154,15 @@ function useStreamingHaptics(threadId: string, feed: ReadonlyArray<ThreadFeedEnt · 68 unmodified lines | ||
| 182 | 154 | } |
| 183 | 155 | |
| 184 | 156 | 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 | + | |
| 188 | 159 | const insets = useSafeAreaInsets(); |
| 189 | 160 | const agentLabel = `${props.selectedThread.modelSelection.provider} agent`; |
| 190 | − const headerOverlayHeight = insets.top + 118; | |
| 191 | 161 | const composerBottomInset = Math.max(insets.bottom, 12); |
| 192 | 162 | const [composerExpanded, setComposerExpanded] = useState(false); |
| 193 | 163 | const composerChrome = composerExpanded ? COMPOSER_EXPANDED_CHROME : COMPOSER_COLLAPSED_CHROME; |
| 194 | 164 | const composerOverlapHeight = composerChrome + composerBottomInset; |
| 195 | − const [renameVisible, setRenameVisible] = useState(false); | |
| 196 | 165 | const [refreshing, setRefreshing] = useState(false); |
| 197 | − const [renameDraft, setRenameDraft] = useState(props.selectedThread.title); | |
| 198 | − const showHeader = props.showHeader ?? true; | |
| 199 | 166 | const showContent = props.showContent ?? true; |
| 200 | 167 | const layoutVariant = props.layoutVariant ?? "compact"; |
| 201 | 168 | const isSplitLayout = layoutVariant === "split"; |
| @@ -214,12 +181,12 @@ export function ThreadDetailScreen(props: ThreadDetailScreenProps) { · 12 unmodified lines | ||
| 214 | 181 | setRefreshing(true); |
| 215 | 182 | try { |
| 216 | 183 | await onRefresh(); |
| 217 | − await onRefreshSelectedThreadGitStatus(); | |
| 218 | 184 | } finally { |
| 219 | 185 | setRefreshing(false); |
| 220 | 186 | } |
| 221 | − }, [onRefresh, onRefreshSelectedThreadGitStatus, refreshing]); | |
| 187 | + }, [onRefresh, refreshing]); | |
| 222 | 188 | |
| 189 | + const drawerGestureThreshold = 80; | |
| 223 | 190 | const headerDrawerGesture = useMemo( |
| 224 | 191 | () => |
| 225 | 192 | Gesture.Pan() |
| @@ -229,110 +196,16 @@ export function ThreadDetailScreen(props: ThreadDetailScreenProps) { · 3 unmodified lines | ||
| 229 | 196 | .failOffsetY([-24, 24]) |
| 230 | 197 | .onEnd((event) => { |
| 231 | 198 | const translationX = Math.max(event.translationX, 0); |
| 232 | − if (event.y < headerOverlayHeight && translationX > 56) { | |
| 199 | + if (event.y < drawerGestureThreshold && translationX > 56) { | |
| 233 | 200 | runOnJS(completeDrawerGesture)(); |
| 234 | 201 | } |
| 235 | 202 | }), |
| 236 | − [completeDrawerGesture, headerOverlayHeight, isSplitLayout], | |
| 203 | + [completeDrawerGesture, isSplitLayout], | |
| 237 | 204 | ); |
| 238 | 205 | |
| 239 | − async function handleSubmitRename(): Promise<void> { | |
| 240 | − const trimmed = renameDraft.trim(); | |
| 241 | − if (trimmed.length === 0) { | |
| 242 | − return; | |
| 243 | − } | |
| 244 | − | |
| 245 | − await props.onRenameThread(trimmed); | |
| 246 | − setRenameVisible(false); | |
| 247 | − } | |
| 248 | − | |
| 249 | 206 | return ( |
| 250 | 207 | <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"> | |
| 336 | 209 | {/* Feed area — KAV shrinks this when keyboard opens */} |
| 337 | 210 | <KeyboardAvoidingView behavior="padding" style={{ flex: 1 }}> |
| 338 | 211 | {showContent ? ( |
| @@ -343,7 +216,6 @@ export function ThreadDetailScreen(props: ThreadDetailScreenProps) { · 4 unmodified lines | ||
| 343 | 216 | httpBaseUrl={props.httpBaseUrl} |
| 344 | 217 | bearerToken={props.bearerToken} |
| 345 | 218 | agentLabel={agentLabel} |
| 346 | − contentTopInset={headerOverlayHeight + 20} | |
| 347 | 219 | contentBottomInset={composerOverlapHeight + 8} |
| 348 | 220 | layoutVariant={layoutVariant} |
| 349 | 221 | composerExpanded={composerExpanded} |
| @@ -410,95 +282,6 @@ export function ThreadDetailScreen(props: ThreadDetailScreenProps) { · 60 unmodified lines | ||
| 410 | 282 | /> |
| 411 | 283 | </KeyboardStickyView> |
| 412 | 284 | ) : null} |
| 413 | − | |
| 414 | − <Modal | |
| 415 | − transparent | |
| 416 | − animationType="fade" | |
| 417 | − visible={renameVisible} | |
| 418 | − onRequestClose={() => setRenameVisible(false)} | |
| 419 | − > | |
| 420 | − <View | |
| 421 | − className="flex-1 items-center justify-center px-5" | |
| 422 | − style={{ backgroundColor: palette.backdrop }} | |
| 423 | − > | |
| 424 | − <View | |
| 425 | − className="w-full gap-4 px-4 py-4" | |
| 426 | − style={{ | |
| 427 | − maxWidth: 420, | |
| 428 | − borderWidth: 1, | |
| 429 | − borderColor: palette.border, | |
| 430 | − backgroundColor: palette.card, | |
| 431 | − }} | |
| 432 | − > | |
| 433 | − <View className="gap-2"> | |
| 434 | − <Text | |
| 435 | − className="text-[11px] font-bold uppercase" | |
| 436 | − style={{ color: palette.textMuted, letterSpacing: 1.2 }} | |
| 437 | − > | |
| 438 | − Thread name | |
| 439 | − </Text> | |
| 440 | − <Text | |
| 441 | − className="text-[20px] font-extrabold leading-[24px]" | |
| 442 | − style={{ color: palette.text }} | |
| 443 | − > | |
| 444 | − Rename thread | |
| 445 | − </Text> | |
| 446 | − </View> | |
| 447 | − | |
| 448 | − <TextInput | |
| 449 | − value={renameDraft} | |
| 450 | − onChangeText={setRenameDraft} | |
| 451 | − placeholder="Thread title" | |
| 452 | − className="min-h-[56px] px-4 py-3 text-[15px]" | |
| 453 | − style={{ | |
| 454 | − borderWidth: 1, | |
| 455 | − borderColor: palette.border, | |
| 456 | − backgroundColor: palette.inputBackground, | |
| 457 | − color: palette.text, | |
| 458 | − }} | |
| 459 | − onSubmitEditing={() => { | |
| 460 | − void handleSubmitRename(); | |
| 461 | − }} | |
| 462 | − /> | |
| 463 | − | |
| 464 | − <View className="flex-row gap-3"> | |
| 465 | − <Pressable | |
| 466 | − className="min-h-[48px] flex-1 items-center justify-center px-4 py-3" | |
| 467 | − style={{ | |
| 468 | − borderWidth: 1, | |
| 469 | − borderColor: palette.border, | |
| 470 | − backgroundColor: palette.secondaryButton, | |
| 471 | − }} | |
| 472 | − onPress={() => { | |
| 473 | − setRenameDraft(props.selectedThread.title); | |
| 474 | − setRenameVisible(false); | |
| 475 | − }} | |
| 476 | − > | |
| 477 | − <Text | |
| 478 | − className="text-sm font-extrabold uppercase" | |
| 479 | − style={{ color: palette.text, letterSpacing: 1 }} | |
| 480 | − > | |
| 481 | − Cancel | |
| 482 | − </Text> | |
| 483 | − </Pressable> | |
| 484 | − <Pressable | |
| 485 | − className="min-h-[48px] flex-1 items-center justify-center px-4 py-3" | |
| 486 | − style={{ backgroundColor: palette.primaryButton }} | |
| 487 | − onPress={() => { | |
| 488 | − void handleSubmitRename(); | |
| 489 | − }} | |
| 490 | − > | |
| 491 | − <Text | |
| 492 | − className="text-sm font-extrabold uppercase" | |
| 493 | − style={{ color: palette.primaryButtonText, letterSpacing: 1 }} | |
| 494 | − > | |
| 495 | − Save | |
| 496 | − </Text> | |
| 497 | − </Pressable> | |
| 498 | − </View> | |
| 499 | − </View> | |
| 500 | − </View> | |
| 501 | − </Modal> | |
| 502 | 285 | </View> |
| 503 | 286 | </GestureDetector> |
| 504 | 287 | ); |
| @@ -4,15 +4,15 @@ import { LegendList, type LegendListRef } from "@legendapp/list/react-native"; · 3 unmodified lines | ||
| 4 | 4 | import { SymbolView } from "expo-symbols"; |
| 5 | 5 | import { memo, useCallback, useEffect, useState, useRef } from "react"; |
| 6 | 6 | 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"; | |
| 8 | 8 | import { TouchableOpacity } from "react-native-gesture-handler"; |
| 9 | 9 | import ImageViewing from "react-native-image-viewing"; |
| 10 | +import { useThemeColor } from "../../lib/useThemeColor"; | |
| 10 | 11 | |
| 11 | 12 | import { AppText as Text } from "../../components/AppText"; |
| 12 | 13 | import { EmptyState } from "../../components/EmptyState"; |
| 13 | −import { cx } from "../../lib/classNames"; | |
| 14 | +import { cn } from "../../lib/cn"; | |
| 14 | 15 | import type { MobileLayoutVariant } from "../../lib/mobileLayout"; |
| 15 | −import { makeAppPalette } from "../../lib/theme"; | |
| 16 | 16 | import type { ThreadFeedEntry } from "../../lib/threadActivity"; |
| 17 | 17 | import { relativeTime } from "../../lib/time"; |
| 18 | 18 | import { messageImageUrl } from "./threadPresentation"; |
| @@ -23,7 +23,6 @@ export interface ThreadFeedProps { · 4 unmodified lines | ||
| 23 | 23 | readonly httpBaseUrl: string | null; |
| 24 | 24 | readonly bearerToken: string | null; |
| 25 | 25 | readonly agentLabel: string; |
| 26 | − readonly contentTopInset?: number; | |
| 27 | 26 | readonly contentBottomInset?: number; |
| 28 | 27 | readonly refreshing?: boolean; |
| 29 | 28 | readonly onRefresh?: () => void; |
| @@ -99,18 +98,26 @@ function buildActivityRows( · 69 unmodified lines | ||
| 99 | 98 | |
| 100 | 99 | const MAX_VISIBLE_WORK_LOG_ENTRIES = 6; |
| 101 | 100 | |
| 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"); | |
| 114 | 121 | |
| 115 | 122 | const base = { |
| 116 | 123 | body: { |
| @@ -193,7 +200,7 @@ function makeMarkdownStyles(isDarkMode: boolean) { · 76 unmodified lines | ||
| 193 | 200 | }, |
| 194 | 201 | code_block: { |
| 195 | 202 | backgroundColor: userFenceBg, |
| 196 | − color: userCodeText, | |
| 203 | + color: userFenceText, | |
| 197 | 204 | borderRadius: 12, |
| 198 | 205 | padding: 12, |
| 199 | 206 | fontFamily: "ui-monospace", |
| @@ -202,7 +209,7 @@ function makeMarkdownStyles(isDarkMode: boolean) { · 2 unmodified lines | ||
| 202 | 209 | }, |
| 203 | 210 | fence: { |
| 204 | 211 | backgroundColor: userFenceBg, |
| 205 | − color: userCodeText, | |
| 212 | + color: userFenceText, | |
| 206 | 213 | borderRadius: 12, |
| 207 | 214 | padding: 12, |
| 208 | 215 | fontFamily: "ui-monospace", |
| @@ -253,12 +260,12 @@ function renderFeedEntry( · 44 unmodified lines | ||
| 253 | 260 | readonly onCopyWorkRow: (rowId: string, value: string) => void; |
| 254 | 261 | readonly onToggleWorkGroup: (groupId: string) => void; |
| 255 | 262 | readonly onPressImage: (uri: string, headers?: Record<string, string>) => void; |
| 256 | − readonly isDarkMode: boolean; | |
| 263 | + readonly iconSubtleColor: string | import("react-native").ColorValue; | |
| 264 | + readonly markdownStyles: MarkdownStyleSets; | |
| 257 | 265 | }, |
| 258 | 266 | ) { |
| 259 | 267 | const entry = info.item; |
| 260 | − const markdownStyles = makeMarkdownStyles(props.isDarkMode); | |
| 261 | − const palette = makeAppPalette(props.isDarkMode); | |
| 268 | + const { markdownStyles, iconSubtleColor } = props; | |
| 262 | 269 | |
| 263 | 270 | if (entry.type === "message") { |
| 264 | 271 | const { message } = entry; |
| @@ -389,18 +396,13 @@ function renderFeedEntry( · 124 unmodified lines | ||
| 389 | 396 | {visibleRows.map((row, index) => ( |
| 390 | 397 | <View |
| 391 | 398 | key={row.id} |
| 392 | − className={cx( | |
| 399 | + className={cn( | |
| 393 | 400 | "flex-row items-center gap-2 rounded-lg px-1 py-1", |
| 394 | 401 | index > 0 && "border-t border-neutral-200/80 dark:border-white/[0.06]", |
| 395 | 402 | )} |
| 396 | 403 | > |
| 397 | 404 | <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" /> | |
| 404 | 406 | </View> |
| 405 | 407 | <ScrollView |
| 406 | 408 | horizontal |
| @@ -439,7 +441,6 @@ function renderFeedEntry( · 32 unmodified lines | ||
| 439 | 441 | } |
| 440 | 442 | |
| 441 | 443 | export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { |
| 442 | − const isDarkMode = useColorScheme() === "dark"; | |
| 443 | 444 | const listRef = useRef<LegendListRef>(null); |
| 444 | 445 | const copyFeedbackTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null); |
| 445 | 446 | const [copiedRowId, setCopiedRowId] = useState<string | null>(null); |
| @@ -450,6 +451,9 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { · 4 unmodified lines | ||
| 450 | 451 | } | null>(null); |
| 451 | 452 | const horizontalPadding = props.layoutVariant === "split" ? 20 : 16; |
| 452 | 453 | |
| 454 | + const iconSubtleColor = useThemeColor("--color-icon-subtle"); | |
| 455 | + const markdownStyles = useMarkdownStyles(); | |
| 456 | + | |
| 453 | 457 | useEffect(() => { |
| 454 | 458 | setCopiedRowId(null); |
| 455 | 459 | setExpandedWorkGroups({}); |
| @@ -509,12 +513,14 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { · 53 unmodified lines | ||
| 509 | 513 | onCopyWorkRow, |
| 510 | 514 | onToggleWorkGroup, |
| 511 | 515 | onPressImage, |
| 512 | − isDarkMode, | |
| 516 | + iconSubtleColor, | |
| 517 | + markdownStyles, | |
| 513 | 518 | }), |
| 514 | 519 | [ |
| 515 | 520 | copiedRowId, |
| 516 | 521 | expandedWorkGroups, |
| 517 | − isDarkMode, | |
| 522 | + iconSubtleColor, | |
| 523 | + markdownStyles, | |
| 518 | 524 | onCopyWorkRow, |
| 519 | 525 | onPressImage, |
| 520 | 526 | onToggleWorkGroup, |
| @@ -525,12 +531,12 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { · 4 unmodified lines | ||
| 525 | 531 | |
| 526 | 532 | if (props.feed.length === 0) { |
| 527 | 533 | 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, | |
| 532 | 539 | paddingHorizontal: horizontalPadding, |
| 533 | − paddingTop: props.contentTopInset ?? 18, | |
| 534 | 540 | paddingBottom: props.contentBottomInset ?? 18, |
| 535 | 541 | }} |
| 536 | 542 | > |
| @@ -538,7 +544,7 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { · 1 unmodified line | ||
| 538 | 544 | title="No conversation yet" |
| 539 | 545 | detail="Ask the agent to inspect the repo, run a command, or continue the active thread." |
| 540 | 546 | /> |
| 541 | − </View> | |
| 547 | + </ScrollView> | |
| 542 | 548 | ); |
| 543 | 549 | } |
| 544 | 550 | |
| @@ -548,6 +554,7 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { · 3 unmodified lines | ||
| 548 | 554 | ref={listRef} |
| 549 | 555 | key={props.threadId} |
| 550 | 556 | style={{ flex: 1 }} |
| 557 | + contentInsetAdjustmentBehavior="automatic" | |
| 551 | 558 | data={props.feed as ThreadFeedEntry[]} |
| 552 | 559 | renderItem={renderItem} |
| 553 | 560 | keyExtractor={(entry) => `${entry.type}:${entry.id}`} |
| @@ -561,7 +568,7 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { · 7 unmodified lines | ||
| 561 | 568 | onRefresh={props.onRefresh} |
| 562 | 569 | contentContainerStyle={{ |
| 563 | 570 | paddingHorizontal: horizontalPadding, |
| 564 | − paddingTop: props.contentTopInset ?? 18, | |
| 571 | + paddingTop: 8, | |
| 565 | 572 | paddingBottom: props.contentBottomInset ?? 18, |
| 566 | 573 | }} |
| 567 | 574 | /> |
| @@ -1,93 +1,13 @@ | ||
| 1 | +import type { GitRunStackedActionResult, GitStatusResult } from "@t3tools/contracts"; | |
| 1 | 2 | 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, | |
| 12 | 3 | type GitActionRequestInput, |
| 13 | 4 | requiresDefaultBranchConfirmation, |
| 14 | − resolveDefaultBranchActionDialogCopy, | |
| 15 | 5 | resolveQuickAction, |
| 16 | 6 | } 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"; | |
| 91 | 11 | |
| 92 | 12 | function truncateMiddle(value: string, maxLength: number): string { |
| 93 | 13 | if (value.length <= maxLength) { |
| @@ -130,211 +50,26 @@ function compactMenuStatus(gitStatus: GitStatusResult | null): string { · 36 unmodified lines | ||
| 130 | 50 | return parts.join(" · "); |
| 131 | 51 | } |
| 132 | 52 | |
| 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 | − | |
| 284 | 53 | export function ThreadGitControls(props: { |
| 285 | 54 | readonly currentBranch: string | null; |
| 286 | − readonly currentWorktreePath: string | null; | |
| 287 | 55 | readonly gitStatus: GitStatusResult | null; |
| 288 | 56 | readonly gitOperationLabel: string | null; |
| 289 | − readonly onRefreshStatus: (options?: { readonly quiet?: boolean }) => Promise<void>; | |
| 290 | − readonly onListBranches: () => Promise<ReadonlyArray<GitBranch>>; | |
| 291 | − readonly onCheckoutBranch: (branch: string) => Promise<void>; | |
| 292 | − readonly onCreateBranch: (branch: string) => Promise<void>; | |
| 293 | − readonly onCreateWorktree: (input: { | |
| 294 | − readonly baseBranch: string; | |
| 295 | − readonly newBranch: string; | |
| 296 | − }) => Promise<void>; | |
| 297 | 57 | readonly onPull: () => Promise<void>; |
| 298 | 58 | readonly onRunAction: (input: GitActionRequestInput) => Promise<GitRunStackedActionResult | null>; |
| 299 | 59 | }) { |
| 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"; | |
| 334 | 68 | const busy = gitOperationLabel !== null; |
| 335 | 69 | const isRepo = gitStatus?.isRepo ?? true; |
| 336 | 70 | const hasOriginRemote = gitStatus?.hasOriginRemote ?? false; |
| 337 | 71 | const isDefaultBranch = gitStatus?.isDefaultBranch ?? false; |
| 72 | + | |
| 338 | 73 | const quickAction = useMemo( |
| 339 | 74 | () => |
| 340 | 75 | isRepo |
| @@ -347,163 +82,21 @@ export function ThreadGitControls(props: { · 6 unmodified lines | ||
| 347 | 82 | }, |
| 348 | 83 | [busy, gitStatus, hasOriginRemote, isDefaultBranch, isRepo], |
| 349 | 84 | ); |
| 350 | − const menuItems = useMemo( | |
| 351 | − () => (isRepo ? buildMenuItems(gitStatus, busy, hasOriginRemote) : []), | |
| 352 | − [busy, gitStatus, hasOriginRemote, isRepo], | |
| 353 | − ); | |
| 85 | + | |
| 354 | 86 | const quickActionHint = quickAction.disabled |
| 355 | 87 | ? (quickAction.hint ?? "This action is unavailable.") |
| 356 | 88 | : null; |
| 357 | − const triggerStatus = triggerStatusLabel(gitStatus, gitOperationLabel); | |
| 358 | − const allFiles = gitStatus?.workingTree.files ?? []; | |
| 359 | − const selectedFiles = allFiles.filter((file) => !excludedFiles.has(file.path)); | |
| 360 | − const allSelected = excludedFiles.size === 0; | |
| 361 | − const noneSelected = selectedFiles.length === 0; | |
| 362 | − const selectedInsertions = selectedFiles.reduce((sum, file) => sum + file.insertions, 0); | |
| 363 | − const selectedDeletions = selectedFiles.reduce((sum, file) => sum + file.deletions, 0); | |
| 364 | − const selectedFilePreview = selectedFiles.slice(0, 3); | |
| 365 | − const renderBackdrop = useCallback( | |
| 366 | − (backdropProps: ComponentProps<typeof BottomSheetBackdrop>) => ( | |
| 367 | − <BottomSheetBackdrop | |
| 368 | − {...backdropProps} | |
| 369 | − appearsOnIndex={0} | |
| 370 | − disappearsOnIndex={-1} | |
| 371 | − opacity={0.18} | |
| 372 | − pressBehavior="close" | |
| 373 | − /> | |
| 374 | − ), | |
| 375 | − [], | |
| 376 | − ); | |
| 377 | − const gitSnapPoints = useMemo<(string | number)[]>(() => ["72%"], []); | |
| 378 | − const branchSnapPoints = useMemo<(string | number)[]>(() => ["86%"], []); | |
| 379 | − const commitSnapPoints = useMemo<(string | number)[]>(() => ["88%"], []); | |
| 380 | − const defaultBranchSnapPoints = useMemo<(string | number)[]>(() => ["40%"], []); | |
| 381 | − const pendingDefaultBranchActionCopy = pendingDefaultBranchAction | |
| 382 | − ? resolveDefaultBranchActionDialogCopy({ | |
| 383 | − action: pendingDefaultBranchAction.action, | |
| 384 | − branchName: pendingDefaultBranchAction.branchName, | |
| 385 | − includesCommit: pendingDefaultBranchAction.includesCommit, | |
| 386 | − }) | |
| 387 | − : null; | |
| 388 | − const disabledExistingBranches = useMemo( | |
| 389 | − () => | |
| 390 | − new Set( | |
| 391 | − availableBranches | |
| 392 | − .filter( | |
| 393 | − (branch) => branch.worktreePath !== null && branch.worktreePath !== currentWorktreePath, | |
| 394 | − ) | |
| 395 | − .map((branch) => branch.name), | |
| 396 | − ), | |
| 397 | − [availableBranches, currentWorktreePath], | |
| 398 | − ); | |
| 399 | − const sheetMenuItems = useMemo( | |
| 400 | − () => | |
| 401 | − menuItems.map((item) => ({ | |
| 402 | − item, | |
| 403 | − disabledReason: getGitActionDisabledReason({ | |
| 404 | − item, | |
| 405 | − gitStatus, | |
| 406 | − isBusy: busy, | |
| 407 | − hasOriginRemote, | |
| 408 | − }), | |
| 409 | − })), | |
| 410 | − [busy, gitStatus, hasOriginRemote, menuItems], | |
| 411 | − ); | |
| 412 | − const nativeMenuActions = useMemo< | |
| 413 | − Array<{ | |
| 414 | − id: string; | |
| 415 | − title: string; | |
| 416 | − subtitle?: string; | |
| 417 | − state?: "off"; | |
| 418 | − image?: string; | |
| 419 | − preferredElementSize?: "small"; | |
| 420 | − attributes?: { | |
| 421 | − disabled?: boolean; | |
| 422 | − }; | |
| 423 | − }> | |
| 424 | − >( | |
| 425 | − () => [ | |
| 426 | − { | |
| 427 | − id: "branch", | |
| 428 | − title: compactMenuBranchLabel(currentBranchLabel), | |
| 429 | − subtitle: compactMenuStatus(gitStatus), | |
| 430 | − image: "point.topleft.down.curvedto.point.bottomright.up", | |
| 431 | − state: "off", | |
| 432 | − preferredElementSize: "small", | |
| 433 | − attributes: { disabled: true }, | |
| 434 | − }, | |
| 435 | − { | |
| 436 | − id: "quick", | |
| 437 | − title: quickAction.label, | |
| 438 | − image: | |
| 439 | − quickAction.kind === "run_pull" | |
| 440 | − ? "arrow.down.circle" | |
| 441 | − : quickAction.kind === "open_pr" | |
| 442 | − ? "arrow.up.right.circle" | |
| 443 | − : quickAction.action === "commit" | |
| 444 | − ? "checkmark.circle" | |
| 445 | − : quickAction.action === "push" || quickAction.action === "commit_push" | |
| 446 | − ? "arrow.up.circle" | |
| 447 | − : "arrow.up.right.circle", | |
| 448 | − ...(quickActionHint ? { subtitle: quickActionHint } : {}), | |
| 449 | − preferredElementSize: "small", | |
| 450 | − ...(quickAction.disabled ? { attributes: { disabled: true } } : {}), | |
| 451 | − }, | |
| 452 | − { | |
| 453 | − id: "more", | |
| 454 | − title: "More", | |
| 455 | − image: "ellipsis.circle", | |
| 456 | − subtitle: "Commit, files, branches", | |
| 457 | − preferredElementSize: "small", | |
| 458 | − }, | |
| 459 | − ], | |
| 460 | − [ | |
| 461 | − currentBranchLabel, | |
| 462 | − gitStatus, | |
| 463 | − quickAction.action, | |
| 464 | − quickAction.disabled, | |
| 465 | − quickAction.kind, | |
| 466 | − quickAction.label, | |
| 467 | − quickActionHint, | |
| 468 | − ], | |
| 469 | − ); | |
| 470 | 89 | |
| 471 | − useSheetVisibility(gitSheetRef, gitSheetVisible); | |
| 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"; | |
| 486 | 97 | } |
| 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 | + })(); | |
| 507 | 100 | |
| 508 | 101 | const openExistingPr = useCallback(async () => { |
| 509 | 102 | const prUrl = gitStatus?.pr?.state === "open" ? gitStatus.pr.url : null; |
| @@ -537,64 +130,26 @@ export function ThreadGitControls(props: { · 27 unmodified lines | ||
| 537 | 130 | !input.featureBranch && |
| 538 | 131 | requiresDefaultBranchConfirmation(input.action, isDefaultBranch) |
| 539 | 132 | ) { |
| 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 | + }, | |
| 546 | 144 | }); |
| 547 | − setDefaultBranchSheetVisible(true); | |
| 548 | 145 | return; |
| 549 | 146 | } |
| 550 | 147 | |
| 551 | − closeTransientSheets(); | |
| 552 | 148 | await onRunAction(input); |
| 553 | 149 | }, |
| 554 | − [closeTransientSheets, gitStatus, isDefaultBranch, onRunAction], | |
| 150 | + [environmentId, gitStatus, isDefaultBranch, onRunAction, router, threadId], | |
| 555 | 151 | ); |
| 556 | 152 | |
| 557 | − const continuePendingDefaultBranchAction = useCallback(async () => { | |
| 558 | − if (!pendingDefaultBranchAction) { | |
| 559 | − return; | |
| 560 | − } | |
| 561 | − const action = pendingDefaultBranchAction; | |
| 562 | − setPendingDefaultBranchAction(null); | |
| 563 | − setDefaultBranchSheetVisible(false); | |
| 564 | − await onRunAction({ | |
| 565 | − action: action.action, | |
| 566 | − ...(action.commitMessage ? { commitMessage: action.commitMessage } : {}), | |
| 567 | − ...(action.filePaths ? { filePaths: action.filePaths } : {}), | |
| 568 | − }); | |
| 569 | − }, [onRunAction, pendingDefaultBranchAction]); | |
| 570 | − | |
| 571 | − const movePendingActionToFeatureBranch = useCallback(async () => { | |
| 572 | − if (!pendingDefaultBranchAction) { | |
| 573 | − return; | |
| 574 | − } | |
| 575 | − | |
| 576 | − const action = pendingDefaultBranchAction; | |
| 577 | − setPendingDefaultBranchAction(null); | |
| 578 | − setDefaultBranchSheetVisible(false); | |
| 579 | − | |
| 580 | − if (action.includesCommit) { | |
| 581 | − await onRunAction({ | |
| 582 | − action: action.action, | |
| 583 | − featureBranch: true, | |
| 584 | − ...(action.commitMessage ? { commitMessage: action.commitMessage } : {}), | |
| 585 | − ...(action.filePaths ? { filePaths: action.filePaths } : {}), | |
| 586 | − }); | |
| 587 | − return; | |
| 588 | − } | |
| 589 | − | |
| 590 | − const branches = await onListBranches(); | |
| 591 | − const branchName = resolveAutoFeatureBranchName( | |
| 592 | − branches.filter((branch) => !branch.isRemote).map((branch) => branch.name), | |
| 593 | − ); | |
| 594 | − await onCreateBranch(branchName); | |
| 595 | − await onRunAction({ action: action.action }); | |
| 596 | − }, [onCreateBranch, onListBranches, onRunAction, pendingDefaultBranchAction]); | |
| 597 | − | |
| 598 | 153 | const runQuickAction = useCallback(async () => { |
| 599 | 154 | if (quickAction.kind === "open_pr") { |
| 600 | 155 | await openExistingPr(); |
| @@ -609,699 +164,38 @@ export function ThreadGitControls(props: { · 8 unmodified lines | ||
| 609 | 164 | } |
| 610 | 165 | }, [onPull, openExistingPr, quickAction, runActionWithPrompt]); |
| 611 | 166 | |
| 612 | − const runCommitAction = useCallback( | |
| 613 | − async (featureBranch: boolean) => { | |
| 614 | − const commitMessage = dialogCommitMessage.trim(); | |
| 615 | − setGitSheetVisible(false); | |
| 616 | − setCommitSheetVisible(false); | |
| 617 | − await onRunAction({ | |
| 618 | − action: "commit", | |
| 619 | − featureBranch, | |
| 620 | − ...(commitMessage ? { commitMessage } : {}), | |
| 621 | − ...(!allSelected ? { filePaths: selectedFiles.map((file) => file.path) } : {}), | |
| 622 | − }); | |
| 623 | − setDialogCommitMessage(""); | |
| 624 | − setExcludedFiles(new Set()); | |
| 625 | − }, | |
| 626 | − [allSelected, dialogCommitMessage, onRunAction, selectedFiles], | |
| 627 | − ); | |
| 628 | − | |
| 629 | − const onPressMenuItem = useCallback( | |
| 630 | − async (item: (typeof menuItems)[number]) => { | |
| 631 | − if (item.disabled) { | |
| 632 | − return; | |
| 633 | − } | |
| 634 | − if (item.kind === "open_pr") { | |
| 635 | − await openExistingPr(); | |
| 636 | − return; | |
| 637 | − } | |
| 638 | − if (item.dialogAction === "commit") { | |
| 639 | − openCommitSheet(); | |
| 640 | − return; | |
| 641 | − } | |
| 642 | − if (item.dialogAction === "push") { | |
| 643 | − await runActionWithPrompt({ action: "push" }); | |
| 644 | − return; | |
| 645 | − } | |
| 646 | − if (item.dialogAction === "create_pr") { | |
| 647 | − await runActionWithPrompt({ action: "create_pr" }); | |
| 648 | − } | |
| 649 | − }, | |
| 650 | − [openCommitSheet, openExistingPr, runActionWithPrompt], | |
| 651 | − ); | |
| 652 | − | |
| 653 | − const onPressNativeMenuAction = useCallback( | |
| 654 | − ({ nativeEvent }: { nativeEvent: { event: string } }) => { | |
| 655 | − if (nativeEvent.event === "quick") { | |
| 656 | − void runQuickAction(); | |
| 657 | − return; | |
| 658 | − } | |
| 659 | − if (nativeEvent.event === "more") { | |
| 660 | − setGitSheetVisible(true); | |
| 661 | − } | |
| 662 | − }, | |
| 663 | − [runQuickAction], | |
| 664 | − ); | |
| 665 | − | |
| 666 | 167 | 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)} | |
| 672 | 175 | > |
| 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} | |
| 1079 | 183 | > |
| 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> | |
| 1306 | 200 | ); |
| 1307 | 201 | } |
| @@ -10,14 +10,15 @@ import { · 9 unmodified lines | ||
| 10 | 10 | type View as RNView, |
| 11 | 11 | } from "react-native"; |
| 12 | 12 | import Animated, { FadeInDown, LinearTransition } from "react-native-reanimated"; |
| 13 | +import { useThemeColor } from "../../lib/useThemeColor"; | |
| 13 | 14 | |
| 14 | 15 | import { BrandMark } from "../../components/BrandMark"; |
| 15 | 16 | import { AppText as Text } from "../../components/AppText"; |
| 16 | 17 | import { EmptyState } from "../../components/EmptyState"; |
| 17 | 18 | import { GlassSafeAreaView } from "../../components/GlassSafeAreaView"; |
| 18 | 19 | import { StatusPill } from "../../components/StatusPill"; |
| 20 | +import { cn } from "../../lib/cn"; | |
| 19 | 21 | import type { MobileLayout } from "../../lib/mobileLayout"; |
| 20 | −import { makeAppPalette } from "../../lib/theme"; | |
| 21 | 22 | import { |
| 22 | 23 | scopedThreadKey, |
| 23 | 24 | type ScopedMobileProject, |
| @@ -70,32 +71,31 @@ function ActionButton(props: { · 46 unmodified lines | ||
| 70 | 71 | readonly primary?: boolean; |
| 71 | 72 | readonly onPress: () => void; |
| 72 | 73 | }) { |
| 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; | |
| 75 | 77 | |
| 76 | 78 | return ( |
| 77 | 79 | <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 | + )} | |
| 84 | 84 | onPress={props.onPress} |
| 85 | 85 | > |
| 86 | 86 | <SymbolView |
| 87 | 87 | name={props.icon} |
| 88 | 88 | size={18} |
| 89 | − tintColor={props.primary ? palette.primaryButtonText : palette.secondaryButtonText} | |
| 89 | + tintColor={tintColor} | |
| 90 | 90 | type="monochrome" |
| 91 | 91 | weight="medium" |
| 92 | 92 | /> |
| 93 | 93 | <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 }} | |
| 99 | 99 | > |
| 100 | 100 | {props.label} |
| 101 | 101 | </Text> |
| @@ -108,35 +108,20 @@ function StatCard(props: { · 6 unmodified lines | ||
| 108 | 108 | readonly value: string; |
| 109 | 109 | readonly icon: React.ComponentProps<typeof SymbolView>["name"]; |
| 110 | 110 | }) { |
| 111 | − const isDarkMode = useColorScheme() === "dark"; | |
| 112 | − const palette = makeAppPalette(isDarkMode); | |
| 111 | + const foregroundSecondary = useThemeColor("--color-foreground-secondary"); | |
| 113 | 112 | |
| 114 | 113 | 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"> | |
| 123 | 115 | <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" /> | |
| 130 | 117 | <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 }} | |
| 133 | 120 | > |
| 134 | 121 | {props.label} |
| 135 | 122 | </Text> |
| 136 | 123 | </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> | |
| 140 | 125 | </View> |
| 141 | 126 | ); |
| 142 | 127 | } |
| @@ -148,19 +133,20 @@ function ThreadRow(props: { · 5 unmodified lines | ||
| 148 | 133 | readonly onPress: (sourceFrame: TransitionSourceFrame | null) => void; |
| 149 | 134 | }) { |
| 150 | 135 | const isDarkMode = useColorScheme() === "dark"; |
| 151 | − const palette = makeAppPalette(isDarkMode); | |
| 136 | + const cardColor = useThemeColor("--color-card"); | |
| 137 | + const borderColor = useThemeColor("--color-border"); | |
| 152 | 138 | const containerRef = useRef<RNView>(null); |
| 153 | 139 | const tone = threadStatusTone(props.thread); |
| 154 | 140 | const backgroundColor = props.selected |
| 155 | 141 | ? isDarkMode |
| 156 | 142 | ? "rgba(249,115,22,0.12)" |
| 157 | 143 | : "rgba(249,115,22,0.09)" |
| 158 | − : palette.card; | |
| 159 | − const borderColor = props.selected | |
| 144 | + : cardColor; | |
| 145 | + const resolvedBorderColor = props.selected | |
| 160 | 146 | ? isDarkMode |
| 161 | 147 | ? "rgba(249,115,22,0.32)" |
| 162 | 148 | : "rgba(249,115,22,0.28)" |
| 163 | − : palette.border; | |
| 149 | + : borderColor; | |
| 164 | 150 | |
| 165 | 151 | return ( |
| 166 | 152 | <Pressable |
| @@ -168,7 +154,7 @@ function ThreadRow(props: { · 1 unmodified line | ||
| 168 | 154 | className="gap-3 rounded-[18px] border px-4 py-4" |
| 169 | 155 | style={{ |
| 170 | 156 | backgroundColor, |
| 171 | − borderColor, | |
| 157 | + borderColor: resolvedBorderColor, | |
| 172 | 158 | opacity: props.hidden ? 0 : 1, |
| 173 | 159 | }} |
| 174 | 160 | onPress={() => { |
| @@ -183,13 +169,8 @@ function ThreadRow(props: { · 8 unmodified lines | ||
| 183 | 169 | > |
| 184 | 170 | <View className="flex-row items-start justify-between gap-3"> |
| 185 | 171 | <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"> | |
| 193 | 174 | {lastConversationLine(props.thread)} |
| 194 | 175 | </Text> |
| 195 | 176 | </View> |
| @@ -197,8 +178,8 @@ function ThreadRow(props: { · 1 unmodified line | ||
| 197 | 178 | </View> |
| 198 | 179 | |
| 199 | 180 | <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 }} | |
| 202 | 183 | > |
| 203 | 184 | {props.thread.environmentLabel} · {props.thread.modelSelection.provider} ·{" "} |
| 204 | 185 | {relativeTime(props.thread.updatedAt ?? props.thread.createdAt)} |
| @@ -218,34 +199,28 @@ function ProjectSection(props: { · 13 unmodified lines | ||
| 218 | 199 | sourceFrame: TransitionSourceFrame | null, |
| 219 | 200 | ) => void; |
| 220 | 201 | }) { |
| 221 | − const isDarkMode = useColorScheme() === "dark"; | |
| 222 | − const palette = makeAppPalette(isDarkMode); | |
| 223 | 202 | const [expanded, setExpanded] = useState(false); |
| 224 | 203 | const visibleThreads = expanded ? props.threads : props.threads.slice(0, 2); |
| 225 | 204 | const hiddenCount = Math.max(props.threads.length - visibleThreads.length, 0); |
| 226 | 205 | |
| 227 | 206 | return ( |
| 228 | − <View className="gap-3 rounded-[22px] border px-4 py-4" style={{ borderColor: palette.border }}> | |
| 207 | + <View className="gap-3 rounded-[22px] border border-border px-4 py-4"> | |
| 229 | 208 | <View className="flex-row items-start justify-between gap-3"> |
| 230 | 209 | <View className="flex-1 gap-1"> |
| 231 | − <Text className="text-[15px] font-t3-bold" style={{ color: palette.text }}> | |
| 210 | + <Text className="text-[15px] font-t3-bold text-foreground"> | |
| 232 | 211 | {props.project.environmentLabel} |
| 233 | 212 | </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"> | |
| 238 | 214 | {props.project.workspaceRoot} |
| 239 | 215 | </Text> |
| 240 | 216 | </View> |
| 241 | 217 | <Pressable |
| 242 | − className="rounded-full px-3 py-2" | |
| 243 | − style={{ backgroundColor: palette.subtleBg }} | |
| 218 | + className="rounded-full bg-subtle px-3 py-2" | |
| 244 | 219 | onPress={() => void props.onCreateThread(props.project)} |
| 245 | 220 | > |
| 246 | 221 | <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 }} | |
| 249 | 224 | > |
| 250 | 225 | New thread |
| 251 | 226 | </Text> |
| @@ -273,13 +248,12 @@ function ProjectSection(props: { · 21 unmodified lines | ||
| 273 | 248 | |
| 274 | 249 | {hiddenCount > 0 ? ( |
| 275 | 250 | <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" | |
| 278 | 252 | onPress={() => setExpanded((current) => !current)} |
| 279 | 253 | > |
| 280 | 254 | <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 }} | |
| 283 | 257 | > |
| 284 | 258 | {expanded ? "Show less" : `Show ${hiddenCount} more`} |
| 285 | 259 | </Text> |
| @@ -297,28 +271,19 @@ function CreateThreadModal(props: { · 11 unmodified lines | ||
| 297 | 271 | readonly onClose: () => void; |
| 298 | 272 | readonly onSelectProject: (project: ScopedMobileProject) => Promise<void>; |
| 299 | 273 | }) { |
| 300 | − const isDarkMode = useColorScheme() === "dark"; | |
| 301 | − const palette = makeAppPalette(isDarkMode); | |
| 302 | − | |
| 303 | 274 | if (!props.group) { |
| 304 | 275 | return null; |
| 305 | 276 | } |
| 306 | 277 | |
| 307 | 278 | return ( |
| 308 | 279 | <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"> | |
| 314 | 282 | <View className="gap-1"> |
| 315 | − <Text className="text-[22px] font-t3-bold" style={{ color: palette.text }}> | |
| 283 | + <Text className="text-[22px] font-t3-bold text-foreground"> | |
| 316 | 284 | New thread in {props.group.title} |
| 317 | 285 | </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"> | |
| 322 | 287 | Choose which environment should own the new thread. |
| 323 | 288 | </Text> |
| 324 | 289 | </View> |
| @@ -327,31 +292,24 @@ function CreateThreadModal(props: { · 2 unmodified lines | ||
| 327 | 292 | {props.group.projects.map(({ project, threads }) => ( |
| 328 | 293 | <Pressable |
| 329 | 294 | 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" | |
| 332 | 296 | onPress={() => { |
| 333 | 297 | void props.onSelectProject(project).then(props.onClose); |
| 334 | 298 | }} |
| 335 | 299 | > |
| 336 | 300 | <View className="flex-row items-start justify-between gap-3"> |
| 337 | 301 | <View className="flex-1 gap-1"> |
| 338 | − <Text className="text-[16px] font-t3-bold" style={{ color: palette.text }}> | |
| 302 | + <Text className="text-[16px] font-t3-bold text-foreground"> | |
| 339 | 303 | {project.environmentLabel} |
| 340 | 304 | </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"> | |
| 345 | 306 | {project.workspaceRoot} |
| 346 | 307 | </Text> |
| 347 | 308 | </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"> | |
| 352 | 310 | <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 }} | |
| 355 | 313 | > |
| 356 | 314 | {threads.length} thread{threads.length === 1 ? "" : "s"} |
| 357 | 315 | </Text> |
| @@ -369,8 +327,7 @@ function CreateThreadModal(props: { · 11 unmodified lines | ||
| 369 | 327 | } |
| 370 | 328 | |
| 371 | 329 | export function ThreadListScreen(props: ThreadListScreenProps) { |
| 372 | − const isDarkMode = useColorScheme() === "dark"; | |
| 373 | − const palette = makeAppPalette(isDarkMode); | |
| 330 | + const borderColorValue = useThemeColor("--color-border"); | |
| 374 | 331 | const repositoryGroups = useMemo( |
| 375 | 332 | () => groupProjectsByRepository({ projects: props.projects, threads: props.threads }), |
| 376 | 333 | [props.projects, props.threads], |
| @@ -379,7 +336,7 @@ export function ThreadListScreen(props: ThreadListScreenProps) { · 2 unmodified lines | ||
| 379 | 336 | const [refreshing, setRefreshing] = useState(false); |
| 380 | 337 | const isSplitLayout = props.layout.usesSplitView; |
| 381 | 338 | const contentHorizontalPadding = isSplitLayout ? 18 : 20; |
| 382 | − const panelBorderColor = isSplitLayout ? palette.border : "transparent"; | |
| 339 | + const panelBorderColor = isSplitLayout ? borderColorValue : "transparent"; | |
| 383 | 340 | const refreshThreads = props.onRefresh; |
| 384 | 341 | |
| 385 | 342 | const handleRefresh = useCallback(async (): Promise<void> => { |
| @@ -397,9 +354,8 @@ export function ThreadListScreen(props: ThreadListScreenProps) { · 11 unmodified lines | ||
| 397 | 354 | |
| 398 | 355 | return ( |
| 399 | 356 | <View |
| 400 | − className="flex-1" | |
| 357 | + className="flex-1 bg-screen" | |
| 401 | 358 | style={{ |
| 402 | − backgroundColor: palette.screenBackground, | |
| 403 | 359 | borderRadius: isSplitLayout ? 32 : 0, |
| 404 | 360 | borderWidth: isSplitLayout ? 1 : 0, |
| 405 | 361 | borderColor: panelBorderColor, |
| @@ -426,28 +382,20 @@ export function ThreadListScreen(props: ThreadListScreenProps) { · 20 unmodified lines | ||
| 426 | 382 | <Animated.View |
| 427 | 383 | entering={FadeInDown.duration(260)} |
| 428 | 384 | 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" | |
| 435 | 386 | > |
| 436 | 387 | <View className="flex-row items-start justify-between gap-4"> |
| 437 | 388 | <View className="flex-1 gap-2"> |
| 438 | 389 | <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 }} | |
| 441 | 392 | > |
| 442 | 393 | {props.heroTitle} |
| 443 | 394 | </Text> |
| 444 | − <Text className="text-[26px] font-t3-bold" style={{ color: palette.text }}> | |
| 395 | + <Text className="text-[26px] font-t3-bold text-foreground"> | |
| 445 | 396 | {isSplitLayout ? "Native workspace" : "Repo board"} |
| 446 | 397 | </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"> | |
| 451 | 399 | {isSplitLayout |
| 452 | 400 | ? "Keep your repositories visible while you move between active threads." |
| 453 | 401 | : "Your connected repositories, grouped by identity across environments."} |
| @@ -497,29 +445,19 @@ export function ThreadListScreen(props: ThreadListScreenProps) { · 43 unmodified lines | ||
| 497 | 445 | key={group.key} |
| 498 | 446 | entering={FadeInDown.duration(260).delay(40 * Math.min(index, 6))} |
| 499 | 447 | 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" | |
| 506 | 449 | > |
| 507 | 450 | <View className="flex-row items-start justify-between gap-4"> |
| 508 | 451 | <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> | |
| 512 | 453 | {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"> | |
| 517 | 455 | {group.subtitle} |
| 518 | 456 | </Text> |
| 519 | 457 | ) : null} |
| 520 | 458 | <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 }} | |
| 523 | 461 | > |
| 524 | 462 | {group.projectCount} environment{group.projectCount === 1 ? "" : "s"} ·{" "} |
| 525 | 463 | {group.threadCount} active thread{group.threadCount === 1 ? "" : "s"} |
| @@ -527,8 +465,7 @@ export function ThreadListScreen(props: ThreadListScreenProps) { · 1 unmodified line | ||
| 527 | 465 | </View> |
| 528 | 466 | |
| 529 | 467 | <Pressable |
| 530 | − className="rounded-full px-3 py-2" | |
| 531 | − style={{ backgroundColor: palette.primaryButton }} | |
| 468 | + className="rounded-full bg-primary px-3 py-2" | |
| 532 | 469 | onPress={() => { |
| 533 | 470 | if (group.projects.length === 1) { |
| 534 | 471 | void props.onCreateThread(group.projects[0]!.project); |
| @@ -538,8 +475,8 @@ export function ThreadListScreen(props: ThreadListScreenProps) { · 3 unmodified lines | ||
| 538 | 475 | }} |
| 539 | 476 | > |
| 540 | 477 | <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 }} | |
| 543 | 480 | > |
| 544 | 481 | New thread |
| 545 | 482 | </Text> |
| @@ -1,13 +1,6 @@ | ||
| 1 | 1 | import { SymbolView } from "expo-symbols"; |
| 2 | 2 | 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"; | |
| 11 | 4 | import { Gesture, GestureDetector } from "react-native-gesture-handler"; |
| 12 | 5 | import { useSafeAreaInsets } from "react-native-safe-area-context"; |
| 13 | 6 | import Animated, { |
| @@ -16,6 +9,7 @@ import Animated, { · 2 unmodified lines | ||
| 16 | 9 | useSharedValue, |
| 17 | 10 | withTiming, |
| 18 | 11 | } from "react-native-reanimated"; |
| 12 | +import { useThemeColor } from "../../lib/useThemeColor"; | |
| 19 | 13 | |
| 20 | 14 | import { AppText as Text } from "../../components/AppText"; |
| 21 | 15 | import { StatusPill } from "../../components/StatusPill"; |
| @@ -23,7 +17,6 @@ import { groupProjectsByRepository } from "../../lib/repositoryGroups"; · 1 unmodified line | ||
| 23 | 17 | import type { ScopedMobileProject, ScopedMobileThread } from "../../lib/scopedEntities"; |
| 24 | 18 | import { scopedThreadKey } from "../../lib/scopedEntities"; |
| 25 | 19 | import { relativeTime } from "../../lib/time"; |
| 26 | −import { makeAppPalette } from "../../lib/theme"; | |
| 27 | 20 | import { threadStatusTone } from "./threadPresentation"; |
| 28 | 21 | |
| 29 | 22 | function compareThreadActivity(left: ScopedMobileThread, right: ScopedMobileThread): number { |
| @@ -42,14 +35,19 @@ export function ThreadNavigationDrawer(props: { · 12 unmodified lines | ||
| 42 | 35 | readonly onSelectThread: (thread: ScopedMobileThread) => void; |
| 43 | 36 | readonly onStartNewTask: () => void; |
| 44 | 37 | }) { |
| 45 | − const isDarkMode = useColorScheme() === "dark"; | |
| 46 | − const palette = makeAppPalette(isDarkMode); | |
| 47 | 38 | const insets = useSafeAreaInsets(); |
| 48 | 39 | const { width } = useWindowDimensions(); |
| 49 | 40 | const drawerWidth = Math.min(width * 0.84, 360); |
| 50 | 41 | const [mounted, setMounted] = useState(props.visible); |
| 51 | 42 | const translateX = useSharedValue(-drawerWidth); |
| 52 | 43 | const overlayOpacity = useSharedValue(0); |
| 44 | + | |
| 45 | + const backdropColor = useThemeColor("--color-backdrop"); | |
| 46 | + const drawerBg = useThemeColor("--color-drawer"); | |
| 47 | + const drawerShadow = useThemeColor("--color-drawer-shadow"); | |
| 48 | + const primaryForeground = useThemeColor("--color-primary-foreground"); | |
| 49 | + const borderSubtleColor = useThemeColor("--color-border-subtle"); | |
| 50 | + | |
| 53 | 51 | const repositoryGroups = useMemo( |
| 54 | 52 | () => groupProjectsByRepository({ projects: props.projects, threads: props.threads }), |
| 55 | 53 | [props.projects, props.threads], |
| @@ -130,7 +128,7 @@ export function ThreadNavigationDrawer(props: { · 74 unmodified lines | ||
| 130 | 128 | right: 0, |
| 131 | 129 | top: 0, |
| 132 | 130 | bottom: 0, |
| 133 | − backgroundColor: palette.backdrop, | |
| 131 | + backgroundColor: backdropColor, | |
| 134 | 132 | }, |
| 135 | 133 | backdropStyle, |
| 136 | 134 | ]} |
| @@ -149,30 +147,27 @@ export function ThreadNavigationDrawer(props: { · 12 unmodified lines | ||
| 149 | 147 | top: 0, |
| 150 | 148 | bottom: 0, |
| 151 | 149 | width: drawerWidth, |
| 152 | − backgroundColor: palette.drawerBackground, | |
| 150 | + backgroundColor: drawerBg, | |
| 153 | 151 | paddingTop: insets.top + 10, |
| 154 | 152 | paddingBottom: Math.max(insets.bottom, 18), |
| 155 | − boxShadow: `20px 0 36px ${palette.drawerShadow}`, | |
| 153 | + boxShadow: `20px 0 36px ${String(drawerShadow)}`, | |
| 156 | 154 | }, |
| 157 | 155 | drawerStyle, |
| 158 | 156 | ]} |
| 159 | 157 | > |
| 160 | 158 | <View className="flex-row items-center justify-between px-4 pb-5"> |
| 161 | − <Text className="text-[26px] font-t3-bold" style={{ color: palette.text }}> | |
| 162 | − Threads | |
| 163 | − </Text> | |
| 159 | + <Text className="text-[26px] font-t3-bold">Threads</Text> | |
| 164 | 160 | <Pressable |
| 165 | 161 | onPress={() => { |
| 166 | 162 | props.onClose(); |
| 167 | 163 | props.onStartNewTask(); |
| 168 | 164 | }} |
| 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" | |
| 171 | 166 | > |
| 172 | 167 | <SymbolView |
| 173 | 168 | name="square.and.pencil" |
| 174 | 169 | size={17} |
| 175 | − tintColor={palette.primaryButtonText} | |
| 170 | + tintColor={primaryForeground} | |
| 176 | 171 | type="monochrome" |
| 177 | 172 | /> |
| 178 | 173 | </Pressable> |
| @@ -189,22 +184,16 @@ export function ThreadNavigationDrawer(props: { · 10 unmodified lines | ||
| 189 | 184 | {groupedThreads.map((group) => ( |
| 190 | 185 | <View key={group.key} className="gap-3"> |
| 191 | 186 | <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 }} | |
| 194 | 189 | > |
| 195 | 190 | {group.title} |
| 196 | 191 | </Text> |
| 197 | 192 | |
| 198 | − <View | |
| 199 | − className="overflow-hidden rounded-[22px]" | |
| 200 | − style={{ backgroundColor: palette.card }} | |
| 201 | − > | |
| 193 | + <View className="overflow-hidden rounded-[22px] bg-card"> | |
| 202 | 194 | {group.threads.length === 0 ? ( |
| 203 | 195 | <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"> | |
| 208 | 197 | No threads yet |
| 209 | 198 | </Text> |
| 210 | 199 | </View> |
| @@ -224,23 +213,19 @@ export function ThreadNavigationDrawer(props: { · 13 unmodified lines | ||
| 224 | 213 | paddingHorizontal: 16, |
| 225 | 214 | paddingVertical: 15, |
| 226 | 215 | borderTopWidth: index === 0 ? 0 : 1, |
| 227 | − borderTopColor: palette.borderSubtle, | |
| 228 | − backgroundColor: selected ? palette.subtleBg : "transparent", | |
| 216 | + borderTopColor: borderSubtleColor, | |
| 217 | + backgroundColor: selected ? undefined : "transparent", | |
| 229 | 218 | }} |
| 219 | + className={selected ? "bg-subtle" : undefined} | |
| 230 | 220 | > |
| 231 | 221 | <View className="flex-row items-start justify-between gap-3"> |
| 232 | 222 | <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}> | |
| 238 | 224 | {thread.title} |
| 239 | 225 | </Text> |
| 240 | 226 | <Text |
| 241 | − className="text-[13px] font-medium" | |
| 227 | + className="text-[13px] font-medium text-foreground-muted" | |
| 242 | 228 | numberOfLines={1} |
| 243 | − style={{ color: palette.textMuted }} | |
| 244 | 229 | > |
| 245 | 230 | {relativeTime(thread.updatedAt ?? thread.createdAt)} |
| 246 | 231 | </Text> |
| @@ -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"; | |
| 4 | 6 | |
| 5 | 7 | import { EmptyState } from "../../components/EmptyState"; |
| 6 | 8 | import { LoadingScreen } from "../../components/LoadingScreen"; |
| 7 | −import { buildThreadRoutePath, dismissRoute } from "../../lib/routes"; | |
| 9 | +import { buildThreadRoutePath } from "../../lib/routes"; | |
| 8 | 10 | import { scopedThreadKey } from "../../lib/scopedEntities"; |
| 9 | −import { makeAppPalette } from "../../lib/theme"; | |
| 10 | 11 | import { connectionTone } from "../connection/connectionTone"; |
| 11 | 12 | import { firstNonNull } from "../../state/remote-runtime-types"; |
| 12 | 13 | import { useRemoteCatalog } from "../../state/use-remote-catalog"; |
| @@ -14,12 +15,15 @@ import { · 1 unmodified line | ||
| 14 | 15 | useRemoteConnectionStatus, |
| 15 | 16 | useRemoteEnvironmentState, |
| 16 | 17 | } 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"; | |
| 17 | 20 | import { useSelectedThreadCommands } from "../../state/use-selected-thread-commands"; |
| 18 | −import { useSelectedThreadGit } from "../../state/use-selected-thread-git"; | |
| 19 | 21 | import { useThreadComposerState } from "../../state/use-thread-composer-state"; |
| 20 | 22 | import { useThreadSelection } from "../../state/use-thread-selection"; |
| 21 | 23 | import { ThreadDetailScreen } from "./ThreadDetailScreen"; |
| 24 | +import { ThreadGitControls } from "./ThreadGitControls"; | |
| 22 | 25 | import { ThreadNavigationDrawer } from "./ThreadNavigationDrawer"; |
| 26 | +import { screenTitle } from "./threadPresentation"; | |
| 23 | 27 | |
| 24 | 28 | function firstRouteParam(value: string | string[] | undefined): string | null { |
| 25 | 29 | if (Array.isArray(value)) { |
| @@ -36,19 +40,19 @@ export function ThreadRouteScreen() { · 10 unmodified lines | ||
| 36 | 40 | const { |
| 37 | 41 | onSelectThread, |
| 38 | 42 | selectedThread, |
| 43 | + selectedThreadProject, | |
| 39 | 44 | selectedEnvironmentConnection, |
| 40 | 45 | selectedEnvironmentRuntime, |
| 41 | 46 | } = useThreadSelection(); |
| 42 | 47 | const composer = useThreadComposerState(); |
| 43 | − const git = useSelectedThreadGit(); | |
| 48 | + const gitState = useSelectedThreadGitState(); | |
| 49 | + const gitActions = useSelectedThreadGitActions(); | |
| 44 | 50 | const commands = useSelectedThreadCommands({ |
| 45 | 51 | activePendingUserInput: composer.activePendingUserInput, |
| 46 | 52 | activePendingUserInputAnswers: composer.activePendingUserInputAnswers, |
| 47 | − refreshSelectedThreadGitStatus: git.refreshSelectedThreadGitStatus, | |
| 53 | + refreshSelectedThreadGitStatus: gitActions.refreshSelectedThreadGitStatus, | |
| 48 | 54 | }); |
| 49 | 55 | const router = useRouter(); |
| 50 | − const colorScheme = useColorScheme(); | |
| 51 | − const isDarkMode = colorScheme !== "light"; | |
| 52 | 56 | const params = useLocalSearchParams<{ |
| 53 | 57 | environmentId?: string | string[]; |
| 54 | 58 | threadId?: string | string[]; |
| @@ -57,6 +61,32 @@ export function ThreadRouteScreen() { · 2 unmodified lines | ||
| 57 | 61 | const environmentId = firstRouteParam(params.environmentId); |
| 58 | 62 | const threadId = firstRouteParam(params.threadId); |
| 59 | 63 | |
| 64 | + /* ─── Native header theming ──────────────────────────────────────── */ | |
| 65 | + const isDark = useColorScheme() === "dark"; | |
| 66 | + const iconColor = String(useThemeColor("--color-icon")); | |
| 67 | + const foregroundColor = String(useThemeColor("--color-foreground")); | |
| 68 | + const secondaryFg = isDark ? "#a3a3a3" : "#525252"; | |
| 69 | + | |
| 70 | + /* ─── Git status for native header trigger ───────────────────────── */ | |
| 71 | + const gitStatus = useGitStatus({ | |
| 72 | + environmentId: selectedThread?.environmentId ?? "", | |
| 73 | + cwd: selectedThread?.worktreePath ?? selectedThreadProject?.workspaceRoot ?? null, | |
| 74 | + }); | |
| 75 | + | |
| 76 | + const handleRefreshGitStatus = useCallback(async () => { | |
| 77 | + if (!selectedThread) return; | |
| 78 | + await gitStatusManager.refresh({ | |
| 79 | + environmentId: selectedThread.environmentId, | |
| 80 | + cwd: selectedThread.worktreePath ?? selectedThreadProject?.workspaceRoot ?? null, | |
| 81 | + }); | |
| 82 | + }, [selectedThread, selectedThreadProject?.workspaceRoot]); | |
| 83 | + | |
| 84 | + /** Wraps thread refresh + git status refresh for pull-to-refresh */ | |
| 85 | + const handleRefreshAll = useCallback(async () => { | |
| 86 | + await commands.onRefresh(); | |
| 87 | + await handleRefreshGitStatus(); | |
| 88 | + }, [commands, handleRefreshGitStatus]); | |
| 89 | + | |
| 60 | 90 | const routeThread = useMemo(() => { |
| 61 | 91 | if (!environmentId || !threadId) { |
| 62 | 92 | return null; |
| @@ -106,7 +136,7 @@ export function ThreadRouteScreen() { · 43 unmodified lines | ||
| 106 | 136 | paddingHorizontal: 24, |
| 107 | 137 | paddingVertical: 32, |
| 108 | 138 | }} |
| 109 | − style={{ flex: 1, backgroundColor: makeAppPalette(isDarkMode).screenBackground }} | |
| 139 | + className="bg-screen flex-1" | |
| 110 | 140 | > |
| 111 | 141 | <EmptyState |
| 112 | 142 | title="Thread unavailable" |
| @@ -125,71 +155,117 @@ export function ThreadRouteScreen() { · 12 unmodified lines | ||
| 125 | 155 | selectedEnvironmentRuntime?.serverConfig ?? |
| 126 | 156 | firstNonNull(Object.values(environmentStateById).map((runtime) => runtime.serverConfig)); |
| 127 | 157 | |
| 158 | + const headerSubtitle = [screenTitle(serverConfig, null), selectedThread.environmentLabel] | |
| 159 | + .filter(Boolean) | |
| 160 | + .join(" · "); | |
| 161 | + | |
| 128 | 162 | 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 | + ), | |
| 161 | 205 | }} |
| 162 | − onListSelectedThreadBranches={git.onListSelectedThreadBranches} | |
| 163 | − onCheckoutSelectedThreadBranch={git.onCheckoutSelectedThreadBranch} | |
| 164 | − onCreateSelectedThreadBranch={git.onCreateSelectedThreadBranch} | |
| 165 | − onCreateSelectedThreadWorktree={git.onCreateSelectedThreadWorktree} | |
| 166 | − onPullSelectedThreadBranch={git.onPullSelectedThreadBranch} | |
| 167 | − onRunSelectedThreadGitAction={git.onRunSelectedThreadGitAction} | |
| 168 | − serverConfig={serverConfig} | |
| 169 | − onRenameThread={commands.onRenameThread} | |
| 170 | − onStopThread={commands.onStopThread} | |
| 171 | − onSendMessage={composer.onSendMessage} | |
| 172 | − onUpdateThreadModelSelection={commands.onUpdateThreadModelSelection} | |
| 173 | − onUpdateThreadRuntimeMode={commands.onUpdateThreadRuntimeMode} | |
| 174 | − onUpdateThreadInteractionMode={commands.onUpdateThreadInteractionMode} | |
| 175 | − onRespondToApproval={commands.onRespondToApproval} | |
| 176 | − onSelectUserInputOption={composer.onSelectUserInputOption} | |
| 177 | − onChangeUserInputCustomAnswer={composer.onChangeUserInputCustomAnswer} | |
| 178 | − onSubmitUserInput={commands.onSubmitUserInput} | |
| 179 | 206 | /> |
| 180 | 207 | |
| 181 | − <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} | |
| 192 | 214 | /> |
| 193 | − </View> | |
| 215 | + | |
| 216 | + <View className="flex-1 bg-screen"> | |
| 217 | + <ThreadDetailScreen | |
| 218 | + selectedThread={selectedThread} | |
| 219 | + screenTone={connectionTone(connectionState)} | |
| 220 | + connectionError={connectionError} | |
| 221 | + httpBaseUrl={selectedEnvironmentConnection?.httpBaseUrl ?? null} | |
| 222 | + bearerToken={selectedEnvironmentConnection?.bearerToken ?? null} | |
| 223 | + selectedThreadFeed={composer.selectedThreadFeed} | |
| 224 | + activeWorkDurationLabel={composer.activeWorkDurationLabel} | |
| 225 | + activePendingApproval={composer.activePendingApproval} | |
| 226 | + respondingApprovalId={commands.respondingApprovalId} | |
| 227 | + activePendingUserInput={composer.activePendingUserInput} | |
| 228 | + activePendingUserInputDrafts={composer.activePendingUserInputDrafts} | |
| 229 | + activePendingUserInputAnswers={composer.activePendingUserInputAnswers} | |
| 230 | + respondingUserInputId={commands.respondingUserInputId} | |
| 231 | + draftMessage={composer.draftMessage} | |
| 232 | + draftAttachments={composer.draftAttachments} | |
| 233 | + connectionStateLabel={connectionState} | |
| 234 | + activeThreadBusy={composer.activeThreadBusy} | |
| 235 | + projectWorkspaceRoot={selectedThreadProject?.workspaceRoot ?? null} | |
| 236 | + selectedThreadQueueCount={composer.selectedThreadQueueCount} | |
| 237 | + onOpenDrawer={() => setDrawerVisible(true)} | |
| 238 | + onOpenConnectionEditor={() => router.push("/connections")} | |
| 239 | + onChangeDraftMessage={composer.onChangeDraftMessage} | |
| 240 | + onPickDraftImages={composer.onPickDraftImages} | |
| 241 | + onNativePasteImages={composer.onNativePasteImages} | |
| 242 | + onRemoveDraftImage={composer.onRemoveDraftImage} | |
| 243 | + onRefresh={handleRefreshAll} | |
| 244 | + serverConfig={serverConfig} | |
| 245 | + onStopThread={commands.onStopThread} | |
| 246 | + onSendMessage={composer.onSendMessage} | |
| 247 | + onUpdateThreadModelSelection={commands.onUpdateThreadModelSelection} | |
| 248 | + onUpdateThreadRuntimeMode={commands.onUpdateThreadRuntimeMode} | |
| 249 | + onUpdateThreadInteractionMode={commands.onUpdateThreadInteractionMode} | |
| 250 | + onRespondToApproval={commands.onRespondToApproval} | |
| 251 | + onSelectUserInputOption={composer.onSelectUserInputOption} | |
| 252 | + onChangeUserInputCustomAnswer={composer.onChangeUserInputCustomAnswer} | |
| 253 | + onSubmitUserInput={commands.onSubmitUserInput} | |
| 254 | + /> | |
| 255 | + | |
| 256 | + <ThreadNavigationDrawer | |
| 257 | + visible={drawerVisible} | |
| 258 | + projects={projects} | |
| 259 | + threads={threads} | |
| 260 | + selectedThreadKey={selectedThreadKey} | |
| 261 | + onClose={() => setDrawerVisible(false)} | |
| 262 | + onSelectThread={(thread) => { | |
| 263 | + onSelectThread(thread); | |
| 264 | + router.replace(buildThreadRoutePath(thread)); | |
| 265 | + }} | |
| 266 | + onStartNewTask={() => router.push("/new")} | |
| 267 | + /> | |
| 268 | + </View> | |
| 269 | + </> | |
| 194 | 270 | ); |
| 195 | 271 | } |
| @@ -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 | +} | |
| @@ -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 | +} | |
| @@ -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 | +} | |
| @@ -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 | +} | |
| @@ -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 | +} | |
| @@ -15,7 +15,8 @@ import { buildModelOptions, groupByProvider } from "../../lib/modelOptions"; · 14 unmodified lines | ||
| 15 | 15 | import { groupProjectsByRepository } from "../../lib/repositoryGroups"; |
| 16 | 16 | import type { ScopedMobileProject } from "../../lib/scopedEntities"; |
| 17 | 17 | 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"; | |
| 19 | 20 | import { useRemoteCatalog } from "../../state/use-remote-catalog"; |
| 20 | 21 | |
| 21 | 22 | export type { ModelOption, ProviderGroup }; |
| @@ -110,7 +111,9 @@ const NewTaskFlowContext = React.createContext<NewTaskFlowContextValue | null>(n · 88 unmodified lines | ||
| 110 | 111 | |
| 111 | 112 | export function NewTaskFlowProvider(props: React.PropsWithChildren) { |
| 112 | 113 | const { projects, serverConfigByEnvironmentId, threads } = useRemoteCatalog(); |
| 113 | − const { onListProjectBranches } = useProjectActions(); | |
| 114 | + const setPendingConnectionError = useRemoteEnvironmentStore( | |
| 115 | + (state) => state.setPendingConnectionError, | |
| 116 | + ); | |
| 114 | 117 | |
| 115 | 118 | const repositoryGroups = useMemo( |
| 116 | 119 | () => groupProjectsByRepository({ projects, threads }), |
| @@ -142,8 +145,6 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) { · 25 unmodified lines | ||
| 142 | 145 | const [attachments, setAttachments] = useState<ReadonlyArray<DraftComposerImageAttachment>>([]); |
| 143 | 146 | const [submitting, setSubmitting] = useState(false); |
| 144 | 147 | const [branchQuery, setBranchQuery] = useState(""); |
| 145 | − const [branchesLoading, setBranchesLoading] = useState(false); | |
| 146 | − const [availableBranches, setAvailableBranches] = useState<ReadonlyArray<GitBranch>>([]); | |
| 147 | 148 | const [runtimeMode, setRuntimeMode] = useState<RuntimeMode>(DEFAULT_RUNTIME_MODE); |
| 148 | 149 | const [interactionMode, setInteractionMode] = useState<ProviderInteractionMode>( |
| 149 | 150 | DEFAULT_PROVIDER_INTERACTION_MODE, |
| @@ -190,8 +191,6 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) { · 40 unmodified lines | ||
| 190 | 191 | clearAttachments(); |
| 191 | 192 | setSubmitting(false); |
| 192 | 193 | setBranchQuery(""); |
| 193 | − setBranchesLoading(false); | |
| 194 | − setAvailableBranches([]); | |
| 195 | 194 | setRuntimeMode(DEFAULT_RUNTIME_MODE); |
| 196 | 195 | setInteractionMode(DEFAULT_PROVIDER_INTERACTION_MODE); |
| 197 | 196 | setEffort("high"); |
| @@ -262,6 +261,20 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) { · 64 unmodified lines | ||
| 262 | 261 | ) ?? null; |
| 263 | 262 | |
| 264 | 263 | const providerGroups = useMemo(() => groupByProvider(modelOptions), [modelOptions]); |
| 264 | + const branchTarget = useMemo( | |
| 265 | + () => ({ | |
| 266 | + environmentId: selectedProject?.environmentId ?? null, | |
| 267 | + cwd: selectedProject?.workspaceRoot ?? null, | |
| 268 | + query: null, | |
| 269 | + }), | |
| 270 | + [selectedProject?.environmentId, selectedProject?.workspaceRoot], | |
| 271 | + ); | |
| 272 | + const branchState = useGitBranches(branchTarget); | |
| 273 | + const branchesLoading = branchState.isPending; | |
| 274 | + const availableBranches = useMemo( | |
| 275 | + () => (branchState.data?.branches ?? []).filter((branch) => !branch.isRemote), | |
| 276 | + [branchState.data?.branches], | |
| 277 | + ); | |
| 265 | 278 | |
| 266 | 279 | const filteredBranches = useMemo(() => { |
| 267 | 280 | const query = branchQuery.trim().toLowerCase(); |
| @@ -299,10 +312,14 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) { · 31 unmodified lines | ||
| 299 | 312 | return; |
| 300 | 313 | } |
| 301 | 314 | |
| 302 | − setBranchesLoading(true); | |
| 303 | 315 | 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); | |
| 306 | 323 | |
| 307 | 324 | if (workspaceMode === "worktree" && !selectedBranchName) { |
| 308 | 325 | const preferredBranch = |
| @@ -313,10 +330,10 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) { · 4 unmodified lines | ||
| 313 | 330 | setSelectedBranchName(preferredBranch); |
| 314 | 331 | } |
| 315 | 332 | } |
| 316 | − } finally { | |
| 317 | − setBranchesLoading(false); | |
| 333 | + } catch { | |
| 334 | + setPendingConnectionError("Failed to load branches."); | |
| 318 | 335 | } |
| 319 | − }, [onListProjectBranches, selectedBranchName, selectedProject, workspaceMode]); | |
| 336 | + }, [selectedBranchName, selectedProject, setPendingConnectionError, workspaceMode]); | |
| 320 | 337 | |
| 321 | 338 | const value = useMemo<NewTaskFlowContextValue>( |
| 322 | 339 | () => ({ |
| @@ -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 | −} | |
| @@ -1,3 +0,0 @@ | ||
| 1 | −export function cx(...parts: Array<string | false | null | undefined>): string { | |
| 2 | − return parts.filter(Boolean).join(" "); | |
| 3 | −} | |
| @@ -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 | −} | |
| @@ -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 | +} | |
| @@ -3,8 +3,7 @@ import { · 2 unmodified lines | ||
| 3 | 3 | PROVIDER_SEND_TURN_MAX_IMAGE_BYTES, |
| 4 | 4 | type UploadChatImageAttachment, |
| 5 | 5 | } from "@t3tools/contracts"; |
| 6 | − | |
| 7 | −import { newClientId } from "./clientId"; | |
| 6 | +import { uuidv4 } from "./uuid"; | |
| 8 | 7 | |
| 9 | 8 | export interface DraftComposerImageAttachment extends UploadChatImageAttachment { |
| 10 | 9 | readonly id: string; |
| @@ -101,7 +100,7 @@ export async function pickComposerImages(input: { readonly existingCount: number · 90 unmodified lines | ||
| 101 | 100 | } |
| 102 | 101 | |
| 103 | 102 | nextImages.push({ |
| 104 | − id: newClientId("attachment"), | |
| 103 | + id: uuidv4(), | |
| 105 | 104 | type: "image", |
| 106 | 105 | name: asset.fileName ?? "image", |
| 107 | 106 | mimeType, |
| @@ -165,7 +164,7 @@ export async function pasteComposerClipboard(input: { readonly existingCount: nu · 57 unmodified lines | ||
| 165 | 164 | return { |
| 166 | 165 | images: [ |
| 167 | 166 | { |
| 168 | − id: newClientId("attachment"), | |
| 167 | + id: uuidv4(), | |
| 169 | 168 | type: "image", |
| 170 | 169 | name: "pasted-image.png", |
| 171 | 170 | mimeType: "image/png", |
| @@ -233,7 +232,7 @@ export async function convertPastedImagesToAttachments(input: { · 61 unmodified lines | ||
| 233 | 232 | } |
| 234 | 233 | const mimeType = mimeTypeFromUri(uri); |
| 235 | 234 | results.push({ |
| 236 | − id: newClientId("attachment"), | |
| 235 | + id: uuidv4(), | |
| 237 | 236 | type: "image", |
| 238 | 237 | name: `pasted-image.${mimeType.split("/")[1] ?? "png"}`, |
| 239 | 238 | mimeType, |
| @@ -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 | +} | |
| @@ -2,9 +2,7 @@ import * as SecureStore from "expo-secure-store"; · 1 unmodified line | ||
| 2 | 2 | |
| 3 | 3 | import type { SavedRemoteConnection } from "./connection"; |
| 4 | 4 | |
| 5 | −const CONNECTIONS_KEY = "t3code:connections"; | |
| 6 | − | |
| 7 | −type SavedRemoteConnectionMetadata = Omit<SavedRemoteConnection, "bearerToken">; | |
| 5 | +const CONNECTIONS_KEY = "t3code.connections"; | |
| 8 | 6 | |
| 9 | 7 | async function readStorageItem(key: string): Promise<string | null> { |
| 10 | 8 | return await SecureStore.getItemAsync(key); |
| @@ -14,31 +12,7 @@ async function writeStorageItem(key: string, value: string): Promise<void> { · 3 unmodified lines | ||
| 14 | 12 | await SecureStore.setItemAsync(key, value); |
| 15 | 13 | } |
| 16 | 14 | |
| 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>> { | |
| 42 | 16 | const raw = (await readStorageItem(CONNECTIONS_KEY)) ?? ""; |
| 43 | 17 | if (!raw.trim()) { |
| 44 | 18 | return []; |
| @@ -46,64 +20,27 @@ async function loadSavedConnectionMetadata(): Promise< · 1 unmodified line | ||
| 46 | 20 | |
| 47 | 21 | try { |
| 48 | 22 | const parsed = JSON.parse(raw) as { |
| 49 | − readonly connections?: ReadonlyArray<SavedRemoteConnectionMetadata>; | |
| 23 | + readonly connections?: ReadonlyArray<SavedRemoteConnection>; | |
| 50 | 24 | }; |
| 51 | − return parsed.connections ?? []; | |
| 25 | + return (parsed.connections ?? []).filter((c) => c.environmentId && c.bearerToken?.trim()); | |
| 52 | 26 | } catch { |
| 53 | 27 | return []; |
| 54 | 28 | } |
| 55 | 29 | } |
| 56 | 30 | |
| 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 | − | |
| 81 | 31 | 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(); | |
| 91 | 33 | const next = current.some((entry) => entry.environmentId === connection.environmentId) |
| 92 | 34 | ? current.map((entry) => |
| 93 | − entry.environmentId === connection.environmentId ? nextConnection : entry, | |
| 35 | + entry.environmentId === connection.environmentId ? connection : entry, | |
| 94 | 36 | ) |
| 95 | − : [...current, nextConnection]; | |
| 37 | + : [...current, connection]; | |
| 96 | 38 | |
| 97 | − await Promise.all([ | |
| 98 | − saveSavedConnectionMetadata(next), | |
| 99 | − storeSecureToken(connection.environmentId, connection.bearerToken.trim()), | |
| 100 | − ]); | |
| 39 | + await writeStorageItem(CONNECTIONS_KEY, JSON.stringify({ connections: next })); | |
| 101 | 40 | } |
| 102 | 41 | |
| 103 | 42 | 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 })); | |
| 109 | 46 | } |
| @@ -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 | −} | |
| @@ -1,5 +1,7 @@ | ||
| 1 | 1 | import type { |
| 2 | 2 | ApprovalRequestId, |
| 3 | + CommandId, | |
| 4 | + MessageId, | |
| 3 | 5 | OrchestrationThread, |
| 4 | 6 | OrchestrationThreadActivity, |
| 5 | 7 | ThreadId, |
| @@ -29,11 +31,10 @@ export interface PendingUserInputDraftAnswer { · 23 unmodified lines | ||
| 29 | 31 | } |
| 30 | 32 | |
| 31 | 33 | export interface QueuedThreadMessage { |
| 32 | − readonly id: string; | |
| 33 | 34 | readonly environmentId: string; |
| 34 | 35 | readonly threadId: ThreadId; |
| 35 | − readonly messageId: string; | |
| 36 | − readonly commandId: string; | |
| 36 | + readonly messageId: MessageId; | |
| 37 | + readonly commandId: CommandId; | |
| 37 | 38 | readonly text: string; |
| 38 | 39 | readonly attachments: ReadonlyArray<DraftComposerImageAttachment>; |
| 39 | 40 | readonly createdAt: string; |
| @@ -380,7 +381,7 @@ export function buildPendingUserInputAnswers( · 340 unmodified lines | ||
| 380 | 381 | export function buildThreadFeed( |
| 381 | 382 | thread: OrchestrationThread, |
| 382 | 383 | queuedMessages: ReadonlyArray<QueuedThreadMessage>, |
| 383 | − dispatchingQueuedMessageId: string | null, | |
| 384 | + dispatchingQueuedMessageId: MessageId | null, | |
| 384 | 385 | options?: { |
| 385 | 386 | readonly loadedMessages?: ReadonlyArray<OrchestrationThread["messages"][number]>; |
| 386 | 387 | }, |
| @@ -398,10 +399,10 @@ export function buildThreadFeed( · 11 unmodified lines | ||
| 398 | 399 | })), |
| 399 | 400 | ...queuedMessages.map<RawThreadFeedEntry>((queuedMessage) => ({ |
| 400 | 401 | type: "queued-message", |
| 401 | − id: queuedMessage.id, | |
| 402 | + id: queuedMessage.messageId, | |
| 402 | 403 | createdAt: queuedMessage.createdAt, |
| 403 | 404 | queuedMessage, |
| 404 | − sending: queuedMessage.id === dispatchingQueuedMessageId, | |
| 405 | + sending: queuedMessage.messageId === dispatchingQueuedMessageId, | |
| 405 | 406 | })), |
| 406 | 407 | ...thread.activities |
| 407 | 408 | .filter((activity) => { |
| @@ -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 | +} | |
| @@ -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); | |
| @@ -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 | +} | |
| @@ -1,4 +1,4 @@ | ||
| 1 | −import type { ThreadId } from "@t3tools/contracts"; | |
| 1 | +import type { MessageId, ThreadId } from "@t3tools/contracts"; | |
| 2 | 2 | import { useStore } from "zustand"; |
| 3 | 3 | import { createStore } from "zustand/vanilla"; |
| 4 | 4 | |
| @@ -10,7 +10,7 @@ interface ThreadComposerStoreState { · 5 unmodified lines | ||
| 10 | 10 | readonly nowTick: number; |
| 11 | 11 | readonly draftMessageByThreadKey: Record<string, string>; |
| 12 | 12 | readonly draftAttachmentsByThreadKey: Record<string, ReadonlyArray<DraftComposerImageAttachment>>; |
| 13 | − readonly dispatchingQueuedMessageId: string | null; | |
| 13 | + readonly dispatchingQueuedMessageId: MessageId | null; | |
| 14 | 14 | readonly queuedMessagesByThreadKey: Record<string, ReadonlyArray<QueuedThreadMessage>>; |
| 15 | 15 | |
| 16 | 16 | readonly setNowTick: (tick: number) => void; |
| @@ -22,13 +22,13 @@ interface ThreadComposerStoreState { · 5 unmodified lines | ||
| 22 | 22 | readonly appendDraftMessage: (threadKey: string, value: string) => void; |
| 23 | 23 | readonly clearDraft: (threadKey: string) => void; |
| 24 | 24 | 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; | |
| 27 | 27 | readonly enqueueQueuedMessage: (message: QueuedThreadMessage) => void; |
| 28 | 28 | readonly removeQueuedMessage: ( |
| 29 | 29 | environmentId: string, |
| 30 | 30 | threadId: ThreadId, |
| 31 | − queuedMessageId: string, | |
| 31 | + queuedMessageId: MessageId, | |
| 32 | 32 | ) => void; |
| 33 | 33 | } |
| 34 | 34 | |
| @@ -107,7 +107,7 @@ export const threadComposerStore = createStore<ThreadComposerStoreState>()((set) · 72 unmodified lines | ||
| 107 | 107 | if (!existing) { |
| 108 | 108 | return state; |
| 109 | 109 | } |
| 110 | − const nextQueue = existing.filter((entry) => entry.id !== queuedMessageId); | |
| 110 | + const nextQueue = existing.filter((entry) => entry.messageId !== queuedMessageId); | |
| 111 | 111 | const next = { ...state.queuedMessagesByThreadKey }; |
| 112 | 112 | if (nextQueue.length === 0) { |
| 113 | 113 | delete next[threadKey]; |
| @@ -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 | −} | |
| @@ -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 | +} | |
| @@ -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 | +} | |
| @@ -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 | +} | |
| @@ -14,11 +14,12 @@ import { · 13 unmodified lines | ||
| 14 | 14 | import { sanitizeFeatureBranchName } from "@t3tools/shared/git"; |
| 15 | 15 | |
| 16 | 16 | import type { DraftComposerImageAttachment } from "../lib/composerImages"; |
| 17 | −import { newClientId } from "../lib/clientId"; | |
| 17 | +import { uuidv4 } from "../lib/uuid"; | |
| 18 | 18 | import type { ScopedMobileProject } from "../lib/scopedEntities"; |
| 19 | 19 | import { buildTemporaryWorktreeBranchName } from "../lib/worktrees"; |
| 20 | 20 | import { deriveThreadTitleFromPrompt } from "./remote-runtime-types"; |
| 21 | 21 | import { useRemoteEnvironmentStore } from "./remote-environment-store"; |
| 22 | +import { gitBranchManager } from "./use-git-branches"; | |
| 22 | 23 | import { useRemoteCatalog } from "./use-remote-catalog"; |
| 23 | 24 | import { getEnvironmentClient, useRemoteEnvironmentState } from "./use-remote-environment-registry"; |
| 24 | 25 | import { useThreadSelectionStore } from "./thread-selection-store"; |
| @@ -90,7 +91,7 @@ export function useProjectActions() { · 65 unmodified lines | ||
| 90 | 91 | return null; |
| 91 | 92 | } |
| 92 | 93 | |
| 93 | − const threadId = ThreadId.makeUnsafe(newClientId("thread")); | |
| 94 | + const threadId = ThreadId.makeUnsafe(uuidv4()); | |
| 94 | 95 | const createdAt = new Date().toISOString(); |
| 95 | 96 | const initialMessageText = input.initialMessageText.trim(); |
| 96 | 97 | const nextTitle = deriveThreadTitleFromPrompt(input.initialMessageText); |
| @@ -102,10 +103,10 @@ export function useProjectActions() { · 5 unmodified lines | ||
| 102 | 103 | |
| 103 | 104 | await client.orchestration.dispatchCommand({ |
| 104 | 105 | type: "thread.turn.start", |
| 105 | − commandId: CommandId.makeUnsafe(newClientId("command")), | |
| 106 | + commandId: CommandId.makeUnsafe(uuidv4()), | |
| 106 | 107 | threadId, |
| 107 | 108 | message: { |
| 108 | − messageId: MessageId.makeUnsafe(newClientId("message")), | |
| 109 | + messageId: MessageId.makeUnsafe(uuidv4()), | |
| 109 | 110 | role: "user", |
| 110 | 111 | text: initialMessageText, |
| 111 | 112 | attachments: input.initialAttachments, |
| @@ -137,7 +138,7 @@ export function useProjectActions() { · 25 unmodified lines | ||
| 137 | 138 | } else { |
| 138 | 139 | await client.orchestration.dispatchCommand({ |
| 139 | 140 | type: "thread.create", |
| 140 | − commandId: CommandId.makeUnsafe(newClientId("command")), | |
| 141 | + commandId: CommandId.makeUnsafe(uuidv4()), | |
| 141 | 142 | threadId, |
| 142 | 143 | projectId: input.project.id, |
| 143 | 144 | title: nextTitle, |
| @@ -152,10 +153,10 @@ export function useProjectActions() { · 8 unmodified lines | ||
| 152 | 153 | if (initialMessageText.length > 0 || input.initialAttachments.length > 0) { |
| 153 | 154 | await client.orchestration.dispatchCommand({ |
| 154 | 155 | type: "thread.turn.start", |
| 155 | − commandId: CommandId.makeUnsafe(newClientId("command")), | |
| 156 | + commandId: CommandId.makeUnsafe(uuidv4()), | |
| 156 | 157 | threadId, |
| 157 | 158 | message: { |
| 158 | − messageId: MessageId.makeUnsafe(newClientId("message")), | |
| 159 | + messageId: MessageId.makeUnsafe(uuidv4()), | |
| 159 | 160 | role: "user", |
| 160 | 161 | text: initialMessageText, |
| 161 | 162 | attachments: input.initialAttachments, |
| @@ -217,11 +218,12 @@ export function useProjectActions() { · 55 unmodified lines | ||
| 217 | 218 | } |
| 218 | 219 | |
| 219 | 220 | 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); | |
| 225 | 227 | } catch (error) { |
| 226 | 228 | setPendingConnectionError( |
| 227 | 229 | error instanceof Error ? error.message : "Failed to load branches.", |
| @@ -255,6 +257,11 @@ export function useProjectActions() { · 27 unmodified lines | ||
| 255 | 257 | newBranch: sanitizeFeatureBranchName(nextWorktree.newBranch), |
| 256 | 258 | path: null, |
| 257 | 259 | }); |
| 260 | + gitBranchManager.invalidate({ | |
| 261 | + environmentId: project.environmentId, | |
| 262 | + cwd: project.workspaceRoot, | |
| 263 | + query: null, | |
| 264 | + }); | |
| 258 | 265 | return { |
| 259 | 266 | branch: result.worktree.branch, |
| 260 | 267 | worktreePath: result.worktree.path, |
| @@ -1,8 +1,8 @@ | ||
| 1 | 1 | import { useMemo } from "react"; |
| 2 | 2 | |
| 3 | 3 | 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"; | |
| 6 | 6 | import type { ScopedMobileProject, ScopedMobileThread } from "../lib/scopedEntities"; |
| 7 | 7 | import { |
| 8 | 8 | deriveOverallConnectionState, |
| @@ -13,13 +13,26 @@ import { · 4 unmodified lines | ||
| 13 | 13 | useRemoteEnvironmentState, |
| 14 | 14 | } from "./use-remote-environment-registry"; |
| 15 | 15 | |
| 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 | + | |
| 16 | 29 | export function useRemoteCatalog() { |
| 17 | 30 | const { connectedEnvironments, connectionState } = useRemoteConnectionStatus(); |
| 18 | 31 | const { environmentStateById, savedConnectionsById } = useRemoteEnvironmentState(); |
| 19 | 32 | |
| 20 | 33 | const projects = useMemo<ReadonlyArray<ScopedMobileProject>>( |
| 21 | 34 | () => |
| 22 | − sortCopy( | |
| 35 | + Arr.sort( | |
| 23 | 36 | Object.values(savedConnectionsById).flatMap((connection) => |
| 24 | 37 | (environmentStateById[connection.environmentId]?.snapshot?.projects ?? []) |
| 25 | 38 | .filter((project) => project.deletedAt === null) |
| @@ -30,16 +43,14 @@ export function useRemoteCatalog() { · 4 unmodified lines | ||
| 30 | 43 | }), |
| 31 | 44 | ), |
| 32 | 45 | ), |
| 33 | − (left, right) => | |
| 34 | − left.title.localeCompare(right.title) || | |
| 35 | − left.environmentLabel.localeCompare(right.environmentLabel), | |
| 46 | + projectsSortOrder, | |
| 36 | 47 | ), |
| 37 | 48 | [environmentStateById, savedConnectionsById], |
| 38 | 49 | ); |
| 39 | 50 | |
| 40 | 51 | const threads = useMemo<ReadonlyArray<ScopedMobileThread>>( |
| 41 | 52 | () => |
| 42 | − sortCopy( | |
| 53 | + Arr.sort( | |
| 43 | 54 | Object.values(savedConnectionsById).flatMap((connection) => |
| 44 | 55 | (environmentStateById[connection.environmentId]?.snapshot?.threads ?? []) |
| 45 | 56 | .filter((thread) => thread.deletedAt === null) |
| @@ -50,10 +61,7 @@ export function useRemoteCatalog() { · 4 unmodified lines | ||
| 50 | 61 | }), |
| 51 | 62 | ), |
| 52 | 63 | ), |
| 53 | − (left, right) => | |
| 54 | − new Date(right.updatedAt ?? right.createdAt).getTime() - | |
| 55 | − new Date(left.updatedAt ?? left.createdAt).getTime() || | |
| 56 | − right.environmentLabel.localeCompare(left.environmentLabel), | |
| 64 | + threadsSortOrder, | |
| 57 | 65 | ), |
| 58 | 66 | [environmentStateById, savedConnectionsById], |
| 59 | 67 | ); |
| @@ -10,8 +10,8 @@ import { · 9 unmodified lines | ||
| 10 | 10 | import { EnvironmentId, type OrchestrationReadModel } from "@t3tools/contracts"; |
| 11 | 11 | import { resolveRemoteWebSocketConnectionUrl } from "@t3tools/shared/remote"; |
| 12 | 12 | 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"; | |
| 15 | 15 | import { type SavedRemoteConnection, bootstrapRemoteConnection } from "../lib/connection"; |
| 16 | 16 | import { applyRealtimeEvent, requiresSnapshotRefresh } from "../lib/orchestration"; |
| 17 | 17 | import { clearSavedConnection, loadSavedConnections, saveConnection } from "../lib/storage"; |
| @@ -25,6 +25,22 @@ import { remoteEnvironmentStore, useRemoteEnvironmentStore } from "./remote-envi · 7 unmodified lines | ||
| 25 | 25 | import { useThreadSelectionStore } from "./thread-selection-store"; |
| 26 | 26 | |
| 27 | 27 | 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 | +} | |
| 28 | 44 | |
| 29 | 45 | function setEnvironmentConnectionStatus( |
| 30 | 46 | environmentId: string, |
| @@ -48,6 +64,7 @@ export async function disconnectEnvironment( · 17 unmodified lines | ||
| 48 | 64 | ) { |
| 49 | 65 | const session = environmentSessions.get(environmentId); |
| 50 | 66 | environmentSessions.delete(environmentId); |
| 67 | + notifyEnvironmentConnectionListeners(); | |
| 51 | 68 | await session?.connection.dispose(); |
| 52 | 69 | remoteEnvironmentStore.getState().removeEnvironmentRuntimeState(environmentId); |
| 53 | 70 | |
| @@ -176,6 +193,7 @@ export async function connectSavedEnvironment( · 122 unmodified lines | ||
| 176 | 193 | client, |
| 177 | 194 | connection: environmentConnection, |
| 178 | 195 | }); |
| 196 | + notifyEnvironmentConnectionListeners(); | |
| 179 | 197 | |
| 180 | 198 | try { |
| 181 | 199 | await environmentConnection.ensureBootstrapped(); |
| @@ -188,11 +206,15 @@ export async function connectSavedEnvironment( · 6 unmodified lines | ||
| 188 | 206 | } |
| 189 | 207 | } |
| 190 | 208 | |
| 209 | +const environmentsSortOrder = Order.make<ConnectedEnvironmentSummary>( | |
| 210 | + (left, right) => left.environmentLabel.localeCompare(right.environmentLabel) as -1 | 0 | 1, | |
| 211 | +); | |
| 212 | + | |
| 191 | 213 | function deriveConnectedEnvironments( |
| 192 | 214 | savedConnectionsById: Record<string, SavedRemoteConnection>, |
| 193 | 215 | environmentStateById: ReturnType<typeof remoteEnvironmentStore.getState>["environmentStateById"], |
| 194 | 216 | ): ReadonlyArray<ConnectedEnvironmentSummary> { |
| 195 | − return sortCopy( | |
| 217 | + return Arr.sort( | |
| 196 | 218 | Object.values(savedConnectionsById).map((connection) => { |
| 197 | 219 | const runtime = environmentStateById[connection.environmentId]; |
| 198 | 220 | return { |
| @@ -203,7 +225,7 @@ function deriveConnectedEnvironments( · 4 unmodified lines | ||
| 203 | 225 | connectionError: runtime?.connectionError ?? null, |
| 204 | 226 | }; |
| 205 | 227 | }), |
| 206 | − (left, right) => left.environmentLabel.localeCompare(right.environmentLabel), | |
| 228 | + environmentsSortOrder, | |
| 207 | 229 | ); |
| 208 | 230 | } |
| 209 | 231 | |
| @@ -250,6 +272,7 @@ export function useRemoteEnvironmentBootstrap() { · 40 unmodified lines | ||
| 250 | 272 | void session.connection.dispose(); |
| 251 | 273 | } |
| 252 | 274 | environmentSessions.clear(); |
| 275 | + notifyEnvironmentConnectionListeners(); | |
| 253 | 276 | }; |
| 254 | 277 | }, [replaceSavedConnections, setIsLoadingSavedConnection]); |
| 255 | 278 | } |
| @@ -9,7 +9,7 @@ import { · 8 unmodified lines | ||
| 9 | 9 | type RuntimeMode, |
| 10 | 10 | } from "@t3tools/contracts"; |
| 11 | 11 | |
| 12 | −import { newClientId } from "../lib/clientId"; | |
| 12 | +import { uuidv4 } from "../lib/uuid"; | |
| 13 | 13 | import { useThreadSelection } from "./use-thread-selection"; |
| 14 | 14 | import { useRemoteEnvironmentStore } from "./remote-environment-store"; |
| 15 | 15 | import { getEnvironmentClient, useRemoteEnvironmentState } from "./use-remote-environment-registry"; |
| @@ -102,7 +102,7 @@ export function useSelectedThreadCommands(input: { · 86 unmodified lines | ||
| 102 | 102 | |
| 103 | 103 | await client.orchestration.dispatchCommand({ |
| 104 | 104 | type: "thread.meta.update", |
| 105 | − commandId: CommandId.makeUnsafe(newClientId("command")), | |
| 105 | + commandId: CommandId.makeUnsafe(uuidv4()), | |
| 106 | 106 | threadId: selectedThread.id, |
| 107 | 107 | modelSelection, |
| 108 | 108 | }); |
| @@ -123,7 +123,7 @@ export function useSelectedThreadCommands(input: { · 14 unmodified lines | ||
| 123 | 123 | |
| 124 | 124 | await client.orchestration.dispatchCommand({ |
| 125 | 125 | type: "thread.runtime-mode.set", |
| 126 | − commandId: CommandId.makeUnsafe(newClientId("command")), | |
| 126 | + commandId: CommandId.makeUnsafe(uuidv4()), | |
| 127 | 127 | threadId: selectedThread.id, |
| 128 | 128 | runtimeMode, |
| 129 | 129 | createdAt: new Date().toISOString(), |
| @@ -145,7 +145,7 @@ export function useSelectedThreadCommands(input: { · 15 unmodified lines | ||
| 145 | 145 | |
| 146 | 146 | await client.orchestration.dispatchCommand({ |
| 147 | 147 | type: "thread.interaction-mode.set", |
| 148 | − commandId: CommandId.makeUnsafe(newClientId("command")), | |
| 148 | + commandId: CommandId.makeUnsafe(uuidv4()), | |
| 149 | 149 | threadId: selectedThread.id, |
| 150 | 150 | interactionMode, |
| 151 | 151 | createdAt: new Date().toISOString(), |
| @@ -173,7 +173,7 @@ export function useSelectedThreadCommands(input: { · 21 unmodified lines | ||
| 173 | 173 | |
| 174 | 174 | await client.orchestration.dispatchCommand({ |
| 175 | 175 | type: "thread.turn.interrupt", |
| 176 | − commandId: CommandId.makeUnsafe(newClientId("command")), | |
| 176 | + commandId: CommandId.makeUnsafe(uuidv4()), | |
| 177 | 177 | threadId: selectedThread.id, |
| 178 | 178 | ...(selectedThread.session?.activeTurnId |
| 179 | 179 | ? { turnId: selectedThread.session.activeTurnId } |
| @@ -200,7 +200,7 @@ export function useSelectedThreadCommands(input: { · 20 unmodified lines | ||
| 200 | 200 | |
| 201 | 201 | await client.orchestration.dispatchCommand({ |
| 202 | 202 | type: "thread.meta.update", |
| 203 | − commandId: CommandId.makeUnsafe(newClientId("command")), | |
| 203 | + commandId: CommandId.makeUnsafe(uuidv4()), | |
| 204 | 204 | threadId: selectedThread.id, |
| 205 | 205 | title: trimmed, |
| 206 | 206 | }); |
| @@ -223,7 +223,7 @@ export function useSelectedThreadCommands(input: { · 16 unmodified lines | ||
| 223 | 223 | try { |
| 224 | 224 | await client.orchestration.dispatchCommand({ |
| 225 | 225 | type: "thread.approval.respond", |
| 226 | − commandId: CommandId.makeUnsafe(newClientId("command")), | |
| 226 | + commandId: CommandId.makeUnsafe(uuidv4()), | |
| 227 | 227 | threadId: selectedThread.id, |
| 228 | 228 | requestId, |
| 229 | 229 | decision, |
| @@ -250,7 +250,7 @@ export function useSelectedThreadCommands(input: { · 20 unmodified lines | ||
| 250 | 250 | try { |
| 251 | 251 | await client.orchestration.dispatchCommand({ |
| 252 | 252 | type: "thread.user-input.respond", |
| 253 | − commandId: CommandId.makeUnsafe(newClientId("command")), | |
| 253 | + commandId: CommandId.makeUnsafe(uuidv4()), | |
| 254 | 254 | threadId: selectedThread.id, |
| 255 | 255 | requestId: activePendingUserInput.requestId, |
| 256 | 256 | answers: activePendingUserInputAnswers, |
| @@ -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 | +} | |
| @@ -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 | +} | |
| @@ -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 | −} | |
| @@ -18,7 +18,7 @@ import { · 17 unmodified lines | ||
| 18 | 18 | derivePendingUserInputs, |
| 19 | 19 | type QueuedThreadMessage, |
| 20 | 20 | } from "../lib/threadActivity"; |
| 21 | −import { newClientId } from "../lib/clientId"; | |
| 21 | +import { uuidv4 } from "../lib/uuid"; | |
| 22 | 22 | import type { ConnectedEnvironmentSummary } from "./remote-runtime-types"; |
| 23 | 23 | import { useRemoteEnvironmentStore } from "./remote-environment-store"; |
| 24 | 24 | import { getEnvironmentClient, useRemoteConnectionStatus } from "./use-remote-environment-registry"; |
| @@ -238,7 +238,7 @@ export function useThreadComposerState() { · 213 unmodified lines | ||
| 238 | 238 | return; |
| 239 | 239 | } |
| 240 | 240 | |
| 241 | − beginDispatchingQueuedMessage(queuedMessage.id); | |
| 241 | + beginDispatchingQueuedMessage(queuedMessage.messageId); | |
| 242 | 242 | try { |
| 243 | 243 | await client.orchestration.dispatchCommand({ |
| 244 | 244 | type: "thread.turn.start", |
| @@ -255,7 +255,11 @@ export function useThreadComposerState() { · 10 unmodified lines | ||
| 255 | 255 | createdAt: queuedMessage.createdAt, |
| 256 | 256 | }); |
| 257 | 257 | |
| 258 | − removeQueuedMessage(queuedMessage.environmentId, queuedMessage.threadId, queuedMessage.id); | |
| 258 | + removeQueuedMessage( | |
| 259 | + queuedMessage.environmentId, | |
| 260 | + queuedMessage.threadId, | |
| 261 | + queuedMessage.messageId, | |
| 262 | + ); | |
| 259 | 263 | patchEnvironmentRuntimeState(queuedMessage.environmentId, (runtime) => ({ |
| 260 | 264 | ...runtime, |
| 261 | 265 | snapshot: runtime.snapshot |
| @@ -269,12 +273,16 @@ export function useThreadComposerState() { · 7 unmodified lines | ||
| 269 | 273 | : runtime.snapshot, |
| 270 | 274 | })); |
| 271 | 275 | } catch (error) { |
| 272 | − removeQueuedMessage(queuedMessage.environmentId, queuedMessage.threadId, queuedMessage.id); | |
| 276 | + removeQueuedMessage( | |
| 277 | + queuedMessage.environmentId, | |
| 278 | + queuedMessage.threadId, | |
| 279 | + queuedMessage.messageId, | |
| 280 | + ); | |
| 273 | 281 | setPendingConnectionError( |
| 274 | 282 | error instanceof Error ? error.message : "Failed to send message.", |
| 275 | 283 | ); |
| 276 | 284 | } finally { |
| 277 | − finishDispatchingQueuedMessage(queuedMessage.id); | |
| 285 | + finishDispatchingQueuedMessage(queuedMessage.messageId); | |
| 278 | 286 | } |
| 279 | 287 | }, |
| 280 | 288 | [ |
| @@ -309,11 +317,10 @@ export function useThreadComposerState() { · 28 unmodified lines | ||
| 309 | 317 | |
| 310 | 318 | const createdAt = new Date().toISOString(); |
| 311 | 319 | enqueueQueuedMessage({ |
| 312 | − id: newClientId("queued-message"), | |
| 313 | 320 | environmentId: selectedThread.environmentId, |
| 314 | 321 | threadId: selectedThread.id, |
| 315 | − messageId: newClientId("message"), | |
| 316 | − commandId: newClientId("command"), | |
| 322 | + messageId: MessageId.makeUnsafe(uuidv4()), | |
| 323 | + commandId: CommandId.makeUnsafe(uuidv4()), | |
| 317 | 324 | text, |
| 318 | 325 | attachments, |
| 319 | 326 | createdAt, |
| @@ -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 {} | |
| @@ -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 | −}); | |
| @@ -1,11 +1,15 @@ | ||
| 1 | 1 | import { useAtomValue } from "@effect/atom-react"; |
| 2 | 2 | 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"; | |
| 9 | 13 | import { useEffect } from "react"; |
| 10 | 14 | |
| 11 | 15 | import { appAtomRegistry } from "../rpc/atomRegistry"; |
| @@ -13,159 +17,38 @@ import { · 1 unmodified line | ||
| 13 | 17 | readEnvironmentConnection, |
| 14 | 18 | subscribeEnvironmentConnections, |
| 15 | 19 | } from "../environments/runtime"; |
| 16 | −import type { WsRpcClient } from "~/rpc/wsRpcClient"; | |
| 17 | 20 | |
| 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, | |
| 70 | 36 | }); |
| 71 | 37 | |
| 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) ────────────────────── */ | |
| 132 | 39 | |
| 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); | |
| 149 | 42 | } |
| 150 | 43 | |
| 151 | 44 | 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(); | |
| 163 | 46 | } |
| 164 | 47 | |
| 165 | 48 | export function useGitStatus(target: GitStatusTarget): GitStatusState { |
| 166 | 49 | const targetKey = getGitStatusTargetKey(target); |
| 167 | 50 | useEffect( |
| 168 | − () => watchGitStatus({ environmentId: target.environmentId, cwd: target.cwd }), | |
| 51 | + () => manager.watch({ environmentId: target.environmentId, cwd: target.cwd }), | |
| 169 | 52 | [target.environmentId, target.cwd], |
| 170 | 53 | ); |
| 171 | 54 | |
| @@ -174,114 +57,3 @@ export function useGitStatus(target: GitStatusTarget): GitStatusState { · 2 unmodified lines | ||
| 174 | 57 | ); |
| 175 | 58 | return targetKey === null ? EMPTY_GIT_STATUS_STATE : state; |
| 176 | 59 | } |
| 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 | −} | |
| @@ -45,6 +45,7 @@ · 44 unmodified lines | ||
| 45 | 45 | "version": "0.0.0", |
| 46 | 46 | "dependencies": { |
| 47 | 47 | "@callstack/liquid-glass": "^0.7.1", |
| 48 | + "@effect/atom-react": "catalog:", | |
| 48 | 49 | "@expo-google-fonts/dm-sans": "^0.4.2", |
| 49 | 50 | "@gorhom/bottom-sheet": "^5", |
| 50 | 51 | "@legendapp/list": "^3.0.0-beta.44", |
| @@ -54,6 +55,7 @@ · 3 unmodified lines | ||
| 54 | 55 | "@t3tools/client-runtime": "workspace:*", |
| 55 | 56 | "@t3tools/contracts": "workspace:*", |
| 56 | 57 | "@t3tools/shared": "workspace:*", |
| 58 | + "clsx": "^2.1.1", | |
| 57 | 59 | "effect": "catalog:", |
| 58 | 60 | "expo": "~55.0.14", |
| 59 | 61 | "expo-camera": "~55.0.15", |
| @@ -73,12 +75,10 @@ · 13 unmodified lines | ||
| 73 | 75 | "expo-splash-screen": "~55.0.13", |
| 74 | 76 | "expo-symbols": "~55.0.5", |
| 75 | 77 | "expo-updates": "~55.0.16", |
| 76 | − "nativewind": "5.0.0-preview.3", | |
| 77 | 78 | "punycode": "^2.3.1", |
| 78 | 79 | "react": "19.2.0", |
| 79 | 80 | "react-dom": "19.2.0", |
| 80 | 81 | "react-native": "0.83.4", |
| 81 | − "react-native-css": "3.0.7", | |
| 82 | 82 | "react-native-gesture-handler": "~2.30.0", |
| 83 | 83 | "react-native-image-viewing": "^0.2.2", |
| 84 | 84 | "react-native-keyboard-controller": "1.20.7", |
| @@ -88,14 +88,13 @@ · 3 unmodified lines | ||
| 88 | 88 | "react-native-screens": "~4.23.0", |
| 89 | 89 | "react-native-svg": "15.15.3", |
| 90 | 90 | "react-native-worklets": "0.7.2", |
| 91 | + "tailwind-merge": "^3.5.0", | |
| 92 | + "uniwind": "^1.6.2", | |
| 91 | 93 | "zustand": "^5.0.12", |
| 92 | 94 | }, |
| 93 | 95 | "devDependencies": { |
| 94 | − "@tailwindcss/postcss": "4.1.12", | |
| 95 | 96 | "@types/react": "~19.2.10", |
| 96 | 97 | "babel-preset-expo": "~55.0.8", |
| 97 | − "lightningcss": "1.30.1", | |
| 98 | − "postcss": "^8.5.8", | |
| 99 | 98 | "tailwindcss": "4.1.12", |
| 100 | 99 | "typescript": "~5.9.2", |
| 101 | 100 | }, |
| @@ -272,8 +271,6 @@ · 170 unmodified lines | ||
| 272 | 271 | "vitest": "^4.0.0", |
| 273 | 272 | }, |
| 274 | 273 | "packages": { |
| 275 | − "@alloc/quick-lru": ["@alloc/quick-lru@5.2.0", "", {}, "sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw=="], | |
| 276 | − | |
| 277 | 274 | "@anthropic-ai/claude-agent-sdk": ["@anthropic-ai/claude-agent-sdk@0.2.104", "", { "dependencies": { "@anthropic-ai/sdk": "^0.81.0", "@modelcontextprotocol/sdk": "^1.29.0" }, "optionalDependencies": { "@img/sharp-darwin-arm64": "^0.34.2", "@img/sharp-darwin-x64": "^0.34.2", "@img/sharp-linux-arm": "^0.34.2", "@img/sharp-linux-arm64": "^0.34.2", "@img/sharp-linux-x64": "^0.34.2", "@img/sharp-linuxmusl-arm64": "^0.34.2", "@img/sharp-linuxmusl-x64": "^0.34.2", "@img/sharp-win32-arm64": "^0.34.2", "@img/sharp-win32-x64": "^0.34.2" }, "peerDependencies": { "zod": "^4.0.0" } }, "sha512-lVm+nS79r6WWlDnv5AgRzTtAlbP8O6M6kkWmDZAWE3nt9agmngxls9frJFvH55uzws2+6l0yyup/JYspfijkzw=="], |
| 278 | 275 | |
| 279 | 276 | "@anthropic-ai/sdk": ["@anthropic-ai/sdk@0.81.0", "", { "dependencies": { "json-schema-to-ts": "^3.1.1" }, "peerDependencies": { "zod": "^3.25.0 || ^4.0.0" }, "optionalPeers": ["zod"], "bin": { "anthropic-ai-sdk": "bin/cli" } }, "sha512-D4K5PvEV6wPiRtVlVsJHIUhHAmOZ6IT/I9rKlTf84gR7GyyAurPJK7z9BOf/AZqC5d1DhYQGJNKRmV+q8dGhgw=="], |
| @@ -738,8 +735,6 @@ · 458 unmodified lines | ||
| 738 | 735 | |
| 739 | 736 | "@ioredis/commands": ["@ioredis/commands@1.5.1", "", {}, "sha512-JH8ZL/ywcJyR9MmJ5BNqZllXNZQqQbnVZOqpPQqE1vHiFgAw4NHbvE0FOduNU8IX9babitBT46571OnPTT0Zcw=="], |
| 740 | 737 | |
| 741 | − "@isaacs/fs-minipass": ["@isaacs/fs-minipass@4.0.1", "", { "dependencies": { "minipass": "^7.0.4" } }, "sha512-wgm9Ehl2jpeqP3zw/7mo3kRHFp5MEDhqAdwy1fTGkHAwnkGOVsgpvQhL8B5n1qlb01jV3n/bI0ZfZp5lWA1k4w=="], | |
| 742 | − | |
| 743 | 738 | "@isaacs/ttlcache": ["@isaacs/ttlcache@1.4.1", "", {}, "sha512-RQgQ4uQ+pLbqXfOmieB91ejmLwvSgv9nLx6sT6sD83s7umBypgg+OIBOBbEUiJXrfpnp9j0mRhYYdzp9uqq3lA=="], |
| 744 | 739 | |
| 745 | 740 | "@istanbuljs/load-nyc-config": ["@istanbuljs/load-nyc-config@1.1.0", "", { "dependencies": { "camelcase": "^5.3.1", "find-up": "^4.1.0", "get-package-type": "^0.1.0", "js-yaml": "^3.13.1", "resolve-from": "^5.0.0" } }, "sha512-VjeHSlIzpv/NyD3N0YuHfXOPDIixcA1q2ZV98wsMqcYlPmv2n3Yb2lYP9XMElnaFVXg5A7YLTeLu6V84uQDjmQ=="], |
| @@ -1096,35 +1091,33 @@ · 350 unmodified lines | ||
| 1096 | 1091 | |
| 1097 | 1092 | "@t3tools/web": ["@t3tools/web@workspace:apps/web"], |
| 1098 | 1093 | |
| 1099 | − "@tailwindcss/node": ["@tailwindcss/node@4.1.12", "", { "dependencies": { "@jridgewell/remapping": "^2.3.4", "enhanced-resolve": "^5.18.3", "jiti": "^2.5.1", "lightningcss": "1.30.1", "magic-string": "^0.30.17", "source-map-js": "^1.2.1", "tailwindcss": "4.1.12" } }, "sha512-3hm9brwvQkZFe++SBt+oLjo4OLDtkvlE8q2WalaD/7QWaeM7KEJbAiY/LJZUaCs7Xa8aUu4xy3uoyX4q54UVdQ=="], | |
| 1094 | + "@tailwindcss/node": ["@tailwindcss/node@4.2.1", "", { "dependencies": { "@jridgewell/remapping": "^2.3.5", "enhanced-resolve": "^5.19.0", "jiti": "^2.6.1", "lightningcss": "1.31.1", "magic-string": "^0.30.21", "source-map-js": "^1.2.1", "tailwindcss": "4.2.1" } }, "sha512-jlx6sLk4EOwO6hHe1oCGm1Q4AN/s0rSrTTPBGPM0/RQ6Uylwq17FuU8IeJJKEjtc6K6O07zsvP+gDO6MMWo7pg=="], | |
| 1100 | 1095 | |
| 1101 | − "@tailwindcss/oxide": ["@tailwindcss/oxide@4.1.12", "", { "dependencies": { "detect-libc": "^2.0.4", "tar": "^7.4.3" }, "optionalDependencies": { "@tailwindcss/oxide-android-arm64": "4.1.12", "@tailwindcss/oxide-darwin-arm64": "4.1.12", "@tailwindcss/oxide-darwin-x64": "4.1.12", "@tailwindcss/oxide-freebsd-x64": "4.1.12", "@tailwindcss/oxide-linux-arm-gnueabihf": "4.1.12", "@tailwindcss/oxide-linux-arm64-gnu": "4.1.12", "@tailwindcss/oxide-linux-arm64-musl": "4.1.12", "@tailwindcss/oxide-linux-x64-gnu": "4.1.12", "@tailwindcss/oxide-linux-x64-musl": "4.1.12", "@tailwindcss/oxide-wasm32-wasi": "4.1.12", "@tailwindcss/oxide-win32-arm64-msvc": "4.1.12", "@tailwindcss/oxide-win32-x64-msvc": "4.1.12" } }, "sha512-gM5EoKHW/ukmlEtphNwaGx45fGoEmP10v51t9unv55voWh6WrOL19hfuIdo2FjxIaZzw776/BUQg7Pck++cIVw=="], | |
| 1096 | + "@tailwindcss/oxide": ["@tailwindcss/oxide@4.2.1", "", { "optionalDependencies": { "@tailwindcss/oxide-android-arm64": "4.2.1", "@tailwindcss/oxide-darwin-arm64": "4.2.1", "@tailwindcss/oxide-darwin-x64": "4.2.1", "@tailwindcss/oxide-freebsd-x64": "4.2.1", "@tailwindcss/oxide-linux-arm-gnueabihf": "4.2.1", "@tailwindcss/oxide-linux-arm64-gnu": "4.2.1", "@tailwindcss/oxide-linux-arm64-musl": "4.2.1", "@tailwindcss/oxide-linux-x64-gnu": "4.2.1", "@tailwindcss/oxide-linux-x64-musl": "4.2.1", "@tailwindcss/oxide-wasm32-wasi": "4.2.1", "@tailwindcss/oxide-win32-arm64-msvc": "4.2.1", "@tailwindcss/oxide-win32-x64-msvc": "4.2.1" } }, "sha512-yv9jeEFWnjKCI6/T3Oq50yQEOqmpmpfzG1hcZsAOaXFQPfzWprWrlHSdGPEF3WQTi8zu8ohC9Mh9J470nT5pUw=="], | |
| 1102 | 1097 | |
| 1103 | − "@tailwindcss/oxide-android-arm64": ["@tailwindcss/oxide-android-arm64@4.1.12", "", { "os": "android", "cpu": "arm64" }, "sha512-oNY5pq+1gc4T6QVTsZKwZaGpBb2N1H1fsc1GD4o7yinFySqIuRZ2E4NvGasWc6PhYJwGK2+5YT1f9Tp80zUQZQ=="], | |
| 1098 | + "@tailwindcss/oxide-android-arm64": ["@tailwindcss/oxide-android-arm64@4.2.1", "", { "os": "android", "cpu": "arm64" }, "sha512-eZ7G1Zm5EC8OOKaesIKuw77jw++QJ2lL9N+dDpdQiAB/c/B2wDh0QPFHbkBVrXnwNugvrbJFk1gK2SsVjwWReg=="], | |
| 1104 | 1099 | |
| 1105 | − "@tailwindcss/oxide-darwin-arm64": ["@tailwindcss/oxide-darwin-arm64@4.1.12", "", { "os": "darwin", "cpu": "arm64" }, "sha512-cq1qmq2HEtDV9HvZlTtrj671mCdGB93bVY6J29mwCyaMYCP/JaUBXxrQQQm7Qn33AXXASPUb2HFZlWiiHWFytw=="], | |
| 1100 | + "@tailwindcss/oxide-darwin-arm64": ["@tailwindcss/oxide-darwin-arm64@4.2.1", "", { "os": "darwin", "cpu": "arm64" }, "sha512-q/LHkOstoJ7pI1J0q6djesLzRvQSIfEto148ppAd+BVQK0JYjQIFSK3JgYZJa+Yzi0DDa52ZsQx2rqytBnf8Hw=="], | |
| 1106 | 1101 | |
| 1107 | − "@tailwindcss/oxide-darwin-x64": ["@tailwindcss/oxide-darwin-x64@4.1.12", "", { "os": "darwin", "cpu": "x64" }, "sha512-6UCsIeFUcBfpangqlXay9Ffty9XhFH1QuUFn0WV83W8lGdX8cD5/+2ONLluALJD5+yJ7k8mVtwy3zMZmzEfbLg=="], | |
| 1102 | + "@tailwindcss/oxide-darwin-x64": ["@tailwindcss/oxide-darwin-x64@4.2.1", "", { "os": "darwin", "cpu": "x64" }, "sha512-/f/ozlaXGY6QLbpvd/kFTro2l18f7dHKpB+ieXz+Cijl4Mt9AI2rTrpq7V+t04nK+j9XBQHnSMdeQRhbGyt6fw=="], | |
| 1108 | 1103 | |
| 1109 | − "@tailwindcss/oxide-freebsd-x64": ["@tailwindcss/oxide-freebsd-x64@4.1.12", "", { "os": "freebsd", "cpu": "x64" }, "sha512-JOH/f7j6+nYXIrHobRYCtoArJdMJh5zy5lr0FV0Qu47MID/vqJAY3r/OElPzx1C/wdT1uS7cPq+xdYYelny1ww=="], | |
| 1104 | + "@tailwindcss/oxide-freebsd-x64": ["@tailwindcss/oxide-freebsd-x64@4.2.1", "", { "os": "freebsd", "cpu": "x64" }, "sha512-5e/AkgYJT/cpbkys/OU2Ei2jdETCLlifwm7ogMC7/hksI2fC3iiq6OcXwjibcIjPung0kRtR3TxEITkqgn0TcA=="], | |
| 1110 | 1105 | |
| 1111 | − "@tailwindcss/oxide-linux-arm-gnueabihf": ["@tailwindcss/oxide-linux-arm-gnueabihf@4.1.12", "", { "os": "linux", "cpu": "arm" }, "sha512-v4Ghvi9AU1SYgGr3/j38PD8PEe6bRfTnNSUE3YCMIRrrNigCFtHZ2TCm8142X8fcSqHBZBceDx+JlFJEfNg5zQ=="], | |
| 1106 | + "@tailwindcss/oxide-linux-arm-gnueabihf": ["@tailwindcss/oxide-linux-arm-gnueabihf@4.2.1", "", { "os": "linux", "cpu": "arm" }, "sha512-Uny1EcVTTmerCKt/1ZuKTkb0x8ZaiuYucg2/kImO5A5Y/kBz41/+j0gxUZl+hTF3xkWpDmHX+TaWhOtba2Fyuw=="], | |
| 1112 | 1107 | |
| 1113 | − "@tailwindcss/oxide-linux-arm64-gnu": ["@tailwindcss/oxide-linux-arm64-gnu@4.1.12", "", { "os": "linux", "cpu": "arm64" }, "sha512-YP5s1LmetL9UsvVAKusHSyPlzSRqYyRB0f+Kl/xcYQSPLEw/BvGfxzbH+ihUciePDjiXwHh+p+qbSP3SlJw+6g=="], | |
| 1108 | + "@tailwindcss/oxide-linux-arm64-gnu": ["@tailwindcss/oxide-linux-arm64-gnu@4.2.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-CTrwomI+c7n6aSSQlsPL0roRiNMDQ/YzMD9EjcR+H4f0I1SQ8QqIuPnsVp7QgMkC1Qi8rtkekLkOFjo7OlEFRQ=="], | |
| 1114 | 1109 | |
| 1115 | − "@tailwindcss/oxide-linux-arm64-musl": ["@tailwindcss/oxide-linux-arm64-musl@4.1.12", "", { "os": "linux", "cpu": "arm64" }, "sha512-V8pAM3s8gsrXcCv6kCHSuwyb/gPsd863iT+v1PGXC4fSL/OJqsKhfK//v8P+w9ThKIoqNbEnsZqNy+WDnwQqCA=="], | |
| 1110 | + "@tailwindcss/oxide-linux-arm64-musl": ["@tailwindcss/oxide-linux-arm64-musl@4.2.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-WZA0CHRL/SP1TRbA5mp9htsppSEkWuQ4KsSUumYQnyl8ZdT39ntwqmz4IUHGN6p4XdSlYfJwM4rRzZLShHsGAQ=="], | |
| 1116 | 1111 | |
| 1117 | − "@tailwindcss/oxide-linux-x64-gnu": ["@tailwindcss/oxide-linux-x64-gnu@4.1.12", "", { "os": "linux", "cpu": "x64" }, "sha512-xYfqYLjvm2UQ3TZggTGrwxjYaLB62b1Wiysw/YE3Yqbh86sOMoTn0feF98PonP7LtjsWOWcXEbGqDL7zv0uW8Q=="], | |
| 1112 | + "@tailwindcss/oxide-linux-x64-gnu": ["@tailwindcss/oxide-linux-x64-gnu@4.2.1", "", { "os": "linux", "cpu": "x64" }, "sha512-qMFzxI2YlBOLW5PhblzuSWlWfwLHaneBE0xHzLrBgNtqN6mWfs+qYbhryGSXQjFYB1Dzf5w+LN5qbUTPhW7Y5g=="], | |
| 1118 | 1113 | |
| 1119 | − "@tailwindcss/oxide-linux-x64-musl": ["@tailwindcss/oxide-linux-x64-musl@4.1.12", "", { "os": "linux", "cpu": "x64" }, "sha512-ha0pHPamN+fWZY7GCzz5rKunlv9L5R8kdh+YNvP5awe3LtuXb5nRi/H27GeL2U+TdhDOptU7T6Is7mdwh5Ar3A=="], | |
| 1114 | + "@tailwindcss/oxide-linux-x64-musl": ["@tailwindcss/oxide-linux-x64-musl@4.2.1", "", { "os": "linux", "cpu": "x64" }, "sha512-5r1X2FKnCMUPlXTWRYpHdPYUY6a1Ar/t7P24OuiEdEOmms5lyqjDRvVY1yy9Rmioh+AunQ0rWiOTPE8F9A3v5g=="], | |
| 1120 | 1115 | |
| 1121 | − "@tailwindcss/oxide-wasm32-wasi": ["@tailwindcss/oxide-wasm32-wasi@4.1.12", "", { "dependencies": { "@emnapi/core": "^1.4.5", "@emnapi/runtime": "^1.4.5", "@emnapi/wasi-threads": "^1.0.4", "@napi-rs/wasm-runtime": "^0.2.12", "@tybys/wasm-util": "^0.10.0", "tslib": "^2.8.0" }, "cpu": "none" }, "sha512-4tSyu3dW+ktzdEpuk6g49KdEangu3eCYoqPhWNsZgUhyegEda3M9rG0/j1GV/JjVVsj+lG7jWAyrTlLzd/WEBg=="], | |
| 1116 | + "@tailwindcss/oxide-wasm32-wasi": ["@tailwindcss/oxide-wasm32-wasi@4.2.1", "", { "dependencies": { "@emnapi/core": "^1.8.1", "@emnapi/runtime": "^1.8.1", "@emnapi/wasi-threads": "^1.1.0", "@napi-rs/wasm-runtime": "^1.1.1", "@tybys/wasm-util": "^0.10.1", "tslib": "^2.8.1" }, "cpu": "none" }, "sha512-MGFB5cVPvshR85MTJkEvqDUnuNoysrsRxd6vnk1Lf2tbiqNlXpHYZqkqOQalydienEWOHHFyyuTSYRsLfxFJ2Q=="], | |
| 1122 | 1117 | |
| 1123 | − "@tailwindcss/oxide-win32-arm64-msvc": ["@tailwindcss/oxide-win32-arm64-msvc@4.1.12", "", { "os": "win32", "cpu": "arm64" }, "sha512-iGLyD/cVP724+FGtMWslhcFyg4xyYyM+5F4hGvKA7eifPkXHRAUDFaimu53fpNg9X8dfP75pXx/zFt/jlNF+lg=="], | |
| 1118 | + "@tailwindcss/oxide-win32-arm64-msvc": ["@tailwindcss/oxide-win32-arm64-msvc@4.2.1", "", { "os": "win32", "cpu": "arm64" }, "sha512-YlUEHRHBGnCMh4Nj4GnqQyBtsshUPdiNroZj8VPkvTZSoHsilRCwXcVKnG9kyi0ZFAS/3u+qKHBdDc81SADTRA=="], | |
| 1124 | 1119 | |
| 1125 | − "@tailwindcss/oxide-win32-x64-msvc": ["@tailwindcss/oxide-win32-x64-msvc@4.1.12", "", { "os": "win32", "cpu": "x64" }, "sha512-NKIh5rzw6CpEodv/++r0hGLlfgT/gFN+5WNdZtvh6wpU2BpGNgdjvj6H2oFc8nCM839QM1YOhjpgbAONUb4IxA=="], | |
| 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=="], | |
| 1128 | 1121 | |
| 1129 | 1122 | "@tailwindcss/vite": ["@tailwindcss/vite@4.2.2", "", { "dependencies": { "@tailwindcss/node": "4.2.2", "@tailwindcss/oxide": "4.2.2", "tailwindcss": "4.2.2" }, "peerDependencies": { "vite": "^5.2.0 || ^6 || ^7 || ^8" } }, "sha512-mEiF5HO1QqCLXoNEfXVA1Tzo+cYsrqV7w9Juj2wdUFyW07JRenqMG225MvPwr3ZD9N1bFQj46X7r33iHxLUW0w=="], |
| 1130 | 1123 | |
| @@ -1320,8 +1313,6 @@ · 189 unmodified lines | ||
| 1320 | 1313 | |
| 1321 | 1314 | "array-iterate": ["array-iterate@2.0.1", "", {}, "sha512-I1jXZMjAgCMmxT4qxXfPXa6SthSoE8h6gkSI9BGGNv8mP8G/v0blc+qFnZu6K42vTOiuME596QaLO0TP3Lk0xg=="], |
| 1322 | 1315 | |
| 1323 | − "array-timsort": ["array-timsort@1.0.3", "", {}, "sha512-/+3GRL7dDAGEfM6TseQk/U+mi18TU2Ms9I3UlLdUMhz2hbvGNTKdj9xniwXfUqgYhHxRx0+8UnKkvlNwVU+cWQ=="], | |
| 1324 | − | |
| 1325 | 1316 | "asap": ["asap@2.0.6", "", {}, "sha512-BSHWgDSAiKs50o2Re8ppvp3seVHXSRM44cdSsT9FfNEUUZLOGWVCsiWaRPWM1Znn+mqZ1OfVZ3z3DWEzSp7hRA=="], |
| 1326 | 1317 | |
| 1327 | 1318 | "assertion-error": ["assertion-error@2.0.1", "", {}, "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA=="], |
| @@ -1440,8 +1431,6 @@ · 112 unmodified lines | ||
| 1440 | 1431 | |
| 1441 | 1432 | "chokidar": ["chokidar@4.0.3", "", { "dependencies": { "readdirp": "^4.0.1" } }, "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA=="], |
| 1442 | 1433 | |
| 1443 | − "chownr": ["chownr@3.0.0", "", {}, "sha512-+IxzY9BZOQd/XuYPRmrvEVjF/nqj5kgT4kEq7VofrDoM1MxoRjEWkrCC3EtLi59TVawxTAn+orJwFQcrqEN1+g=="], | |
| 1444 | − | |
| 1445 | 1434 | "chrome-launcher": ["chrome-launcher@0.15.2", "", { "dependencies": { "@types/node": "*", "escape-string-regexp": "^4.0.0", "is-wsl": "^2.2.0", "lighthouse-logger": "^1.0.0" }, "bin": { "print-chrome-path": "bin/print-chrome-path.js" } }, "sha512-zdLEwNo3aUVzIhKhTtXfxhdvZhUghrnmkvcAq2NoDd+LeOHKf03H5jwZ8T/STsAlzyALkBVK552iaG1fGf1xVQ=="], |
| 1446 | 1435 | |
| 1447 | 1436 | "chromium-edge-launcher": ["chromium-edge-launcher@0.2.0", "", { "dependencies": { "@types/node": "*", "escape-string-regexp": "^4.0.0", "is-wsl": "^2.2.0", "lighthouse-logger": "^1.0.0", "mkdirp": "^1.0.4", "rimraf": "^3.0.2" } }, "sha512-JfJjUnq25y9yg4FABRRVPmBGWPZZi+AQXT4mxupb67766/0UlhG8PAZCz6xzEMXTbW3CsSoE8PcCWA49n35mKg=="], |
| @@ -1476,14 +1465,10 @@ · 28 unmodified lines | ||
| 1476 | 1465 | |
| 1477 | 1466 | "color-string": ["color-string@1.9.1", "", { "dependencies": { "color-name": "^1.0.0", "simple-swizzle": "^0.2.2" } }, "sha512-shrVawQFojnZv6xM40anx4CkoDP+fZsw/ZerEMsW/pyzsRbElpsL/DBVW7q3ExxwusdNXI3lXpuhEZkzs8p5Eg=="], |
| 1478 | 1467 | |
| 1479 | − "colorjs.io": ["colorjs.io@0.6.0-alpha.1", "", {}, "sha512-c/h/8uAmPydQcriRdX8UTAFHj6SpSHFHBA8LvMikvYWAVApPTwg/pyOXNsGmaCBd6L/EeDlRHSNhTtnIFp/qsg=="], | |
| 1480 | − | |
| 1481 | 1468 | "comma-separated-tokens": ["comma-separated-tokens@2.0.3", "", {}, "sha512-Fu4hJdvzeylCfQPp9SGWidpzrMs7tTrlu6Vb8XGaRGck8QSNZJJp538Wrb60Lax4fPwR64ViY468OIUTbRlGZg=="], |
| 1482 | 1469 | |
| 1483 | 1470 | "commander": ["commander@12.1.0", "", {}, "sha512-Vw8qHK3bZM9y/P10u3Vib8o/DdkvA2OtPtZvD871QKjy74Wj1WSKFILMPRPSdUSx5RFK1arlJzEtA4PkFgnbuA=="], |
| 1484 | 1471 | |
| 1485 | − "comment-json": ["comment-json@4.6.2", "", { "dependencies": { "array-timsort": "^1.0.3", "esprima": "^4.0.1" } }, "sha512-R2rze/hDX30uul4NZoIZ76ImSJLFxn/1/ZxtKC1L77y2X1k+yYu1joKbAtMA2Fg3hZrTOiw0I5mwVMo0cf250w=="], | |
| 1486 | − | |
| 1487 | 1472 | "common-ancestor-path": ["common-ancestor-path@2.0.0", "", {}, "sha512-dnN3ibLeoRf2HNC+OlCiNc5d2zxbLJXOtiZUudNFSXZrNSydxcCsSpRzXwfu7BBWCIfHPw+xTayeBvJCP/D8Ng=="], |
| 1488 | 1473 | |
| 1489 | 1474 | "compressible": ["compressible@2.0.18", "", { "dependencies": { "mime-db": ">= 1.43.0 < 2" } }, "sha512-AF3r7P5dWxL8MxyITRMlORQNaOA2IkAFaTr4k7BUumjPtRpGDTZpl0Pb1XCO6JeDCBdp126Cgs9sMxqSjgYyRg=="], |
| @@ -1528,6 +1513,8 @@ · 38 unmodified lines | ||
| 1528 | 1513 | |
| 1529 | 1514 | "csstype": ["csstype@3.2.3", "", {}, "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ=="], |
| 1530 | 1515 | |
| 1516 | + "culori": ["culori@4.0.2", "", {}, "sha512-1+BhOB8ahCn4O0cep0Sh2l9KCOfOdY+BXJnKMHFFzDEouSr/el18QwXEMRlOj9UY5nCeA8UN3a/82rUWRBeyBw=="], | |
| 1517 | + | |
| 1531 | 1518 | "date-fns": ["date-fns@4.1.0", "", {}, "sha512-Ukq0owbQXxa/U3EGtsdVBkR1w7KOQ5gIBqdH2hkvknzZPYvBxb/aa6E8L7tmjFtkwZBu3UXBbjIgPo/Ez4xaNg=="], |
| 1532 | 1519 | |
| 1533 | 1520 | "debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="], |
| @@ -2240,8 +2227,6 @@ · 706 unmodified lines | ||
| 2240 | 2227 | |
| 2241 | 2228 | "minipass": ["minipass@7.1.3", "", {}, "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A=="], |
| 2242 | 2229 | |
| 2243 | − "minizlib": ["minizlib@3.1.0", "", { "dependencies": { "minipass": "^7.1.2" } }, "sha512-KZxYo1BUkWD2TVFLr0MQoM8vUUigWD3LlD83a/75BqC+4qE0Hb1Vo5v1FgcfaNXvfXzr+5EhQ6ing/CaBijTlw=="], | |
| 2244 | − | |
| 2245 | 2230 | "mkdirp": ["mkdirp@1.0.4", "", { "bin": { "mkdirp": "bin/cmd.js" } }, "sha512-vVqVZQyf3WLx2Shd0qJ9xuvqgAyKPLAiqITEtqW0oIUjzo3PePDd6fW9iFz30ef7Ysp/oiWqbhszeGWW2T6Gzw=="], |
| 2246 | 2231 | |
| 2247 | 2232 | "mrmime": ["mrmime@2.0.1", "", {}, "sha512-Y3wQdFg2Va6etvQ5I82yUhGdsKrcYox6p7FfL1LbK2J4V01F9TGlepTIhnK24t7koZibmg82KGglhA1XK5IsLQ=="], |
| @@ -2264,8 +2249,6 @@ · 16 unmodified lines | ||
| 2264 | 2249 | |
| 2265 | 2250 | "nanoid": ["nanoid@3.3.11", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w=="], |
| 2266 | 2251 | |
| 2267 | − "nativewind": ["nativewind@5.0.0-preview.3", "", { "dependencies": { "tailwindcss-safe-area": "^1.1.0" }, "peerDependencies": { "react-native-css": "^3.0.1", "tailwindcss": ">4.1.11" } }, "sha512-cbl1GzzY55NL2IG35AaAVfrL4+bCh77sa39aST5/o7xy3TLPthAtzhNPstnrCn+DtIglTnXOlOJGFrX2WdhI6w=="], | |
| 2268 | − | |
| 2269 | 2252 | "negotiator": ["negotiator@0.6.3", "", {}, "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg=="], |
| 2270 | 2253 | |
| 2271 | 2254 | "neotraverse": ["neotraverse@0.6.18", "", {}, "sha512-Z4SmBUweYa09+o6pG+eASabEpP6QkQ70yHj351pQoEXIs8uHbaU2DWVmzBANKgflPa47A50PtB2+NgRpQvr7vA=="], |
| @@ -2458,8 +2441,6 @@ · 186 unmodified lines | ||
| 2458 | 2441 | |
| 2459 | 2442 | "react-native": ["react-native@0.83.4", "", { "dependencies": { "@jest/create-cache-key-function": "^29.7.0", "@react-native/assets-registry": "0.83.4", "@react-native/codegen": "0.83.4", "@react-native/community-cli-plugin": "0.83.4", "@react-native/gradle-plugin": "0.83.4", "@react-native/js-polyfills": "0.83.4", "@react-native/normalize-colors": "0.83.4", "@react-native/virtualized-lists": "0.83.4", "abort-controller": "^3.0.0", "anser": "^1.4.9", "ansi-regex": "^5.0.0", "babel-jest": "^29.7.0", "babel-plugin-syntax-hermes-parser": "0.32.0", "base64-js": "^1.5.1", "commander": "^12.0.0", "flow-enums-runtime": "^0.0.6", "glob": "^7.1.1", "hermes-compiler": "0.14.1", "invariant": "^2.2.4", "jest-environment-node": "^29.7.0", "memoize-one": "^5.0.0", "metro-runtime": "^0.83.3", "metro-source-map": "^0.83.3", "nullthrows": "^1.1.1", "pretty-format": "^29.7.0", "promise": "^8.3.0", "react-devtools-core": "^6.1.5", "react-refresh": "^0.14.0", "regenerator-runtime": "^0.13.2", "scheduler": "0.27.0", "semver": "^7.1.3", "stacktrace-parser": "^0.1.10", "whatwg-fetch": "^3.0.0", "ws": "^7.5.10", "yargs": "^17.6.2" }, "peerDependencies": { "@types/react": "^19.1.1", "react": "^19.2.0" }, "optionalPeers": ["@types/react"], "bin": { "react-native": "cli.js" } }, "sha512-H5Wco3UJyY6zZsjoBayY8RM9uiAEQ3FeG4G2NAt+lr9DO43QeqPlVe9xxxYEukMkEmeIhNjR70F6bhXuWArOMQ=="], |
| 2460 | 2443 | |
| 2461 | − "react-native-css": ["react-native-css@3.0.7", "", { "dependencies": { "@types/debug": "^4.1.12", "babel-plugin-react-compiler": "^19.1.0-rc.2", "colorjs.io": "0.6.0-alpha.1", "comment-json": "^4.2.5", "debug": "^4.4.1" }, "peerDependencies": { "@expo/metro-config": ">=54", "lightningcss": ">=1.27.0", "react": ">=19", "react-native": ">=0.81" } }, "sha512-xsU9T5MleHI/DDmY72A7ZikbkDfDNkMrAEichOJW2bBUMxPzAJPrFk9ys4cXYk4gLFaR11y23EccOJ+JT6Iusw=="], | |
| 2462 | − | |
| 2463 | 2444 | "react-native-fit-image": ["react-native-fit-image@1.5.5", "", { "dependencies": { "prop-types": "^15.5.10" } }, "sha512-Wl3Vq2DQzxgsWKuW4USfck9zS7YzhvLNPpkwUUCF90bL32e1a0zOVQ3WsJILJOwzmPdHfzZmWasiiAUNBkhNkg=="], |
| 2464 | 2445 | |
| 2465 | 2446 | "react-native-gesture-handler": ["react-native-gesture-handler@2.30.1", "", { "dependencies": { "@egjs/hammerjs": "^2.0.17", "hoist-non-react-statics": "^3.3.0", "invariant": "^2.2.4" }, "peerDependencies": { "react": "*", "react-native": "*" } }, "sha512-xIUBDo5ktmJs++0fZlavQNvDEE4PsihWhSeJsJtoz4Q6p0MiTM9TgrTgfEgzRR36qGPytFoeq+ShLrVwGdpUdA=="], |
| @@ -2706,12 +2687,8 @@ · 240 unmodified lines | ||
| 2706 | 2687 | |
| 2707 | 2688 | "tailwindcss": ["tailwindcss@4.1.12", "", {}, "sha512-DzFtxOi+7NsFf7DBtI3BJsynR+0Yp6etH+nRPTbpWnS2pZBaSksv/JGctNwSWzbFjp0vxSqknaUylseZqMDGrA=="], |
| 2708 | 2689 | |
| 2709 | − "tailwindcss-safe-area": ["tailwindcss-safe-area@1.3.0", "", { "peerDependencies": { "tailwindcss": "^4.0.0" } }, "sha512-RoxnW1zAjBWC3XK+row7Qj5toRMRlKNN/p3FLXb6fTGKxDGWT6JP/mcNX1yf09xRficQ308hbwiedgniepSp1Q=="], | |
| 2710 | − | |
| 2711 | 2690 | "tapable": ["tapable@2.3.2", "", {}, "sha512-1MOpMXuhGzGL5TTCZFItxCc0AARf1EZFQkGqMm7ERKj8+Hgr5oLvJOVFcC+lRmR8hCe2S3jC4T5D7Vg/d7/fhA=="], |
| 2712 | 2691 | |
| 2713 | − "tar": ["tar@7.5.13", "", { "dependencies": { "@isaacs/fs-minipass": "^4.0.0", "chownr": "^3.0.0", "minipass": "^7.1.2", "minizlib": "^3.1.0", "yallist": "^5.0.0" } }, "sha512-tOG/7GyXpFevhXVh8jOPJrmtRpOTsYqUIkVdVooZYJS/z8WhfQUX8RJILmeuJNinGAMSu1veBr4asSHFt5/hng=="], | |
| 2714 | − | |
| 2715 | 2692 | "terminal-link": ["terminal-link@2.1.1", "", { "dependencies": { "ansi-escapes": "^4.2.1", "supports-hyperlinks": "^2.0.0" } }, "sha512-un0FmiRUQNr5PJqy9kP7c40F5BOfpGlYTrxonDChEZB7pzZxRNp/bt+ymiy9/npwXya9KH99nJ/GXFIiUkYGFQ=="], |
| 2716 | 2693 | |
| 2717 | 2694 | "terser": ["terser@5.46.1", "", { "dependencies": { "@jridgewell/source-map": "^0.3.3", "acorn": "^8.15.0", "commander": "^2.20.0", "source-map-support": "~0.5.20" }, "bin": { "terser": "bin/terser" } }, "sha512-vzCjQO/rgUuK9sf8VJZvjqiqiHFaZLnOiimmUuOKODxWL8mm/xua7viT7aqX7dgPY60otQjUotzFMmCB4VdmqQ=="], |
| @@ -2830,6 +2807,8 @@ · 112 unmodified lines | ||
| 2830 | 2807 | |
| 2831 | 2808 | "universalify": ["universalify@2.0.1", "", {}, "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw=="], |
| 2832 | 2809 | |
| 2810 | + "uniwind": ["uniwind@1.6.2", "", { "dependencies": { "@tailwindcss/node": "4.2.1", "@tailwindcss/oxide": "4.2.1", "culori": "4.0.2", "lightningcss": "1.30.1" }, "peerDependencies": { "react": ">=19.0.0", "react-native": ">=0.81.0", "tailwindcss": ">=4" } }, "sha512-skT0I/PFGajygQPGJ20sYbqc3HxmcOMkfQWy51Ha6eXXJWRJh6vYiy7jUJvLFwdzWlfX1g+Z8oC/SpxgRB5R+g=="], | |
| 2811 | + | |
| 2833 | 2812 | "unpipe": ["unpipe@1.0.0", "", {}, "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ=="], |
| 2834 | 2813 | |
| 2835 | 2814 | "unplugin": ["unplugin@2.3.11", "", { "dependencies": { "@jridgewell/remapping": "^2.3.5", "acorn": "^8.15.0", "picomatch": "^4.0.3", "webpack-virtual-modules": "^0.6.2" } }, "sha512-5uKD0nqiYVzlmCRs01Fhs2BdkEgBS3SAVP6ndrBsuK42iC2+JHyxM05Rm9G8+5mkmRtzMZGY8Ct5+mliZxU/Ww=="], |
| @@ -2950,7 +2929,7 @@ · 114 unmodified lines | ||
| 2950 | 2929 | |
| 2951 | 2930 | "y18n": ["y18n@5.0.8", "", {}, "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA=="], |
| 2952 | 2931 | |
| 2953 | − "yallist": ["yallist@5.0.0", "", {}, "sha512-YgvUTfwqyc7UXVMrB+SImsVYSmTS8X/tSrtdNZMImM+n7+QTriRXyXim0mBrTXNeqzVF0KWGgHPeiyViFFrNDw=="], | |
| 2932 | + "yallist": ["yallist@3.1.1", "", {}, "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g=="], | |
| 2954 | 2933 | |
| 2955 | 2934 | "yaml": ["yaml@2.8.3", "", { "bin": { "yaml": "bin.mjs" } }, "sha512-AvbaCLOO2Otw/lW5bmh9d/WEdcDFdQp2Z2ZUH3pX9U2ihyUY0nvLv7J6TrWowklRGPYbB/IuIMfYgxaCPg5Bpg=="], |
| 2956 | 2935 | |
| @@ -3056,13 +3035,15 @@ · 99 unmodified lines | ||
| 3056 | 3035 | |
| 3057 | 3036 | "@shikijs/transformers/@shikijs/types": ["@shikijs/types@3.23.0", "", { "dependencies": { "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-3JZ5HXOZfYjsYSk0yPwBrkupyYSLpAE26Qc0HLghhZNGTZg/SKxXIIgoxOpmmeQP0RRSDJTk1/vPfw9tbw+jSQ=="], |
| 3058 | 3037 | |
| 3038 | + "@tailwindcss/node/tailwindcss": ["tailwindcss@4.2.1", "", {}, "sha512-/tBrSQ36vCleJkAOsy9kbNTgaxvGbyOamC30PRePTQe/o1MFwEKHQk4Cn7BNGaPtjp+PuUrByJehM1hgxfq4sw=="], | |
| 3039 | + | |
| 3059 | 3040 | "@tailwindcss/oxide-wasm32-wasi/@emnapi/core": ["@emnapi/core@1.9.2", "", { "dependencies": { "@emnapi/wasi-threads": "1.2.1", "tslib": "^2.4.0" }, "bundled": true }, "sha512-UC+ZhH3XtczQYfOlu3lNEkdW/p4dsJ1r/bP7H8+rhao3TTTMO1ATq/4DdIi23XuGoFY+Cz0JmCbdVl0hz9jZcA=="], |
| 3060 | 3041 | |
| 3061 | 3042 | "@tailwindcss/oxide-wasm32-wasi/@emnapi/runtime": ["@emnapi/runtime@1.9.2", "", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-3U4+MIWHImeyu1wnmVygh5WlgfYDtyf0k8AbLhMFxOipihf6nrWC4syIm/SwEeec0mNSafiiNnMJwbza/Is6Lw=="], |
| 3062 | 3043 | |
| 3063 | 3044 | "@tailwindcss/oxide-wasm32-wasi/@emnapi/wasi-threads": ["@emnapi/wasi-threads@1.2.1", "", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w=="], |
| 3064 | 3045 | |
| 3065 | − "@tailwindcss/oxide-wasm32-wasi/@napi-rs/wasm-runtime": ["@napi-rs/wasm-runtime@0.2.12", "", { "dependencies": { "@emnapi/core": "^1.4.3", "@emnapi/runtime": "^1.4.3", "@tybys/wasm-util": "^0.10.0" }, "bundled": true }, "sha512-ZVWUcfwY4E/yPitQJl481FjFo3K22D6qF0DuFH6Y/nbnE11GY5uguDxZMGXPQ8WQ0128MXQD7TnfHyK4oWoIJQ=="], | |
| 3046 | + "@tailwindcss/oxide-wasm32-wasi/@napi-rs/wasm-runtime": ["@napi-rs/wasm-runtime@1.1.3", "", { "dependencies": { "@tybys/wasm-util": "^0.10.1" }, "peerDependencies": { "@emnapi/core": "^1.7.1", "@emnapi/runtime": "^1.7.1" }, "bundled": true }, "sha512-xK9sGVbJWYb08+mTJt3/YV24WxvxpXcXtP6B172paPZ+Ts69Re9dAr7lKwJoeIx8OoeuimEiRZ7umkiUVClmmQ=="], | |
| 3066 | 3047 | |
| 3067 | 3048 | "@tailwindcss/oxide-wasm32-wasi/@tybys/wasm-util": ["@tybys/wasm-util@0.10.1", "", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg=="], |
| 3068 | 3049 | |
| @@ -3212,8 +3193,6 @@ · 143 unmodified lines | ||
| 3212 | 3193 | |
| 3213 | 3194 | "react-native/glob": ["glob@7.2.3", "", { "dependencies": { "fs.realpath": "^1.0.0", "inflight": "^1.0.4", "inherits": "2", "minimatch": "^3.1.1", "once": "^1.3.0", "path-is-absolute": "^1.0.0" } }, "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q=="], |
| 3214 | 3195 | |
| 3215 | − "react-native-css/babel-plugin-react-compiler": ["babel-plugin-react-compiler@19.1.0-rc.1-rc-af1b7da-20250421", "", { "dependencies": { "@babel/types": "^7.26.0" } }, "sha512-E3kaokBhWDLf7ZD8fuYjYn0ZJHYZ+3EHtAWCdX2hl4lpu1z9S/Xr99sxhx2bTCVB41oIesz9FtM8f4INsrZaOw=="], | |
| 3216 | − | |
| 3217 | 3196 | "react-native-reanimated/react-native-is-edge-to-edge": ["react-native-is-edge-to-edge@1.2.1", "", { "peerDependencies": { "react": "*", "react-native": "*" } }, "sha512-FLbPWl/MyYQWz+KwqOZsSyj2JmLKglHatd3xLZWskXOpRaio4LfEDEz8E/A6uD8QoTHW6Aobw1jbEwK7KMgR7Q=="], |
| 3218 | 3197 | |
| 3219 | 3198 | "react-native-reanimated/semver": ["semver@7.7.3", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q=="], |
| @@ -3292,8 +3271,6 @@ · 72 unmodified lines | ||
| 3292 | 3271 | |
| 3293 | 3272 | "yargs/yargs-parser": ["yargs-parser@21.1.1", "", {}, "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw=="], |
| 3294 | 3273 | |
| 3295 | − "@babel/helper-compilation-targets/lru-cache/yallist": ["yallist@3.1.1", "", {}, "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g=="], | |
| 3296 | − | |
| 3297 | 3274 | "@electron/get/fs-extra/jsonfile": ["jsonfile@4.0.0", "", { "optionalDependencies": { "graceful-fs": "^4.1.6" } }, "sha512-m6F1R3z8jjlf2imQHS2Qez5sjKWQzbuuhuJ/FKYFRZvPE3PuHcSMVZzfsLhGVOkfd20obL5SWEBew5ShlquNxg=="], |
| 3298 | 3275 | |
| 3299 | 3276 | "@electron/get/fs-extra/universalify": ["universalify@0.1.2", "", {}, "sha512-rBJeI5CXAlmy1pV+617WB9J63U6XcazHHF2f2dbJix4XzpUF0RS3Zbj0FGIOCAva5P/d/GBOYaACQ1w+0azUkg=="], |
| @@ -6,6 +6,10 @@ import type { · 5 unmodified lines | ||
| 6 | 6 | ServerLifecycleWelcomePayload, |
| 7 | 7 | TerminalEvent, |
| 8 | 8 | } 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"; | |
| 9 | 13 | |
| 10 | 14 | import type { KnownEnvironment } from "./knownEnvironment"; |
| 11 | 15 | import type { WsRpcClient } from "./wsRpcClient"; |
| @@ -137,14 +141,20 @@ function createOrchestrationRecoveryCoordinator() { · 125 unmodified lines | ||
| 137 | 141 | markEventBatchApplied<T extends Readonly<{ sequence: number }>>( |
| 138 | 142 | events: ReadonlyArray<T>, |
| 139 | 143 | ): 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 | + ); | |
| 143 | 149 | if (nextEvents.length === 0) { |
| 144 | 150 | return []; |
| 145 | 151 | } |
| 146 | 152 | |
| 147 | − const latestSequence = nextEvents.at(-1)?.sequence ?? state.latestSequence; | |
| 153 | + const latestSequence = Arr.last(nextEvents).pipe( | |
| 154 | + Option.map((event) => event.sequence), | |
| 155 | + Option.getOrElse(() => state.latestSequence), | |
| 156 | + ); | |
| 157 | + | |
| 148 | 158 | state = { |
| 149 | 159 | ...state, |
| 150 | 160 | latestSequence, |
| @@ -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 | +}); | |
| @@ -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 | +} | |
| @@ -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 | +}); | |
| @@ -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 | +} | |
| @@ -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 | +}); | |
| @@ -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 | +} | |
| @@ -1,6 +1,9 @@ | ||
| 1 | 1 | export * from "./knownEnvironment"; |
| 2 | 2 | export * from "./scoped"; |
| 3 | 3 | export * from "./gitActions"; |
| 4 | +export * from "./gitActionState"; | |
| 5 | +export * from "./gitBranchState"; | |
| 6 | +export * from "./gitStatusState"; | |
| 4 | 7 | export * from "./wsRpcProtocol"; |
| 5 | 8 | export * from "./wsTransport"; |
| 6 | 9 | export * from "./wsRpcClient"; |