- 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 | module.exports = function (api) { | 1 | module.exports = function (api) { |
| 2 | api.cache(true); | 2 | api.cache(true); |
| 3 | return { | 3 | return { |
| 4 | presets: [["babel-preset-expo", { unstable_transformImportMeta: true }], "nativewind/babel"], | 4 | presets: [["babel-preset-expo", { unstable_transformImportMeta: true }]], |
| 5 | }; | 5 | }; |
| 6 | }; | 6 | }; |
| @@ -1,17 +1,192 @@ | |||
| 1 | @import "tailwindcss/theme.css" layer(theme); | 1 | @import "tailwindcss"; |
| 2 | @import "tailwindcss/preflight.css" layer(base); | 2 | @import "uniwind"; |
| 3 | @import "tailwindcss/utilities.css" layer(utilities); | ||
| 4 | 3 | ||
| 5 | @import "nativewind/theme"; | 4 | /* ─── Theme tokens ──────────────────────────────────────────────────── */ |
| 5 | @layer theme { | ||
| 6 | :root { | ||
| 7 | @variant light { | ||
| 8 | /* Page backgrounds */ | ||
| 9 | --color-screen: #f2f2f7; | ||
| 10 | --color-sheet: rgba(242, 242, 247, 0.98); | ||
| 6 | 11 | ||
| 12 | /* Card / surface */ | ||
| 13 | --color-card: #ffffff; | ||
| 14 | --color-card-alt: #f5f5f5; | ||
| 15 | --color-card-translucent: rgba(255, 255, 255, 0.94); | ||
| 16 | |||
| 17 | /* Text */ | ||
| 18 | --color-foreground: #262626; | ||
| 19 | --color-foreground-secondary: #525252; | ||
| 20 | --color-foreground-muted: #737373; | ||
| 21 | --color-foreground-tertiary: #a3a3a3; | ||
| 22 | |||
| 23 | /* Borders & separators */ | ||
| 24 | --color-border: rgba(0, 0, 0, 0.08); | ||
| 25 | --color-border-subtle: rgba(0, 0, 0, 0.06); | ||
| 26 | --color-separator: rgba(0, 0, 0, 0.04); | ||
| 27 | |||
| 28 | /* Subtle backgrounds (badges, pills, overlays) */ | ||
| 29 | --color-subtle: rgba(0, 0, 0, 0.04); | ||
| 30 | --color-subtle-strong: rgba(0, 0, 0, 0.08); | ||
| 31 | |||
| 32 | /* Primary action */ | ||
| 33 | --color-primary: #262626; | ||
| 34 | --color-primary-foreground: #ffffff; | ||
| 35 | --color-primary-shadow: rgba(0, 0, 0, 0.18); | ||
| 36 | |||
| 37 | /* Secondary action */ | ||
| 38 | --color-secondary: #ffffff; | ||
| 39 | --color-secondary-foreground: #262626; | ||
| 40 | --color-secondary-border: rgba(0, 0, 0, 0.08); | ||
| 41 | |||
| 42 | /* Danger */ | ||
| 43 | --color-danger: #fef2f2; | ||
| 44 | --color-danger-border: rgba(239, 68, 68, 0.12); | ||
| 45 | --color-danger-foreground: #dc2626; | ||
| 46 | |||
| 47 | /* Inputs */ | ||
| 48 | --color-input: #ffffff; | ||
| 49 | --color-input-border: rgba(0, 0, 0, 0.1); | ||
| 50 | --color-placeholder: #a3a3a3; | ||
| 51 | |||
| 52 | /* Icons */ | ||
| 53 | --color-icon: #262626; | ||
| 54 | --color-icon-muted: #525252; | ||
| 55 | --color-icon-subtle: #a3a3a3; | ||
| 56 | |||
| 57 | /* Header / glass chrome */ | ||
| 58 | --color-header: rgba(255, 255, 255, 0.97); | ||
| 59 | --color-header-border: rgba(0, 0, 0, 0.06); | ||
| 60 | |||
| 61 | /* StatusBar */ | ||
| 62 | --color-status-bar: #f2f2f7; | ||
| 63 | |||
| 64 | /* Markdown */ | ||
| 65 | --color-md-body: #262626; | ||
| 66 | --color-md-strong: #171717; | ||
| 67 | --color-md-link: #2563eb; | ||
| 68 | --color-md-blockquote-border: rgba(0, 0, 0, 0.08); | ||
| 69 | --color-md-blockquote-bg: rgba(0, 0, 0, 0.02); | ||
| 70 | --color-md-code-bg: rgba(0, 0, 0, 0.04); | ||
| 71 | --color-md-code-text: #262626; | ||
| 72 | --color-md-user-code-bg: rgba(255, 255, 255, 0.55); | ||
| 73 | --color-md-user-code-text: #262626; | ||
| 74 | --color-md-user-fence-bg: rgba(0, 0, 0, 0.04); | ||
| 75 | --color-md-user-fence-text: #1e3a5f; | ||
| 76 | --color-md-hr: rgba(0, 0, 0, 0.08); | ||
| 77 | |||
| 78 | /* Drawer / modal backdrop */ | ||
| 79 | --color-backdrop: rgba(0, 0, 0, 0.22); | ||
| 80 | --color-drawer: rgba(255, 255, 255, 0.99); | ||
| 81 | --color-drawer-shadow: rgba(0, 0, 0, 0.12); | ||
| 82 | |||
| 83 | /* Misc */ | ||
| 84 | --color-dot-separator: rgba(0, 0, 0, 0.2); | ||
| 85 | --color-wordmark: #262626; | ||
| 86 | --color-chevron: rgba(0, 0, 0, 0.2); | ||
| 87 | } | ||
| 88 | |||
| 89 | @variant dark { | ||
| 90 | /* Page backgrounds */ | ||
| 91 | --color-screen: #0a0a0a; | ||
| 92 | --color-sheet: rgba(14, 14, 14, 0.98); | ||
| 93 | |||
| 94 | /* Card / surface */ | ||
| 95 | --color-card: #171717; | ||
| 96 | --color-card-alt: #1c1c1c; | ||
| 97 | --color-card-translucent: rgba(17, 17, 17, 0.94); | ||
| 98 | |||
| 99 | /* Text */ | ||
| 100 | --color-foreground: #f5f5f5; | ||
| 101 | --color-foreground-secondary: #a3a3a3; | ||
| 102 | --color-foreground-muted: #737373; | ||
| 103 | --color-foreground-tertiary: #525252; | ||
| 104 | |||
| 105 | /* Borders & separators */ | ||
| 106 | --color-border: rgba(255, 255, 255, 0.06); | ||
| 107 | --color-border-subtle: rgba(255, 255, 255, 0.04); | ||
| 108 | --color-separator: rgba(255, 255, 255, 0.03); | ||
| 109 | |||
| 110 | /* Subtle backgrounds (badges, pills, overlays) */ | ||
| 111 | --color-subtle: rgba(255, 255, 255, 0.04); | ||
| 112 | --color-subtle-strong: rgba(255, 255, 255, 0.08); | ||
| 113 | |||
| 114 | /* Primary action */ | ||
| 115 | --color-primary: #f5f5f5; | ||
| 116 | --color-primary-foreground: #0a0a0a; | ||
| 117 | --color-primary-shadow: rgba(0, 0, 0, 0.22); | ||
| 118 | |||
| 119 | /* Secondary action */ | ||
| 120 | --color-secondary: rgba(255, 255, 255, 0.04); | ||
| 121 | --color-secondary-foreground: #f5f5f5; | ||
| 122 | --color-secondary-border: rgba(255, 255, 255, 0.06); | ||
| 123 | |||
| 124 | /* Danger */ | ||
| 125 | --color-danger: rgba(239, 68, 68, 0.14); | ||
| 126 | --color-danger-border: rgba(248, 113, 113, 0.18); | ||
| 127 | --color-danger-foreground: #fca5a5; | ||
| 128 | |||
| 129 | /* Inputs */ | ||
| 130 | --color-input: #141414; | ||
| 131 | --color-input-border: rgba(255, 255, 255, 0.08); | ||
| 132 | --color-placeholder: #737373; | ||
| 133 | |||
| 134 | /* Icons */ | ||
| 135 | --color-icon: #f5f5f5; | ||
| 136 | --color-icon-muted: #a3a3a3; | ||
| 137 | --color-icon-subtle: #737373; | ||
| 138 | |||
| 139 | /* Header / glass chrome */ | ||
| 140 | --color-header: rgba(10, 10, 10, 0.97); | ||
| 141 | --color-header-border: rgba(255, 255, 255, 0.06); | ||
| 142 | |||
| 143 | /* StatusBar */ | ||
| 144 | --color-status-bar: #0a0a0a; | ||
| 145 | |||
| 146 | /* Markdown */ | ||
| 147 | --color-md-body: #e5e5e5; | ||
| 148 | --color-md-strong: #f5f5f5; | ||
| 149 | --color-md-link: #60a5fa; | ||
| 150 | --color-md-blockquote-border: rgba(255, 255, 255, 0.1); | ||
| 151 | --color-md-blockquote-bg: rgba(255, 255, 255, 0.03); | ||
| 152 | --color-md-code-bg: rgba(255, 255, 255, 0.06); | ||
| 153 | --color-md-code-text: #e5e5e5; | ||
| 154 | --color-md-user-code-bg: rgba(255, 255, 255, 0.1); | ||
| 155 | --color-md-user-code-text: #e5e5e5; | ||
| 156 | --color-md-user-fence-bg: rgba(255, 255, 255, 0.08); | ||
| 157 | --color-md-user-fence-text: #e5e5e5; | ||
| 158 | --color-md-hr: rgba(255, 255, 255, 0.08); | ||
| 159 | |||
| 160 | /* Drawer / modal backdrop */ | ||
| 161 | --color-backdrop: rgba(0, 0, 0, 0.48); | ||
| 162 | --color-drawer: rgba(14, 14, 14, 0.99); | ||
| 163 | --color-drawer-shadow: rgba(0, 0, 0, 0.32); | ||
| 164 | |||
| 165 | /* Misc */ | ||
| 166 | --color-dot-separator: rgba(255, 255, 255, 0.2); | ||
| 167 | --color-wordmark: #f5f5f5; | ||
| 168 | --color-chevron: rgba(255, 255, 255, 0.2); | ||
| 169 | } | ||
| 170 | } | ||
| 171 | } | ||
| 172 | |||
| 173 | /* ─── Font family ───────────────────────────────────────────────────── */ | ||
| 174 | @theme { | ||
| 175 | --font-sans: "DMSans_400Regular"; | ||
| 176 | --font-medium: "DMSans_500Medium"; | ||
| 177 | --font-bold: "DMSans_700Bold"; | ||
| 178 | } | ||
| 179 | |||
| 180 | /* ─── Custom utilities ──────────────────────────────────────────────── */ | ||
| 7 | @utility font-t3-medium { | 181 | @utility font-t3-medium { |
| 8 | font-weight: 500; | 182 | font-family: "DMSans_500Medium"; |
| 9 | } | 183 | } |
| 10 | 184 | ||
| 11 | @utility font-t3-bold { | 185 | @utility font-t3-bold { |
| 12 | font-weight: 700; | 186 | font-family: "DMSans_700Bold"; |
| 13 | } | 187 | } |
| 14 | 188 | ||
| 15 | @utility font-t3-extrabold { | 189 | @utility font-t3-extrabold { |
| 190 | font-family: "DMSans_700Bold"; | ||
| 16 | font-weight: 800; | 191 | font-weight: 800; |
| 17 | } | 192 | } |
| @@ -1,6 +1,6 @@ | |||
| 1 | const path = require("node:path"); | 1 | const path = require("node:path"); |
| 2 | const { getDefaultConfig } = require("expo/metro-config"); | 2 | const { getDefaultConfig } = require("expo/metro-config"); |
| 3 | const { withNativewind } = require("nativewind/metro"); | 3 | const { withUniwindConfig } = require("uniwind/metro"); |
| 4 | 4 | ||
| 5 | /** @type {import("expo/metro-config").MetroConfig} */ | 5 | /** @type {import("expo/metro-config").MetroConfig} */ |
| 6 | const config = getDefaultConfig(__dirname); | 6 | const config = getDefaultConfig(__dirname); |
| @@ -8,7 +8,7 @@ const workspaceRoot = path.resolve(__dirname, "../.."); · 1 unmodified line | |||
| 8 | 8 | ||
| 9 | config.watchFolders = [...new Set([...(config.watchFolders ?? []), workspaceRoot])]; | 9 | config.watchFolders = [...new Set([...(config.watchFolders ?? []), workspaceRoot])]; |
| 10 | 10 | ||
| 11 | module.exports = withNativewind(config, { | 11 | module.exports = withUniwindConfig(config, { |
| 12 | input: "./global.css", | 12 | cssEntryFile: "./global.css", |
| 13 | globalClassNamePolyfill: true, | 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 | "dependencies": { | 14 | "dependencies": { |
| 15 | "@callstack/liquid-glass": "^0.7.1", | 15 | "@callstack/liquid-glass": "^0.7.1", |
| 16 | "@effect/atom-react": "catalog:", | ||
| 16 | "@expo-google-fonts/dm-sans": "^0.4.2", | 17 | "@expo-google-fonts/dm-sans": "^0.4.2", |
| 17 | "@gorhom/bottom-sheet": "^5", | 18 | "@gorhom/bottom-sheet": "^5", |
| 18 | "@legendapp/list": "^3.0.0-beta.44", | 19 | "@legendapp/list": "^3.0.0-beta.44", |
| @@ -22,6 +23,7 @@ · 3 unmodified lines | |||
| 22 | "@t3tools/client-runtime": "workspace:*", | 23 | "@t3tools/client-runtime": "workspace:*", |
| 23 | "@t3tools/contracts": "workspace:*", | 24 | "@t3tools/contracts": "workspace:*", |
| 24 | "@t3tools/shared": "workspace:*", | 25 | "@t3tools/shared": "workspace:*", |
| 26 | "clsx": "^2.1.1", | ||
| 25 | "effect": "catalog:", | 27 | "effect": "catalog:", |
| 26 | "expo": "~55.0.14", | 28 | "expo": "~55.0.14", |
| 27 | "expo-camera": "~55.0.15", | 29 | "expo-camera": "~55.0.15", |
| @@ -41,12 +43,10 @@ · 13 unmodified lines | |||
| 41 | "expo-splash-screen": "~55.0.13", | 43 | "expo-splash-screen": "~55.0.13", |
| 42 | "expo-symbols": "~55.0.5", | 44 | "expo-symbols": "~55.0.5", |
| 43 | "expo-updates": "~55.0.16", | 45 | "expo-updates": "~55.0.16", |
| 44 | "nativewind": "5.0.0-preview.3", | ||
| 45 | "punycode": "^2.3.1", | 46 | "punycode": "^2.3.1", |
| 46 | "react": "19.2.0", | 47 | "react": "19.2.0", |
| 47 | "react-dom": "19.2.0", | 48 | "react-dom": "19.2.0", |
| 48 | "react-native": "0.83.4", | 49 | "react-native": "0.83.4", |
| 49 | "react-native-css": "3.0.7", | ||
| 50 | "react-native-gesture-handler": "~2.30.0", | 50 | "react-native-gesture-handler": "~2.30.0", |
| 51 | "react-native-image-viewing": "^0.2.2", | 51 | "react-native-image-viewing": "^0.2.2", |
| 52 | "react-native-keyboard-controller": "1.20.7", | 52 | "react-native-keyboard-controller": "1.20.7", |
| @@ -56,14 +56,13 @@ · 3 unmodified lines | |||
| 56 | "react-native-screens": "~4.23.0", | 56 | "react-native-screens": "~4.23.0", |
| 57 | "react-native-svg": "15.15.3", | 57 | "react-native-svg": "15.15.3", |
| 58 | "react-native-worklets": "0.7.2", | 58 | "react-native-worklets": "0.7.2", |
| 59 | "tailwind-merge": "^3.5.0", | ||
| 60 | "uniwind": "^1.6.2", | ||
| 59 | "zustand": "^5.0.12" | 61 | "zustand": "^5.0.12" |
| 60 | }, | 62 | }, |
| 61 | "devDependencies": { | 63 | "devDependencies": { |
| 62 | "@tailwindcss/postcss": "4.1.12", | ||
| 63 | "@types/react": "~19.2.10", | 64 | "@types/react": "~19.2.10", |
| 64 | "babel-preset-expo": "~55.0.8", | 65 | "babel-preset-expo": "~55.0.8", |
| 65 | "lightningcss": "1.30.1", | ||
| 66 | "postcss": "^8.5.8", | ||
| 67 | "tailwindcss": "4.1.12", | 66 | "tailwindcss": "4.1.12", |
| 68 | "typescript": "~5.9.2" | 67 | "typescript": "~5.9.2" |
| 69 | } | 68 | } |
| @@ -1,5 +0,0 @@ | |||
| 1 | export default { | ||
| 2 | plugins: { | ||
| 3 | "@tailwindcss/postcss": {}, | ||
| 4 | }, | ||
| 5 | }; | ||
| @@ -1,12 +1,12 @@ | |||
| 1 | import { Link } from "expo-router"; | 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 | import { AppText as Text } from "../components/AppText"; | 5 | import { AppText as Text } from "../components/AppText"; |
| 5 | import { makeAppPalette } from "../lib/theme"; | ||
| 6 | 6 | ||
| 7 | export default function NotFoundRoute() { | 7 | export default function NotFoundRoute() { |
| 8 | const isDarkMode = useColorScheme() === "dark"; | 8 | const screenBgStyle = StyleSheet.flatten(useResolveClassNames("bg-screen")); |
| 9 | const palette = makeAppPalette(isDarkMode); | 9 | const primaryBgStyle = StyleSheet.flatten(useResolveClassNames("bg-primary")); |
| 10 | 10 | ||
| 11 | return ( | 11 | return ( |
| 12 | <ScrollView | 12 | <ScrollView |
| @@ -19,23 +19,23 @@ export default function NotFoundRoute() { · 6 unmodified lines | |||
| 19 | paddingHorizontal: 24, | 19 | paddingHorizontal: 24, |
| 20 | paddingVertical: 32, | 20 | paddingVertical: 32, |
| 21 | }} | 21 | }} |
| 22 | style={{ flex: 1, backgroundColor: palette.screenBackground }} | 22 | style={[{ flex: 1 }, screenBgStyle]} |
| 23 | > | 23 | > |
| 24 | <Text className="text-[28px] font-t3-bold" selectable style={{ color: palette.text }}> | 24 | <Text className="text-[28px] font-t3-bold text-foreground" selectable> |
| 25 | Route not found | 25 | Route not found |
| 26 | </Text> | 26 | </Text> |
| 27 | <Link href="/" asChild> | 27 | <Link href="/" asChild> |
| 28 | <Pressable | 28 | <Pressable |
| 29 | style={{ | 29 | style={[ |
| 30 | backgroundColor: palette.primaryButton, | 30 | { |
| 31 | borderRadius: 999, | 31 | borderRadius: 999, |
| 32 | paddingHorizontal: 20, | 32 | paddingHorizontal: 20, |
| 33 | paddingVertical: 14, | 33 | paddingVertical: 14, |
| 34 | }} | 34 | }, |
| 35 | primaryBgStyle, | ||
| 36 | ]} | ||
| 35 | > | 37 | > |
| 36 | <Text className="text-[16px] font-t3-bold" style={{ color: palette.primaryButtonText }}> | 38 | <Text className="text-[16px] font-t3-bold text-primary-foreground">Return home</Text> |
| 37 | Return home | ||
| 38 | </Text> | ||
| 39 | </Pressable> | 39 | </Pressable> |
| 40 | </Link> | 40 | </Link> |
| 41 | </ScrollView> | 41 | </ScrollView> |
| @@ -6,15 +6,15 @@ import { · 5 unmodified lines | |||
| 6 | DMSans_700Bold, | 6 | DMSans_700Bold, |
| 7 | useFonts, | 7 | useFonts, |
| 8 | } from "@expo-google-fonts/dm-sans"; | 8 | } from "@expo-google-fonts/dm-sans"; |
| 9 | import { BottomSheetModalProvider } from "@gorhom/bottom-sheet"; | ||
| 10 | import Stack from "expo-router/stack"; | 9 | import Stack from "expo-router/stack"; |
| 11 | import { StatusBar, useColorScheme } from "react-native"; | 10 | import { StatusBar, useColorScheme } from "react-native"; |
| 12 | import { GestureHandlerRootView } from "react-native-gesture-handler"; | 11 | import { GestureHandlerRootView } from "react-native-gesture-handler"; |
| 13 | import { KeyboardProvider } from "react-native-keyboard-controller"; | 12 | import { KeyboardProvider } from "react-native-keyboard-controller"; |
| 14 | import { SafeAreaProvider } from "react-native-safe-area-context"; | 13 | import { SafeAreaProvider } from "react-native-safe-area-context"; |
| 14 | import { useCSSVariable, useResolveClassNames } from "uniwind"; | ||
| 15 | 15 | ||
| 16 | import { LoadingScreen } from "../components/LoadingScreen"; | 16 | import { LoadingScreen } from "../components/LoadingScreen"; |
| 17 | import { makeAppPalette } from "../lib/theme"; | 17 | import { AppAtomRegistryProvider } from "../state/atomRegistry"; |
| 18 | import { | 18 | import { |
| 19 | useRemoteEnvironmentBootstrap, | 19 | useRemoteEnvironmentBootstrap, |
| 20 | useRemoteEnvironmentState, | 20 | useRemoteEnvironmentState, |
| @@ -22,11 +22,13 @@ import { · 1 unmodified line | |||
| 22 | 22 | ||
| 23 | function AppNavigator() { | 23 | function AppNavigator() { |
| 24 | const { isLoadingSavedConnection } = useRemoteEnvironmentState(); | 24 | const { isLoadingSavedConnection } = useRemoteEnvironmentState(); |
| 25 | const isDarkMode = useColorScheme() !== "light"; | 25 | const colorScheme = useColorScheme(); |
| 26 | const palette = makeAppPalette(isDarkMode); | 26 | const statusBarBg = useCSSVariable("--color-status-bar"); |
| 27 | const sheetStyle = useResolveClassNames("bg-sheet"); | ||
| 28 | const screenStyle = useResolveClassNames("bg-screen"); | ||
| 27 | 29 | ||
| 28 | const newTaskScreenOptions = { | 30 | const newTaskScreenOptions = { |
| 29 | contentStyle: { backgroundColor: palette.sheetBackground }, | 31 | contentStyle: sheetStyle, |
| 30 | gestureEnabled: true, | 32 | gestureEnabled: true, |
| 31 | headerShown: false, | 33 | headerShown: false, |
| 32 | presentation: "formSheet" as const, | 34 | presentation: "formSheet" as const, |
| @@ -35,7 +37,7 @@ function AppNavigator() { · 2 unmodified lines | |||
| 35 | }; | 37 | }; |
| 36 | 38 | ||
| 37 | const connectionSheetScreenOptions = { | 39 | const connectionSheetScreenOptions = { |
| 38 | contentStyle: { backgroundColor: palette.sheetBackground }, | 40 | contentStyle: sheetStyle, |
| 39 | gestureEnabled: true, | 41 | gestureEnabled: true, |
| 40 | headerShown: false, | 42 | headerShown: false, |
| 41 | presentation: "formSheet" as const, | 43 | presentation: "formSheet" as const, |
| @@ -50,18 +52,27 @@ function AppNavigator() { · 8 unmodified lines | |||
| 50 | return ( | 52 | return ( |
| 51 | <> | 53 | <> |
| 52 | <StatusBar | 54 | <StatusBar |
| 53 | barStyle={palette.statusBarStyle} | 55 | barStyle={colorScheme === "dark" ? "light-content" : "dark-content"} |
| 54 | backgroundColor={palette.statusBarBackground} | 56 | backgroundColor={statusBarBg as string} |
| 55 | translucent | 57 | translucent |
| 56 | /> | 58 | /> |
| 57 | <Stack screenOptions={{ headerShown: false }}> | 59 | <Stack screenOptions={{ headerShown: false }}> |
| 60 | <Stack.Screen | ||
| 61 | name="index" | ||
| 62 | options={{ | ||
| 63 | contentStyle: { backgroundColor: "transparent" }, | ||
| 64 | headerShown: true, | ||
| 65 | headerTransparent: true, | ||
| 66 | headerShadowVisible: false, | ||
| 67 | }} | ||
| 68 | /> | ||
| 58 | <Stack.Screen name="connections" options={connectionSheetScreenOptions} /> | 69 | <Stack.Screen name="connections" options={connectionSheetScreenOptions} /> |
| 59 | <Stack.Screen name="new" options={newTaskScreenOptions} /> | 70 | <Stack.Screen name="new" options={newTaskScreenOptions} /> |
| 60 | <Stack.Screen | 71 | <Stack.Screen |
| 61 | name="threads/[environmentId]/[threadId]" | 72 | name="threads/[environmentId]/[threadId]" |
| 62 | options={{ | 73 | options={{ |
| 63 | animation: "slide_from_right", | 74 | animation: "slide_from_right", |
| 64 | contentStyle: { backgroundColor: palette.screenBackground }, | 75 | contentStyle: { backgroundColor: "transparent" }, |
| 65 | gestureEnabled: true, | 76 | gestureEnabled: true, |
| 66 | headerShown: false, | 77 | headerShown: false, |
| 67 | }} | 78 | }} |
| @@ -80,14 +91,18 @@ export default function RootLayout() { · 12 unmodified lines | |||
| 80 | useRemoteEnvironmentBootstrap(); | 91 | useRemoteEnvironmentBootstrap(); |
| 81 | 92 | ||
| 82 | return ( | 93 | return ( |
| 83 | <GestureHandlerRootView style={{ flex: 1 }}> | 94 | <AppAtomRegistryProvider> |
| 84 | <KeyboardProvider statusBarTranslucent> | 95 | <GestureHandlerRootView style={{ flex: 1 }}> |
| 85 | <BottomSheetModalProvider> | 96 | <KeyboardProvider statusBarTranslucent> |
| 86 | <SafeAreaProvider> | 97 | <SafeAreaProvider> |
| 87 | {fontsLoaded ? <AppNavigator /> : <LoadingScreen message="Loading remote workspace…" />} | 98 | {fontsLoaded ? ( |
| 99 | <AppNavigator /> | ||
| 100 | ) : ( | ||
| 101 | <LoadingScreen message="Loading remote workspace…" /> | ||
| 102 | )} | ||
| 88 | </SafeAreaProvider> | 103 | </SafeAreaProvider> |
| 89 | </BottomSheetModalProvider> | 104 | </KeyboardProvider> |
| 90 | </KeyboardProvider> | 105 | </GestureHandlerRootView> |
| 91 | </GestureHandlerRootView> | 106 | </AppAtomRegistryProvider> |
| 92 | ); | 107 | ); |
| 93 | } | 108 | } |
| @@ -1,14 +1,26 @@ | |||
| 1 | import Stack from "expo-router/stack"; | 1 | import Stack from "expo-router/stack"; |
| 2 | import { useResolveClassNames } from "uniwind"; | ||
| 3 | |||
| 4 | import { useThemeColor } from "../../lib/useThemeColor"; | ||
| 2 | 5 | ||
| 3 | export const unstable_settings = { | 6 | export const unstable_settings = { |
| 4 | anchor: "index", | 7 | anchor: "index", |
| 5 | }; | 8 | }; |
| 6 | 9 | ||
| 7 | export default function ConnectionsLayout() { | 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 | return ( | 15 | return ( |
| 9 | <Stack | 16 | <Stack |
| 10 | screenOptions={{ | 17 | screenOptions={{ |
| 11 | contentStyle: { backgroundColor: "rgba(246,244,239,0.98)" }, | 18 | contentStyle, |
| 19 | headerStyle: { backgroundColor: connSheetBg }, | ||
| 20 | headerTintColor: headerTint, | ||
| 21 | headerTitleStyle: { fontFamily: "DMSans_700Bold" }, | ||
| 22 | headerShadowVisible: false, | ||
| 23 | headerBackButtonDisplayMode: "minimal", | ||
| 12 | }} | 24 | }} |
| 13 | > | 25 | > |
| 14 | <Stack.Screen name="index" options={{ animation: "none" }} /> | 26 | <Stack.Screen name="index" options={{ animation: "none" }} /> |
| @@ -1,7 +1,6 @@ | |||
| 1 | import Stack from "expo-router/stack"; | 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 | import { NewTaskFlowProvider } from "../../features/threads/new-task-flow-provider"; | 4 | import { NewTaskFlowProvider } from "../../features/threads/new-task-flow-provider"; |
| 6 | 5 | ||
| 7 | export const unstable_settings = { | 6 | export const unstable_settings = { |
| @@ -9,14 +8,13 @@ export const unstable_settings = { · 1 unmodified line | |||
| 9 | }; | 8 | }; |
| 10 | 9 | ||
| 11 | export default function NewTaskLayout() { | 10 | export default function NewTaskLayout() { |
| 12 | const isDarkMode = useColorScheme() === "dark"; | 11 | const sheetStyle = useResolveClassNames("bg-sheet"); |
| 13 | const palette = makeAppPalette(isDarkMode); | ||
| 14 | 12 | ||
| 15 | return ( | 13 | return ( |
| 16 | <NewTaskFlowProvider> | 14 | <NewTaskFlowProvider> |
| 17 | <Stack | 15 | <Stack |
| 18 | screenOptions={{ | 16 | screenOptions={{ |
| 19 | contentStyle: { backgroundColor: palette.sheetBackground }, | 17 | contentStyle: sheetStyle, |
| 20 | }} | 18 | }} |
| 21 | > | 19 | > |
| 22 | <Stack.Screen name="index" options={{ animation: "none" }} /> | 20 | <Stack.Screen name="index" options={{ animation: "none" }} /> |
| @@ -1,19 +1,22 @@ | |||
| 1 | import { Link, Stack } from "expo-router"; | 1 | import { Link, Stack } from "expo-router"; |
| 2 | import { SymbolView } from "expo-symbols"; | 2 | import { SymbolView } from "expo-symbols"; |
| 3 | import { useMemo } from "react"; | 3 | import { useMemo } from "react"; |
| 4 | import { Pressable, ScrollView, useColorScheme, View } from "react-native"; | 4 | import { Pressable, ScrollView, View } from "react-native"; |
| 5 | import { useSafeAreaInsets } from "react-native-safe-area-context"; | 5 | import { useSafeAreaInsets } from "react-native-safe-area-context"; |
| 6 | import { useThemeColor } from "../../lib/useThemeColor"; | ||
| 6 | 7 | ||
| 7 | import { AppText as Text } from "../../components/AppText"; | 8 | import { AppText as Text } from "../../components/AppText"; |
| 9 | import { ProjectFavicon } from "../../components/ProjectFavicon"; | ||
| 8 | import { groupProjectsByRepository } from "../../lib/repositoryGroups"; | 10 | import { groupProjectsByRepository } from "../../lib/repositoryGroups"; |
| 9 | import { makeAppPalette } from "../../lib/theme"; | ||
| 10 | import { useRemoteCatalog } from "../../state/use-remote-catalog"; | 11 | import { useRemoteCatalog } from "../../state/use-remote-catalog"; |
| 12 | import { useRemoteEnvironmentState } from "../../state/use-remote-environment-registry"; | ||
| 11 | 13 | ||
| 12 | export default function NewTaskRoute() { | 14 | export default function NewTaskRoute() { |
| 13 | const { projects, threads } = useRemoteCatalog(); | 15 | const { projects, threads } = useRemoteCatalog(); |
| 14 | const isDarkMode = useColorScheme() === "dark"; | 16 | const { savedConnectionsById } = useRemoteEnvironmentState(); |
| 15 | const palette = makeAppPalette(isDarkMode); | ||
| 16 | const insets = useSafeAreaInsets(); | 17 | const insets = useSafeAreaInsets(); |
| 18 | const chevronColor = useThemeColor("--color-chevron"); | ||
| 19 | const borderSubtleColor = useThemeColor("--color-border-subtle"); | ||
| 17 | const repositoryGroups = useMemo( | 20 | const repositoryGroups = useMemo( |
| 18 | () => groupProjectsByRepository({ projects, threads }), | 21 | () => groupProjectsByRepository({ projects, threads }), |
| 19 | [projects, threads], | 22 | [projects, threads], |
| @@ -32,6 +35,7 @@ export default function NewTaskRoute() { · 12 unmodified lines | |||
| 32 | id: project.id, | 35 | id: project.id, |
| 33 | key: group.key, | 36 | key: group.key, |
| 34 | title: project.title, | 37 | title: project.title, |
| 38 | workspaceRoot: project.workspaceRoot, | ||
| 35 | }; | 39 | }; |
| 36 | }) | 40 | }) |
| 37 | .filter((entry) => entry !== null), | 41 | .filter((entry) => entry !== null), |
| @@ -39,34 +43,38 @@ export default function NewTaskRoute() { · 1 unmodified line | |||
| 39 | ); | 43 | ); |
| 40 | 44 | ||
| 41 | return ( | 45 | return ( |
| 42 | <View collapsable={false} style={{ flex: 1, backgroundColor: palette.sheetBackground }}> | 46 | <View collapsable={false} className="flex-1 bg-sheet"> |
| 43 | <Stack.Screen options={{ title: "Choose Project" }} /> | 47 | <Stack.Screen options={{ headerShown: false }} /> |
| 48 | |||
| 49 | <View style={{ minHeight: 16, paddingTop: 8 }} /> | ||
| 50 | |||
| 51 | <View className="items-center gap-1 px-5 pb-3 pt-4"> | ||
| 52 | <Text | ||
| 53 | className="text-[12px] font-t3-bold uppercase text-foreground-muted" | ||
| 54 | style={{ letterSpacing: 1 }} | ||
| 55 | > | ||
| 56 | New task | ||
| 57 | </Text> | ||
| 58 | <Text className="text-[28px] font-t3-bold">Choose project</Text> | ||
| 59 | </View> | ||
| 60 | |||
| 44 | <ScrollView | 61 | <ScrollView |
| 45 | contentInsetAdjustmentBehavior="automatic" | ||
| 46 | showsVerticalScrollIndicator={false} | 62 | showsVerticalScrollIndicator={false} |
| 47 | style={{ flex: 1 }} | 63 | style={{ flex: 1 }} |
| 48 | contentContainerStyle={{ | 64 | contentContainerStyle={{ |
| 49 | paddingHorizontal: 20, | 65 | paddingHorizontal: 20, |
| 50 | paddingTop: 16, | 66 | paddingTop: 8, |
| 51 | paddingBottom: Math.max(insets.bottom, 18) + 18, | 67 | paddingBottom: Math.max(insets.bottom, 18) + 18, |
| 52 | }} | 68 | }} |
| 53 | > | 69 | > |
| 54 | {items.length === 0 ? ( | 70 | {items.length === 0 ? ( |
| 55 | <View | 71 | <View collapsable={false} className="items-center rounded-[24px] bg-card px-6 py-8"> |
| 56 | collapsable={false} | 72 | <Text className="text-[16px] font-medium text-foreground-muted"> |
| 57 | className="items-center rounded-[24px] px-6 py-8" | 73 | Loading projects... |
| 58 | style={{ backgroundColor: palette.card }} | ||
| 59 | > | ||
| 60 | <Text className="text-[16px] font-medium" style={{ color: palette.textMuted }}> | ||
| 61 | Loading projects… | ||
| 62 | </Text> | 74 | </Text> |
| 63 | </View> | 75 | </View> |
| 64 | ) : ( | 76 | ) : ( |
| 65 | <View | 77 | <View collapsable={false} className="overflow-hidden rounded-[24px] bg-card"> |
| 66 | collapsable={false} | ||
| 67 | className="overflow-hidden rounded-[24px]" | ||
| 68 | style={{ backgroundColor: palette.card }} | ||
| 69 | > | ||
| 70 | {items.map((item, index) => { | 78 | {items.map((item, index) => { |
| 71 | const isFirst = index === 0; | 79 | const isFirst = index === 0; |
| 72 | const isLast = index === items.length - 1; | 80 | const isLast = index === items.length - 1; |
| @@ -85,12 +93,12 @@ export default function NewTaskRoute() { · 12 unmodified lines | |||
| 85 | asChild | 93 | asChild |
| 86 | > | 94 | > |
| 87 | <Pressable | 95 | <Pressable |
| 96 | className="bg-card" | ||
| 88 | style={{ | 97 | style={{ |
| 89 | paddingHorizontal: 16, | 98 | paddingHorizontal: 16, |
| 90 | paddingVertical: 18, | 99 | paddingVertical: 18, |
| 91 | borderTopWidth: isFirst ? 0 : 1, | 100 | borderTopWidth: isFirst ? 0 : 1, |
| 92 | borderTopColor: palette.borderSubtle, | 101 | borderTopColor: borderSubtleColor, |
| 93 | backgroundColor: palette.card, | ||
| 94 | borderTopLeftRadius: isFirst ? 24 : 0, | 102 | borderTopLeftRadius: isFirst ? 24 : 0, |
| 95 | borderTopRightRadius: isFirst ? 24 : 0, | 103 | borderTopRightRadius: isFirst ? 24 : 0, |
| 96 | borderBottomLeftRadius: isLast ? 24 : 0, | 104 | borderBottomLeftRadius: isLast ? 24 : 0, |
| @@ -98,15 +106,20 @@ export default function NewTaskRoute() { · 1 unmodified line | |||
| 98 | }} | 106 | }} |
| 99 | > | 107 | > |
| 100 | <View className="flex-row items-center justify-between gap-3"> | 108 | <View className="flex-row items-center justify-between gap-3"> |
| 109 | <ProjectFavicon | ||
| 110 | size={22} | ||
| 111 | projectTitle={item.title} | ||
| 112 | httpBaseUrl={savedConnectionsById[item.environmentId]?.httpBaseUrl ?? null} | ||
| 113 | workspaceRoot={item.workspaceRoot} | ||
| 114 | bearerToken={savedConnectionsById[item.environmentId]?.bearerToken ?? null} | ||
| 115 | /> | ||
| 101 | <View className="flex-1"> | 116 | <View className="flex-1"> |
| 102 | <Text className="text-[18px] font-t3-bold" style={{ color: palette.text }}> | 117 | <Text className="text-[18px] font-t3-bold">{item.title}</Text> |
| 103 | {item.title} | ||
| 104 | </Text> | ||
| 105 | </View> | 118 | </View> |
| 106 | <SymbolView | 119 | <SymbolView |
| 107 | name="chevron.right" | 120 | name="chevron.right" |
| 108 | size={14} | 121 | size={14} |
| 109 | tintColor={palette.chevronColor} | 122 | tintColor={chevronColor} |
| 110 | type="monochrome" | 123 | type="monochrome" |
| 111 | /> | 124 | /> |
| 112 | </View> | 125 | </View> |
| @@ -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 | import { | 1 | import { |
| 2 | Text as RNText, | 2 | Text as RNText, |
| 3 | type StyleProp, | ||
| 4 | TextInput as RNTextInput, | 3 | TextInput as RNTextInput, |
| 5 | type TextInputProps as RNTextInputProps, | 4 | type TextInputProps as RNTextInputProps, |
| 6 | type TextProps as RNTextProps, | 5 | type TextProps as RNTextProps, |
| 7 | type TextStyle, | ||
| 8 | useColorScheme, | ||
| 9 | type ViewStyle, | ||
| 10 | } from "react-native"; | 6 | } from "react-native"; |
| 7 | import { useThemeColor } from "../lib/useThemeColor"; | ||
| 11 | 8 | ||
| 12 | type ClassNameProp = { | 9 | import { cn } from "../lib/cn"; |
| 13 | readonly className?: string; | ||
| 14 | }; | ||
| 15 | 10 | ||
| 16 | const colorMap = { | 11 | export type AppTextProps = RNTextProps & { readonly className?: string }; |
| 17 | "text-white": "#ffffff", | ||
| 18 | "text-neutral-50": "#fafafa", | ||
| 19 | "text-neutral-300": "#d4d4d4", | ||
| 20 | "text-neutral-400": "#a3a3a3", | ||
| 21 | "text-neutral-500": "#737373", | ||
| 22 | "text-neutral-600": "#525252", | ||
| 23 | "text-neutral-700": "#404040", | ||
| 24 | "text-neutral-950": "#0a0a0a", | ||
| 25 | "text-orange-300": "#fdba74", | ||
| 26 | "text-orange-600": "#ea580c", | ||
| 27 | "text-orange-700": "#c2410c", | ||
| 28 | "text-emerald-300": "#6ee7b7", | ||
| 29 | "text-emerald-700": "#047857", | ||
| 30 | "text-rose-300": "#fda4af", | ||
| 31 | "text-rose-700": "#be123c", | ||
| 32 | "text-sky-300": "#7dd3fc", | ||
| 33 | "text-sky-700": "#0369a1", | ||
| 34 | } satisfies Record<string, string>; | ||
| 35 | 12 | ||
| 36 | const backgroundColorMap = { | 13 | /** |
| 37 | "bg-white": "#ffffff", | 14 | * Thin wrapper around RN Text with default font-family and foreground color. |
| 38 | "bg-neutral-900": "#171717", | 15 | * Uses Uniwind className — no manual style parsing. |
| 39 | "bg-neutral-950/70": "rgba(10, 10, 10, 0.7)", | 16 | */ |
| 40 | } satisfies Record<string, string>; | 17 | export function AppText({ className, ...props }: AppTextProps) { |
| 41 | 18 | return <RNText className={cn("font-sans text-foreground", className)} {...props} />; | |
| 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]} />; | ||
| 298 | } | 19 | } |
| 299 | 20 | ||
| 300 | export type AppTextInputProps = RNTextInputProps & ClassNameProp; | 21 | export type AppTextInputProps = RNTextInputProps & { readonly className?: string }; |
| 301 | 22 | ||
| 302 | export function AppTextInput({ | 23 | /** |
| 303 | className, | 24 | * Thin wrapper around RN TextInput with default input styling. |
| 304 | placeholderTextColor, | 25 | * Uses Uniwind className — no manual style parsing. |
| 305 | style, | 26 | */ |
| 306 | ...props | 27 | export function AppTextInput({ className, placeholderTextColor, ...props }: AppTextInputProps) { |
| 307 | }: AppTextInputProps) { | 28 | const placeholderColor = useThemeColor("--color-placeholder"); |
| 308 | const isDarkMode = useColorScheme() === "dark"; | ||
| 309 | const resolvedStyle = resolveTextInputStyle(className, isDarkMode); | ||
| 310 | 29 | ||
| 311 | return ( | 30 | return ( |
| 312 | <RNTextInput | 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 | {...props} | 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 | import { AppText as Text } from "./AppText"; | 3 | import { AppText as Text } from "./AppText"; |
| 5 | 4 | ||
| 6 | const BRAND_MARK_SOURCE = require("../../../../assets/dev/blueprint-ios-1024.png"); | 5 | const BRAND_MARK_SOURCE = require("../../../../assets/dev/blueprint-ios-1024.png"); |
| 7 | 6 | ||
| 8 | export function BrandMark(props: { readonly compact?: boolean; readonly stageLabel?: string }) { | 7 | export function BrandMark(props: { readonly compact?: boolean; readonly stageLabel?: string }) { |
| 9 | const isDarkMode = useColorScheme() === "dark"; | ||
| 10 | const palette = makeAppPalette(isDarkMode); | ||
| 11 | const compact = props.compact ?? false; | 8 | const compact = props.compact ?? false; |
| 12 | const iconSize = compact ? 32 : 44; | 9 | const iconSize = compact ? 32 : 44; |
| 13 | const stageLabel = props.stageLabel ?? "Alpha"; | 10 | const stageLabel = props.stageLabel ?? "Alpha"; |
| @@ -26,27 +23,22 @@ export function BrandMark(props: { readonly compact?: boolean; readonly stageLab · 12 unmodified lines | |||
| 26 | <View className="gap-1"> | 23 | <View className="gap-1"> |
| 27 | <View className="flex-row items-center gap-2"> | 24 | <View className="flex-row items-center gap-2"> |
| 28 | <Text | 25 | <Text |
| 29 | className="text-[17px] font-t3-bold" | 26 | className="text-[17px] font-t3-bold text-foreground" |
| 30 | style={{ color: palette.text, letterSpacing: -0.4 }} | 27 | style={{ letterSpacing: -0.4 }} |
| 31 | > | 28 | > |
| 32 | T3 Code | 29 | T3 Code |
| 33 | </Text> | 30 | </Text> |
| 34 | <View | 31 | <View className="rounded-full bg-subtle px-2 py-1"> |
| 35 | className="rounded-full px-2 py-1" | ||
| 36 | style={{ | ||
| 37 | backgroundColor: palette.subtleBg, | ||
| 38 | }} | ||
| 39 | > | ||
| 40 | <Text | 32 | <Text |
| 41 | className="text-[10px] font-t3-bold uppercase" | 33 | className="text-[10px] font-t3-bold uppercase text-foreground-muted" |
| 42 | style={{ color: palette.textMuted, letterSpacing: 1.1 }} | 34 | style={{ letterSpacing: 1.1 }} |
| 43 | > | 35 | > |
| 44 | {stageLabel} | 36 | {stageLabel} |
| 45 | </Text> | 37 | </Text> |
| 46 | </View> | 38 | </View> |
| 47 | </View> | 39 | </View> |
| 48 | {!compact ? ( | 40 | {!compact ? ( |
| 49 | <Text className="text-[12px] font-medium" style={{ color: palette.textMuted }}> | 41 | <Text className="text-[12px] font-medium text-foreground-muted"> |
| 50 | Mobile control surface for your live coding environments | 42 | Mobile control surface for your live coding environments |
| 51 | </Text> | 43 | </Text> |
| 52 | ) : null} | 44 | ) : null} |
| @@ -1,8 +1,8 @@ | |||
| 1 | import { SymbolView } from "expo-symbols"; | 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 | import type { DraftComposerImageAttachment } from "../lib/composerImages"; | 5 | import type { DraftComposerImageAttachment } from "../lib/composerImages"; |
| 5 | import { makeAppPalette } from "../lib/theme"; | ||
| 6 | 6 | ||
| 7 | export interface ComposerAttachmentStripProps { | 7 | export interface ComposerAttachmentStripProps { |
| 8 | /** Attachment images to display. */ | 8 | /** Attachment images to display. */ |
| @@ -22,8 +22,7 @@ export interface ComposerAttachmentStripProps { · 13 unmodified lines | |||
| 22 | * buttons. Used by both the thread composer and the new-task draft screen. | 22 | * buttons. Used by both the thread composer and the new-task draft screen. |
| 23 | */ | 23 | */ |
| 24 | export function ComposerAttachmentStrip(props: ComposerAttachmentStripProps) { | 24 | export function ComposerAttachmentStrip(props: ComposerAttachmentStripProps) { |
| 25 | const isDarkMode = useColorScheme() === "dark"; | 25 | const subtleBg = useThemeColor("--color-subtle"); |
| 26 | const palette = makeAppPalette(isDarkMode); | ||
| 27 | const size = props.imageSize ?? 72; | 26 | const size = props.imageSize ?? 72; |
| 28 | const radius = props.imageBorderRadius ?? 16; | 27 | const radius = props.imageBorderRadius ?? 16; |
| 29 | 28 | ||
| @@ -50,7 +49,7 @@ export function ComposerAttachmentStrip(props: ComposerAttachmentStripProps) { · 20 unmodified lines | |||
| 50 | width: size, | 49 | width: size, |
| 51 | height: size, | 50 | height: size, |
| 52 | borderRadius: radius, | 51 | borderRadius: radius, |
| 53 | backgroundColor: palette.subtleBg, | 52 | backgroundColor: subtleBg, |
| 54 | }} | 53 | }} |
| 55 | resizeMode="cover" | 54 | resizeMode="cover" |
| 56 | /> | 55 | /> |
| @@ -1,9 +1,9 @@ | |||
| 1 | import type { ComponentProps, ReactNode } from "react"; | 1 | import type { ComponentProps, ReactNode } from "react"; |
| 2 | import { Pressable, useColorScheme, View } from "react-native"; | 2 | import { Pressable, View } from "react-native"; |
| 3 | import { SymbolView } from "expo-symbols"; | 3 | import { SymbolView } from "expo-symbols"; |
| 4 | import { useThemeColor } from "../lib/useThemeColor"; | ||
| 4 | 5 | ||
| 5 | import { AppText as Text } from "./AppText"; | 6 | import { AppText as Text } from "./AppText"; |
| 6 | import { makeAppPalette } from "../lib/theme"; | ||
| 7 | 7 | ||
| 8 | export function ControlPill(props: { | 8 | export function ControlPill(props: { |
| 9 | readonly icon?: ComponentProps<typeof SymbolView>["name"]; | 9 | readonly icon?: ComponentProps<typeof SymbolView>["name"]; |
| @@ -13,31 +13,36 @@ export function ControlPill(props: { · 3 unmodified lines | |||
| 13 | readonly variant?: "circle" | "pill" | "primary" | "danger"; | 13 | readonly variant?: "circle" | "pill" | "primary" | "danger"; |
| 14 | readonly disabled?: boolean; | 14 | readonly disabled?: boolean; |
| 15 | }) { | 15 | }) { |
| 16 | const isDarkMode = useColorScheme() === "dark"; | ||
| 17 | const palette = makeAppPalette(isDarkMode); | ||
| 18 | const variant = props.variant ?? "circle"; | 16 | const variant = props.variant ?? "circle"; |
| 17 | |||
| 18 | const subtleBg = useThemeColor("--color-subtle"); | ||
| 19 | const subtleBgStrong = useThemeColor("--color-subtle-strong"); | ||
| 20 | const primaryBg = useThemeColor("--color-primary"); | ||
| 21 | const dangerBg = useThemeColor("--color-danger"); | ||
| 22 | const iconColor = useThemeColor("--color-icon"); | ||
| 23 | const iconSubtle = useThemeColor("--color-icon-subtle"); | ||
| 24 | const primaryFg = useThemeColor("--color-primary-foreground"); | ||
| 25 | const dangerFg = useThemeColor("--color-danger-foreground"); | ||
| 26 | const textColor = useThemeColor("--color-foreground"); | ||
| 27 | const textMuted = useThemeColor("--color-foreground-muted"); | ||
| 28 | |||
| 19 | const backgroundColor = | 29 | const backgroundColor = |
| 20 | variant === "primary" | 30 | variant === "primary" |
| 21 | ? props.disabled | 31 | ? props.disabled |
| 22 | ? palette.subtleBgStrong | 32 | ? subtleBgStrong |
| 23 | : palette.primaryButton | 33 | : primaryBg |
| 24 | : variant === "danger" | 34 | : variant === "danger" |
| 25 | ? palette.dangerButton | 35 | ? dangerBg |
| 26 | : palette.subtleBg; | 36 | : subtleBg; |
| 27 | const iconTintColor = | 37 | const iconTintColor = |
| 28 | variant === "primary" | 38 | variant === "primary" |
| 29 | ? props.disabled | 39 | ? props.disabled |
| 30 | ? palette.iconSubtle | 40 | ? iconSubtle |
| 31 | : palette.primaryButtonText | 41 | : primaryFg |
| 32 | : variant === "danger" | 42 | : variant === "danger" |
| 33 | ? palette.dangerText | 43 | ? dangerFg |
| 34 | : palette.icon; | 44 | : iconColor; |
| 35 | const textColor = | 45 | const labelColor = variant === "primary" ? (props.disabled ? textMuted : primaryFg) : textColor; |
| 36 | variant === "primary" | ||
| 37 | ? props.disabled | ||
| 38 | ? palette.textMuted | ||
| 39 | : palette.primaryButtonText | ||
| 40 | : palette.text; | ||
| 41 | 46 | ||
| 42 | const isCircle = | 47 | const isCircle = |
| 43 | variant === "circle" || variant === "danger" || (variant === "primary" && !props.label); | 48 | variant === "circle" || variant === "danger" || (variant === "primary" && !props.label); |
| @@ -61,7 +66,7 @@ export function ControlPill(props: { · 17 unmodified lines | |||
| 61 | <SymbolView name={props.icon} size={16} tintColor={iconTintColor} type="monochrome" /> | 66 | <SymbolView name={props.icon} size={16} tintColor={iconTintColor} type="monochrome" /> |
| 62 | ) : null} | 67 | ) : null} |
| 63 | {props.label ? ( | 68 | {props.label ? ( |
| 64 | <Text className="text-center text-[12px] font-t3-bold" style={{ color: textColor }}> | 69 | <Text className="text-center text-[12px] font-t3-bold" style={{ color: labelColor }}> |
| 65 | {props.label} | 70 | {props.label} |
| 66 | </Text> | 71 | </Text> |
| 67 | ) : null} | 72 | ) : null} |
| @@ -1,13 +1,12 @@ | |||
| 1 | import { View } from "react-native"; | 1 | import { View } from "react-native"; |
| 2 | 2 | ||
| 3 | import { AppText as Text } from "./AppText"; | 3 | import { AppText as Text } from "./AppText"; |
| 4 | |||
| 4 | export function EmptyState(props: { readonly title: string; readonly detail: string }) { | 5 | export function EmptyState(props: { readonly title: string; readonly detail: string }) { |
| 5 | return ( | 6 | return ( |
| 6 | <View className="rounded-[22px] border border-neutral-200 bg-white p-5 dark:border-white/6 dark:bg-neutral-900"> | 7 | <View className="rounded-[22px] border border-border bg-card p-5"> |
| 7 | <Text className="font-t3-bold text-lg text-neutral-950 dark:text-neutral-50"> | 8 | <Text className="font-t3-bold text-lg text-foreground">{props.title}</Text> |
| 8 | {props.title} | 9 | <Text className="mt-2 font-sans text-sm leading-[21px] text-foreground-muted"> |
| 9 | </Text> | ||
| 10 | <Text className="mt-2 font-sans text-sm leading-[21px] text-neutral-600 dark:text-neutral-400"> | ||
| 11 | {props.detail} | 10 | {props.detail} |
| 12 | </Text> | 11 | </Text> |
| 13 | </View> | 12 | </View> |
| @@ -1,28 +1,26 @@ | |||
| 1 | import { ActivityIndicator, StatusBar, View, useColorScheme } from "react-native"; | 1 | import { ActivityIndicator, StatusBar, View, useColorScheme } from "react-native"; |
| 2 | import { useSafeAreaInsets } from "react-native-safe-area-context"; | 2 | import { useSafeAreaInsets } from "react-native-safe-area-context"; |
| 3 | import { useThemeColor } from "../lib/useThemeColor"; | ||
| 3 | 4 | ||
| 4 | import { makeAppPalette } from "../lib/theme"; | ||
| 5 | import { AppText as Text } from "./AppText"; | 5 | import { AppText as Text } from "./AppText"; |
| 6 | import { BrandMark } from "./BrandMark"; | 6 | import { BrandMark } from "./BrandMark"; |
| 7 | 7 | ||
| 8 | export function LoadingScreen(props: { readonly message: string }) { | 8 | export function LoadingScreen(props: { readonly message: string }) { |
| 9 | const isDarkMode = useColorScheme() === "dark"; | 9 | const colorScheme = useColorScheme(); |
| 10 | const palette = makeAppPalette(isDarkMode); | 10 | const screenBg = useThemeColor("--color-screen"); |
| 11 | const insets = useSafeAreaInsets(); | 11 | const insets = useSafeAreaInsets(); |
| 12 | 12 | ||
| 13 | return ( | 13 | return ( |
| 14 | <View style={{ flex: 1, backgroundColor: palette.screenBackground, paddingTop: insets.top }}> | 14 | <View className="flex-1 bg-screen" style={{ paddingTop: insets.top }}> |
| 15 | <StatusBar | 15 | <StatusBar |
| 16 | barStyle={palette.statusBarStyle} | 16 | barStyle={colorScheme === "dark" ? "light-content" : "dark-content"} |
| 17 | backgroundColor={palette.screenBackground} | 17 | backgroundColor={screenBg as string} |
| 18 | translucent | 18 | translucent |
| 19 | /> | 19 | /> |
| 20 | <View className="flex-1 items-center justify-center gap-5 px-6"> | 20 | <View className="flex-1 items-center justify-center gap-5 px-6"> |
| 21 | <BrandMark compact /> | 21 | <BrandMark compact /> |
| 22 | <ActivityIndicator size="large" /> | 22 | <ActivityIndicator size="large" /> |
| 23 | <Text className="font-t3-bold text-lg text-neutral-950 dark:text-neutral-50"> | 23 | <Text className="font-t3-bold text-lg text-foreground">{props.message}</Text> |
| 24 | {props.message} | ||
| 25 | </Text> | ||
| 26 | </View> | 24 | </View> |
| 27 | </View> | 25 | </View> |
| 28 | ); | 26 | ); |
| @@ -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 | import { View } from "react-native"; | 1 | import { View } from "react-native"; |
| 2 | 2 | ||
| 3 | import { AppText as Text } from "./AppText"; | 3 | import { AppText as Text } from "./AppText"; |
| 4 | import { cx } from "../lib/classNames"; | 4 | import { cn } from "../lib/cn"; |
| 5 | 5 | ||
| 6 | export interface StatusTone { | 6 | export interface StatusTone { |
| 7 | readonly label: string; | 7 | readonly label: string; |
| @@ -17,14 +17,14 @@ export function StatusPill( · 9 unmodified lines | |||
| 17 | const size = props.size ?? "default"; | 17 | const size = props.size ?? "default"; |
| 18 | return ( | 18 | return ( |
| 19 | <View | 19 | <View |
| 20 | className={cx( | 20 | className={cn( |
| 21 | "rounded-full", | 21 | "rounded-full", |
| 22 | size === "compact" ? "px-2.5 py-1" : "px-3 py-1.5", | 22 | size === "compact" ? "px-2.5 py-1" : "px-3 py-1.5", |
| 23 | props.pillClassName, | 23 | props.pillClassName, |
| 24 | )} | 24 | )} |
| 25 | > | 25 | > |
| 26 | <Text | 26 | <Text |
| 27 | className={cx( | 27 | className={cn( |
| 28 | "font-t3-bold", | 28 | "font-t3-bold", |
| 29 | size === "compact" ? "text-[11px]" : "text-xs", | 29 | size === "compact" ? "text-[11px]" : "text-xs", |
| 30 | props.textClassName, | 30 | props.textClassName, |
| @@ -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 | import { Link, Stack } from "expo-router"; | 1 | import { Link, Stack } from "expo-router"; |
| 2 | import { SymbolView } from "expo-symbols"; | 2 | import { SymbolView } from "expo-symbols"; |
| 3 | import { useCallback, useState } from "react"; | 3 | import { useCallback, useState } from "react"; |
| 4 | import { Pressable, ScrollView, View, useColorScheme } from "react-native"; | 4 | import { Pressable, ScrollView, View } from "react-native"; |
| 5 | import Animated, { FadeIn, FadeOut, LinearTransition } from "react-native-reanimated"; | 5 | import Animated, { FadeIn, FadeOut, LinearTransition } from "react-native-reanimated"; |
| 6 | import { useSafeAreaInsets } from "react-native-safe-area-context"; | 6 | import { useSafeAreaInsets } from "react-native-safe-area-context"; |
| 7 | import { useThemeColor } from "../../lib/useThemeColor"; | ||
| 7 | 8 | ||
| 8 | import { AppText as Text, AppTextInput as TextInput } from "../../components/AppText"; | 9 | import { AppText as Text, AppTextInput as TextInput } from "../../components/AppText"; |
| 10 | import { cn } from "../../lib/cn"; | ||
| 9 | import { useRemoteConnections } from "../../state/use-remote-environment-registry"; | 11 | import { useRemoteConnections } from "../../state/use-remote-environment-registry"; |
| 10 | import type { ConnectedEnvironmentSummary } from "../../state/remote-runtime-types"; | 12 | import type { ConnectedEnvironmentSummary } from "../../state/remote-runtime-types"; |
| 11 | import { ConnectionStatusDot } from "./ConnectionStatusDot"; | 13 | import { ConnectionStatusDot } from "./ConnectionStatusDot"; |
| 12 | import { makeConnectionSheetPalette as makePalette } from "./connection-sheet-shared"; | ||
| 13 | 14 | ||
| 14 | function EnvironmentRow(props: { | 15 | function EnvironmentRow(props: { |
| 15 | readonly environment: ConnectedEnvironmentSummary; | 16 | readonly environment: ConnectedEnvironmentSummary; |
| @@ -20,11 +21,15 @@ function EnvironmentRow(props: { · 4 unmodified lines | |||
| 20 | environmentId: string, | 21 | environmentId: string, |
| 21 | updates: { readonly label: string; readonly displayUrl: string }, | 22 | updates: { readonly label: string; readonly displayUrl: string }, |
| 22 | ) => void; | 23 | ) => void; |
| 23 | readonly palette: ReturnType<typeof makePalette>; | ||
| 24 | }) { | 24 | }) { |
| 25 | const [label, setLabel] = useState(props.environment.environmentLabel); | 25 | const [label, setLabel] = useState(props.environment.environmentLabel); |
| 26 | const [url, setUrl] = useState(props.environment.displayUrl); | 26 | const [url, setUrl] = useState(props.environment.displayUrl); |
| 27 | 27 | ||
| 28 | const mutedColor = useThemeColor("--color-icon-subtle"); | ||
| 29 | const placeholderColor = useThemeColor("--color-placeholder"); | ||
| 30 | const primaryFg = useThemeColor("--color-primary-foreground"); | ||
| 31 | const dangerFg = useThemeColor("--color-danger-foreground"); | ||
| 32 | |||
| 28 | const handleSave = useCallback(() => { | 33 | const handleSave = useCallback(() => { |
| 29 | props.onUpdate(props.environment.environmentId, { | 34 | props.onUpdate(props.environment.environmentId, { |
| 30 | label: label.trim(), | 35 | label: label.trim(), |
| @@ -34,26 +39,18 @@ function EnvironmentRow(props: { · 3 unmodified lines | |||
| 34 | }, [label, url, props]); | 39 | }, [label, url, props]); |
| 35 | 40 | ||
| 36 | return ( | 41 | return ( |
| 37 | <Animated.View | 42 | <Animated.View layout={LinearTransition.duration(250)} className="bg-card"> |
| 38 | layout={LinearTransition.duration(250)} | ||
| 39 | style={{ backgroundColor: props.palette.card }} | ||
| 40 | > | ||
| 41 | <Pressable className="flex-row items-center gap-3 px-4 py-3.5" onPress={props.onToggle}> | 43 | <Pressable className="flex-row items-center gap-3 px-4 py-3.5" onPress={props.onToggle}> |
| 42 | <ConnectionStatusDot state={props.environment.connectionState} pulse={false} size={8} /> | 44 | <ConnectionStatusDot state={props.environment.connectionState} pulse={false} size={8} /> |
| 43 | 45 | ||
| 44 | <View className="flex-1 gap-0.5"> | 46 | <View className="flex-1 gap-0.5"> |
| 45 | <Text | 47 | <Text |
| 46 | className="text-[16px] font-t3-bold leading-[21px]" | 48 | className="text-[16px] font-t3-bold leading-[21px] text-foreground" |
| 47 | style={{ color: props.palette.text }} | ||
| 48 | numberOfLines={1} | 49 | numberOfLines={1} |
| 49 | > | 50 | > |
| 50 | {props.environment.environmentLabel} | 51 | {props.environment.environmentLabel} |
| 51 | </Text> | 52 | </Text> |
| 52 | <Text | 53 | <Text className="text-[12px] leading-[16px] text-foreground-muted" numberOfLines={1}> |
| 53 | className="text-[12px] leading-[16px]" | ||
| 54 | style={{ color: props.palette.muted }} | ||
| 55 | numberOfLines={1} | ||
| 56 | > | ||
| 57 | {props.environment.displayUrl} | 54 | {props.environment.displayUrl} |
| 58 | </Text> | 55 | </Text> |
| 59 | </View> | 56 | </View> |
| @@ -61,7 +58,7 @@ function EnvironmentRow(props: { · 1 unmodified line | |||
| 61 | <SymbolView | 58 | <SymbolView |
| 62 | name="chevron.down" | 59 | name="chevron.down" |
| 63 | size={12} | 60 | size={12} |
| 64 | tintColor={props.palette.muted} | 61 | tintColor={mutedColor} |
| 65 | type="monochrome" | 62 | type="monochrome" |
| 66 | style={{ | 63 | style={{ |
| 67 | transform: [{ rotate: props.expanded ? "180deg" : "0deg" }], | 64 | transform: [{ rotate: props.expanded ? "180deg" : "0deg" }], |
| @@ -77,8 +74,8 @@ function EnvironmentRow(props: { · 9 unmodified lines | |||
| 77 | > | 74 | > |
| 78 | <View className="gap-1.5"> | 75 | <View className="gap-1.5"> |
| 79 | <Text | 76 | <Text |
| 80 | className="text-[11px] font-t3-bold uppercase" | 77 | className="text-[11px] font-t3-bold uppercase text-foreground-muted" |
| 81 | style={{ color: props.palette.muted, letterSpacing: 0.8 }} | 78 | style={{ letterSpacing: 0.8 }} |
| 82 | > | 79 | > |
| 83 | Label | 80 | Label |
| 84 | </Text> | 81 | </Text> |
| @@ -86,23 +83,17 @@ function EnvironmentRow(props: { · 1 unmodified line | |||
| 86 | autoCapitalize="words" | 83 | autoCapitalize="words" |
| 87 | autoCorrect={false} | 84 | autoCorrect={false} |
| 88 | placeholder="My MacBook" | 85 | placeholder="My MacBook" |
| 89 | placeholderTextColor={props.palette.placeholder} | 86 | placeholderTextColor={placeholderColor} |
| 90 | value={label} | 87 | value={label} |
| 91 | onChangeText={setLabel} | 88 | onChangeText={setLabel} |
| 92 | className="rounded-[14px] px-4 py-3 text-[15px]" | 89 | className="rounded-[14px] border border-input-border bg-input px-4 py-3 text-[15px] text-foreground" |
| 93 | style={{ | ||
| 94 | backgroundColor: props.palette.inputBackground, | ||
| 95 | borderWidth: 1, | ||
| 96 | borderColor: props.palette.inputBorder, | ||
| 97 | color: props.palette.text, | ||
| 98 | }} | ||
| 99 | /> | 90 | /> |
| 100 | </View> | 91 | </View> |
| 101 | 92 | ||
| 102 | <View className="gap-1.5"> | 93 | <View className="gap-1.5"> |
| 103 | <Text | 94 | <Text |
| 104 | className="text-[11px] font-t3-bold uppercase" | 95 | className="text-[11px] font-t3-bold uppercase text-foreground-muted" |
| 105 | style={{ color: props.palette.muted, letterSpacing: 0.8 }} | 96 | style={{ letterSpacing: 0.8 }} |
| 106 | > | 97 | > |
| 107 | URL | 98 | URL |
| 108 | </Text> | 99 | </Text> |
| @@ -111,59 +102,32 @@ function EnvironmentRow(props: { · 2 unmodified lines | |||
| 111 | autoCorrect={false} | 102 | autoCorrect={false} |
| 112 | keyboardType="url" | 103 | keyboardType="url" |
| 113 | placeholder="192.168.1.100:8080" | 104 | placeholder="192.168.1.100:8080" |
| 114 | placeholderTextColor={props.palette.placeholder} | 105 | placeholderTextColor={placeholderColor} |
| 115 | value={url} | 106 | value={url} |
| 116 | onChangeText={setUrl} | 107 | onChangeText={setUrl} |
| 117 | className="rounded-[14px] px-4 py-3 text-[15px]" | 108 | className="rounded-[14px] border border-input-border bg-input px-4 py-3 text-[15px] text-foreground" |
| 118 | style={{ | ||
| 119 | backgroundColor: props.palette.inputBackground, | ||
| 120 | borderWidth: 1, | ||
| 121 | borderColor: props.palette.inputBorder, | ||
| 122 | color: props.palette.text, | ||
| 123 | }} | ||
| 124 | /> | 109 | /> |
| 125 | </View> | 110 | </View> |
| 126 | 111 | ||
| 127 | <View className="flex-row gap-2"> | 112 | <View className="flex-row gap-2"> |
| 128 | <Pressable | 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 | onPress={handleSave} | 115 | onPress={handleSave} |
| 131 | style={{ | ||
| 132 | backgroundColor: props.palette.primaryButton, | ||
| 133 | }} | ||
| 134 | > | 116 | > |
| 135 | <SymbolView | 117 | <SymbolView name="checkmark" size={13} tintColor={primaryFg} type="monochrome" /> |
| 136 | name="checkmark" | ||
| 137 | size={13} | ||
| 138 | tintColor={props.palette.primaryButtonText} | ||
| 139 | type="monochrome" | ||
| 140 | /> | ||
| 141 | <Text | 118 | <Text |
| 142 | className="text-[12px] font-t3-bold uppercase" | 119 | className="text-[12px] font-t3-bold uppercase text-primary-foreground" |
| 143 | style={{ | 120 | style={{ letterSpacing: 0.8 }} |
| 144 | color: props.palette.primaryButtonText, | ||
| 145 | letterSpacing: 0.8, | ||
| 146 | }} | ||
| 147 | > | 121 | > |
| 148 | Save | 122 | Save |
| 149 | </Text> | 123 | </Text> |
| 150 | </Pressable> | 124 | </Pressable> |
| 151 | 125 | ||
| 152 | <Pressable | 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 | onPress={() => props.onRemove(props.environment.environmentId)} | 128 | onPress={() => props.onRemove(props.environment.environmentId)} |
| 155 | style={{ | ||
| 156 | backgroundColor: props.palette.dangerButton, | ||
| 157 | borderWidth: 1, | ||
| 158 | borderColor: props.palette.dangerBorder, | ||
| 159 | }} | ||
| 160 | > | 129 | > |
| 161 | <SymbolView | 130 | <SymbolView name="trash" size={14} tintColor={dangerFg} type="monochrome" /> |
| 162 | name="trash" | ||
| 163 | size={14} | ||
| 164 | tintColor={props.palette.dangerText} | ||
| 165 | type="monochrome" | ||
| 166 | /> | ||
| 167 | </Pressable> | 131 | </Pressable> |
| 168 | </View> | 132 | </View> |
| 169 | </Animated.View> | 133 | </Animated.View> |
| @@ -176,30 +140,29 @@ export function ConnectionsRouteScreen() { · 6 unmodified lines | |||
| 176 | const { connectedEnvironments, onRemoveEnvironmentPress, onUpdateEnvironment } = | 140 | const { connectedEnvironments, onRemoveEnvironmentPress, onUpdateEnvironment } = |
| 177 | useRemoteConnections(); | 141 | useRemoteConnections(); |
| 178 | const insets = useSafeAreaInsets(); | 142 | const insets = useSafeAreaInsets(); |
| 179 | const isDarkMode = useColorScheme() === "dark"; | ||
| 180 | const palette = makePalette(isDarkMode); | ||
| 181 | const hasEnvironments = connectedEnvironments.length > 0; | 143 | const hasEnvironments = connectedEnvironments.length > 0; |
| 182 | const [expandedId, setExpandedId] = useState<string | null>(null); | 144 | const [expandedId, setExpandedId] = useState<string | null>(null); |
| 183 | 145 | ||
| 146 | const primaryFg = useThemeColor("--color-primary-foreground"); | ||
| 147 | const accentColor = useThemeColor("--color-icon-muted"); | ||
| 148 | const mutedColor = useThemeColor("--color-icon-subtle"); | ||
| 149 | |||
| 184 | const handleToggle = useCallback((environmentId: string) => { | 150 | const handleToggle = useCallback((environmentId: string) => { |
| 185 | setExpandedId((prev) => (prev === environmentId ? null : environmentId)); | 151 | setExpandedId((prev) => (prev === environmentId ? null : environmentId)); |
| 186 | }, []); | 152 | }, []); |
| 187 | 153 | ||
| 188 | return ( | 154 | return ( |
| 189 | <View collapsable={false} style={{ flex: 1, backgroundColor: palette.sheet }}> | 155 | <View collapsable={false} className="flex-1 bg-sheet"> |
| 190 | <Stack.Screen | 156 | <Stack.Screen |
| 191 | options={{ | 157 | options={{ |
| 192 | title: "Backends", | 158 | title: "Backends", |
| 193 | headerRight: () => ( | 159 | headerRight: () => ( |
| 194 | <Link href="/connections/new" asChild> | 160 | <Link href="/connections/new" asChild> |
| 195 | <Pressable | 161 | <Pressable className="h-10 w-10 items-center justify-center rounded-full bg-primary"> |
| 196 | className="h-10 w-10 items-center justify-center rounded-full" | ||
| 197 | style={{ backgroundColor: palette.primaryButton }} | ||
| 198 | > | ||
| 199 | <SymbolView | 162 | <SymbolView |
| 200 | name="plus" | 163 | name="plus" |
| 201 | size={18} | 164 | size={18} |
| 202 | tintColor={palette.primaryButtonText} | 165 | tintColor={primaryFg} |
| 203 | type="monochrome" | 166 | type="monochrome" |
| 204 | weight="semibold" | 167 | weight="semibold" |
| 205 | /> | 168 | /> |
| @@ -219,19 +182,15 @@ export function ConnectionsRouteScreen() { · 13 unmodified lines | |||
| 219 | }} | 182 | }} |
| 220 | > | 183 | > |
| 221 | {hasEnvironments ? ( | 184 | {hasEnvironments ? ( |
| 222 | <View | 185 | <View collapsable={false} className="overflow-hidden rounded-[24px] bg-card"> |
| 223 | collapsable={false} | ||
| 224 | className="overflow-hidden rounded-[24px]" | ||
| 225 | style={{ backgroundColor: palette.card }} | ||
| 226 | > | ||
| 227 | {connectedEnvironments.map((environment, index) => ( | 186 | {connectedEnvironments.map((environment, index) => ( |
| 228 | <View | 187 | <View |
| 229 | key={environment.environmentId} | 188 | key={environment.environmentId} |
| 230 | collapsable={false} | 189 | collapsable={false} |
| 231 | style={{ | 190 | style={{ |
| 232 | borderTopWidth: index === 0 ? 0 : 1, | 191 | borderTopWidth: index === 0 ? 0 : 1, |
| 233 | borderTopColor: palette.border, | ||
| 234 | }} | 192 | }} |
| 193 | className={cn(index !== 0 && "border-border")} | ||
| 235 | > | 194 | > |
| 236 | <EnvironmentRow | 195 | <EnvironmentRow |
| 237 | environment={environment} | 196 | environment={environment} |
| @@ -239,37 +198,23 @@ export function ConnectionsRouteScreen() { · 1 unmodified line | |||
| 239 | onToggle={() => handleToggle(environment.environmentId)} | 198 | onToggle={() => handleToggle(environment.environmentId)} |
| 240 | onRemove={onRemoveEnvironmentPress} | 199 | onRemove={onRemoveEnvironmentPress} |
| 241 | onUpdate={onUpdateEnvironment} | 200 | onUpdate={onUpdateEnvironment} |
| 242 | palette={palette} | ||
| 243 | /> | 201 | /> |
| 244 | </View> | 202 | </View> |
| 245 | ))} | 203 | ))} |
| 246 | </View> | 204 | </View> |
| 247 | ) : ( | 205 | ) : ( |
| 248 | <View | 206 | <View collapsable={false} className="items-center gap-3 rounded-[24px] bg-card px-6 py-8"> |
| 249 | collapsable={false} | 207 | <View className="h-12 w-12 items-center justify-center rounded-[16px] bg-subtle"> |
| 250 | className="items-center gap-3 rounded-[24px] px-6 py-8" | ||
| 251 | style={{ backgroundColor: palette.card }} | ||
| 252 | > | ||
| 253 | <View | ||
| 254 | className="h-12 w-12 items-center justify-center rounded-[16px]" | ||
| 255 | style={{ backgroundColor: palette.iconBg }} | ||
| 256 | > | ||
| 257 | <SymbolView | 208 | <SymbolView |
| 258 | name="point.3.connected.trianglepath.dotted" | 209 | name="point.3.connected.trianglepath.dotted" |
| 259 | size={20} | 210 | size={20} |
| 260 | tintColor={palette.accent} | 211 | tintColor={accentColor} |
| 261 | type="monochrome" | 212 | type="monochrome" |
| 262 | /> | 213 | /> |
| 263 | </View> | 214 | </View> |
| 264 | <Text | 215 | <Text className="text-center text-[14px] leading-[20px] text-foreground-muted"> |
| 265 | className="text-center text-[14px] leading-[20px]" | ||
| 266 | style={{ color: palette.muted }} | ||
| 267 | > | ||
| 268 | No backends connected yet.{"\n"}Tap{" "} | 216 | No backends connected yet.{"\n"}Tap{" "} |
| 269 | <Text className="font-t3-bold" style={{ color: palette.text }}> | 217 | <Text className="font-t3-bold text-foreground">+</Text> to add one. |
| 270 | + | ||
| 271 | </Text>{" "} | ||
| 272 | to add one. | ||
| 273 | </Text> | 218 | </Text> |
| 274 | </View> | 219 | </View> |
| 275 | )} | 220 | )} |
| @@ -2,18 +2,18 @@ import { CameraView, useCameraPermissions } from "expo-camera"; · 1 unmodified line | |||
| 2 | import { Stack, useLocalSearchParams, useRouter } from "expo-router"; | 2 | import { Stack, useLocalSearchParams, useRouter } from "expo-router"; |
| 3 | import { SymbolView } from "expo-symbols"; | 3 | import { SymbolView } from "expo-symbols"; |
| 4 | import { useCallback, useEffect, useState } from "react"; | 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 | import { useSafeAreaInsets } from "react-native-safe-area-context"; | 6 | import { useSafeAreaInsets } from "react-native-safe-area-context"; |
| 7 | import { useThemeColor } from "../../lib/useThemeColor"; | ||
| 7 | 8 | ||
| 8 | import { AppText as Text, AppTextInput as TextInput } from "../../components/AppText"; | 9 | import { AppText as Text, AppTextInput as TextInput } from "../../components/AppText"; |
| 9 | import { ErrorBanner } from "../../components/ErrorBanner"; | 10 | import { ErrorBanner } from "../../components/ErrorBanner"; |
| 10 | import { dismissRoute } from "../../lib/routes"; | 11 | import { dismissRoute } from "../../lib/routes"; |
| 11 | import { extractPairingUrlFromQrPayload } from "../../lib/pairingQr"; | 12 | import { extractPairingUrlFromQrPayload } from "./pairingQr"; |
| 12 | import { useRemoteConnections } from "../../state/use-remote-environment-registry"; | 13 | import { useRemoteConnections } from "../../state/use-remote-environment-registry"; |
| 13 | import { | 14 | import { |
| 14 | buildPairingUrl, | 15 | buildPairingUrl, |
| 15 | ConnectionSheetButton as SheetButton, | 16 | ConnectionSheetButton as SheetButton, |
| 16 | makeConnectionSheetPalette as makePalette, | ||
| 17 | parsePairingUrl, | 17 | parsePairingUrl, |
| 18 | } from "./connection-sheet-shared"; | 18 | } from "./connection-sheet-shared"; |
| 19 | 19 | ||
| @@ -28,8 +28,6 @@ export function NewConnectionRouteScreen() { · 8 unmodified lines | |||
| 28 | const router = useRouter(); | 28 | const router = useRouter(); |
| 29 | const params = useLocalSearchParams<{ mode?: string }>(); | 29 | const params = useLocalSearchParams<{ mode?: string }>(); |
| 30 | const insets = useSafeAreaInsets(); | 30 | const insets = useSafeAreaInsets(); |
| 31 | const isDarkMode = useColorScheme() === "dark"; | ||
| 32 | const palette = makePalette(isDarkMode); | ||
| 33 | const [hostInput, setHostInput] = useState(""); | 31 | const [hostInput, setHostInput] = useState(""); |
| 34 | const [codeInput, setCodeInput] = useState(""); | 32 | const [codeInput, setCodeInput] = useState(""); |
| 35 | const [isSubmitting, setIsSubmitting] = useState(false); | 33 | const [isSubmitting, setIsSubmitting] = useState(false); |
| @@ -37,6 +35,9 @@ export function NewConnectionRouteScreen() { · 1 unmodified line | |||
| 37 | const [cameraPermission, requestCameraPermission] = useCameraPermissions(); | 35 | const [cameraPermission, requestCameraPermission] = useCameraPermissions(); |
| 38 | const [scannerLocked, setScannerLocked] = useState(false); | 36 | const [scannerLocked, setScannerLocked] = useState(false); |
| 39 | 37 | ||
| 38 | const textColor = useThemeColor("--color-icon"); | ||
| 39 | const placeholderColor = useThemeColor("--color-placeholder"); | ||
| 40 | |||
| 40 | const connectDisabled = | 41 | const connectDisabled = |
| 41 | isSubmitting || connectionState === "connecting" || hostInput.trim().length === 0; | 42 | isSubmitting || connectionState === "connecting" || hostInput.trim().length === 0; |
| 42 | 43 | ||
| @@ -125,13 +126,13 @@ export function NewConnectionRouteScreen() { · 82 unmodified lines | |||
| 125 | }, [codeInput, hostInput, onChangeConnectionPairingUrl, onConnectPress, router]); | 126 | }, [codeInput, hostInput, onChangeConnectionPairingUrl, onConnectPress, router]); |
| 126 | 127 | ||
| 127 | return ( | 128 | return ( |
| 128 | <View collapsable={false} style={{ flex: 1, backgroundColor: palette.sheet }}> | 129 | <View collapsable={false} className="flex-1 bg-sheet"> |
| 129 | <Stack.Screen | 130 | <Stack.Screen |
| 130 | options={{ | 131 | options={{ |
| 131 | title: showScanner ? "Scan QR Code" : "Add Backend", | 132 | title: showScanner ? "Scan QR Code" : "Add Backend", |
| 132 | headerRight: () => ( | 133 | headerRight: () => ( |
| 133 | <Pressable | 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 | onPress={() => { | 136 | onPress={() => { |
| 136 | if (showScanner) { | 137 | if (showScanner) { |
| 137 | closeScanner(); | 138 | closeScanner(); |
| @@ -139,16 +140,11 @@ export function NewConnectionRouteScreen() { · 1 unmodified line | |||
| 139 | void openScanner(); | 140 | void openScanner(); |
| 140 | } | 141 | } |
| 141 | }} | 142 | }} |
| 142 | style={{ | ||
| 143 | backgroundColor: palette.secondaryButton, | ||
| 144 | borderWidth: 1, | ||
| 145 | borderColor: palette.border, | ||
| 146 | }} | ||
| 147 | > | 143 | > |
| 148 | <SymbolView | 144 | <SymbolView |
| 149 | name={showScanner ? "xmark" : "qrcode.viewfinder"} | 145 | name={showScanner ? "xmark" : "qrcode.viewfinder"} |
| 150 | size={showScanner ? 14 : 18} | 146 | size={showScanner ? 14 : 18} |
| 151 | tintColor={palette.text} | 147 | tintColor={textColor} |
| 152 | type="monochrome" | 148 | type="monochrome" |
| 153 | weight="semibold" | 149 | weight="semibold" |
| 154 | /> | 150 | /> |
| @@ -182,20 +178,16 @@ export function NewConnectionRouteScreen() { · 27 unmodified lines | |||
| 182 | </View> | 178 | </View> |
| 183 | ) : ( | 179 | ) : ( |
| 184 | <View | 180 | <View |
| 185 | className="items-center gap-3 rounded-[24px] px-5 py-8" | 181 | className="items-center gap-3 rounded-[24px] bg-card px-5 py-8" |
| 186 | style={{ backgroundColor: palette.card, borderCurve: "continuous" }} | 182 | style={{ borderCurve: "continuous" }} |
| 187 | > | 183 | > |
| 188 | <Text | 184 | <Text className="text-center text-[14px] leading-[20px] text-foreground-muted"> |
| 189 | className="text-center text-[14px] leading-[20px]" | ||
| 190 | style={{ color: palette.muted }} | ||
| 191 | > | ||
| 192 | Camera permission is required to scan a QR code. | 185 | Camera permission is required to scan a QR code. |
| 193 | </Text> | 186 | </Text> |
| 194 | <SheetButton | 187 | <SheetButton |
| 195 | compact | 188 | compact |
| 196 | icon="camera" | 189 | icon="camera" |
| 197 | label="Allow camera" | 190 | label="Allow camera" |
| 198 | palette={palette} | ||
| 199 | tone="secondary" | 191 | tone="secondary" |
| 200 | onPress={() => { | 192 | onPress={() => { |
| 201 | void openScanner(); | 193 | void openScanner(); |
| @@ -204,15 +196,11 @@ export function NewConnectionRouteScreen() { · 2 unmodified lines | |||
| 204 | </View> | 196 | </View> |
| 205 | ) | 197 | ) |
| 206 | ) : ( | 198 | ) : ( |
| 207 | <View | 199 | <View collapsable={false} className="gap-4 rounded-[24px] bg-card p-4"> |
| 208 | collapsable={false} | ||
| 209 | className="gap-4 rounded-[24px] p-4" | ||
| 210 | style={{ backgroundColor: palette.card }} | ||
| 211 | > | ||
| 212 | <View collapsable={false} className="gap-1.5"> | 200 | <View collapsable={false} className="gap-1.5"> |
| 213 | <Text | 201 | <Text |
| 214 | className="text-[11px] font-t3-bold uppercase" | 202 | className="text-[11px] font-t3-bold uppercase text-foreground-muted" |
| 215 | style={{ color: palette.muted, letterSpacing: 0.8 }} | 203 | style={{ letterSpacing: 0.8 }} |
| 216 | > | 204 | > |
| 217 | Host | 205 | Host |
| 218 | </Text> | 206 | </Text> |
| @@ -221,23 +209,17 @@ export function NewConnectionRouteScreen() { · 2 unmodified lines | |||
| 221 | autoCorrect={false} | 209 | autoCorrect={false} |
| 222 | keyboardType="url" | 210 | keyboardType="url" |
| 223 | placeholder="192.168.1.100:8080" | 211 | placeholder="192.168.1.100:8080" |
| 224 | placeholderTextColor={palette.placeholder} | 212 | placeholderTextColor={placeholderColor} |
| 225 | value={hostInput} | 213 | value={hostInput} |
| 226 | onChangeText={handleHostChange} | 214 | onChangeText={handleHostChange} |
| 227 | className="rounded-[14px] px-4 py-3.5 text-[15px]" | 215 | className="rounded-[14px] border border-input-border bg-input px-4 py-3.5 text-[15px] text-foreground" |
| 228 | style={{ | ||
| 229 | backgroundColor: palette.inputBackground, | ||
| 230 | borderWidth: 1, | ||
| 231 | borderColor: palette.inputBorder, | ||
| 232 | color: palette.text, | ||
| 233 | }} | ||
| 234 | /> | 216 | /> |
| 235 | </View> | 217 | </View> |
| 236 | 218 | ||
| 237 | <View collapsable={false} className="gap-1.5"> | 219 | <View collapsable={false} className="gap-1.5"> |
| 238 | <Text | 220 | <Text |
| 239 | className="text-[11px] font-t3-bold uppercase" | 221 | className="text-[11px] font-t3-bold uppercase text-foreground-muted" |
| 240 | style={{ color: palette.muted, letterSpacing: 0.8 }} | 222 | style={{ letterSpacing: 0.8 }} |
| 241 | > | 223 | > |
| 242 | Pairing code | 224 | Pairing code |
| 243 | </Text> | 225 | </Text> |
| @@ -245,16 +227,10 @@ export function NewConnectionRouteScreen() { · 1 unmodified line | |||
| 245 | autoCapitalize="none" | 227 | autoCapitalize="none" |
| 246 | autoCorrect={false} | 228 | autoCorrect={false} |
| 247 | placeholder="abc-123-xyz" | 229 | placeholder="abc-123-xyz" |
| 248 | placeholderTextColor={palette.placeholder} | 230 | placeholderTextColor={placeholderColor} |
| 249 | value={codeInput} | 231 | value={codeInput} |
| 250 | onChangeText={handleCodeChange} | 232 | onChangeText={handleCodeChange} |
| 251 | className="rounded-[14px] px-4 py-3.5 text-[15px]" | 233 | className="rounded-[14px] border border-input-border bg-input px-4 py-3.5 text-[15px] text-foreground" |
| 252 | style={{ | ||
| 253 | backgroundColor: palette.inputBackground, | ||
| 254 | borderWidth: 1, | ||
| 255 | borderColor: palette.inputBorder, | ||
| 256 | color: palette.text, | ||
| 257 | }} | ||
| 258 | /> | 234 | /> |
| 259 | </View> | 235 | </View> |
| 260 | 236 | ||
| @@ -263,10 +239,9 @@ export function NewConnectionRouteScreen() { · 2 unmodified lines | |||
| 263 | <SheetButton | 239 | <SheetButton |
| 264 | icon="plus" | 240 | icon="plus" |
| 265 | label={ | 241 | label={ |
| 266 | isSubmitting || connectionState === "connecting" ? "Pairing…" : "Add backend" | 242 | isSubmitting || connectionState === "connecting" ? "Pairing..." : "Add backend" |
| 267 | } | 243 | } |
| 268 | disabled={connectDisabled} | 244 | disabled={connectDisabled} |
| 269 | palette={palette} | ||
| 270 | tone="primary" | 245 | tone="primary" |
| 271 | onPress={() => { | 246 | onPress={() => { |
| 272 | void handleSubmit(); | 247 | void handleSubmit(); |
| @@ -1,7 +1,9 @@ | |||
| 1 | import { SymbolView } from "expo-symbols"; | 1 | import { SymbolView } from "expo-symbols"; |
| 2 | import { Platform, Pressable } from "react-native"; | 2 | import { Platform, Pressable } from "react-native"; |
| 3 | import { useThemeColor } from "../../lib/useThemeColor"; | ||
| 3 | 4 | ||
| 4 | import { AppText as Text } from "../../components/AppText"; | 5 | import { AppText as Text } from "../../components/AppText"; |
| 6 | import { cn } from "../../lib/cn"; | ||
| 5 | 7 | ||
| 6 | export function parsePairingUrl(url: string): { host: string; code: string } { | 8 | export function parsePairingUrl(url: string): { host: string; code: string } { |
| 7 | const trimmed = url.trim(); | 9 | const trimmed = url.trim(); |
| @@ -38,73 +40,27 @@ export function buildPairingUrl(host: string, code: string): string { · 30 unmodified lines | |||
| 38 | } | 40 | } |
| 39 | } | 41 | } |
| 40 | 42 | ||
| 41 | export function makeConnectionSheetPalette(isDarkMode: boolean) { | 43 | const CARD_SHADOW = Platform.select({ |
| 42 | if (isDarkMode) { | 44 | ios: { |
| 43 | return { | 45 | shadowColor: "rgba(23,23,23,0.08)", |
| 44 | sheet: "rgba(18,20,23,0.98)", | 46 | shadowOffset: { width: 0, height: 4 }, |
| 45 | card: "#1f2329", | 47 | shadowOpacity: 1, |
| 46 | cardAlt: "#242a31", | 48 | shadowRadius: 16, |
| 47 | border: "rgba(255,255,255,0.06)", | 49 | }, |
| 48 | text: "#f8fafc", | 50 | android: { elevation: 3 }, |
| 49 | muted: "#94a3b8", | 51 | }); |
| 50 | subtle: "#cbd5e1", | ||
| 51 | accent: "#f59e0b", | ||
| 52 | accentSubtle: "rgba(245,158,11,0.12)", | ||
| 53 | iconBg: "rgba(245,158,11,0.10)", | ||
| 54 | inputBackground: "#171b20", | ||
| 55 | inputBorder: "rgba(255,255,255,0.06)", | ||
| 56 | primaryButton: "#f8fafc", | ||
| 57 | primaryButtonText: "#171717", | ||
| 58 | secondaryButton: "rgba(255,255,255,0.05)", | ||
| 59 | secondaryButtonText: "#f8fafc", | ||
| 60 | dangerButton: "rgba(190,24,93,0.14)", | ||
| 61 | dangerBorder: "rgba(244,114,182,0.18)", | ||
| 62 | dangerText: "#fda4af", | ||
| 63 | placeholder: "#64748b", | ||
| 64 | cardShadow: { | ||
| 65 | shadowColor: "#000", | ||
| 66 | shadowOffset: { width: 0, height: 2 }, | ||
| 67 | shadowOpacity: 0.18, | ||
| 68 | shadowRadius: 8, | ||
| 69 | elevation: 4, | ||
| 70 | } as const, | ||
| 71 | separator: "rgba(255,255,255,0.04)", | ||
| 72 | }; | ||
| 73 | } | ||
| 74 | 52 | ||
| 75 | return { | 53 | const CARD_SHADOW_DARK = Platform.select({ |
| 76 | sheet: "rgba(246,244,239,0.98)", | 54 | ios: { |
| 77 | card: "#fffdf9", | 55 | shadowColor: "#000", |
| 78 | cardAlt: "#f8f4ec", | 56 | shadowOffset: { width: 0, height: 2 }, |
| 79 | border: "rgba(23,23,23,0.06)", | 57 | shadowOpacity: 0.18, |
| 80 | text: "#171717", | 58 | shadowRadius: 8, |
| 81 | muted: "#78716c", | 59 | }, |
| 82 | subtle: "#57534e", | 60 | android: { elevation: 4 }, |
| 83 | accent: "#a16207", | 61 | }); |
| 84 | accentSubtle: "rgba(161,98,7,0.08)", | ||
| 85 | iconBg: "rgba(161,98,7,0.07)", | ||
| 86 | inputBackground: "#ffffff", | ||
| 87 | inputBorder: "rgba(23,23,23,0.06)", | ||
| 88 | primaryButton: "#171717", | ||
| 89 | primaryButtonText: "#fafaf9", | ||
| 90 | secondaryButton: "#f1ece3", | ||
| 91 | secondaryButtonText: "#171717", | ||
| 92 | dangerButton: "#fff1f2", | ||
| 93 | dangerBorder: "rgba(225,29,72,0.10)", | ||
| 94 | dangerText: "#be123c", | ||
| 95 | placeholder: "#94a3b8", | ||
| 96 | cardShadow: { | ||
| 97 | shadowColor: "rgba(23,23,23,0.08)", | ||
| 98 | shadowOffset: { width: 0, height: 4 }, | ||
| 99 | shadowOpacity: 1, | ||
| 100 | shadowRadius: 16, | ||
| 101 | elevation: 3, | ||
| 102 | } as const, | ||
| 103 | separator: "rgba(23,23,23,0.04)", | ||
| 104 | }; | ||
| 105 | } | ||
| 106 | 62 | ||
| 107 | export type ConnectionSheetPalette = ReturnType<typeof makeConnectionSheetPalette>; | 63 | export { CARD_SHADOW, CARD_SHADOW_DARK }; |
| 108 | 64 | ||
| 109 | export function ConnectionSheetButton(props: { | 65 | export function ConnectionSheetButton(props: { |
| 110 | readonly icon: React.ComponentProps<typeof SymbolView>["name"]; | 66 | readonly icon: React.ComponentProps<typeof SymbolView>["name"]; |
| @@ -112,27 +68,36 @@ export function ConnectionSheetButton(props: { · 1 unmodified line | |||
| 112 | readonly disabled?: boolean; | 68 | readonly disabled?: boolean; |
| 113 | readonly tone?: "primary" | "secondary" | "danger"; | 69 | readonly tone?: "primary" | "secondary" | "danger"; |
| 114 | readonly compact?: boolean; | 70 | readonly compact?: boolean; |
| 115 | readonly palette: ConnectionSheetPalette; | ||
| 116 | readonly onPress: () => void; | 71 | readonly onPress: () => void; |
| 117 | }) { | 72 | }) { |
| 118 | const tone = props.tone ?? "secondary"; | 73 | const tone = props.tone ?? "secondary"; |
| 74 | |||
| 75 | const primaryBg = useThemeColor("--color-primary"); | ||
| 76 | const primaryFg = useThemeColor("--color-primary-foreground"); | ||
| 77 | const dangerBg = useThemeColor("--color-danger"); | ||
| 78 | const dangerBorderColor = useThemeColor("--color-danger-border"); | ||
| 79 | const dangerFg = useThemeColor("--color-danger-foreground"); | ||
| 80 | const secondaryBg = useThemeColor("--color-secondary"); | ||
| 81 | const secondaryFg = useThemeColor("--color-secondary-foreground"); | ||
| 82 | const borderColor = useThemeColor("--color-border"); | ||
| 83 | |||
| 119 | const colors = | 84 | const colors = |
| 120 | tone === "primary" | 85 | tone === "primary" |
| 121 | ? { | 86 | ? { |
| 122 | backgroundColor: props.palette.primaryButton, | 87 | backgroundColor: primaryBg, |
| 123 | borderColor: "transparent", | 88 | borderColor: "transparent", |
| 124 | textColor: props.palette.primaryButtonText, | 89 | textColor: primaryFg, |
| 125 | } | 90 | } |
| 126 | : tone === "danger" | 91 | : tone === "danger" |
| 127 | ? { | 92 | ? { |
| 128 | backgroundColor: props.palette.dangerButton, | 93 | backgroundColor: dangerBg, |
| 129 | borderColor: props.palette.dangerBorder, | 94 | borderColor: dangerBorderColor, |
| 130 | textColor: props.palette.dangerText, | 95 | textColor: dangerFg, |
| 131 | } | 96 | } |
| 132 | : { | 97 | : { |
| 133 | backgroundColor: props.palette.secondaryButton, | 98 | backgroundColor: secondaryBg, |
| 134 | borderColor: props.palette.border, | 99 | borderColor: borderColor, |
| 135 | textColor: props.palette.secondaryButtonText, | 100 | textColor: secondaryFg, |
| 136 | }; | 101 | }; |
| 137 | 102 | ||
| 138 | const primaryShadow = | 103 | const primaryShadow = |
| @@ -150,11 +115,11 @@ export function ConnectionSheetButton(props: { · 11 unmodified lines | |||
| 150 | 115 | ||
| 151 | return ( | 116 | return ( |
| 152 | <Pressable | 117 | <Pressable |
| 153 | className={ | 118 | className={cn( |
| 154 | props.compact | 119 | props.compact |
| 155 | ? "min-h-[42px] flex-row items-center justify-center gap-1.5 rounded-[14px] px-3.5 py-2.5" | 120 | ? "min-h-[42px] flex-row items-center justify-center gap-1.5 rounded-[14px] px-3.5 py-2.5" |
| 156 | : "min-h-[48px] flex-row items-center justify-center gap-2 rounded-[16px] px-4 py-3" | 121 | : "min-h-[48px] flex-row items-center justify-center gap-2 rounded-[16px] px-4 py-3", |
| 157 | } | 122 | )} |
| 158 | disabled={props.disabled} | 123 | disabled={props.disabled} |
| 159 | onPress={props.onPress} | 124 | onPress={props.onPress} |
| 160 | style={[ | 125 | style={[ |
| @@ -1,15 +1,37 @@ | |||
| 1 | import { useFocusEffect, useRouter } from "expo-router"; | 1 | import { Stack, useFocusEffect, useRouter } from "expo-router"; |
| 2 | import { useCallback } from "react"; | 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 | import { buildThreadRoutePath } from "../../lib/routes"; | 6 | import { buildThreadRoutePath } from "../../lib/routes"; |
| 7 | import { ConnectionStatusDot } from "../connection/ConnectionStatusDot"; | ||
| 5 | import { useRemoteCatalog } from "../../state/use-remote-catalog"; | 8 | import { useRemoteCatalog } from "../../state/use-remote-catalog"; |
| 9 | import { useRemoteEnvironmentState } from "../../state/use-remote-environment-registry"; | ||
| 6 | import { useThreadSelection } from "../../state/use-thread-selection"; | 10 | import { useThreadSelection } from "../../state/use-thread-selection"; |
| 7 | import { HomeScreen } from "./HomeScreen"; | 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 | export function HomeRouteScreen() { | 25 | export function HomeRouteScreen() { |
| 10 | const { connectionState, hasRemoteActivity, projects, threads } = useRemoteCatalog(); | 26 | const { connectionState, hasRemoteActivity, projects, threads } = useRemoteCatalog(); |
| 27 | const { savedConnectionsById } = useRemoteEnvironmentState(); | ||
| 11 | const { onBackFromThread, onSelectThread } = useThreadSelection(); | 28 | const { onBackFromThread, onSelectThread } = useThreadSelection(); |
| 12 | const router = useRouter(); | 29 | const router = useRouter(); |
| 30 | const [searchQuery, setSearchQuery] = useState(""); | ||
| 31 | |||
| 32 | const isDark = useColorScheme() === "dark"; | ||
| 33 | const iconColor = String(useThemeColor("--color-icon")); | ||
| 34 | const secondaryFg = isDark ? "#a3a3a3" : "#525252"; | ||
| 13 | 35 | ||
| 14 | useFocusEffect( | 36 | useFocusEffect( |
| 15 | useCallback(() => { | 37 | useCallback(() => { |
| @@ -18,19 +40,108 @@ export function HomeRouteScreen() { · 2 unmodified lines | |||
| 18 | ); | 40 | ); |
| 19 | 41 | ||
| 20 | return ( | 42 | return ( |
| 21 | <HomeScreen | 43 | <> |
| 22 | projects={projects} | 44 | <Stack.Screen |
| 23 | threads={threads} | 45 | options={{ |
| 24 | connectionState={connectionState} | 46 | headerShown: true, |
| 25 | connectionPulse={hasRemoteActivity} | 47 | headerTransparent: true, |
| 26 | onOpenConnectionEditor={() => router.push("/connections")} | 48 | headerStyle: { backgroundColor: "transparent" }, |
| 27 | onOpenNewTask={() => router.push("/new")} | 49 | headerShadowVisible: false, |
| 28 | onSelectThread={(thread) => { | 50 | headerTintColor: iconColor, |
| 29 | onSelectThread(thread); | 51 | headerTitle: "", |
| 30 | router.push(buildThreadRoutePath(thread)); | 52 | headerRight: () => ( |
| 31 | }} | 53 | <Pressable |
| 32 | showFloatingConnectionButton | 54 | onPress={() => router.push("/connections")} |
| 33 | showFloatingNewTaskButton | 55 | hitSlop={8} |
| 34 | /> | 56 | style={{ |
| 57 | flexDirection: "row", | ||
| 58 | alignItems: "center", | ||
| 59 | gap: 8, | ||
| 60 | paddingHorizontal: 4, | ||
| 61 | paddingVertical: 4, | ||
| 62 | }} | ||
| 63 | > | ||
| 64 | <ConnectionStatusDot state={connectionState} pulse={hasRemoteActivity} size={7} /> | ||
| 65 | <RNText | ||
| 66 | style={{ | ||
| 67 | fontFamily: "DMSans_700Bold", | ||
| 68 | fontSize: 12, | ||
| 69 | color: secondaryFg, | ||
| 70 | }} | ||
| 71 | > | ||
| 72 | {CONNECTION_LABEL[connectionState] ?? "Unknown"} | ||
| 73 | </RNText> | ||
| 74 | </Pressable> | ||
| 75 | ), | ||
| 76 | headerSearchBarOptions: { | ||
| 77 | placeholder: "Search threads", | ||
| 78 | onChangeText: (event) => { | ||
| 79 | setSearchQuery(event.nativeEvent.text); | ||
| 80 | }, | ||
| 81 | allowToolbarIntegration: true, | ||
| 82 | }, | ||
| 83 | }} | ||
| 84 | /> | ||
| 85 | |||
| 86 | {/* Header left: plain text, no Liquid Glass button chrome */} | ||
| 87 | <Stack.Toolbar placement="left"> | ||
| 88 | <Stack.Toolbar.View hidesSharedBackground> | ||
| 89 | <View style={{ flexDirection: "row", alignItems: "center", gap: 8 }}> | ||
| 90 | <RNText | ||
| 91 | style={{ | ||
| 92 | fontFamily: "DMSans_700Bold", | ||
| 93 | fontSize: 17, | ||
| 94 | color: iconColor, | ||
| 95 | letterSpacing: -0.4, | ||
| 96 | }} | ||
| 97 | > | ||
| 98 | T3 Code | ||
| 99 | </RNText> | ||
| 100 | <View | ||
| 101 | style={{ | ||
| 102 | backgroundColor: isDark ? "rgba(255,255,255,0.04)" : "rgba(0,0,0,0.04)", | ||
| 103 | borderRadius: 99, | ||
| 104 | paddingHorizontal: 8, | ||
| 105 | paddingVertical: 3, | ||
| 106 | }} | ||
| 107 | > | ||
| 108 | <RNText | ||
| 109 | style={{ | ||
| 110 | fontFamily: "DMSans_700Bold", | ||
| 111 | fontSize: 10, | ||
| 112 | color: "#737373", | ||
| 113 | letterSpacing: 1.1, | ||
| 114 | textTransform: "uppercase", | ||
| 115 | }} | ||
| 116 | > | ||
| 117 | Alpha | ||
| 118 | </RNText> | ||
| 119 | </View> | ||
| 120 | </View> | ||
| 121 | </Stack.Toolbar.View> | ||
| 122 | </Stack.Toolbar> | ||
| 123 | |||
| 124 | {/* Bottom toolbar: search + compose, visually split like iMessage */} | ||
| 125 | <Stack.Toolbar placement="bottom"> | ||
| 126 | <Stack.Toolbar.SearchBarSlot /> | ||
| 127 | <Stack.Toolbar.Spacer width={8} sharesBackground={false} /> | ||
| 128 | <Stack.Toolbar.Button | ||
| 129 | icon="square.and.pencil" | ||
| 130 | onPress={() => router.push("/new")} | ||
| 131 | separateBackground | ||
| 132 | /> | ||
| 133 | </Stack.Toolbar> | ||
| 134 | |||
| 135 | <HomeScreen | ||
| 136 | projects={projects} | ||
| 137 | threads={threads} | ||
| 138 | savedConnectionsById={savedConnectionsById} | ||
| 139 | searchQuery={searchQuery} | ||
| 140 | onSelectThread={(thread) => { | ||
| 141 | onSelectThread(thread); | ||
| 142 | router.push(buildThreadRoutePath(thread)); | ||
| 143 | }} | ||
| 144 | /> | ||
| 145 | </> | ||
| 35 | ); | 146 | ); |
| 36 | } | 147 | } |
| @@ -1,215 +1,349 @@ | |||
| 1 | import * as Haptics from "expo-haptics"; | 1 | import type { GitStatusState } from "@t3tools/client-runtime"; |
| 2 | import { SymbolView } from "expo-symbols"; | 2 | import { SymbolView } from "expo-symbols"; |
| 3 | import { useMemo, useState } from "react"; | 3 | import { useCallback, useMemo, useState } from "react"; |
| 4 | import { Pressable, ScrollView, useColorScheme, View } from "react-native"; | 4 | import { Pressable, ScrollView, View } from "react-native"; |
| 5 | import { useSafeAreaInsets } from "react-native-safe-area-context"; | 5 | import { useThemeColor } from "../../lib/useThemeColor"; |
| 6 | 6 | ||
| 7 | import { AppText as Text } from "../../components/AppText"; | 7 | import { AppText as Text } from "../../components/AppText"; |
| 8 | import { EmptyState } from "../../components/EmptyState"; | 8 | import { EmptyState } from "../../components/EmptyState"; |
| 9 | import { StatusPill } from "../../components/StatusPill"; | 9 | import { ProjectFavicon } from "../../components/ProjectFavicon"; |
| 10 | import { groupProjectsByRepository } from "../../lib/repositoryGroups"; | 10 | import type { SavedRemoteConnection } from "../../lib/connection"; |
| 11 | import type { ScopedMobileProject, ScopedMobileThread } from "../../lib/scopedEntities"; | 11 | import type { ScopedMobileProject, ScopedMobileThread } from "../../lib/scopedEntities"; |
| 12 | import { scopedProjectKey } from "../../lib/scopedEntities"; | 12 | import { scopedProjectKey } from "../../lib/scopedEntities"; |
| 13 | import { relativeTime } from "../../lib/time"; | 13 | import { relativeTime } from "../../lib/time"; |
| 14 | import { makeAppPalette } from "../../lib/theme"; | 14 | import { useGitStatus } from "../../state/use-git-status"; |
| 15 | import { ConnectionStatusDot } from "../connection/ConnectionStatusDot"; | 15 | import { lastConversationLine, threadStatusTone } from "../threads/threadPresentation"; |
| 16 | import { threadStatusTone } from "../threads/threadPresentation"; | ||
| 17 | 16 | ||
| 18 | export function HomeScreen(props: { | 17 | /* ─── Types ──────────────────────────────────────────────────────────── */ |
| 18 | |||
| 19 | interface HomeScreenProps { | ||
| 19 | readonly projects: ReadonlyArray<ScopedMobileProject>; | 20 | readonly projects: ReadonlyArray<ScopedMobileProject>; |
| 20 | readonly threads: ReadonlyArray<ScopedMobileThread>; | 21 | readonly threads: ReadonlyArray<ScopedMobileThread>; |
| 21 | readonly connectionState: "ready" | "connecting" | "reconnecting" | "disconnected" | "idle"; | 22 | readonly savedConnectionsById: Readonly<Record<string, SavedRemoteConnection>>; |
| 22 | readonly connectionPulse: boolean; | 23 | readonly searchQuery: string; |
| 23 | readonly onOpenConnectionEditor: () => void; | ||
| 24 | readonly onOpenNewTask: () => void; | ||
| 25 | readonly onSelectThread: (thread: ScopedMobileThread) => void; | 24 | readonly onSelectThread: (thread: ScopedMobileThread) => void; |
| 26 | readonly showFloatingConnectionButton: boolean; | 25 | } |
| 27 | readonly showFloatingNewTaskButton: boolean; | ||
| 28 | }) { | ||
| 29 | const isDarkMode = useColorScheme() === "dark"; | ||
| 30 | const palette = makeAppPalette(isDarkMode); | ||
| 31 | const insets = useSafeAreaInsets(); | ||
| 32 | const [newTaskPressed, setNewTaskPressed] = useState(false); | ||
| 33 | const repositoryGroups = useMemo( | ||
| 34 | () => groupProjectsByRepository({ projects: props.projects, threads: props.threads }), | ||
| 35 | [props.projects, props.threads], | ||
| 36 | ); | ||
| 37 | const projectLabelsByKey = useMemo(() => { | ||
| 38 | const map = new Map< | ||
| 39 | string, | ||
| 40 | { | ||
| 41 | readonly projectTitle: string; | ||
| 42 | readonly environmentLabel: string; | ||
| 43 | } | ||
| 44 | >(); | ||
| 45 | |||
| 46 | for (const group of repositoryGroups) { | ||
| 47 | const primaryProjectTitle = group.projects[0]?.project.title ?? group.title; | ||
| 48 | for (const projectGroup of group.projects) { | ||
| 49 | map.set(scopedProjectKey(projectGroup.project.environmentId, projectGroup.project.id), { | ||
| 50 | projectTitle: primaryProjectTitle, | ||
| 51 | environmentLabel: projectGroup.project.environmentLabel, | ||
| 52 | }); | ||
| 53 | } | ||
| 54 | } | ||
| 55 | 26 | ||
| 56 | return map; | 27 | interface ProjectGroup { |
| 57 | }, [repositoryGroups]); | 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 | return ( | 64 | return ( |
| 60 | <View style={{ flex: 1, backgroundColor: palette.screenBackground }}> | 65 | <View className="flex-row items-center gap-2.5 px-1 pb-2"> |
| 61 | <ScrollView | 66 | <ProjectFavicon |
| 62 | showsVerticalScrollIndicator={false} | 67 | size={18} |
| 63 | contentContainerStyle={{ | 68 | projectTitle={props.project.title} |
| 64 | paddingBottom: Math.max(insets.bottom, 24) + (props.showFloatingNewTaskButton ? 92 : 24), | 69 | httpBaseUrl={props.httpBaseUrl} |
| 65 | paddingHorizontal: 20, | 70 | workspaceRoot={props.project.workspaceRoot} |
| 66 | paddingTop: insets.top + 6, | 71 | bearerToken={props.bearerToken} |
| 67 | gap: 16, | 72 | /> |
| 68 | }} | 73 | <Text |
| 74 | className="flex-1 text-[13px] font-t3-bold uppercase text-foreground-muted" | ||
| 75 | style={{ letterSpacing: 0.6 }} | ||
| 76 | numberOfLines={1} | ||
| 69 | > | 77 | > |
| 70 | <View className="flex-row items-center justify-between"> | 78 | {props.project.title} |
| 71 | <Text className="text-[34px] font-t3-bold" style={{ color: palette.text }}> | 79 | </Text> |
| 72 | Recents | 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 | </Text> | 88 | </Text> |
| 89 | </Pressable> | ||
| 90 | ) : null} | ||
| 91 | </View> | ||
| 92 | ); | ||
| 93 | } | ||
| 74 | 94 | ||
| 75 | {props.showFloatingConnectionButton ? ( | 95 | /* ─── Git summary line ──────────────────────────────────────────────── */ |
| 76 | <Pressable | ||
| 77 | onPress={props.onOpenConnectionEditor} | ||
| 78 | className="h-14 min-w-[56px] items-center justify-center rounded-full px-4" | ||
| 79 | style={{ | ||
| 80 | backgroundColor: isDarkMode ? "rgba(255,255,255,0.08)" : "rgba(255,255,255,0.9)", | ||
| 81 | borderWidth: isDarkMode ? 1 : 0, | ||
| 82 | borderColor: isDarkMode ? palette.border : "transparent", | ||
| 83 | }} | ||
| 84 | > | ||
| 85 | <ConnectionStatusDot state={props.connectionState} pulse={props.connectionPulse} /> | ||
| 86 | </Pressable> | ||
| 87 | ) : null} | ||
| 88 | </View> | ||
| 89 | 96 | ||
| 90 | {props.threads.length === 0 ? ( | 97 | function gitSummaryParts(gitStatus: GitStatusState): ReadonlyArray<string> { |
| 91 | <EmptyState | 98 | if (!gitStatus.data) return []; |
| 92 | title="No threads yet" | 99 | const { data } = gitStatus; |
| 93 | detail="Create a task to start a new coding session in one of your connected projects." | 100 | const parts: string[] = []; |
| 94 | /> | 101 | if (data.hasWorkingTreeChanges) { |
| 95 | ) : ( | 102 | parts.push(`${data.workingTree.files.length} changed`); |
| 96 | <View | 103 | } |
| 97 | className="overflow-hidden rounded-[24px]" | 104 | if (data.aheadCount > 0) parts.push(`${data.aheadCount} ahead`); |
| 98 | style={{ backgroundColor: palette.card }} | 105 | if (data.behindCount > 0) parts.push(`${data.behindCount} behind`); |
| 99 | > | 106 | if (data.pr?.state === "open") parts.push(`PR #${data.pr.number}`); |
| 100 | {props.threads.map((thread, index) => { | 107 | return parts; |
| 101 | const projectKey = scopedProjectKey(thread.environmentId, thread.projectId); | 108 | } |
| 102 | const projectLabel = projectLabelsByKey.get(projectKey); | 109 | |
| 103 | 110 | /* ─── Thread row ─────────────────────────────────────────────────────── */ | |
| 104 | return ( | 111 | |
| 105 | <Pressable | 112 | function ThreadRow(props: { |
| 106 | key={`${thread.environmentId}:${thread.id}`} | 113 | readonly thread: ScopedMobileThread; |
| 107 | onPress={() => props.onSelectThread(thread)} | 114 | readonly projectCwd: string | null; |
| 108 | style={{ | 115 | readonly onPress: () => void; |
| 109 | paddingHorizontal: 16, | 116 | readonly isLast: boolean; |
| 110 | paddingVertical: 13, | 117 | }) { |
| 111 | borderTopWidth: index === 0 ? 0 : 1, | 118 | const separatorColor = useThemeColor("--color-separator"); |
| 112 | borderTopColor: palette.borderSubtle, | 119 | const { bg, fg } = statusColors(props.thread); |
| 113 | }} | 120 | const tone = threadStatusTone(props.thread); |
| 114 | > | 121 | const preview = lastConversationLine(props.thread); |
| 115 | <View className="flex-row items-start justify-between gap-3"> | 122 | const timestamp = relativeTime(props.thread.updatedAt ?? props.thread.createdAt); |
| 116 | <View className="flex-1 gap-1.5"> | 123 | const branch = props.thread.branch; |
| 117 | <Text | 124 | |
| 118 | className="text-[17px] font-t3-bold leading-[24px]" | 125 | // Subscribe to live git status — only when thread has a branch set. |
| 119 | numberOfLines={2} | 126 | // Threads sharing the same cwd share one WS subscription via ref-counting. |
| 120 | style={{ color: palette.text }} | 127 | const cwd = branch ? (props.thread.worktreePath ?? props.projectCwd) : null; |
| 121 | > | 128 | const gitStatus = useGitStatus({ |
| 122 | {thread.title} | 129 | environmentId: cwd ? props.thread.environmentId : null, |
| 123 | </Text> | 130 | cwd, |
| 124 | </View> | 131 | }); |
| 125 | <StatusPill {...threadStatusTone(thread)} size="compact" /> | 132 | const gitParts = gitSummaryParts(gitStatus); |
| 126 | </View> | ||
| 127 | |||
| 128 | <View className="mt-2 flex-row items-center justify-between gap-3"> | ||
| 129 | <View className="flex-1 flex-row items-center gap-2"> | ||
| 130 | <Text | ||
| 131 | className="text-[13px] font-medium leading-[18px]" | ||
| 132 | numberOfLines={1} | ||
| 133 | style={{ color: palette.textSecondary, flexShrink: 1 }} | ||
| 134 | > | ||
| 135 | {projectLabel?.projectTitle ?? thread.title} | ||
| 136 | </Text> | ||
| 137 | <View | ||
| 138 | className="h-1 w-1 rounded-full" | ||
| 139 | style={{ backgroundColor: palette.dotSeparator }} | ||
| 140 | /> | ||
| 141 | <Text | ||
| 142 | className="text-[12px] font-t3-bold uppercase tracking-[0.4px]" | ||
| 143 | numberOfLines={1} | ||
| 144 | style={{ color: palette.textMuted, flexShrink: 1 }} | ||
| 145 | > | ||
| 146 | {projectLabel?.environmentLabel ?? "Local"} | ||
| 147 | </Text> | ||
| 148 | </View> | ||
| 149 | <Text | ||
| 150 | className="text-[12px] font-t3-bold" | ||
| 151 | style={{ color: palette.textMuted, fontVariant: ["tabular-nums"] }} | ||
| 152 | > | ||
| 153 | {relativeTime(thread.updatedAt ?? thread.createdAt)} | ||
| 154 | </Text> | ||
| 155 | </View> | ||
| 156 | </Pressable> | ||
| 157 | ); | ||
| 158 | })} | ||
| 159 | </View> | ||
| 160 | )} | ||
| 161 | </ScrollView> | ||
| 162 | 133 | ||
| 163 | {props.showFloatingNewTaskButton ? ( | 134 | return ( |
| 135 | <Pressable onPress={props.onPress} style={({ pressed }) => ({ opacity: pressed ? 0.7 : 1 })}> | ||
| 136 | <View | ||
| 137 | style={{ | ||
| 138 | flexDirection: "row", | ||
| 139 | paddingLeft: 16, | ||
| 140 | paddingRight: 16, | ||
| 141 | paddingVertical: 10, | ||
| 142 | gap: 12, | ||
| 143 | borderBottomWidth: props.isLast ? 0 : 1, | ||
| 144 | borderBottomColor: separatorColor, | ||
| 145 | }} | ||
| 146 | > | ||
| 147 | {/* Git status indicator */} | ||
| 164 | <View | 148 | <View |
| 165 | pointerEvents="box-none" | ||
| 166 | style={{ | 149 | style={{ |
| 167 | position: "absolute", | 150 | width: 30, |
| 168 | right: 20, | 151 | height: 30, |
| 169 | bottom: Math.max(insets.bottom, 18), | 152 | borderRadius: 9, |
| 153 | backgroundColor: bg, | ||
| 154 | alignItems: "center", | ||
| 155 | justifyContent: "center", | ||
| 156 | marginTop: 2, | ||
| 170 | }} | 157 | }} |
| 171 | > | 158 | > |
| 172 | <Pressable | 159 | <SymbolView name="arrow.triangle.branch" size={13} tintColor={fg} type="monochrome" /> |
| 173 | accessibilityRole="button" | 160 | </View> |
| 174 | hitSlop={6} | 161 | |
| 175 | onPressIn={() => { | 162 | {/* Content */} |
| 176 | setNewTaskPressed(true); | 163 | <View style={{ flex: 1, gap: 3 }}> |
| 177 | void Haptics.selectionAsync(); | 164 | {/* Title + Status + Timestamp */} |
| 178 | }} | 165 | <View className="flex-row items-center justify-between gap-2"> |
| 179 | onPressOut={() => { | 166 | <Text |
| 180 | setNewTaskPressed(false); | 167 | className="flex-1 text-[15px] font-t3-bold leading-[20px] text-foreground" |
| 181 | }} | 168 | numberOfLines={1} |
| 182 | onPress={props.onOpenNewTask} | ||
| 183 | > | ||
| 184 | <View | ||
| 185 | className="flex-row items-center gap-3 rounded-full px-6 py-4" | ||
| 186 | style={{ | ||
| 187 | backgroundColor: palette.primaryButton, | ||
| 188 | borderWidth: isDarkMode ? 1 : 0, | ||
| 189 | borderColor: isDarkMode ? "rgba(255,255,255,0.08)" : "transparent", | ||
| 190 | boxShadow: newTaskPressed | ||
| 191 | ? `0 12px 24px ${palette.primaryButtonShadow}` | ||
| 192 | : `0 20px 36px ${palette.primaryButtonShadow}`, | ||
| 193 | opacity: newTaskPressed ? 0.96 : 1, | ||
| 194 | transform: [{ scale: newTaskPressed ? 0.985 : 1 }], | ||
| 195 | }} | ||
| 196 | > | 169 | > |
| 170 | {props.thread.title} | ||
| 171 | </Text> | ||
| 172 | <View className="flex-row items-center gap-2"> | ||
| 173 | <View | ||
| 174 | className={tone.pillClassName} | ||
| 175 | style={{ borderRadius: 99, paddingHorizontal: 6, paddingVertical: 2 }} | ||
| 176 | > | ||
| 177 | <Text className={`text-[10px] font-t3-bold ${tone.textClassName}`}> | ||
| 178 | {tone.label} | ||
| 179 | </Text> | ||
| 180 | </View> | ||
| 181 | <Text | ||
| 182 | className="text-[12px] text-foreground-tertiary" | ||
| 183 | style={{ fontVariant: ["tabular-nums"] }} | ||
| 184 | > | ||
| 185 | {timestamp} | ||
| 186 | </Text> | ||
| 187 | </View> | ||
| 188 | </View> | ||
| 189 | |||
| 190 | {/* Message preview */} | ||
| 191 | <Text className="text-[13px] leading-[18px] text-foreground-muted" numberOfLines={2}> | ||
| 192 | {preview} | ||
| 193 | </Text> | ||
| 194 | |||
| 195 | {/* Branch + git info */} | ||
| 196 | {branch ? ( | ||
| 197 | <View className="flex-row items-center gap-1.5" style={{ marginTop: 1 }}> | ||
| 197 | <SymbolView | 198 | <SymbolView |
| 198 | name="square.and.pencil" | 199 | name="arrow.triangle.branch" |
| 199 | size={20} | 200 | size={10} |
| 200 | tintColor={palette.primaryButtonText} | 201 | tintColor="#737373" |
| 201 | type="monochrome" | 202 | type="monochrome" |
| 202 | /> | 203 | /> |
| 203 | <Text | 204 | <Text |
| 204 | className="text-[18px] font-t3-bold" | 205 | className="text-[11px] text-foreground-tertiary" |
| 205 | style={{ color: palette.primaryButtonText }} | 206 | numberOfLines={1} |
| 207 | style={{ fontFamily: "monospace" }} | ||
| 206 | > | 208 | > |
| 207 | New task | 209 | {branch} |
| 208 | </Text> | 210 | </Text> |
| 211 | {gitParts.length > 0 ? ( | ||
| 212 | <Text className="text-[11px] text-foreground-tertiary"> | ||
| 213 | {" · " + gitParts.join(" · ")} | ||
| 214 | </Text> | ||
| 215 | ) : null} | ||
| 209 | </View> | 216 | </View> |
| 210 | </Pressable> | 217 | ) : null} |
| 211 | </View> | 218 | </View> |
| 212 | ) : null} | 219 | </View> |
| 213 | </View> | 220 | </Pressable> |
| 221 | ); | ||
| 222 | } | ||
| 223 | |||
| 224 | /* ─── Main screen ────────────────────────────────────────────────────── */ | ||
| 225 | |||
| 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 | import { SymbolView } from "expo-symbols"; | 3 | import { SymbolView } from "expo-symbols"; |
| 4 | import { TextInputWrapper } from "expo-paste-input"; | 4 | import { TextInputWrapper } from "expo-paste-input"; |
| 5 | import { useCallback, useEffect, useMemo } from "react"; | 5 | import { useCallback, useEffect, useMemo } from "react"; |
| 6 | import { Pressable, useColorScheme, View } from "react-native"; | 6 | import { Pressable, View, useColorScheme } from "react-native"; |
| 7 | import Animated, { useAnimatedKeyboard, useAnimatedStyle } from "react-native-reanimated"; | 7 | import Animated, { useAnimatedKeyboard, useAnimatedStyle } from "react-native-reanimated"; |
| 8 | import { useSafeAreaInsets } from "react-native-safe-area-context"; | 8 | import { useSafeAreaInsets } from "react-native-safe-area-context"; |
| 9 | import { useThemeColor } from "../../lib/useThemeColor"; | ||
| 9 | 10 | ||
| 10 | import type { ModelSelection } from "@t3tools/contracts"; | 11 | import type { ModelSelection } from "@t3tools/contracts"; |
| 11 | import { CLAUDE_CODE_EFFORT_OPTIONS } from "@t3tools/contracts"; | 12 | import { CLAUDE_CODE_EFFORT_OPTIONS } from "@t3tools/contracts"; |
| @@ -17,7 +18,6 @@ import { ProviderIcon } from "../../components/ProviderIcon"; · 5 unmodified lines | |||
| 17 | 18 | ||
| 18 | import { convertPastedImagesToAttachments, pickComposerImages } from "../../lib/composerImages"; | 19 | import { convertPastedImagesToAttachments, pickComposerImages } from "../../lib/composerImages"; |
| 19 | import { buildThreadRoutePath } from "../../lib/routes"; | 20 | import { buildThreadRoutePath } from "../../lib/routes"; |
| 20 | import { makeAppPalette } from "../../lib/theme"; | ||
| 21 | import { useProjectActions } from "../../state/use-project-actions"; | 21 | import { useProjectActions } from "../../state/use-project-actions"; |
| 22 | import { useRemoteCatalog } from "../../state/use-remote-catalog"; | 22 | import { useRemoteCatalog } from "../../state/use-remote-catalog"; |
| 23 | import { useNativePaste } from "../../hooks/useNativePaste"; | 23 | import { useNativePaste } from "../../hooks/useNativePaste"; |
| @@ -33,9 +33,8 @@ export function NewTaskDraftScreen(props: { · 9 unmodified lines | |||
| 33 | const { onCreateThreadWithOptions } = useProjectActions(); | 33 | const { onCreateThreadWithOptions } = useProjectActions(); |
| 34 | const flow = useNewTaskFlow(); | 34 | const flow = useNewTaskFlow(); |
| 35 | const router = useRouter(); | 35 | const router = useRouter(); |
| 36 | const isDarkMode = useColorScheme() === "dark"; | ||
| 37 | const palette = makeAppPalette(isDarkMode); | ||
| 38 | const insets = useSafeAreaInsets(); | 36 | const insets = useSafeAreaInsets(); |
| 37 | const isDarkMode = useColorScheme() === "dark"; | ||
| 39 | const keyboard = useAnimatedKeyboard(); | 38 | const keyboard = useAnimatedKeyboard(); |
| 40 | const containerAnimatedStyle = useAnimatedStyle(() => ({ | 39 | const containerAnimatedStyle = useAnimatedStyle(() => ({ |
| 41 | paddingBottom: keyboard.height.value, | 40 | paddingBottom: keyboard.height.value, |
| @@ -45,6 +44,9 @@ export function NewTaskDraftScreen(props: { · 3 unmodified lines | |||
| 45 | })); | 44 | })); |
| 46 | const { logicalProjects, selectedProject, setProject } = flow; | 45 | const { logicalProjects, selectedProject, setProject } = flow; |
| 47 | 46 | ||
| 47 | const iconColor = useThemeColor("--color-icon"); | ||
| 48 | const borderColor = useThemeColor("--color-border"); | ||
| 49 | |||
| 48 | useEffect(() => { | 50 | useEffect(() => { |
| 49 | if (props.initialProjectRef?.environmentId && props.initialProjectRef?.projectId) { | 51 | if (props.initialProjectRef?.environmentId && props.initialProjectRef?.projectId) { |
| 50 | const directProject = | 52 | const directProject = |
| @@ -384,54 +386,48 @@ export function NewTaskDraftScreen(props: { · 333 unmodified lines | |||
| 384 | 386 | ||
| 385 | if (!selectedProject) { | 387 | if (!selectedProject) { |
| 386 | return ( | 388 | return ( |
| 387 | <View style={{ flex: 1, backgroundColor: palette.sheetBackground }}> | 389 | <View className="flex-1 bg-sheet"> |
| 388 | <View style={{ minHeight: 16, paddingTop: 8 }} /> | 390 | <View style={{ minHeight: 16, paddingTop: 8 }} /> |
| 389 | <View className="items-center gap-1 px-5 pb-3 pt-4"> | 391 | <View className="items-center gap-1 px-5 pb-3 pt-4"> |
| 390 | <Text | 392 | <Text |
| 391 | className="text-[12px] font-t3-bold uppercase" | 393 | className="text-[12px] font-t3-bold uppercase text-foreground-muted" |
| 392 | style={{ color: palette.textMuted, letterSpacing: 1 }} | 394 | style={{ letterSpacing: 1 }} |
| 393 | > | 395 | > |
| 394 | New task | 396 | New task |
| 395 | </Text> | 397 | </Text> |
| 396 | <Text className="text-[28px] font-t3-bold" style={{ color: palette.text }}> | 398 | <Text className="text-[28px] font-t3-bold">Loading task</Text> |
| 397 | Loading task | ||
| 398 | </Text> | ||
| 399 | </View> | 399 | </View> |
| 400 | </View> | 400 | </View> |
| 401 | ); | 401 | ); |
| 402 | } | 402 | } |
| 403 | 403 | ||
| 404 | return ( | 404 | return ( |
| 405 | <Animated.View | 405 | <Animated.View className="flex-1 bg-sheet" style={containerAnimatedStyle}> |
| 406 | style={[{ flex: 1, backgroundColor: palette.sheetBackground }, containerAnimatedStyle]} | ||
| 407 | > | ||
| 408 | <View style={{ minHeight: 16, paddingTop: 8 }} /> | 406 | <View style={{ minHeight: 16, paddingTop: 8 }} /> |
| 409 | 407 | ||
| 410 | <View className="items-center gap-1 px-5 pb-3 pt-4"> | 408 | <View className="items-center gap-1 px-5 pb-3 pt-4"> |
| 411 | {flow.logicalProjects.length > 1 ? ( | 409 | {flow.logicalProjects.length > 1 ? ( |
| 412 | <Pressable | 410 | <Pressable |
| 413 | className="absolute left-3 top-4 h-9 w-9 items-center justify-center rounded-full" | 411 | className="absolute left-3 top-4 h-9 w-9 items-center justify-center rounded-full bg-subtle" |
| 414 | style={{ backgroundColor: palette.subtleBg, zIndex: 1 }} | 412 | style={{ zIndex: 1 }} |
| 415 | onPress={() => router.back()} | 413 | onPress={() => router.back()} |
| 416 | > | 414 | > |
| 417 | <SymbolView | 415 | <SymbolView |
| 418 | name="chevron.left" | 416 | name="chevron.left" |
| 419 | size={16} | 417 | size={16} |
| 420 | tintColor={palette.icon} | 418 | tintColor={iconColor} |
| 421 | type="monochrome" | 419 | type="monochrome" |
| 422 | weight="medium" | 420 | weight="medium" |
| 423 | /> | 421 | /> |
| 424 | </Pressable> | 422 | </Pressable> |
| 425 | ) : null} | 423 | ) : null} |
| 426 | <Text | 424 | <Text |
| 427 | className="text-[12px] font-t3-bold uppercase" | 425 | className="text-[12px] font-t3-bold uppercase text-foreground-muted" |
| 428 | style={{ color: palette.textMuted, letterSpacing: 1 }} | 426 | style={{ letterSpacing: 1 }} |
| 429 | > | 427 | > |
| 430 | New task | 428 | New task |
| 431 | </Text> | 429 | </Text> |
| 432 | <Text className="text-[28px] font-t3-bold" style={{ color: palette.text }}> | 430 | <Text className="text-[28px] font-t3-bold">{selectedProject.title}</Text> |
| 433 | {selectedProject.title} | ||
| 434 | </Text> | ||
| 435 | </View> | 431 | </View> |
| 436 | 432 | ||
| 437 | <View style={{ flex: 1, paddingHorizontal: 20, paddingTop: 8 }}> | 433 | <View style={{ flex: 1, paddingHorizontal: 20, paddingTop: 8 }}> |
| @@ -440,17 +436,9 @@ export function NewTaskDraftScreen(props: { · 2 unmodified lines | |||
| 440 | multiline | 436 | multiline |
| 441 | value={flow.prompt} | 437 | value={flow.prompt} |
| 442 | onChangeText={flow.setPrompt} | 438 | onChangeText={flow.setPrompt} |
| 443 | placeholderTextColor={palette.placeholder} | ||
| 444 | placeholder={`Describe a coding task in ${selectedProject.title}`} | 439 | placeholder={`Describe a coding task in ${selectedProject.title}`} |
| 445 | textAlignVertical="top" | 440 | textAlignVertical="top" |
| 446 | style={{ | 441 | className="flex-1 border-0 bg-transparent text-[18px] leading-[28px]" |
| 447 | flex: 1, | ||
| 448 | borderWidth: 0, | ||
| 449 | backgroundColor: "transparent", | ||
| 450 | color: palette.text, | ||
| 451 | fontSize: 18, | ||
| 452 | lineHeight: 28, | ||
| 453 | }} | ||
| 454 | /> | 442 | /> |
| 455 | </TextInputWrapper> | 443 | </TextInputWrapper> |
| 456 | </View> | 444 | </View> |
| @@ -459,7 +447,7 @@ export function NewTaskDraftScreen(props: { · 2 unmodified lines | |||
| 459 | style={[ | 447 | style={[ |
| 460 | { | 448 | { |
| 461 | borderTopWidth: 1, | 449 | borderTopWidth: 1, |
| 462 | borderTopColor: palette.border, | 450 | borderTopColor: borderColor, |
| 463 | }, | 451 | }, |
| 464 | controlsBottomPadding, | 452 | controlsBottomPadding, |
| 465 | ]} | 453 | ]} |
| @@ -479,6 +467,7 @@ export function NewTaskDraftScreen(props: { · 13 unmodified lines | |||
| 479 | <MenuView | 467 | <MenuView |
| 480 | actions={modelMenuActions} | 468 | actions={modelMenuActions} |
| 481 | onPressAction={({ nativeEvent }) => handleModelMenuAction(nativeEvent.event)} | 469 | onPressAction={({ nativeEvent }) => handleModelMenuAction(nativeEvent.event)} |
| 470 | themeVariant={isDarkMode ? "dark" : "light"} | ||
| 482 | > | 471 | > |
| 483 | <ControlPill | 472 | <ControlPill |
| 484 | iconNode={<ProviderIcon provider={flow.selectedModel?.provider} size={16} />} | 473 | iconNode={<ProviderIcon provider={flow.selectedModel?.provider} size={16} />} |
| @@ -487,18 +476,21 @@ export function NewTaskDraftScreen(props: { · 2 unmodified lines | |||
| 487 | <MenuView | 476 | <MenuView |
| 488 | actions={optionsMenuActions} | 477 | actions={optionsMenuActions} |
| 489 | onPressAction={({ nativeEvent }) => handleOptionsMenuAction(nativeEvent.event)} | 478 | onPressAction={({ nativeEvent }) => handleOptionsMenuAction(nativeEvent.event)} |
| 479 | themeVariant={isDarkMode ? "dark" : "light"} | ||
| 490 | > | 480 | > |
| 491 | <ControlPill icon="slider.horizontal.3" /> | 481 | <ControlPill icon="slider.horizontal.3" /> |
| 492 | </MenuView> | 482 | </MenuView> |
| 493 | <MenuView | 483 | <MenuView |
| 494 | actions={environmentMenuActions} | 484 | actions={environmentMenuActions} |
| 495 | onPressAction={({ nativeEvent }) => handleEnvironmentMenuAction(nativeEvent.event)} | 485 | onPressAction={({ nativeEvent }) => handleEnvironmentMenuAction(nativeEvent.event)} |
| 486 | themeVariant={isDarkMode ? "dark" : "light"} | ||
| 496 | > | 487 | > |
| 497 | <ControlPill icon="desktopcomputer" /> | 488 | <ControlPill icon="desktopcomputer" /> |
| 498 | </MenuView> | 489 | </MenuView> |
| 499 | <MenuView | 490 | <MenuView |
| 500 | actions={workspaceMenuActions} | 491 | actions={workspaceMenuActions} |
| 501 | onPressAction={({ nativeEvent }) => handleWorkspaceMenuAction(nativeEvent.event)} | 492 | onPressAction={({ nativeEvent }) => handleWorkspaceMenuAction(nativeEvent.event)} |
| 493 | themeVariant={isDarkMode ? "dark" : "light"} | ||
| 502 | > | 494 | > |
| 503 | <ControlPill icon="point.topleft.down.curvedto.point.bottomright.up" /> | 495 | <ControlPill icon="point.topleft.down.curvedto.point.bottomright.up" /> |
| 504 | </MenuView> | 496 | </MenuView> |
| @@ -2,7 +2,7 @@ import type { ApprovalRequestId } from "@t3tools/contracts"; · 1 unmodified line | |||
| 2 | import { Pressable, View } from "react-native"; | 2 | import { Pressable, View } from "react-native"; |
| 3 | 3 | ||
| 4 | import { AppText as Text, AppTextInput as TextInput } from "../../components/AppText"; | 4 | import { AppText as Text, AppTextInput as TextInput } from "../../components/AppText"; |
| 5 | import { cx } from "../../lib/classNames"; | 5 | import { cn } from "../../lib/cn"; |
| 6 | import type { PendingUserInput, PendingUserInputDraftAnswer } from "../../lib/threadActivity"; | 6 | import type { PendingUserInput, PendingUserInputDraftAnswer } from "../../lib/threadActivity"; |
| 7 | 7 | ||
| 8 | export interface PendingUserInputCardProps { | 8 | export interface PendingUserInputCardProps { |
| @@ -45,7 +45,7 @@ export function PendingUserInputCard(props: PendingUserInputCardProps) { · 36 unmodified lines | |||
| 45 | return ( | 45 | return ( |
| 46 | <Pressable | 46 | <Pressable |
| 47 | key={option.label} | 47 | key={option.label} |
| 48 | className={cx( | 48 | className={cn( |
| 49 | "rounded-full border px-3 py-2.5 ", | 49 | "rounded-full border px-3 py-2.5 ", |
| 50 | selected | 50 | selected |
| 51 | ? "border-blue-300/50 bg-blue-50 dark:border-blue-400/28 dark:bg-blue-400/14" | 51 | ? "border-blue-300/50 bg-blue-50 dark:border-blue-400/28 dark:bg-blue-400/14" |
| @@ -60,7 +60,7 @@ export function PendingUserInputCard(props: PendingUserInputCardProps) { · 8 unmodified lines | |||
| 60 | } | 60 | } |
| 61 | > | 61 | > |
| 62 | <Text | 62 | <Text |
| 63 | className={cx( | 63 | className={cn( |
| 64 | "font-t3-bold text-[13px]", | 64 | "font-t3-bold text-[13px]", |
| 65 | selected | 65 | selected |
| 66 | ? "text-sky-700 dark:text-sky-300" | 66 | ? "text-sky-700 dark:text-sky-300" |
| @@ -85,7 +85,7 @@ export function PendingUserInputCard(props: PendingUserInputCardProps) { · 18 unmodified lines | |||
| 85 | ); | 85 | ); |
| 86 | })} | 86 | })} |
| 87 | <Pressable | 87 | <Pressable |
| 88 | className={cx( | 88 | className={cn( |
| 89 | "items-center justify-center rounded-2xl px-4 py-3.5", | 89 | "items-center justify-center rounded-2xl px-4 py-3.5", |
| 90 | props.answers ? "bg-blue-500" : "bg-neutral-200 dark:bg-neutral-700/60", | 90 | props.answers ? "bg-blue-500" : "bg-neutral-200 dark:bg-neutral-700/60", |
| 91 | )} | 91 | )} |
| @@ -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 | type ViewStyle, | 20 | type ViewStyle, |
| 21 | } from "react-native"; | 21 | } from "react-native"; |
| 22 | import ImageViewing from "react-native-image-viewing"; | 22 | import ImageViewing from "react-native-image-viewing"; |
| 23 | import { useThemeColor } from "../../lib/useThemeColor"; | ||
| 23 | 24 | ||
| 24 | import { AppText as Text } from "../../components/AppText"; | 25 | import { AppText as Text } from "../../components/AppText"; |
| 25 | import { ComposerAttachmentStrip } from "../../components/ComposerAttachmentStrip"; | 26 | import { ComposerAttachmentStrip } from "../../components/ComposerAttachmentStrip"; |
| @@ -29,7 +30,6 @@ import type { DraftComposerImageAttachment } from "../../lib/composerImages"; · 3 unmodified lines | |||
| 29 | import type { MobileLayoutVariant } from "../../lib/mobileLayout"; | 30 | import type { MobileLayoutVariant } from "../../lib/mobileLayout"; |
| 30 | import { buildModelOptions, groupByProvider } from "../../lib/modelOptions"; | 31 | import { buildModelOptions, groupByProvider } from "../../lib/modelOptions"; |
| 31 | import type { RemoteClientConnectionState } from "../../lib/remoteClient"; | 32 | import type { RemoteClientConnectionState } from "../../lib/remoteClient"; |
| 32 | import { makeAppPalette } from "../../lib/theme"; | ||
| 33 | import { useNativePaste } from "../../hooks/useNativePaste"; | 33 | import { useNativePaste } from "../../hooks/useNativePaste"; |
| 34 | 34 | ||
| 35 | /** | 35 | /** |
| @@ -109,7 +109,8 @@ function ComposerSurface(props: { · 73 unmodified lines | |||
| 109 | 109 | ||
| 110 | export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposerProps) { | 110 | export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposerProps) { |
| 111 | const isDarkMode = useColorScheme() === "dark"; | 111 | const isDarkMode = useColorScheme() === "dark"; |
| 112 | const palette = makeAppPalette(isDarkMode); | 112 | const placeholderColor = useThemeColor("--color-placeholder"); |
| 113 | const foregroundColor = useThemeColor("--color-foreground"); | ||
| 113 | const inputRef = useRef<RNTextInput>(null); | 114 | const inputRef = useRef<RNTextInput>(null); |
| 114 | const [isFocused, setIsFocused] = useState(false); | 115 | const [isFocused, setIsFocused] = useState(false); |
| 115 | const wasExpandedBeforePreviewRef = useRef(false); | 116 | const wasExpandedBeforePreviewRef = useRef(false); |
| @@ -337,14 +338,11 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer · 221 unmodified lines | |||
| 337 | 338 | ||
| 338 | return ( | 339 | return ( |
| 339 | <View | 340 | <View |
| 341 | className="bg-screen" | ||
| 340 | style={{ | 342 | style={{ |
| 341 | paddingHorizontal: 16, | 343 | paddingHorizontal: 16, |
| 342 | paddingTop: isExpanded ? 12 : 10, | 344 | paddingTop: isExpanded ? 12 : 10, |
| 343 | paddingBottom: (props.bottomInset ?? 0) + (isExpanded ? 4 : 10), | 345 | paddingBottom: (props.bottomInset ?? 0) + (isExpanded ? 4 : 10), |
| 344 | // Always opaque — the glass effect lives on the ComposerSurface (pill/card), | ||
| 345 | // not the container. This prevents chat feed text from bleeding through | ||
| 346 | // the padding areas around and below the pill. | ||
| 347 | backgroundColor: palette.screenBackground, | ||
| 348 | }} | 346 | }} |
| 349 | > | 347 | > |
| 350 | <View | 348 | <View |
| @@ -395,7 +393,7 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer · 44 unmodified lines | |||
| 395 | value={props.draftMessage} | 393 | value={props.draftMessage} |
| 396 | onChangeText={props.onChangeDraftMessage} | 394 | onChangeText={props.onChangeDraftMessage} |
| 397 | placeholder={props.placeholder} | 395 | placeholder={props.placeholder} |
| 398 | placeholderTextColor={palette.placeholder} | 396 | placeholderTextColor={placeholderColor} |
| 399 | editable={props.connectionState === "ready"} | 397 | editable={props.connectionState === "ready"} |
| 400 | onFocus={() => setIsFocused(true)} | 398 | onFocus={() => setIsFocused(true)} |
| 401 | onBlur={() => setIsFocused(false)} | 399 | onBlur={() => setIsFocused(false)} |
| @@ -409,7 +407,7 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer · 7 unmodified lines | |||
| 409 | paddingVertical: 4, | 407 | paddingVertical: 4, |
| 410 | fontSize: 15, | 408 | fontSize: 15, |
| 411 | lineHeight: 22, | 409 | lineHeight: 22, |
| 412 | color: palette.text, | 410 | color: foregroundColor, |
| 413 | fontFamily: "DMSans_400Regular", | 411 | fontFamily: "DMSans_400Regular", |
| 414 | } | 412 | } |
| 415 | : { | 413 | : { |
| @@ -417,7 +415,7 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer · 1 unmodified line | |||
| 417 | paddingVertical: 6, | 415 | paddingVertical: 6, |
| 418 | fontSize: 15, | 416 | fontSize: 15, |
| 419 | lineHeight: 20, | 417 | lineHeight: 20, |
| 420 | color: palette.text, | 418 | color: foregroundColor, |
| 421 | fontFamily: "DMSans_400Regular", | 419 | fontFamily: "DMSans_400Regular", |
| 422 | } | 420 | } |
| 423 | } | 421 | } |
| @@ -430,11 +428,11 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer · 6 unmodified lines | |||
| 430 | <Pressable key={image.id} onPress={() => onPressImage(image.previewUri)}> | 428 | <Pressable key={image.id} onPress={() => onPressImage(image.previewUri)}> |
| 431 | <Image | 429 | <Image |
| 432 | source={{ uri: image.previewUri }} | 430 | source={{ uri: image.previewUri }} |
| 431 | className="bg-subtle" | ||
| 433 | style={{ | 432 | style={{ |
| 434 | width: 30, | 433 | width: 30, |
| 435 | height: 30, | 434 | height: 30, |
| 436 | borderRadius: 8, | 435 | borderRadius: 8, |
| 437 | backgroundColor: palette.subtleBg, | ||
| 438 | }} | 436 | }} |
| 439 | resizeMode="cover" | 437 | resizeMode="cover" |
| 440 | /> | 438 | /> |
| @@ -442,22 +440,16 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer · 1 unmodified line | |||
| 442 | ))} | 440 | ))} |
| 443 | {props.draftAttachments.length > 3 ? ( | 441 | {props.draftAttachments.length > 3 ? ( |
| 444 | <View | 442 | <View |
| 443 | className="bg-subtle-strong" | ||
| 445 | style={{ | 444 | style={{ |
| 446 | width: 30, | 445 | width: 30, |
| 447 | height: 30, | 446 | height: 30, |
| 448 | borderRadius: 8, | 447 | borderRadius: 8, |
| 449 | backgroundColor: palette.subtleBgStrong, | ||
| 450 | alignItems: "center", | 448 | alignItems: "center", |
| 451 | justifyContent: "center", | 449 | justifyContent: "center", |
| 452 | }} | 450 | }} |
| 453 | > | 451 | > |
| 454 | <Text | 452 | <Text className="text-foreground-muted text-[11px] font-t3-bold"> |
| 455 | style={{ | ||
| 456 | fontSize: 11, | ||
| 457 | fontFamily: "DMSans_700Bold", | ||
| 458 | color: palette.textMuted, | ||
| 459 | }} | ||
| 460 | > | ||
| 461 | +{props.draftAttachments.length - 3} | 453 | +{props.draftAttachments.length - 3} |
| 462 | </Text> | 454 | </Text> |
| 463 | </View> | 455 | </View> |
| @@ -497,12 +489,14 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer · 33 unmodified lines | |||
| 497 | <MenuView | 489 | <MenuView |
| 498 | actions={modelMenuActions} | 490 | actions={modelMenuActions} |
| 499 | onPressAction={({ nativeEvent }) => handleModelMenuAction(nativeEvent.event)} | 491 | onPressAction={({ nativeEvent }) => handleModelMenuAction(nativeEvent.event)} |
| 492 | themeVariant={isDarkMode ? "dark" : "light"} | ||
| 500 | > | 493 | > |
| 501 | <ControlPill iconNode={<ProviderIcon provider={modelProvider} size={16} />} /> | 494 | <ControlPill iconNode={<ProviderIcon provider={modelProvider} size={16} />} /> |
| 502 | </MenuView> | 495 | </MenuView> |
| 503 | <MenuView | 496 | <MenuView |
| 504 | actions={optionsMenuActions} | 497 | actions={optionsMenuActions} |
| 505 | onPressAction={({ nativeEvent }) => handleOptionsMenuAction(nativeEvent.event)} | 498 | onPressAction={({ nativeEvent }) => handleOptionsMenuAction(nativeEvent.event)} |
| 499 | themeVariant={isDarkMode ? "dark" : "light"} | ||
| 506 | > | 500 | > |
| 507 | <ControlPill icon="slider.horizontal.3" /> | 501 | <ControlPill icon="slider.horizontal.3" /> |
| 508 | </MenuView> | 502 | </MenuView> |
| @@ -527,8 +521,8 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer · 18 unmodified lines | |||
| 527 | {/* Queue count */} | 521 | {/* Queue count */} |
| 528 | {props.queueCount > 0 ? ( | 522 | {props.queueCount > 0 ? ( |
| 529 | <Text | 523 | <Text |
| 524 | className="text-foreground-muted" | ||
| 530 | style={{ | 525 | style={{ |
| 531 | color: palette.textMuted, | ||
| 532 | fontSize: 12, | 526 | fontSize: 12, |
| 533 | lineHeight: 18, | 527 | lineHeight: 18, |
| 534 | paddingTop: 8, | 528 | paddingTop: 8, |
| @@ -1,31 +1,23 @@ | |||
| 1 | import type { | 1 | import type { |
| 2 | ApprovalRequestId, | 2 | ApprovalRequestId, |
| 3 | GitBranch, | ||
| 4 | GitRunStackedActionResult, | ||
| 5 | GitStatusResult, | ||
| 6 | ModelSelection, | 3 | ModelSelection, |
| 7 | ProviderApprovalDecision, | 4 | ProviderApprovalDecision, |
| 8 | ProviderInteractionMode, | 5 | ProviderInteractionMode, |
| 9 | RuntimeMode, | 6 | RuntimeMode, |
| 10 | ServerConfig as T3ServerConfig, | 7 | ServerConfig as T3ServerConfig, |
| 11 | } from "@t3tools/contracts"; | 8 | } from "@t3tools/contracts"; |
| 12 | import type { GitActionRequestInput } from "@t3tools/client-runtime"; | ||
| 13 | import * as Haptics from "expo-haptics"; | 9 | import * as Haptics from "expo-haptics"; |
| 14 | import { SymbolView } from "expo-symbols"; | ||
| 15 | import { useCallback, useEffect, useMemo, useRef, useState } from "react"; | 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 | import { Gesture, GestureDetector } from "react-native-gesture-handler"; | 12 | import { Gesture, GestureDetector } from "react-native-gesture-handler"; |
| 18 | import { KeyboardAvoidingView, KeyboardStickyView } from "react-native-keyboard-controller"; | 13 | import { KeyboardAvoidingView, KeyboardStickyView } from "react-native-keyboard-controller"; |
| 19 | import { useSafeAreaInsets } from "react-native-safe-area-context"; | 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 | import type { StatusTone } from "../../components/StatusPill"; | 17 | import type { StatusTone } from "../../components/StatusPill"; |
| 25 | import type { DraftComposerImageAttachment } from "../../lib/composerImages"; | 18 | import type { DraftComposerImageAttachment } from "../../lib/composerImages"; |
| 26 | import type { MobileLayoutVariant } from "../../lib/mobileLayout"; | 19 | import type { MobileLayoutVariant } from "../../lib/mobileLayout"; |
| 27 | import type { ScopedMobileThread } from "../../lib/scopedEntities"; | 20 | import type { ScopedMobileThread } from "../../lib/scopedEntities"; |
| 28 | import { makeAppPalette } from "../../lib/theme"; | ||
| 29 | import type { | 21 | import type { |
| 30 | PendingApproval, | 22 | PendingApproval, |
| 31 | PendingUserInput, | 23 | PendingUserInput, |
| @@ -33,7 +25,6 @@ import type { · 1 unmodified line | |||
| 33 | ThreadFeedEntry, | 25 | ThreadFeedEntry, |
| 34 | } from "../../lib/threadActivity"; | 26 | } from "../../lib/threadActivity"; |
| 35 | import { PendingApprovalCard } from "./PendingApprovalCard"; | 27 | import { PendingApprovalCard } from "./PendingApprovalCard"; |
| 36 | import { ThreadGitControls } from "./ThreadGitControls"; | ||
| 37 | import { PendingUserInputCard } from "./PendingUserInputCard"; | 28 | import { PendingUserInputCard } from "./PendingUserInputCard"; |
| 38 | import { | 29 | import { |
| 39 | COMPOSER_COLLAPSED_CHROME, | 30 | COMPOSER_COLLAPSED_CHROME, |
| @@ -41,7 +32,6 @@ import { · 1 unmodified line | |||
| 41 | ThreadComposer, | 32 | ThreadComposer, |
| 42 | } from "./ThreadComposer"; | 33 | } from "./ThreadComposer"; |
| 43 | import { ThreadFeed } from "./ThreadFeed"; | 34 | import { ThreadFeed } from "./ThreadFeed"; |
| 44 | import { screenTitle } from "./threadPresentation"; | ||
| 45 | 35 | ||
| 46 | export interface ThreadDetailScreenProps { | 36 | export interface ThreadDetailScreenProps { |
| 47 | readonly selectedThread: ScopedMobileThread; | 37 | readonly selectedThread: ScopedMobileThread; |
| @@ -61,12 +51,10 @@ export interface ThreadDetailScreenProps { · 13 unmodified lines | |||
| 61 | readonly draftAttachments: ReadonlyArray<DraftComposerImageAttachment>; | 51 | readonly draftAttachments: ReadonlyArray<DraftComposerImageAttachment>; |
| 62 | readonly connectionStateLabel: "ready" | "connecting" | "reconnecting" | "disconnected" | "idle"; | 52 | readonly connectionStateLabel: "ready" | "connecting" | "reconnecting" | "disconnected" | "idle"; |
| 63 | readonly activeThreadBusy: boolean; | 53 | readonly activeThreadBusy: boolean; |
| 64 | readonly selectedThreadGitStatus: GitStatusResult | null; | 54 | readonly projectWorkspaceRoot: string | null; |
| 65 | readonly gitOperationLabel: string | null; | ||
| 66 | readonly selectedThreadQueueCount: number; | 55 | readonly selectedThreadQueueCount: number; |
| 67 | readonly serverConfig: T3ServerConfig | null; | 56 | readonly serverConfig: T3ServerConfig | null; |
| 68 | readonly layoutVariant?: MobileLayoutVariant; | 57 | readonly layoutVariant?: MobileLayoutVariant; |
| 69 | readonly onBack: () => void; | ||
| 70 | readonly onOpenDrawer: () => void; | 58 | readonly onOpenDrawer: () => void; |
| 71 | readonly onOpenConnectionEditor: () => void; | 59 | readonly onOpenConnectionEditor: () => void; |
| 72 | readonly onChangeDraftMessage: (value: string) => void; | 60 | readonly onChangeDraftMessage: (value: string) => void; |
| @@ -74,21 +62,6 @@ export interface ThreadDetailScreenProps { · 1 unmodified line | |||
| 74 | readonly onNativePasteImages: (uris: ReadonlyArray<string>) => Promise<void>; | 62 | readonly onNativePasteImages: (uris: ReadonlyArray<string>) => Promise<void>; |
| 75 | readonly onRemoveDraftImage: (imageId: string) => void; | 63 | readonly onRemoveDraftImage: (imageId: string) => void; |
| 76 | readonly onRefresh: () => Promise<void>; | 64 | readonly onRefresh: () => Promise<void>; |
| 77 | readonly onRefreshSelectedThreadGitStatus: (options?: { | ||
| 78 | readonly quiet?: boolean; | ||
| 79 | }) => Promise<void>; | ||
| 80 | readonly onListSelectedThreadBranches: () => Promise<ReadonlyArray<GitBranch>>; | ||
| 81 | readonly onCheckoutSelectedThreadBranch: (branch: string) => Promise<void>; | ||
| 82 | readonly onCreateSelectedThreadBranch: (branch: string) => Promise<void>; | ||
| 83 | readonly onCreateSelectedThreadWorktree: (input: { | ||
| 84 | readonly baseBranch: string; | ||
| 85 | readonly newBranch: string; | ||
| 86 | }) => Promise<void>; | ||
| 87 | readonly onPullSelectedThreadBranch: () => Promise<void>; | ||
| 88 | readonly onRunSelectedThreadGitAction: ( | ||
| 89 | input: GitActionRequestInput, | ||
| 90 | ) => Promise<GitRunStackedActionResult | null>; | ||
| 91 | readonly onRenameThread: (title: string) => Promise<void>; | ||
| 92 | readonly onStopThread: () => Promise<void>; | 65 | readonly onStopThread: () => Promise<void>; |
| 93 | readonly onSendMessage: () => void; | 66 | readonly onSendMessage: () => void; |
| 94 | readonly onUpdateThreadModelSelection: (modelSelection: ModelSelection) => Promise<void>; | 67 | readonly onUpdateThreadModelSelection: (modelSelection: ModelSelection) => Promise<void>; |
| @@ -107,7 +80,6 @@ export interface ThreadDetailScreenProps { · 12 unmodified lines | |||
| 107 | customAnswer: string, | 80 | customAnswer: string, |
| 108 | ) => void; | 81 | ) => void; |
| 109 | readonly onSubmitUserInput: () => Promise<void>; | 82 | readonly onSubmitUserInput: () => Promise<void>; |
| 110 | readonly showHeader?: boolean; | ||
| 111 | readonly showContent?: boolean; | 83 | readonly showContent?: boolean; |
| 112 | } | 84 | } |
| 113 | 85 | ||
| @@ -182,20 +154,15 @@ function useStreamingHaptics(threadId: string, feed: ReadonlyArray<ThreadFeedEnt · 68 unmodified lines | |||
| 182 | } | 154 | } |
| 183 | 155 | ||
| 184 | export function ThreadDetailScreen(props: ThreadDetailScreenProps) { | 156 | export function ThreadDetailScreen(props: ThreadDetailScreenProps) { |
| 185 | const { onOpenDrawer, onRefresh, onRefreshSelectedThreadGitStatus } = props; | 157 | const { onOpenDrawer, onRefresh } = props; |
| 186 | const isDarkMode = useColorScheme() === "dark"; | 158 | |
| 187 | const palette = makeAppPalette(isDarkMode); | ||
| 188 | const insets = useSafeAreaInsets(); | 159 | const insets = useSafeAreaInsets(); |
| 189 | const agentLabel = `${props.selectedThread.modelSelection.provider} agent`; | 160 | const agentLabel = `${props.selectedThread.modelSelection.provider} agent`; |
| 190 | const headerOverlayHeight = insets.top + 118; | ||
| 191 | const composerBottomInset = Math.max(insets.bottom, 12); | 161 | const composerBottomInset = Math.max(insets.bottom, 12); |
| 192 | const [composerExpanded, setComposerExpanded] = useState(false); | 162 | const [composerExpanded, setComposerExpanded] = useState(false); |
| 193 | const composerChrome = composerExpanded ? COMPOSER_EXPANDED_CHROME : COMPOSER_COLLAPSED_CHROME; | 163 | const composerChrome = composerExpanded ? COMPOSER_EXPANDED_CHROME : COMPOSER_COLLAPSED_CHROME; |
| 194 | const composerOverlapHeight = composerChrome + composerBottomInset; | 164 | const composerOverlapHeight = composerChrome + composerBottomInset; |
| 195 | const [renameVisible, setRenameVisible] = useState(false); | ||
| 196 | const [refreshing, setRefreshing] = useState(false); | 165 | const [refreshing, setRefreshing] = useState(false); |
| 197 | const [renameDraft, setRenameDraft] = useState(props.selectedThread.title); | ||
| 198 | const showHeader = props.showHeader ?? true; | ||
| 199 | const showContent = props.showContent ?? true; | 166 | const showContent = props.showContent ?? true; |
| 200 | const layoutVariant = props.layoutVariant ?? "compact"; | 167 | const layoutVariant = props.layoutVariant ?? "compact"; |
| 201 | const isSplitLayout = layoutVariant === "split"; | 168 | const isSplitLayout = layoutVariant === "split"; |
| @@ -214,12 +181,12 @@ export function ThreadDetailScreen(props: ThreadDetailScreenProps) { · 12 unmodified lines | |||
| 214 | setRefreshing(true); | 181 | setRefreshing(true); |
| 215 | try { | 182 | try { |
| 216 | await onRefresh(); | 183 | await onRefresh(); |
| 217 | await onRefreshSelectedThreadGitStatus(); | ||
| 218 | } finally { | 184 | } finally { |
| 219 | setRefreshing(false); | 185 | setRefreshing(false); |
| 220 | } | 186 | } |
| 221 | }, [onRefresh, onRefreshSelectedThreadGitStatus, refreshing]); | 187 | }, [onRefresh, refreshing]); |
| 222 | 188 | ||
| 189 | const drawerGestureThreshold = 80; | ||
| 223 | const headerDrawerGesture = useMemo( | 190 | const headerDrawerGesture = useMemo( |
| 224 | () => | 191 | () => |
| 225 | Gesture.Pan() | 192 | Gesture.Pan() |
| @@ -229,110 +196,16 @@ export function ThreadDetailScreen(props: ThreadDetailScreenProps) { · 3 unmodified lines | |||
| 229 | .failOffsetY([-24, 24]) | 196 | .failOffsetY([-24, 24]) |
| 230 | .onEnd((event) => { | 197 | .onEnd((event) => { |
| 231 | const translationX = Math.max(event.translationX, 0); | 198 | const translationX = Math.max(event.translationX, 0); |
| 232 | if (event.y < headerOverlayHeight && translationX > 56) { | 199 | if (event.y < drawerGestureThreshold && translationX > 56) { |
| 233 | runOnJS(completeDrawerGesture)(); | 200 | runOnJS(completeDrawerGesture)(); |
| 234 | } | 201 | } |
| 235 | }), | 202 | }), |
| 236 | [completeDrawerGesture, headerOverlayHeight, isSplitLayout], | 203 | [completeDrawerGesture, isSplitLayout], |
| 237 | ); | 204 | ); |
| 238 | 205 | ||
| 239 | async function handleSubmitRename(): Promise<void> { | ||
| 240 | const trimmed = renameDraft.trim(); | ||
| 241 | if (trimmed.length === 0) { | ||
| 242 | return; | ||
| 243 | } | ||
| 244 | |||
| 245 | await props.onRenameThread(trimmed); | ||
| 246 | setRenameVisible(false); | ||
| 247 | } | ||
| 248 | |||
| 249 | return ( | 206 | return ( |
| 250 | <GestureDetector gesture={headerDrawerGesture}> | 207 | <GestureDetector gesture={headerDrawerGesture}> |
| 251 | <View style={{ flex: 1, backgroundColor: palette.screenBackground }}> | 208 | <View className="flex-1"> |
| 252 | {showHeader ? ( | ||
| 253 | <View className="absolute inset-x-0 top-0 z-20"> | ||
| 254 | <View | ||
| 255 | style={{ | ||
| 256 | backgroundColor: palette.headerBackground, | ||
| 257 | borderBottomWidth: 1, | ||
| 258 | borderBottomColor: palette.headerBorder, | ||
| 259 | }} | ||
| 260 | > | ||
| 261 | <GlassSafeAreaView | ||
| 262 | leftSlot={ | ||
| 263 | isSplitLayout ? null : ( | ||
| 264 | <Pressable | ||
| 265 | className="h-11 w-11 items-center justify-center rounded-full" | ||
| 266 | style={{ | ||
| 267 | backgroundColor: palette.subtleBg, | ||
| 268 | }} | ||
| 269 | onPress={props.onBack} | ||
| 270 | > | ||
| 271 | <SymbolView | ||
| 272 | name="chevron.left" | ||
| 273 | size={18} | ||
| 274 | tintColor={palette.icon} | ||
| 275 | type="monochrome" | ||
| 276 | /> | ||
| 277 | </Pressable> | ||
| 278 | ) | ||
| 279 | } | ||
| 280 | centerSlot={ | ||
| 281 | <View style={{ alignItems: "center", gap: 2, width: "100%" }}> | ||
| 282 | <Pressable | ||
| 283 | style={{ maxWidth: "100%" }} | ||
| 284 | onLongPress={() => { | ||
| 285 | setRenameDraft(props.selectedThread.title); | ||
| 286 | setRenameVisible(true); | ||
| 287 | }} | ||
| 288 | > | ||
| 289 | <Animated.Text | ||
| 290 | numberOfLines={1} | ||
| 291 | style={{ | ||
| 292 | color: palette.text, | ||
| 293 | fontSize: 17, | ||
| 294 | fontWeight: "800", | ||
| 295 | lineHeight: 22, | ||
| 296 | textAlign: "center", | ||
| 297 | }} | ||
| 298 | > | ||
| 299 | {props.selectedThread.title} | ||
| 300 | </Animated.Text> | ||
| 301 | </Pressable> | ||
| 302 | <Text | ||
| 303 | className="text-[11px] font-t3-medium" | ||
| 304 | numberOfLines={1} | ||
| 305 | style={{ color: palette.textMuted, letterSpacing: 0.3, textAlign: "center" }} | ||
| 306 | > | ||
| 307 | {[ | ||
| 308 | screenTitle(props.serverConfig, null), | ||
| 309 | props.selectedThread.environmentLabel, | ||
| 310 | ] | ||
| 311 | .filter(Boolean) | ||
| 312 | .join(" · ")} | ||
| 313 | </Text> | ||
| 314 | </View> | ||
| 315 | } | ||
| 316 | rightSlot={ | ||
| 317 | <ThreadGitControls | ||
| 318 | currentBranch={props.selectedThread.branch} | ||
| 319 | currentWorktreePath={props.selectedThread.worktreePath} | ||
| 320 | gitStatus={props.selectedThreadGitStatus} | ||
| 321 | gitOperationLabel={props.gitOperationLabel} | ||
| 322 | onRefreshStatus={props.onRefreshSelectedThreadGitStatus} | ||
| 323 | onListBranches={props.onListSelectedThreadBranches} | ||
| 324 | onCheckoutBranch={props.onCheckoutSelectedThreadBranch} | ||
| 325 | onCreateBranch={props.onCreateSelectedThreadBranch} | ||
| 326 | onCreateWorktree={props.onCreateSelectedThreadWorktree} | ||
| 327 | onPull={props.onPullSelectedThreadBranch} | ||
| 328 | onRunAction={props.onRunSelectedThreadGitAction} | ||
| 329 | /> | ||
| 330 | } | ||
| 331 | /> | ||
| 332 | </View> | ||
| 333 | </View> | ||
| 334 | ) : null} | ||
| 335 | |||
| 336 | {/* Feed area — KAV shrinks this when keyboard opens */} | 209 | {/* Feed area — KAV shrinks this when keyboard opens */} |
| 337 | <KeyboardAvoidingView behavior="padding" style={{ flex: 1 }}> | 210 | <KeyboardAvoidingView behavior="padding" style={{ flex: 1 }}> |
| 338 | {showContent ? ( | 211 | {showContent ? ( |
| @@ -343,7 +216,6 @@ export function ThreadDetailScreen(props: ThreadDetailScreenProps) { · 4 unmodified lines | |||
| 343 | httpBaseUrl={props.httpBaseUrl} | 216 | httpBaseUrl={props.httpBaseUrl} |
| 344 | bearerToken={props.bearerToken} | 217 | bearerToken={props.bearerToken} |
| 345 | agentLabel={agentLabel} | 218 | agentLabel={agentLabel} |
| 346 | contentTopInset={headerOverlayHeight + 20} | ||
| 347 | contentBottomInset={composerOverlapHeight + 8} | 219 | contentBottomInset={composerOverlapHeight + 8} |
| 348 | layoutVariant={layoutVariant} | 220 | layoutVariant={layoutVariant} |
| 349 | composerExpanded={composerExpanded} | 221 | composerExpanded={composerExpanded} |
| @@ -410,95 +282,6 @@ export function ThreadDetailScreen(props: ThreadDetailScreenProps) { · 60 unmodified lines | |||
| 410 | /> | 282 | /> |
| 411 | </KeyboardStickyView> | 283 | </KeyboardStickyView> |
| 412 | ) : null} | 284 | ) : null} |
| 413 | |||
| 414 | <Modal | ||
| 415 | transparent | ||
| 416 | animationType="fade" | ||
| 417 | visible={renameVisible} | ||
| 418 | onRequestClose={() => setRenameVisible(false)} | ||
| 419 | > | ||
| 420 | <View | ||
| 421 | className="flex-1 items-center justify-center px-5" | ||
| 422 | style={{ backgroundColor: palette.backdrop }} | ||
| 423 | > | ||
| 424 | <View | ||
| 425 | className="w-full gap-4 px-4 py-4" | ||
| 426 | style={{ | ||
| 427 | maxWidth: 420, | ||
| 428 | borderWidth: 1, | ||
| 429 | borderColor: palette.border, | ||
| 430 | backgroundColor: palette.card, | ||
| 431 | }} | ||
| 432 | > | ||
| 433 | <View className="gap-2"> | ||
| 434 | <Text | ||
| 435 | className="text-[11px] font-bold uppercase" | ||
| 436 | style={{ color: palette.textMuted, letterSpacing: 1.2 }} | ||
| 437 | > | ||
| 438 | Thread name | ||
| 439 | </Text> | ||
| 440 | <Text | ||
| 441 | className="text-[20px] font-extrabold leading-[24px]" | ||
| 442 | style={{ color: palette.text }} | ||
| 443 | > | ||
| 444 | Rename thread | ||
| 445 | </Text> | ||
| 446 | </View> | ||
| 447 | |||
| 448 | <TextInput | ||
| 449 | value={renameDraft} | ||
| 450 | onChangeText={setRenameDraft} | ||
| 451 | placeholder="Thread title" | ||
| 452 | className="min-h-[56px] px-4 py-3 text-[15px]" | ||
| 453 | style={{ | ||
| 454 | borderWidth: 1, | ||
| 455 | borderColor: palette.border, | ||
| 456 | backgroundColor: palette.inputBackground, | ||
| 457 | color: palette.text, | ||
| 458 | }} | ||
| 459 | onSubmitEditing={() => { | ||
| 460 | void handleSubmitRename(); | ||
| 461 | }} | ||
| 462 | /> | ||
| 463 | |||
| 464 | <View className="flex-row gap-3"> | ||
| 465 | <Pressable | ||
| 466 | className="min-h-[48px] flex-1 items-center justify-center px-4 py-3" | ||
| 467 | style={{ | ||
| 468 | borderWidth: 1, | ||
| 469 | borderColor: palette.border, | ||
| 470 | backgroundColor: palette.secondaryButton, | ||
| 471 | }} | ||
| 472 | onPress={() => { | ||
| 473 | setRenameDraft(props.selectedThread.title); | ||
| 474 | setRenameVisible(false); | ||
| 475 | }} | ||
| 476 | > | ||
| 477 | <Text | ||
| 478 | className="text-sm font-extrabold uppercase" | ||
| 479 | style={{ color: palette.text, letterSpacing: 1 }} | ||
| 480 | > | ||
| 481 | Cancel | ||
| 482 | </Text> | ||
| 483 | </Pressable> | ||
| 484 | <Pressable | ||
| 485 | className="min-h-[48px] flex-1 items-center justify-center px-4 py-3" | ||
| 486 | style={{ backgroundColor: palette.primaryButton }} | ||
| 487 | onPress={() => { | ||
| 488 | void handleSubmitRename(); | ||
| 489 | }} | ||
| 490 | > | ||
| 491 | <Text | ||
| 492 | className="text-sm font-extrabold uppercase" | ||
| 493 | style={{ color: palette.primaryButtonText, letterSpacing: 1 }} | ||
| 494 | > | ||
| 495 | Save | ||
| 496 | </Text> | ||
| 497 | </Pressable> | ||
| 498 | </View> | ||
| 499 | </View> | ||
| 500 | </View> | ||
| 501 | </Modal> | ||
| 502 | </View> | 285 | </View> |
| 503 | </GestureDetector> | 286 | </GestureDetector> |
| 504 | ); | 287 | ); |
| @@ -4,15 +4,15 @@ import { LegendList, type LegendListRef } from "@legendapp/list/react-native"; · 3 unmodified lines | |||
| 4 | import { SymbolView } from "expo-symbols"; | 4 | import { SymbolView } from "expo-symbols"; |
| 5 | import { memo, useCallback, useEffect, useState, useRef } from "react"; | 5 | import { memo, useCallback, useEffect, useState, useRef } from "react"; |
| 6 | import Markdown from "react-native-markdown-display"; | 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 | import { TouchableOpacity } from "react-native-gesture-handler"; | 8 | import { TouchableOpacity } from "react-native-gesture-handler"; |
| 9 | import ImageViewing from "react-native-image-viewing"; | 9 | import ImageViewing from "react-native-image-viewing"; |
| 10 | import { useThemeColor } from "../../lib/useThemeColor"; | ||
| 10 | 11 | ||
| 11 | import { AppText as Text } from "../../components/AppText"; | 12 | import { AppText as Text } from "../../components/AppText"; |
| 12 | import { EmptyState } from "../../components/EmptyState"; | 13 | import { EmptyState } from "../../components/EmptyState"; |
| 13 | import { cx } from "../../lib/classNames"; | 14 | import { cn } from "../../lib/cn"; |
| 14 | import type { MobileLayoutVariant } from "../../lib/mobileLayout"; | 15 | import type { MobileLayoutVariant } from "../../lib/mobileLayout"; |
| 15 | import { makeAppPalette } from "../../lib/theme"; | ||
| 16 | import type { ThreadFeedEntry } from "../../lib/threadActivity"; | 16 | import type { ThreadFeedEntry } from "../../lib/threadActivity"; |
| 17 | import { relativeTime } from "../../lib/time"; | 17 | import { relativeTime } from "../../lib/time"; |
| 18 | import { messageImageUrl } from "./threadPresentation"; | 18 | import { messageImageUrl } from "./threadPresentation"; |
| @@ -23,7 +23,6 @@ export interface ThreadFeedProps { · 4 unmodified lines | |||
| 23 | readonly httpBaseUrl: string | null; | 23 | readonly httpBaseUrl: string | null; |
| 24 | readonly bearerToken: string | null; | 24 | readonly bearerToken: string | null; |
| 25 | readonly agentLabel: string; | 25 | readonly agentLabel: string; |
| 26 | readonly contentTopInset?: number; | ||
| 27 | readonly contentBottomInset?: number; | 26 | readonly contentBottomInset?: number; |
| 28 | readonly refreshing?: boolean; | 27 | readonly refreshing?: boolean; |
| 29 | readonly onRefresh?: () => void; | 28 | readonly onRefresh?: () => void; |
| @@ -99,18 +98,26 @@ function buildActivityRows( · 69 unmodified lines | |||
| 99 | 98 | ||
| 100 | const MAX_VISIBLE_WORK_LOG_ENTRIES = 6; | 99 | const MAX_VISIBLE_WORK_LOG_ENTRIES = 6; |
| 101 | 100 | ||
| 102 | function makeMarkdownStyles(isDarkMode: boolean) { | 101 | interface MarkdownStyleSets { |
| 103 | const bodyColor = isDarkMode ? "#e5e5e5" : "#262626"; | 102 | // eslint-disable-next-line @typescript-eslint/no-explicit-any |
| 104 | const strongColor = isDarkMode ? "#f5f5f5" : "#171717"; | 103 | readonly user: Record<string, any>; |
| 105 | const linkColor = isDarkMode ? "#60a5fa" : "#2563eb"; | 104 | // eslint-disable-next-line @typescript-eslint/no-explicit-any |
| 106 | const blockquoteBg = isDarkMode ? "rgba(255,255,255,0.03)" : "rgba(0,0,0,0.02)"; | 105 | readonly assistant: Record<string, any>; |
| 107 | const blockquoteBorder = isDarkMode ? "rgba(255,255,255,0.10)" : "rgba(0,0,0,0.10)"; | 106 | } |
| 108 | const codeBg = isDarkMode ? "rgba(255,255,255,0.06)" : "rgba(0,0,0,0.04)"; | 107 | |
| 109 | const codeText = isDarkMode ? "#e5e5e5" : "#262626"; | 108 | function useMarkdownStyles(): MarkdownStyleSets { |
| 110 | const hrColor = isDarkMode ? "rgba(255,255,255,0.08)" : "rgba(0,0,0,0.08)"; | 109 | const bodyColor = useThemeColor("--color-md-body"); |
| 111 | const userCodeBg = isDarkMode ? "rgba(255,255,255,0.10)" : "rgba(0,0,0,0.05)"; | 110 | const strongColor = useThemeColor("--color-md-strong"); |
| 112 | const userCodeText = isDarkMode ? "#e5e5e5" : "#1e3a5f"; | 111 | const linkColor = useThemeColor("--color-md-link"); |
| 113 | const userFenceBg = isDarkMode ? "rgba(255,255,255,0.08)" : "rgba(0,0,0,0.04)"; | 112 | const blockquoteBg = useThemeColor("--color-md-blockquote-bg"); |
| 113 | const blockquoteBorder = useThemeColor("--color-md-blockquote-border"); | ||
| 114 | const codeBg = useThemeColor("--color-md-code-bg"); | ||
| 115 | const codeText = useThemeColor("--color-md-code-text"); | ||
| 116 | const hrColor = useThemeColor("--color-md-hr"); | ||
| 117 | const userCodeBg = useThemeColor("--color-md-user-code-bg"); | ||
| 118 | const userCodeText = useThemeColor("--color-md-user-code-text"); | ||
| 119 | const userFenceBg = useThemeColor("--color-md-user-fence-bg"); | ||
| 120 | const userFenceText = useThemeColor("--color-md-user-fence-text"); | ||
| 114 | 121 | ||
| 115 | const base = { | 122 | const base = { |
| 116 | body: { | 123 | body: { |
| @@ -193,7 +200,7 @@ function makeMarkdownStyles(isDarkMode: boolean) { · 76 unmodified lines | |||
| 193 | }, | 200 | }, |
| 194 | code_block: { | 201 | code_block: { |
| 195 | backgroundColor: userFenceBg, | 202 | backgroundColor: userFenceBg, |
| 196 | color: userCodeText, | 203 | color: userFenceText, |
| 197 | borderRadius: 12, | 204 | borderRadius: 12, |
| 198 | padding: 12, | 205 | padding: 12, |
| 199 | fontFamily: "ui-monospace", | 206 | fontFamily: "ui-monospace", |
| @@ -202,7 +209,7 @@ function makeMarkdownStyles(isDarkMode: boolean) { · 2 unmodified lines | |||
| 202 | }, | 209 | }, |
| 203 | fence: { | 210 | fence: { |
| 204 | backgroundColor: userFenceBg, | 211 | backgroundColor: userFenceBg, |
| 205 | color: userCodeText, | 212 | color: userFenceText, |
| 206 | borderRadius: 12, | 213 | borderRadius: 12, |
| 207 | padding: 12, | 214 | padding: 12, |
| 208 | fontFamily: "ui-monospace", | 215 | fontFamily: "ui-monospace", |
| @@ -253,12 +260,12 @@ function renderFeedEntry( · 44 unmodified lines | |||
| 253 | readonly onCopyWorkRow: (rowId: string, value: string) => void; | 260 | readonly onCopyWorkRow: (rowId: string, value: string) => void; |
| 254 | readonly onToggleWorkGroup: (groupId: string) => void; | 261 | readonly onToggleWorkGroup: (groupId: string) => void; |
| 255 | readonly onPressImage: (uri: string, headers?: Record<string, string>) => void; | 262 | readonly onPressImage: (uri: string, headers?: Record<string, string>) => void; |
| 256 | readonly isDarkMode: boolean; | 263 | readonly iconSubtleColor: string | import("react-native").ColorValue; |
| 264 | readonly markdownStyles: MarkdownStyleSets; | ||
| 257 | }, | 265 | }, |
| 258 | ) { | 266 | ) { |
| 259 | const entry = info.item; | 267 | const entry = info.item; |
| 260 | const markdownStyles = makeMarkdownStyles(props.isDarkMode); | 268 | const { markdownStyles, iconSubtleColor } = props; |
| 261 | const palette = makeAppPalette(props.isDarkMode); | ||
| 262 | 269 | ||
| 263 | if (entry.type === "message") { | 270 | if (entry.type === "message") { |
| 264 | const { message } = entry; | 271 | const { message } = entry; |
| @@ -389,18 +396,13 @@ function renderFeedEntry( · 124 unmodified lines | |||
| 389 | {visibleRows.map((row, index) => ( | 396 | {visibleRows.map((row, index) => ( |
| 390 | <View | 397 | <View |
| 391 | key={row.id} | 398 | key={row.id} |
| 392 | className={cx( | 399 | className={cn( |
| 393 | "flex-row items-center gap-2 rounded-lg px-1 py-1", | 400 | "flex-row items-center gap-2 rounded-lg px-1 py-1", |
| 394 | index > 0 && "border-t border-neutral-200/80 dark:border-white/[0.06]", | 401 | index > 0 && "border-t border-neutral-200/80 dark:border-white/[0.06]", |
| 395 | )} | 402 | )} |
| 396 | > | 403 | > |
| 397 | <View className="items-center justify-center pt-0.5"> | 404 | <View className="items-center justify-center pt-0.5"> |
| 398 | <SymbolView | 405 | <SymbolView name="terminal" size={13} tintColor={iconSubtleColor} type="monochrome" /> |
| 399 | name="terminal" | ||
| 400 | size={13} | ||
| 401 | tintColor={palette.iconSubtle} | ||
| 402 | type="monochrome" | ||
| 403 | /> | ||
| 404 | </View> | 406 | </View> |
| 405 | <ScrollView | 407 | <ScrollView |
| 406 | horizontal | 408 | horizontal |
| @@ -439,7 +441,6 @@ function renderFeedEntry( · 32 unmodified lines | |||
| 439 | } | 441 | } |
| 440 | 442 | ||
| 441 | export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { | 443 | export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { |
| 442 | const isDarkMode = useColorScheme() === "dark"; | ||
| 443 | const listRef = useRef<LegendListRef>(null); | 444 | const listRef = useRef<LegendListRef>(null); |
| 444 | const copyFeedbackTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null); | 445 | const copyFeedbackTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null); |
| 445 | const [copiedRowId, setCopiedRowId] = useState<string | null>(null); | 446 | const [copiedRowId, setCopiedRowId] = useState<string | null>(null); |
| @@ -450,6 +451,9 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { · 4 unmodified lines | |||
| 450 | } | null>(null); | 451 | } | null>(null); |
| 451 | const horizontalPadding = props.layoutVariant === "split" ? 20 : 16; | 452 | const horizontalPadding = props.layoutVariant === "split" ? 20 : 16; |
| 452 | 453 | ||
| 454 | const iconSubtleColor = useThemeColor("--color-icon-subtle"); | ||
| 455 | const markdownStyles = useMarkdownStyles(); | ||
| 456 | |||
| 453 | useEffect(() => { | 457 | useEffect(() => { |
| 454 | setCopiedRowId(null); | 458 | setCopiedRowId(null); |
| 455 | setExpandedWorkGroups({}); | 459 | setExpandedWorkGroups({}); |
| @@ -509,12 +513,14 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { · 53 unmodified lines | |||
| 509 | onCopyWorkRow, | 513 | onCopyWorkRow, |
| 510 | onToggleWorkGroup, | 514 | onToggleWorkGroup, |
| 511 | onPressImage, | 515 | onPressImage, |
| 512 | isDarkMode, | 516 | iconSubtleColor, |
| 517 | markdownStyles, | ||
| 513 | }), | 518 | }), |
| 514 | [ | 519 | [ |
| 515 | copiedRowId, | 520 | copiedRowId, |
| 516 | expandedWorkGroups, | 521 | expandedWorkGroups, |
| 517 | isDarkMode, | 522 | iconSubtleColor, |
| 523 | markdownStyles, | ||
| 518 | onCopyWorkRow, | 524 | onCopyWorkRow, |
| 519 | onPressImage, | 525 | onPressImage, |
| 520 | onToggleWorkGroup, | 526 | onToggleWorkGroup, |
| @@ -525,12 +531,12 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { · 4 unmodified lines | |||
| 525 | 531 | ||
| 526 | if (props.feed.length === 0) { | 532 | if (props.feed.length === 0) { |
| 527 | return ( | 533 | return ( |
| 528 | <View | 534 | <ScrollView |
| 529 | className="flex-1" | 535 | style={{ flex: 1 }} |
| 530 | style={{ | 536 | contentInsetAdjustmentBehavior="automatic" |
| 531 | minHeight: 0, | 537 | contentContainerStyle={{ |
| 538 | flexGrow: 1, | ||
| 532 | paddingHorizontal: horizontalPadding, | 539 | paddingHorizontal: horizontalPadding, |
| 533 | paddingTop: props.contentTopInset ?? 18, | ||
| 534 | paddingBottom: props.contentBottomInset ?? 18, | 540 | paddingBottom: props.contentBottomInset ?? 18, |
| 535 | }} | 541 | }} |
| 536 | > | 542 | > |
| @@ -538,7 +544,7 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { · 1 unmodified line | |||
| 538 | title="No conversation yet" | 544 | title="No conversation yet" |
| 539 | detail="Ask the agent to inspect the repo, run a command, or continue the active thread." | 545 | detail="Ask the agent to inspect the repo, run a command, or continue the active thread." |
| 540 | /> | 546 | /> |
| 541 | </View> | 547 | </ScrollView> |
| 542 | ); | 548 | ); |
| 543 | } | 549 | } |
| 544 | 550 | ||
| @@ -548,6 +554,7 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { · 3 unmodified lines | |||
| 548 | ref={listRef} | 554 | ref={listRef} |
| 549 | key={props.threadId} | 555 | key={props.threadId} |
| 550 | style={{ flex: 1 }} | 556 | style={{ flex: 1 }} |
| 557 | contentInsetAdjustmentBehavior="automatic" | ||
| 551 | data={props.feed as ThreadFeedEntry[]} | 558 | data={props.feed as ThreadFeedEntry[]} |
| 552 | renderItem={renderItem} | 559 | renderItem={renderItem} |
| 553 | keyExtractor={(entry) => `${entry.type}:${entry.id}`} | 560 | keyExtractor={(entry) => `${entry.type}:${entry.id}`} |
| @@ -561,7 +568,7 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { · 7 unmodified lines | |||
| 561 | onRefresh={props.onRefresh} | 568 | onRefresh={props.onRefresh} |
| 562 | contentContainerStyle={{ | 569 | contentContainerStyle={{ |
| 563 | paddingHorizontal: horizontalPadding, | 570 | paddingHorizontal: horizontalPadding, |
| 564 | paddingTop: props.contentTopInset ?? 18, | 571 | paddingTop: 8, |
| 565 | paddingBottom: props.contentBottomInset ?? 18, | 572 | paddingBottom: props.contentBottomInset ?? 18, |
| 566 | }} | 573 | }} |
| 567 | /> | 574 | /> |
| @@ -1,93 +1,13 @@ | |||
| 1 | import type { GitRunStackedActionResult, GitStatusResult } from "@t3tools/contracts"; | ||
| 1 | import { | 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 | type GitActionRequestInput, | 3 | type GitActionRequestInput, |
| 13 | requiresDefaultBranchConfirmation, | 4 | requiresDefaultBranchConfirmation, |
| 14 | resolveDefaultBranchActionDialogCopy, | ||
| 15 | resolveQuickAction, | 5 | resolveQuickAction, |
| 16 | } from "@t3tools/client-runtime"; | 6 | } from "@t3tools/client-runtime"; |
| 17 | import { resolveAutoFeatureBranchName, sanitizeFeatureBranchName } from "@t3tools/shared/git"; | 7 | import { useLocalSearchParams, useRouter } from "expo-router"; |
| 18 | import { SymbolView } from "expo-symbols"; | 8 | import Stack from "expo-router/stack"; |
| 19 | import type { ComponentProps, RefObject } from "react"; | 9 | import { useCallback, useMemo } from "react"; |
| 20 | import { useCallback, useEffect, useMemo, useRef, useState } from "react"; | 10 | import { Alert, Linking } from "react-native"; |
| 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 | } | ||
| 91 | 11 | ||
| 92 | function truncateMiddle(value: string, maxLength: number): string { | 12 | function truncateMiddle(value: string, maxLength: number): string { |
| 93 | if (value.length <= maxLength) { | 13 | if (value.length <= maxLength) { |
| @@ -130,211 +50,26 @@ function compactMenuStatus(gitStatus: GitStatusResult | null): string { · 36 unmodified lines | |||
| 130 | return parts.join(" · "); | 50 | return parts.join(" · "); |
| 131 | } | 51 | } |
| 132 | 52 | ||
| 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 | export function ThreadGitControls(props: { | 53 | export function ThreadGitControls(props: { |
| 285 | readonly currentBranch: string | null; | 54 | readonly currentBranch: string | null; |
| 286 | readonly currentWorktreePath: string | null; | ||
| 287 | readonly gitStatus: GitStatusResult | null; | 55 | readonly gitStatus: GitStatusResult | null; |
| 288 | readonly gitOperationLabel: string | null; | 56 | readonly gitOperationLabel: string | null; |
| 289 | readonly onRefreshStatus: (options?: { readonly quiet?: boolean }) => Promise<void>; | ||
| 290 | readonly onListBranches: () => Promise<ReadonlyArray<GitBranch>>; | ||
| 291 | readonly onCheckoutBranch: (branch: string) => Promise<void>; | ||
| 292 | readonly onCreateBranch: (branch: string) => Promise<void>; | ||
| 293 | readonly onCreateWorktree: (input: { | ||
| 294 | readonly baseBranch: string; | ||
| 295 | readonly newBranch: string; | ||
| 296 | }) => Promise<void>; | ||
| 297 | readonly onPull: () => Promise<void>; | 57 | readonly onPull: () => Promise<void>; |
| 298 | readonly onRunAction: (input: GitActionRequestInput) => Promise<GitRunStackedActionResult | null>; | 58 | readonly onRunAction: (input: GitActionRequestInput) => Promise<GitRunStackedActionResult | null>; |
| 299 | }) { | 59 | }) { |
| 300 | const isDarkMode = useColorScheme() === "dark"; | 60 | const router = useRouter(); |
| 301 | const palette = makeAppPalette(isDarkMode); | 61 | const { environmentId, threadId } = useLocalSearchParams<{ |
| 302 | const insets = useSafeAreaInsets(); | 62 | environmentId: string; |
| 303 | const { | 63 | threadId: string; |
| 304 | currentWorktreePath, | 64 | }>(); |
| 305 | gitOperationLabel, | 65 | const { gitStatus, gitOperationLabel, onPull, onRunAction } = props; |
| 306 | gitStatus, | 66 | |
| 307 | onCheckoutBranch, | 67 | const currentBranchLabel = gitStatus?.branch ?? props.currentBranch ?? "Detached HEAD"; |
| 308 | onCreateBranch, | ||
| 309 | onCreateWorktree, | ||
| 310 | onListBranches, | ||
| 311 | onPull, | ||
| 312 | onRefreshStatus, | ||
| 313 | onRunAction, | ||
| 314 | } = props; | ||
| 315 | const gitSheetRef = useRef<BottomSheetModal>(null); | ||
| 316 | const branchSheetRef = useRef<BottomSheetModal>(null); | ||
| 317 | const commitSheetRef = useRef<BottomSheetModal>(null); | ||
| 318 | const defaultBranchSheetRef = useRef<BottomSheetModal>(null); | ||
| 319 | const [gitSheetVisible, setGitSheetVisible] = useState(false); | ||
| 320 | const [branchSheetVisible, setBranchSheetVisible] = useState(false); | ||
| 321 | const [commitSheetVisible, setCommitSheetVisible] = useState(false); | ||
| 322 | const [defaultBranchSheetVisible, setDefaultBranchSheetVisible] = useState(false); | ||
| 323 | const [isEditingFiles, setIsEditingFiles] = useState(false); | ||
| 324 | const [availableBranches, setAvailableBranches] = useState<ReadonlyArray<GitBranch>>([]); | ||
| 325 | const [branchesLoading, setBranchesLoading] = useState(false); | ||
| 326 | const [newBranchName, setNewBranchName] = useState(""); | ||
| 327 | const [worktreeBaseBranch, setWorktreeBaseBranch] = useState(""); | ||
| 328 | const [worktreeBranchName, setWorktreeBranchName] = useState(""); | ||
| 329 | const [dialogCommitMessage, setDialogCommitMessage] = useState(""); | ||
| 330 | const [excludedFiles, setExcludedFiles] = useState<ReadonlySet<string>>(new Set()); | ||
| 331 | const [pendingDefaultBranchAction, setPendingDefaultBranchAction] = | ||
| 332 | useState<PendingDefaultBranchAction | null>(null); | ||
| 333 | const currentBranchLabel = deriveAvailableBranchLabel(props.currentBranch, gitStatus); | ||
| 334 | const busy = gitOperationLabel !== null; | 68 | const busy = gitOperationLabel !== null; |
| 335 | const isRepo = gitStatus?.isRepo ?? true; | 69 | const isRepo = gitStatus?.isRepo ?? true; |
| 336 | const hasOriginRemote = gitStatus?.hasOriginRemote ?? false; | 70 | const hasOriginRemote = gitStatus?.hasOriginRemote ?? false; |
| 337 | const isDefaultBranch = gitStatus?.isDefaultBranch ?? false; | 71 | const isDefaultBranch = gitStatus?.isDefaultBranch ?? false; |
| 72 | |||
| 338 | const quickAction = useMemo( | 73 | const quickAction = useMemo( |
| 339 | () => | 74 | () => |
| 340 | isRepo | 75 | isRepo |
| @@ -347,163 +82,21 @@ export function ThreadGitControls(props: { · 6 unmodified lines | |||
| 347 | }, | 82 | }, |
| 348 | [busy, gitStatus, hasOriginRemote, isDefaultBranch, isRepo], | 83 | [busy, gitStatus, hasOriginRemote, isDefaultBranch, isRepo], |
| 349 | ); | 84 | ); |
| 350 | const menuItems = useMemo( | 85 | |
| 351 | () => (isRepo ? buildMenuItems(gitStatus, busy, hasOriginRemote) : []), | ||
| 352 | [busy, gitStatus, hasOriginRemote, isRepo], | ||
| 353 | ); | ||
| 354 | const quickActionHint = quickAction.disabled | 86 | const quickActionHint = quickAction.disabled |
| 355 | ? (quickAction.hint ?? "This action is unavailable.") | 87 | ? (quickAction.hint ?? "This action is unavailable.") |
| 356 | : null; | 88 | : null; |
| 357 | const triggerStatus = triggerStatusLabel(gitStatus, gitOperationLabel); | ||
| 358 | const allFiles = gitStatus?.workingTree.files ?? []; | ||
| 359 | const selectedFiles = allFiles.filter((file) => !excludedFiles.has(file.path)); | ||
| 360 | const allSelected = excludedFiles.size === 0; | ||
| 361 | const noneSelected = selectedFiles.length === 0; | ||
| 362 | const selectedInsertions = selectedFiles.reduce((sum, file) => sum + file.insertions, 0); | ||
| 363 | const selectedDeletions = selectedFiles.reduce((sum, file) => sum + file.deletions, 0); | ||
| 364 | const selectedFilePreview = selectedFiles.slice(0, 3); | ||
| 365 | const renderBackdrop = useCallback( | ||
| 366 | (backdropProps: ComponentProps<typeof BottomSheetBackdrop>) => ( | ||
| 367 | <BottomSheetBackdrop | ||
| 368 | {...backdropProps} | ||
| 369 | appearsOnIndex={0} | ||
| 370 | disappearsOnIndex={-1} | ||
| 371 | opacity={0.18} | ||
| 372 | pressBehavior="close" | ||
| 373 | /> | ||
| 374 | ), | ||
| 375 | [], | ||
| 376 | ); | ||
| 377 | const gitSnapPoints = useMemo<(string | number)[]>(() => ["72%"], []); | ||
| 378 | const branchSnapPoints = useMemo<(string | number)[]>(() => ["86%"], []); | ||
| 379 | const commitSnapPoints = useMemo<(string | number)[]>(() => ["88%"], []); | ||
| 380 | const defaultBranchSnapPoints = useMemo<(string | number)[]>(() => ["40%"], []); | ||
| 381 | const pendingDefaultBranchActionCopy = pendingDefaultBranchAction | ||
| 382 | ? resolveDefaultBranchActionDialogCopy({ | ||
| 383 | action: pendingDefaultBranchAction.action, | ||
| 384 | branchName: pendingDefaultBranchAction.branchName, | ||
| 385 | includesCommit: pendingDefaultBranchAction.includesCommit, | ||
| 386 | }) | ||
| 387 | : null; | ||
| 388 | const disabledExistingBranches = useMemo( | ||
| 389 | () => | ||
| 390 | new Set( | ||
| 391 | availableBranches | ||
| 392 | .filter( | ||
| 393 | (branch) => branch.worktreePath !== null && branch.worktreePath !== currentWorktreePath, | ||
| 394 | ) | ||
| 395 | .map((branch) => branch.name), | ||
| 396 | ), | ||
| 397 | [availableBranches, currentWorktreePath], | ||
| 398 | ); | ||
| 399 | const sheetMenuItems = useMemo( | ||
| 400 | () => | ||
| 401 | menuItems.map((item) => ({ | ||
| 402 | item, | ||
| 403 | disabledReason: getGitActionDisabledReason({ | ||
| 404 | item, | ||
| 405 | gitStatus, | ||
| 406 | isBusy: busy, | ||
| 407 | hasOriginRemote, | ||
| 408 | }), | ||
| 409 | })), | ||
| 410 | [busy, gitStatus, hasOriginRemote, menuItems], | ||
| 411 | ); | ||
| 412 | const nativeMenuActions = useMemo< | ||
| 413 | Array<{ | ||
| 414 | id: string; | ||
| 415 | title: string; | ||
| 416 | subtitle?: string; | ||
| 417 | state?: "off"; | ||
| 418 | image?: string; | ||
| 419 | preferredElementSize?: "small"; | ||
| 420 | attributes?: { | ||
| 421 | disabled?: boolean; | ||
| 422 | }; | ||
| 423 | }> | ||
| 424 | >( | ||
| 425 | () => [ | ||
| 426 | { | ||
| 427 | id: "branch", | ||
| 428 | title: compactMenuBranchLabel(currentBranchLabel), | ||
| 429 | subtitle: compactMenuStatus(gitStatus), | ||
| 430 | image: "point.topleft.down.curvedto.point.bottomright.up", | ||
| 431 | state: "off", | ||
| 432 | preferredElementSize: "small", | ||
| 433 | attributes: { disabled: true }, | ||
| 434 | }, | ||
| 435 | { | ||
| 436 | id: "quick", | ||
| 437 | title: quickAction.label, | ||
| 438 | image: | ||
| 439 | quickAction.kind === "run_pull" | ||
| 440 | ? "arrow.down.circle" | ||
| 441 | : quickAction.kind === "open_pr" | ||
| 442 | ? "arrow.up.right.circle" | ||
| 443 | : quickAction.action === "commit" | ||
| 444 | ? "checkmark.circle" | ||
| 445 | : quickAction.action === "push" || quickAction.action === "commit_push" | ||
| 446 | ? "arrow.up.circle" | ||
| 447 | : "arrow.up.right.circle", | ||
| 448 | ...(quickActionHint ? { subtitle: quickActionHint } : {}), | ||
| 449 | preferredElementSize: "small", | ||
| 450 | ...(quickAction.disabled ? { attributes: { disabled: true } } : {}), | ||
| 451 | }, | ||
| 452 | { | ||
| 453 | id: "more", | ||
| 454 | title: "More", | ||
| 455 | image: "ellipsis.circle", | ||
| 456 | subtitle: "Commit, files, branches", | ||
| 457 | preferredElementSize: "small", | ||
| 458 | }, | ||
| 459 | ], | ||
| 460 | [ | ||
| 461 | currentBranchLabel, | ||
| 462 | gitStatus, | ||
| 463 | quickAction.action, | ||
| 464 | quickAction.disabled, | ||
| 465 | quickAction.kind, | ||
| 466 | quickAction.label, | ||
| 467 | quickActionHint, | ||
| 468 | ], | ||
| 469 | ); | ||
| 470 | 89 | ||
| 471 | useSheetVisibility(gitSheetRef, gitSheetVisible); | 90 | const quickActionIcon = (() => { |
| 472 | useSheetVisibility(branchSheetRef, branchSheetVisible); | 91 | if (quickAction.kind === "run_pull") return "arrow.down.circle"; |
| 473 | useSheetVisibility(commitSheetRef, commitSheetVisible); | 92 | if (quickAction.kind === "open_pr") return "arrow.up.right.circle"; |
| 474 | useSheetVisibility(defaultBranchSheetRef, defaultBranchSheetVisible); | 93 | if (quickAction.kind === "run_action") { |
| 475 | 94 | if (quickAction.action === "commit") return "checkmark.circle"; | |
| 476 | useEffect(() => { | 95 | if (quickAction.action === "push" || quickAction.action === "commit_push") |
| 477 | if (!gitSheetVisible) { | 96 | return "arrow.up.circle"; |
| 478 | return; | ||
| 479 | } | ||
| 480 | void onRefreshStatus({ quiet: true }); | ||
| 481 | }, [gitSheetVisible, onRefreshStatus]); | ||
| 482 | |||
| 483 | useEffect(() => { | ||
| 484 | if (!branchSheetVisible) { | ||
| 485 | return; | ||
| 486 | } | 97 | } |
| 487 | 98 | return "arrow.up.right.circle"; | |
| 488 | setWorktreeBaseBranch(currentBranchLabel === "Detached HEAD" ? "main" : currentBranchLabel); | 99 | })(); |
| 489 | setBranchesLoading(true); | ||
| 490 | void onListBranches() | ||
| 491 | .then((branches) => setAvailableBranches(branches)) | ||
| 492 | .finally(() => setBranchesLoading(false)); | ||
| 493 | }, [branchSheetVisible, currentBranchLabel, onListBranches]); | ||
| 494 | |||
| 495 | const closeTransientSheets = useCallback(() => { | ||
| 496 | setGitSheetVisible(false); | ||
| 497 | setCommitSheetVisible(false); | ||
| 498 | setDefaultBranchSheetVisible(false); | ||
| 499 | }, []); | ||
| 500 | |||
| 501 | const openCommitSheet = useCallback(() => { | ||
| 502 | setExcludedFiles(new Set()); | ||
| 503 | setIsEditingFiles(false); | ||
| 504 | setDialogCommitMessage(""); | ||
| 505 | setCommitSheetVisible(true); | ||
| 506 | }, []); | ||
| 507 | 100 | ||
| 508 | const openExistingPr = useCallback(async () => { | 101 | const openExistingPr = useCallback(async () => { |
| 509 | const prUrl = gitStatus?.pr?.state === "open" ? gitStatus.pr.url : null; | 102 | const prUrl = gitStatus?.pr?.state === "open" ? gitStatus.pr.url : null; |
| @@ -537,64 +130,26 @@ export function ThreadGitControls(props: { · 27 unmodified lines | |||
| 537 | !input.featureBranch && | 130 | !input.featureBranch && |
| 538 | requiresDefaultBranchConfirmation(input.action, isDefaultBranch) | 131 | requiresDefaultBranchConfirmation(input.action, isDefaultBranch) |
| 539 | ) { | 132 | ) { |
| 540 | setPendingDefaultBranchAction({ | 133 | router.push({ |
| 541 | action: confirmableAction, | 134 | pathname: "/threads/[environmentId]/[threadId]/git-confirm", |
| 542 | branchName, | 135 | params: { |
| 543 | includesCommit: input.action === "commit_push" || input.action === "commit_push_pr", | 136 | environmentId, |
| 544 | ...(input.commitMessage ? { commitMessage: input.commitMessage } : {}), | 137 | threadId, |
| 545 | ...(input.filePaths ? { filePaths: input.filePaths } : {}), | 138 | confirmAction: confirmableAction, |
| 139 | branchName, | ||
| 140 | includesCommit: String( | ||
| 141 | input.action === "commit_push" || input.action === "commit_push_pr", | ||
| 142 | ), | ||
| 143 | }, | ||
| 546 | }); | 144 | }); |
| 547 | setDefaultBranchSheetVisible(true); | ||
| 548 | return; | 145 | return; |
| 549 | } | 146 | } |
| 550 | 147 | ||
| 551 | closeTransientSheets(); | ||
| 552 | await onRunAction(input); | 148 | await onRunAction(input); |
| 553 | }, | 149 | }, |
| 554 | [closeTransientSheets, gitStatus, isDefaultBranch, onRunAction], | 150 | [environmentId, gitStatus, isDefaultBranch, onRunAction, router, threadId], |
| 555 | ); | 151 | ); |
| 556 | 152 | ||
| 557 | const continuePendingDefaultBranchAction = useCallback(async () => { | ||
| 558 | if (!pendingDefaultBranchAction) { | ||
| 559 | return; | ||
| 560 | } | ||
| 561 | const action = pendingDefaultBranchAction; | ||
| 562 | setPendingDefaultBranchAction(null); | ||
| 563 | setDefaultBranchSheetVisible(false); | ||
| 564 | await onRunAction({ | ||
| 565 | action: action.action, | ||
| 566 | ...(action.commitMessage ? { commitMessage: action.commitMessage } : {}), | ||
| 567 | ...(action.filePaths ? { filePaths: action.filePaths } : {}), | ||
| 568 | }); | ||
| 569 | }, [onRunAction, pendingDefaultBranchAction]); | ||
| 570 | |||
| 571 | const movePendingActionToFeatureBranch = useCallback(async () => { | ||
| 572 | if (!pendingDefaultBranchAction) { | ||
| 573 | return; | ||
| 574 | } | ||
| 575 | |||
| 576 | const action = pendingDefaultBranchAction; | ||
| 577 | setPendingDefaultBranchAction(null); | ||
| 578 | setDefaultBranchSheetVisible(false); | ||
| 579 | |||
| 580 | if (action.includesCommit) { | ||
| 581 | await onRunAction({ | ||
| 582 | action: action.action, | ||
| 583 | featureBranch: true, | ||
| 584 | ...(action.commitMessage ? { commitMessage: action.commitMessage } : {}), | ||
| 585 | ...(action.filePaths ? { filePaths: action.filePaths } : {}), | ||
| 586 | }); | ||
| 587 | return; | ||
| 588 | } | ||
| 589 | |||
| 590 | const branches = await onListBranches(); | ||
| 591 | const branchName = resolveAutoFeatureBranchName( | ||
| 592 | branches.filter((branch) => !branch.isRemote).map((branch) => branch.name), | ||
| 593 | ); | ||
| 594 | await onCreateBranch(branchName); | ||
| 595 | await onRunAction({ action: action.action }); | ||
| 596 | }, [onCreateBranch, onListBranches, onRunAction, pendingDefaultBranchAction]); | ||
| 597 | |||
| 598 | const runQuickAction = useCallback(async () => { | 153 | const runQuickAction = useCallback(async () => { |
| 599 | if (quickAction.kind === "open_pr") { | 154 | if (quickAction.kind === "open_pr") { |
| 600 | await openExistingPr(); | 155 | await openExistingPr(); |
| @@ -609,699 +164,38 @@ export function ThreadGitControls(props: { · 8 unmodified lines | |||
| 609 | } | 164 | } |
| 610 | }, [onPull, openExistingPr, quickAction, runActionWithPrompt]); | 165 | }, [onPull, openExistingPr, quickAction, runActionWithPrompt]); |
| 611 | 166 | ||
| 612 | const runCommitAction = useCallback( | ||
| 613 | async (featureBranch: boolean) => { | ||
| 614 | const commitMessage = dialogCommitMessage.trim(); | ||
| 615 | setGitSheetVisible(false); | ||
| 616 | setCommitSheetVisible(false); | ||
| 617 | await onRunAction({ | ||
| 618 | action: "commit", | ||
| 619 | featureBranch, | ||
| 620 | ...(commitMessage ? { commitMessage } : {}), | ||
| 621 | ...(!allSelected ? { filePaths: selectedFiles.map((file) => file.path) } : {}), | ||
| 622 | }); | ||
| 623 | setDialogCommitMessage(""); | ||
| 624 | setExcludedFiles(new Set()); | ||
| 625 | }, | ||
| 626 | [allSelected, dialogCommitMessage, onRunAction, selectedFiles], | ||
| 627 | ); | ||
| 628 | |||
| 629 | const onPressMenuItem = useCallback( | ||
| 630 | async (item: (typeof menuItems)[number]) => { | ||
| 631 | if (item.disabled) { | ||
| 632 | return; | ||
| 633 | } | ||
| 634 | if (item.kind === "open_pr") { | ||
| 635 | await openExistingPr(); | ||
| 636 | return; | ||
| 637 | } | ||
| 638 | if (item.dialogAction === "commit") { | ||
| 639 | openCommitSheet(); | ||
| 640 | return; | ||
| 641 | } | ||
| 642 | if (item.dialogAction === "push") { | ||
| 643 | await runActionWithPrompt({ action: "push" }); | ||
| 644 | return; | ||
| 645 | } | ||
| 646 | if (item.dialogAction === "create_pr") { | ||
| 647 | await runActionWithPrompt({ action: "create_pr" }); | ||
| 648 | } | ||
| 649 | }, | ||
| 650 | [openCommitSheet, openExistingPr, runActionWithPrompt], | ||
| 651 | ); | ||
| 652 | |||
| 653 | const onPressNativeMenuAction = useCallback( | ||
| 654 | ({ nativeEvent }: { nativeEvent: { event: string } }) => { | ||
| 655 | if (nativeEvent.event === "quick") { | ||
| 656 | void runQuickAction(); | ||
| 657 | return; | ||
| 658 | } | ||
| 659 | if (nativeEvent.event === "more") { | ||
| 660 | setGitSheetVisible(true); | ||
| 661 | } | ||
| 662 | }, | ||
| 663 | [runQuickAction], | ||
| 664 | ); | ||
| 665 | |||
| 666 | return ( | 167 | return ( |
| 667 | <> | 168 | <Stack.Toolbar placement="right"> |
| 668 | <MenuView actions={nativeMenuActions} onPressAction={onPressNativeMenuAction}> | 169 | <Stack.Toolbar.Menu icon="point.topleft.down.curvedto.point.bottomright.up"> |
| 669 | <Pressable | 170 | <Stack.Toolbar.MenuAction |
| 670 | className="flex-row items-center gap-2 rounded-full px-3 py-2" | 171 | icon="point.topleft.down.curvedto.point.bottomright.up" |
| 671 | style={{ backgroundColor: palette.subtleBg }} | 172 | disabled |
| 173 | onPress={() => {}} | ||
| 174 | subtitle={compactMenuStatus(gitStatus)} | ||
| 672 | > | 175 | > |
| 673 | <SymbolView | 176 | <Stack.Toolbar.Label>{compactMenuBranchLabel(currentBranchLabel)}</Stack.Toolbar.Label> |
| 674 | name="point.topleft.down.curvedto.point.bottomright.up" | 177 | </Stack.Toolbar.MenuAction> |
| 675 | size={14} | 178 | <Stack.Toolbar.MenuAction |
| 676 | tintColor={palette.icon} | 179 | icon={quickActionIcon} |
| 677 | type="monochrome" | 180 | disabled={quickAction.disabled} |
| 678 | /> | 181 | onPress={() => void runQuickAction()} |
| 679 | <View className="shrink"> | 182 | subtitle={quickActionHint ?? undefined} |
| 680 | <Text | ||
| 681 | className="text-[11px] font-t3-bold uppercase" | ||
| 682 | numberOfLines={1} | ||
| 683 | style={{ color: palette.textMuted, letterSpacing: 0.8 }} | ||
| 684 | > | ||
| 685 | Git | ||
| 686 | </Text> | ||
| 687 | <Text | ||
| 688 | className="text-[12px] font-t3-bold" | ||
| 689 | numberOfLines={1} | ||
| 690 | style={{ color: palette.text }} | ||
| 691 | > | ||
| 692 | {triggerStatus} | ||
| 693 | </Text> | ||
| 694 | </View> | ||
| 695 | <SymbolView | ||
| 696 | name="chevron.down" | ||
| 697 | size={11} | ||
| 698 | tintColor={palette.textMuted} | ||
| 699 | type="monochrome" | ||
| 700 | weight="medium" | ||
| 701 | /> | ||
| 702 | </Pressable> | ||
| 703 | </MenuView> | ||
| 704 | |||
| 705 | <BottomSheetModal | ||
| 706 | ref={gitSheetRef} | ||
| 707 | index={0} | ||
| 708 | snapPoints={gitSnapPoints} | ||
| 709 | enableDynamicSizing={false} | ||
| 710 | enablePanDownToClose | ||
| 711 | backdropComponent={renderBackdrop} | ||
| 712 | onDismiss={() => setGitSheetVisible(false)} | ||
| 713 | backgroundStyle={{ backgroundColor: palette.sheetBackground }} | ||
| 714 | handleIndicatorStyle={{ backgroundColor: palette.dotSeparator }} | ||
| 715 | > | ||
| 716 | <BottomSheetView style={{ flex: 1 }}> | ||
| 717 | <BottomSheetScrollView | ||
| 718 | showsVerticalScrollIndicator={false} | ||
| 719 | contentContainerStyle={{ | ||
| 720 | paddingHorizontal: 20, | ||
| 721 | paddingTop: 8, | ||
| 722 | paddingBottom: Math.max(insets.bottom, 18) + 8, | ||
| 723 | gap: 14, | ||
| 724 | }} | ||
| 725 | > | ||
| 726 | <View className="flex-row items-start justify-between gap-3"> | ||
| 727 | <View className="flex-1 gap-1"> | ||
| 728 | <Text | ||
| 729 | className="text-[11px] font-t3-bold uppercase" | ||
| 730 | style={{ color: palette.textMuted, letterSpacing: 1 }} | ||
| 731 | > | ||
| 732 | Branch | ||
| 733 | </Text> | ||
| 734 | <Text className="text-[24px] font-t3-bold" style={{ color: palette.text }}> | ||
| 735 | {currentBranchLabel} | ||
| 736 | </Text> | ||
| 737 | <Text | ||
| 738 | className="text-[13px] font-medium leading-[19px]" | ||
| 739 | style={{ color: palette.textSecondary }} | ||
| 740 | > | ||
| 741 | {statusSummary(gitStatus)} | ||
| 742 | </Text> | ||
| 743 | </View> | ||
| 744 | <Pressable | ||
| 745 | className="rounded-full p-2" | ||
| 746 | disabled={busy} | ||
| 747 | style={{ backgroundColor: palette.subtleBg, opacity: busy ? 0.45 : 1 }} | ||
| 748 | onPress={() => void onRefreshStatus()} | ||
| 749 | > | ||
| 750 | <SymbolView | ||
| 751 | name="arrow.clockwise" | ||
| 752 | size={16} | ||
| 753 | tintColor={palette.icon} | ||
| 754 | type="monochrome" | ||
| 755 | weight="medium" | ||
| 756 | /> | ||
| 757 | </Pressable> | ||
| 758 | </View> | ||
| 759 | |||
| 760 | <View className="gap-3"> | ||
| 761 | <View | ||
| 762 | className="overflow-hidden rounded-[22px] border px-4 py-1" | ||
| 763 | style={{ backgroundColor: palette.card, borderColor: palette.border }} | ||
| 764 | > | ||
| 765 | {sheetMenuItems.map(({ item, disabledReason }, index) => ( | ||
| 766 | <View key={`${item.id}-${item.label}`}> | ||
| 767 | {index > 0 ? ( | ||
| 768 | <View className="ml-12 h-px" style={{ backgroundColor: palette.border }} /> | ||
| 769 | ) : null} | ||
| 770 | <SheetListRow | ||
| 771 | icon={menuItemIconName(item.icon)} | ||
| 772 | title={item.label} | ||
| 773 | subtitle={disabledReason} | ||
| 774 | disabled={item.disabled} | ||
| 775 | onPress={() => { | ||
| 776 | void onPressMenuItem(item); | ||
| 777 | }} | ||
| 778 | /> | ||
| 779 | </View> | ||
| 780 | ))} | ||
| 781 | {(gitStatus?.behindCount ?? 0) > 0 ? ( | ||
| 782 | <> | ||
| 783 | <View className="ml-12 h-px" style={{ backgroundColor: palette.border }} /> | ||
| 784 | <SheetListRow | ||
| 785 | icon="arrow.down.circle" | ||
| 786 | title="Pull latest" | ||
| 787 | subtitle="Sync this branch with upstream" | ||
| 788 | disabled={busy || !isRepo} | ||
| 789 | onPress={() => { | ||
| 790 | void onPull(); | ||
| 791 | }} | ||
| 792 | /> | ||
| 793 | </> | ||
| 794 | ) : null} | ||
| 795 | <View className="ml-12 h-px" style={{ backgroundColor: palette.border }} /> | ||
| 796 | <SheetListRow | ||
| 797 | icon="point.topleft.down.curvedto.point.bottomright.up" | ||
| 798 | title="Branches & worktrees" | ||
| 799 | subtitle="Switch branch, create branch, or move to a worktree" | ||
| 800 | disabled={busy || !isRepo} | ||
| 801 | onPress={() => setBranchSheetVisible(true)} | ||
| 802 | /> | ||
| 803 | </View> | ||
| 804 | </View> | ||
| 805 | |||
| 806 | {currentWorktreePath ? <MetaCard label="Worktree" value={currentWorktreePath} /> : null} | ||
| 807 | |||
| 808 | {gitOperationLabel ? ( | ||
| 809 | <View className="self-start"> | ||
| 810 | <StatusPill | ||
| 811 | label={gitOperationLabel} | ||
| 812 | pillClassName="bg-neutral-900/8" | ||
| 813 | textClassName="text-neutral-700" | ||
| 814 | size="compact" | ||
| 815 | /> | ||
| 816 | </View> | ||
| 817 | ) : null} | ||
| 818 | </BottomSheetScrollView> | ||
| 819 | </BottomSheetView> | ||
| 820 | </BottomSheetModal> | ||
| 821 | |||
| 822 | <BottomSheetModal | ||
| 823 | ref={commitSheetRef} | ||
| 824 | stackBehavior="push" | ||
| 825 | index={0} | ||
| 826 | snapPoints={commitSnapPoints} | ||
| 827 | enableDynamicSizing={false} | ||
| 828 | enablePanDownToClose | ||
| 829 | backdropComponent={renderBackdrop} | ||
| 830 | onDismiss={() => setCommitSheetVisible(false)} | ||
| 831 | backgroundStyle={{ backgroundColor: palette.sheetBackground }} | ||
| 832 | handleIndicatorStyle={{ backgroundColor: palette.dotSeparator }} | ||
| 833 | > | ||
| 834 | <BottomSheetView style={{ flex: 1 }}> | ||
| 835 | <BottomSheetScrollView | ||
| 836 | keyboardShouldPersistTaps="handled" | ||
| 837 | showsVerticalScrollIndicator={false} | ||
| 838 | contentContainerStyle={{ | ||
| 839 | paddingHorizontal: 20, | ||
| 840 | paddingTop: 8, | ||
| 841 | paddingBottom: Math.max(insets.bottom, 18) + 8, | ||
| 842 | gap: 16, | ||
| 843 | }} | ||
| 844 | > | ||
| 845 | <View className="gap-1"> | ||
| 846 | <Text className="text-[24px] font-t3-bold" style={{ color: palette.text }}> | ||
| 847 | Commit changes | ||
| 848 | </Text> | ||
| 849 | <Text | ||
| 850 | className="text-[13px] font-medium leading-[19px]" | ||
| 851 | style={{ color: palette.textSecondary }} | ||
| 852 | > | ||
| 853 | Review the file set, optionally write a message, then commit here or on a new | ||
| 854 | feature branch. | ||
| 855 | </Text> | ||
| 856 | </View> | ||
| 857 | |||
| 858 | <View | ||
| 859 | className="gap-3 rounded-[22px] border px-4 py-4" | ||
| 860 | style={{ backgroundColor: palette.card, borderColor: palette.border }} | ||
| 861 | > | ||
| 862 | <View className="flex-row items-center justify-between gap-3"> | ||
| 863 | <Text className="text-[13px] font-medium" style={{ color: palette.textMuted }}> | ||
| 864 | Branch | ||
| 865 | </Text> | ||
| 866 | <Text className="text-[15px] font-t3-bold" style={{ color: palette.text }}> | ||
| 867 | {gitStatus?.branch ?? "(detached HEAD)"} | ||
| 868 | </Text> | ||
| 869 | </View> | ||
| 870 | {isDefaultBranch ? ( | ||
| 871 | <Text | ||
| 872 | className="text-[12px] leading-[18px]" | ||
| 873 | style={{ color: isDarkMode ? "#fbbf24" : "#b45309" }} | ||
| 874 | > | ||
| 875 | Warning: this is the default branch. | ||
| 876 | </Text> | ||
| 877 | ) : null} | ||
| 878 | </View> | ||
| 879 | |||
| 880 | <View | ||
| 881 | className="gap-3 rounded-[22px] border px-4 py-4" | ||
| 882 | style={{ backgroundColor: palette.card, borderColor: palette.border }} | ||
| 883 | > | ||
| 884 | <View className="flex-row items-center justify-between gap-3"> | ||
| 885 | <View className="gap-1"> | ||
| 886 | <Text className="text-[16px] font-t3-bold" style={{ color: palette.text }}> | ||
| 887 | Files | ||
| 888 | </Text> | ||
| 889 | <Text className="text-[12px] leading-[18px]" style={{ color: palette.textMuted }}> | ||
| 890 | {selectedFiles.length} selected · +{selectedInsertions} / -{selectedDeletions} | ||
| 891 | </Text> | ||
| 892 | </View> | ||
| 893 | <View className="flex-row items-center gap-2"> | ||
| 894 | {!allSelected && isEditingFiles ? ( | ||
| 895 | <Pressable | ||
| 896 | className="rounded-full px-3 py-2" | ||
| 897 | style={{ backgroundColor: palette.subtleBg }} | ||
| 898 | onPress={() => setExcludedFiles(new Set())} | ||
| 899 | > | ||
| 900 | <Text | ||
| 901 | className="text-[11px] font-t3-bold uppercase" | ||
| 902 | style={{ color: palette.text }} | ||
| 903 | > | ||
| 904 | Reset | ||
| 905 | </Text> | ||
| 906 | </Pressable> | ||
| 907 | ) : null} | ||
| 908 | <Pressable | ||
| 909 | className="rounded-full px-3 py-2" | ||
| 910 | style={{ backgroundColor: palette.subtleBg }} | ||
| 911 | onPress={() => setIsEditingFiles((current) => !current)} | ||
| 912 | > | ||
| 913 | <Text | ||
| 914 | className="text-[11px] font-t3-bold uppercase" | ||
| 915 | style={{ color: palette.text }} | ||
| 916 | > | ||
| 917 | {isEditingFiles ? "Done" : "Edit"} | ||
| 918 | </Text> | ||
| 919 | </Pressable> | ||
| 920 | </View> | ||
| 921 | </View> | ||
| 922 | |||
| 923 | {allFiles.length === 0 ? ( | ||
| 924 | <Text | ||
| 925 | className="text-[13px] leading-[19px]" | ||
| 926 | style={{ color: palette.textSecondary }} | ||
| 927 | > | ||
| 928 | No changed files are available to commit. | ||
| 929 | </Text> | ||
| 930 | ) : !isEditingFiles ? ( | ||
| 931 | <View className="gap-2"> | ||
| 932 | {selectedFilePreview.map((file) => ( | ||
| 933 | <View key={file.path} className="flex-row items-center justify-between gap-3"> | ||
| 934 | <Text | ||
| 935 | className="flex-1 text-[13px] font-medium" | ||
| 936 | numberOfLines={1} | ||
| 937 | style={{ color: palette.text }} | ||
| 938 | > | ||
| 939 | {file.path} | ||
| 940 | </Text> | ||
| 941 | <Text className="text-[12px] font-t3-bold" style={{ color: "#10b981" }}> | ||
| 942 | +{file.insertions} | ||
| 943 | </Text> | ||
| 944 | <Text className="text-[12px] font-t3-bold" style={{ color: "#f43f5e" }}> | ||
| 945 | -{file.deletions} | ||
| 946 | </Text> | ||
| 947 | </View> | ||
| 948 | ))} | ||
| 949 | {selectedFiles.length > selectedFilePreview.length ? ( | ||
| 950 | <Text | ||
| 951 | className="text-[12px] leading-[17px]" | ||
| 952 | style={{ color: palette.textMuted }} | ||
| 953 | > | ||
| 954 | +{selectedFiles.length - selectedFilePreview.length} more files | ||
| 955 | </Text> | ||
| 956 | ) : null} | ||
| 957 | </View> | ||
| 958 | ) : ( | ||
| 959 | <View className="gap-2"> | ||
| 960 | {allFiles.map((file) => { | ||
| 961 | const included = !excludedFiles.has(file.path); | ||
| 962 | return ( | ||
| 963 | <Pressable | ||
| 964 | key={file.path} | ||
| 965 | className="rounded-[18px] border px-4 py-3" | ||
| 966 | style={{ | ||
| 967 | backgroundColor: included ? palette.card : palette.subtleBg, | ||
| 968 | borderColor: included ? palette.border : palette.borderSubtle, | ||
| 969 | }} | ||
| 970 | onPress={() => { | ||
| 971 | setExcludedFiles((current) => { | ||
| 972 | const next = new Set(current); | ||
| 973 | if (next.has(file.path)) { | ||
| 974 | next.delete(file.path); | ||
| 975 | } else { | ||
| 976 | next.add(file.path); | ||
| 977 | } | ||
| 978 | return next; | ||
| 979 | }); | ||
| 980 | }} | ||
| 981 | > | ||
| 982 | <View className="flex-row items-start justify-between gap-3"> | ||
| 983 | <View className="flex-1 gap-1"> | ||
| 984 | <Text | ||
| 985 | selectable | ||
| 986 | className="text-[13px] font-t3-bold" | ||
| 987 | style={{ color: included ? palette.text : palette.textMuted }} | ||
| 988 | > | ||
| 989 | {file.path} | ||
| 990 | </Text> | ||
| 991 | {!included ? ( | ||
| 992 | <Text | ||
| 993 | className="text-[11px] leading-[16px]" | ||
| 994 | style={{ color: palette.textMuted }} | ||
| 995 | > | ||
| 996 | Excluded from this commit | ||
| 997 | </Text> | ||
| 998 | ) : null} | ||
| 999 | </View> | ||
| 1000 | <View className="items-end gap-1"> | ||
| 1001 | <Text className="text-[12px] font-t3-bold" style={{ color: "#10b981" }}> | ||
| 1002 | +{file.insertions} | ||
| 1003 | </Text> | ||
| 1004 | <Text className="text-[12px] font-t3-bold" style={{ color: "#f43f5e" }}> | ||
| 1005 | -{file.deletions} | ||
| 1006 | </Text> | ||
| 1007 | </View> | ||
| 1008 | </View> | ||
| 1009 | </Pressable> | ||
| 1010 | ); | ||
| 1011 | })} | ||
| 1012 | </View> | ||
| 1013 | )} | ||
| 1014 | </View> | ||
| 1015 | |||
| 1016 | <View className="gap-2"> | ||
| 1017 | <Text className="text-[13px] font-t3-bold" style={{ color: palette.text }}> | ||
| 1018 | Commit message | ||
| 1019 | </Text> | ||
| 1020 | <TextInput | ||
| 1021 | multiline | ||
| 1022 | value={dialogCommitMessage} | ||
| 1023 | onChangeText={setDialogCommitMessage} | ||
| 1024 | placeholder="Leave empty to auto-generate" | ||
| 1025 | textAlignVertical="top" | ||
| 1026 | className="min-h-[128px] rounded-[20px] px-4 py-3.5 font-sans text-[15px]" | ||
| 1027 | style={{ | ||
| 1028 | minHeight: 128, | ||
| 1029 | borderWidth: 1, | ||
| 1030 | borderColor: palette.inputBorder, | ||
| 1031 | backgroundColor: palette.inputBackground, | ||
| 1032 | color: palette.text, | ||
| 1033 | }} | ||
| 1034 | /> | ||
| 1035 | </View> | ||
| 1036 | |||
| 1037 | <View className="flex-row gap-3"> | ||
| 1038 | <View className="flex-1"> | ||
| 1039 | <SheetActionButton | ||
| 1040 | icon="arrow.branch" | ||
| 1041 | label="Commit on new branch" | ||
| 1042 | disabled={noneSelected || busy} | ||
| 1043 | onPress={() => { | ||
| 1044 | void runCommitAction(true); | ||
| 1045 | }} | ||
| 1046 | /> | ||
| 1047 | </View> | ||
| 1048 | <View className="flex-1"> | ||
| 1049 | <SheetActionButton | ||
| 1050 | icon="checkmark.circle" | ||
| 1051 | label="Commit" | ||
| 1052 | tone="primary" | ||
| 1053 | disabled={noneSelected || busy} | ||
| 1054 | onPress={() => { | ||
| 1055 | void runCommitAction(false); | ||
| 1056 | }} | ||
| 1057 | /> | ||
| 1058 | </View> | ||
| 1059 | </View> | ||
| 1060 | </BottomSheetScrollView> | ||
| 1061 | </BottomSheetView> | ||
| 1062 | </BottomSheetModal> | ||
| 1063 | |||
| 1064 | <BottomSheetModal | ||
| 1065 | ref={defaultBranchSheetRef} | ||
| 1066 | stackBehavior="push" | ||
| 1067 | index={0} | ||
| 1068 | snapPoints={defaultBranchSnapPoints} | ||
| 1069 | enableDynamicSizing={false} | ||
| 1070 | enablePanDownToClose | ||
| 1071 | backdropComponent={renderBackdrop} | ||
| 1072 | onDismiss={() => setDefaultBranchSheetVisible(false)} | ||
| 1073 | backgroundStyle={{ backgroundColor: palette.sheetBackground }} | ||
| 1074 | handleIndicatorStyle={{ backgroundColor: palette.dotSeparator }} | ||
| 1075 | > | ||
| 1076 | <BottomSheetView | ||
| 1077 | className="gap-4 px-5 pt-2" | ||
| 1078 | style={{ paddingBottom: Math.max(insets.bottom, 18) + 8 }} | ||
| 1079 | > | 183 | > |
| 1080 | <View className="gap-1"> | 184 | <Stack.Toolbar.Label>{quickAction.label}</Stack.Toolbar.Label> |
| 1081 | <Text className="text-[22px] font-t3-bold" style={{ color: palette.text }}> | 185 | </Stack.Toolbar.MenuAction> |
| 1082 | {pendingDefaultBranchActionCopy?.title ?? "Run action on default branch?"} | 186 | <Stack.Toolbar.MenuAction |
| 1083 | </Text> | 187 | icon="ellipsis.circle" |
| 1084 | <Text | 188 | onPress={() => |
| 1085 | className="text-[13px] font-medium leading-[19px]" | 189 | router.push({ |
| 1086 | style={{ color: palette.textSecondary }} | 190 | pathname: "/threads/[environmentId]/[threadId]/git", |
| 1087 | > | 191 | params: { environmentId, threadId }, |
| 1088 | {pendingDefaultBranchActionCopy?.description ?? "Choose how to continue."} | 192 | }) |
| 1089 | </Text> | 193 | } |
| 1090 | </View> | 194 | subtitle="Commit, files, branches" |
| 1091 | 195 | > | |
| 1092 | <View className="gap-3"> | 196 | <Stack.Toolbar.Label>More</Stack.Toolbar.Label> |
| 1093 | <SheetActionButton | 197 | </Stack.Toolbar.MenuAction> |
| 1094 | icon="arrow.right.circle" | 198 | </Stack.Toolbar.Menu> |
| 1095 | label={pendingDefaultBranchActionCopy?.continueLabel ?? "Continue"} | 199 | </Stack.Toolbar> |
| 1096 | onPress={() => { | ||
| 1097 | void continuePendingDefaultBranchAction(); | ||
| 1098 | }} | ||
| 1099 | /> | ||
| 1100 | <SheetActionButton | ||
| 1101 | icon="arrow.branch" | ||
| 1102 | label="Feature branch & continue" | ||
| 1103 | tone="primary" | ||
| 1104 | onPress={() => { | ||
| 1105 | void movePendingActionToFeatureBranch(); | ||
| 1106 | }} | ||
| 1107 | /> | ||
| 1108 | </View> | ||
| 1109 | </BottomSheetView> | ||
| 1110 | </BottomSheetModal> | ||
| 1111 | |||
| 1112 | <BottomSheetModal | ||
| 1113 | ref={branchSheetRef} | ||
| 1114 | stackBehavior="push" | ||
| 1115 | index={0} | ||
| 1116 | snapPoints={branchSnapPoints} | ||
| 1117 | enableDynamicSizing={false} | ||
| 1118 | enablePanDownToClose | ||
| 1119 | backdropComponent={renderBackdrop} | ||
| 1120 | onDismiss={() => setBranchSheetVisible(false)} | ||
| 1121 | backgroundStyle={{ backgroundColor: palette.sheetBackground }} | ||
| 1122 | handleIndicatorStyle={{ backgroundColor: palette.dotSeparator }} | ||
| 1123 | > | ||
| 1124 | <BottomSheetView style={{ flex: 1 }}> | ||
| 1125 | <BottomSheetScrollView | ||
| 1126 | showsVerticalScrollIndicator={false} | ||
| 1127 | contentContainerStyle={{ | ||
| 1128 | paddingHorizontal: 20, | ||
| 1129 | paddingTop: 8, | ||
| 1130 | paddingBottom: Math.max(insets.bottom, 18) + 8, | ||
| 1131 | gap: 16, | ||
| 1132 | }} | ||
| 1133 | > | ||
| 1134 | <View className="gap-1"> | ||
| 1135 | <Text className="text-[22px] font-t3-bold" style={{ color: palette.text }}> | ||
| 1136 | Branches & worktrees | ||
| 1137 | </Text> | ||
| 1138 | <Text | ||
| 1139 | className="text-[13px] font-medium leading-[19px]" | ||
| 1140 | style={{ color: palette.textSecondary }} | ||
| 1141 | > | ||
| 1142 | Switch this thread, create a branch, or move work onto its own worktree. | ||
| 1143 | </Text> | ||
| 1144 | </View> | ||
| 1145 | |||
| 1146 | <View | ||
| 1147 | className="gap-2 rounded-[18px] border px-4 py-4" | ||
| 1148 | style={{ backgroundColor: palette.card, borderColor: palette.border }} | ||
| 1149 | > | ||
| 1150 | <Text | ||
| 1151 | className="text-[11px] font-t3-bold uppercase" | ||
| 1152 | style={{ color: palette.textSecondary, letterSpacing: 1 }} | ||
| 1153 | > | ||
| 1154 | New branch | ||
| 1155 | </Text> | ||
| 1156 | <TextInput | ||
| 1157 | value={newBranchName} | ||
| 1158 | onChangeText={setNewBranchName} | ||
| 1159 | placeholder="feature/mobile-polish" | ||
| 1160 | className="rounded-[18px] px-3.5 py-3 font-sans text-[15px]" | ||
| 1161 | style={{ | ||
| 1162 | borderWidth: 1, | ||
| 1163 | borderColor: palette.inputBorder, | ||
| 1164 | backgroundColor: palette.inputBackground, | ||
| 1165 | color: palette.text, | ||
| 1166 | }} | ||
| 1167 | /> | ||
| 1168 | <SheetActionButton | ||
| 1169 | icon="plus" | ||
| 1170 | label="Create & checkout" | ||
| 1171 | tone="primary" | ||
| 1172 | disabled={busy || newBranchName.trim().length === 0} | ||
| 1173 | onPress={() => { | ||
| 1174 | const branch = sanitizeFeatureBranchName(newBranchName.trim()); | ||
| 1175 | if (branch.length === 0) { | ||
| 1176 | return; | ||
| 1177 | } | ||
| 1178 | void onCreateBranch(branch).then(() => { | ||
| 1179 | setNewBranchName(""); | ||
| 1180 | setBranchSheetVisible(false); | ||
| 1181 | }); | ||
| 1182 | }} | ||
| 1183 | /> | ||
| 1184 | </View> | ||
| 1185 | |||
| 1186 | <View | ||
| 1187 | className="gap-2 rounded-[18px] border px-4 py-4" | ||
| 1188 | style={{ backgroundColor: palette.card, borderColor: palette.border }} | ||
| 1189 | > | ||
| 1190 | <Text | ||
| 1191 | className="text-[11px] font-t3-bold uppercase" | ||
| 1192 | style={{ color: palette.textSecondary, letterSpacing: 1 }} | ||
| 1193 | > | ||
| 1194 | New worktree | ||
| 1195 | </Text> | ||
| 1196 | <TextInput | ||
| 1197 | value={worktreeBaseBranch} | ||
| 1198 | onChangeText={setWorktreeBaseBranch} | ||
| 1199 | placeholder="main" | ||
| 1200 | className="rounded-[18px] px-3.5 py-3 font-sans text-[15px]" | ||
| 1201 | style={{ | ||
| 1202 | borderWidth: 1, | ||
| 1203 | borderColor: palette.inputBorder, | ||
| 1204 | backgroundColor: palette.inputBackground, | ||
| 1205 | color: palette.text, | ||
| 1206 | }} | ||
| 1207 | /> | ||
| 1208 | <TextInput | ||
| 1209 | value={worktreeBranchName} | ||
| 1210 | onChangeText={setWorktreeBranchName} | ||
| 1211 | placeholder="feature/mobile-thread" | ||
| 1212 | className="rounded-[18px] px-3.5 py-3 font-sans text-[15px]" | ||
| 1213 | style={{ | ||
| 1214 | borderWidth: 1, | ||
| 1215 | borderColor: palette.inputBorder, | ||
| 1216 | backgroundColor: palette.inputBackground, | ||
| 1217 | color: palette.text, | ||
| 1218 | }} | ||
| 1219 | /> | ||
| 1220 | <SheetActionButton | ||
| 1221 | icon="square.split.2x1" | ||
| 1222 | label="Create worktree" | ||
| 1223 | tone="primary" | ||
| 1224 | disabled={ | ||
| 1225 | busy || | ||
| 1226 | worktreeBaseBranch.trim().length === 0 || | ||
| 1227 | worktreeBranchName.trim().length === 0 | ||
| 1228 | } | ||
| 1229 | onPress={() => { | ||
| 1230 | const baseBranch = worktreeBaseBranch.trim(); | ||
| 1231 | const newBranch = worktreeBranchName.trim(); | ||
| 1232 | if (baseBranch.length === 0 || newBranch.length === 0) { | ||
| 1233 | return; | ||
| 1234 | } | ||
| 1235 | void onCreateWorktree({ | ||
| 1236 | baseBranch, | ||
| 1237 | newBranch, | ||
| 1238 | }).then(() => { | ||
| 1239 | setWorktreeBranchName(""); | ||
| 1240 | setBranchSheetVisible(false); | ||
| 1241 | }); | ||
| 1242 | }} | ||
| 1243 | /> | ||
| 1244 | </View> | ||
| 1245 | |||
| 1246 | <View className="gap-2"> | ||
| 1247 | <Text | ||
| 1248 | className="text-[11px] font-t3-bold uppercase" | ||
| 1249 | style={{ color: palette.textSecondary, letterSpacing: 1 }} | ||
| 1250 | > | ||
| 1251 | Existing branches | ||
| 1252 | </Text> | ||
| 1253 | {branchesLoading ? ( | ||
| 1254 | <Text className="text-[13px] font-medium" style={{ color: palette.textSecondary }}> | ||
| 1255 | Loading branches… | ||
| 1256 | </Text> | ||
| 1257 | ) : null} | ||
| 1258 | {!branchesLoading && availableBranches.length === 0 ? ( | ||
| 1259 | <Text className="text-[13px] font-medium" style={{ color: palette.textSecondary }}> | ||
| 1260 | No local branches found. | ||
| 1261 | </Text> | ||
| 1262 | ) : null} | ||
| 1263 | {availableBranches.map((branch) => { | ||
| 1264 | const disabled = disabledExistingBranches.has(branch.name); | ||
| 1265 | const subtitle = branch.worktreePath | ||
| 1266 | ? branch.worktreePath === currentWorktreePath | ||
| 1267 | ? "Checked out in this thread" | ||
| 1268 | : "Checked out in another worktree" | ||
| 1269 | : branch.isDefault | ||
| 1270 | ? "Default branch" | ||
| 1271 | : "Local branch"; | ||
| 1272 | |||
| 1273 | return ( | ||
| 1274 | <Pressable | ||
| 1275 | key={branch.name} | ||
| 1276 | className="gap-1 rounded-[18px] border px-4 py-3" | ||
| 1277 | disabled={busy || disabled} | ||
| 1278 | style={{ | ||
| 1279 | backgroundColor: palette.card, | ||
| 1280 | borderColor: branch.current ? palette.subtleBgStrong : palette.border, | ||
| 1281 | opacity: busy || disabled ? 0.45 : 1, | ||
| 1282 | }} | ||
| 1283 | onPress={() => { | ||
| 1284 | void onCheckoutBranch(branch.name).then(() => { | ||
| 1285 | setBranchSheetVisible(false); | ||
| 1286 | }); | ||
| 1287 | }} | ||
| 1288 | > | ||
| 1289 | <Text className="text-[15px] font-t3-bold" style={{ color: palette.text }}> | ||
| 1290 | {branch.name} | ||
| 1291 | </Text> | ||
| 1292 | <Text | ||
| 1293 | className="text-[12px] font-medium" | ||
| 1294 | style={{ color: palette.textSecondary }} | ||
| 1295 | > | ||
| 1296 | {subtitle} | ||
| 1297 | </Text> | ||
| 1298 | </Pressable> | ||
| 1299 | ); | ||
| 1300 | })} | ||
| 1301 | </View> | ||
| 1302 | </BottomSheetScrollView> | ||
| 1303 | </BottomSheetView> | ||
| 1304 | </BottomSheetModal> | ||
| 1305 | </> | ||
| 1306 | ); | 200 | ); |
| 1307 | } | 201 | } |
| @@ -10,14 +10,15 @@ import { · 9 unmodified lines | |||
| 10 | type View as RNView, | 10 | type View as RNView, |
| 11 | } from "react-native"; | 11 | } from "react-native"; |
| 12 | import Animated, { FadeInDown, LinearTransition } from "react-native-reanimated"; | 12 | import Animated, { FadeInDown, LinearTransition } from "react-native-reanimated"; |
| 13 | import { useThemeColor } from "../../lib/useThemeColor"; | ||
| 13 | 14 | ||
| 14 | import { BrandMark } from "../../components/BrandMark"; | 15 | import { BrandMark } from "../../components/BrandMark"; |
| 15 | import { AppText as Text } from "../../components/AppText"; | 16 | import { AppText as Text } from "../../components/AppText"; |
| 16 | import { EmptyState } from "../../components/EmptyState"; | 17 | import { EmptyState } from "../../components/EmptyState"; |
| 17 | import { GlassSafeAreaView } from "../../components/GlassSafeAreaView"; | 18 | import { GlassSafeAreaView } from "../../components/GlassSafeAreaView"; |
| 18 | import { StatusPill } from "../../components/StatusPill"; | 19 | import { StatusPill } from "../../components/StatusPill"; |
| 20 | import { cn } from "../../lib/cn"; | ||
| 19 | import type { MobileLayout } from "../../lib/mobileLayout"; | 21 | import type { MobileLayout } from "../../lib/mobileLayout"; |
| 20 | import { makeAppPalette } from "../../lib/theme"; | ||
| 21 | import { | 22 | import { |
| 22 | scopedThreadKey, | 23 | scopedThreadKey, |
| 23 | type ScopedMobileProject, | 24 | type ScopedMobileProject, |
| @@ -70,32 +71,31 @@ function ActionButton(props: { · 46 unmodified lines | |||
| 70 | readonly primary?: boolean; | 71 | readonly primary?: boolean; |
| 71 | readonly onPress: () => void; | 72 | readonly onPress: () => void; |
| 72 | }) { | 73 | }) { |
| 73 | const isDarkMode = useColorScheme() === "dark"; | 74 | const primaryForeground = useThemeColor("--color-primary-foreground"); |
| 74 | const palette = makeAppPalette(isDarkMode); | 75 | const secondaryForeground = useThemeColor("--color-secondary-foreground"); |
| 76 | const tintColor = props.primary ? primaryForeground : secondaryForeground; | ||
| 75 | 77 | ||
| 76 | return ( | 78 | return ( |
| 77 | <Pressable | 79 | <Pressable |
| 78 | className="min-h-[50px] flex-1 flex-row items-center justify-center gap-2 rounded-[16px] px-3 py-3" | 80 | className={cn( |
| 79 | style={{ | 81 | "min-h-[50px] flex-1 flex-row items-center justify-center gap-2 rounded-[16px] px-3 py-3", |
| 80 | backgroundColor: props.primary ? palette.primaryButton : palette.secondaryButton, | 82 | props.primary ? "bg-primary" : "border border-secondary-border bg-secondary", |
| 81 | borderWidth: props.primary ? 0 : 1, | 83 | )} |
| 82 | borderColor: props.primary ? "transparent" : palette.secondaryButtonBorder, | ||
| 83 | }} | ||
| 84 | onPress={props.onPress} | 84 | onPress={props.onPress} |
| 85 | > | 85 | > |
| 86 | <SymbolView | 86 | <SymbolView |
| 87 | name={props.icon} | 87 | name={props.icon} |
| 88 | size={18} | 88 | size={18} |
| 89 | tintColor={props.primary ? palette.primaryButtonText : palette.secondaryButtonText} | 89 | tintColor={tintColor} |
| 90 | type="monochrome" | 90 | type="monochrome" |
| 91 | weight="medium" | 91 | weight="medium" |
| 92 | /> | 92 | /> |
| 93 | <Text | 93 | <Text |
| 94 | className="text-[13px] font-t3-bold uppercase" | 94 | className={cn( |
| 95 | style={{ | 95 | "text-[13px] font-t3-bold uppercase", |
| 96 | color: props.primary ? palette.primaryButtonText : palette.secondaryButtonText, | 96 | props.primary ? "text-primary-foreground" : "text-secondary-foreground", |
| 97 | letterSpacing: 0.9, | 97 | )} |
| 98 | }} | 98 | style={{ letterSpacing: 0.9 }} |
| 99 | > | 99 | > |
| 100 | {props.label} | 100 | {props.label} |
| 101 | </Text> | 101 | </Text> |
| @@ -108,35 +108,20 @@ function StatCard(props: { · 6 unmodified lines | |||
| 108 | readonly value: string; | 108 | readonly value: string; |
| 109 | readonly icon: React.ComponentProps<typeof SymbolView>["name"]; | 109 | readonly icon: React.ComponentProps<typeof SymbolView>["name"]; |
| 110 | }) { | 110 | }) { |
| 111 | const isDarkMode = useColorScheme() === "dark"; | 111 | const foregroundSecondary = useThemeColor("--color-foreground-secondary"); |
| 112 | const palette = makeAppPalette(isDarkMode); | ||
| 113 | 112 | ||
| 114 | return ( | 113 | return ( |
| 115 | <View | 114 | <View className="flex-1 gap-2 rounded-[18px] border border-border bg-card px-4 py-4"> |
| 116 | className="flex-1 gap-2 rounded-[18px] px-4 py-4" | ||
| 117 | style={{ | ||
| 118 | backgroundColor: palette.card, | ||
| 119 | borderWidth: 1, | ||
| 120 | borderColor: palette.border, | ||
| 121 | }} | ||
| 122 | > | ||
| 123 | <View className="flex-row items-center gap-2"> | 115 | <View className="flex-row items-center gap-2"> |
| 124 | <SymbolView | 116 | <SymbolView name={props.icon} size={15} tintColor={foregroundSecondary} type="monochrome" /> |
| 125 | name={props.icon} | ||
| 126 | size={15} | ||
| 127 | tintColor={palette.textSecondary} | ||
| 128 | type="monochrome" | ||
| 129 | /> | ||
| 130 | <Text | 117 | <Text |
| 131 | className="text-[11px] font-t3-bold uppercase" | 118 | className="text-[11px] font-t3-bold uppercase text-foreground-secondary" |
| 132 | style={{ color: palette.textSecondary, letterSpacing: 1 }} | 119 | style={{ letterSpacing: 1 }} |
| 133 | > | 120 | > |
| 134 | {props.label} | 121 | {props.label} |
| 135 | </Text> | 122 | </Text> |
| 136 | </View> | 123 | </View> |
| 137 | <Text className="text-[28px] font-t3-bold" style={{ color: palette.text }}> | 124 | <Text className="text-[28px] font-t3-bold text-foreground">{props.value}</Text> |
| 138 | {props.value} | ||
| 139 | </Text> | ||
| 140 | </View> | 125 | </View> |
| 141 | ); | 126 | ); |
| 142 | } | 127 | } |
| @@ -148,19 +133,20 @@ function ThreadRow(props: { · 5 unmodified lines | |||
| 148 | readonly onPress: (sourceFrame: TransitionSourceFrame | null) => void; | 133 | readonly onPress: (sourceFrame: TransitionSourceFrame | null) => void; |
| 149 | }) { | 134 | }) { |
| 150 | const isDarkMode = useColorScheme() === "dark"; | 135 | const isDarkMode = useColorScheme() === "dark"; |
| 151 | const palette = makeAppPalette(isDarkMode); | 136 | const cardColor = useThemeColor("--color-card"); |
| 137 | const borderColor = useThemeColor("--color-border"); | ||
| 152 | const containerRef = useRef<RNView>(null); | 138 | const containerRef = useRef<RNView>(null); |
| 153 | const tone = threadStatusTone(props.thread); | 139 | const tone = threadStatusTone(props.thread); |
| 154 | const backgroundColor = props.selected | 140 | const backgroundColor = props.selected |
| 155 | ? isDarkMode | 141 | ? isDarkMode |
| 156 | ? "rgba(249,115,22,0.12)" | 142 | ? "rgba(249,115,22,0.12)" |
| 157 | : "rgba(249,115,22,0.09)" | 143 | : "rgba(249,115,22,0.09)" |
| 158 | : palette.card; | 144 | : cardColor; |
| 159 | const borderColor = props.selected | 145 | const resolvedBorderColor = props.selected |
| 160 | ? isDarkMode | 146 | ? isDarkMode |
| 161 | ? "rgba(249,115,22,0.32)" | 147 | ? "rgba(249,115,22,0.32)" |
| 162 | : "rgba(249,115,22,0.28)" | 148 | : "rgba(249,115,22,0.28)" |
| 163 | : palette.border; | 149 | : borderColor; |
| 164 | 150 | ||
| 165 | return ( | 151 | return ( |
| 166 | <Pressable | 152 | <Pressable |
| @@ -168,7 +154,7 @@ function ThreadRow(props: { · 1 unmodified line | |||
| 168 | className="gap-3 rounded-[18px] border px-4 py-4" | 154 | className="gap-3 rounded-[18px] border px-4 py-4" |
| 169 | style={{ | 155 | style={{ |
| 170 | backgroundColor, | 156 | backgroundColor, |
| 171 | borderColor, | 157 | borderColor: resolvedBorderColor, |
| 172 | opacity: props.hidden ? 0 : 1, | 158 | opacity: props.hidden ? 0 : 1, |
| 173 | }} | 159 | }} |
| 174 | onPress={() => { | 160 | onPress={() => { |
| @@ -183,13 +169,8 @@ function ThreadRow(props: { · 8 unmodified lines | |||
| 183 | > | 169 | > |
| 184 | <View className="flex-row items-start justify-between gap-3"> | 170 | <View className="flex-row items-start justify-between gap-3"> |
| 185 | <View className="flex-1 gap-2"> | 171 | <View className="flex-1 gap-2"> |
| 186 | <Text className="text-[17px] font-t3-bold" style={{ color: palette.text }}> | 172 | <Text className="text-[17px] font-t3-bold text-foreground">{props.thread.title}</Text> |
| 187 | {props.thread.title} | 173 | <Text className="text-[13px] font-medium leading-[19px] text-foreground-secondary"> |
| 188 | </Text> | ||
| 189 | <Text | ||
| 190 | className="text-[13px] font-medium leading-[19px]" | ||
| 191 | style={{ color: palette.textSecondary }} | ||
| 192 | > | ||
| 193 | {lastConversationLine(props.thread)} | 174 | {lastConversationLine(props.thread)} |
| 194 | </Text> | 175 | </Text> |
| 195 | </View> | 176 | </View> |
| @@ -197,8 +178,8 @@ function ThreadRow(props: { · 1 unmodified line | |||
| 197 | </View> | 178 | </View> |
| 198 | 179 | ||
| 199 | <Text | 180 | <Text |
| 200 | className="text-[11px] font-t3-bold uppercase" | 181 | className="text-[11px] font-t3-bold uppercase text-foreground-muted" |
| 201 | style={{ color: palette.textMuted, letterSpacing: 0.9 }} | 182 | style={{ letterSpacing: 0.9 }} |
| 202 | > | 183 | > |
| 203 | {props.thread.environmentLabel} · {props.thread.modelSelection.provider} ·{" "} | 184 | {props.thread.environmentLabel} · {props.thread.modelSelection.provider} ·{" "} |
| 204 | {relativeTime(props.thread.updatedAt ?? props.thread.createdAt)} | 185 | {relativeTime(props.thread.updatedAt ?? props.thread.createdAt)} |
| @@ -218,34 +199,28 @@ function ProjectSection(props: { · 13 unmodified lines | |||
| 218 | sourceFrame: TransitionSourceFrame | null, | 199 | sourceFrame: TransitionSourceFrame | null, |
| 219 | ) => void; | 200 | ) => void; |
| 220 | }) { | 201 | }) { |
| 221 | const isDarkMode = useColorScheme() === "dark"; | ||
| 222 | const palette = makeAppPalette(isDarkMode); | ||
| 223 | const [expanded, setExpanded] = useState(false); | 202 | const [expanded, setExpanded] = useState(false); |
| 224 | const visibleThreads = expanded ? props.threads : props.threads.slice(0, 2); | 203 | const visibleThreads = expanded ? props.threads : props.threads.slice(0, 2); |
| 225 | const hiddenCount = Math.max(props.threads.length - visibleThreads.length, 0); | 204 | const hiddenCount = Math.max(props.threads.length - visibleThreads.length, 0); |
| 226 | 205 | ||
| 227 | return ( | 206 | return ( |
| 228 | <View className="gap-3 rounded-[22px] border px-4 py-4" style={{ borderColor: palette.border }}> | 207 | <View className="gap-3 rounded-[22px] border border-border px-4 py-4"> |
| 229 | <View className="flex-row items-start justify-between gap-3"> | 208 | <View className="flex-row items-start justify-between gap-3"> |
| 230 | <View className="flex-1 gap-1"> | 209 | <View className="flex-1 gap-1"> |
| 231 | <Text className="text-[15px] font-t3-bold" style={{ color: palette.text }}> | 210 | <Text className="text-[15px] font-t3-bold text-foreground"> |
| 232 | {props.project.environmentLabel} | 211 | {props.project.environmentLabel} |
| 233 | </Text> | 212 | </Text> |
| 234 | <Text | 213 | <Text className="text-[12px] font-medium leading-[18px] text-foreground-secondary"> |
| 235 | className="text-[12px] font-medium leading-[18px]" | ||
| 236 | style={{ color: palette.textSecondary }} | ||
| 237 | > | ||
| 238 | {props.project.workspaceRoot} | 214 | {props.project.workspaceRoot} |
| 239 | </Text> | 215 | </Text> |
| 240 | </View> | 216 | </View> |
| 241 | <Pressable | 217 | <Pressable |
| 242 | className="rounded-full px-3 py-2" | 218 | className="rounded-full bg-subtle px-3 py-2" |
| 243 | style={{ backgroundColor: palette.subtleBg }} | ||
| 244 | onPress={() => void props.onCreateThread(props.project)} | 219 | onPress={() => void props.onCreateThread(props.project)} |
| 245 | > | 220 | > |
| 246 | <Text | 221 | <Text |
| 247 | className="text-[11px] font-t3-bold uppercase" | 222 | className="text-[11px] font-t3-bold uppercase text-foreground" |
| 248 | style={{ color: palette.text, letterSpacing: 0.9 }} | 223 | style={{ letterSpacing: 0.9 }} |
| 249 | > | 224 | > |
| 250 | New thread | 225 | New thread |
| 251 | </Text> | 226 | </Text> |
| @@ -273,13 +248,12 @@ function ProjectSection(props: { · 21 unmodified lines | |||
| 273 | 248 | ||
| 274 | {hiddenCount > 0 ? ( | 249 | {hiddenCount > 0 ? ( |
| 275 | <Pressable | 250 | <Pressable |
| 276 | className="items-center rounded-[16px] px-3 py-3" | 251 | className="items-center rounded-[16px] bg-separator px-3 py-3" |
| 277 | style={{ backgroundColor: palette.separator }} | ||
| 278 | onPress={() => setExpanded((current) => !current)} | 252 | onPress={() => setExpanded((current) => !current)} |
| 279 | > | 253 | > |
| 280 | <Text | 254 | <Text |
| 281 | className="text-[11px] font-t3-bold uppercase" | 255 | className="text-[11px] font-t3-bold uppercase text-foreground-secondary" |
| 282 | style={{ color: palette.textSecondary, letterSpacing: 1 }} | 256 | style={{ letterSpacing: 1 }} |
| 283 | > | 257 | > |
| 284 | {expanded ? "Show less" : `Show ${hiddenCount} more`} | 258 | {expanded ? "Show less" : `Show ${hiddenCount} more`} |
| 285 | </Text> | 259 | </Text> |
| @@ -297,28 +271,19 @@ function CreateThreadModal(props: { · 11 unmodified lines | |||
| 297 | readonly onClose: () => void; | 271 | readonly onClose: () => void; |
| 298 | readonly onSelectProject: (project: ScopedMobileProject) => Promise<void>; | 272 | readonly onSelectProject: (project: ScopedMobileProject) => Promise<void>; |
| 299 | }) { | 273 | }) { |
| 300 | const isDarkMode = useColorScheme() === "dark"; | ||
| 301 | const palette = makeAppPalette(isDarkMode); | ||
| 302 | |||
| 303 | if (!props.group) { | 274 | if (!props.group) { |
| 304 | return null; | 275 | return null; |
| 305 | } | 276 | } |
| 306 | 277 | ||
| 307 | return ( | 278 | return ( |
| 308 | <Modal transparent animationType="slide" visible={props.visible} onRequestClose={props.onClose}> | 279 | <Modal transparent animationType="slide" visible={props.visible} onRequestClose={props.onClose}> |
| 309 | <View className="flex-1 justify-end" style={{ backgroundColor: palette.backdrop }}> | 280 | <View className="flex-1 justify-end bg-backdrop"> |
| 310 | <View | 281 | <View className="gap-4 rounded-t-[28px] bg-screen px-5 pb-8 pt-5"> |
| 311 | className="gap-4 rounded-t-[28px] px-5 pb-8 pt-5" | ||
| 312 | style={{ backgroundColor: palette.screenBackground }} | ||
| 313 | > | ||
| 314 | <View className="gap-1"> | 282 | <View className="gap-1"> |
| 315 | <Text className="text-[22px] font-t3-bold" style={{ color: palette.text }}> | 283 | <Text className="text-[22px] font-t3-bold text-foreground"> |
| 316 | New thread in {props.group.title} | 284 | New thread in {props.group.title} |
| 317 | </Text> | 285 | </Text> |
| 318 | <Text | 286 | <Text className="text-[13px] font-medium leading-[19px] text-foreground-secondary"> |
| 319 | className="text-[13px] font-medium leading-[19px]" | ||
| 320 | style={{ color: palette.textSecondary }} | ||
| 321 | > | ||
| 322 | Choose which environment should own the new thread. | 287 | Choose which environment should own the new thread. |
| 323 | </Text> | 288 | </Text> |
| 324 | </View> | 289 | </View> |
| @@ -327,31 +292,24 @@ function CreateThreadModal(props: { · 2 unmodified lines | |||
| 327 | {props.group.projects.map(({ project, threads }) => ( | 292 | {props.group.projects.map(({ project, threads }) => ( |
| 328 | <Pressable | 293 | <Pressable |
| 329 | key={project.environmentId + project.id} | 294 | key={project.environmentId + project.id} |
| 330 | className="gap-2 rounded-[20px] border px-4 py-4" | 295 | className="gap-2 rounded-[20px] border border-border bg-card px-4 py-4" |
| 331 | style={{ backgroundColor: palette.card, borderColor: palette.border }} | ||
| 332 | onPress={() => { | 296 | onPress={() => { |
| 333 | void props.onSelectProject(project).then(props.onClose); | 297 | void props.onSelectProject(project).then(props.onClose); |
| 334 | }} | 298 | }} |
| 335 | > | 299 | > |
| 336 | <View className="flex-row items-start justify-between gap-3"> | 300 | <View className="flex-row items-start justify-between gap-3"> |
| 337 | <View className="flex-1 gap-1"> | 301 | <View className="flex-1 gap-1"> |
| 338 | <Text className="text-[16px] font-t3-bold" style={{ color: palette.text }}> | 302 | <Text className="text-[16px] font-t3-bold text-foreground"> |
| 339 | {project.environmentLabel} | 303 | {project.environmentLabel} |
| 340 | </Text> | 304 | </Text> |
| 341 | <Text | 305 | <Text className="text-[12px] font-medium leading-[18px] text-foreground-secondary"> |
| 342 | className="text-[12px] font-medium leading-[18px]" | ||
| 343 | style={{ color: palette.textSecondary }} | ||
| 344 | > | ||
| 345 | {project.workspaceRoot} | 306 | {project.workspaceRoot} |
| 346 | </Text> | 307 | </Text> |
| 347 | </View> | 308 | </View> |
| 348 | <View | 309 | <View className="rounded-full bg-subtle px-3 py-2"> |
| 349 | className="rounded-full px-3 py-2" | ||
| 350 | style={{ backgroundColor: palette.subtleBg }} | ||
| 351 | > | ||
| 352 | <Text | 310 | <Text |
| 353 | className="text-[11px] font-t3-bold uppercase" | 311 | className="text-[11px] font-t3-bold uppercase text-foreground" |
| 354 | style={{ color: palette.text, letterSpacing: 0.9 }} | 312 | style={{ letterSpacing: 0.9 }} |
| 355 | > | 313 | > |
| 356 | {threads.length} thread{threads.length === 1 ? "" : "s"} | 314 | {threads.length} thread{threads.length === 1 ? "" : "s"} |
| 357 | </Text> | 315 | </Text> |
| @@ -369,8 +327,7 @@ function CreateThreadModal(props: { · 11 unmodified lines | |||
| 369 | } | 327 | } |
| 370 | 328 | ||
| 371 | export function ThreadListScreen(props: ThreadListScreenProps) { | 329 | export function ThreadListScreen(props: ThreadListScreenProps) { |
| 372 | const isDarkMode = useColorScheme() === "dark"; | 330 | const borderColorValue = useThemeColor("--color-border"); |
| 373 | const palette = makeAppPalette(isDarkMode); | ||
| 374 | const repositoryGroups = useMemo( | 331 | const repositoryGroups = useMemo( |
| 375 | () => groupProjectsByRepository({ projects: props.projects, threads: props.threads }), | 332 | () => groupProjectsByRepository({ projects: props.projects, threads: props.threads }), |
| 376 | [props.projects, props.threads], | 333 | [props.projects, props.threads], |
| @@ -379,7 +336,7 @@ export function ThreadListScreen(props: ThreadListScreenProps) { · 2 unmodified lines | |||
| 379 | const [refreshing, setRefreshing] = useState(false); | 336 | const [refreshing, setRefreshing] = useState(false); |
| 380 | const isSplitLayout = props.layout.usesSplitView; | 337 | const isSplitLayout = props.layout.usesSplitView; |
| 381 | const contentHorizontalPadding = isSplitLayout ? 18 : 20; | 338 | const contentHorizontalPadding = isSplitLayout ? 18 : 20; |
| 382 | const panelBorderColor = isSplitLayout ? palette.border : "transparent"; | 339 | const panelBorderColor = isSplitLayout ? borderColorValue : "transparent"; |
| 383 | const refreshThreads = props.onRefresh; | 340 | const refreshThreads = props.onRefresh; |
| 384 | 341 | ||
| 385 | const handleRefresh = useCallback(async (): Promise<void> => { | 342 | const handleRefresh = useCallback(async (): Promise<void> => { |
| @@ -397,9 +354,8 @@ export function ThreadListScreen(props: ThreadListScreenProps) { · 11 unmodified lines | |||
| 397 | 354 | ||
| 398 | return ( | 355 | return ( |
| 399 | <View | 356 | <View |
| 400 | className="flex-1" | 357 | className="flex-1 bg-screen" |
| 401 | style={{ | 358 | style={{ |
| 402 | backgroundColor: palette.screenBackground, | ||
| 403 | borderRadius: isSplitLayout ? 32 : 0, | 359 | borderRadius: isSplitLayout ? 32 : 0, |
| 404 | borderWidth: isSplitLayout ? 1 : 0, | 360 | borderWidth: isSplitLayout ? 1 : 0, |
| 405 | borderColor: panelBorderColor, | 361 | borderColor: panelBorderColor, |
| @@ -426,28 +382,20 @@ export function ThreadListScreen(props: ThreadListScreenProps) { · 20 unmodified lines | |||
| 426 | <Animated.View | 382 | <Animated.View |
| 427 | entering={FadeInDown.duration(260)} | 383 | entering={FadeInDown.duration(260)} |
| 428 | layout={LinearTransition.springify().damping(18).stiffness(180)} | 384 | layout={LinearTransition.springify().damping(18).stiffness(180)} |
| 429 | className="gap-4 rounded-[26px] px-4 py-4" | 385 | className="gap-4 rounded-[26px] border border-border bg-card-translucent px-4 py-4" |
| 430 | style={{ | ||
| 431 | backgroundColor: palette.cardTranslucent, | ||
| 432 | borderWidth: 1, | ||
| 433 | borderColor: palette.border, | ||
| 434 | }} | ||
| 435 | > | 386 | > |
| 436 | <View className="flex-row items-start justify-between gap-4"> | 387 | <View className="flex-row items-start justify-between gap-4"> |
| 437 | <View className="flex-1 gap-2"> | 388 | <View className="flex-1 gap-2"> |
| 438 | <Text | 389 | <Text |
| 439 | className="text-[11px] font-t3-bold uppercase" | 390 | className="text-[11px] font-t3-bold uppercase text-foreground-muted" |
| 440 | style={{ color: palette.textMuted, letterSpacing: 1.1 }} | 391 | style={{ letterSpacing: 1.1 }} |
| 441 | > | 392 | > |
| 442 | {props.heroTitle} | 393 | {props.heroTitle} |
| 443 | </Text> | 394 | </Text> |
| 444 | <Text className="text-[26px] font-t3-bold" style={{ color: palette.text }}> | 395 | <Text className="text-[26px] font-t3-bold text-foreground"> |
| 445 | {isSplitLayout ? "Native workspace" : "Repo board"} | 396 | {isSplitLayout ? "Native workspace" : "Repo board"} |
| 446 | </Text> | 397 | </Text> |
| 447 | <Text | 398 | <Text className="text-[13px] font-medium leading-[19px] text-foreground-secondary"> |
| 448 | className="text-[13px] font-medium leading-[19px]" | ||
| 449 | style={{ color: palette.textSecondary }} | ||
| 450 | > | ||
| 451 | {isSplitLayout | 399 | {isSplitLayout |
| 452 | ? "Keep your repositories visible while you move between active threads." | 400 | ? "Keep your repositories visible while you move between active threads." |
| 453 | : "Your connected repositories, grouped by identity across environments."} | 401 | : "Your connected repositories, grouped by identity across environments."} |
| @@ -497,29 +445,19 @@ export function ThreadListScreen(props: ThreadListScreenProps) { · 43 unmodified lines | |||
| 497 | key={group.key} | 445 | key={group.key} |
| 498 | entering={FadeInDown.duration(260).delay(40 * Math.min(index, 6))} | 446 | entering={FadeInDown.duration(260).delay(40 * Math.min(index, 6))} |
| 499 | layout={LinearTransition.springify().damping(18).stiffness(180)} | 447 | layout={LinearTransition.springify().damping(18).stiffness(180)} |
| 500 | className="gap-4 rounded-[26px] px-4 py-4" | 448 | className="gap-4 rounded-[26px] border border-border bg-card-translucent px-4 py-4" |
| 501 | style={{ | ||
| 502 | backgroundColor: palette.cardTranslucent, | ||
| 503 | borderWidth: 1, | ||
| 504 | borderColor: palette.border, | ||
| 505 | }} | ||
| 506 | > | 449 | > |
| 507 | <View className="flex-row items-start justify-between gap-4"> | 450 | <View className="flex-row items-start justify-between gap-4"> |
| 508 | <View className="flex-1 gap-1"> | 451 | <View className="flex-1 gap-1"> |
| 509 | <Text className="text-[22px] font-t3-bold" style={{ color: palette.text }}> | 452 | <Text className="text-[22px] font-t3-bold text-foreground">{group.title}</Text> |
| 510 | {group.title} | ||
| 511 | </Text> | ||
| 512 | {group.subtitle ? ( | 453 | {group.subtitle ? ( |
| 513 | <Text | 454 | <Text className="text-[13px] font-medium leading-[19px] text-foreground-secondary"> |
| 514 | className="text-[13px] font-medium leading-[19px]" | ||
| 515 | style={{ color: palette.textSecondary }} | ||
| 516 | > | ||
| 517 | {group.subtitle} | 455 | {group.subtitle} |
| 518 | </Text> | 456 | </Text> |
| 519 | ) : null} | 457 | ) : null} |
| 520 | <Text | 458 | <Text |
| 521 | className="text-[11px] font-t3-bold uppercase" | 459 | className="text-[11px] font-t3-bold uppercase text-foreground-muted" |
| 522 | style={{ color: palette.textMuted, letterSpacing: 0.9 }} | 460 | style={{ letterSpacing: 0.9 }} |
| 523 | > | 461 | > |
| 524 | {group.projectCount} environment{group.projectCount === 1 ? "" : "s"} ·{" "} | 462 | {group.projectCount} environment{group.projectCount === 1 ? "" : "s"} ·{" "} |
| 525 | {group.threadCount} active thread{group.threadCount === 1 ? "" : "s"} | 463 | {group.threadCount} active thread{group.threadCount === 1 ? "" : "s"} |
| @@ -527,8 +465,7 @@ export function ThreadListScreen(props: ThreadListScreenProps) { · 1 unmodified line | |||
| 527 | </View> | 465 | </View> |
| 528 | 466 | ||
| 529 | <Pressable | 467 | <Pressable |
| 530 | className="rounded-full px-3 py-2" | 468 | className="rounded-full bg-primary px-3 py-2" |
| 531 | style={{ backgroundColor: palette.primaryButton }} | ||
| 532 | onPress={() => { | 469 | onPress={() => { |
| 533 | if (group.projects.length === 1) { | 470 | if (group.projects.length === 1) { |
| 534 | void props.onCreateThread(group.projects[0]!.project); | 471 | void props.onCreateThread(group.projects[0]!.project); |
| @@ -538,8 +475,8 @@ export function ThreadListScreen(props: ThreadListScreenProps) { · 3 unmodified lines | |||
| 538 | }} | 475 | }} |
| 539 | > | 476 | > |
| 540 | <Text | 477 | <Text |
| 541 | className="text-[11px] font-t3-bold uppercase" | 478 | className="text-[11px] font-t3-bold uppercase text-primary-foreground" |
| 542 | style={{ color: palette.primaryButtonText, letterSpacing: 0.9 }} | 479 | style={{ letterSpacing: 0.9 }} |
| 543 | > | 480 | > |
| 544 | New thread | 481 | New thread |
| 545 | </Text> | 482 | </Text> |
| @@ -1,13 +1,6 @@ | |||
| 1 | import { SymbolView } from "expo-symbols"; | 1 | import { SymbolView } from "expo-symbols"; |
| 2 | import { useCallback, useEffect, useMemo, useState } from "react"; | 2 | import { useCallback, useEffect, useMemo, useState } from "react"; |
| 3 | import { | 3 | import { Modal, Pressable, ScrollView, useWindowDimensions, View } from "react-native"; |
| 4 | Modal, | ||
| 5 | Pressable, | ||
| 6 | ScrollView, | ||
| 7 | useColorScheme, | ||
| 8 | useWindowDimensions, | ||
| 9 | View, | ||
| 10 | } from "react-native"; | ||
| 11 | import { Gesture, GestureDetector } from "react-native-gesture-handler"; | 4 | import { Gesture, GestureDetector } from "react-native-gesture-handler"; |
| 12 | import { useSafeAreaInsets } from "react-native-safe-area-context"; | 5 | import { useSafeAreaInsets } from "react-native-safe-area-context"; |
| 13 | import Animated, { | 6 | import Animated, { |
| @@ -16,6 +9,7 @@ import Animated, { · 2 unmodified lines | |||
| 16 | useSharedValue, | 9 | useSharedValue, |
| 17 | withTiming, | 10 | withTiming, |
| 18 | } from "react-native-reanimated"; | 11 | } from "react-native-reanimated"; |
| 12 | import { useThemeColor } from "../../lib/useThemeColor"; | ||
| 19 | 13 | ||
| 20 | import { AppText as Text } from "../../components/AppText"; | 14 | import { AppText as Text } from "../../components/AppText"; |
| 21 | import { StatusPill } from "../../components/StatusPill"; | 15 | import { StatusPill } from "../../components/StatusPill"; |
| @@ -23,7 +17,6 @@ import { groupProjectsByRepository } from "../../lib/repositoryGroups"; · 1 unmodified line | |||
| 23 | import type { ScopedMobileProject, ScopedMobileThread } from "../../lib/scopedEntities"; | 17 | import type { ScopedMobileProject, ScopedMobileThread } from "../../lib/scopedEntities"; |
| 24 | import { scopedThreadKey } from "../../lib/scopedEntities"; | 18 | import { scopedThreadKey } from "../../lib/scopedEntities"; |
| 25 | import { relativeTime } from "../../lib/time"; | 19 | import { relativeTime } from "../../lib/time"; |
| 26 | import { makeAppPalette } from "../../lib/theme"; | ||
| 27 | import { threadStatusTone } from "./threadPresentation"; | 20 | import { threadStatusTone } from "./threadPresentation"; |
| 28 | 21 | ||
| 29 | function compareThreadActivity(left: ScopedMobileThread, right: ScopedMobileThread): number { | 22 | function compareThreadActivity(left: ScopedMobileThread, right: ScopedMobileThread): number { |
| @@ -42,14 +35,19 @@ export function ThreadNavigationDrawer(props: { · 12 unmodified lines | |||
| 42 | readonly onSelectThread: (thread: ScopedMobileThread) => void; | 35 | readonly onSelectThread: (thread: ScopedMobileThread) => void; |
| 43 | readonly onStartNewTask: () => void; | 36 | readonly onStartNewTask: () => void; |
| 44 | }) { | 37 | }) { |
| 45 | const isDarkMode = useColorScheme() === "dark"; | ||
| 46 | const palette = makeAppPalette(isDarkMode); | ||
| 47 | const insets = useSafeAreaInsets(); | 38 | const insets = useSafeAreaInsets(); |
| 48 | const { width } = useWindowDimensions(); | 39 | const { width } = useWindowDimensions(); |
| 49 | const drawerWidth = Math.min(width * 0.84, 360); | 40 | const drawerWidth = Math.min(width * 0.84, 360); |
| 50 | const [mounted, setMounted] = useState(props.visible); | 41 | const [mounted, setMounted] = useState(props.visible); |
| 51 | const translateX = useSharedValue(-drawerWidth); | 42 | const translateX = useSharedValue(-drawerWidth); |
| 52 | const overlayOpacity = useSharedValue(0); | 43 | const overlayOpacity = useSharedValue(0); |
| 44 | |||
| 45 | const backdropColor = useThemeColor("--color-backdrop"); | ||
| 46 | const drawerBg = useThemeColor("--color-drawer"); | ||
| 47 | const drawerShadow = useThemeColor("--color-drawer-shadow"); | ||
| 48 | const primaryForeground = useThemeColor("--color-primary-foreground"); | ||
| 49 | const borderSubtleColor = useThemeColor("--color-border-subtle"); | ||
| 50 | |||
| 53 | const repositoryGroups = useMemo( | 51 | const repositoryGroups = useMemo( |
| 54 | () => groupProjectsByRepository({ projects: props.projects, threads: props.threads }), | 52 | () => groupProjectsByRepository({ projects: props.projects, threads: props.threads }), |
| 55 | [props.projects, props.threads], | 53 | [props.projects, props.threads], |
| @@ -130,7 +128,7 @@ export function ThreadNavigationDrawer(props: { · 74 unmodified lines | |||
| 130 | right: 0, | 128 | right: 0, |
| 131 | top: 0, | 129 | top: 0, |
| 132 | bottom: 0, | 130 | bottom: 0, |
| 133 | backgroundColor: palette.backdrop, | 131 | backgroundColor: backdropColor, |
| 134 | }, | 132 | }, |
| 135 | backdropStyle, | 133 | backdropStyle, |
| 136 | ]} | 134 | ]} |
| @@ -149,30 +147,27 @@ export function ThreadNavigationDrawer(props: { · 12 unmodified lines | |||
| 149 | top: 0, | 147 | top: 0, |
| 150 | bottom: 0, | 148 | bottom: 0, |
| 151 | width: drawerWidth, | 149 | width: drawerWidth, |
| 152 | backgroundColor: palette.drawerBackground, | 150 | backgroundColor: drawerBg, |
| 153 | paddingTop: insets.top + 10, | 151 | paddingTop: insets.top + 10, |
| 154 | paddingBottom: Math.max(insets.bottom, 18), | 152 | paddingBottom: Math.max(insets.bottom, 18), |
| 155 | boxShadow: `20px 0 36px ${palette.drawerShadow}`, | 153 | boxShadow: `20px 0 36px ${String(drawerShadow)}`, |
| 156 | }, | 154 | }, |
| 157 | drawerStyle, | 155 | drawerStyle, |
| 158 | ]} | 156 | ]} |
| 159 | > | 157 | > |
| 160 | <View className="flex-row items-center justify-between px-4 pb-5"> | 158 | <View className="flex-row items-center justify-between px-4 pb-5"> |
| 161 | <Text className="text-[26px] font-t3-bold" style={{ color: palette.text }}> | 159 | <Text className="text-[26px] font-t3-bold">Threads</Text> |
| 162 | Threads | ||
| 163 | </Text> | ||
| 164 | <Pressable | 160 | <Pressable |
| 165 | onPress={() => { | 161 | onPress={() => { |
| 166 | props.onClose(); | 162 | props.onClose(); |
| 167 | props.onStartNewTask(); | 163 | props.onStartNewTask(); |
| 168 | }} | 164 | }} |
| 169 | className="h-11 w-11 items-center justify-center rounded-full" | 165 | className="h-11 w-11 items-center justify-center rounded-full bg-primary" |
| 170 | style={{ backgroundColor: palette.primaryButton }} | ||
| 171 | > | 166 | > |
| 172 | <SymbolView | 167 | <SymbolView |
| 173 | name="square.and.pencil" | 168 | name="square.and.pencil" |
| 174 | size={17} | 169 | size={17} |
| 175 | tintColor={palette.primaryButtonText} | 170 | tintColor={primaryForeground} |
| 176 | type="monochrome" | 171 | type="monochrome" |
| 177 | /> | 172 | /> |
| 178 | </Pressable> | 173 | </Pressable> |
| @@ -189,22 +184,16 @@ export function ThreadNavigationDrawer(props: { · 10 unmodified lines | |||
| 189 | {groupedThreads.map((group) => ( | 184 | {groupedThreads.map((group) => ( |
| 190 | <View key={group.key} className="gap-3"> | 185 | <View key={group.key} className="gap-3"> |
| 191 | <Text | 186 | <Text |
| 192 | className="px-1 text-[15px] font-t3-bold" | 187 | className="px-1 text-[15px] font-t3-bold text-foreground-muted" |
| 193 | style={{ color: palette.textMuted, letterSpacing: -0.2 }} | 188 | style={{ letterSpacing: -0.2 }} |
| 194 | > | 189 | > |
| 195 | {group.title} | 190 | {group.title} |
| 196 | </Text> | 191 | </Text> |
| 197 | 192 | ||
| 198 | <View | 193 | <View className="overflow-hidden rounded-[22px] bg-card"> |
| 199 | className="overflow-hidden rounded-[22px]" | ||
| 200 | style={{ backgroundColor: palette.card }} | ||
| 201 | > | ||
| 202 | {group.threads.length === 0 ? ( | 194 | {group.threads.length === 0 ? ( |
| 203 | <View className="px-4 py-4"> | 195 | <View className="px-4 py-4"> |
| 204 | <Text | 196 | <Text className="text-[14px] font-medium text-foreground-tertiary"> |
| 205 | className="text-[14px] font-medium" | ||
| 206 | style={{ color: palette.textTertiary }} | ||
| 207 | > | ||
| 208 | No threads yet | 197 | No threads yet |
| 209 | </Text> | 198 | </Text> |
| 210 | </View> | 199 | </View> |
| @@ -224,23 +213,19 @@ export function ThreadNavigationDrawer(props: { · 13 unmodified lines | |||
| 224 | paddingHorizontal: 16, | 213 | paddingHorizontal: 16, |
| 225 | paddingVertical: 15, | 214 | paddingVertical: 15, |
| 226 | borderTopWidth: index === 0 ? 0 : 1, | 215 | borderTopWidth: index === 0 ? 0 : 1, |
| 227 | borderTopColor: palette.borderSubtle, | 216 | borderTopColor: borderSubtleColor, |
| 228 | backgroundColor: selected ? palette.subtleBg : "transparent", | 217 | backgroundColor: selected ? undefined : "transparent", |
| 229 | }} | 218 | }} |
| 219 | className={selected ? "bg-subtle" : undefined} | ||
| 230 | > | 220 | > |
| 231 | <View className="flex-row items-start justify-between gap-3"> | 221 | <View className="flex-row items-start justify-between gap-3"> |
| 232 | <View className="flex-1 gap-1"> | 222 | <View className="flex-1 gap-1"> |
| 233 | <Text | 223 | <Text className="text-[16px] font-t3-bold" numberOfLines={1}> |
| 234 | className="text-[16px] font-t3-bold" | ||
| 235 | numberOfLines={1} | ||
| 236 | style={{ color: palette.text }} | ||
| 237 | > | ||
| 238 | {thread.title} | 224 | {thread.title} |
| 239 | </Text> | 225 | </Text> |
| 240 | <Text | 226 | <Text |
| 241 | className="text-[13px] font-medium" | 227 | className="text-[13px] font-medium text-foreground-muted" |
| 242 | numberOfLines={1} | 228 | numberOfLines={1} |
| 243 | style={{ color: palette.textMuted }} | ||
| 244 | > | 229 | > |
| 245 | {relativeTime(thread.updatedAt ?? thread.createdAt)} | 230 | {relativeTime(thread.updatedAt ?? thread.createdAt)} |
| 246 | </Text> | 231 | </Text> |
| @@ -1,12 +1,13 @@ | |||
| 1 | import { useLocalSearchParams, useRouter } from "expo-router"; | 1 | import { Stack, useLocalSearchParams, useRouter } from "expo-router"; |
| 2 | import { useEffect, useMemo, useState } from "react"; | 2 | import { useCallback, useEffect, useMemo, useState } from "react"; |
| 3 | import { ScrollView, useColorScheme, View } from "react-native"; | 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 | import { EmptyState } from "../../components/EmptyState"; | 7 | import { EmptyState } from "../../components/EmptyState"; |
| 6 | import { LoadingScreen } from "../../components/LoadingScreen"; | 8 | import { LoadingScreen } from "../../components/LoadingScreen"; |
| 7 | import { buildThreadRoutePath, dismissRoute } from "../../lib/routes"; | 9 | import { buildThreadRoutePath } from "../../lib/routes"; |
| 8 | import { scopedThreadKey } from "../../lib/scopedEntities"; | 10 | import { scopedThreadKey } from "../../lib/scopedEntities"; |
| 9 | import { makeAppPalette } from "../../lib/theme"; | ||
| 10 | import { connectionTone } from "../connection/connectionTone"; | 11 | import { connectionTone } from "../connection/connectionTone"; |
| 11 | import { firstNonNull } from "../../state/remote-runtime-types"; | 12 | import { firstNonNull } from "../../state/remote-runtime-types"; |
| 12 | import { useRemoteCatalog } from "../../state/use-remote-catalog"; | 13 | import { useRemoteCatalog } from "../../state/use-remote-catalog"; |
| @@ -14,12 +15,15 @@ import { · 1 unmodified line | |||
| 14 | useRemoteConnectionStatus, | 15 | useRemoteConnectionStatus, |
| 15 | useRemoteEnvironmentState, | 16 | useRemoteEnvironmentState, |
| 16 | } from "../../state/use-remote-environment-registry"; | 17 | } from "../../state/use-remote-environment-registry"; |
| 18 | import { useSelectedThreadGitActions } from "../../state/use-selected-thread-git-actions"; | ||
| 19 | import { useSelectedThreadGitState } from "../../state/use-selected-thread-git-state"; | ||
| 17 | import { useSelectedThreadCommands } from "../../state/use-selected-thread-commands"; | 20 | import { useSelectedThreadCommands } from "../../state/use-selected-thread-commands"; |
| 18 | import { useSelectedThreadGit } from "../../state/use-selected-thread-git"; | ||
| 19 | import { useThreadComposerState } from "../../state/use-thread-composer-state"; | 21 | import { useThreadComposerState } from "../../state/use-thread-composer-state"; |
| 20 | import { useThreadSelection } from "../../state/use-thread-selection"; | 22 | import { useThreadSelection } from "../../state/use-thread-selection"; |
| 21 | import { ThreadDetailScreen } from "./ThreadDetailScreen"; | 23 | import { ThreadDetailScreen } from "./ThreadDetailScreen"; |
| 24 | import { ThreadGitControls } from "./ThreadGitControls"; | ||
| 22 | import { ThreadNavigationDrawer } from "./ThreadNavigationDrawer"; | 25 | import { ThreadNavigationDrawer } from "./ThreadNavigationDrawer"; |
| 26 | import { screenTitle } from "./threadPresentation"; | ||
| 23 | 27 | ||
| 24 | function firstRouteParam(value: string | string[] | undefined): string | null { | 28 | function firstRouteParam(value: string | string[] | undefined): string | null { |
| 25 | if (Array.isArray(value)) { | 29 | if (Array.isArray(value)) { |
| @@ -36,19 +40,19 @@ export function ThreadRouteScreen() { · 10 unmodified lines | |||
| 36 | const { | 40 | const { |
| 37 | onSelectThread, | 41 | onSelectThread, |
| 38 | selectedThread, | 42 | selectedThread, |
| 43 | selectedThreadProject, | ||
| 39 | selectedEnvironmentConnection, | 44 | selectedEnvironmentConnection, |
| 40 | selectedEnvironmentRuntime, | 45 | selectedEnvironmentRuntime, |
| 41 | } = useThreadSelection(); | 46 | } = useThreadSelection(); |
| 42 | const composer = useThreadComposerState(); | 47 | const composer = useThreadComposerState(); |
| 43 | const git = useSelectedThreadGit(); | 48 | const gitState = useSelectedThreadGitState(); |
| 49 | const gitActions = useSelectedThreadGitActions(); | ||
| 44 | const commands = useSelectedThreadCommands({ | 50 | const commands = useSelectedThreadCommands({ |
| 45 | activePendingUserInput: composer.activePendingUserInput, | 51 | activePendingUserInput: composer.activePendingUserInput, |
| 46 | activePendingUserInputAnswers: composer.activePendingUserInputAnswers, | 52 | activePendingUserInputAnswers: composer.activePendingUserInputAnswers, |
| 47 | refreshSelectedThreadGitStatus: git.refreshSelectedThreadGitStatus, | 53 | refreshSelectedThreadGitStatus: gitActions.refreshSelectedThreadGitStatus, |
| 48 | }); | 54 | }); |
| 49 | const router = useRouter(); | 55 | const router = useRouter(); |
| 50 | const colorScheme = useColorScheme(); | ||
| 51 | const isDarkMode = colorScheme !== "light"; | ||
| 52 | const params = useLocalSearchParams<{ | 56 | const params = useLocalSearchParams<{ |
| 53 | environmentId?: string | string[]; | 57 | environmentId?: string | string[]; |
| 54 | threadId?: string | string[]; | 58 | threadId?: string | string[]; |
| @@ -57,6 +61,32 @@ export function ThreadRouteScreen() { · 2 unmodified lines | |||
| 57 | const environmentId = firstRouteParam(params.environmentId); | 61 | const environmentId = firstRouteParam(params.environmentId); |
| 58 | const threadId = firstRouteParam(params.threadId); | 62 | const threadId = firstRouteParam(params.threadId); |
| 59 | 63 | ||
| 64 | /* ─── Native header theming ──────────────────────────────────────── */ | ||
| 65 | const isDark = useColorScheme() === "dark"; | ||
| 66 | const iconColor = String(useThemeColor("--color-icon")); | ||
| 67 | const foregroundColor = String(useThemeColor("--color-foreground")); | ||
| 68 | const secondaryFg = isDark ? "#a3a3a3" : "#525252"; | ||
| 69 | |||
| 70 | /* ─── Git status for native header trigger ───────────────────────── */ | ||
| 71 | const gitStatus = useGitStatus({ | ||
| 72 | environmentId: selectedThread?.environmentId ?? "", | ||
| 73 | cwd: selectedThread?.worktreePath ?? selectedThreadProject?.workspaceRoot ?? null, | ||
| 74 | }); | ||
| 75 | |||
| 76 | const handleRefreshGitStatus = useCallback(async () => { | ||
| 77 | if (!selectedThread) return; | ||
| 78 | await gitStatusManager.refresh({ | ||
| 79 | environmentId: selectedThread.environmentId, | ||
| 80 | cwd: selectedThread.worktreePath ?? selectedThreadProject?.workspaceRoot ?? null, | ||
| 81 | }); | ||
| 82 | }, [selectedThread, selectedThreadProject?.workspaceRoot]); | ||
| 83 | |||
| 84 | /** Wraps thread refresh + git status refresh for pull-to-refresh */ | ||
| 85 | const handleRefreshAll = useCallback(async () => { | ||
| 86 | await commands.onRefresh(); | ||
| 87 | await handleRefreshGitStatus(); | ||
| 88 | }, [commands, handleRefreshGitStatus]); | ||
| 89 | |||
| 60 | const routeThread = useMemo(() => { | 90 | const routeThread = useMemo(() => { |
| 61 | if (!environmentId || !threadId) { | 91 | if (!environmentId || !threadId) { |
| 62 | return null; | 92 | return null; |
| @@ -106,7 +136,7 @@ export function ThreadRouteScreen() { · 43 unmodified lines | |||
| 106 | paddingHorizontal: 24, | 136 | paddingHorizontal: 24, |
| 107 | paddingVertical: 32, | 137 | paddingVertical: 32, |
| 108 | }} | 138 | }} |
| 109 | style={{ flex: 1, backgroundColor: makeAppPalette(isDarkMode).screenBackground }} | 139 | className="bg-screen flex-1" |
| 110 | > | 140 | > |
| 111 | <EmptyState | 141 | <EmptyState |
| 112 | title="Thread unavailable" | 142 | title="Thread unavailable" |
| @@ -125,71 +155,117 @@ export function ThreadRouteScreen() { · 12 unmodified lines | |||
| 125 | selectedEnvironmentRuntime?.serverConfig ?? | 155 | selectedEnvironmentRuntime?.serverConfig ?? |
| 126 | firstNonNull(Object.values(environmentStateById).map((runtime) => runtime.serverConfig)); | 156 | firstNonNull(Object.values(environmentStateById).map((runtime) => runtime.serverConfig)); |
| 127 | 157 | ||
| 158 | const headerSubtitle = [screenTitle(serverConfig, null), selectedThread.environmentLabel] | ||
| 159 | .filter(Boolean) | ||
| 160 | .join(" · "); | ||
| 161 | |||
| 128 | return ( | 162 | return ( |
| 129 | <View style={{ flex: 1 }}> | 163 | <> |
| 130 | <ThreadDetailScreen | 164 | <Stack.Screen |
| 131 | selectedThread={selectedThread} | 165 | options={{ |
| 132 | screenTone={connectionTone(connectionState)} | 166 | headerShown: true, |
| 133 | connectionError={connectionError} | 167 | headerTransparent: true, |
| 134 | httpBaseUrl={selectedEnvironmentConnection?.httpBaseUrl ?? null} | 168 | headerStyle: { backgroundColor: "transparent" }, |
| 135 | bearerToken={selectedEnvironmentConnection?.bearerToken ?? null} | 169 | headerShadowVisible: false, |
| 136 | selectedThreadFeed={composer.selectedThreadFeed} | 170 | headerTintColor: iconColor, |
| 137 | activeWorkDurationLabel={composer.activeWorkDurationLabel} | 171 | headerBackTitle: "", |
| 138 | activePendingApproval={composer.activePendingApproval} | 172 | headerTitle: () => ( |
| 139 | respondingApprovalId={commands.respondingApprovalId} | 173 | <Pressable |
| 140 | activePendingUserInput={composer.activePendingUserInput} | 174 | style={{ alignItems: "center", maxWidth: 200 }} |
| 141 | activePendingUserInputDrafts={composer.activePendingUserInputDrafts} | 175 | onLongPress={() => { |
| 142 | activePendingUserInputAnswers={composer.activePendingUserInputAnswers} | 176 | // TODO: trigger rename modal |
| 143 | respondingUserInputId={commands.respondingUserInputId} | 177 | }} |
| 144 | draftMessage={composer.draftMessage} | 178 | > |
| 145 | draftAttachments={composer.draftAttachments} | 179 | <RNText |
| 146 | connectionStateLabel={connectionState} | 180 | numberOfLines={1} |
| 147 | activeThreadBusy={composer.activeThreadBusy} | 181 | style={{ |
| 148 | selectedThreadGitStatus={git.selectedThreadGitStatus} | 182 | fontFamily: "DMSans_700Bold", |
| 149 | gitOperationLabel={git.gitOperationLabel} | 183 | fontSize: 18, |
| 150 | selectedThreadQueueCount={composer.selectedThreadQueueCount} | 184 | fontWeight: "900", |
| 151 | onBack={() => dismissRoute(router)} | 185 | color: foregroundColor, |
| 152 | onOpenDrawer={() => setDrawerVisible(true)} | 186 | letterSpacing: -0.4, |
| 153 | onOpenConnectionEditor={() => router.push("/connections")} | 187 | }} |
| 154 | onChangeDraftMessage={composer.onChangeDraftMessage} | 188 | > |
| 155 | onPickDraftImages={composer.onPickDraftImages} | 189 | {selectedThread.title} |
| 156 | onNativePasteImages={composer.onNativePasteImages} | 190 | </RNText> |
| 157 | onRemoveDraftImage={composer.onRemoveDraftImage} | 191 | <RNText |
| 158 | onRefresh={commands.onRefresh} | 192 | numberOfLines={1} |
| 159 | onRefreshSelectedThreadGitStatus={async (options) => { | 193 | style={{ |
| 160 | await git.refreshSelectedThreadGitStatus(options); | 194 | fontFamily: "DMSans_700Bold", |
| 195 | fontSize: 12, | ||
| 196 | fontWeight: "700", | ||
| 197 | color: secondaryFg, | ||
| 198 | letterSpacing: 0.3, | ||
| 199 | }} | ||
| 200 | > | ||
| 201 | {headerSubtitle} | ||
| 202 | </RNText> | ||
| 203 | </Pressable> | ||
| 204 | ), | ||
| 161 | }} | 205 | }} |
| 162 | onListSelectedThreadBranches={git.onListSelectedThreadBranches} | ||
| 163 | onCheckoutSelectedThreadBranch={git.onCheckoutSelectedThreadBranch} | ||
| 164 | onCreateSelectedThreadBranch={git.onCreateSelectedThreadBranch} | ||
| 165 | onCreateSelectedThreadWorktree={git.onCreateSelectedThreadWorktree} | ||
| 166 | onPullSelectedThreadBranch={git.onPullSelectedThreadBranch} | ||
| 167 | onRunSelectedThreadGitAction={git.onRunSelectedThreadGitAction} | ||
| 168 | serverConfig={serverConfig} | ||
| 169 | onRenameThread={commands.onRenameThread} | ||
| 170 | onStopThread={commands.onStopThread} | ||
| 171 | onSendMessage={composer.onSendMessage} | ||
| 172 | onUpdateThreadModelSelection={commands.onUpdateThreadModelSelection} | ||
| 173 | onUpdateThreadRuntimeMode={commands.onUpdateThreadRuntimeMode} | ||
| 174 | onUpdateThreadInteractionMode={commands.onUpdateThreadInteractionMode} | ||
| 175 | onRespondToApproval={commands.onRespondToApproval} | ||
| 176 | onSelectUserInputOption={composer.onSelectUserInputOption} | ||
| 177 | onChangeUserInputCustomAnswer={composer.onChangeUserInputCustomAnswer} | ||
| 178 | onSubmitUserInput={commands.onSubmitUserInput} | ||
| 179 | /> | 206 | /> |
| 180 | 207 | ||
| 181 | <ThreadNavigationDrawer | 208 | <ThreadGitControls |
| 182 | visible={drawerVisible} | 209 | currentBranch={selectedThread.branch} |
| 183 | projects={projects} | 210 | gitStatus={gitStatus.data} |
| 184 | threads={threads} | 211 | gitOperationLabel={gitState.gitOperationLabel} |
| 185 | selectedThreadKey={selectedThreadKey} | 212 | onPull={gitActions.onPullSelectedThreadBranch} |
| 186 | onClose={() => setDrawerVisible(false)} | 213 | onRunAction={gitActions.onRunSelectedThreadGitAction} |
| 187 | onSelectThread={(thread) => { | ||
| 188 | onSelectThread(thread); | ||
| 189 | router.replace(buildThreadRoutePath(thread)); | ||
| 190 | }} | ||
| 191 | onStartNewTask={() => router.push("/new")} | ||
| 192 | /> | 214 | /> |
| 193 | </View> | 215 | |
| 216 | <View className="flex-1 bg-screen"> | ||
| 217 | <ThreadDetailScreen | ||
| 218 | selectedThread={selectedThread} | ||
| 219 | screenTone={connectionTone(connectionState)} | ||
| 220 | connectionError={connectionError} | ||
| 221 | httpBaseUrl={selectedEnvironmentConnection?.httpBaseUrl ?? null} | ||
| 222 | bearerToken={selectedEnvironmentConnection?.bearerToken ?? null} | ||
| 223 | selectedThreadFeed={composer.selectedThreadFeed} | ||
| 224 | activeWorkDurationLabel={composer.activeWorkDurationLabel} | ||
| 225 | activePendingApproval={composer.activePendingApproval} | ||
| 226 | respondingApprovalId={commands.respondingApprovalId} | ||
| 227 | activePendingUserInput={composer.activePendingUserInput} | ||
| 228 | activePendingUserInputDrafts={composer.activePendingUserInputDrafts} | ||
| 229 | activePendingUserInputAnswers={composer.activePendingUserInputAnswers} | ||
| 230 | respondingUserInputId={commands.respondingUserInputId} | ||
| 231 | draftMessage={composer.draftMessage} | ||
| 232 | draftAttachments={composer.draftAttachments} | ||
| 233 | connectionStateLabel={connectionState} | ||
| 234 | activeThreadBusy={composer.activeThreadBusy} | ||
| 235 | projectWorkspaceRoot={selectedThreadProject?.workspaceRoot ?? null} | ||
| 236 | selectedThreadQueueCount={composer.selectedThreadQueueCount} | ||
| 237 | onOpenDrawer={() => setDrawerVisible(true)} | ||
| 238 | onOpenConnectionEditor={() => router.push("/connections")} | ||
| 239 | onChangeDraftMessage={composer.onChangeDraftMessage} | ||
| 240 | onPickDraftImages={composer.onPickDraftImages} | ||
| 241 | onNativePasteImages={composer.onNativePasteImages} | ||
| 242 | onRemoveDraftImage={composer.onRemoveDraftImage} | ||
| 243 | onRefresh={handleRefreshAll} | ||
| 244 | serverConfig={serverConfig} | ||
| 245 | onStopThread={commands.onStopThread} | ||
| 246 | onSendMessage={composer.onSendMessage} | ||
| 247 | onUpdateThreadModelSelection={commands.onUpdateThreadModelSelection} | ||
| 248 | onUpdateThreadRuntimeMode={commands.onUpdateThreadRuntimeMode} | ||
| 249 | onUpdateThreadInteractionMode={commands.onUpdateThreadInteractionMode} | ||
| 250 | onRespondToApproval={commands.onRespondToApproval} | ||
| 251 | onSelectUserInputOption={composer.onSelectUserInputOption} | ||
| 252 | onChangeUserInputCustomAnswer={composer.onChangeUserInputCustomAnswer} | ||
| 253 | onSubmitUserInput={commands.onSubmitUserInput} | ||
| 254 | /> | ||
| 255 | |||
| 256 | <ThreadNavigationDrawer | ||
| 257 | visible={drawerVisible} | ||
| 258 | projects={projects} | ||
| 259 | threads={threads} | ||
| 260 | selectedThreadKey={selectedThreadKey} | ||
| 261 | onClose={() => setDrawerVisible(false)} | ||
| 262 | onSelectThread={(thread) => { | ||
| 263 | onSelectThread(thread); | ||
| 264 | router.replace(buildThreadRoutePath(thread)); | ||
| 265 | }} | ||
| 266 | onStartNewTask={() => router.push("/new")} | ||
| 267 | /> | ||
| 268 | </View> | ||
| 269 | </> | ||
| 194 | ); | 270 | ); |
| 195 | } | 271 | } |
| @@ -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 | import { groupProjectsByRepository } from "../../lib/repositoryGroups"; | 15 | import { groupProjectsByRepository } from "../../lib/repositoryGroups"; |
| 16 | import type { ScopedMobileProject } from "../../lib/scopedEntities"; | 16 | import type { ScopedMobileProject } from "../../lib/scopedEntities"; |
| 17 | import { scopedProjectKey } from "../../lib/scopedEntities"; | 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 | import { useRemoteCatalog } from "../../state/use-remote-catalog"; | 20 | import { useRemoteCatalog } from "../../state/use-remote-catalog"; |
| 20 | 21 | ||
| 21 | export type { ModelOption, ProviderGroup }; | 22 | export type { ModelOption, ProviderGroup }; |
| @@ -110,7 +111,9 @@ const NewTaskFlowContext = React.createContext<NewTaskFlowContextValue | null>(n · 88 unmodified lines | |||
| 110 | 111 | ||
| 111 | export function NewTaskFlowProvider(props: React.PropsWithChildren) { | 112 | export function NewTaskFlowProvider(props: React.PropsWithChildren) { |
| 112 | const { projects, serverConfigByEnvironmentId, threads } = useRemoteCatalog(); | 113 | const { projects, serverConfigByEnvironmentId, threads } = useRemoteCatalog(); |
| 113 | const { onListProjectBranches } = useProjectActions(); | 114 | const setPendingConnectionError = useRemoteEnvironmentStore( |
| 115 | (state) => state.setPendingConnectionError, | ||
| 116 | ); | ||
| 114 | 117 | ||
| 115 | const repositoryGroups = useMemo( | 118 | const repositoryGroups = useMemo( |
| 116 | () => groupProjectsByRepository({ projects, threads }), | 119 | () => groupProjectsByRepository({ projects, threads }), |
| @@ -142,8 +145,6 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) { · 25 unmodified lines | |||
| 142 | const [attachments, setAttachments] = useState<ReadonlyArray<DraftComposerImageAttachment>>([]); | 145 | const [attachments, setAttachments] = useState<ReadonlyArray<DraftComposerImageAttachment>>([]); |
| 143 | const [submitting, setSubmitting] = useState(false); | 146 | const [submitting, setSubmitting] = useState(false); |
| 144 | const [branchQuery, setBranchQuery] = useState(""); | 147 | const [branchQuery, setBranchQuery] = useState(""); |
| 145 | const [branchesLoading, setBranchesLoading] = useState(false); | ||
| 146 | const [availableBranches, setAvailableBranches] = useState<ReadonlyArray<GitBranch>>([]); | ||
| 147 | const [runtimeMode, setRuntimeMode] = useState<RuntimeMode>(DEFAULT_RUNTIME_MODE); | 148 | const [runtimeMode, setRuntimeMode] = useState<RuntimeMode>(DEFAULT_RUNTIME_MODE); |
| 148 | const [interactionMode, setInteractionMode] = useState<ProviderInteractionMode>( | 149 | const [interactionMode, setInteractionMode] = useState<ProviderInteractionMode>( |
| 149 | DEFAULT_PROVIDER_INTERACTION_MODE, | 150 | DEFAULT_PROVIDER_INTERACTION_MODE, |
| @@ -190,8 +191,6 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) { · 40 unmodified lines | |||
| 190 | clearAttachments(); | 191 | clearAttachments(); |
| 191 | setSubmitting(false); | 192 | setSubmitting(false); |
| 192 | setBranchQuery(""); | 193 | setBranchQuery(""); |
| 193 | setBranchesLoading(false); | ||
| 194 | setAvailableBranches([]); | ||
| 195 | setRuntimeMode(DEFAULT_RUNTIME_MODE); | 194 | setRuntimeMode(DEFAULT_RUNTIME_MODE); |
| 196 | setInteractionMode(DEFAULT_PROVIDER_INTERACTION_MODE); | 195 | setInteractionMode(DEFAULT_PROVIDER_INTERACTION_MODE); |
| 197 | setEffort("high"); | 196 | setEffort("high"); |
| @@ -262,6 +261,20 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) { · 64 unmodified lines | |||
| 262 | ) ?? null; | 261 | ) ?? null; |
| 263 | 262 | ||
| 264 | const providerGroups = useMemo(() => groupByProvider(modelOptions), [modelOptions]); | 263 | const providerGroups = useMemo(() => groupByProvider(modelOptions), [modelOptions]); |
| 264 | const branchTarget = useMemo( | ||
| 265 | () => ({ | ||
| 266 | environmentId: selectedProject?.environmentId ?? null, | ||
| 267 | cwd: selectedProject?.workspaceRoot ?? null, | ||
| 268 | query: null, | ||
| 269 | }), | ||
| 270 | [selectedProject?.environmentId, selectedProject?.workspaceRoot], | ||
| 271 | ); | ||
| 272 | const branchState = useGitBranches(branchTarget); | ||
| 273 | const branchesLoading = branchState.isPending; | ||
| 274 | const availableBranches = useMemo( | ||
| 275 | () => (branchState.data?.branches ?? []).filter((branch) => !branch.isRemote), | ||
| 276 | [branchState.data?.branches], | ||
| 277 | ); | ||
| 265 | 278 | ||
| 266 | const filteredBranches = useMemo(() => { | 279 | const filteredBranches = useMemo(() => { |
| 267 | const query = branchQuery.trim().toLowerCase(); | 280 | const query = branchQuery.trim().toLowerCase(); |
| @@ -299,10 +312,14 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) { · 31 unmodified lines | |||
| 299 | return; | 312 | return; |
| 300 | } | 313 | } |
| 301 | 314 | ||
| 302 | setBranchesLoading(true); | ||
| 303 | try { | 315 | try { |
| 304 | const branches = await onListProjectBranches(selectedProject); | 316 | const result = await gitBranchManager.load({ |
| 305 | setAvailableBranches(branches); | 317 | environmentId: selectedProject.environmentId, |
| 318 | cwd: selectedProject.workspaceRoot, | ||
| 319 | query: null, | ||
| 320 | }); | ||
| 321 | setPendingConnectionError(null); | ||
| 322 | const branches = (result?.branches ?? []).filter((branch) => !branch.isRemote); | ||
| 306 | 323 | ||
| 307 | if (workspaceMode === "worktree" && !selectedBranchName) { | 324 | if (workspaceMode === "worktree" && !selectedBranchName) { |
| 308 | const preferredBranch = | 325 | const preferredBranch = |
| @@ -313,10 +330,10 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) { · 4 unmodified lines | |||
| 313 | setSelectedBranchName(preferredBranch); | 330 | setSelectedBranchName(preferredBranch); |
| 314 | } | 331 | } |
| 315 | } | 332 | } |
| 316 | } finally { | 333 | } catch { |
| 317 | setBranchesLoading(false); | 334 | setPendingConnectionError("Failed to load branches."); |
| 318 | } | 335 | } |
| 319 | }, [onListProjectBranches, selectedBranchName, selectedProject, workspaceMode]); | 336 | }, [selectedBranchName, selectedProject, setPendingConnectionError, workspaceMode]); |
| 320 | 337 | ||
| 321 | const value = useMemo<NewTaskFlowContextValue>( | 338 | const value = useMemo<NewTaskFlowContextValue>( |
| 322 | () => ({ | 339 | () => ({ |
| @@ -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 | PROVIDER_SEND_TURN_MAX_IMAGE_BYTES, | 3 | PROVIDER_SEND_TURN_MAX_IMAGE_BYTES, |
| 4 | type UploadChatImageAttachment, | 4 | type UploadChatImageAttachment, |
| 5 | } from "@t3tools/contracts"; | 5 | } from "@t3tools/contracts"; |
| 6 | 6 | import { uuidv4 } from "./uuid"; | |
| 7 | import { newClientId } from "./clientId"; | ||
| 8 | 7 | ||
| 9 | export interface DraftComposerImageAttachment extends UploadChatImageAttachment { | 8 | export interface DraftComposerImageAttachment extends UploadChatImageAttachment { |
| 10 | readonly id: string; | 9 | readonly id: string; |
| @@ -101,7 +100,7 @@ export async function pickComposerImages(input: { readonly existingCount: number · 90 unmodified lines | |||
| 101 | } | 100 | } |
| 102 | 101 | ||
| 103 | nextImages.push({ | 102 | nextImages.push({ |
| 104 | id: newClientId("attachment"), | 103 | id: uuidv4(), |
| 105 | type: "image", | 104 | type: "image", |
| 106 | name: asset.fileName ?? "image", | 105 | name: asset.fileName ?? "image", |
| 107 | mimeType, | 106 | mimeType, |
| @@ -165,7 +164,7 @@ export async function pasteComposerClipboard(input: { readonly existingCount: nu · 57 unmodified lines | |||
| 165 | return { | 164 | return { |
| 166 | images: [ | 165 | images: [ |
| 167 | { | 166 | { |
| 168 | id: newClientId("attachment"), | 167 | id: uuidv4(), |
| 169 | type: "image", | 168 | type: "image", |
| 170 | name: "pasted-image.png", | 169 | name: "pasted-image.png", |
| 171 | mimeType: "image/png", | 170 | mimeType: "image/png", |
| @@ -233,7 +232,7 @@ export async function convertPastedImagesToAttachments(input: { · 61 unmodified lines | |||
| 233 | } | 232 | } |
| 234 | const mimeType = mimeTypeFromUri(uri); | 233 | const mimeType = mimeTypeFromUri(uri); |
| 235 | results.push({ | 234 | results.push({ |
| 236 | id: newClientId("attachment"), | 235 | id: uuidv4(), |
| 237 | type: "image", | 236 | type: "image", |
| 238 | name: `pasted-image.${mimeType.split("/")[1] ?? "png"}`, | 237 | name: `pasted-image.${mimeType.split("/")[1] ?? "png"}`, |
| 239 | mimeType, | 238 | mimeType, |
| @@ -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 | import type { SavedRemoteConnection } from "./connection"; | 3 | import type { SavedRemoteConnection } from "./connection"; |
| 4 | 4 | ||
| 5 | const CONNECTIONS_KEY = "t3code:connections"; | 5 | const CONNECTIONS_KEY = "t3code.connections"; |
| 6 | |||
| 7 | type SavedRemoteConnectionMetadata = Omit<SavedRemoteConnection, "bearerToken">; | ||
| 8 | 6 | ||
| 9 | async function readStorageItem(key: string): Promise<string | null> { | 7 | async function readStorageItem(key: string): Promise<string | null> { |
| 10 | return await SecureStore.getItemAsync(key); | 8 | return await SecureStore.getItemAsync(key); |
| @@ -14,31 +12,7 @@ async function writeStorageItem(key: string, value: string): Promise<void> { · 3 unmodified lines | |||
| 14 | await SecureStore.setItemAsync(key, value); | 12 | await SecureStore.setItemAsync(key, value); |
| 15 | } | 13 | } |
| 16 | 14 | ||
| 17 | async function removeStorageItem(key: string): Promise<void> { | 15 | export async function loadSavedConnections(): Promise<ReadonlyArray<SavedRemoteConnection>> { |
| 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 | > { | ||
| 42 | const raw = (await readStorageItem(CONNECTIONS_KEY)) ?? ""; | 16 | const raw = (await readStorageItem(CONNECTIONS_KEY)) ?? ""; |
| 43 | if (!raw.trim()) { | 17 | if (!raw.trim()) { |
| 44 | return []; | 18 | return []; |
| @@ -46,64 +20,27 @@ async function loadSavedConnectionMetadata(): Promise< · 1 unmodified line | |||
| 46 | 20 | ||
| 47 | try { | 21 | try { |
| 48 | const parsed = JSON.parse(raw) as { | 22 | const parsed = JSON.parse(raw) as { |
| 49 | readonly connections?: ReadonlyArray<SavedRemoteConnectionMetadata>; | 23 | readonly connections?: ReadonlyArray<SavedRemoteConnection>; |
| 50 | }; | 24 | }; |
| 51 | return parsed.connections ?? []; | 25 | return (parsed.connections ?? []).filter((c) => c.environmentId && c.bearerToken?.trim()); |
| 52 | } catch { | 26 | } catch { |
| 53 | return []; | 27 | return []; |
| 54 | } | 28 | } |
| 55 | } | 29 | } |
| 56 | 30 | ||
| 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 | export async function saveConnection(connection: SavedRemoteConnection): Promise<void> { | 31 | export async function saveConnection(connection: SavedRemoteConnection): Promise<void> { |
| 82 | const current = await loadSavedConnectionMetadata(); | 32 | const current = await loadSavedConnections(); |
| 83 | const nextConnection: SavedRemoteConnectionMetadata = { | ||
| 84 | environmentId: connection.environmentId, | ||
| 85 | environmentLabel: connection.environmentLabel, | ||
| 86 | pairingUrl: connection.pairingUrl.trim(), | ||
| 87 | displayUrl: connection.displayUrl.trim(), | ||
| 88 | httpBaseUrl: connection.httpBaseUrl.trim(), | ||
| 89 | wsBaseUrl: connection.wsBaseUrl.trim(), | ||
| 90 | }; | ||
| 91 | const next = current.some((entry) => entry.environmentId === connection.environmentId) | 33 | const next = current.some((entry) => entry.environmentId === connection.environmentId) |
| 92 | ? current.map((entry) => | 34 | ? current.map((entry) => |
| 93 | entry.environmentId === connection.environmentId ? nextConnection : entry, | 35 | entry.environmentId === connection.environmentId ? connection : entry, |
| 94 | ) | 36 | ) |
| 95 | : [...current, nextConnection]; | 37 | : [...current, connection]; |
| 96 | 38 | ||
| 97 | await Promise.all([ | 39 | await writeStorageItem(CONNECTIONS_KEY, JSON.stringify({ connections: next })); |
| 98 | saveSavedConnectionMetadata(next), | ||
| 99 | storeSecureToken(connection.environmentId, connection.bearerToken.trim()), | ||
| 100 | ]); | ||
| 101 | } | 40 | } |
| 102 | 41 | ||
| 103 | export async function clearSavedConnection(environmentId: string): Promise<void> { | 42 | export async function clearSavedConnection(environmentId: string): Promise<void> { |
| 104 | const current = await loadSavedConnectionMetadata(); | 43 | const current = await loadSavedConnections(); |
| 105 | await Promise.all([ | 44 | const next = current.filter((entry) => entry.environmentId !== environmentId); |
| 106 | saveSavedConnectionMetadata(current.filter((entry) => entry.environmentId !== environmentId)), | 45 | await writeStorageItem(CONNECTIONS_KEY, JSON.stringify({ connections: next })); |
| 107 | storeSecureToken(environmentId, ""), | ||
| 108 | ]); | ||
| 109 | } | 46 | } |
| @@ -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 | import type { | 1 | import type { |
| 2 | ApprovalRequestId, | 2 | ApprovalRequestId, |
| 3 | CommandId, | ||
| 4 | MessageId, | ||
| 3 | OrchestrationThread, | 5 | OrchestrationThread, |
| 4 | OrchestrationThreadActivity, | 6 | OrchestrationThreadActivity, |
| 5 | ThreadId, | 7 | ThreadId, |
| @@ -29,11 +31,10 @@ export interface PendingUserInputDraftAnswer { · 23 unmodified lines | |||
| 29 | } | 31 | } |
| 30 | 32 | ||
| 31 | export interface QueuedThreadMessage { | 33 | export interface QueuedThreadMessage { |
| 32 | readonly id: string; | ||
| 33 | readonly environmentId: string; | 34 | readonly environmentId: string; |
| 34 | readonly threadId: ThreadId; | 35 | readonly threadId: ThreadId; |
| 35 | readonly messageId: string; | 36 | readonly messageId: MessageId; |
| 36 | readonly commandId: string; | 37 | readonly commandId: CommandId; |
| 37 | readonly text: string; | 38 | readonly text: string; |
| 38 | readonly attachments: ReadonlyArray<DraftComposerImageAttachment>; | 39 | readonly attachments: ReadonlyArray<DraftComposerImageAttachment>; |
| 39 | readonly createdAt: string; | 40 | readonly createdAt: string; |
| @@ -380,7 +381,7 @@ export function buildPendingUserInputAnswers( · 340 unmodified lines | |||
| 380 | export function buildThreadFeed( | 381 | export function buildThreadFeed( |
| 381 | thread: OrchestrationThread, | 382 | thread: OrchestrationThread, |
| 382 | queuedMessages: ReadonlyArray<QueuedThreadMessage>, | 383 | queuedMessages: ReadonlyArray<QueuedThreadMessage>, |
| 383 | dispatchingQueuedMessageId: string | null, | 384 | dispatchingQueuedMessageId: MessageId | null, |
| 384 | options?: { | 385 | options?: { |
| 385 | readonly loadedMessages?: ReadonlyArray<OrchestrationThread["messages"][number]>; | 386 | readonly loadedMessages?: ReadonlyArray<OrchestrationThread["messages"][number]>; |
| 386 | }, | 387 | }, |
| @@ -398,10 +399,10 @@ export function buildThreadFeed( · 11 unmodified lines | |||
| 398 | })), | 399 | })), |
| 399 | ...queuedMessages.map<RawThreadFeedEntry>((queuedMessage) => ({ | 400 | ...queuedMessages.map<RawThreadFeedEntry>((queuedMessage) => ({ |
| 400 | type: "queued-message", | 401 | type: "queued-message", |
| 401 | id: queuedMessage.id, | 402 | id: queuedMessage.messageId, |
| 402 | createdAt: queuedMessage.createdAt, | 403 | createdAt: queuedMessage.createdAt, |
| 403 | queuedMessage, | 404 | queuedMessage, |
| 404 | sending: queuedMessage.id === dispatchingQueuedMessageId, | 405 | sending: queuedMessage.messageId === dispatchingQueuedMessageId, |
| 405 | })), | 406 | })), |
| 406 | ...thread.activities | 407 | ...thread.activities |
| 407 | .filter((activity) => { | 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 | import { useStore } from "zustand"; | 2 | import { useStore } from "zustand"; |
| 3 | import { createStore } from "zustand/vanilla"; | 3 | import { createStore } from "zustand/vanilla"; |
| 4 | 4 | ||
| @@ -10,7 +10,7 @@ interface ThreadComposerStoreState { · 5 unmodified lines | |||
| 10 | readonly nowTick: number; | 10 | readonly nowTick: number; |
| 11 | readonly draftMessageByThreadKey: Record<string, string>; | 11 | readonly draftMessageByThreadKey: Record<string, string>; |
| 12 | readonly draftAttachmentsByThreadKey: Record<string, ReadonlyArray<DraftComposerImageAttachment>>; | 12 | readonly draftAttachmentsByThreadKey: Record<string, ReadonlyArray<DraftComposerImageAttachment>>; |
| 13 | readonly dispatchingQueuedMessageId: string | null; | 13 | readonly dispatchingQueuedMessageId: MessageId | null; |
| 14 | readonly queuedMessagesByThreadKey: Record<string, ReadonlyArray<QueuedThreadMessage>>; | 14 | readonly queuedMessagesByThreadKey: Record<string, ReadonlyArray<QueuedThreadMessage>>; |
| 15 | 15 | ||
| 16 | readonly setNowTick: (tick: number) => void; | 16 | readonly setNowTick: (tick: number) => void; |
| @@ -22,13 +22,13 @@ interface ThreadComposerStoreState { · 5 unmodified lines | |||
| 22 | readonly appendDraftMessage: (threadKey: string, value: string) => void; | 22 | readonly appendDraftMessage: (threadKey: string, value: string) => void; |
| 23 | readonly clearDraft: (threadKey: string) => void; | 23 | readonly clearDraft: (threadKey: string) => void; |
| 24 | readonly removeDraftImage: (threadKey: string, imageId: string) => void; | 24 | readonly removeDraftImage: (threadKey: string, imageId: string) => void; |
| 25 | readonly beginDispatchingQueuedMessage: (queuedMessageId: string) => void; | 25 | readonly beginDispatchingQueuedMessage: (queuedMessageId: MessageId) => void; |
| 26 | readonly finishDispatchingQueuedMessage: (queuedMessageId: string) => void; | 26 | readonly finishDispatchingQueuedMessage: (queuedMessageId: MessageId) => void; |
| 27 | readonly enqueueQueuedMessage: (message: QueuedThreadMessage) => void; | 27 | readonly enqueueQueuedMessage: (message: QueuedThreadMessage) => void; |
| 28 | readonly removeQueuedMessage: ( | 28 | readonly removeQueuedMessage: ( |
| 29 | environmentId: string, | 29 | environmentId: string, |
| 30 | threadId: ThreadId, | 30 | threadId: ThreadId, |
| 31 | queuedMessageId: string, | 31 | queuedMessageId: MessageId, |
| 32 | ) => void; | 32 | ) => void; |
| 33 | } | 33 | } |
| 34 | 34 | ||
| @@ -107,7 +107,7 @@ export const threadComposerStore = createStore<ThreadComposerStoreState>()((set) · 72 unmodified lines | |||
| 107 | if (!existing) { | 107 | if (!existing) { |
| 108 | return state; | 108 | return state; |
| 109 | } | 109 | } |
| 110 | const nextQueue = existing.filter((entry) => entry.id !== queuedMessageId); | 110 | const nextQueue = existing.filter((entry) => entry.messageId !== queuedMessageId); |
| 111 | const next = { ...state.queuedMessagesByThreadKey }; | 111 | const next = { ...state.queuedMessagesByThreadKey }; |
| 112 | if (nextQueue.length === 0) { | 112 | if (nextQueue.length === 0) { |
| 113 | delete next[threadKey]; | 113 | delete next[threadKey]; |
| @@ -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 | import { sanitizeFeatureBranchName } from "@t3tools/shared/git"; | 14 | import { sanitizeFeatureBranchName } from "@t3tools/shared/git"; |
| 15 | 15 | ||
| 16 | import type { DraftComposerImageAttachment } from "../lib/composerImages"; | 16 | import type { DraftComposerImageAttachment } from "../lib/composerImages"; |
| 17 | import { newClientId } from "../lib/clientId"; | 17 | import { uuidv4 } from "../lib/uuid"; |
| 18 | import type { ScopedMobileProject } from "../lib/scopedEntities"; | 18 | import type { ScopedMobileProject } from "../lib/scopedEntities"; |
| 19 | import { buildTemporaryWorktreeBranchName } from "../lib/worktrees"; | 19 | import { buildTemporaryWorktreeBranchName } from "../lib/worktrees"; |
| 20 | import { deriveThreadTitleFromPrompt } from "./remote-runtime-types"; | 20 | import { deriveThreadTitleFromPrompt } from "./remote-runtime-types"; |
| 21 | import { useRemoteEnvironmentStore } from "./remote-environment-store"; | 21 | import { useRemoteEnvironmentStore } from "./remote-environment-store"; |
| 22 | import { gitBranchManager } from "./use-git-branches"; | ||
| 22 | import { useRemoteCatalog } from "./use-remote-catalog"; | 23 | import { useRemoteCatalog } from "./use-remote-catalog"; |
| 23 | import { getEnvironmentClient, useRemoteEnvironmentState } from "./use-remote-environment-registry"; | 24 | import { getEnvironmentClient, useRemoteEnvironmentState } from "./use-remote-environment-registry"; |
| 24 | import { useThreadSelectionStore } from "./thread-selection-store"; | 25 | import { useThreadSelectionStore } from "./thread-selection-store"; |
| @@ -90,7 +91,7 @@ export function useProjectActions() { · 65 unmodified lines | |||
| 90 | return null; | 91 | return null; |
| 91 | } | 92 | } |
| 92 | 93 | ||
| 93 | const threadId = ThreadId.makeUnsafe(newClientId("thread")); | 94 | const threadId = ThreadId.makeUnsafe(uuidv4()); |
| 94 | const createdAt = new Date().toISOString(); | 95 | const createdAt = new Date().toISOString(); |
| 95 | const initialMessageText = input.initialMessageText.trim(); | 96 | const initialMessageText = input.initialMessageText.trim(); |
| 96 | const nextTitle = deriveThreadTitleFromPrompt(input.initialMessageText); | 97 | const nextTitle = deriveThreadTitleFromPrompt(input.initialMessageText); |
| @@ -102,10 +103,10 @@ export function useProjectActions() { · 5 unmodified lines | |||
| 102 | 103 | ||
| 103 | await client.orchestration.dispatchCommand({ | 104 | await client.orchestration.dispatchCommand({ |
| 104 | type: "thread.turn.start", | 105 | type: "thread.turn.start", |
| 105 | commandId: CommandId.makeUnsafe(newClientId("command")), | 106 | commandId: CommandId.makeUnsafe(uuidv4()), |
| 106 | threadId, | 107 | threadId, |
| 107 | message: { | 108 | message: { |
| 108 | messageId: MessageId.makeUnsafe(newClientId("message")), | 109 | messageId: MessageId.makeUnsafe(uuidv4()), |
| 109 | role: "user", | 110 | role: "user", |
| 110 | text: initialMessageText, | 111 | text: initialMessageText, |
| 111 | attachments: input.initialAttachments, | 112 | attachments: input.initialAttachments, |
| @@ -137,7 +138,7 @@ export function useProjectActions() { · 25 unmodified lines | |||
| 137 | } else { | 138 | } else { |
| 138 | await client.orchestration.dispatchCommand({ | 139 | await client.orchestration.dispatchCommand({ |
| 139 | type: "thread.create", | 140 | type: "thread.create", |
| 140 | commandId: CommandId.makeUnsafe(newClientId("command")), | 141 | commandId: CommandId.makeUnsafe(uuidv4()), |
| 141 | threadId, | 142 | threadId, |
| 142 | projectId: input.project.id, | 143 | projectId: input.project.id, |
| 143 | title: nextTitle, | 144 | title: nextTitle, |
| @@ -152,10 +153,10 @@ export function useProjectActions() { · 8 unmodified lines | |||
| 152 | if (initialMessageText.length > 0 || input.initialAttachments.length > 0) { | 153 | if (initialMessageText.length > 0 || input.initialAttachments.length > 0) { |
| 153 | await client.orchestration.dispatchCommand({ | 154 | await client.orchestration.dispatchCommand({ |
| 154 | type: "thread.turn.start", | 155 | type: "thread.turn.start", |
| 155 | commandId: CommandId.makeUnsafe(newClientId("command")), | 156 | commandId: CommandId.makeUnsafe(uuidv4()), |
| 156 | threadId, | 157 | threadId, |
| 157 | message: { | 158 | message: { |
| 158 | messageId: MessageId.makeUnsafe(newClientId("message")), | 159 | messageId: MessageId.makeUnsafe(uuidv4()), |
| 159 | role: "user", | 160 | role: "user", |
| 160 | text: initialMessageText, | 161 | text: initialMessageText, |
| 161 | attachments: input.initialAttachments, | 162 | attachments: input.initialAttachments, |
| @@ -217,11 +218,12 @@ export function useProjectActions() { · 55 unmodified lines | |||
| 217 | } | 218 | } |
| 218 | 219 | ||
| 219 | try { | 220 | try { |
| 220 | const result = await client.git.listBranches({ | 221 | const result = await gitBranchManager.load( |
| 221 | cwd: project.workspaceRoot, | 222 | { environmentId: project.environmentId, cwd: project.workspaceRoot, query: null }, |
| 222 | limit: 100, | 223 | client.git, |
| 223 | }); | 224 | { limit: 100 }, |
| 224 | return result.branches.filter((branch) => !branch.isRemote); | 225 | ); |
| 226 | return (result?.branches ?? []).filter((branch) => !branch.isRemote); | ||
| 225 | } catch (error) { | 227 | } catch (error) { |
| 226 | setPendingConnectionError( | 228 | setPendingConnectionError( |
| 227 | error instanceof Error ? error.message : "Failed to load branches.", | 229 | error instanceof Error ? error.message : "Failed to load branches.", |
| @@ -255,6 +257,11 @@ export function useProjectActions() { · 27 unmodified lines | |||
| 255 | newBranch: sanitizeFeatureBranchName(nextWorktree.newBranch), | 257 | newBranch: sanitizeFeatureBranchName(nextWorktree.newBranch), |
| 256 | path: null, | 258 | path: null, |
| 257 | }); | 259 | }); |
| 260 | gitBranchManager.invalidate({ | ||
| 261 | environmentId: project.environmentId, | ||
| 262 | cwd: project.workspaceRoot, | ||
| 263 | query: null, | ||
| 264 | }); | ||
| 258 | return { | 265 | return { |
| 259 | branch: result.worktree.branch, | 266 | branch: result.worktree.branch, |
| 260 | worktreePath: result.worktree.path, | 267 | worktreePath: result.worktree.path, |
| @@ -1,8 +1,8 @@ | |||
| 1 | import { useMemo } from "react"; | 1 | import { useMemo } from "react"; |
| 2 | 2 | ||
| 3 | import type { ServerConfig as T3ServerConfig } from "@t3tools/contracts"; | 3 | import type { ServerConfig as T3ServerConfig } from "@t3tools/contracts"; |
| 4 | 4 | import * as Order from "effect/Order"; | |
| 5 | import { sortCopy } from "../lib/arrayCompat"; | 5 | import * as Arr from "effect/Array"; |
| 6 | import type { ScopedMobileProject, ScopedMobileThread } from "../lib/scopedEntities"; | 6 | import type { ScopedMobileProject, ScopedMobileThread } from "../lib/scopedEntities"; |
| 7 | import { | 7 | import { |
| 8 | deriveOverallConnectionState, | 8 | deriveOverallConnectionState, |
| @@ -13,13 +13,26 @@ import { · 4 unmodified lines | |||
| 13 | useRemoteEnvironmentState, | 13 | useRemoteEnvironmentState, |
| 14 | } from "./use-remote-environment-registry"; | 14 | } from "./use-remote-environment-registry"; |
| 15 | 15 | ||
| 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 | export function useRemoteCatalog() { | 29 | export function useRemoteCatalog() { |
| 17 | const { connectedEnvironments, connectionState } = useRemoteConnectionStatus(); | 30 | const { connectedEnvironments, connectionState } = useRemoteConnectionStatus(); |
| 18 | const { environmentStateById, savedConnectionsById } = useRemoteEnvironmentState(); | 31 | const { environmentStateById, savedConnectionsById } = useRemoteEnvironmentState(); |
| 19 | 32 | ||
| 20 | const projects = useMemo<ReadonlyArray<ScopedMobileProject>>( | 33 | const projects = useMemo<ReadonlyArray<ScopedMobileProject>>( |
| 21 | () => | 34 | () => |
| 22 | sortCopy( | 35 | Arr.sort( |
| 23 | Object.values(savedConnectionsById).flatMap((connection) => | 36 | Object.values(savedConnectionsById).flatMap((connection) => |
| 24 | (environmentStateById[connection.environmentId]?.snapshot?.projects ?? []) | 37 | (environmentStateById[connection.environmentId]?.snapshot?.projects ?? []) |
| 25 | .filter((project) => project.deletedAt === null) | 38 | .filter((project) => project.deletedAt === null) |
| @@ -30,16 +43,14 @@ export function useRemoteCatalog() { · 4 unmodified lines | |||
| 30 | }), | 43 | }), |
| 31 | ), | 44 | ), |
| 32 | ), | 45 | ), |
| 33 | (left, right) => | 46 | projectsSortOrder, |
| 34 | left.title.localeCompare(right.title) || | ||
| 35 | left.environmentLabel.localeCompare(right.environmentLabel), | ||
| 36 | ), | 47 | ), |
| 37 | [environmentStateById, savedConnectionsById], | 48 | [environmentStateById, savedConnectionsById], |
| 38 | ); | 49 | ); |
| 39 | 50 | ||
| 40 | const threads = useMemo<ReadonlyArray<ScopedMobileThread>>( | 51 | const threads = useMemo<ReadonlyArray<ScopedMobileThread>>( |
| 41 | () => | 52 | () => |
| 42 | sortCopy( | 53 | Arr.sort( |
| 43 | Object.values(savedConnectionsById).flatMap((connection) => | 54 | Object.values(savedConnectionsById).flatMap((connection) => |
| 44 | (environmentStateById[connection.environmentId]?.snapshot?.threads ?? []) | 55 | (environmentStateById[connection.environmentId]?.snapshot?.threads ?? []) |
| 45 | .filter((thread) => thread.deletedAt === null) | 56 | .filter((thread) => thread.deletedAt === null) |
| @@ -50,10 +61,7 @@ export function useRemoteCatalog() { · 4 unmodified lines | |||
| 50 | }), | 61 | }), |
| 51 | ), | 62 | ), |
| 52 | ), | 63 | ), |
| 53 | (left, right) => | 64 | threadsSortOrder, |
| 54 | new Date(right.updatedAt ?? right.createdAt).getTime() - | ||
| 55 | new Date(left.updatedAt ?? left.createdAt).getTime() || | ||
| 56 | right.environmentLabel.localeCompare(left.environmentLabel), | ||
| 57 | ), | 65 | ), |
| 58 | [environmentStateById, savedConnectionsById], | 66 | [environmentStateById, savedConnectionsById], |
| 59 | ); | 67 | ); |
| @@ -10,8 +10,8 @@ import { · 9 unmodified lines | |||
| 10 | import { EnvironmentId, type OrchestrationReadModel } from "@t3tools/contracts"; | 10 | import { EnvironmentId, type OrchestrationReadModel } from "@t3tools/contracts"; |
| 11 | import { resolveRemoteWebSocketConnectionUrl } from "@t3tools/shared/remote"; | 11 | import { resolveRemoteWebSocketConnectionUrl } from "@t3tools/shared/remote"; |
| 12 | import { useShallow } from "zustand/react/shallow"; | 12 | import { useShallow } from "zustand/react/shallow"; |
| 13 | 13 | import * as Arr from "effect/Array"; | |
| 14 | import { sortCopy } from "../lib/arrayCompat"; | 14 | import * as Order from "effect/Order"; |
| 15 | import { type SavedRemoteConnection, bootstrapRemoteConnection } from "../lib/connection"; | 15 | import { type SavedRemoteConnection, bootstrapRemoteConnection } from "../lib/connection"; |
| 16 | import { applyRealtimeEvent, requiresSnapshotRefresh } from "../lib/orchestration"; | 16 | import { applyRealtimeEvent, requiresSnapshotRefresh } from "../lib/orchestration"; |
| 17 | import { clearSavedConnection, loadSavedConnections, saveConnection } from "../lib/storage"; | 17 | import { clearSavedConnection, loadSavedConnections, saveConnection } from "../lib/storage"; |
| @@ -25,6 +25,22 @@ import { remoteEnvironmentStore, useRemoteEnvironmentStore } from "./remote-envi · 7 unmodified lines | |||
| 25 | import { useThreadSelectionStore } from "./thread-selection-store"; | 25 | import { useThreadSelectionStore } from "./thread-selection-store"; |
| 26 | 26 | ||
| 27 | const environmentSessions = new Map<string, EnvironmentSession>(); | 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 | function setEnvironmentConnectionStatus( | 45 | function setEnvironmentConnectionStatus( |
| 30 | environmentId: string, | 46 | environmentId: string, |
| @@ -48,6 +64,7 @@ export async function disconnectEnvironment( · 17 unmodified lines | |||
| 48 | ) { | 64 | ) { |
| 49 | const session = environmentSessions.get(environmentId); | 65 | const session = environmentSessions.get(environmentId); |
| 50 | environmentSessions.delete(environmentId); | 66 | environmentSessions.delete(environmentId); |
| 67 | notifyEnvironmentConnectionListeners(); | ||
| 51 | await session?.connection.dispose(); | 68 | await session?.connection.dispose(); |
| 52 | remoteEnvironmentStore.getState().removeEnvironmentRuntimeState(environmentId); | 69 | remoteEnvironmentStore.getState().removeEnvironmentRuntimeState(environmentId); |
| 53 | 70 | ||
| @@ -176,6 +193,7 @@ export async function connectSavedEnvironment( · 122 unmodified lines | |||
| 176 | client, | 193 | client, |
| 177 | connection: environmentConnection, | 194 | connection: environmentConnection, |
| 178 | }); | 195 | }); |
| 196 | notifyEnvironmentConnectionListeners(); | ||
| 179 | 197 | ||
| 180 | try { | 198 | try { |
| 181 | await environmentConnection.ensureBootstrapped(); | 199 | await environmentConnection.ensureBootstrapped(); |
| @@ -188,11 +206,15 @@ export async function connectSavedEnvironment( · 6 unmodified lines | |||
| 188 | } | 206 | } |
| 189 | } | 207 | } |
| 190 | 208 | ||
| 209 | const environmentsSortOrder = Order.make<ConnectedEnvironmentSummary>( | ||
| 210 | (left, right) => left.environmentLabel.localeCompare(right.environmentLabel) as -1 | 0 | 1, | ||
| 211 | ); | ||
| 212 | |||
| 191 | function deriveConnectedEnvironments( | 213 | function deriveConnectedEnvironments( |
| 192 | savedConnectionsById: Record<string, SavedRemoteConnection>, | 214 | savedConnectionsById: Record<string, SavedRemoteConnection>, |
| 193 | environmentStateById: ReturnType<typeof remoteEnvironmentStore.getState>["environmentStateById"], | 215 | environmentStateById: ReturnType<typeof remoteEnvironmentStore.getState>["environmentStateById"], |
| 194 | ): ReadonlyArray<ConnectedEnvironmentSummary> { | 216 | ): ReadonlyArray<ConnectedEnvironmentSummary> { |
| 195 | return sortCopy( | 217 | return Arr.sort( |
| 196 | Object.values(savedConnectionsById).map((connection) => { | 218 | Object.values(savedConnectionsById).map((connection) => { |
| 197 | const runtime = environmentStateById[connection.environmentId]; | 219 | const runtime = environmentStateById[connection.environmentId]; |
| 198 | return { | 220 | return { |
| @@ -203,7 +225,7 @@ function deriveConnectedEnvironments( · 4 unmodified lines | |||
| 203 | connectionError: runtime?.connectionError ?? null, | 225 | connectionError: runtime?.connectionError ?? null, |
| 204 | }; | 226 | }; |
| 205 | }), | 227 | }), |
| 206 | (left, right) => left.environmentLabel.localeCompare(right.environmentLabel), | 228 | environmentsSortOrder, |
| 207 | ); | 229 | ); |
| 208 | } | 230 | } |
| 209 | 231 | ||
| @@ -250,6 +272,7 @@ export function useRemoteEnvironmentBootstrap() { · 40 unmodified lines | |||
| 250 | void session.connection.dispose(); | 272 | void session.connection.dispose(); |
| 251 | } | 273 | } |
| 252 | environmentSessions.clear(); | 274 | environmentSessions.clear(); |
| 275 | notifyEnvironmentConnectionListeners(); | ||
| 253 | }; | 276 | }; |
| 254 | }, [replaceSavedConnections, setIsLoadingSavedConnection]); | 277 | }, [replaceSavedConnections, setIsLoadingSavedConnection]); |
| 255 | } | 278 | } |
| @@ -9,7 +9,7 @@ import { · 8 unmodified lines | |||
| 9 | type RuntimeMode, | 9 | type RuntimeMode, |
| 10 | } from "@t3tools/contracts"; | 10 | } from "@t3tools/contracts"; |
| 11 | 11 | ||
| 12 | import { newClientId } from "../lib/clientId"; | 12 | import { uuidv4 } from "../lib/uuid"; |
| 13 | import { useThreadSelection } from "./use-thread-selection"; | 13 | import { useThreadSelection } from "./use-thread-selection"; |
| 14 | import { useRemoteEnvironmentStore } from "./remote-environment-store"; | 14 | import { useRemoteEnvironmentStore } from "./remote-environment-store"; |
| 15 | import { getEnvironmentClient, useRemoteEnvironmentState } from "./use-remote-environment-registry"; | 15 | import { getEnvironmentClient, useRemoteEnvironmentState } from "./use-remote-environment-registry"; |
| @@ -102,7 +102,7 @@ export function useSelectedThreadCommands(input: { · 86 unmodified lines | |||
| 102 | 102 | ||
| 103 | await client.orchestration.dispatchCommand({ | 103 | await client.orchestration.dispatchCommand({ |
| 104 | type: "thread.meta.update", | 104 | type: "thread.meta.update", |
| 105 | commandId: CommandId.makeUnsafe(newClientId("command")), | 105 | commandId: CommandId.makeUnsafe(uuidv4()), |
| 106 | threadId: selectedThread.id, | 106 | threadId: selectedThread.id, |
| 107 | modelSelection, | 107 | modelSelection, |
| 108 | }); | 108 | }); |
| @@ -123,7 +123,7 @@ export function useSelectedThreadCommands(input: { · 14 unmodified lines | |||
| 123 | 123 | ||
| 124 | await client.orchestration.dispatchCommand({ | 124 | await client.orchestration.dispatchCommand({ |
| 125 | type: "thread.runtime-mode.set", | 125 | type: "thread.runtime-mode.set", |
| 126 | commandId: CommandId.makeUnsafe(newClientId("command")), | 126 | commandId: CommandId.makeUnsafe(uuidv4()), |
| 127 | threadId: selectedThread.id, | 127 | threadId: selectedThread.id, |
| 128 | runtimeMode, | 128 | runtimeMode, |
| 129 | createdAt: new Date().toISOString(), | 129 | createdAt: new Date().toISOString(), |
| @@ -145,7 +145,7 @@ export function useSelectedThreadCommands(input: { · 15 unmodified lines | |||
| 145 | 145 | ||
| 146 | await client.orchestration.dispatchCommand({ | 146 | await client.orchestration.dispatchCommand({ |
| 147 | type: "thread.interaction-mode.set", | 147 | type: "thread.interaction-mode.set", |
| 148 | commandId: CommandId.makeUnsafe(newClientId("command")), | 148 | commandId: CommandId.makeUnsafe(uuidv4()), |
| 149 | threadId: selectedThread.id, | 149 | threadId: selectedThread.id, |
| 150 | interactionMode, | 150 | interactionMode, |
| 151 | createdAt: new Date().toISOString(), | 151 | createdAt: new Date().toISOString(), |
| @@ -173,7 +173,7 @@ export function useSelectedThreadCommands(input: { · 21 unmodified lines | |||
| 173 | 173 | ||
| 174 | await client.orchestration.dispatchCommand({ | 174 | await client.orchestration.dispatchCommand({ |
| 175 | type: "thread.turn.interrupt", | 175 | type: "thread.turn.interrupt", |
| 176 | commandId: CommandId.makeUnsafe(newClientId("command")), | 176 | commandId: CommandId.makeUnsafe(uuidv4()), |
| 177 | threadId: selectedThread.id, | 177 | threadId: selectedThread.id, |
| 178 | ...(selectedThread.session?.activeTurnId | 178 | ...(selectedThread.session?.activeTurnId |
| 179 | ? { turnId: selectedThread.session.activeTurnId } | 179 | ? { turnId: selectedThread.session.activeTurnId } |
| @@ -200,7 +200,7 @@ export function useSelectedThreadCommands(input: { · 20 unmodified lines | |||
| 200 | 200 | ||
| 201 | await client.orchestration.dispatchCommand({ | 201 | await client.orchestration.dispatchCommand({ |
| 202 | type: "thread.meta.update", | 202 | type: "thread.meta.update", |
| 203 | commandId: CommandId.makeUnsafe(newClientId("command")), | 203 | commandId: CommandId.makeUnsafe(uuidv4()), |
| 204 | threadId: selectedThread.id, | 204 | threadId: selectedThread.id, |
| 205 | title: trimmed, | 205 | title: trimmed, |
| 206 | }); | 206 | }); |
| @@ -223,7 +223,7 @@ export function useSelectedThreadCommands(input: { · 16 unmodified lines | |||
| 223 | try { | 223 | try { |
| 224 | await client.orchestration.dispatchCommand({ | 224 | await client.orchestration.dispatchCommand({ |
| 225 | type: "thread.approval.respond", | 225 | type: "thread.approval.respond", |
| 226 | commandId: CommandId.makeUnsafe(newClientId("command")), | 226 | commandId: CommandId.makeUnsafe(uuidv4()), |
| 227 | threadId: selectedThread.id, | 227 | threadId: selectedThread.id, |
| 228 | requestId, | 228 | requestId, |
| 229 | decision, | 229 | decision, |
| @@ -250,7 +250,7 @@ export function useSelectedThreadCommands(input: { · 20 unmodified lines | |||
| 250 | try { | 250 | try { |
| 251 | await client.orchestration.dispatchCommand({ | 251 | await client.orchestration.dispatchCommand({ |
| 252 | type: "thread.user-input.respond", | 252 | type: "thread.user-input.respond", |
| 253 | commandId: CommandId.makeUnsafe(newClientId("command")), | 253 | commandId: CommandId.makeUnsafe(uuidv4()), |
| 254 | threadId: selectedThread.id, | 254 | threadId: selectedThread.id, |
| 255 | requestId: activePendingUserInput.requestId, | 255 | requestId: activePendingUserInput.requestId, |
| 256 | answers: activePendingUserInputAnswers, | 256 | answers: activePendingUserInputAnswers, |
| @@ -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 | derivePendingUserInputs, | 18 | derivePendingUserInputs, |
| 19 | type QueuedThreadMessage, | 19 | type QueuedThreadMessage, |
| 20 | } from "../lib/threadActivity"; | 20 | } from "../lib/threadActivity"; |
| 21 | import { newClientId } from "../lib/clientId"; | 21 | import { uuidv4 } from "../lib/uuid"; |
| 22 | import type { ConnectedEnvironmentSummary } from "./remote-runtime-types"; | 22 | import type { ConnectedEnvironmentSummary } from "./remote-runtime-types"; |
| 23 | import { useRemoteEnvironmentStore } from "./remote-environment-store"; | 23 | import { useRemoteEnvironmentStore } from "./remote-environment-store"; |
| 24 | import { getEnvironmentClient, useRemoteConnectionStatus } from "./use-remote-environment-registry"; | 24 | import { getEnvironmentClient, useRemoteConnectionStatus } from "./use-remote-environment-registry"; |
| @@ -238,7 +238,7 @@ export function useThreadComposerState() { · 213 unmodified lines | |||
| 238 | return; | 238 | return; |
| 239 | } | 239 | } |
| 240 | 240 | ||
| 241 | beginDispatchingQueuedMessage(queuedMessage.id); | 241 | beginDispatchingQueuedMessage(queuedMessage.messageId); |
| 242 | try { | 242 | try { |
| 243 | await client.orchestration.dispatchCommand({ | 243 | await client.orchestration.dispatchCommand({ |
| 244 | type: "thread.turn.start", | 244 | type: "thread.turn.start", |
| @@ -255,7 +255,11 @@ export function useThreadComposerState() { · 10 unmodified lines | |||
| 255 | createdAt: queuedMessage.createdAt, | 255 | createdAt: queuedMessage.createdAt, |
| 256 | }); | 256 | }); |
| 257 | 257 | ||
| 258 | removeQueuedMessage(queuedMessage.environmentId, queuedMessage.threadId, queuedMessage.id); | 258 | removeQueuedMessage( |
| 259 | queuedMessage.environmentId, | ||
| 260 | queuedMessage.threadId, | ||
| 261 | queuedMessage.messageId, | ||
| 262 | ); | ||
| 259 | patchEnvironmentRuntimeState(queuedMessage.environmentId, (runtime) => ({ | 263 | patchEnvironmentRuntimeState(queuedMessage.environmentId, (runtime) => ({ |
| 260 | ...runtime, | 264 | ...runtime, |
| 261 | snapshot: runtime.snapshot | 265 | snapshot: runtime.snapshot |
| @@ -269,12 +273,16 @@ export function useThreadComposerState() { · 7 unmodified lines | |||
| 269 | : runtime.snapshot, | 273 | : runtime.snapshot, |
| 270 | })); | 274 | })); |
| 271 | } catch (error) { | 275 | } catch (error) { |
| 272 | removeQueuedMessage(queuedMessage.environmentId, queuedMessage.threadId, queuedMessage.id); | 276 | removeQueuedMessage( |
| 277 | queuedMessage.environmentId, | ||
| 278 | queuedMessage.threadId, | ||
| 279 | queuedMessage.messageId, | ||
| 280 | ); | ||
| 273 | setPendingConnectionError( | 281 | setPendingConnectionError( |
| 274 | error instanceof Error ? error.message : "Failed to send message.", | 282 | error instanceof Error ? error.message : "Failed to send message.", |
| 275 | ); | 283 | ); |
| 276 | } finally { | 284 | } finally { |
| 277 | finishDispatchingQueuedMessage(queuedMessage.id); | 285 | finishDispatchingQueuedMessage(queuedMessage.messageId); |
| 278 | } | 286 | } |
| 279 | }, | 287 | }, |
| 280 | [ | 288 | [ |
| @@ -309,11 +317,10 @@ export function useThreadComposerState() { · 28 unmodified lines | |||
| 309 | 317 | ||
| 310 | const createdAt = new Date().toISOString(); | 318 | const createdAt = new Date().toISOString(); |
| 311 | enqueueQueuedMessage({ | 319 | enqueueQueuedMessage({ |
| 312 | id: newClientId("queued-message"), | ||
| 313 | environmentId: selectedThread.environmentId, | 320 | environmentId: selectedThread.environmentId, |
| 314 | threadId: selectedThread.id, | 321 | threadId: selectedThread.id, |
| 315 | messageId: newClientId("message"), | 322 | messageId: MessageId.makeUnsafe(uuidv4()), |
| 316 | commandId: newClientId("command"), | 323 | commandId: CommandId.makeUnsafe(uuidv4()), |
| 317 | text, | 324 | text, |
| 318 | attachments, | 325 | attachments, |
| 319 | createdAt, | 326 | createdAt, |
| @@ -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 | import { useAtomValue } from "@effect/atom-react"; | 1 | import { useAtomValue } from "@effect/atom-react"; |
| 2 | import { | 2 | import { |
| 3 | type EnvironmentId, | 3 | type GitStatusClient, |
| 4 | type GitManagerServiceError, | 4 | type GitStatusState, |
| 5 | type GitStatusResult, | 5 | type GitStatusTarget, |
| 6 | } from "@t3tools/contracts"; | 6 | EMPTY_GIT_STATUS_ATOM, |
| 7 | import { Cause } from "effect"; | 7 | EMPTY_GIT_STATUS_STATE, |
| 8 | import { Atom } from "effect/unstable/reactivity"; | 8 | createGitStatusManager, |
| 9 | getGitStatusTargetKey, | ||
| 10 | gitStatusStateAtom, | ||
| 11 | } from "@t3tools/client-runtime"; | ||
| 12 | import { type EnvironmentId } from "@t3tools/contracts"; | ||
| 9 | import { useEffect } from "react"; | 13 | import { useEffect } from "react"; |
| 10 | 14 | ||
| 11 | import { appAtomRegistry } from "../rpc/atomRegistry"; | 15 | import { appAtomRegistry } from "../rpc/atomRegistry"; |
| @@ -13,159 +17,38 @@ import { · 1 unmodified line | |||
| 13 | readEnvironmentConnection, | 17 | readEnvironmentConnection, |
| 14 | subscribeEnvironmentConnections, | 18 | subscribeEnvironmentConnections, |
| 15 | } from "../environments/runtime"; | 19 | } from "../environments/runtime"; |
| 16 | import type { WsRpcClient } from "~/rpc/wsRpcClient"; | ||
| 17 | 20 | ||
| 18 | interface GitStatusState { | 21 | export type { GitStatusState, GitStatusTarget }; |
| 19 | readonly data: GitStatusResult | null; | 22 | |
| 20 | readonly error: GitManagerServiceError | null; | 23 | /* ─── Manager singleton ─────────────────────────────────────────────── */ |
| 21 | readonly cause: Cause.Cause<GitManagerServiceError> | null; | 24 | |
| 22 | readonly isPending: boolean; | 25 | const manager = createGitStatusManager({ |
| 23 | } | 26 | getRegistry: () => appAtomRegistry, |
| 24 | 27 | getClient: (environmentId) => { | |
| 25 | type GitStatusClient = Pick<WsRpcClient["git"], "onStatus" | "refreshStatus">; | 28 | const connection = readEnvironmentConnection(environmentId as EnvironmentId); |
| 26 | interface ResolvedGitStatusClient { | 29 | return connection ? connection.client.git : null; |
| 27 | readonly clientIdentity: string; | 30 | }, |
| 28 | readonly client: GitStatusClient; | 31 | getClientIdentity: (environmentId) => { |
| 29 | } | 32 | const connection = readEnvironmentConnection(environmentId as EnvironmentId); |
| 30 | 33 | return connection ? connection.environmentId : null; | |
| 31 | interface WatchedGitStatus { | 34 | }, |
| 32 | refCount: number; | 35 | subscribeClientChanges: subscribeEnvironmentConnections, |
| 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 | ); | ||
| 70 | }); | 36 | }); |
| 71 | 37 | ||
| 72 | function getGitStatusTargetKey(target: GitStatusTarget): string | null { | 38 | /* ─── Public API (preserves existing call-sites) ────────────────────── */ |
| 73 | if (target.environmentId === null || target.cwd === null) { | ||
| 74 | return null; | ||
| 75 | } | ||
| 76 | |||
| 77 | return `${target.environmentId}:${target.cwd}`; | ||
| 78 | } | ||
| 79 | |||
| 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 | } | ||
| 132 | 39 | ||
| 133 | const currentInFlight = gitStatusRefreshInFlight.get(targetKey); | 40 | export function refreshGitStatus(target: GitStatusTarget, client?: GitStatusClient) { |
| 134 | if (currentInFlight) { | 41 | return manager.refresh(target, client); |
| 135 | return currentInFlight; | ||
| 136 | } | ||
| 137 | |||
| 138 | const lastRequestedAt = gitStatusLastRefreshAtByKey.get(targetKey) ?? 0; | ||
| 139 | if (Date.now() - lastRequestedAt < GIT_STATUS_REFRESH_DEBOUNCE_MS) { | ||
| 140 | return Promise.resolve(getGitStatusSnapshot(target).data); | ||
| 141 | } | ||
| 142 | |||
| 143 | gitStatusLastRefreshAtByKey.set(targetKey, Date.now()); | ||
| 144 | const refreshPromise = resolvedClient.refreshStatus({ cwd: target.cwd }).finally(() => { | ||
| 145 | gitStatusRefreshInFlight.delete(targetKey); | ||
| 146 | }); | ||
| 147 | gitStatusRefreshInFlight.set(targetKey, refreshPromise); | ||
| 148 | return refreshPromise; | ||
| 149 | } | 42 | } |
| 150 | 43 | ||
| 151 | export function resetGitStatusStateForTests(): void { | 44 | export function resetGitStatusStateForTests(): void { |
| 152 | for (const watched of watchedGitStatuses.values()) { | 45 | manager.reset(); |
| 153 | watched.unsubscribe(); | ||
| 154 | } | ||
| 155 | watchedGitStatuses.clear(); | ||
| 156 | gitStatusRefreshInFlight.clear(); | ||
| 157 | gitStatusLastRefreshAtByKey.clear(); | ||
| 158 | |||
| 159 | for (const key of knownGitStatusKeys) { | ||
| 160 | appAtomRegistry.set(gitStatusStateAtom(key), INITIAL_GIT_STATUS_STATE); | ||
| 161 | } | ||
| 162 | knownGitStatusKeys.clear(); | ||
| 163 | } | 46 | } |
| 164 | 47 | ||
| 165 | export function useGitStatus(target: GitStatusTarget): GitStatusState { | 48 | export function useGitStatus(target: GitStatusTarget): GitStatusState { |
| 166 | const targetKey = getGitStatusTargetKey(target); | 49 | const targetKey = getGitStatusTargetKey(target); |
| 167 | useEffect( | 50 | useEffect( |
| 168 | () => watchGitStatus({ environmentId: target.environmentId, cwd: target.cwd }), | 51 | () => manager.watch({ environmentId: target.environmentId, cwd: target.cwd }), |
| 169 | [target.environmentId, target.cwd], | 52 | [target.environmentId, target.cwd], |
| 170 | ); | 53 | ); |
| 171 | 54 | ||
| @@ -174,114 +57,3 @@ export function useGitStatus(target: GitStatusTarget): GitStatusState { · 2 unmodified lines | |||
| 174 | ); | 57 | ); |
| 175 | return targetKey === null ? EMPTY_GIT_STATUS_STATE : state; | 58 | return targetKey === null ? EMPTY_GIT_STATUS_STATE : state; |
| 176 | } | 59 | } |
| 177 | |||
| 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 | "version": "0.0.0", | 45 | "version": "0.0.0", |
| 46 | "dependencies": { | 46 | "dependencies": { |
| 47 | "@callstack/liquid-glass": "^0.7.1", | 47 | "@callstack/liquid-glass": "^0.7.1", |
| 48 | "@effect/atom-react": "catalog:", | ||
| 48 | "@expo-google-fonts/dm-sans": "^0.4.2", | 49 | "@expo-google-fonts/dm-sans": "^0.4.2", |
| 49 | "@gorhom/bottom-sheet": "^5", | 50 | "@gorhom/bottom-sheet": "^5", |
| 50 | "@legendapp/list": "^3.0.0-beta.44", | 51 | "@legendapp/list": "^3.0.0-beta.44", |
| @@ -54,6 +55,7 @@ · 3 unmodified lines | |||
| 54 | "@t3tools/client-runtime": "workspace:*", | 55 | "@t3tools/client-runtime": "workspace:*", |
| 55 | "@t3tools/contracts": "workspace:*", | 56 | "@t3tools/contracts": "workspace:*", |
| 56 | "@t3tools/shared": "workspace:*", | 57 | "@t3tools/shared": "workspace:*", |
| 58 | "clsx": "^2.1.1", | ||
| 57 | "effect": "catalog:", | 59 | "effect": "catalog:", |
| 58 | "expo": "~55.0.14", | 60 | "expo": "~55.0.14", |
| 59 | "expo-camera": "~55.0.15", | 61 | "expo-camera": "~55.0.15", |
| @@ -73,12 +75,10 @@ · 13 unmodified lines | |||
| 73 | "expo-splash-screen": "~55.0.13", | 75 | "expo-splash-screen": "~55.0.13", |
| 74 | "expo-symbols": "~55.0.5", | 76 | "expo-symbols": "~55.0.5", |
| 75 | "expo-updates": "~55.0.16", | 77 | "expo-updates": "~55.0.16", |
| 76 | "nativewind": "5.0.0-preview.3", | ||
| 77 | "punycode": "^2.3.1", | 78 | "punycode": "^2.3.1", |
| 78 | "react": "19.2.0", | 79 | "react": "19.2.0", |
| 79 | "react-dom": "19.2.0", | 80 | "react-dom": "19.2.0", |
| 80 | "react-native": "0.83.4", | 81 | "react-native": "0.83.4", |
| 81 | "react-native-css": "3.0.7", | ||
| 82 | "react-native-gesture-handler": "~2.30.0", | 82 | "react-native-gesture-handler": "~2.30.0", |
| 83 | "react-native-image-viewing": "^0.2.2", | 83 | "react-native-image-viewing": "^0.2.2", |
| 84 | "react-native-keyboard-controller": "1.20.7", | 84 | "react-native-keyboard-controller": "1.20.7", |
| @@ -88,14 +88,13 @@ · 3 unmodified lines | |||
| 88 | "react-native-screens": "~4.23.0", | 88 | "react-native-screens": "~4.23.0", |
| 89 | "react-native-svg": "15.15.3", | 89 | "react-native-svg": "15.15.3", |
| 90 | "react-native-worklets": "0.7.2", | 90 | "react-native-worklets": "0.7.2", |
| 91 | "tailwind-merge": "^3.5.0", | ||
| 92 | "uniwind": "^1.6.2", | ||
| 91 | "zustand": "^5.0.12", | 93 | "zustand": "^5.0.12", |
| 92 | }, | 94 | }, |
| 93 | "devDependencies": { | 95 | "devDependencies": { |
| 94 | "@tailwindcss/postcss": "4.1.12", | ||
| 95 | "@types/react": "~19.2.10", | 96 | "@types/react": "~19.2.10", |
| 96 | "babel-preset-expo": "~55.0.8", | 97 | "babel-preset-expo": "~55.0.8", |
| 97 | "lightningcss": "1.30.1", | ||
| 98 | "postcss": "^8.5.8", | ||
| 99 | "tailwindcss": "4.1.12", | 98 | "tailwindcss": "4.1.12", |
| 100 | "typescript": "~5.9.2", | 99 | "typescript": "~5.9.2", |
| 101 | }, | 100 | }, |
| @@ -272,8 +271,6 @@ · 170 unmodified lines | |||
| 272 | "vitest": "^4.0.0", | 271 | "vitest": "^4.0.0", |
| 273 | }, | 272 | }, |
| 274 | "packages": { | 273 | "packages": { |
| 275 | "@alloc/quick-lru": ["@alloc/quick-lru@5.2.0", "", {}, "sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw=="], | ||
| 276 | |||
| 277 | "@anthropic-ai/claude-agent-sdk": ["@anthropic-ai/claude-agent-sdk@0.2.104", "", { "dependencies": { "@anthropic-ai/sdk": "^0.81.0", "@modelcontextprotocol/sdk": "^1.29.0" }, "optionalDependencies": { "@img/sharp-darwin-arm64": "^0.34.2", "@img/sharp-darwin-x64": "^0.34.2", "@img/sharp-linux-arm": "^0.34.2", "@img/sharp-linux-arm64": "^0.34.2", "@img/sharp-linux-x64": "^0.34.2", "@img/sharp-linuxmusl-arm64": "^0.34.2", "@img/sharp-linuxmusl-x64": "^0.34.2", "@img/sharp-win32-arm64": "^0.34.2", "@img/sharp-win32-x64": "^0.34.2" }, "peerDependencies": { "zod": "^4.0.0" } }, "sha512-lVm+nS79r6WWlDnv5AgRzTtAlbP8O6M6kkWmDZAWE3nt9agmngxls9frJFvH55uzws2+6l0yyup/JYspfijkzw=="], | 274 | "@anthropic-ai/claude-agent-sdk": ["@anthropic-ai/claude-agent-sdk@0.2.104", "", { "dependencies": { "@anthropic-ai/sdk": "^0.81.0", "@modelcontextprotocol/sdk": "^1.29.0" }, "optionalDependencies": { "@img/sharp-darwin-arm64": "^0.34.2", "@img/sharp-darwin-x64": "^0.34.2", "@img/sharp-linux-arm": "^0.34.2", "@img/sharp-linux-arm64": "^0.34.2", "@img/sharp-linux-x64": "^0.34.2", "@img/sharp-linuxmusl-arm64": "^0.34.2", "@img/sharp-linuxmusl-x64": "^0.34.2", "@img/sharp-win32-arm64": "^0.34.2", "@img/sharp-win32-x64": "^0.34.2" }, "peerDependencies": { "zod": "^4.0.0" } }, "sha512-lVm+nS79r6WWlDnv5AgRzTtAlbP8O6M6kkWmDZAWE3nt9agmngxls9frJFvH55uzws2+6l0yyup/JYspfijkzw=="], |
| 278 | 275 | ||
| 279 | "@anthropic-ai/sdk": ["@anthropic-ai/sdk@0.81.0", "", { "dependencies": { "json-schema-to-ts": "^3.1.1" }, "peerDependencies": { "zod": "^3.25.0 || ^4.0.0" }, "optionalPeers": ["zod"], "bin": { "anthropic-ai-sdk": "bin/cli" } }, "sha512-D4K5PvEV6wPiRtVlVsJHIUhHAmOZ6IT/I9rKlTf84gR7GyyAurPJK7z9BOf/AZqC5d1DhYQGJNKRmV+q8dGhgw=="], | 276 | "@anthropic-ai/sdk": ["@anthropic-ai/sdk@0.81.0", "", { "dependencies": { "json-schema-to-ts": "^3.1.1" }, "peerDependencies": { "zod": "^3.25.0 || ^4.0.0" }, "optionalPeers": ["zod"], "bin": { "anthropic-ai-sdk": "bin/cli" } }, "sha512-D4K5PvEV6wPiRtVlVsJHIUhHAmOZ6IT/I9rKlTf84gR7GyyAurPJK7z9BOf/AZqC5d1DhYQGJNKRmV+q8dGhgw=="], |
| @@ -738,8 +735,6 @@ · 458 unmodified lines | |||
| 738 | 735 | ||
| 739 | "@ioredis/commands": ["@ioredis/commands@1.5.1", "", {}, "sha512-JH8ZL/ywcJyR9MmJ5BNqZllXNZQqQbnVZOqpPQqE1vHiFgAw4NHbvE0FOduNU8IX9babitBT46571OnPTT0Zcw=="], | 736 | "@ioredis/commands": ["@ioredis/commands@1.5.1", "", {}, "sha512-JH8ZL/ywcJyR9MmJ5BNqZllXNZQqQbnVZOqpPQqE1vHiFgAw4NHbvE0FOduNU8IX9babitBT46571OnPTT0Zcw=="], |
| 740 | 737 | ||
| 741 | "@isaacs/fs-minipass": ["@isaacs/fs-minipass@4.0.1", "", { "dependencies": { "minipass": "^7.0.4" } }, "sha512-wgm9Ehl2jpeqP3zw/7mo3kRHFp5MEDhqAdwy1fTGkHAwnkGOVsgpvQhL8B5n1qlb01jV3n/bI0ZfZp5lWA1k4w=="], | ||
| 742 | |||
| 743 | "@isaacs/ttlcache": ["@isaacs/ttlcache@1.4.1", "", {}, "sha512-RQgQ4uQ+pLbqXfOmieB91ejmLwvSgv9nLx6sT6sD83s7umBypgg+OIBOBbEUiJXrfpnp9j0mRhYYdzp9uqq3lA=="], | 738 | "@isaacs/ttlcache": ["@isaacs/ttlcache@1.4.1", "", {}, "sha512-RQgQ4uQ+pLbqXfOmieB91ejmLwvSgv9nLx6sT6sD83s7umBypgg+OIBOBbEUiJXrfpnp9j0mRhYYdzp9uqq3lA=="], |
| 744 | 739 | ||
| 745 | "@istanbuljs/load-nyc-config": ["@istanbuljs/load-nyc-config@1.1.0", "", { "dependencies": { "camelcase": "^5.3.1", "find-up": "^4.1.0", "get-package-type": "^0.1.0", "js-yaml": "^3.13.1", "resolve-from": "^5.0.0" } }, "sha512-VjeHSlIzpv/NyD3N0YuHfXOPDIixcA1q2ZV98wsMqcYlPmv2n3Yb2lYP9XMElnaFVXg5A7YLTeLu6V84uQDjmQ=="], | 740 | "@istanbuljs/load-nyc-config": ["@istanbuljs/load-nyc-config@1.1.0", "", { "dependencies": { "camelcase": "^5.3.1", "find-up": "^4.1.0", "get-package-type": "^0.1.0", "js-yaml": "^3.13.1", "resolve-from": "^5.0.0" } }, "sha512-VjeHSlIzpv/NyD3N0YuHfXOPDIixcA1q2ZV98wsMqcYlPmv2n3Yb2lYP9XMElnaFVXg5A7YLTeLu6V84uQDjmQ=="], |
| @@ -1096,35 +1091,33 @@ · 350 unmodified lines | |||
| 1096 | 1091 | ||
| 1097 | "@t3tools/web": ["@t3tools/web@workspace:apps/web"], | 1092 | "@t3tools/web": ["@t3tools/web@workspace:apps/web"], |
| 1098 | 1093 | ||
| 1099 | "@tailwindcss/node": ["@tailwindcss/node@4.1.12", "", { "dependencies": { "@jridgewell/remapping": "^2.3.4", "enhanced-resolve": "^5.18.3", "jiti": "^2.5.1", "lightningcss": "1.30.1", "magic-string": "^0.30.17", "source-map-js": "^1.2.1", "tailwindcss": "4.1.12" } }, "sha512-3hm9brwvQkZFe++SBt+oLjo4OLDtkvlE8q2WalaD/7QWaeM7KEJbAiY/LJZUaCs7Xa8aUu4xy3uoyX4q54UVdQ=="], | 1094 | "@tailwindcss/node": ["@tailwindcss/node@4.2.1", "", { "dependencies": { "@jridgewell/remapping": "^2.3.5", "enhanced-resolve": "^5.19.0", "jiti": "^2.6.1", "lightningcss": "1.31.1", "magic-string": "^0.30.21", "source-map-js": "^1.2.1", "tailwindcss": "4.2.1" } }, "sha512-jlx6sLk4EOwO6hHe1oCGm1Q4AN/s0rSrTTPBGPM0/RQ6Uylwq17FuU8IeJJKEjtc6K6O07zsvP+gDO6MMWo7pg=="], |
| 1100 | 1095 | ||
| 1101 | "@tailwindcss/oxide": ["@tailwindcss/oxide@4.1.12", "", { "dependencies": { "detect-libc": "^2.0.4", "tar": "^7.4.3" }, "optionalDependencies": { "@tailwindcss/oxide-android-arm64": "4.1.12", "@tailwindcss/oxide-darwin-arm64": "4.1.12", "@tailwindcss/oxide-darwin-x64": "4.1.12", "@tailwindcss/oxide-freebsd-x64": "4.1.12", "@tailwindcss/oxide-linux-arm-gnueabihf": "4.1.12", "@tailwindcss/oxide-linux-arm64-gnu": "4.1.12", "@tailwindcss/oxide-linux-arm64-musl": "4.1.12", "@tailwindcss/oxide-linux-x64-gnu": "4.1.12", "@tailwindcss/oxide-linux-x64-musl": "4.1.12", "@tailwindcss/oxide-wasm32-wasi": "4.1.12", "@tailwindcss/oxide-win32-arm64-msvc": "4.1.12", "@tailwindcss/oxide-win32-x64-msvc": "4.1.12" } }, "sha512-gM5EoKHW/ukmlEtphNwaGx45fGoEmP10v51t9unv55voWh6WrOL19hfuIdo2FjxIaZzw776/BUQg7Pck++cIVw=="], | 1096 | "@tailwindcss/oxide": ["@tailwindcss/oxide@4.2.1", "", { "optionalDependencies": { "@tailwindcss/oxide-android-arm64": "4.2.1", "@tailwindcss/oxide-darwin-arm64": "4.2.1", "@tailwindcss/oxide-darwin-x64": "4.2.1", "@tailwindcss/oxide-freebsd-x64": "4.2.1", "@tailwindcss/oxide-linux-arm-gnueabihf": "4.2.1", "@tailwindcss/oxide-linux-arm64-gnu": "4.2.1", "@tailwindcss/oxide-linux-arm64-musl": "4.2.1", "@tailwindcss/oxide-linux-x64-gnu": "4.2.1", "@tailwindcss/oxide-linux-x64-musl": "4.2.1", "@tailwindcss/oxide-wasm32-wasi": "4.2.1", "@tailwindcss/oxide-win32-arm64-msvc": "4.2.1", "@tailwindcss/oxide-win32-x64-msvc": "4.2.1" } }, "sha512-yv9jeEFWnjKCI6/T3Oq50yQEOqmpmpfzG1hcZsAOaXFQPfzWprWrlHSdGPEF3WQTi8zu8ohC9Mh9J470nT5pUw=="], |
| 1102 | 1097 | ||
| 1103 | "@tailwindcss/oxide-android-arm64": ["@tailwindcss/oxide-android-arm64@4.1.12", "", { "os": "android", "cpu": "arm64" }, "sha512-oNY5pq+1gc4T6QVTsZKwZaGpBb2N1H1fsc1GD4o7yinFySqIuRZ2E4NvGasWc6PhYJwGK2+5YT1f9Tp80zUQZQ=="], | 1098 | "@tailwindcss/oxide-android-arm64": ["@tailwindcss/oxide-android-arm64@4.2.1", "", { "os": "android", "cpu": "arm64" }, "sha512-eZ7G1Zm5EC8OOKaesIKuw77jw++QJ2lL9N+dDpdQiAB/c/B2wDh0QPFHbkBVrXnwNugvrbJFk1gK2SsVjwWReg=="], |
| 1104 | 1099 | ||
| 1105 | "@tailwindcss/oxide-darwin-arm64": ["@tailwindcss/oxide-darwin-arm64@4.1.12", "", { "os": "darwin", "cpu": "arm64" }, "sha512-cq1qmq2HEtDV9HvZlTtrj671mCdGB93bVY6J29mwCyaMYCP/JaUBXxrQQQm7Qn33AXXASPUb2HFZlWiiHWFytw=="], | 1100 | "@tailwindcss/oxide-darwin-arm64": ["@tailwindcss/oxide-darwin-arm64@4.2.1", "", { "os": "darwin", "cpu": "arm64" }, "sha512-q/LHkOstoJ7pI1J0q6djesLzRvQSIfEto148ppAd+BVQK0JYjQIFSK3JgYZJa+Yzi0DDa52ZsQx2rqytBnf8Hw=="], |
| 1106 | 1101 | ||
| 1107 | "@tailwindcss/oxide-darwin-x64": ["@tailwindcss/oxide-darwin-x64@4.1.12", "", { "os": "darwin", "cpu": "x64" }, "sha512-6UCsIeFUcBfpangqlXay9Ffty9XhFH1QuUFn0WV83W8lGdX8cD5/+2ONLluALJD5+yJ7k8mVtwy3zMZmzEfbLg=="], | 1102 | "@tailwindcss/oxide-darwin-x64": ["@tailwindcss/oxide-darwin-x64@4.2.1", "", { "os": "darwin", "cpu": "x64" }, "sha512-/f/ozlaXGY6QLbpvd/kFTro2l18f7dHKpB+ieXz+Cijl4Mt9AI2rTrpq7V+t04nK+j9XBQHnSMdeQRhbGyt6fw=="], |
| 1108 | 1103 | ||
| 1109 | "@tailwindcss/oxide-freebsd-x64": ["@tailwindcss/oxide-freebsd-x64@4.1.12", "", { "os": "freebsd", "cpu": "x64" }, "sha512-JOH/f7j6+nYXIrHobRYCtoArJdMJh5zy5lr0FV0Qu47MID/vqJAY3r/OElPzx1C/wdT1uS7cPq+xdYYelny1ww=="], | 1104 | "@tailwindcss/oxide-freebsd-x64": ["@tailwindcss/oxide-freebsd-x64@4.2.1", "", { "os": "freebsd", "cpu": "x64" }, "sha512-5e/AkgYJT/cpbkys/OU2Ei2jdETCLlifwm7ogMC7/hksI2fC3iiq6OcXwjibcIjPung0kRtR3TxEITkqgn0TcA=="], |
| 1110 | 1105 | ||
| 1111 | "@tailwindcss/oxide-linux-arm-gnueabihf": ["@tailwindcss/oxide-linux-arm-gnueabihf@4.1.12", "", { "os": "linux", "cpu": "arm" }, "sha512-v4Ghvi9AU1SYgGr3/j38PD8PEe6bRfTnNSUE3YCMIRrrNigCFtHZ2TCm8142X8fcSqHBZBceDx+JlFJEfNg5zQ=="], | 1106 | "@tailwindcss/oxide-linux-arm-gnueabihf": ["@tailwindcss/oxide-linux-arm-gnueabihf@4.2.1", "", { "os": "linux", "cpu": "arm" }, "sha512-Uny1EcVTTmerCKt/1ZuKTkb0x8ZaiuYucg2/kImO5A5Y/kBz41/+j0gxUZl+hTF3xkWpDmHX+TaWhOtba2Fyuw=="], |
| 1112 | 1107 | ||
| 1113 | "@tailwindcss/oxide-linux-arm64-gnu": ["@tailwindcss/oxide-linux-arm64-gnu@4.1.12", "", { "os": "linux", "cpu": "arm64" }, "sha512-YP5s1LmetL9UsvVAKusHSyPlzSRqYyRB0f+Kl/xcYQSPLEw/BvGfxzbH+ihUciePDjiXwHh+p+qbSP3SlJw+6g=="], | 1108 | "@tailwindcss/oxide-linux-arm64-gnu": ["@tailwindcss/oxide-linux-arm64-gnu@4.2.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-CTrwomI+c7n6aSSQlsPL0roRiNMDQ/YzMD9EjcR+H4f0I1SQ8QqIuPnsVp7QgMkC1Qi8rtkekLkOFjo7OlEFRQ=="], |
| 1114 | 1109 | ||
| 1115 | "@tailwindcss/oxide-linux-arm64-musl": ["@tailwindcss/oxide-linux-arm64-musl@4.1.12", "", { "os": "linux", "cpu": "arm64" }, "sha512-V8pAM3s8gsrXcCv6kCHSuwyb/gPsd863iT+v1PGXC4fSL/OJqsKhfK//v8P+w9ThKIoqNbEnsZqNy+WDnwQqCA=="], | 1110 | "@tailwindcss/oxide-linux-arm64-musl": ["@tailwindcss/oxide-linux-arm64-musl@4.2.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-WZA0CHRL/SP1TRbA5mp9htsppSEkWuQ4KsSUumYQnyl8ZdT39ntwqmz4IUHGN6p4XdSlYfJwM4rRzZLShHsGAQ=="], |
| 1116 | 1111 | ||
| 1117 | "@tailwindcss/oxide-linux-x64-gnu": ["@tailwindcss/oxide-linux-x64-gnu@4.1.12", "", { "os": "linux", "cpu": "x64" }, "sha512-xYfqYLjvm2UQ3TZggTGrwxjYaLB62b1Wiysw/YE3Yqbh86sOMoTn0feF98PonP7LtjsWOWcXEbGqDL7zv0uW8Q=="], | 1112 | "@tailwindcss/oxide-linux-x64-gnu": ["@tailwindcss/oxide-linux-x64-gnu@4.2.1", "", { "os": "linux", "cpu": "x64" }, "sha512-qMFzxI2YlBOLW5PhblzuSWlWfwLHaneBE0xHzLrBgNtqN6mWfs+qYbhryGSXQjFYB1Dzf5w+LN5qbUTPhW7Y5g=="], |
| 1118 | 1113 | ||
| 1119 | "@tailwindcss/oxide-linux-x64-musl": ["@tailwindcss/oxide-linux-x64-musl@4.1.12", "", { "os": "linux", "cpu": "x64" }, "sha512-ha0pHPamN+fWZY7GCzz5rKunlv9L5R8kdh+YNvP5awe3LtuXb5nRi/H27GeL2U+TdhDOptU7T6Is7mdwh5Ar3A=="], | 1114 | "@tailwindcss/oxide-linux-x64-musl": ["@tailwindcss/oxide-linux-x64-musl@4.2.1", "", { "os": "linux", "cpu": "x64" }, "sha512-5r1X2FKnCMUPlXTWRYpHdPYUY6a1Ar/t7P24OuiEdEOmms5lyqjDRvVY1yy9Rmioh+AunQ0rWiOTPE8F9A3v5g=="], |
| 1120 | 1115 | ||
| 1121 | "@tailwindcss/oxide-wasm32-wasi": ["@tailwindcss/oxide-wasm32-wasi@4.1.12", "", { "dependencies": { "@emnapi/core": "^1.4.5", "@emnapi/runtime": "^1.4.5", "@emnapi/wasi-threads": "^1.0.4", "@napi-rs/wasm-runtime": "^0.2.12", "@tybys/wasm-util": "^0.10.0", "tslib": "^2.8.0" }, "cpu": "none" }, "sha512-4tSyu3dW+ktzdEpuk6g49KdEangu3eCYoqPhWNsZgUhyegEda3M9rG0/j1GV/JjVVsj+lG7jWAyrTlLzd/WEBg=="], | 1116 | "@tailwindcss/oxide-wasm32-wasi": ["@tailwindcss/oxide-wasm32-wasi@4.2.1", "", { "dependencies": { "@emnapi/core": "^1.8.1", "@emnapi/runtime": "^1.8.1", "@emnapi/wasi-threads": "^1.1.0", "@napi-rs/wasm-runtime": "^1.1.1", "@tybys/wasm-util": "^0.10.1", "tslib": "^2.8.1" }, "cpu": "none" }, "sha512-MGFB5cVPvshR85MTJkEvqDUnuNoysrsRxd6vnk1Lf2tbiqNlXpHYZqkqOQalydienEWOHHFyyuTSYRsLfxFJ2Q=="], |
| 1122 | 1117 | ||
| 1123 | "@tailwindcss/oxide-win32-arm64-msvc": ["@tailwindcss/oxide-win32-arm64-msvc@4.1.12", "", { "os": "win32", "cpu": "arm64" }, "sha512-iGLyD/cVP724+FGtMWslhcFyg4xyYyM+5F4hGvKA7eifPkXHRAUDFaimu53fpNg9X8dfP75pXx/zFt/jlNF+lg=="], | 1118 | "@tailwindcss/oxide-win32-arm64-msvc": ["@tailwindcss/oxide-win32-arm64-msvc@4.2.1", "", { "os": "win32", "cpu": "arm64" }, "sha512-YlUEHRHBGnCMh4Nj4GnqQyBtsshUPdiNroZj8VPkvTZSoHsilRCwXcVKnG9kyi0ZFAS/3u+qKHBdDc81SADTRA=="], |
| 1124 | 1119 | ||
| 1125 | "@tailwindcss/oxide-win32-x64-msvc": ["@tailwindcss/oxide-win32-x64-msvc@4.1.12", "", { "os": "win32", "cpu": "x64" }, "sha512-NKIh5rzw6CpEodv/++r0hGLlfgT/gFN+5WNdZtvh6wpU2BpGNgdjvj6H2oFc8nCM839QM1YOhjpgbAONUb4IxA=="], | 1120 | "@tailwindcss/oxide-win32-x64-msvc": ["@tailwindcss/oxide-win32-x64-msvc@4.2.1", "", { "os": "win32", "cpu": "x64" }, "sha512-rbO34G5sMWWyrN/idLeVxAZgAKWrn5LiR3/I90Q9MkA67s6T1oB0xtTe+0heoBvHSpbU9Mk7i6uwJnpo4u21XQ=="], |
| 1126 | |||
| 1127 | "@tailwindcss/postcss": ["@tailwindcss/postcss@4.1.12", "", { "dependencies": { "@alloc/quick-lru": "^5.2.0", "@tailwindcss/node": "4.1.12", "@tailwindcss/oxide": "4.1.12", "postcss": "^8.4.41", "tailwindcss": "4.1.12" } }, "sha512-5PpLYhCAwf9SJEeIsSmCDLgyVfdBhdBpzX1OJ87anT9IVR0Z9pjM0FNixCAUAHGnMBGB8K99SwAheXrT0Kh6QQ=="], | ||
| 1128 | 1121 | ||
| 1129 | "@tailwindcss/vite": ["@tailwindcss/vite@4.2.2", "", { "dependencies": { "@tailwindcss/node": "4.2.2", "@tailwindcss/oxide": "4.2.2", "tailwindcss": "4.2.2" }, "peerDependencies": { "vite": "^5.2.0 || ^6 || ^7 || ^8" } }, "sha512-mEiF5HO1QqCLXoNEfXVA1Tzo+cYsrqV7w9Juj2wdUFyW07JRenqMG225MvPwr3ZD9N1bFQj46X7r33iHxLUW0w=="], | 1122 | "@tailwindcss/vite": ["@tailwindcss/vite@4.2.2", "", { "dependencies": { "@tailwindcss/node": "4.2.2", "@tailwindcss/oxide": "4.2.2", "tailwindcss": "4.2.2" }, "peerDependencies": { "vite": "^5.2.0 || ^6 || ^7 || ^8" } }, "sha512-mEiF5HO1QqCLXoNEfXVA1Tzo+cYsrqV7w9Juj2wdUFyW07JRenqMG225MvPwr3ZD9N1bFQj46X7r33iHxLUW0w=="], |
| 1130 | 1123 | ||
| @@ -1320,8 +1313,6 @@ · 189 unmodified lines | |||
| 1320 | 1313 | ||
| 1321 | "array-iterate": ["array-iterate@2.0.1", "", {}, "sha512-I1jXZMjAgCMmxT4qxXfPXa6SthSoE8h6gkSI9BGGNv8mP8G/v0blc+qFnZu6K42vTOiuME596QaLO0TP3Lk0xg=="], | 1314 | "array-iterate": ["array-iterate@2.0.1", "", {}, "sha512-I1jXZMjAgCMmxT4qxXfPXa6SthSoE8h6gkSI9BGGNv8mP8G/v0blc+qFnZu6K42vTOiuME596QaLO0TP3Lk0xg=="], |
| 1322 | 1315 | ||
| 1323 | "array-timsort": ["array-timsort@1.0.3", "", {}, "sha512-/+3GRL7dDAGEfM6TseQk/U+mi18TU2Ms9I3UlLdUMhz2hbvGNTKdj9xniwXfUqgYhHxRx0+8UnKkvlNwVU+cWQ=="], | ||
| 1324 | |||
| 1325 | "asap": ["asap@2.0.6", "", {}, "sha512-BSHWgDSAiKs50o2Re8ppvp3seVHXSRM44cdSsT9FfNEUUZLOGWVCsiWaRPWM1Znn+mqZ1OfVZ3z3DWEzSp7hRA=="], | 1316 | "asap": ["asap@2.0.6", "", {}, "sha512-BSHWgDSAiKs50o2Re8ppvp3seVHXSRM44cdSsT9FfNEUUZLOGWVCsiWaRPWM1Znn+mqZ1OfVZ3z3DWEzSp7hRA=="], |
| 1326 | 1317 | ||
| 1327 | "assertion-error": ["assertion-error@2.0.1", "", {}, "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA=="], | 1318 | "assertion-error": ["assertion-error@2.0.1", "", {}, "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA=="], |
| @@ -1440,8 +1431,6 @@ · 112 unmodified lines | |||
| 1440 | 1431 | ||
| 1441 | "chokidar": ["chokidar@4.0.3", "", { "dependencies": { "readdirp": "^4.0.1" } }, "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA=="], | 1432 | "chokidar": ["chokidar@4.0.3", "", { "dependencies": { "readdirp": "^4.0.1" } }, "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA=="], |
| 1442 | 1433 | ||
| 1443 | "chownr": ["chownr@3.0.0", "", {}, "sha512-+IxzY9BZOQd/XuYPRmrvEVjF/nqj5kgT4kEq7VofrDoM1MxoRjEWkrCC3EtLi59TVawxTAn+orJwFQcrqEN1+g=="], | ||
| 1444 | |||
| 1445 | "chrome-launcher": ["chrome-launcher@0.15.2", "", { "dependencies": { "@types/node": "*", "escape-string-regexp": "^4.0.0", "is-wsl": "^2.2.0", "lighthouse-logger": "^1.0.0" }, "bin": { "print-chrome-path": "bin/print-chrome-path.js" } }, "sha512-zdLEwNo3aUVzIhKhTtXfxhdvZhUghrnmkvcAq2NoDd+LeOHKf03H5jwZ8T/STsAlzyALkBVK552iaG1fGf1xVQ=="], | 1434 | "chrome-launcher": ["chrome-launcher@0.15.2", "", { "dependencies": { "@types/node": "*", "escape-string-regexp": "^4.0.0", "is-wsl": "^2.2.0", "lighthouse-logger": "^1.0.0" }, "bin": { "print-chrome-path": "bin/print-chrome-path.js" } }, "sha512-zdLEwNo3aUVzIhKhTtXfxhdvZhUghrnmkvcAq2NoDd+LeOHKf03H5jwZ8T/STsAlzyALkBVK552iaG1fGf1xVQ=="], |
| 1446 | 1435 | ||
| 1447 | "chromium-edge-launcher": ["chromium-edge-launcher@0.2.0", "", { "dependencies": { "@types/node": "*", "escape-string-regexp": "^4.0.0", "is-wsl": "^2.2.0", "lighthouse-logger": "^1.0.0", "mkdirp": "^1.0.4", "rimraf": "^3.0.2" } }, "sha512-JfJjUnq25y9yg4FABRRVPmBGWPZZi+AQXT4mxupb67766/0UlhG8PAZCz6xzEMXTbW3CsSoE8PcCWA49n35mKg=="], | 1436 | "chromium-edge-launcher": ["chromium-edge-launcher@0.2.0", "", { "dependencies": { "@types/node": "*", "escape-string-regexp": "^4.0.0", "is-wsl": "^2.2.0", "lighthouse-logger": "^1.0.0", "mkdirp": "^1.0.4", "rimraf": "^3.0.2" } }, "sha512-JfJjUnq25y9yg4FABRRVPmBGWPZZi+AQXT4mxupb67766/0UlhG8PAZCz6xzEMXTbW3CsSoE8PcCWA49n35mKg=="], |
| @@ -1476,14 +1465,10 @@ · 28 unmodified lines | |||
| 1476 | 1465 | ||
| 1477 | "color-string": ["color-string@1.9.1", "", { "dependencies": { "color-name": "^1.0.0", "simple-swizzle": "^0.2.2" } }, "sha512-shrVawQFojnZv6xM40anx4CkoDP+fZsw/ZerEMsW/pyzsRbElpsL/DBVW7q3ExxwusdNXI3lXpuhEZkzs8p5Eg=="], | 1466 | "color-string": ["color-string@1.9.1", "", { "dependencies": { "color-name": "^1.0.0", "simple-swizzle": "^0.2.2" } }, "sha512-shrVawQFojnZv6xM40anx4CkoDP+fZsw/ZerEMsW/pyzsRbElpsL/DBVW7q3ExxwusdNXI3lXpuhEZkzs8p5Eg=="], |
| 1478 | 1467 | ||
| 1479 | "colorjs.io": ["colorjs.io@0.6.0-alpha.1", "", {}, "sha512-c/h/8uAmPydQcriRdX8UTAFHj6SpSHFHBA8LvMikvYWAVApPTwg/pyOXNsGmaCBd6L/EeDlRHSNhTtnIFp/qsg=="], | ||
| 1480 | |||
| 1481 | "comma-separated-tokens": ["comma-separated-tokens@2.0.3", "", {}, "sha512-Fu4hJdvzeylCfQPp9SGWidpzrMs7tTrlu6Vb8XGaRGck8QSNZJJp538Wrb60Lax4fPwR64ViY468OIUTbRlGZg=="], | 1468 | "comma-separated-tokens": ["comma-separated-tokens@2.0.3", "", {}, "sha512-Fu4hJdvzeylCfQPp9SGWidpzrMs7tTrlu6Vb8XGaRGck8QSNZJJp538Wrb60Lax4fPwR64ViY468OIUTbRlGZg=="], |
| 1482 | 1469 | ||
| 1483 | "commander": ["commander@12.1.0", "", {}, "sha512-Vw8qHK3bZM9y/P10u3Vib8o/DdkvA2OtPtZvD871QKjy74Wj1WSKFILMPRPSdUSx5RFK1arlJzEtA4PkFgnbuA=="], | 1470 | "commander": ["commander@12.1.0", "", {}, "sha512-Vw8qHK3bZM9y/P10u3Vib8o/DdkvA2OtPtZvD871QKjy74Wj1WSKFILMPRPSdUSx5RFK1arlJzEtA4PkFgnbuA=="], |
| 1484 | 1471 | ||
| 1485 | "comment-json": ["comment-json@4.6.2", "", { "dependencies": { "array-timsort": "^1.0.3", "esprima": "^4.0.1" } }, "sha512-R2rze/hDX30uul4NZoIZ76ImSJLFxn/1/ZxtKC1L77y2X1k+yYu1joKbAtMA2Fg3hZrTOiw0I5mwVMo0cf250w=="], | ||
| 1486 | |||
| 1487 | "common-ancestor-path": ["common-ancestor-path@2.0.0", "", {}, "sha512-dnN3ibLeoRf2HNC+OlCiNc5d2zxbLJXOtiZUudNFSXZrNSydxcCsSpRzXwfu7BBWCIfHPw+xTayeBvJCP/D8Ng=="], | 1472 | "common-ancestor-path": ["common-ancestor-path@2.0.0", "", {}, "sha512-dnN3ibLeoRf2HNC+OlCiNc5d2zxbLJXOtiZUudNFSXZrNSydxcCsSpRzXwfu7BBWCIfHPw+xTayeBvJCP/D8Ng=="], |
| 1488 | 1473 | ||
| 1489 | "compressible": ["compressible@2.0.18", "", { "dependencies": { "mime-db": ">= 1.43.0 < 2" } }, "sha512-AF3r7P5dWxL8MxyITRMlORQNaOA2IkAFaTr4k7BUumjPtRpGDTZpl0Pb1XCO6JeDCBdp126Cgs9sMxqSjgYyRg=="], | 1474 | "compressible": ["compressible@2.0.18", "", { "dependencies": { "mime-db": ">= 1.43.0 < 2" } }, "sha512-AF3r7P5dWxL8MxyITRMlORQNaOA2IkAFaTr4k7BUumjPtRpGDTZpl0Pb1XCO6JeDCBdp126Cgs9sMxqSjgYyRg=="], |
| @@ -1528,6 +1513,8 @@ · 38 unmodified lines | |||
| 1528 | 1513 | ||
| 1529 | "csstype": ["csstype@3.2.3", "", {}, "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ=="], | 1514 | "csstype": ["csstype@3.2.3", "", {}, "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ=="], |
| 1530 | 1515 | ||
| 1516 | "culori": ["culori@4.0.2", "", {}, "sha512-1+BhOB8ahCn4O0cep0Sh2l9KCOfOdY+BXJnKMHFFzDEouSr/el18QwXEMRlOj9UY5nCeA8UN3a/82rUWRBeyBw=="], | ||
| 1517 | |||
| 1531 | "date-fns": ["date-fns@4.1.0", "", {}, "sha512-Ukq0owbQXxa/U3EGtsdVBkR1w7KOQ5gIBqdH2hkvknzZPYvBxb/aa6E8L7tmjFtkwZBu3UXBbjIgPo/Ez4xaNg=="], | 1518 | "date-fns": ["date-fns@4.1.0", "", {}, "sha512-Ukq0owbQXxa/U3EGtsdVBkR1w7KOQ5gIBqdH2hkvknzZPYvBxb/aa6E8L7tmjFtkwZBu3UXBbjIgPo/Ez4xaNg=="], |
| 1532 | 1519 | ||
| 1533 | "debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="], | 1520 | "debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="], |
| @@ -2240,8 +2227,6 @@ · 706 unmodified lines | |||
| 2240 | 2227 | ||
| 2241 | "minipass": ["minipass@7.1.3", "", {}, "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A=="], | 2228 | "minipass": ["minipass@7.1.3", "", {}, "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A=="], |
| 2242 | 2229 | ||
| 2243 | "minizlib": ["minizlib@3.1.0", "", { "dependencies": { "minipass": "^7.1.2" } }, "sha512-KZxYo1BUkWD2TVFLr0MQoM8vUUigWD3LlD83a/75BqC+4qE0Hb1Vo5v1FgcfaNXvfXzr+5EhQ6ing/CaBijTlw=="], | ||
| 2244 | |||
| 2245 | "mkdirp": ["mkdirp@1.0.4", "", { "bin": { "mkdirp": "bin/cmd.js" } }, "sha512-vVqVZQyf3WLx2Shd0qJ9xuvqgAyKPLAiqITEtqW0oIUjzo3PePDd6fW9iFz30ef7Ysp/oiWqbhszeGWW2T6Gzw=="], | 2230 | "mkdirp": ["mkdirp@1.0.4", "", { "bin": { "mkdirp": "bin/cmd.js" } }, "sha512-vVqVZQyf3WLx2Shd0qJ9xuvqgAyKPLAiqITEtqW0oIUjzo3PePDd6fW9iFz30ef7Ysp/oiWqbhszeGWW2T6Gzw=="], |
| 2246 | 2231 | ||
| 2247 | "mrmime": ["mrmime@2.0.1", "", {}, "sha512-Y3wQdFg2Va6etvQ5I82yUhGdsKrcYox6p7FfL1LbK2J4V01F9TGlepTIhnK24t7koZibmg82KGglhA1XK5IsLQ=="], | 2232 | "mrmime": ["mrmime@2.0.1", "", {}, "sha512-Y3wQdFg2Va6etvQ5I82yUhGdsKrcYox6p7FfL1LbK2J4V01F9TGlepTIhnK24t7koZibmg82KGglhA1XK5IsLQ=="], |
| @@ -2264,8 +2249,6 @@ · 16 unmodified lines | |||
| 2264 | 2249 | ||
| 2265 | "nanoid": ["nanoid@3.3.11", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w=="], | 2250 | "nanoid": ["nanoid@3.3.11", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w=="], |
| 2266 | 2251 | ||
| 2267 | "nativewind": ["nativewind@5.0.0-preview.3", "", { "dependencies": { "tailwindcss-safe-area": "^1.1.0" }, "peerDependencies": { "react-native-css": "^3.0.1", "tailwindcss": ">4.1.11" } }, "sha512-cbl1GzzY55NL2IG35AaAVfrL4+bCh77sa39aST5/o7xy3TLPthAtzhNPstnrCn+DtIglTnXOlOJGFrX2WdhI6w=="], | ||
| 2268 | |||
| 2269 | "negotiator": ["negotiator@0.6.3", "", {}, "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg=="], | 2252 | "negotiator": ["negotiator@0.6.3", "", {}, "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg=="], |
| 2270 | 2253 | ||
| 2271 | "neotraverse": ["neotraverse@0.6.18", "", {}, "sha512-Z4SmBUweYa09+o6pG+eASabEpP6QkQ70yHj351pQoEXIs8uHbaU2DWVmzBANKgflPa47A50PtB2+NgRpQvr7vA=="], | 2254 | "neotraverse": ["neotraverse@0.6.18", "", {}, "sha512-Z4SmBUweYa09+o6pG+eASabEpP6QkQ70yHj351pQoEXIs8uHbaU2DWVmzBANKgflPa47A50PtB2+NgRpQvr7vA=="], |
| @@ -2458,8 +2441,6 @@ · 186 unmodified lines | |||
| 2458 | 2441 | ||
| 2459 | "react-native": ["react-native@0.83.4", "", { "dependencies": { "@jest/create-cache-key-function": "^29.7.0", "@react-native/assets-registry": "0.83.4", "@react-native/codegen": "0.83.4", "@react-native/community-cli-plugin": "0.83.4", "@react-native/gradle-plugin": "0.83.4", "@react-native/js-polyfills": "0.83.4", "@react-native/normalize-colors": "0.83.4", "@react-native/virtualized-lists": "0.83.4", "abort-controller": "^3.0.0", "anser": "^1.4.9", "ansi-regex": "^5.0.0", "babel-jest": "^29.7.0", "babel-plugin-syntax-hermes-parser": "0.32.0", "base64-js": "^1.5.1", "commander": "^12.0.0", "flow-enums-runtime": "^0.0.6", "glob": "^7.1.1", "hermes-compiler": "0.14.1", "invariant": "^2.2.4", "jest-environment-node": "^29.7.0", "memoize-one": "^5.0.0", "metro-runtime": "^0.83.3", "metro-source-map": "^0.83.3", "nullthrows": "^1.1.1", "pretty-format": "^29.7.0", "promise": "^8.3.0", "react-devtools-core": "^6.1.5", "react-refresh": "^0.14.0", "regenerator-runtime": "^0.13.2", "scheduler": "0.27.0", "semver": "^7.1.3", "stacktrace-parser": "^0.1.10", "whatwg-fetch": "^3.0.0", "ws": "^7.5.10", "yargs": "^17.6.2" }, "peerDependencies": { "@types/react": "^19.1.1", "react": "^19.2.0" }, "optionalPeers": ["@types/react"], "bin": { "react-native": "cli.js" } }, "sha512-H5Wco3UJyY6zZsjoBayY8RM9uiAEQ3FeG4G2NAt+lr9DO43QeqPlVe9xxxYEukMkEmeIhNjR70F6bhXuWArOMQ=="], | 2442 | "react-native": ["react-native@0.83.4", "", { "dependencies": { "@jest/create-cache-key-function": "^29.7.0", "@react-native/assets-registry": "0.83.4", "@react-native/codegen": "0.83.4", "@react-native/community-cli-plugin": "0.83.4", "@react-native/gradle-plugin": "0.83.4", "@react-native/js-polyfills": "0.83.4", "@react-native/normalize-colors": "0.83.4", "@react-native/virtualized-lists": "0.83.4", "abort-controller": "^3.0.0", "anser": "^1.4.9", "ansi-regex": "^5.0.0", "babel-jest": "^29.7.0", "babel-plugin-syntax-hermes-parser": "0.32.0", "base64-js": "^1.5.1", "commander": "^12.0.0", "flow-enums-runtime": "^0.0.6", "glob": "^7.1.1", "hermes-compiler": "0.14.1", "invariant": "^2.2.4", "jest-environment-node": "^29.7.0", "memoize-one": "^5.0.0", "metro-runtime": "^0.83.3", "metro-source-map": "^0.83.3", "nullthrows": "^1.1.1", "pretty-format": "^29.7.0", "promise": "^8.3.0", "react-devtools-core": "^6.1.5", "react-refresh": "^0.14.0", "regenerator-runtime": "^0.13.2", "scheduler": "0.27.0", "semver": "^7.1.3", "stacktrace-parser": "^0.1.10", "whatwg-fetch": "^3.0.0", "ws": "^7.5.10", "yargs": "^17.6.2" }, "peerDependencies": { "@types/react": "^19.1.1", "react": "^19.2.0" }, "optionalPeers": ["@types/react"], "bin": { "react-native": "cli.js" } }, "sha512-H5Wco3UJyY6zZsjoBayY8RM9uiAEQ3FeG4G2NAt+lr9DO43QeqPlVe9xxxYEukMkEmeIhNjR70F6bhXuWArOMQ=="], |
| 2460 | 2443 | ||
| 2461 | "react-native-css": ["react-native-css@3.0.7", "", { "dependencies": { "@types/debug": "^4.1.12", "babel-plugin-react-compiler": "^19.1.0-rc.2", "colorjs.io": "0.6.0-alpha.1", "comment-json": "^4.2.5", "debug": "^4.4.1" }, "peerDependencies": { "@expo/metro-config": ">=54", "lightningcss": ">=1.27.0", "react": ">=19", "react-native": ">=0.81" } }, "sha512-xsU9T5MleHI/DDmY72A7ZikbkDfDNkMrAEichOJW2bBUMxPzAJPrFk9ys4cXYk4gLFaR11y23EccOJ+JT6Iusw=="], | ||
| 2462 | |||
| 2463 | "react-native-fit-image": ["react-native-fit-image@1.5.5", "", { "dependencies": { "prop-types": "^15.5.10" } }, "sha512-Wl3Vq2DQzxgsWKuW4USfck9zS7YzhvLNPpkwUUCF90bL32e1a0zOVQ3WsJILJOwzmPdHfzZmWasiiAUNBkhNkg=="], | 2444 | "react-native-fit-image": ["react-native-fit-image@1.5.5", "", { "dependencies": { "prop-types": "^15.5.10" } }, "sha512-Wl3Vq2DQzxgsWKuW4USfck9zS7YzhvLNPpkwUUCF90bL32e1a0zOVQ3WsJILJOwzmPdHfzZmWasiiAUNBkhNkg=="], |
| 2464 | 2445 | ||
| 2465 | "react-native-gesture-handler": ["react-native-gesture-handler@2.30.1", "", { "dependencies": { "@egjs/hammerjs": "^2.0.17", "hoist-non-react-statics": "^3.3.0", "invariant": "^2.2.4" }, "peerDependencies": { "react": "*", "react-native": "*" } }, "sha512-xIUBDo5ktmJs++0fZlavQNvDEE4PsihWhSeJsJtoz4Q6p0MiTM9TgrTgfEgzRR36qGPytFoeq+ShLrVwGdpUdA=="], | 2446 | "react-native-gesture-handler": ["react-native-gesture-handler@2.30.1", "", { "dependencies": { "@egjs/hammerjs": "^2.0.17", "hoist-non-react-statics": "^3.3.0", "invariant": "^2.2.4" }, "peerDependencies": { "react": "*", "react-native": "*" } }, "sha512-xIUBDo5ktmJs++0fZlavQNvDEE4PsihWhSeJsJtoz4Q6p0MiTM9TgrTgfEgzRR36qGPytFoeq+ShLrVwGdpUdA=="], |
| @@ -2706,12 +2687,8 @@ · 240 unmodified lines | |||
| 2706 | 2687 | ||
| 2707 | "tailwindcss": ["tailwindcss@4.1.12", "", {}, "sha512-DzFtxOi+7NsFf7DBtI3BJsynR+0Yp6etH+nRPTbpWnS2pZBaSksv/JGctNwSWzbFjp0vxSqknaUylseZqMDGrA=="], | 2688 | "tailwindcss": ["tailwindcss@4.1.12", "", {}, "sha512-DzFtxOi+7NsFf7DBtI3BJsynR+0Yp6etH+nRPTbpWnS2pZBaSksv/JGctNwSWzbFjp0vxSqknaUylseZqMDGrA=="], |
| 2708 | 2689 | ||
| 2709 | "tailwindcss-safe-area": ["tailwindcss-safe-area@1.3.0", "", { "peerDependencies": { "tailwindcss": "^4.0.0" } }, "sha512-RoxnW1zAjBWC3XK+row7Qj5toRMRlKNN/p3FLXb6fTGKxDGWT6JP/mcNX1yf09xRficQ308hbwiedgniepSp1Q=="], | ||
| 2710 | |||
| 2711 | "tapable": ["tapable@2.3.2", "", {}, "sha512-1MOpMXuhGzGL5TTCZFItxCc0AARf1EZFQkGqMm7ERKj8+Hgr5oLvJOVFcC+lRmR8hCe2S3jC4T5D7Vg/d7/fhA=="], | 2690 | "tapable": ["tapable@2.3.2", "", {}, "sha512-1MOpMXuhGzGL5TTCZFItxCc0AARf1EZFQkGqMm7ERKj8+Hgr5oLvJOVFcC+lRmR8hCe2S3jC4T5D7Vg/d7/fhA=="], |
| 2712 | 2691 | ||
| 2713 | "tar": ["tar@7.5.13", "", { "dependencies": { "@isaacs/fs-minipass": "^4.0.0", "chownr": "^3.0.0", "minipass": "^7.1.2", "minizlib": "^3.1.0", "yallist": "^5.0.0" } }, "sha512-tOG/7GyXpFevhXVh8jOPJrmtRpOTsYqUIkVdVooZYJS/z8WhfQUX8RJILmeuJNinGAMSu1veBr4asSHFt5/hng=="], | ||
| 2714 | |||
| 2715 | "terminal-link": ["terminal-link@2.1.1", "", { "dependencies": { "ansi-escapes": "^4.2.1", "supports-hyperlinks": "^2.0.0" } }, "sha512-un0FmiRUQNr5PJqy9kP7c40F5BOfpGlYTrxonDChEZB7pzZxRNp/bt+ymiy9/npwXya9KH99nJ/GXFIiUkYGFQ=="], | 2692 | "terminal-link": ["terminal-link@2.1.1", "", { "dependencies": { "ansi-escapes": "^4.2.1", "supports-hyperlinks": "^2.0.0" } }, "sha512-un0FmiRUQNr5PJqy9kP7c40F5BOfpGlYTrxonDChEZB7pzZxRNp/bt+ymiy9/npwXya9KH99nJ/GXFIiUkYGFQ=="], |
| 2716 | 2693 | ||
| 2717 | "terser": ["terser@5.46.1", "", { "dependencies": { "@jridgewell/source-map": "^0.3.3", "acorn": "^8.15.0", "commander": "^2.20.0", "source-map-support": "~0.5.20" }, "bin": { "terser": "bin/terser" } }, "sha512-vzCjQO/rgUuK9sf8VJZvjqiqiHFaZLnOiimmUuOKODxWL8mm/xua7viT7aqX7dgPY60otQjUotzFMmCB4VdmqQ=="], | 2694 | "terser": ["terser@5.46.1", "", { "dependencies": { "@jridgewell/source-map": "^0.3.3", "acorn": "^8.15.0", "commander": "^2.20.0", "source-map-support": "~0.5.20" }, "bin": { "terser": "bin/terser" } }, "sha512-vzCjQO/rgUuK9sf8VJZvjqiqiHFaZLnOiimmUuOKODxWL8mm/xua7viT7aqX7dgPY60otQjUotzFMmCB4VdmqQ=="], |
| @@ -2830,6 +2807,8 @@ · 112 unmodified lines | |||
| 2830 | 2807 | ||
| 2831 | "universalify": ["universalify@2.0.1", "", {}, "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw=="], | 2808 | "universalify": ["universalify@2.0.1", "", {}, "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw=="], |
| 2832 | 2809 | ||
| 2810 | "uniwind": ["uniwind@1.6.2", "", { "dependencies": { "@tailwindcss/node": "4.2.1", "@tailwindcss/oxide": "4.2.1", "culori": "4.0.2", "lightningcss": "1.30.1" }, "peerDependencies": { "react": ">=19.0.0", "react-native": ">=0.81.0", "tailwindcss": ">=4" } }, "sha512-skT0I/PFGajygQPGJ20sYbqc3HxmcOMkfQWy51Ha6eXXJWRJh6vYiy7jUJvLFwdzWlfX1g+Z8oC/SpxgRB5R+g=="], | ||
| 2811 | |||
| 2833 | "unpipe": ["unpipe@1.0.0", "", {}, "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ=="], | 2812 | "unpipe": ["unpipe@1.0.0", "", {}, "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ=="], |
| 2834 | 2813 | ||
| 2835 | "unplugin": ["unplugin@2.3.11", "", { "dependencies": { "@jridgewell/remapping": "^2.3.5", "acorn": "^8.15.0", "picomatch": "^4.0.3", "webpack-virtual-modules": "^0.6.2" } }, "sha512-5uKD0nqiYVzlmCRs01Fhs2BdkEgBS3SAVP6ndrBsuK42iC2+JHyxM05Rm9G8+5mkmRtzMZGY8Ct5+mliZxU/Ww=="], | 2814 | "unplugin": ["unplugin@2.3.11", "", { "dependencies": { "@jridgewell/remapping": "^2.3.5", "acorn": "^8.15.0", "picomatch": "^4.0.3", "webpack-virtual-modules": "^0.6.2" } }, "sha512-5uKD0nqiYVzlmCRs01Fhs2BdkEgBS3SAVP6ndrBsuK42iC2+JHyxM05Rm9G8+5mkmRtzMZGY8Ct5+mliZxU/Ww=="], |
| @@ -2950,7 +2929,7 @@ · 114 unmodified lines | |||
| 2950 | 2929 | ||
| 2951 | "y18n": ["y18n@5.0.8", "", {}, "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA=="], | 2930 | "y18n": ["y18n@5.0.8", "", {}, "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA=="], |
| 2952 | 2931 | ||
| 2953 | "yallist": ["yallist@5.0.0", "", {}, "sha512-YgvUTfwqyc7UXVMrB+SImsVYSmTS8X/tSrtdNZMImM+n7+QTriRXyXim0mBrTXNeqzVF0KWGgHPeiyViFFrNDw=="], | 2932 | "yallist": ["yallist@3.1.1", "", {}, "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g=="], |
| 2954 | 2933 | ||
| 2955 | "yaml": ["yaml@2.8.3", "", { "bin": { "yaml": "bin.mjs" } }, "sha512-AvbaCLOO2Otw/lW5bmh9d/WEdcDFdQp2Z2ZUH3pX9U2ihyUY0nvLv7J6TrWowklRGPYbB/IuIMfYgxaCPg5Bpg=="], | 2934 | "yaml": ["yaml@2.8.3", "", { "bin": { "yaml": "bin.mjs" } }, "sha512-AvbaCLOO2Otw/lW5bmh9d/WEdcDFdQp2Z2ZUH3pX9U2ihyUY0nvLv7J6TrWowklRGPYbB/IuIMfYgxaCPg5Bpg=="], |
| 2956 | 2935 | ||
| @@ -3056,13 +3035,15 @@ · 99 unmodified lines | |||
| 3056 | 3035 | ||
| 3057 | "@shikijs/transformers/@shikijs/types": ["@shikijs/types@3.23.0", "", { "dependencies": { "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-3JZ5HXOZfYjsYSk0yPwBrkupyYSLpAE26Qc0HLghhZNGTZg/SKxXIIgoxOpmmeQP0RRSDJTk1/vPfw9tbw+jSQ=="], | 3036 | "@shikijs/transformers/@shikijs/types": ["@shikijs/types@3.23.0", "", { "dependencies": { "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-3JZ5HXOZfYjsYSk0yPwBrkupyYSLpAE26Qc0HLghhZNGTZg/SKxXIIgoxOpmmeQP0RRSDJTk1/vPfw9tbw+jSQ=="], |
| 3058 | 3037 | ||
| 3038 | "@tailwindcss/node/tailwindcss": ["tailwindcss@4.2.1", "", {}, "sha512-/tBrSQ36vCleJkAOsy9kbNTgaxvGbyOamC30PRePTQe/o1MFwEKHQk4Cn7BNGaPtjp+PuUrByJehM1hgxfq4sw=="], | ||
| 3039 | |||
| 3059 | "@tailwindcss/oxide-wasm32-wasi/@emnapi/core": ["@emnapi/core@1.9.2", "", { "dependencies": { "@emnapi/wasi-threads": "1.2.1", "tslib": "^2.4.0" }, "bundled": true }, "sha512-UC+ZhH3XtczQYfOlu3lNEkdW/p4dsJ1r/bP7H8+rhao3TTTMO1ATq/4DdIi23XuGoFY+Cz0JmCbdVl0hz9jZcA=="], | 3040 | "@tailwindcss/oxide-wasm32-wasi/@emnapi/core": ["@emnapi/core@1.9.2", "", { "dependencies": { "@emnapi/wasi-threads": "1.2.1", "tslib": "^2.4.0" }, "bundled": true }, "sha512-UC+ZhH3XtczQYfOlu3lNEkdW/p4dsJ1r/bP7H8+rhao3TTTMO1ATq/4DdIi23XuGoFY+Cz0JmCbdVl0hz9jZcA=="], |
| 3060 | 3041 | ||
| 3061 | "@tailwindcss/oxide-wasm32-wasi/@emnapi/runtime": ["@emnapi/runtime@1.9.2", "", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-3U4+MIWHImeyu1wnmVygh5WlgfYDtyf0k8AbLhMFxOipihf6nrWC4syIm/SwEeec0mNSafiiNnMJwbza/Is6Lw=="], | 3042 | "@tailwindcss/oxide-wasm32-wasi/@emnapi/runtime": ["@emnapi/runtime@1.9.2", "", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-3U4+MIWHImeyu1wnmVygh5WlgfYDtyf0k8AbLhMFxOipihf6nrWC4syIm/SwEeec0mNSafiiNnMJwbza/Is6Lw=="], |
| 3062 | 3043 | ||
| 3063 | "@tailwindcss/oxide-wasm32-wasi/@emnapi/wasi-threads": ["@emnapi/wasi-threads@1.2.1", "", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w=="], | 3044 | "@tailwindcss/oxide-wasm32-wasi/@emnapi/wasi-threads": ["@emnapi/wasi-threads@1.2.1", "", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w=="], |
| 3064 | 3045 | ||
| 3065 | "@tailwindcss/oxide-wasm32-wasi/@napi-rs/wasm-runtime": ["@napi-rs/wasm-runtime@0.2.12", "", { "dependencies": { "@emnapi/core": "^1.4.3", "@emnapi/runtime": "^1.4.3", "@tybys/wasm-util": "^0.10.0" }, "bundled": true }, "sha512-ZVWUcfwY4E/yPitQJl481FjFo3K22D6qF0DuFH6Y/nbnE11GY5uguDxZMGXPQ8WQ0128MXQD7TnfHyK4oWoIJQ=="], | 3046 | "@tailwindcss/oxide-wasm32-wasi/@napi-rs/wasm-runtime": ["@napi-rs/wasm-runtime@1.1.3", "", { "dependencies": { "@tybys/wasm-util": "^0.10.1" }, "peerDependencies": { "@emnapi/core": "^1.7.1", "@emnapi/runtime": "^1.7.1" }, "bundled": true }, "sha512-xK9sGVbJWYb08+mTJt3/YV24WxvxpXcXtP6B172paPZ+Ts69Re9dAr7lKwJoeIx8OoeuimEiRZ7umkiUVClmmQ=="], |
| 3066 | 3047 | ||
| 3067 | "@tailwindcss/oxide-wasm32-wasi/@tybys/wasm-util": ["@tybys/wasm-util@0.10.1", "", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg=="], | 3048 | "@tailwindcss/oxide-wasm32-wasi/@tybys/wasm-util": ["@tybys/wasm-util@0.10.1", "", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg=="], |
| 3068 | 3049 | ||
| @@ -3212,8 +3193,6 @@ · 143 unmodified lines | |||
| 3212 | 3193 | ||
| 3213 | "react-native/glob": ["glob@7.2.3", "", { "dependencies": { "fs.realpath": "^1.0.0", "inflight": "^1.0.4", "inherits": "2", "minimatch": "^3.1.1", "once": "^1.3.0", "path-is-absolute": "^1.0.0" } }, "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q=="], | 3194 | "react-native/glob": ["glob@7.2.3", "", { "dependencies": { "fs.realpath": "^1.0.0", "inflight": "^1.0.4", "inherits": "2", "minimatch": "^3.1.1", "once": "^1.3.0", "path-is-absolute": "^1.0.0" } }, "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q=="], |
| 3214 | 3195 | ||
| 3215 | "react-native-css/babel-plugin-react-compiler": ["babel-plugin-react-compiler@19.1.0-rc.1-rc-af1b7da-20250421", "", { "dependencies": { "@babel/types": "^7.26.0" } }, "sha512-E3kaokBhWDLf7ZD8fuYjYn0ZJHYZ+3EHtAWCdX2hl4lpu1z9S/Xr99sxhx2bTCVB41oIesz9FtM8f4INsrZaOw=="], | ||
| 3216 | |||
| 3217 | "react-native-reanimated/react-native-is-edge-to-edge": ["react-native-is-edge-to-edge@1.2.1", "", { "peerDependencies": { "react": "*", "react-native": "*" } }, "sha512-FLbPWl/MyYQWz+KwqOZsSyj2JmLKglHatd3xLZWskXOpRaio4LfEDEz8E/A6uD8QoTHW6Aobw1jbEwK7KMgR7Q=="], | 3196 | "react-native-reanimated/react-native-is-edge-to-edge": ["react-native-is-edge-to-edge@1.2.1", "", { "peerDependencies": { "react": "*", "react-native": "*" } }, "sha512-FLbPWl/MyYQWz+KwqOZsSyj2JmLKglHatd3xLZWskXOpRaio4LfEDEz8E/A6uD8QoTHW6Aobw1jbEwK7KMgR7Q=="], |
| 3218 | 3197 | ||
| 3219 | "react-native-reanimated/semver": ["semver@7.7.3", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q=="], | 3198 | "react-native-reanimated/semver": ["semver@7.7.3", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q=="], |
| @@ -3292,8 +3271,6 @@ · 72 unmodified lines | |||
| 3292 | 3271 | ||
| 3293 | "yargs/yargs-parser": ["yargs-parser@21.1.1", "", {}, "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw=="], | 3272 | "yargs/yargs-parser": ["yargs-parser@21.1.1", "", {}, "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw=="], |
| 3294 | 3273 | ||
| 3295 | "@babel/helper-compilation-targets/lru-cache/yallist": ["yallist@3.1.1", "", {}, "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g=="], | ||
| 3296 | |||
| 3297 | "@electron/get/fs-extra/jsonfile": ["jsonfile@4.0.0", "", { "optionalDependencies": { "graceful-fs": "^4.1.6" } }, "sha512-m6F1R3z8jjlf2imQHS2Qez5sjKWQzbuuhuJ/FKYFRZvPE3PuHcSMVZzfsLhGVOkfd20obL5SWEBew5ShlquNxg=="], | 3274 | "@electron/get/fs-extra/jsonfile": ["jsonfile@4.0.0", "", { "optionalDependencies": { "graceful-fs": "^4.1.6" } }, "sha512-m6F1R3z8jjlf2imQHS2Qez5sjKWQzbuuhuJ/FKYFRZvPE3PuHcSMVZzfsLhGVOkfd20obL5SWEBew5ShlquNxg=="], |
| 3298 | 3275 | ||
| 3299 | "@electron/get/fs-extra/universalify": ["universalify@0.1.2", "", {}, "sha512-rBJeI5CXAlmy1pV+617WB9J63U6XcazHHF2f2dbJix4XzpUF0RS3Zbj0FGIOCAva5P/d/GBOYaACQ1w+0azUkg=="], | 3276 | "@electron/get/fs-extra/universalify": ["universalify@0.1.2", "", {}, "sha512-rBJeI5CXAlmy1pV+617WB9J63U6XcazHHF2f2dbJix4XzpUF0RS3Zbj0FGIOCAva5P/d/GBOYaACQ1w+0azUkg=="], |
| @@ -6,6 +6,10 @@ import type { · 5 unmodified lines | |||
| 6 | ServerLifecycleWelcomePayload, | 6 | ServerLifecycleWelcomePayload, |
| 7 | TerminalEvent, | 7 | TerminalEvent, |
| 8 | } from "@t3tools/contracts"; | 8 | } from "@t3tools/contracts"; |
| 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 | import type { KnownEnvironment } from "./knownEnvironment"; | 14 | import type { KnownEnvironment } from "./knownEnvironment"; |
| 11 | import type { WsRpcClient } from "./wsRpcClient"; | 15 | import type { WsRpcClient } from "./wsRpcClient"; |
| @@ -137,14 +141,20 @@ function createOrchestrationRecoveryCoordinator() { · 125 unmodified lines | |||
| 137 | markEventBatchApplied<T extends Readonly<{ sequence: number }>>( | 141 | markEventBatchApplied<T extends Readonly<{ sequence: number }>>( |
| 138 | events: ReadonlyArray<T>, | 142 | events: ReadonlyArray<T>, |
| 139 | ): ReadonlyArray<T> { | 143 | ): ReadonlyArray<T> { |
| 140 | const nextEvents = events | 144 | const nextEvents = pipe( |
| 141 | .filter((event) => event.sequence > state.latestSequence) | 145 | events, |
| 142 | .toSorted((left, right) => left.sequence - right.sequence); | 146 | Arr.filter((event) => event.sequence > state.latestSequence), |
| 147 | Arr.sortWith((event) => event.sequence, Order.Number), | ||
| 148 | ); | ||
| 143 | if (nextEvents.length === 0) { | 149 | if (nextEvents.length === 0) { |
| 144 | return []; | 150 | return []; |
| 145 | } | 151 | } |
| 146 | 152 | ||
| 147 | const latestSequence = nextEvents.at(-1)?.sequence ?? state.latestSequence; | 153 | const latestSequence = Arr.last(nextEvents).pipe( |
| 154 | Option.map((event) => event.sequence), | ||
| 155 | Option.getOrElse(() => state.latestSequence), | ||
| 156 | ); | ||
| 157 | |||
| 148 | state = { | 158 | state = { |
| 149 | ...state, | 159 | ...state, |
| 150 | latestSequence, | 160 | latestSequence, |
| @@ -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 | export * from "./knownEnvironment"; | 1 | export * from "./knownEnvironment"; |
| 2 | export * from "./scoped"; | 2 | export * from "./scoped"; |
| 3 | export * from "./gitActions"; | 3 | export * from "./gitActions"; |
| 4 | export * from "./gitActionState"; | ||
| 5 | export * from "./gitBranchState"; | ||
| 6 | export * from "./gitStatusState"; | ||
| 4 | export * from "./wsRpcProtocol"; | 7 | export * from "./wsRpcProtocol"; |
| 5 | export * from "./wsTransport"; | 8 | export * from "./wsTransport"; |
| 6 | export * from "./wsRpcClient"; | 9 | export * from "./wsRpcClient"; |