- Rework the mobile shell around home, thread, and task sheets - Add pairing, connection editing, and git branch/worktree actions - Share client-runtime git helpers across mobile and web
Unified · Split
| @@ -0,0 +1,15 @@ | |||
| 1 | - Expo Router for navigation | ||
| 2 | - / (Home, list of recent threads) | ||
| 3 | - /connections (Connections, list of connected environments) | ||
| 4 | - /connections/new (Add backend, QR code scanner, manual input) | ||
| 5 | - /new (New thread) | ||
| 6 | - /new (Sheet, select project) | ||
| 7 | - /draft (New thread, prompt, select model, environment, workspace etc) | ||
| 8 | These two is 2 pages in one sheet with sliding animation between them | ||
| 9 | - effect/rpc for typed rpc | ||
| 10 | - Uniwind? | ||
| 11 | - Theme it like the desktop app | ||
| 12 | - Share more code between web and mobile using @t3tools/client-runtime | ||
| 13 | - Make sure model list, options etc are server authorative and not hardcoded in the client | ||
| 14 | - Fix bug where sheet content doesn't scroll | ||
| 15 | - Allow pasting images into text input | ||
| @@ -1,8 +1,8 @@ | |||
| 1 | { | 1 | { |
| 2 | "expo": { | 2 | "expo": { |
| 3 | "name": "T3 Remote", | 3 | "name": "T3 Code", |
| 4 | "slug": "t3-remote", | 4 | "slug": "t3-code", |
| 5 | "scheme": "t3remote", | 5 | "scheme": "t3code", |
| 6 | "version": "0.1.0", | 6 | "version": "0.1.0", |
| 7 | "runtimeVersion": { | 7 | "runtimeVersion": { |
| 8 | "policy": "appVersion" | 8 | "policy": "appVersion" |
| @@ -12,7 +12,7 @@ · 3 unmodified lines | |||
| 12 | "userInterfaceStyle": "automatic", | 12 | "userInterfaceStyle": "automatic", |
| 13 | "updates": { | 13 | "updates": { |
| 14 | "enabled": true, | 14 | "enabled": true, |
| 15 | "url": "https://u.expo.dev/525f875b-b769-4d0b-88d7-c55e97e42943", | 15 | "url": "https://u.expo.dev/d763fcb8-d37c-41ea-a773-b54a0ab4a454", |
| 16 | "checkAutomatically": "ON_LOAD", | 16 | "checkAutomatically": "ON_LOAD", |
| 17 | "fallbackToCacheTimeout": 0 | 17 | "fallbackToCacheTimeout": 0 |
| 18 | }, | 18 | }, |
| @@ -24,7 +24,7 @@ · 5 unmodified lines | |||
| 24 | "ios": { | 24 | "ios": { |
| 25 | "icon": "./assets/icon.png", | 25 | "icon": "./assets/icon.png", |
| 26 | "supportsTablet": true, | 26 | "supportsTablet": true, |
| 27 | "bundleIdentifier": "com.t3tools.t3remote", | 27 | "bundleIdentifier": "com.t3tools.t3code", |
| 28 | "infoPlist": { | 28 | "infoPlist": { |
| 29 | "NSAppTransportSecurity": { | 29 | "NSAppTransportSecurity": { |
| 30 | "NSAllowsArbitraryLoads": true | 30 | "NSAllowsArbitraryLoads": true |
| @@ -34,7 +34,7 @@ · 3 unmodified lines | |||
| 34 | }, | 34 | }, |
| 35 | "android": { | 35 | "android": { |
| 36 | "icon": "./assets/icon.png", | 36 | "icon": "./assets/icon.png", |
| 37 | "package": "com.t3tools.t3remote", | 37 | "package": "com.t3tools.t3code", |
| 38 | "usesCleartextTraffic": true, | 38 | "usesCleartextTraffic": true, |
| 39 | "adaptiveIcon": { | 39 | "adaptiveIcon": { |
| 40 | "backgroundColor": "#E6F4FE", | 40 | "backgroundColor": "#E6F4FE", |
| @@ -48,6 +48,13 @@ · 7 unmodified lines | |||
| 48 | "favicon": "./assets/favicon.png" | 48 | "favicon": "./assets/favicon.png" |
| 49 | }, | 49 | }, |
| 50 | "plugins": [ | 50 | "plugins": [ |
| 51 | [ | ||
| 52 | "expo-camera", | ||
| 53 | { | ||
| 54 | "cameraPermission": "Allow T3 Code to access your camera so you can scan pairing QR codes.", | ||
| 55 | "barcodeScannerEnabled": true | ||
| 56 | } | ||
| 57 | ], | ||
| 51 | [ | 58 | [ |
| 52 | "expo-splash-screen", | 59 | "expo-splash-screen", |
| 53 | { | 60 | { |
| @@ -61,8 +68,9 @@ · 7 unmodified lines | |||
| 61 | ], | 68 | ], |
| 62 | "extra": { | 69 | "extra": { |
| 63 | "eas": { | 70 | "eas": { |
| 64 | "projectId": "525f875b-b769-4d0b-88d7-c55e97e42943" | 71 | "projectId": "d763fcb8-d37c-41ea-a773-b54a0ab4a454" |
| 65 | } | 72 | } |
| 66 | } | 73 | }, |
| 74 | "owner": "pingdotgg" | ||
| 67 | } | 75 | } |
| 68 | } | 76 | } |
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
| @@ -1,3 +1,4 @@ | |||
| 1 | import "react-native-gesture-handler"; | ||
| 1 | import { registerRootComponent } from "expo"; | 2 | import { registerRootComponent } from "expo"; |
| 2 | 3 | ||
| 3 | import App from "./App"; | 4 | import App from "./App"; |
| @@ -1,8 +1,12 @@ | |||
| 1 | const path = require("node:path"); | ||
| 1 | const { getDefaultConfig } = require("expo/metro-config"); | 2 | const { getDefaultConfig } = require("expo/metro-config"); |
| 2 | const { withNativewind } = require("nativewind/metro"); | 3 | const { withNativewind } = require("nativewind/metro"); |
| 3 | 4 | ||
| 4 | /** @type {import("expo/metro-config").MetroConfig} */ | 5 | /** @type {import("expo/metro-config").MetroConfig} */ |
| 5 | const config = getDefaultConfig(__dirname); | 6 | const config = getDefaultConfig(__dirname); |
| 7 | const workspaceRoot = path.resolve(__dirname, "../.."); | ||
| 8 | |||
| 9 | config.watchFolders = [...new Set([...(config.watchFolders ?? []), workspaceRoot])]; | ||
| 6 | 10 | ||
| 7 | module.exports = withNativewind(config, { | 11 | module.exports = withNativewind(config, { |
| 8 | input: "./global.css", | 12 | input: "./global.css", |
| @@ -1,3 +1,5 @@ | |||
| 1 | /// <reference types="react-native-css/types" /> | 1 | /// <reference types="react-native-css/types" /> |
| 2 | 2 | ||
| 3 | // NOTE: This file is generated by react-native-css / NativeWind integration. | 3 | // NOTE: This file is generated by react-native-css / NativeWind integration. |
| 4 | |||
| 5 | declare module "*.css"; | ||
| @@ -13,14 +13,18 @@ · 12 unmodified lines | |||
| 13 | }, | 13 | }, |
| 14 | "dependencies": { | 14 | "dependencies": { |
| 15 | "@expo-google-fonts/dm-sans": "^0.4.2", | 15 | "@expo-google-fonts/dm-sans": "^0.4.2", |
| 16 | "@gorhom/bottom-sheet": "^5", | ||
| 16 | "@react-native-async-storage/async-storage": "2.2.0", | 17 | "@react-native-async-storage/async-storage": "2.2.0", |
| 17 | "@react-native-masked-view/masked-view": "0.3.2", | 18 | "@react-native-masked-view/masked-view": "0.3.2", |
| 19 | "@react-native-menu/menu": "^2.0.0", | ||
| 18 | "@sethwebster/react-ethereal-input": "^0.4.0", | 20 | "@sethwebster/react-ethereal-input": "^0.4.0", |
| 19 | "@shopify/flash-list": "2.0.2", | 21 | "@shopify/flash-list": "2.0.2", |
| 22 | "@t3tools/client-runtime": "workspace:*", | ||
| 20 | "@t3tools/contracts": "workspace:*", | 23 | "@t3tools/contracts": "workspace:*", |
| 21 | "@t3tools/shared": "workspace:*", | 24 | "@t3tools/shared": "workspace:*", |
| 22 | "effect": "catalog:", | 25 | "effect": "catalog:", |
| 23 | "expo": "~55.0.8", | 26 | "expo": "~55.0.8", |
| 27 | "expo-camera": "~55.0.15", | ||
| 24 | "expo-clipboard": "~55.0.9", | 28 | "expo-clipboard": "~55.0.9", |
| 25 | "expo-constants": "^55.0.9", | 29 | "expo-constants": "^55.0.9", |
| 26 | "expo-font": "^55.0.4", | 30 | "expo-font": "^55.0.4", |
| @@ -36,6 +40,7 @@ · 9 unmodified lines | |||
| 36 | "react": "19.2.0", | 40 | "react": "19.2.0", |
| 37 | "react-native": "0.83.4", | 41 | "react-native": "0.83.4", |
| 38 | "react-native-css": "3.0.7", | 42 | "react-native-css": "3.0.7", |
| 43 | "react-native-gesture-handler": "~2.31.1", | ||
| 39 | "react-native-markdown-display": "^7.0.2", | 44 | "react-native-markdown-display": "^7.0.2", |
| 40 | "react-native-reanimated": "4.2.1", | 45 | "react-native-reanimated": "4.2.1", |
| 41 | "react-native-safe-area-context": "~5.6.2", | 46 | "react-native-safe-area-context": "~5.6.2", |
| @@ -1,6 +1,14 @@ | |||
| 1 | import { useColorScheme } from "react-native"; | 1 | import { useColorScheme } from "react-native"; |
| 2 | import "./../../global.css"; | 2 | import "./../../global.css"; |
| 3 | 3 | ||
| 4 | import { | ||
| 5 | DMSans_400Regular, | ||
| 6 | DMSans_500Medium, | ||
| 7 | DMSans_700Bold, | ||
| 8 | useFonts, | ||
| 9 | } from "@expo-google-fonts/dm-sans"; | ||
| 10 | import { BottomSheetModalProvider } from "@gorhom/bottom-sheet"; | ||
| 11 | import { GestureHandlerRootView } from "react-native-gesture-handler"; | ||
| 4 | import { SafeAreaProvider } from "react-native-safe-area-context"; | 12 | import { SafeAreaProvider } from "react-native-safe-area-context"; |
| 5 | 13 | ||
| 6 | import { LoadingScreen } from "../components/LoadingScreen"; | 14 | import { LoadingScreen } from "../components/LoadingScreen"; |
| @@ -10,10 +18,15 @@ import { useRemoteAppState } from "./useRemoteAppState"; · 3 unmodified lines | |||
| 10 | export default function App() { | 18 | export default function App() { |
| 11 | const colorScheme = useColorScheme(); | 19 | const colorScheme = useColorScheme(); |
| 12 | const isDarkMode = colorScheme !== "light"; | 20 | const isDarkMode = colorScheme !== "light"; |
| 21 | const [fontsLoaded] = useFonts({ | ||
| 22 | DMSans_400Regular, | ||
| 23 | DMSans_500Medium, | ||
| 24 | DMSans_700Bold, | ||
| 25 | }); | ||
| 13 | const app = useRemoteAppState(); | 26 | const app = useRemoteAppState(); |
| 14 | let content; | 27 | let content; |
| 15 | 28 | ||
| 16 | if (app.isLoadingSavedConnection) { | 29 | if (!fontsLoaded || app.isLoadingSavedConnection) { |
| 17 | content = <LoadingScreen isDarkMode={isDarkMode} message="Loading remote workspace…" />; | 30 | content = <LoadingScreen isDarkMode={isDarkMode} message="Loading remote workspace…" />; |
| 18 | } else if (app.reconnectingScreenVisible) { | 31 | } else if (app.reconnectingScreenVisible) { |
| 19 | content = <LoadingScreen isDarkMode={isDarkMode} message="Reconnecting…" />; | 32 | content = <LoadingScreen isDarkMode={isDarkMode} message="Reconnecting…" />; |
| @@ -21,5 +34,11 @@ export default function App() { · 1 unmodified line | |||
| 21 | content = <MobileAppShell app={app} isDarkMode={isDarkMode} />; | 34 | content = <MobileAppShell app={app} isDarkMode={isDarkMode} />; |
| 22 | } | 35 | } |
| 23 | 36 | ||
| 24 | return <SafeAreaProvider>{content}</SafeAreaProvider>; | 37 | return ( |
| 38 | <GestureHandlerRootView style={{ flex: 1 }}> | ||
| 39 | <BottomSheetModalProvider> | ||
| 40 | <SafeAreaProvider>{content}</SafeAreaProvider> | ||
| 41 | </BottomSheetModalProvider> | ||
| 42 | </GestureHandlerRootView> | ||
| 43 | ); | ||
| 25 | } | 44 | } |
| @@ -1,198 +1,28 @@ | |||
| 1 | import { useCallback, useEffect, useRef, useState } from "react"; | 1 | import { useState } from "react"; |
| 2 | import { StatusBar, useWindowDimensions, View } from "react-native"; | 2 | import { StatusBar, View } from "react-native"; |
| 3 | import Animated, { | ||
| 4 | Easing, | ||
| 5 | interpolate, | ||
| 6 | runOnJS, | ||
| 7 | useAnimatedStyle, | ||
| 8 | useSharedValue, | ||
| 9 | withTiming, | ||
| 10 | } from "react-native-reanimated"; | ||
| 11 | 3 | ||
| 12 | import { ConnectionSheet } from "../features/connection/ConnectionSheet"; | 4 | import { ConnectionSheet } from "../features/connection/ConnectionSheet"; |
| 5 | import { HomeScreen } from "../features/home/HomeScreen"; | ||
| 6 | import { NewTaskSheet } from "../features/threads/NewTaskSheet"; | ||
| 13 | import { ThreadDetailScreen } from "../features/threads/ThreadDetailScreen"; | 7 | import { ThreadDetailScreen } from "../features/threads/ThreadDetailScreen"; |
| 14 | import { ThreadListScreen, type TransitionSourceFrame } from "../features/threads/ThreadListScreen"; | 8 | import { ThreadNavigationDrawer } from "../features/threads/ThreadNavigationDrawer"; |
| 15 | import type { ScopedMobileThread } from "../lib/scopedEntities"; | 9 | import { scopedThreadKey } from "../lib/scopedEntities"; |
| 16 | import type { RemoteAppModel } from "./useRemoteAppState"; | 10 | import type { RemoteAppModel } from "./useRemoteAppState"; |
| 17 | 11 | ||
| 18 | function revealCenter( | ||
| 19 | sourceFrame: TransitionSourceFrame | null, | ||
| 20 | width: number, | ||
| 21 | height: number, | ||
| 22 | ): { readonly x: number; readonly y: number } { | ||
| 23 | "worklet"; | ||
| 24 | if (!sourceFrame) { | ||
| 25 | return { | ||
| 26 | x: width / 2, | ||
| 27 | y: height / 2, | ||
| 28 | }; | ||
| 29 | } | ||
| 30 | |||
| 31 | return { | ||
| 32 | x: sourceFrame.x + sourceFrame.width / 2, | ||
| 33 | y: sourceFrame.y + sourceFrame.height / 2, | ||
| 34 | }; | ||
| 35 | } | ||
| 36 | |||
| 37 | function revealRadius(centerX: number, centerY: number, width: number, height: number): number { | ||
| 38 | "worklet"; | ||
| 39 | const distances = [ | ||
| 40 | Math.hypot(centerX, centerY), | ||
| 41 | Math.hypot(width - centerX, centerY), | ||
| 42 | Math.hypot(centerX, height - centerY), | ||
| 43 | Math.hypot(width - centerX, height - centerY), | ||
| 44 | ]; | ||
| 45 | |||
| 46 | return Math.max(...distances); | ||
| 47 | } | ||
| 48 | |||
| 49 | function useRevealTransition( | ||
| 50 | selectedThread: ScopedMobileThread | null, | ||
| 51 | onSelectThread: (thread: ScopedMobileThread) => void, | ||
| 52 | onBackFromThread: () => void, | ||
| 53 | width: number, | ||
| 54 | height: number, | ||
| 55 | ) { | ||
| 56 | const [transitionSource, setTransitionSource] = useState<TransitionSourceFrame | null>(null); | ||
| 57 | const [transitionPhase, setTransitionPhase] = useState<"idle" | "opening" | "closing">("idle"); | ||
| 58 | const revealProgress = useSharedValue(1); | ||
| 59 | const openingAnimationFrameRef = useRef<number | null>(null); | ||
| 60 | |||
| 61 | const revealMaskStyle = useAnimatedStyle(() => { | ||
| 62 | const center = revealCenter(transitionSource, width, height); | ||
| 63 | const startRadius = transitionSource | ||
| 64 | ? Math.max(transitionSource.width, transitionSource.height) * 0.3 | ||
| 65 | : 20; | ||
| 66 | const endRadius = revealRadius(center.x, center.y, width, height); | ||
| 67 | const radius = startRadius + (endRadius - startRadius) * revealProgress.value; | ||
| 68 | |||
| 69 | return { | ||
| 70 | position: "absolute", | ||
| 71 | left: center.x - radius, | ||
| 72 | top: center.y - radius, | ||
| 73 | width: radius * 2, | ||
| 74 | height: radius * 2, | ||
| 75 | borderRadius: radius, | ||
| 76 | overflow: "hidden", | ||
| 77 | opacity: interpolate(revealProgress.value, [0, 0.12, 1], [0, 1, 1]), | ||
| 78 | }; | ||
| 79 | }); | ||
| 80 | |||
| 81 | const revealContentStyle = useAnimatedStyle(() => { | ||
| 82 | const center = revealCenter(transitionSource, width, height); | ||
| 83 | const startRadius = transitionSource | ||
| 84 | ? Math.max(transitionSource.width, transitionSource.height) * 0.3 | ||
| 85 | : 20; | ||
| 86 | const endRadius = revealRadius(center.x, center.y, width, height); | ||
| 87 | const radius = startRadius + (endRadius - startRadius) * revealProgress.value; | ||
| 88 | |||
| 89 | return { | ||
| 90 | width, | ||
| 91 | height, | ||
| 92 | transform: [{ translateX: -(center.x - radius) }, { translateY: -(center.y - radius) }], | ||
| 93 | }; | ||
| 94 | }); | ||
| 95 | |||
| 96 | const handleSelectThread = useCallback( | ||
| 97 | (thread: ScopedMobileThread, sourceFrame: TransitionSourceFrame | null): void => { | ||
| 98 | setTransitionSource(sourceFrame); | ||
| 99 | setTransitionPhase("opening"); | ||
| 100 | revealProgress.value = 0; | ||
| 101 | onSelectThread(thread); | ||
| 102 | }, | ||
| 103 | [onSelectThread, revealProgress], | ||
| 104 | ); | ||
| 105 | |||
| 106 | const handleBackFromThread = useCallback((): void => { | ||
| 107 | if (!selectedThread) { | ||
| 108 | return; | ||
| 109 | } | ||
| 110 | |||
| 111 | setTransitionPhase("closing"); | ||
| 112 | revealProgress.value = withTiming( | ||
| 113 | 0, | ||
| 114 | { | ||
| 115 | duration: 260, | ||
| 116 | easing: Easing.inOut(Easing.cubic), | ||
| 117 | }, | ||
| 118 | (finished) => { | ||
| 119 | if (!finished) { | ||
| 120 | return; | ||
| 121 | } | ||
| 122 | runOnJS(onBackFromThread)(); | ||
| 123 | runOnJS(setTransitionPhase)("idle"); | ||
| 124 | runOnJS(setTransitionSource)(null); | ||
| 125 | revealProgress.value = 1; | ||
| 126 | }, | ||
| 127 | ); | ||
| 128 | }, [onBackFromThread, revealProgress, selectedThread]); | ||
| 129 | |||
| 130 | useEffect(() => { | ||
| 131 | if (transitionPhase !== "opening" || !selectedThread) { | ||
| 132 | return; | ||
| 133 | } | ||
| 134 | |||
| 135 | if (openingAnimationFrameRef.current !== null) { | ||
| 136 | cancelAnimationFrame(openingAnimationFrameRef.current); | ||
| 137 | } | ||
| 138 | |||
| 139 | openingAnimationFrameRef.current = requestAnimationFrame(() => { | ||
| 140 | openingAnimationFrameRef.current = null; | ||
| 141 | revealProgress.value = withTiming( | ||
| 142 | 1, | ||
| 143 | { | ||
| 144 | duration: 420, | ||
| 145 | easing: Easing.out(Easing.cubic), | ||
| 146 | }, | ||
| 147 | (finished) => { | ||
| 148 | if (!finished) { | ||
| 149 | return; | ||
| 150 | } | ||
| 151 | runOnJS(setTransitionPhase)("idle"); | ||
| 152 | }, | ||
| 153 | ); | ||
| 154 | }); | ||
| 155 | |||
| 156 | return () => { | ||
| 157 | if (openingAnimationFrameRef.current !== null) { | ||
| 158 | cancelAnimationFrame(openingAnimationFrameRef.current); | ||
| 159 | openingAnimationFrameRef.current = null; | ||
| 160 | } | ||
| 161 | }; | ||
| 162 | }, [selectedThread, revealProgress, transitionPhase]); | ||
| 163 | |||
| 164 | return { | ||
| 165 | transitionPhase, | ||
| 166 | revealMaskStyle, | ||
| 167 | revealContentStyle, | ||
| 168 | handleSelectThread, | ||
| 169 | handleBackFromThread, | ||
| 170 | }; | ||
| 171 | } | ||
| 172 | |||
| 173 | export function MobileAppShell(props: { | 12 | export function MobileAppShell(props: { |
| 174 | readonly app: RemoteAppModel; | 13 | readonly app: RemoteAppModel; |
| 175 | readonly isDarkMode: boolean; | 14 | readonly isDarkMode: boolean; |
| 176 | }) { | 15 | }) { |
| 177 | const { app } = props; | 16 | const { app } = props; |
| 178 | const { width, height } = useWindowDimensions(); | 17 | const [threadsSheetVisible, setThreadsSheetVisible] = useState(false); |
| 179 | const backgroundColor = props.isDarkMode ? "#020617" : "#f8fafc"; | 18 | const [newTaskVisible, setNewTaskVisible] = useState(false); |
| 19 | const backgroundColor = props.isDarkMode ? "#020617" : "#f6f4ef"; | ||
| 180 | 20 | ||
| 181 | const { | 21 | const selectedThreadKey = app.selectedThread |
| 182 | transitionPhase, | 22 | ? scopedThreadKey(app.selectedThread.environmentId, app.selectedThread.id) |
| 183 | revealMaskStyle, | 23 | : null; |
| 184 | revealContentStyle, | ||
| 185 | handleSelectThread, | ||
| 186 | handleBackFromThread, | ||
| 187 | } = useRevealTransition( | ||
| 188 | app.selectedThread, | ||
| 189 | app.onSelectThread, | ||
| 190 | app.onBackFromThread, | ||
| 191 | width, | ||
| 192 | height, | ||
| 193 | ); | ||
| 194 | 24 | ||
| 195 | const sharedDetailProps = app.selectedThread | 25 | const selectedThreadDetailProps = app.selectedThread |
| 196 | ? { | 26 | ? { |
| 197 | selectedThread: app.selectedThread, | 27 | selectedThread: app.selectedThread, |
| 198 | screenTone: app.screenTone, | 28 | screenTone: app.screenTone, |
| @@ -211,14 +41,24 @@ export function MobileAppShell(props: { · 12 unmodified lines | |||
| 211 | draftAttachments: app.draftAttachments, | 41 | draftAttachments: app.draftAttachments, |
| 212 | connectionStateLabel: app.connectionState, | 42 | connectionStateLabel: app.connectionState, |
| 213 | activeThreadBusy: app.activeThreadBusy, | 43 | activeThreadBusy: app.activeThreadBusy, |
| 44 | selectedThreadGitStatus: app.selectedThreadGitStatus, | ||
| 45 | gitOperationLabel: app.gitOperationLabel, | ||
| 214 | selectedThreadQueueCount: app.selectedThreadQueueCount, | 46 | selectedThreadQueueCount: app.selectedThreadQueueCount, |
| 215 | onBack: handleBackFromThread, | 47 | onBack: app.onBackFromThread, |
| 48 | onOpenDrawer: () => setThreadsSheetVisible(true), | ||
| 216 | onOpenConnectionEditor: app.onOpenConnectionEditor, | 49 | onOpenConnectionEditor: app.onOpenConnectionEditor, |
| 217 | onChangeDraftMessage: app.onChangeDraftMessage, | 50 | onChangeDraftMessage: app.onChangeDraftMessage, |
| 218 | onPickDraftImages: app.onPickDraftImages, | 51 | onPickDraftImages: app.onPickDraftImages, |
| 219 | onPasteIntoDraft: app.onPasteIntoDraft, | 52 | onPasteIntoDraft: app.onPasteIntoDraft, |
| 220 | onRemoveDraftImage: app.onRemoveDraftImage, | 53 | onRemoveDraftImage: app.onRemoveDraftImage, |
| 221 | onRefresh: app.onRefresh, | 54 | onRefresh: app.onRefresh, |
| 55 | onRefreshSelectedThreadGitStatus: app.onRefreshSelectedThreadGitStatus, | ||
| 56 | onListSelectedThreadBranches: app.onListSelectedThreadBranches, | ||
| 57 | onCheckoutSelectedThreadBranch: app.onCheckoutSelectedThreadBranch, | ||
| 58 | onCreateSelectedThreadBranch: app.onCreateSelectedThreadBranch, | ||
| 59 | onCreateSelectedThreadWorktree: app.onCreateSelectedThreadWorktree, | ||
| 60 | onPullSelectedThreadBranch: app.onPullSelectedThreadBranch, | ||
| 61 | onRunSelectedThreadGitAction: app.onRunSelectedThreadGitAction, | ||
| 222 | onRenameThread: app.onRenameThread, | 62 | onRenameThread: app.onRenameThread, |
| 223 | onStopThread: app.onStopThread, | 63 | onStopThread: app.onStopThread, |
| 224 | onSendMessage: app.onSendMessage, | 64 | onSendMessage: app.onSendMessage, |
| @@ -237,47 +77,19 @@ export function MobileAppShell(props: { · 12 unmodified lines | |||
| 237 | translucent | 77 | translucent |
| 238 | /> | 78 | /> |
| 239 | 79 | ||
| 240 | <View style={{ flex: 1 }} pointerEvents={app.selectedThread ? "none" : "auto"}> | 80 | {app.selectedThread && selectedThreadDetailProps ? ( |
| 241 | <ThreadListScreen | 81 | <ThreadDetailScreen {...selectedThreadDetailProps} /> |
| 242 | heroTitle={app.heroTitle} | 82 | ) : ( |
| 243 | showBrandWordmark={app.showBrandWordmark} | 83 | <HomeScreen |
| 244 | screenTone={app.screenTone} | ||
| 245 | connectionState={app.connectionState} | ||
| 246 | connectionPulse={app.hasRemoteActivity} | ||
| 247 | projects={app.projects} | 84 | projects={app.projects} |
| 248 | threads={app.threads} | 85 | threads={app.threads} |
| 249 | connectedEnvironmentCount={app.connectedEnvironmentCount} | 86 | connectionState={app.connectionState} |
| 250 | hasClient={app.hasClient} | 87 | connectionPulse={app.hasRemoteActivity} |
| 251 | hasServerConfig={app.serverConfig !== null} | ||
| 252 | hiddenThreadKey={ | ||
| 253 | app.selectedThread | ||
| 254 | ? `${app.selectedThread.environmentId}:${app.selectedThread.id}` | ||
| 255 | : null | ||
| 256 | } | ||
| 257 | connectionError={app.connectionError} | ||
| 258 | onOpenConnectionEditor={app.onOpenConnectionEditor} | 88 | onOpenConnectionEditor={app.onOpenConnectionEditor} |
| 259 | onRefresh={app.onRefresh} | 89 | onOpenNewTask={() => setNewTaskVisible(true)} |
| 260 | onCreateThread={app.onCreateThread} | 90 | onSelectThread={app.onSelectThread} |
| 261 | onSelectThread={handleSelectThread} | ||
| 262 | /> | 91 | /> |
| 263 | </View> | 92 | )} |
| 264 | |||
| 265 | {app.selectedThread && sharedDetailProps ? ( | ||
| 266 | <View className="absolute inset-0"> | ||
| 267 | <ThreadDetailScreen {...sharedDetailProps} showContent={transitionPhase === "idle"} /> | ||
| 268 | {transitionPhase !== "idle" ? ( | ||
| 269 | <Animated.View | ||
| 270 | className="absolute inset-0" | ||
| 271 | pointerEvents="none" | ||
| 272 | style={revealMaskStyle} | ||
| 273 | > | ||
| 274 | <Animated.View style={revealContentStyle}> | ||
| 275 | <ThreadDetailScreen {...sharedDetailProps} showHeader={false} /> | ||
| 276 | </Animated.View> | ||
| 277 | </Animated.View> | ||
| 278 | ) : null} | ||
| 279 | </View> | ||
| 280 | ) : null} | ||
| 281 | 93 | ||
| 282 | <ConnectionSheet | 94 | <ConnectionSheet |
| 283 | visible={app.connectionSheetRequired} | 95 | visible={app.connectionSheetRequired} |
| @@ -288,9 +100,31 @@ export function MobileAppShell(props: { · 4 unmodified lines | |||
| 288 | onRequestClose={app.onRequestCloseConnectionEditor} | 100 | onRequestClose={app.onRequestCloseConnectionEditor} |
| 289 | onChangePairingUrl={app.onChangeConnectionPairingUrl} | 101 | onChangePairingUrl={app.onChangeConnectionPairingUrl} |
| 290 | onConnect={app.onConnectPress} | 102 | onConnect={app.onConnectPress} |
| 291 | onClose={app.onCloseConnectionEditor} | 103 | onUpdateEnvironment={app.onUpdateEnvironment} |
| 292 | onRemoveEnvironment={app.onRemoveEnvironmentPress} | 104 | onRemoveEnvironment={app.onRemoveEnvironmentPress} |
| 293 | /> | 105 | /> |
| 106 | |||
| 107 | <ThreadNavigationDrawer | ||
| 108 | visible={threadsSheetVisible} | ||
| 109 | projects={app.projects} | ||
| 110 | threads={app.threads} | ||
| 111 | selectedThreadKey={selectedThreadKey} | ||
| 112 | onClose={() => setThreadsSheetVisible(false)} | ||
| 113 | onSelectThread={app.onSelectThread} | ||
| 114 | onStartNewTask={() => setNewTaskVisible(true)} | ||
| 115 | /> | ||
| 116 | |||
| 117 | {newTaskVisible ? ( | ||
| 118 | <NewTaskSheet | ||
| 119 | visible={newTaskVisible} | ||
| 120 | projects={app.projects} | ||
| 121 | threads={app.threads} | ||
| 122 | serverConfigByEnvironmentId={app.serverConfigByEnvironmentId} | ||
| 123 | onRequestClose={() => setNewTaskVisible(false)} | ||
| 124 | onCreateThreadWithOptions={app.onCreateThreadWithOptions} | ||
| 125 | onListProjectBranches={app.onListProjectBranches} | ||
| 126 | /> | ||
| 127 | ) : null} | ||
| 294 | </View> | 128 | </View> |
| 295 | ); | 129 | ); |
| 296 | } | 130 | } |
| @@ -1,17 +1,28 @@ | |||
| 1 | import { useCallback, useEffect, useMemo, useRef, useState } from "react"; | 1 | import { useCallback, useEffect, useMemo, useRef, useState } from "react"; |
| 2 | import { Alert } from "react-native"; | 2 | import { Alert } from "react-native"; |
| 3 | 3 | ||
| 4 | import { buildGitActionProgressStages, type GitActionRequestInput } from "@t3tools/client-runtime"; | ||
| 4 | import { | 5 | import { |
| 5 | ApprovalRequestId, | 6 | ApprovalRequestId, |
| 6 | CommandId, | 7 | CommandId, |
| 7 | DEFAULT_PROVIDER_INTERACTION_MODE, | 8 | DEFAULT_PROVIDER_INTERACTION_MODE, |
| 8 | DEFAULT_RUNTIME_MODE, | 9 | DEFAULT_RUNTIME_MODE, |
| 10 | type GitBranch, | ||
| 11 | type GitRunStackedActionResult, | ||
| 12 | type GitStatusResult, | ||
| 9 | MessageId, | 13 | MessageId, |
| 14 | type ModelSelection, | ||
| 10 | type OrchestrationReadModel, | 15 | type OrchestrationReadModel, |
| 11 | type ProviderApprovalDecision, | 16 | type ProviderApprovalDecision, |
| 17 | type ProviderInteractionMode, | ||
| 18 | type RuntimeMode, | ||
| 12 | type ServerConfig as T3ServerConfig, | 19 | type ServerConfig as T3ServerConfig, |
| 13 | ThreadId, | 20 | ThreadId, |
| 14 | } from "@t3tools/contracts"; | 21 | } from "@t3tools/contracts"; |
| 22 | import { | ||
| 23 | dedupeRemoteBranchesWithLocalMatches, | ||
| 24 | sanitizeFeatureBranchName, | ||
| 25 | } from "@t3tools/shared/git"; | ||
| 15 | import { deriveActiveWorkStartedAt, formatElapsed } from "@t3tools/shared/orchestrationTiming"; | 26 | import { deriveActiveWorkStartedAt, formatElapsed } from "@t3tools/shared/orchestrationTiming"; |
| 16 | 27 | ||
| 17 | import { connectionTone } from "../features/connection/connectionTone"; | 28 | import { connectionTone } from "../features/connection/connectionTone"; |
| @@ -46,6 +57,7 @@ import { · 28 unmodified lines | |||
| 46 | } from "../lib/threadActivity"; | 57 | } from "../lib/threadActivity"; |
| 47 | import { sortCopy } from "../lib/arrayCompat"; | 58 | import { sortCopy } from "../lib/arrayCompat"; |
| 48 | import { newClientId } from "../lib/clientId"; | 59 | import { newClientId } from "../lib/clientId"; |
| 60 | import { buildTemporaryWorktreeBranchName } from "../lib/worktrees"; | ||
| 49 | 61 | ||
| 50 | export interface ConnectedEnvironmentSummary { | 62 | export interface ConnectedEnvironmentSummary { |
| 51 | readonly environmentId: string; | 63 | readonly environmentId: string; |
| @@ -65,6 +77,7 @@ export interface RemoteAppModel { · 13 unmodified lines | |||
| 65 | readonly connectedEnvironments: ReadonlyArray<ConnectedEnvironmentSummary>; | 77 | readonly connectedEnvironments: ReadonlyArray<ConnectedEnvironmentSummary>; |
| 66 | readonly connectedEnvironmentCount: number; | 78 | readonly connectedEnvironmentCount: number; |
| 67 | readonly serverConfig: T3ServerConfig | null; | 79 | readonly serverConfig: T3ServerConfig | null; |
| 80 | readonly serverConfigByEnvironmentId: Readonly<Record<string, T3ServerConfig | null>>; | ||
| 68 | readonly projects: ReadonlyArray<ScopedMobileProject>; | 81 | readonly projects: ReadonlyArray<ScopedMobileProject>; |
| 69 | readonly threads: ReadonlyArray<ScopedMobileThread>; | 82 | readonly threads: ReadonlyArray<ScopedMobileThread>; |
| 70 | readonly selectedThread: ScopedMobileThread | null; | 83 | readonly selectedThread: ScopedMobileThread | null; |
| @@ -81,6 +94,8 @@ export interface RemoteAppModel { · 10 unmodified lines | |||
| 81 | readonly draftAttachments: ReadonlyArray<DraftComposerImageAttachment>; | 94 | readonly draftAttachments: ReadonlyArray<DraftComposerImageAttachment>; |
| 82 | readonly screenTone: ReturnType<typeof connectionTone>; | 95 | readonly screenTone: ReturnType<typeof connectionTone>; |
| 83 | readonly activeThreadBusy: boolean; | 96 | readonly activeThreadBusy: boolean; |
| 97 | readonly selectedThreadGitStatus: GitStatusResult | null; | ||
| 98 | readonly gitOperationLabel: string | null; | ||
| 84 | readonly hasRemoteActivity: boolean; | 99 | readonly hasRemoteActivity: boolean; |
| 85 | readonly selectedEnvironmentBaseUrl: string | null; | 100 | readonly selectedEnvironmentBaseUrl: string | null; |
| 86 | readonly selectedEnvironmentBearerToken: string | null; | 101 | readonly selectedEnvironmentBearerToken: string | null; |
| @@ -92,15 +107,57 @@ export interface RemoteAppModel { · 5 unmodified lines | |||
| 92 | readonly onRequestCloseConnectionEditor: () => void; | 107 | readonly onRequestCloseConnectionEditor: () => void; |
| 93 | readonly onChangeConnectionPairingUrl: (pairingUrl: string) => void; | 108 | readonly onChangeConnectionPairingUrl: (pairingUrl: string) => void; |
| 94 | readonly onConnectPress: () => void; | 109 | readonly onConnectPress: () => void; |
| 110 | readonly onUpdateEnvironment: ( | ||
| 111 | environmentId: string, | ||
| 112 | updates: { readonly label: string; readonly displayUrl: string }, | ||
| 113 | ) => Promise<void>; | ||
| 95 | readonly onRemoveEnvironmentPress: (environmentId: string) => void; | 114 | readonly onRemoveEnvironmentPress: (environmentId: string) => void; |
| 96 | readonly onRefresh: () => Promise<void>; | 115 | readonly onRefresh: () => Promise<void>; |
| 97 | readonly onCreateThread: (project: ScopedMobileProject) => Promise<void>; | 116 | readonly onCreateThread: (project: ScopedMobileProject) => Promise<void>; |
| 117 | readonly onCreateThreadWithOptions: (input: { | ||
| 118 | readonly project: ScopedMobileProject; | ||
| 119 | readonly modelSelection: ModelSelection; | ||
| 120 | readonly envMode: "local" | "worktree"; | ||
| 121 | readonly branch: string | null; | ||
| 122 | readonly worktreePath: string | null; | ||
| 123 | readonly runtimeMode: RuntimeMode; | ||
| 124 | readonly interactionMode: ProviderInteractionMode; | ||
| 125 | readonly initialMessageText: string; | ||
| 126 | readonly initialAttachments: ReadonlyArray<DraftComposerImageAttachment>; | ||
| 127 | }) => Promise<void>; | ||
| 98 | readonly onSelectThread: (thread: ScopedMobileThread) => void; | 128 | readonly onSelectThread: (thread: ScopedMobileThread) => void; |
| 99 | readonly onBackFromThread: () => void; | 129 | readonly onBackFromThread: () => void; |
| 100 | readonly onChangeDraftMessage: (value: string) => void; | 130 | readonly onChangeDraftMessage: (value: string) => void; |
| 101 | readonly onPickDraftImages: () => Promise<void>; | 131 | readonly onPickDraftImages: () => Promise<void>; |
| 102 | readonly onPasteIntoDraft: () => Promise<void>; | 132 | readonly onPasteIntoDraft: () => Promise<void>; |
| 103 | readonly onRemoveDraftImage: (imageId: string) => void; | 133 | readonly onRemoveDraftImage: (imageId: string) => void; |
| 134 | readonly onRefreshSelectedThreadGitStatus: (options?: { | ||
| 135 | readonly quiet?: boolean; | ||
| 136 | }) => Promise<void>; | ||
| 137 | readonly onListProjectBranches: ( | ||
| 138 | project: ScopedMobileProject, | ||
| 139 | ) => Promise<ReadonlyArray<GitBranch>>; | ||
| 140 | readonly onCreateProjectWorktree: ( | ||
| 141 | project: ScopedMobileProject, | ||
| 142 | input: { | ||
| 143 | readonly baseBranch: string; | ||
| 144 | readonly newBranch: string; | ||
| 145 | }, | ||
| 146 | ) => Promise<{ | ||
| 147 | readonly branch: string; | ||
| 148 | readonly worktreePath: string; | ||
| 149 | } | null>; | ||
| 150 | readonly onListSelectedThreadBranches: () => Promise<ReadonlyArray<GitBranch>>; | ||
| 151 | readonly onCheckoutSelectedThreadBranch: (branch: string) => Promise<void>; | ||
| 152 | readonly onCreateSelectedThreadBranch: (branch: string) => Promise<void>; | ||
| 153 | readonly onCreateSelectedThreadWorktree: (input: { | ||
| 154 | readonly baseBranch: string; | ||
| 155 | readonly newBranch: string; | ||
| 156 | }) => Promise<void>; | ||
| 157 | readonly onPullSelectedThreadBranch: () => Promise<void>; | ||
| 158 | readonly onRunSelectedThreadGitAction: ( | ||
| 159 | input: GitActionRequestInput, | ||
| 160 | ) => Promise<GitRunStackedActionResult | null>; | ||
| 104 | readonly onSendMessage: () => void; | 161 | readonly onSendMessage: () => void; |
| 105 | readonly onRenameThread: (title: string) => Promise<void>; | 162 | readonly onRenameThread: (title: string) => Promise<void>; |
| 106 | readonly onStopThread: () => Promise<void>; | 163 | readonly onStopThread: () => Promise<void>; |
| @@ -147,6 +204,16 @@ function firstNonNull<T>(values: ReadonlyArray<T | null | undefined>): T | null · 40 unmodified lines | |||
| 147 | return null; | 204 | return null; |
| 148 | } | 205 | } |
| 149 | 206 | ||
| 207 | function deriveThreadTitleFromPrompt(value: string): string { | ||
| 208 | const trimmed = value.trim(); | ||
| 209 | if (trimmed.length === 0) { | ||
| 210 | return "New thread"; | ||
| 211 | } | ||
| 212 | |||
| 213 | const compact = trimmed.replace(/\s+/g, " "); | ||
| 214 | return compact.length <= 72 ? compact : `${compact.slice(0, 69).trimEnd()}...`; | ||
| 215 | } | ||
| 216 | |||
| 150 | function deriveOverallConnectionState( | 217 | function deriveOverallConnectionState( |
| 151 | environments: ReadonlyArray<ConnectedEnvironmentSummary>, | 218 | environments: ReadonlyArray<ConnectedEnvironmentSummary>, |
| 152 | ): RemoteClientConnectionState { | 219 | ): RemoteClientConnectionState { |
| @@ -268,6 +335,10 @@ export function useRemoteAppState(): RemoteAppModel { · 115 unmodified lines | |||
| 268 | null, | 335 | null, |
| 269 | ); | 336 | ); |
| 270 | const [dispatchingQueuedMessageId, setDispatchingQueuedMessageId] = useState<string | null>(null); | 337 | const [dispatchingQueuedMessageId, setDispatchingQueuedMessageId] = useState<string | null>(null); |
| 338 | const [selectedThreadGitStatus, setSelectedThreadGitStatus] = useState<GitStatusResult | null>( | ||
| 339 | null, | ||
| 340 | ); | ||
| 341 | const [gitOperationLabel, setGitOperationLabel] = useState<string | null>(null); | ||
| 271 | const [queuedMessagesByThreadKey, setQueuedMessagesByThreadKey] = useState< | 342 | const [queuedMessagesByThreadKey, setQueuedMessagesByThreadKey] = useState< |
| 272 | Record<string, ReadonlyArray<QueuedThreadMessage>> | 343 | Record<string, ReadonlyArray<QueuedThreadMessage>> |
| 273 | >({}); | 344 | >({}); |
| @@ -516,6 +587,333 @@ export function useRemoteAppState(): RemoteAppModel { · 242 unmodified lines | |||
| 516 | setSelectedThreadRef(null); | 587 | setSelectedThreadRef(null); |
| 517 | }, [selectedThread, selectedThreadRef]); | 588 | }, [selectedThread, selectedThreadRef]); |
| 518 | 589 | ||
| 590 | const selectedThreadProject = useMemo( | ||
| 591 | () => | ||
| 592 | selectedThread | ||
| 593 | ? (projects.find( | ||
| 594 | (project) => | ||
| 595 | project.environmentId === selectedThread.environmentId && | ||
| 596 | project.id === selectedThread.projectId, | ||
| 597 | ) ?? null) | ||
| 598 | : null, | ||
| 599 | [projects, selectedThread], | ||
| 600 | ); | ||
| 601 | |||
| 602 | const selectedThreadGitRootCwd = selectedThreadProject?.workspaceRoot ?? null; | ||
| 603 | |||
| 604 | const updateThreadGitContext = useCallback( | ||
| 605 | async ( | ||
| 606 | thread: ScopedMobileThread, | ||
| 607 | input: { | ||
| 608 | readonly branch?: string | null; | ||
| 609 | readonly worktreePath?: string | null; | ||
| 610 | }, | ||
| 611 | ) => { | ||
| 612 | const client = clientsRef.current.get(thread.environmentId); | ||
| 613 | if (!client) { | ||
| 614 | return; | ||
| 615 | } | ||
| 616 | |||
| 617 | await client.dispatchCommand({ | ||
| 618 | type: "thread.meta.update", | ||
| 619 | commandId: CommandId.makeUnsafe(newClientId("command")), | ||
| 620 | threadId: ThreadId.makeUnsafe(thread.id), | ||
| 621 | ...(input.branch !== undefined ? { branch: input.branch } : {}), | ||
| 622 | ...(input.worktreePath !== undefined ? { worktreePath: input.worktreePath } : {}), | ||
| 623 | }); | ||
| 624 | }, | ||
| 625 | [], | ||
| 626 | ); | ||
| 627 | |||
| 628 | const refreshSelectedThreadGitStatus = useCallback( | ||
| 629 | async (options?: { readonly quiet?: boolean; readonly cwd?: string | null }) => { | ||
| 630 | if (!selectedThread || !selectedThreadProject) { | ||
| 631 | setSelectedThreadGitStatus(null); | ||
| 632 | return null; | ||
| 633 | } | ||
| 634 | |||
| 635 | const cwd = | ||
| 636 | options?.cwd ?? selectedThread.worktreePath ?? selectedThreadProject.workspaceRoot; | ||
| 637 | if (!cwd) { | ||
| 638 | setSelectedThreadGitStatus(null); | ||
| 639 | return null; | ||
| 640 | } | ||
| 641 | |||
| 642 | if (!options?.quiet) { | ||
| 643 | setGitOperationLabel("Refreshing git status"); | ||
| 644 | } | ||
| 645 | |||
| 646 | try { | ||
| 647 | const client = clientsRef.current.get(selectedThread.environmentId); | ||
| 648 | if (!client) { | ||
| 649 | return null; | ||
| 650 | } | ||
| 651 | const status = await client.gitRefreshStatus({ cwd }); | ||
| 652 | setSelectedThreadGitStatus(status); | ||
| 653 | setPendingConnectionError(null); | ||
| 654 | return status; | ||
| 655 | } catch (error) { | ||
| 656 | const message = error instanceof Error ? error.message : "Failed to refresh git status."; | ||
| 657 | setPendingConnectionError(message); | ||
| 658 | return null; | ||
| 659 | } finally { | ||
| 660 | if (!options?.quiet) { | ||
| 661 | setGitOperationLabel(null); | ||
| 662 | } | ||
| 663 | } | ||
| 664 | }, | ||
| 665 | [selectedThread, selectedThreadProject], | ||
| 666 | ); | ||
| 667 | |||
| 668 | useEffect(() => { | ||
| 669 | if (!selectedThread || !selectedThreadProject) { | ||
| 670 | setSelectedThreadGitStatus(null); | ||
| 671 | return; | ||
| 672 | } | ||
| 673 | |||
| 674 | void refreshSelectedThreadGitStatus({ quiet: true }); | ||
| 675 | }, [refreshSelectedThreadGitStatus, selectedThread, selectedThreadProject]); | ||
| 676 | |||
| 677 | const runSelectedThreadGitMutation = useCallback( | ||
| 678 | async <T>( | ||
| 679 | label: string, | ||
| 680 | operation: (input: { | ||
| 681 | readonly client: RemoteClient; | ||
| 682 | readonly thread: ScopedMobileThread; | ||
| 683 | readonly project: ScopedMobileProject; | ||
| 684 | }) => Promise<T>, | ||
| 685 | ): Promise<T | null> => { | ||
| 686 | if (!selectedThread || !selectedThreadProject) { | ||
| 687 | return null; | ||
| 688 | } | ||
| 689 | |||
| 690 | const client = clientsRef.current.get(selectedThread.environmentId); | ||
| 691 | if (!client) { | ||
| 692 | return null; | ||
| 693 | } | ||
| 694 | |||
| 695 | setGitOperationLabel(label); | ||
| 696 | try { | ||
| 697 | setPendingConnectionError(null); | ||
| 698 | return await operation({ | ||
| 699 | client, | ||
| 700 | thread: selectedThread, | ||
| 701 | project: selectedThreadProject, | ||
| 702 | }); | ||
| 703 | } catch (error) { | ||
| 704 | setPendingConnectionError(error instanceof Error ? error.message : "Git action failed."); | ||
| 705 | return null; | ||
| 706 | } finally { | ||
| 707 | setGitOperationLabel(null); | ||
| 708 | } | ||
| 709 | }, | ||
| 710 | [selectedThread, selectedThreadProject], | ||
| 711 | ); | ||
| 712 | |||
| 713 | const onListSelectedThreadBranches = useCallback(async (): Promise<ReadonlyArray<GitBranch>> => { | ||
| 714 | if (!selectedThread || !selectedThreadProject || !selectedThreadGitRootCwd) { | ||
| 715 | return []; | ||
| 716 | } | ||
| 717 | |||
| 718 | const client = clientsRef.current.get(selectedThread.environmentId); | ||
| 719 | if (!client) { | ||
| 720 | return []; | ||
| 721 | } | ||
| 722 | |||
| 723 | try { | ||
| 724 | const result = await client.gitListBranches({ | ||
| 725 | cwd: selectedThreadGitRootCwd, | ||
| 726 | limit: 100, | ||
| 727 | }); | ||
| 728 | return dedupeRemoteBranchesWithLocalMatches(result.branches).filter( | ||
| 729 | (branch) => !branch.isRemote, | ||
| 730 | ); | ||
| 731 | } catch (error) { | ||
| 732 | setPendingConnectionError( | ||
| 733 | error instanceof Error ? error.message : "Failed to load branches.", | ||
| 734 | ); | ||
| 735 | return []; | ||
| 736 | } | ||
| 737 | }, [selectedThread, selectedThreadGitRootCwd, selectedThreadProject]); | ||
| 738 | |||
| 739 | const onListProjectBranches = useCallback( | ||
| 740 | async (project: ScopedMobileProject): Promise<ReadonlyArray<GitBranch>> => { | ||
| 741 | const client = clientsRef.current.get(project.environmentId); | ||
| 742 | if (!client) { | ||
| 743 | return []; | ||
| 744 | } | ||
| 745 | |||
| 746 | try { | ||
| 747 | const result = await client.gitListBranches({ | ||
| 748 | cwd: project.workspaceRoot, | ||
| 749 | limit: 100, | ||
| 750 | }); | ||
| 751 | return dedupeRemoteBranchesWithLocalMatches(result.branches).filter( | ||
| 752 | (branch) => !branch.isRemote, | ||
| 753 | ); | ||
| 754 | } catch (error) { | ||
| 755 | setPendingConnectionError( | ||
| 756 | error instanceof Error ? error.message : "Failed to load branches.", | ||
| 757 | ); | ||
| 758 | return []; | ||
| 759 | } | ||
| 760 | }, | ||
| 761 | [], | ||
| 762 | ); | ||
| 763 | |||
| 764 | const onCreateProjectWorktree = useCallback( | ||
| 765 | async ( | ||
| 766 | project: ScopedMobileProject, | ||
| 767 | input: { | ||
| 768 | readonly baseBranch: string; | ||
| 769 | readonly newBranch: string; | ||
| 770 | }, | ||
| 771 | ): Promise<{ | ||
| 772 | readonly branch: string; | ||
| 773 | readonly worktreePath: string; | ||
| 774 | } | null> => { | ||
| 775 | const client = clientsRef.current.get(project.environmentId); | ||
| 776 | if (!client) { | ||
| 777 | return null; | ||
| 778 | } | ||
| 779 | |||
| 780 | try { | ||
| 781 | const result = await client.gitCreateWorktree({ | ||
| 782 | cwd: project.workspaceRoot, | ||
| 783 | branch: input.baseBranch, | ||
| 784 | newBranch: sanitizeFeatureBranchName(input.newBranch), | ||
| 785 | path: null, | ||
| 786 | }); | ||
| 787 | return { | ||
| 788 | branch: result.worktree.branch, | ||
| 789 | worktreePath: result.worktree.path, | ||
| 790 | }; | ||
| 791 | } catch (error) { | ||
| 792 | setPendingConnectionError( | ||
| 793 | error instanceof Error ? error.message : "Failed to create worktree.", | ||
| 794 | ); | ||
| 795 | return null; | ||
| 796 | } | ||
| 797 | }, | ||
| 798 | [], | ||
| 799 | ); | ||
| 800 | |||
| 801 | const onCheckoutSelectedThreadBranch = useCallback( | ||
| 802 | async (branch: string) => { | ||
| 803 | await runSelectedThreadGitMutation( | ||
| 804 | "Checking out branch", | ||
| 805 | async ({ client, thread, project }) => { | ||
| 806 | const cwd = thread.worktreePath ?? project.workspaceRoot; | ||
| 807 | const result = await client.gitCheckout({ cwd, branch }); | ||
| 808 | await updateThreadGitContext(thread, { | ||
| 809 | branch: result.branch, | ||
| 810 | worktreePath: thread.worktreePath, | ||
| 811 | }); | ||
| 812 | await refreshSelectedThreadGitStatus({ quiet: true, cwd }); | ||
| 813 | }, | ||
| 814 | ); | ||
| 815 | }, | ||
| 816 | [refreshSelectedThreadGitStatus, runSelectedThreadGitMutation, updateThreadGitContext], | ||
| 817 | ); | ||
| 818 | |||
| 819 | const onCreateSelectedThreadBranch = useCallback( | ||
| 820 | async (branch: string) => { | ||
| 821 | await runSelectedThreadGitMutation("Creating branch", async ({ client, thread, project }) => { | ||
| 822 | const cwd = thread.worktreePath ?? project.workspaceRoot; | ||
| 823 | const result = await client.gitCreateBranch({ | ||
| 824 | cwd, | ||
| 825 | branch, | ||
| 826 | checkout: true, | ||
| 827 | }); | ||
| 828 | await updateThreadGitContext(thread, { | ||
| 829 | branch: result.branch, | ||
| 830 | worktreePath: thread.worktreePath, | ||
| 831 | }); | ||
| 832 | await refreshSelectedThreadGitStatus({ quiet: true, cwd }); | ||
| 833 | }); | ||
| 834 | }, | ||
| 835 | [refreshSelectedThreadGitStatus, runSelectedThreadGitMutation, updateThreadGitContext], | ||
| 836 | ); | ||
| 837 | |||
| 838 | const onCreateSelectedThreadWorktree = useCallback( | ||
| 839 | async (input: { readonly baseBranch: string; readonly newBranch: string }) => { | ||
| 840 | await runSelectedThreadGitMutation( | ||
| 841 | "Creating worktree", | ||
| 842 | async ({ client, thread, project }) => { | ||
| 843 | const result = await client.gitCreateWorktree({ | ||
| 844 | cwd: project.workspaceRoot, | ||
| 845 | branch: input.baseBranch, | ||
| 846 | newBranch: sanitizeFeatureBranchName(input.newBranch), | ||
| 847 | path: null, | ||
| 848 | }); | ||
| 849 | await updateThreadGitContext(thread, { | ||
| 850 | branch: result.worktree.branch, | ||
| 851 | worktreePath: result.worktree.path, | ||
| 852 | }); | ||
| 853 | await refreshSelectedThreadGitStatus({ quiet: true, cwd: result.worktree.path }); | ||
| 854 | }, | ||
| 855 | ); | ||
| 856 | }, | ||
| 857 | [refreshSelectedThreadGitStatus, runSelectedThreadGitMutation, updateThreadGitContext], | ||
| 858 | ); | ||
| 859 | |||
| 860 | const onPullSelectedThreadBranch = useCallback(async () => { | ||
| 861 | await runSelectedThreadGitMutation( | ||
| 862 | "Pulling latest changes", | ||
| 863 | async ({ client, thread, project }) => { | ||
| 864 | const cwd = thread.worktreePath ?? project.workspaceRoot; | ||
| 865 | await client.gitPull({ cwd }); | ||
| 866 | await refreshSelectedThreadGitStatus({ quiet: true, cwd }); | ||
| 867 | }, | ||
| 868 | ); | ||
| 869 | }, [refreshSelectedThreadGitStatus, runSelectedThreadGitMutation]); | ||
| 870 | |||
| 871 | const onRunSelectedThreadGitAction = useCallback( | ||
| 872 | async (input: GitActionRequestInput): Promise<GitRunStackedActionResult | null> => { | ||
| 873 | const [firstStage] = buildGitActionProgressStages({ | ||
| 874 | action: input.action, | ||
| 875 | hasCustomCommitMessage: Boolean(input.commitMessage?.trim()), | ||
| 876 | hasWorkingTreeChanges: selectedThreadGitStatus?.hasWorkingTreeChanges ?? false, | ||
| 877 | featureBranch: input.featureBranch ?? false, | ||
| 878 | shouldPushBeforePr: | ||
| 879 | input.action === "create_pr" && | ||
| 880 | ((selectedThreadGitStatus?.aheadCount ?? 0) > 0 || | ||
| 881 | !(selectedThreadGitStatus?.hasUpstream ?? false)), | ||
| 882 | }); | ||
| 883 | |||
| 884 | return await runSelectedThreadGitMutation( | ||
| 885 | firstStage ?? "Running git action", | ||
| 886 | async ({ client, thread, project }) => { | ||
| 887 | const cwd = thread.worktreePath ?? project.workspaceRoot; | ||
| 888 | const result = await client.gitRunStackedAction({ | ||
| 889 | actionId: newClientId("git-action"), | ||
| 890 | cwd, | ||
| 891 | action: input.action, | ||
| 892 | ...(input.commitMessage ? { commitMessage: input.commitMessage } : {}), | ||
| 893 | ...(input.featureBranch ? { featureBranch: input.featureBranch } : {}), | ||
| 894 | ...(input.filePaths?.length ? { filePaths: [...input.filePaths] } : {}), | ||
| 895 | }); | ||
| 896 | |||
| 897 | if (result.branch.status === "created" && result.branch.name) { | ||
| 898 | await updateThreadGitContext(thread, { | ||
| 899 | branch: result.branch.name, | ||
| 900 | worktreePath: thread.worktreePath, | ||
| 901 | }); | ||
| 902 | } | ||
| 903 | |||
| 904 | await refreshSelectedThreadGitStatus({ quiet: true, cwd }); | ||
| 905 | return result; | ||
| 906 | }, | ||
| 907 | ); | ||
| 908 | }, | ||
| 909 | [ | ||
| 910 | refreshSelectedThreadGitStatus, | ||
| 911 | runSelectedThreadGitMutation, | ||
| 912 | selectedThreadGitStatus, | ||
| 913 | updateThreadGitContext, | ||
| 914 | ], | ||
| 915 | ); | ||
| 916 | |||
| 519 | const selectedThreadKey = selectedThread | 917 | const selectedThreadKey = selectedThread |
| 520 | ? scopedThreadKey(selectedThread.environmentId, selectedThread.id) | 918 | ? scopedThreadKey(selectedThread.environmentId, selectedThread.id) |
| 521 | : null; | 919 | : null; |
| @@ -740,15 +1138,115 @@ export function useRemoteAppState(): RemoteAppModel { · 218 unmodified lines | |||
| 740 | } | 1138 | } |
| 741 | }), | 1139 | }), |
| 742 | ); | 1140 | ); |
| 743 | }, [savedConnectionsById, selectedThread]); | 1141 | if (selectedThread) { |
| 744 | 1142 | await refreshSelectedThreadGitStatus({ quiet: true }); | |
| 745 | const onCreateThread = useCallback( | 1143 | } |
| 746 | async (project: ScopedMobileProject) => { | 1144 | }, [refreshSelectedThreadGitStatus, savedConnectionsById, selectedThread]); |
| 747 | const client = clientsRef.current.get(project.environmentId); | 1145 | |
| 1146 | const onCreateThreadWithOptions = useCallback( | ||
| 1147 | async (input: { | ||
| 1148 | readonly project: ScopedMobileProject; | ||
| 1149 | readonly modelSelection: ModelSelection; | ||
| 1150 | readonly envMode: "local" | "worktree"; | ||
| 1151 | readonly branch: string | null; | ||
| 1152 | readonly worktreePath: string | null; | ||
| 1153 | readonly runtimeMode: RuntimeMode; | ||
| 1154 | readonly interactionMode: ProviderInteractionMode; | ||
| 1155 | readonly initialMessageText: string; | ||
| 1156 | readonly initialAttachments: ReadonlyArray<DraftComposerImageAttachment>; | ||
| 1157 | }) => { | ||
| 1158 | const client = clientsRef.current.get(input.project.environmentId); | ||
| 748 | if (!client) { | 1159 | if (!client) { |
| 749 | return; | 1160 | return; |
| 750 | } | 1161 | } |
| 751 | 1162 | ||
| 1163 | const threadId = ThreadId.makeUnsafe(newClientId("thread")); | ||
| 1164 | const createdAt = new Date().toISOString(); | ||
| 1165 | const initialMessageText = input.initialMessageText.trim(); | ||
| 1166 | const nextTitle = deriveThreadTitleFromPrompt(input.initialMessageText); | ||
| 1167 | if (input.envMode === "worktree") { | ||
| 1168 | if (!input.branch || initialMessageText.length === 0) { | ||
| 1169 | return; | ||
| 1170 | } | ||
| 1171 | |||
| 1172 | await client.dispatchCommand({ | ||
| 1173 | type: "thread.turn.start", | ||
| 1174 | commandId: CommandId.makeUnsafe(newClientId("command")), | ||
| 1175 | threadId, | ||
| 1176 | message: { | ||
| 1177 | messageId: MessageId.makeUnsafe(newClientId("message")), | ||
| 1178 | role: "user", | ||
| 1179 | text: initialMessageText, | ||
| 1180 | attachments: input.initialAttachments, | ||
| 1181 | }, | ||
| 1182 | modelSelection: input.modelSelection, | ||
| 1183 | titleSeed: nextTitle, | ||
| 1184 | runtimeMode: input.runtimeMode, | ||
| 1185 | interactionMode: input.interactionMode, | ||
| 1186 | bootstrap: { | ||
| 1187 | createThread: { | ||
| 1188 | projectId: input.project.id, | ||
| 1189 | title: nextTitle, | ||
| 1190 | modelSelection: input.modelSelection, | ||
| 1191 | runtimeMode: input.runtimeMode, | ||
| 1192 | interactionMode: input.interactionMode, | ||
| 1193 | branch: input.branch, | ||
| 1194 | worktreePath: null, | ||
| 1195 | createdAt, | ||
| 1196 | }, | ||
| 1197 | prepareWorktree: { | ||
| 1198 | projectCwd: input.project.workspaceRoot, | ||
| 1199 | baseBranch: input.branch, | ||
| 1200 | branch: buildTemporaryWorktreeBranchName(), | ||
| 1201 | }, | ||
| 1202 | runSetupScript: true, | ||
| 1203 | }, | ||
| 1204 | createdAt: new Date().toISOString(), | ||
| 1205 | }); | ||
| 1206 | } else { | ||
| 1207 | await client.dispatchCommand({ | ||
| 1208 | type: "thread.create", | ||
| 1209 | commandId: CommandId.makeUnsafe(newClientId("command")), | ||
| 1210 | threadId, | ||
| 1211 | projectId: input.project.id, | ||
| 1212 | title: nextTitle, | ||
| 1213 | modelSelection: input.modelSelection, | ||
| 1214 | runtimeMode: input.runtimeMode, | ||
| 1215 | interactionMode: input.interactionMode, | ||
| 1216 | branch: input.branch, | ||
| 1217 | worktreePath: input.worktreePath, | ||
| 1218 | createdAt, | ||
| 1219 | }); | ||
| 1220 | |||
| 1221 | if (initialMessageText.length > 0 || input.initialAttachments.length > 0) { | ||
| 1222 | await client.dispatchCommand({ | ||
| 1223 | type: "thread.turn.start", | ||
| 1224 | commandId: CommandId.makeUnsafe(newClientId("command")), | ||
| 1225 | threadId, | ||
| 1226 | message: { | ||
| 1227 | messageId: MessageId.makeUnsafe(newClientId("message")), | ||
| 1228 | role: "user", | ||
| 1229 | text: initialMessageText, | ||
| 1230 | attachments: input.initialAttachments, | ||
| 1231 | }, | ||
| 1232 | runtimeMode: input.runtimeMode, | ||
| 1233 | interactionMode: input.interactionMode, | ||
| 1234 | createdAt: new Date().toISOString(), | ||
| 1235 | }); | ||
| 1236 | } | ||
| 1237 | } | ||
| 1238 | |||
| 1239 | setSelectedThreadRef({ | ||
| 1240 | environmentId: input.project.environmentId, | ||
| 1241 | threadId, | ||
| 1242 | }); | ||
| 1243 | await onRefresh(); | ||
| 1244 | }, | ||
| 1245 | [onRefresh], | ||
| 1246 | ); | ||
| 1247 | |||
| 1248 | const onCreateThread = useCallback( | ||
| 1249 | async (project: ScopedMobileProject) => { | ||
| 752 | const latestProjectThread = | 1250 | const latestProjectThread = |
| 753 | threads.find( | 1251 | threads.find( |
| 754 | (thread) => | 1252 | (thread) => |
| @@ -761,28 +1259,19 @@ export function useRemoteAppState(): RemoteAppModel { · 6 unmodified lines | |||
| 761 | return; | 1259 | return; |
| 762 | } | 1260 | } |
| 763 | 1261 | ||
| 764 | const threadId = ThreadId.makeUnsafe(newClientId("thread")); | 1262 | await onCreateThreadWithOptions({ |
| 765 | const createdAt = new Date().toISOString(); | 1263 | project, |
| 766 | await client.dispatchCommand({ | ||
| 767 | type: "thread.create", | ||
| 768 | commandId: CommandId.makeUnsafe(newClientId("command")), | ||
| 769 | threadId, | ||
| 770 | projectId: project.id, | ||
| 771 | title: "New thread", | ||
| 772 | modelSelection, | 1264 | modelSelection, |
| 773 | runtimeMode: DEFAULT_RUNTIME_MODE, | 1265 | envMode: "local", |
| 774 | interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, | ||
| 775 | branch: null, | 1266 | branch: null, |
| 776 | worktreePath: null, | 1267 | worktreePath: null, |
| 777 | createdAt, | 1268 | runtimeMode: DEFAULT_RUNTIME_MODE, |
| 778 | }); | 1269 | interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, |
| 779 | setSelectedThreadRef({ | 1270 | initialMessageText: "", |
| 780 | environmentId: project.environmentId, | 1271 | initialAttachments: [], |
| 781 | threadId, | ||
| 782 | }); | 1272 | }); |
| 783 | await onRefresh(); | ||
| 784 | }, | 1273 | }, |
| 785 | [onRefresh, threads], | 1274 | [onCreateThreadWithOptions, threads], |
| 786 | ); | 1275 | ); |
| 787 | 1276 | ||
| 788 | const onConnectPress = useCallback(() => { | 1277 | const onConnectPress = useCallback(() => { |
| @@ -825,6 +1314,31 @@ export function useRemoteAppState(): RemoteAppModel { · 36 unmodified lines | |||
| 825 | [disconnectEnvironment, savedConnectionsById], | 1314 | [disconnectEnvironment, savedConnectionsById], |
| 826 | ); | 1315 | ); |
| 827 | 1316 | ||
| 1317 | const onUpdateEnvironment = useCallback( | ||
| 1318 | async ( | ||
| 1319 | environmentId: string, | ||
| 1320 | updates: { readonly label: string; readonly displayUrl: string }, | ||
| 1321 | ) => { | ||
| 1322 | const connection = savedConnectionsById[environmentId]; | ||
| 1323 | if (!connection) { | ||
| 1324 | return; | ||
| 1325 | } | ||
| 1326 | |||
| 1327 | const updated: SavedRemoteConnection = { | ||
| 1328 | ...connection, | ||
| 1329 | environmentLabel: updates.label.trim() || connection.environmentLabel, | ||
| 1330 | displayUrl: updates.displayUrl.trim() || connection.displayUrl, | ||
| 1331 | }; | ||
| 1332 | |||
| 1333 | await saveConnection(updated); | ||
| 1334 | setSavedConnectionsById((current) => ({ | ||
| 1335 | ...current, | ||
| 1336 | [environmentId]: updated, | ||
| 1337 | })); | ||
| 1338 | }, | ||
| 1339 | [savedConnectionsById], | ||
| 1340 | ); | ||
| 1341 | |||
| 828 | const onSendMessage = useCallback(() => { | 1342 | const onSendMessage = useCallback(() => { |
| 829 | if (!selectedThread) { | 1343 | if (!selectedThread) { |
| 830 | return; | 1344 | return; |
| @@ -1105,6 +1619,16 @@ export function useRemoteAppState(): RemoteAppModel { · 274 unmodified lines | |||
| 1105 | const serverConfig = | 1619 | const serverConfig = |
| 1106 | selectedEnvironmentRuntime?.serverConfig ?? | 1620 | selectedEnvironmentRuntime?.serverConfig ?? |
| 1107 | firstNonNull(Object.values(environmentStateById).map((runtime) => runtime.serverConfig)); | 1621 | firstNonNull(Object.values(environmentStateById).map((runtime) => runtime.serverConfig)); |
| 1622 | const serverConfigByEnvironmentId = useMemo( | ||
| 1623 | () => | ||
| 1624 | Object.fromEntries( | ||
| 1625 | Object.entries(environmentStateById).map(([environmentId, runtime]) => [ | ||
| 1626 | environmentId, | ||
| 1627 | runtime.serverConfig ?? null, | ||
| 1628 | ]), | ||
| 1629 | ), | ||
| 1630 | [environmentStateById], | ||
| 1631 | ); | ||
| 1108 | const hasClient = connectedEnvironments.length > 0; | 1632 | const hasClient = connectedEnvironments.length > 0; |
| 1109 | 1633 | ||
| 1110 | return { | 1634 | return { |
| @@ -1117,6 +1641,7 @@ export function useRemoteAppState(): RemoteAppModel { · 6 unmodified lines | |||
| 1117 | connectedEnvironments, | 1641 | connectedEnvironments, |
| 1118 | connectedEnvironmentCount: connectedEnvironments.length, | 1642 | connectedEnvironmentCount: connectedEnvironments.length, |
| 1119 | serverConfig, | 1643 | serverConfig, |
| 1644 | serverConfigByEnvironmentId, | ||
| 1120 | projects, | 1645 | projects, |
| 1121 | threads, | 1646 | threads, |
| 1122 | selectedThread, | 1647 | selectedThread, |
| @@ -1133,6 +1658,8 @@ export function useRemoteAppState(): RemoteAppModel { · 10 unmodified lines | |||
| 1133 | draftAttachments, | 1658 | draftAttachments, |
| 1134 | screenTone, | 1659 | screenTone, |
| 1135 | activeThreadBusy, | 1660 | activeThreadBusy, |
| 1661 | selectedThreadGitStatus, | ||
| 1662 | gitOperationLabel, | ||
| 1136 | hasRemoteActivity, | 1663 | hasRemoteActivity, |
| 1137 | selectedEnvironmentBaseUrl: selectedEnvironmentConnection?.httpBaseUrl ?? null, | 1664 | selectedEnvironmentBaseUrl: selectedEnvironmentConnection?.httpBaseUrl ?? null, |
| 1138 | selectedEnvironmentBearerToken: selectedEnvironmentConnection?.bearerToken ?? null, | 1665 | selectedEnvironmentBearerToken: selectedEnvironmentConnection?.bearerToken ?? null, |
| @@ -1148,9 +1675,11 @@ export function useRemoteAppState(): RemoteAppModel { · 9 unmodified lines | |||
| 1148 | }, | 1675 | }, |
| 1149 | onChangeConnectionPairingUrl: (pairingUrl: string) => setConnectionInput({ pairingUrl }), | 1676 | onChangeConnectionPairingUrl: (pairingUrl: string) => setConnectionInput({ pairingUrl }), |
| 1150 | onConnectPress, | 1677 | onConnectPress, |
| 1678 | onUpdateEnvironment, | ||
| 1151 | onRemoveEnvironmentPress, | 1679 | onRemoveEnvironmentPress, |
| 1152 | onRefresh, | 1680 | onRefresh, |
| 1153 | onCreateThread, | 1681 | onCreateThread, |
| 1682 | onCreateThreadWithOptions, | ||
| 1154 | onSelectThread: (thread) => | 1683 | onSelectThread: (thread) => |
| 1155 | setSelectedThreadRef({ | 1684 | setSelectedThreadRef({ |
| 1156 | environmentId: thread.environmentId, | 1685 | environmentId: thread.environmentId, |
| @@ -1161,6 +1690,17 @@ export function useRemoteAppState(): RemoteAppModel { · 4 unmodified lines | |||
| 1161 | onPickDraftImages, | 1690 | onPickDraftImages, |
| 1162 | onPasteIntoDraft, | 1691 | onPasteIntoDraft, |
| 1163 | onRemoveDraftImage, | 1692 | onRemoveDraftImage, |
| 1693 | onRefreshSelectedThreadGitStatus: async (options) => { | ||
| 1694 | await refreshSelectedThreadGitStatus(options); | ||
| 1695 | }, | ||
| 1696 | onListProjectBranches, | ||
| 1697 | onCreateProjectWorktree, | ||
| 1698 | onListSelectedThreadBranches, | ||
| 1699 | onCheckoutSelectedThreadBranch, | ||
| 1700 | onCreateSelectedThreadBranch, | ||
| 1701 | onCreateSelectedThreadWorktree, | ||
| 1702 | onPullSelectedThreadBranch, | ||
| 1703 | onRunSelectedThreadGitAction, | ||
| 1164 | onSendMessage, | 1704 | onSendMessage, |
| 1165 | onRenameThread, | 1705 | onRenameThread, |
| 1166 | onStopThread, | 1706 | onStopThread, |
| @@ -55,6 +55,7 @@ function activeToken(token: string, isDarkMode: boolean) { · 54 unmodified lines | |||
| 55 | function resolveTextStyle(className: string | undefined, isDarkMode: boolean): TextStyle { | 55 | function resolveTextStyle(className: string | undefined, isDarkMode: boolean): TextStyle { |
| 56 | const style: TextStyle = { | 56 | const style: TextStyle = { |
| 57 | color: isDarkMode ? "#f8fafc" : "#020617", | 57 | color: isDarkMode ? "#f8fafc" : "#020617", |
| 58 | fontFamily: "DMSans_400Regular", | ||
| 58 | }; | 59 | }; |
| 59 | 60 | ||
| 60 | let hasLeadingNone = false; | 61 | let hasLeadingNone = false; |
| @@ -70,37 +71,37 @@ function resolveTextStyle(className: string | undefined, isDarkMode: boolean): T · 9 unmodified lines | |||
| 70 | } | 71 | } |
| 71 | 72 | ||
| 72 | if (token === "font-sans") { | 73 | if (token === "font-sans") { |
| 73 | style.fontWeight = "400"; | 74 | style.fontFamily = "DMSans_400Regular"; |
| 74 | continue; | 75 | continue; |
| 75 | } | 76 | } |
| 76 | 77 | ||
| 77 | if (token === "font-medium") { | 78 | if (token === "font-medium") { |
| 78 | style.fontWeight = "500"; | 79 | style.fontFamily = "DMSans_500Medium"; |
| 79 | continue; | 80 | continue; |
| 80 | } | 81 | } |
| 81 | 82 | ||
| 82 | if (token === "font-bold") { | 83 | if (token === "font-bold") { |
| 83 | style.fontWeight = "700"; | 84 | style.fontFamily = "DMSans_700Bold"; |
| 84 | continue; | 85 | continue; |
| 85 | } | 86 | } |
| 86 | 87 | ||
| 87 | if (token === "font-extrabold") { | 88 | if (token === "font-extrabold") { |
| 88 | style.fontWeight = "800"; | 89 | style.fontFamily = "DMSans_700Bold"; |
| 89 | continue; | 90 | continue; |
| 90 | } | 91 | } |
| 91 | 92 | ||
| 92 | if (token === "font-t3-medium") { | 93 | if (token === "font-t3-medium") { |
| 93 | style.fontWeight = "500"; | 94 | style.fontFamily = "DMSans_500Medium"; |
| 94 | continue; | 95 | continue; |
| 95 | } | 96 | } |
| 96 | 97 | ||
| 97 | if (token === "font-t3-bold") { | 98 | if (token === "font-t3-bold") { |
| 98 | style.fontWeight = "700"; | 99 | style.fontFamily = "DMSans_700Bold"; |
| 99 | continue; | 100 | continue; |
| 100 | } | 101 | } |
| 101 | 102 | ||
| 102 | if (token === "font-t3-extrabold") { | 103 | if (token === "font-t3-extrabold") { |
| 103 | style.fontWeight = "800"; | 104 | style.fontFamily = "DMSans_700Bold"; |
| 104 | continue; | 105 | continue; |
| 105 | } | 106 | } |
| 106 | 107 | ||
| @@ -197,6 +198,7 @@ function resolveTextInputStyle( · 90 unmodified lines | |||
| 197 | paddingHorizontal: 14, | 198 | paddingHorizontal: 14, |
| 198 | paddingVertical: 12, | 199 | paddingVertical: 12, |
| 199 | fontSize: 15, | 200 | fontSize: 15, |
| 201 | fontFamily: "DMSans_400Regular", | ||
| 200 | minHeight: 54, | 202 | minHeight: 54, |
| 201 | }; | 203 | }; |
| 202 | 204 | ||
| @@ -252,7 +254,7 @@ function resolveTextInputStyle( · 49 unmodified lines | |||
| 252 | } | 254 | } |
| 253 | 255 | ||
| 254 | if (token === "font-sans") { | 256 | if (token === "font-sans") { |
| 255 | style.fontWeight = "400"; | 257 | style.fontFamily = "DMSans_400Regular"; |
| 256 | continue; | 258 | continue; |
| 257 | } | 259 | } |
| 258 | 260 | ||
| @@ -0,0 +1,60 @@ | |||
| 1 | import { Image, View } from "react-native"; | ||
| 2 | |||
| 3 | import { AppText as Text } from "./AppText"; | ||
| 4 | |||
| 5 | const BRAND_MARK_SOURCE = require("../../../../assets/dev/blueprint-ios-1024.png"); | ||
| 6 | |||
| 7 | export function BrandMark(props: { | ||
| 8 | readonly compact?: boolean; | ||
| 9 | readonly stageLabel?: string; | ||
| 10 | readonly dark?: boolean; | ||
| 11 | }) { | ||
| 12 | const compact = props.compact ?? false; | ||
| 13 | const iconSize = compact ? 32 : 44; | ||
| 14 | const stageLabel = props.stageLabel ?? "Alpha"; | ||
| 15 | const textColor = props.dark ? "#f5f5f4" : "#171717"; | ||
| 16 | const mutedColor = props.dark ? "rgba(245,245,244,0.72)" : "rgba(23,23,23,0.6)"; | ||
| 17 | const stageBackground = props.dark ? "rgba(255,255,255,0.08)" : "rgba(23,23,23,0.06)"; | ||
| 18 | |||
| 19 | return ( | ||
| 20 | <View className="flex-row items-center gap-3"> | ||
| 21 | <Image | ||
| 22 | source={BRAND_MARK_SOURCE} | ||
| 23 | accessibilityIgnoresInvertColors | ||
| 24 | style={{ | ||
| 25 | width: iconSize, | ||
| 26 | height: iconSize, | ||
| 27 | borderRadius: compact ? 10 : 14, | ||
| 28 | }} | ||
| 29 | /> | ||
| 30 | <View className="gap-1"> | ||
| 31 | <View className="flex-row items-center gap-2"> | ||
| 32 | <Text | ||
| 33 | className="text-[17px] font-t3-bold" | ||
| 34 | style={{ color: textColor, letterSpacing: -0.4 }} | ||
| 35 | > | ||
| 36 | T3 Code | ||
| 37 | </Text> | ||
| 38 | <View | ||
| 39 | className="rounded-full px-2 py-1" | ||
| 40 | style={{ | ||
| 41 | backgroundColor: stageBackground, | ||
| 42 | }} | ||
| 43 | > | ||
| 44 | <Text | ||
| 45 | className="text-[10px] font-t3-bold uppercase" | ||
| 46 | style={{ color: mutedColor, letterSpacing: 1.1 }} | ||
| 47 | > | ||
| 48 | {stageLabel} | ||
| 49 | </Text> | ||
| 50 | </View> | ||
| 51 | </View> | ||
| 52 | {!compact ? ( | ||
| 53 | <Text className="text-[12px] font-medium" style={{ color: mutedColor }}> | ||
| 54 | Mobile control surface for your live coding environments | ||
| 55 | </Text> | ||
| 56 | ) : null} | ||
| 57 | </View> | ||
| 58 | </View> | ||
| 59 | ); | ||
| 60 | } | ||
| @@ -2,6 +2,7 @@ import { ActivityIndicator, StatusBar, View } from "react-native"; · 1 unmodified line | |||
| 2 | import { useSafeAreaInsets } from "react-native-safe-area-context"; | 2 | import { useSafeAreaInsets } from "react-native-safe-area-context"; |
| 3 | 3 | ||
| 4 | import { AppText as Text } from "./AppText"; | 4 | import { AppText as Text } from "./AppText"; |
| 5 | import { BrandMark } from "./BrandMark"; | ||
| 5 | 6 | ||
| 6 | export function LoadingScreen(props: { readonly isDarkMode: boolean; readonly message: string }) { | 7 | export function LoadingScreen(props: { readonly isDarkMode: boolean; readonly message: string }) { |
| 7 | const insets = useSafeAreaInsets(); | 8 | const insets = useSafeAreaInsets(); |
| @@ -14,7 +15,8 @@ export function LoadingScreen(props: { readonly isDarkMode: boolean; readonly me · 6 unmodified lines | |||
| 14 | backgroundColor={backgroundColor} | 15 | backgroundColor={backgroundColor} |
| 15 | translucent | 16 | translucent |
| 16 | /> | 17 | /> |
| 17 | <View className="flex-1 items-center justify-center gap-3.5"> | 18 | <View className="flex-1 items-center justify-center gap-5 px-6"> |
| 19 | <BrandMark compact dark={props.isDarkMode} /> | ||
| 18 | <ActivityIndicator size="large" /> | 20 | <ActivityIndicator size="large" /> |
| 19 | <Text className="font-t3-bold text-lg text-slate-950 dark:text-slate-50"> | 21 | <Text className="font-t3-bold text-lg text-slate-950 dark:text-slate-50"> |
| 20 | {props.message} | 22 | {props.message} |
| @@ -9,10 +9,29 @@ export interface StatusTone { · 8 unmodified lines | |||
| 9 | readonly textClassName: string; | 9 | readonly textClassName: string; |
| 10 | } | 10 | } |
| 11 | 11 | ||
| 12 | export function StatusPill(props: StatusTone) { | 12 | export function StatusPill( |
| 13 | props: StatusTone & { | ||
| 14 | readonly size?: "default" | "compact"; | ||
| 15 | }, | ||
| 16 | ) { | ||
| 17 | const size = props.size ?? "default"; | ||
| 13 | return ( | 18 | return ( |
| 14 | <View className={cx("rounded-full px-3 py-1.5", props.pillClassName)}> | 19 | <View |
| 15 | <Text className={cx("font-t3-bold text-xs", props.textClassName)}>{props.label}</Text> | 20 | className={cx( |
| 21 | "rounded-full", | ||
| 22 | size === "compact" ? "px-2.5 py-1" : "px-3 py-1.5", | ||
| 23 | props.pillClassName, | ||
| 24 | )} | ||
| 25 | > | ||
| 26 | <Text | ||
| 27 | className={cx( | ||
| 28 | "font-t3-bold", | ||
| 29 | size === "compact" ? "text-[11px]" : "text-xs", | ||
| 30 | props.textClassName, | ||
| 31 | )} | ||
| 32 | > | ||
| 33 | {props.label} | ||
| 34 | </Text> | ||
| 16 | </View> | 35 | </View> |
| 17 | ); | 36 | ); |
| 18 | } | 37 | } |
| @@ -1,11 +1,23 @@ | |||
| 1 | import { Modal, Pressable, ScrollView, View } from "react-native"; | 1 | import { |
| 2 | import { useColorScheme } from "react-native"; | 2 | BottomSheetBackdrop, |
| 3 | BottomSheetModal, | ||
| 4 | BottomSheetScrollView, | ||
| 5 | BottomSheetView, | ||
| 6 | } from "@gorhom/bottom-sheet"; | ||
| 7 | import { MenuView } from "@react-native-menu/menu"; | ||
| 8 | import { CameraView, type BarcodeScanningResult, useCameraPermissions } from "expo-camera"; | ||
| 9 | import { SymbolView } from "expo-symbols"; | ||
| 10 | import { useCallback, useEffect, useMemo, useRef, useState } from "react"; | ||
| 11 | import { Linking, Platform, Pressable, View, useColorScheme } from "react-native"; | ||
| 12 | import { useSafeAreaInsets } from "react-native-safe-area-context"; | ||
| 3 | 13 | ||
| 14 | import type { ConnectedEnvironmentSummary } from "../../app/useRemoteAppState"; | ||
| 4 | import { AppText as Text, AppTextInput as TextInput } from "../../components/AppText"; | 15 | import { AppText as Text, AppTextInput as TextInput } from "../../components/AppText"; |
| 5 | import { ErrorBanner } from "../../components/ErrorBanner"; | 16 | import { ErrorBanner } from "../../components/ErrorBanner"; |
| 6 | import type { ConnectedEnvironmentSummary } from "../../app/useRemoteAppState"; | ||
| 7 | import type { RemoteConnectionInput } from "../../lib/connection"; | 17 | import type { RemoteConnectionInput } from "../../lib/connection"; |
| 18 | import { extractPairingUrlFromQrPayload } from "../../lib/pairingQr"; | ||
| 8 | import type { RemoteClientConnectionState } from "../../lib/remoteClient"; | 19 | import type { RemoteClientConnectionState } from "../../lib/remoteClient"; |
| 20 | import { ConnectionStatusDot } from "./ConnectionStatusDot"; | ||
| 9 | 21 | ||
| 10 | export interface ConnectionSheetProps { | 22 | export interface ConnectionSheetProps { |
| 11 | readonly visible: boolean; | 23 | readonly visible: boolean; |
| @@ -16,82 +28,196 @@ export interface ConnectionSheetProps { · 4 unmodified lines | |||
| 16 | readonly onRequestClose: () => void; | 28 | readonly onRequestClose: () => void; |
| 17 | readonly onChangePairingUrl: (pairingUrl: string) => void; | 29 | readonly onChangePairingUrl: (pairingUrl: string) => void; |
| 18 | readonly onConnect: () => void; | 30 | readonly onConnect: () => void; |
| 19 | readonly onClose: () => void; | 31 | readonly onUpdateEnvironment: ( |
| 32 | environmentId: string, | ||
| 33 | updates: { readonly label: string; readonly displayUrl: string }, | ||
| 34 | ) => Promise<void>; | ||
| 20 | readonly onRemoveEnvironment: (environmentId: string) => void; | 35 | readonly onRemoveEnvironment: (environmentId: string) => void; |
| 21 | } | 36 | } |
| 22 | 37 | ||
| 38 | // --------------------------------------------------------------------------- | ||
| 39 | // Helpers | ||
| 40 | // --------------------------------------------------------------------------- | ||
| 41 | |||
| 42 | function parsePairingUrl(url: string): { host: string; code: string } { | ||
| 43 | const trimmed = url.trim(); | ||
| 44 | if (!trimmed) return { host: "", code: "" }; | ||
| 45 | |||
| 46 | try { | ||
| 47 | const parsed = new URL(trimmed); | ||
| 48 | const hashParams = new URLSearchParams(parsed.hash.slice(1)); | ||
| 49 | const hashToken = hashParams.get("token"); | ||
| 50 | const queryToken = parsed.searchParams.get("token"); | ||
| 51 | const code = hashToken || queryToken || ""; | ||
| 52 | |||
| 53 | parsed.hash = ""; | ||
| 54 | parsed.search = ""; | ||
| 55 | parsed.pathname = "/"; | ||
| 56 | return { host: parsed.toString().replace(/\/$/, ""), code }; | ||
| 57 | } catch { | ||
| 58 | return { host: trimmed, code: "" }; | ||
| 59 | } | ||
| 60 | } | ||
| 61 | |||
| 62 | function buildPairingUrl(host: string, code: string): string { | ||
| 63 | const h = host.trim(); | ||
| 64 | const c = code.trim(); | ||
| 65 | if (!h) return ""; | ||
| 66 | if (!c) return h; | ||
| 67 | |||
| 68 | try { | ||
| 69 | const url = new URL(h.includes("://") ? h : `https://${h}`); | ||
| 70 | url.hash = new URLSearchParams([["token", c]]).toString(); | ||
| 71 | return url.toString(); | ||
| 72 | } catch { | ||
| 73 | return `${h}#token=${c}`; | ||
| 74 | } | ||
| 75 | } | ||
| 76 | |||
| 77 | // --------------------------------------------------------------------------- | ||
| 78 | // Palette | ||
| 79 | // --------------------------------------------------------------------------- | ||
| 80 | |||
| 23 | function makePalette(isDarkMode: boolean) { | 81 | function makePalette(isDarkMode: boolean) { |
| 24 | if (isDarkMode) { | 82 | if (isDarkMode) { |
| 25 | return { | 83 | return { |
| 26 | sheet: "#151618", | 84 | sheet: "rgba(18,20,23,0.98)", |
| 27 | panel: "#1d1e21", | 85 | card: "#1f2329", |
| 28 | border: "rgba(255,255,255,0.08)", | 86 | cardAlt: "#242a31", |
| 29 | text: "#f4f3ef", | 87 | border: "rgba(255,255,255,0.06)", |
| 30 | muted: "#8f918f", | 88 | text: "#f8fafc", |
| 31 | input: "#1f2230", | 89 | muted: "#94a3b8", |
| 32 | inputText: "#f8fafc", | 90 | subtle: "#cbd5e1", |
| 33 | placeholder: "#6b7280", | 91 | accent: "#f59e0b", |
| 34 | action: "#f97316", | 92 | accentSubtle: "rgba(245,158,11,0.12)", |
| 35 | actionText: "#fff7ed", | 93 | iconBg: "rgba(245,158,11,0.10)", |
| 36 | secondary: "#303440", | 94 | inputBackground: "#171b20", |
| 37 | secondaryText: "#f8fafc", | 95 | inputBorder: "rgba(255,255,255,0.06)", |
| 38 | danger: "#381624", | 96 | primaryButton: "#f8fafc", |
| 97 | primaryButtonText: "#171717", | ||
| 98 | secondaryButton: "rgba(255,255,255,0.05)", | ||
| 99 | secondaryButtonText: "#f8fafc", | ||
| 100 | dangerButton: "rgba(190,24,93,0.14)", | ||
| 101 | dangerBorder: "rgba(244,114,182,0.18)", | ||
| 39 | dangerText: "#fda4af", | 102 | dangerText: "#fda4af", |
| 40 | accent: "#d8b27a", | 103 | placeholder: "#64748b", |
| 104 | cardShadow: { | ||
| 105 | shadowColor: "#000", | ||
| 106 | shadowOffset: { width: 0, height: 2 }, | ||
| 107 | shadowOpacity: 0.18, | ||
| 108 | shadowRadius: 8, | ||
| 109 | elevation: 4, | ||
| 110 | } as const, | ||
| 111 | separator: "rgba(255,255,255,0.04)", | ||
| 41 | }; | 112 | }; |
| 42 | } | 113 | } |
| 43 | 114 | ||
| 44 | return { | 115 | return { |
| 45 | sheet: "#f2ece4", | 116 | sheet: "rgba(246,244,239,0.98)", |
| 46 | panel: "#fbf7f1", | 117 | card: "#fffdf9", |
| 47 | border: "#d7cdbf", | 118 | cardAlt: "#f8f4ec", |
| 48 | text: "#1f1b17", | 119 | border: "rgba(23,23,23,0.06)", |
| 49 | muted: "#847b71", | 120 | text: "#171717", |
| 50 | input: "#ffffff", | 121 | muted: "#78716c", |
| 51 | inputText: "#1f2937", | 122 | subtle: "#57534e", |
| 123 | accent: "#a16207", | ||
| 124 | accentSubtle: "rgba(161,98,7,0.08)", | ||
| 125 | iconBg: "rgba(161,98,7,0.07)", | ||
| 126 | inputBackground: "#ffffff", | ||
| 127 | inputBorder: "rgba(23,23,23,0.06)", | ||
| 128 | primaryButton: "#171717", | ||
| 129 | primaryButtonText: "#fafaf9", | ||
| 130 | secondaryButton: "#f1ece3", | ||
| 131 | secondaryButtonText: "#171717", | ||
| 132 | dangerButton: "#fff1f2", | ||
| 133 | dangerBorder: "rgba(225,29,72,0.10)", | ||
| 134 | dangerText: "#be123c", | ||
| 52 | placeholder: "#94a3b8", | 135 | placeholder: "#94a3b8", |
| 53 | action: "#2c2a2d", | 136 | cardShadow: { |
| 54 | actionText: "#f8f4ee", | 137 | shadowColor: "rgba(23,23,23,0.08)", |
| 55 | secondary: "#ffffff", | 138 | shadowOffset: { width: 0, height: 4 }, |
| 56 | secondaryText: "#1f1b17", | 139 | shadowOpacity: 1, |
| 57 | danger: "#fde7e7", | 140 | shadowRadius: 16, |
| 58 | dangerText: "#a11d33", | 141 | elevation: 3, |
| 59 | accent: "#9a6b30", | 142 | } as const, |
| 143 | separator: "rgba(23,23,23,0.04)", | ||
| 60 | }; | 144 | }; |
| 61 | } | 145 | } |
| 62 | 146 | ||
| 63 | function ActionButton(props: { | 147 | // --------------------------------------------------------------------------- |
| 148 | // Shared button | ||
| 149 | // --------------------------------------------------------------------------- | ||
| 150 | |||
| 151 | function SheetButton(props: { | ||
| 152 | readonly icon: React.ComponentProps<typeof SymbolView>["name"]; | ||
| 64 | readonly label: string; | 153 | readonly label: string; |
| 65 | readonly onPress: () => void; | 154 | readonly disabled?: boolean; |
| 66 | readonly palette: ReturnType<typeof makePalette>; | ||
| 67 | readonly tone?: "primary" | "secondary" | "danger"; | 155 | readonly tone?: "primary" | "secondary" | "danger"; |
| 156 | readonly compact?: boolean; | ||
| 157 | readonly palette: ReturnType<typeof makePalette>; | ||
| 158 | readonly onPress: () => void; | ||
| 68 | }) { | 159 | }) { |
| 69 | const tone = props.tone ?? "secondary"; | 160 | const tone = props.tone ?? "secondary"; |
| 70 | const styles = | 161 | const colors = |
| 71 | tone === "primary" | 162 | tone === "primary" |
| 72 | ? { | 163 | ? { |
| 73 | backgroundColor: props.palette.action, | 164 | backgroundColor: props.palette.primaryButton, |
| 74 | color: props.palette.actionText, | 165 | borderColor: "transparent", |
| 166 | textColor: props.palette.primaryButtonText, | ||
| 75 | } | 167 | } |
| 76 | : tone === "danger" | 168 | : tone === "danger" |
| 77 | ? { | 169 | ? { |
| 78 | backgroundColor: props.palette.danger, | 170 | backgroundColor: props.palette.dangerButton, |
| 79 | color: props.palette.dangerText, | 171 | borderColor: props.palette.dangerBorder, |
| 172 | textColor: props.palette.dangerText, | ||
| 80 | } | 173 | } |
| 81 | : { | 174 | : { |
| 82 | backgroundColor: props.palette.secondary, | 175 | backgroundColor: props.palette.secondaryButton, |
| 83 | color: props.palette.secondaryText, | 176 | borderColor: props.palette.border, |
| 177 | textColor: props.palette.secondaryButtonText, | ||
| 84 | }; | 178 | }; |
| 85 | 179 | ||
| 180 | const primaryShadow = | ||
| 181 | tone === "primary" | ||
| 182 | ? Platform.select({ | ||
| 183 | ios: { | ||
| 184 | shadowColor: "#000", | ||
| 185 | shadowOffset: { width: 0, height: 3 }, | ||
| 186 | shadowOpacity: 0.14, | ||
| 187 | shadowRadius: 6, | ||
| 188 | }, | ||
| 189 | android: { elevation: 3 }, | ||
| 190 | }) | ||
| 191 | : undefined; | ||
| 192 | |||
| 86 | return ( | 193 | return ( |
| 87 | <Pressable | 194 | <Pressable |
| 88 | className="min-h-[52px] items-center justify-center px-4 py-3" | 195 | className={ |
| 89 | style={{ backgroundColor: styles.backgroundColor }} | 196 | props.compact |
| 197 | ? "min-h-[42px] flex-row items-center justify-center gap-1.5 rounded-[14px] px-3.5 py-2.5" | ||
| 198 | : "min-h-[48px] flex-row items-center justify-center gap-2 rounded-[16px] px-4 py-3" | ||
| 199 | } | ||
| 200 | disabled={props.disabled} | ||
| 90 | onPress={props.onPress} | 201 | onPress={props.onPress} |
| 202 | style={[ | ||
| 203 | { | ||
| 204 | backgroundColor: colors.backgroundColor, | ||
| 205 | borderWidth: tone === "primary" ? 0 : 1, | ||
| 206 | borderColor: colors.borderColor, | ||
| 207 | opacity: props.disabled ? 0.5 : 1, | ||
| 208 | }, | ||
| 209 | primaryShadow, | ||
| 210 | ]} | ||
| 91 | > | 211 | > |
| 212 | <SymbolView | ||
| 213 | name={props.icon} | ||
| 214 | size={props.compact ? 13 : 14} | ||
| 215 | tintColor={colors.textColor} | ||
| 216 | type="monochrome" | ||
| 217 | /> | ||
| 92 | <Text | 218 | <Text |
| 93 | className="text-sm font-extrabold uppercase" | 219 | className="text-[12px] font-t3-bold uppercase" |
| 94 | style={{ color: styles.color, letterSpacing: 1 }} | 220 | style={{ color: colors.textColor, letterSpacing: 0.8 }} |
| 95 | > | 221 | > |
| 96 | {props.label} | 222 | {props.label} |
| 97 | </Text> | 223 | </Text> |
| @@ -99,151 +225,799 @@ function ActionButton(props: { · 1 unmodified line | |||
| 99 | ); | 225 | ); |
| 100 | } | 226 | } |
| 101 | 227 | ||
| 102 | export function ConnectionSheet(props: ConnectionSheetProps) { | 228 | // --------------------------------------------------------------------------- |
| 103 | const isDarkMode = useColorScheme() === "dark"; | 229 | // Environment card (compact single row) |
| 104 | const palette = makePalette(isDarkMode); | 230 | // --------------------------------------------------------------------------- |
| 231 | |||
| 232 | function EnvironmentCard(props: { | ||
| 233 | readonly environment: ConnectedEnvironmentSummary; | ||
| 234 | readonly palette: ReturnType<typeof makePalette>; | ||
| 235 | readonly onPress: () => void; | ||
| 236 | readonly onRemove: (environmentId: string) => void; | ||
| 237 | }) { | ||
| 238 | return ( | ||
| 239 | <Pressable | ||
| 240 | className="flex-row items-center gap-3 rounded-[18px] px-4 py-3.5" | ||
| 241 | style={[ | ||
| 242 | { | ||
| 243 | backgroundColor: props.palette.card, | ||
| 244 | borderWidth: 1, | ||
| 245 | borderColor: props.palette.border, | ||
| 246 | }, | ||
| 247 | props.palette.cardShadow, | ||
| 248 | ]} | ||
| 249 | onPress={props.onPress} | ||
| 250 | > | ||
| 251 | <ConnectionStatusDot state={props.environment.connectionState} pulse={false} size={8} /> | ||
| 252 | |||
| 253 | <View className="flex-1 gap-0.5"> | ||
| 254 | <Text | ||
| 255 | className="text-[16px] font-t3-bold leading-[21px]" | ||
| 256 | style={{ color: props.palette.text }} | ||
| 257 | numberOfLines={1} | ||
| 258 | > | ||
| 259 | {props.environment.environmentLabel} | ||
| 260 | </Text> | ||
| 261 | <Text | ||
| 262 | className="text-[12px] leading-[16px]" | ||
| 263 | style={{ color: props.palette.muted }} | ||
| 264 | numberOfLines={1} | ||
| 265 | > | ||
| 266 | {props.environment.displayUrl} | ||
| 267 | </Text> | ||
| 268 | </View> | ||
| 269 | |||
| 270 | <Pressable | ||
| 271 | className="h-9 w-9 items-center justify-center rounded-[12px]" | ||
| 272 | style={{ | ||
| 273 | backgroundColor: props.palette.dangerButton, | ||
| 274 | borderWidth: 1, | ||
| 275 | borderColor: props.palette.dangerBorder, | ||
| 276 | }} | ||
| 277 | onPress={(e) => { | ||
| 278 | e.stopPropagation(); | ||
| 279 | props.onRemove(props.environment.environmentId); | ||
| 280 | }} | ||
| 281 | hitSlop={4} | ||
| 282 | > | ||
| 283 | <SymbolView name="trash" size={13} tintColor={props.palette.dangerText} type="monochrome" /> | ||
| 284 | </Pressable> | ||
| 285 | </Pressable> | ||
| 286 | ); | ||
| 287 | } | ||
| 288 | |||
| 289 | // --------------------------------------------------------------------------- | ||
| 290 | // Edit backend (nested sheet) | ||
| 291 | // --------------------------------------------------------------------------- | ||
| 292 | |||
| 293 | function EnvironmentDetailSheet(props: { | ||
| 294 | readonly environment: ConnectedEnvironmentSummary | null; | ||
| 295 | readonly palette: ReturnType<typeof makePalette>; | ||
| 296 | readonly bottomInset: number; | ||
| 297 | readonly onDismiss: () => void; | ||
| 298 | readonly onSave: ( | ||
| 299 | environmentId: string, | ||
| 300 | updates: { readonly label: string; readonly displayUrl: string }, | ||
| 301 | ) => void; | ||
| 302 | readonly onRemove: (environmentId: string) => void; | ||
| 303 | }) { | ||
| 304 | const sheetRef = useRef<BottomSheetModal>(null); | ||
| 305 | const [label, setLabel] = useState(""); | ||
| 306 | const [displayUrl, setDisplayUrl] = useState(""); | ||
| 307 | const env = props.environment; | ||
| 308 | |||
| 309 | useEffect(() => { | ||
| 310 | const sheet = sheetRef.current; | ||
| 311 | if (!sheet) return; | ||
| 312 | if (env) { | ||
| 313 | setLabel(env.environmentLabel); | ||
| 314 | setDisplayUrl(env.displayUrl); | ||
| 315 | sheet.present(); | ||
| 316 | } else { | ||
| 317 | sheet.dismiss(); | ||
| 318 | } | ||
| 319 | }, [env]); | ||
| 320 | |||
| 321 | const renderBackdrop = useCallback( | ||
| 322 | (backdropProps: React.ComponentProps<typeof BottomSheetBackdrop>) => ( | ||
| 323 | <BottomSheetBackdrop | ||
| 324 | {...backdropProps} | ||
| 325 | appearsOnIndex={0} | ||
| 326 | disappearsOnIndex={-1} | ||
| 327 | opacity={0.24} | ||
| 328 | pressBehavior="close" | ||
| 329 | /> | ||
| 330 | ), | ||
| 331 | [], | ||
| 332 | ); | ||
| 333 | |||
| 334 | const hasChanges = | ||
| 335 | env !== null && | ||
| 336 | (label.trim() !== env.environmentLabel || displayUrl.trim() !== env.displayUrl); | ||
| 105 | 337 | ||
| 106 | return ( | 338 | return ( |
| 107 | <Modal | 339 | <BottomSheetModal |
| 108 | animationType="slide" | 340 | ref={sheetRef} |
| 109 | presentationStyle="pageSheet" | 341 | stackBehavior="push" |
| 110 | allowSwipeDismissal | 342 | index={0} |
| 111 | visible={props.visible} | 343 | snapPoints={["52%"]} |
| 112 | onRequestClose={props.onRequestClose} | 344 | enableDynamicSizing={false} |
| 345 | enablePanDownToClose | ||
| 346 | backdropComponent={renderBackdrop} | ||
| 347 | onDismiss={props.onDismiss} | ||
| 348 | backgroundStyle={{ backgroundColor: props.palette.sheet }} | ||
| 349 | handleIndicatorStyle={{ backgroundColor: "rgba(120,113,108,0.32)" }} | ||
| 113 | > | 350 | > |
| 114 | <View className="flex-1" style={{ backgroundColor: palette.sheet }}> | 351 | <BottomSheetView style={{ flex: 1 }}> |
| 115 | <ScrollView bounces={false} showsVerticalScrollIndicator={false}> | 352 | <View className="gap-5 px-5 pt-1"> |
| 116 | <View className="gap-5 px-5 py-5"> | 353 | <View className="flex-row items-center gap-3"> |
| 354 | {env ? ( | ||
| 355 | <ConnectionStatusDot state={env.connectionState} pulse={false} size={8} /> | ||
| 356 | ) : null} | ||
| 357 | <Text | ||
| 358 | className="text-[24px] font-t3-bold" | ||
| 359 | style={{ color: props.palette.text, letterSpacing: -0.2 }} | ||
| 360 | > | ||
| 361 | Edit backend | ||
| 362 | </Text> | ||
| 363 | </View> | ||
| 364 | |||
| 365 | <View className="gap-2"> | ||
| 366 | <Text | ||
| 367 | className="text-[11px] font-t3-bold uppercase" | ||
| 368 | style={{ color: props.palette.muted, letterSpacing: 0.8 }} | ||
| 369 | > | ||
| 370 | Label | ||
| 371 | </Text> | ||
| 372 | <TextInput | ||
| 373 | autoCapitalize="words" | ||
| 374 | autoCorrect={false} | ||
| 375 | placeholder="My MacBook Pro" | ||
| 376 | placeholderTextColor={props.palette.placeholder} | ||
| 377 | value={label} | ||
| 378 | onChangeText={setLabel} | ||
| 379 | className="rounded-[14px] px-4 py-3.5 text-[15px]" | ||
| 380 | style={{ | ||
| 381 | backgroundColor: props.palette.inputBackground, | ||
| 382 | borderWidth: 1, | ||
| 383 | borderColor: props.palette.inputBorder, | ||
| 384 | color: props.palette.text, | ||
| 385 | }} | ||
| 386 | /> | ||
| 387 | </View> | ||
| 388 | |||
| 389 | <View className="gap-2"> | ||
| 390 | <Text | ||
| 391 | className="text-[11px] font-t3-bold uppercase" | ||
| 392 | style={{ color: props.palette.muted, letterSpacing: 0.8 }} | ||
| 393 | > | ||
| 394 | Host | ||
| 395 | </Text> | ||
| 396 | <TextInput | ||
| 397 | autoCapitalize="none" | ||
| 398 | autoCorrect={false} | ||
| 399 | keyboardType="url" | ||
| 400 | placeholder="http://192.168.1.100:8080/" | ||
| 401 | placeholderTextColor={props.palette.placeholder} | ||
| 402 | value={displayUrl} | ||
| 403 | onChangeText={setDisplayUrl} | ||
| 404 | className="rounded-[14px] px-4 py-3.5 text-[15px]" | ||
| 405 | style={{ | ||
| 406 | backgroundColor: props.palette.inputBackground, | ||
| 407 | borderWidth: 1, | ||
| 408 | borderColor: props.palette.inputBorder, | ||
| 409 | color: props.palette.text, | ||
| 410 | }} | ||
| 411 | /> | ||
| 412 | </View> | ||
| 413 | |||
| 414 | <View | ||
| 415 | className="flex-row gap-3" | ||
| 416 | style={{ paddingBottom: Math.max(props.bottomInset, 16) }} | ||
| 417 | > | ||
| 418 | <View className="flex-1"> | ||
| 419 | <SheetButton | ||
| 420 | icon="checkmark" | ||
| 421 | label="Save" | ||
| 422 | disabled={!hasChanges} | ||
| 423 | palette={props.palette} | ||
| 424 | tone="primary" | ||
| 425 | onPress={() => { | ||
| 426 | if (env) { | ||
| 427 | props.onSave(env.environmentId, { | ||
| 428 | label: label.trim(), | ||
| 429 | displayUrl: displayUrl.trim(), | ||
| 430 | }); | ||
| 431 | props.onDismiss(); | ||
| 432 | } | ||
| 433 | }} | ||
| 434 | /> | ||
| 435 | </View> | ||
| 436 | <View className="flex-1"> | ||
| 437 | <SheetButton | ||
| 438 | icon="xmark" | ||
| 439 | label="Cancel" | ||
| 440 | palette={props.palette} | ||
| 441 | tone="secondary" | ||
| 442 | onPress={props.onDismiss} | ||
| 443 | /> | ||
| 444 | </View> | ||
| 445 | </View> | ||
| 446 | </View> | ||
| 447 | </BottomSheetView> | ||
| 448 | </BottomSheetModal> | ||
| 449 | ); | ||
| 450 | } | ||
| 451 | |||
| 452 | // --------------------------------------------------------------------------- | ||
| 453 | // Add backend (nested sheet — manual entry + optional QR scanner) | ||
| 454 | // --------------------------------------------------------------------------- | ||
| 455 | |||
| 456 | function AddBackendSheet(props: { | ||
| 457 | readonly visible: boolean; | ||
| 458 | readonly startWithScanner: boolean; | ||
| 459 | readonly connectionState: RemoteClientConnectionState; | ||
| 460 | readonly connectionError: string | null; | ||
| 461 | readonly palette: ReturnType<typeof makePalette>; | ||
| 462 | readonly bottomInset: number; | ||
| 463 | readonly onDismiss: () => void; | ||
| 464 | readonly onChangePairingUrl: (url: string) => void; | ||
| 465 | readonly onConnect: () => void; | ||
| 466 | }) { | ||
| 467 | const sheetRef = useRef<BottomSheetModal>(null); | ||
| 468 | const [cameraPermission, requestCameraPermission] = useCameraPermissions(); | ||
| 469 | const [hostInput, setHostInput] = useState(""); | ||
| 470 | const [codeInput, setCodeInput] = useState(""); | ||
| 471 | const [scannerVisible, setScannerVisible] = useState(false); | ||
| 472 | const [scannerError, setScannerError] = useState<string | null>(null); | ||
| 473 | const [scannerPaused, setScannerPaused] = useState(false); | ||
| 474 | |||
| 475 | const connectDisabled = | ||
| 476 | props.connectionState === "connecting" || hostInput.trim().length === 0; | ||
| 477 | |||
| 478 | const snapPoints = useMemo<(string | number)[]>( | ||
| 479 | () => [scannerVisible ? "82%" : "54%"], | ||
| 480 | [scannerVisible], | ||
| 481 | ); | ||
| 482 | |||
| 483 | // Present / dismiss | ||
| 484 | useEffect(() => { | ||
| 485 | const sheet = sheetRef.current; | ||
| 486 | if (!sheet) return; | ||
| 487 | if (props.visible) { | ||
| 488 | setHostInput(""); | ||
| 489 | setCodeInput(""); | ||
| 490 | setScannerError(null); | ||
| 491 | setScannerPaused(false); | ||
| 492 | |||
| 493 | if (props.startWithScanner) { | ||
| 494 | setScannerVisible(true); | ||
| 495 | if (!cameraPermission?.granted && cameraPermission?.canAskAgain !== false) { | ||
| 496 | void requestCameraPermission().catch(() => {}); | ||
| 497 | } | ||
| 498 | } else { | ||
| 499 | setScannerVisible(false); | ||
| 500 | } | ||
| 501 | |||
| 502 | sheet.present(); | ||
| 503 | } else { | ||
| 504 | sheet.dismiss(); | ||
| 505 | } | ||
| 506 | // eslint-disable-next-line react-hooks/exhaustive-deps | ||
| 507 | }, [props.visible]); | ||
| 508 | |||
| 509 | // Sync fields → parent | ||
| 510 | const syncUrl = useCallback( | ||
| 511 | (host: string, code: string) => { | ||
| 512 | props.onChangePairingUrl(buildPairingUrl(host, code)); | ||
| 513 | }, | ||
| 514 | [props.onChangePairingUrl], | ||
| 515 | ); | ||
| 516 | |||
| 517 | const handleHostChange = useCallback( | ||
| 518 | (v: string) => { | ||
| 519 | setHostInput(v); | ||
| 520 | syncUrl(v, codeInput); | ||
| 521 | }, | ||
| 522 | [codeInput, syncUrl], | ||
| 523 | ); | ||
| 524 | |||
| 525 | const handleCodeChange = useCallback( | ||
| 526 | (v: string) => { | ||
| 527 | setCodeInput(v); | ||
| 528 | syncUrl(hostInput, v); | ||
| 529 | }, | ||
| 530 | [hostInput, syncUrl], | ||
| 531 | ); | ||
| 532 | |||
| 533 | const handleToggleScanner = useCallback(() => { | ||
| 534 | setScannerVisible((v) => { | ||
| 535 | if (!v) { | ||
| 536 | setScannerError(null); | ||
| 537 | setScannerPaused(false); | ||
| 538 | if (!cameraPermission?.granted && cameraPermission?.canAskAgain !== false) { | ||
| 539 | void requestCameraPermission().catch(() => {}); | ||
| 540 | } | ||
| 541 | } | ||
| 542 | return !v; | ||
| 543 | }); | ||
| 544 | }, [cameraPermission?.canAskAgain, cameraPermission?.granted, requestCameraPermission]); | ||
| 545 | |||
| 546 | const handleBarcodeScanned = useCallback( | ||
| 547 | (result: BarcodeScanningResult) => { | ||
| 548 | if (scannerPaused) return; | ||
| 549 | setScannerPaused(true); | ||
| 550 | try { | ||
| 551 | const url = extractPairingUrlFromQrPayload(result.data); | ||
| 552 | const { host, code } = parsePairingUrl(url); | ||
| 553 | setHostInput(host); | ||
| 554 | setCodeInput(code); | ||
| 555 | props.onChangePairingUrl(url); | ||
| 556 | setScannerVisible(false); | ||
| 557 | } catch (error) { | ||
| 558 | setScannerError( | ||
| 559 | error instanceof Error | ||
| 560 | ? error.message | ||
| 561 | : "Could not read a pairing URL from that QR code.", | ||
| 562 | ); | ||
| 563 | setScannerPaused(false); | ||
| 564 | } | ||
| 565 | }, | ||
| 566 | [props.onChangePairingUrl, scannerPaused], | ||
| 567 | ); | ||
| 568 | |||
| 569 | const renderBackdrop = useCallback( | ||
| 570 | (backdropProps: React.ComponentProps<typeof BottomSheetBackdrop>) => ( | ||
| 571 | <BottomSheetBackdrop | ||
| 572 | {...backdropProps} | ||
| 573 | appearsOnIndex={0} | ||
| 574 | disappearsOnIndex={-1} | ||
| 575 | opacity={0.24} | ||
| 576 | pressBehavior="close" | ||
| 577 | /> | ||
| 578 | ), | ||
| 579 | [], | ||
| 580 | ); | ||
| 581 | |||
| 582 | return ( | ||
| 583 | <BottomSheetModal | ||
| 584 | ref={sheetRef} | ||
| 585 | stackBehavior="push" | ||
| 586 | index={0} | ||
| 587 | snapPoints={snapPoints} | ||
| 588 | enableDynamicSizing={false} | ||
| 589 | enablePanDownToClose | ||
| 590 | backdropComponent={renderBackdrop} | ||
| 591 | onDismiss={props.onDismiss} | ||
| 592 | backgroundStyle={{ backgroundColor: props.palette.sheet }} | ||
| 593 | handleIndicatorStyle={{ backgroundColor: "rgba(120,113,108,0.32)" }} | ||
| 594 | > | ||
| 595 | <BottomSheetView style={{ flex: 1 }}> | ||
| 596 | <BottomSheetScrollView | ||
| 597 | keyboardShouldPersistTaps="handled" | ||
| 598 | showsVerticalScrollIndicator={false} | ||
| 599 | contentContainerStyle={{ | ||
| 600 | paddingHorizontal: 20, | ||
| 601 | paddingTop: 4, | ||
| 602 | paddingBottom: Math.max(props.bottomInset, 16), | ||
| 603 | gap: 20, | ||
| 604 | }} | ||
| 605 | > | ||
| 606 | <Text | ||
| 607 | className="text-[24px] font-t3-bold" | ||
| 608 | style={{ color: props.palette.text, letterSpacing: -0.2 }} | ||
| 609 | > | ||
| 610 | Add backend | ||
| 611 | </Text> | ||
| 612 | |||
| 613 | {/* QR scanner */} | ||
| 614 | {scannerVisible ? ( | ||
| 117 | <View | 615 | <View |
| 118 | className="gap-4 px-4 py-4" | 616 | className="gap-3 rounded-[18px] p-3" |
| 119 | style={{ | 617 | style={{ |
| 618 | backgroundColor: props.palette.cardAlt, | ||
| 120 | borderWidth: 1, | 619 | borderWidth: 1, |
| 121 | borderColor: palette.border, | 620 | borderColor: props.palette.border, |
| 122 | backgroundColor: palette.panel, | ||
| 123 | }} | 621 | }} |
| 124 | > | 622 | > |
| 125 | <View className="gap-2"> | 623 | {!cameraPermission ? ( |
| 126 | <Text | ||
| 127 | className="text-[11px] font-bold uppercase" | ||
| 128 | style={{ color: palette.accent, letterSpacing: 1.3 }} | ||
| 129 | > | ||
| 130 | Pair environment | ||
| 131 | </Text> | ||
| 132 | <Text | 624 | <Text |
| 133 | className="text-[20px] font-extrabold leading-[24px]" | 625 | className="text-[13px] leading-[18px]" |
| 134 | style={{ color: palette.text }} | 626 | style={{ color: props.palette.subtle }} |
| 135 | > | 627 | > |
| 136 | Add another backend | 628 | Checking camera permission… |
| 137 | </Text> | 629 | </Text> |
| 138 | <Text className="text-[14px] leading-[20px]" style={{ color: palette.muted }}> | 630 | ) : cameraPermission.granted ? ( |
| 139 | Paste the pairing link from the web or desktop app. The mobile client will keep a | 631 | <> |
| 140 | separate live connection for each paired environment. | 632 | <View |
| 141 | </Text> | 633 | className="overflow-hidden rounded-[14px]" |
| 142 | </View> | 634 | style={{ |
| 635 | height: 200, | ||
| 636 | borderWidth: 1, | ||
| 637 | borderColor: props.palette.border, | ||
| 638 | backgroundColor: "#000000", | ||
| 639 | }} | ||
| 640 | > | ||
| 641 | <CameraView | ||
| 642 | barcodeScannerSettings={{ barcodeTypes: ["qr"] }} | ||
| 643 | facing="back" | ||
| 644 | onBarcodeScanned={scannerPaused ? undefined : handleBarcodeScanned} | ||
| 645 | onMountError={(error) => setScannerError(error.message)} | ||
| 646 | style={{ flex: 1 }} | ||
| 647 | /> | ||
| 648 | </View> | ||
| 649 | <Text | ||
| 650 | className="text-[12px] leading-[17px]" | ||
| 651 | style={{ color: props.palette.subtle }} | ||
| 652 | > | ||
| 653 | Point the camera at a pairing QR code. | ||
| 654 | </Text> | ||
| 655 | {scannerPaused ? ( | ||
| 656 | <SheetButton | ||
| 657 | icon="arrow.clockwise" | ||
| 658 | label="Scan again" | ||
| 659 | palette={props.palette} | ||
| 660 | tone="secondary" | ||
| 661 | compact | ||
| 662 | onPress={() => { | ||
| 663 | setScannerError(null); | ||
| 664 | setScannerPaused(false); | ||
| 665 | }} | ||
| 666 | /> | ||
| 667 | ) : null} | ||
| 668 | </> | ||
| 669 | ) : ( | ||
| 670 | <> | ||
| 671 | <Text | ||
| 672 | className="text-[12px] leading-[17px]" | ||
| 673 | style={{ color: props.palette.subtle }} | ||
| 674 | > | ||
| 675 | Camera access is required to scan a pairing QR code. | ||
| 676 | </Text> | ||
| 677 | {cameraPermission.canAskAgain ? ( | ||
| 678 | <SheetButton | ||
| 679 | icon="camera" | ||
| 680 | label="Allow camera" | ||
| 681 | palette={props.palette} | ||
| 682 | tone="secondary" | ||
| 683 | compact | ||
| 684 | onPress={() => void requestCameraPermission().catch(() => {})} | ||
| 685 | /> | ||
| 686 | ) : ( | ||
| 687 | <SheetButton | ||
| 688 | icon="gear" | ||
| 689 | label="Open settings" | ||
| 690 | palette={props.palette} | ||
| 691 | tone="secondary" | ||
| 692 | compact | ||
| 693 | onPress={() => void Linking.openSettings().catch(() => {})} | ||
| 694 | /> | ||
| 695 | )} | ||
| 696 | </> | ||
| 697 | )} | ||
| 698 | {scannerError ? <ErrorBanner message={scannerError} /> : null} | ||
| 699 | </View> | ||
| 700 | ) : null} | ||
| 143 | 701 | ||
| 144 | <View className="gap-2"> | 702 | {/* Host field */} |
| 145 | <Text | 703 | <View className="gap-1.5"> |
| 146 | className="text-[11px] font-bold uppercase" | 704 | <Text |
| 147 | style={{ color: palette.muted, letterSpacing: 1.15 }} | 705 | className="text-[11px] font-t3-bold uppercase" |
| 148 | > | 706 | style={{ color: props.palette.muted, letterSpacing: 0.8 }} |
| 149 | Pairing URL | 707 | > |
| 150 | </Text> | 708 | Host |
| 709 | </Text> | ||
| 710 | <View className="flex-row items-center gap-2"> | ||
| 711 | <View className="flex-1"> | ||
| 151 | <TextInput | 712 | <TextInput |
| 152 | autoCapitalize="none" | 713 | autoCapitalize="none" |
| 153 | autoCorrect={false} | 714 | autoCorrect={false} |
| 154 | keyboardType="url" | 715 | keyboardType="url" |
| 155 | placeholder="https://backend.example.com/pair#token=..." | 716 | placeholder="192.168.1.100:8080" |
| 156 | placeholderTextColor={palette.placeholder} | 717 | placeholderTextColor={props.palette.placeholder} |
| 157 | className="min-h-[56px] px-4 py-3 text-[15px]" | 718 | value={hostInput} |
| 719 | onChangeText={handleHostChange} | ||
| 720 | className="rounded-[14px] px-4 py-3.5 text-[15px]" | ||
| 158 | style={{ | 721 | style={{ |
| 722 | backgroundColor: props.palette.inputBackground, | ||
| 159 | borderWidth: 1, | 723 | borderWidth: 1, |
| 160 | borderColor: palette.border, | 724 | borderColor: props.palette.inputBorder, |
| 161 | backgroundColor: palette.input, | 725 | color: props.palette.text, |
| 162 | color: palette.inputText, | ||
| 163 | }} | 726 | }} |
| 164 | value={props.connectionInput.pairingUrl} | ||
| 165 | onChangeText={props.onChangePairingUrl} | ||
| 166 | /> | 727 | /> |
| 167 | </View> | 728 | </View> |
| 168 | 729 | <Pressable | |
| 169 | <View className="flex-row gap-3"> | 730 | className="h-[50px] w-[50px] items-center justify-center rounded-[14px]" |
| 170 | <View className="flex-1"> | 731 | style={{ |
| 171 | <ActionButton | 732 | backgroundColor: scannerVisible |
| 172 | label={props.connectionState === "connecting" ? "Pairing…" : "Add backend"} | 733 | ? props.palette.accent |
| 173 | onPress={props.onConnect} | 734 | : props.palette.secondaryButton, |
| 174 | palette={palette} | 735 | borderWidth: scannerVisible ? 0 : 1, |
| 175 | tone="primary" | 736 | borderColor: props.palette.border, |
| 176 | /> | 737 | }} |
| 177 | </View> | 738 | onPress={handleToggleScanner} |
| 178 | {props.connectedEnvironments.length > 0 ? ( | 739 | > |
| 179 | <View className="flex-1"> | 740 | <SymbolView |
| 180 | <ActionButton label="Close" onPress={props.onClose} palette={palette} /> | 741 | name={scannerVisible ? "xmark" : "qrcode.viewfinder"} |
| 181 | </View> | 742 | size={20} |
| 182 | ) : null} | 743 | tintColor={ |
| 183 | </View> | 744 | scannerVisible |
| 745 | ? props.palette.primaryButtonText | ||
| 746 | : props.palette.secondaryButtonText | ||
| 747 | } | ||
| 748 | type="monochrome" | ||
| 749 | /> | ||
| 750 | </Pressable> | ||
| 184 | </View> | 751 | </View> |
| 752 | </View> | ||
| 753 | |||
| 754 | {/* Pairing code field */} | ||
| 755 | <View className="gap-1.5"> | ||
| 756 | <Text | ||
| 757 | className="text-[11px] font-t3-bold uppercase" | ||
| 758 | style={{ color: props.palette.muted, letterSpacing: 0.8 }} | ||
| 759 | > | ||
| 760 | Pairing code | ||
| 761 | </Text> | ||
| 762 | <TextInput | ||
| 763 | autoCapitalize="none" | ||
| 764 | autoCorrect={false} | ||
| 765 | placeholder="abc-123-xyz" | ||
| 766 | placeholderTextColor={props.palette.placeholder} | ||
| 767 | value={codeInput} | ||
| 768 | onChangeText={handleCodeChange} | ||
| 769 | className="rounded-[14px] px-4 py-3.5 text-[15px]" | ||
| 770 | style={{ | ||
| 771 | backgroundColor: props.palette.inputBackground, | ||
| 772 | borderWidth: 1, | ||
| 773 | borderColor: props.palette.inputBorder, | ||
| 774 | color: props.palette.text, | ||
| 775 | }} | ||
| 776 | /> | ||
| 777 | </View> | ||
| 778 | |||
| 779 | {props.connectionError ? <ErrorBanner message={props.connectionError} /> : null} | ||
| 780 | |||
| 781 | <SheetButton | ||
| 782 | icon="plus" | ||
| 783 | label={props.connectionState === "connecting" ? "Pairing…" : "Add backend"} | ||
| 784 | disabled={connectDisabled} | ||
| 785 | palette={props.palette} | ||
| 786 | tone="primary" | ||
| 787 | onPress={props.onConnect} | ||
| 788 | /> | ||
| 789 | </BottomSheetScrollView> | ||
| 790 | </BottomSheetView> | ||
| 791 | </BottomSheetModal> | ||
| 792 | ); | ||
| 793 | } | ||
| 794 | |||
| 795 | // --------------------------------------------------------------------------- | ||
| 796 | // Main connection sheet | ||
| 797 | // --------------------------------------------------------------------------- | ||
| 798 | |||
| 799 | const ADD_MENU_ACTIONS = [ | ||
| 800 | { | ||
| 801 | id: "scan_qr", | ||
| 802 | title: "Scan QR Code", | ||
| 803 | image: "qrcode.viewfinder", | ||
| 804 | }, | ||
| 805 | { | ||
| 806 | id: "enter_manually", | ||
| 807 | title: "Enter Manually", | ||
| 808 | image: "keyboard", | ||
| 809 | }, | ||
| 810 | ] as const; | ||
| 811 | |||
| 812 | export function ConnectionSheet(props: ConnectionSheetProps) { | ||
| 813 | const insets = useSafeAreaInsets(); | ||
| 814 | const isDarkMode = useColorScheme() === "dark"; | ||
| 815 | const palette = makePalette(isDarkMode); | ||
| 816 | const sheetRef = useRef<BottomSheetModal>(null); | ||
| 817 | |||
| 818 | const [editingEnvironment, setEditingEnvironment] = | ||
| 819 | useState<ConnectedEnvironmentSummary | null>(null); | ||
| 820 | const [addSheetVisible, setAddSheetVisible] = useState(false); | ||
| 821 | const [addSheetStartWithScanner, setAddSheetStartWithScanner] = useState(false); | ||
| 822 | |||
| 823 | const dismissible = props.connectedEnvironments.length > 0; | ||
| 824 | const hasEnvironments = props.connectedEnvironments.length > 0; | ||
| 825 | |||
| 826 | const snapPoints = useMemo<(string | number)[]>( | ||
| 827 | () => [hasEnvironments ? "52%" : "38%"], | ||
| 828 | [hasEnvironments], | ||
| 829 | ); | ||
| 830 | |||
| 831 | const renderBackdrop = useCallback( | ||
| 832 | (backdropProps: React.ComponentProps<typeof BottomSheetBackdrop>) => ( | ||
| 833 | <BottomSheetBackdrop | ||
| 834 | {...backdropProps} | ||
| 835 | appearsOnIndex={0} | ||
| 836 | disappearsOnIndex={-1} | ||
| 837 | opacity={0.18} | ||
| 838 | pressBehavior={dismissible ? "close" : "none"} | ||
| 839 | /> | ||
| 840 | ), | ||
| 841 | [dismissible], | ||
| 842 | ); | ||
| 843 | |||
| 844 | useEffect(() => { | ||
| 845 | const sheet = sheetRef.current; | ||
| 846 | if (!sheet) return; | ||
| 847 | if (props.visible) { | ||
| 848 | sheet.present(); | ||
| 849 | } else { | ||
| 850 | sheet.dismiss(); | ||
| 851 | } | ||
| 852 | }, [props.visible]); | ||
| 853 | |||
| 854 | useEffect(() => { | ||
| 855 | if (!props.visible) { | ||
| 856 | setEditingEnvironment(null); | ||
| 857 | setAddSheetVisible(false); | ||
| 858 | } | ||
| 859 | }, [props.visible]); | ||
| 860 | |||
| 861 | // When there are no backends, open the add flow automatically | ||
| 862 | useEffect(() => { | ||
| 863 | if (props.visible && !dismissible) { | ||
| 864 | setAddSheetStartWithScanner(false); | ||
| 865 | setAddSheetVisible(true); | ||
| 866 | } | ||
| 867 | }, [props.visible, dismissible]); | ||
| 868 | |||
| 869 | const handleMenuAction = useCallback( | ||
| 870 | ({ nativeEvent }: { nativeEvent: { event: string } }) => { | ||
| 871 | if (nativeEvent.event === "scan_qr") { | ||
| 872 | setAddSheetStartWithScanner(true); | ||
| 873 | setAddSheetVisible(true); | ||
| 874 | } else if (nativeEvent.event === "enter_manually") { | ||
| 875 | setAddSheetStartWithScanner(false); | ||
| 876 | setAddSheetVisible(true); | ||
| 877 | } | ||
| 878 | }, | ||
| 879 | [], | ||
| 880 | ); | ||
| 185 | 881 | ||
| 186 | {props.connectionError ? <ErrorBanner message={props.connectionError} /> : null} | 882 | return ( |
| 883 | <BottomSheetModal | ||
| 884 | ref={sheetRef} | ||
| 885 | stackBehavior="push" | ||
| 886 | index={0} | ||
| 887 | snapPoints={snapPoints} | ||
| 888 | enableDynamicSizing={false} | ||
| 889 | enablePanDownToClose={dismissible} | ||
| 890 | backdropComponent={renderBackdrop} | ||
| 891 | onDismiss={props.onRequestClose} | ||
| 892 | backgroundStyle={{ backgroundColor: palette.sheet }} | ||
| 893 | handleIndicatorStyle={{ backgroundColor: "rgba(120,113,108,0.32)" }} | ||
| 894 | > | ||
| 895 | <BottomSheetView style={{ flex: 1 }}> | ||
| 896 | {/* Header */} | ||
| 897 | <View className="flex-row items-center justify-between px-5 pb-4 pt-1"> | ||
| 898 | <View className="flex-1 gap-1"> | ||
| 899 | <Text | ||
| 900 | className="text-[28px] font-t3-bold" | ||
| 901 | style={{ color: palette.text, letterSpacing: -0.3 }} | ||
| 902 | > | ||
| 903 | Backends | ||
| 904 | </Text> | ||
| 905 | <Text | ||
| 906 | className="text-[14px] leading-[20px]" | ||
| 907 | style={{ color: palette.muted }} | ||
| 908 | > | ||
| 909 | Manage your connected environments. | ||
| 910 | </Text> | ||
| 911 | </View> | ||
| 912 | |||
| 913 | {/* + button with native menu */} | ||
| 914 | <MenuView actions={[...ADD_MENU_ACTIONS]} onPressAction={handleMenuAction}> | ||
| 915 | <Pressable | ||
| 916 | className="h-11 w-11 items-center justify-center rounded-full" | ||
| 917 | style={{ | ||
| 918 | backgroundColor: palette.primaryButton, | ||
| 919 | ...Platform.select({ | ||
| 920 | ios: { | ||
| 921 | shadowColor: "#000", | ||
| 922 | shadowOffset: { width: 0, height: 2 }, | ||
| 923 | shadowOpacity: 0.12, | ||
| 924 | shadowRadius: 4, | ||
| 925 | }, | ||
| 926 | android: { elevation: 3 }, | ||
| 927 | }), | ||
| 928 | }} | ||
| 929 | > | ||
| 930 | <SymbolView | ||
| 931 | name="plus" | ||
| 932 | size={18} | ||
| 933 | tintColor={palette.primaryButtonText} | ||
| 934 | type="monochrome" | ||
| 935 | weight="semibold" | ||
| 936 | /> | ||
| 937 | </Pressable> | ||
| 938 | </MenuView> | ||
| 939 | </View> | ||
| 187 | 940 | ||
| 188 | {props.connectedEnvironments.length > 0 ? ( | 941 | {/* Backend list */} |
| 942 | <BottomSheetScrollView | ||
| 943 | keyboardShouldPersistTaps="handled" | ||
| 944 | showsVerticalScrollIndicator={false} | ||
| 945 | contentContainerStyle={{ | ||
| 946 | paddingHorizontal: 20, | ||
| 947 | paddingTop: 4, | ||
| 948 | paddingBottom: Math.max(insets.bottom, 16) + 12, | ||
| 949 | gap: 10, | ||
| 950 | }} | ||
| 951 | > | ||
| 952 | {hasEnvironments ? ( | ||
| 953 | props.connectedEnvironments.map((environment) => ( | ||
| 954 | <EnvironmentCard | ||
| 955 | key={environment.environmentId} | ||
| 956 | environment={environment} | ||
| 957 | palette={palette} | ||
| 958 | onPress={() => setEditingEnvironment(environment)} | ||
| 959 | onRemove={props.onRemoveEnvironment} | ||
| 960 | /> | ||
| 961 | )) | ||
| 962 | ) : ( | ||
| 963 | <View | ||
| 964 | className="items-center gap-3 rounded-[20px] px-6 py-8" | ||
| 965 | style={{ | ||
| 966 | backgroundColor: palette.card, | ||
| 967 | borderWidth: 1, | ||
| 968 | borderColor: palette.border, | ||
| 969 | }} | ||
| 970 | > | ||
| 189 | <View | 971 | <View |
| 190 | className="gap-3 px-4 py-4" | 972 | className="h-12 w-12 items-center justify-center rounded-[16px]" |
| 191 | style={{ | 973 | style={{ backgroundColor: palette.iconBg }} |
| 192 | borderWidth: 1, | ||
| 193 | borderColor: palette.border, | ||
| 194 | backgroundColor: palette.panel, | ||
| 195 | }} | ||
| 196 | > | 974 | > |
| 197 | <Text | 975 | <SymbolView |
| 198 | className="text-[11px] font-bold uppercase" | 976 | name="point.3.connected.trianglepath.dotted" |
| 199 | style={{ color: palette.accent, letterSpacing: 1.3 }} | 977 | size={20} |
| 200 | > | 978 | tintColor={palette.accent} |
| 201 | Connected backends | 979 | type="monochrome" |
| 202 | </Text> | 980 | /> |
| 203 | |||
| 204 | {props.connectedEnvironments.map((environment) => ( | ||
| 205 | <View | ||
| 206 | key={environment.environmentId} | ||
| 207 | className="gap-3 px-3 py-3" | ||
| 208 | style={{ | ||
| 209 | borderWidth: 1, | ||
| 210 | borderColor: palette.border, | ||
| 211 | }} | ||
| 212 | > | ||
| 213 | <View className="gap-1"> | ||
| 214 | <Text className="text-[16px] font-extrabold" style={{ color: palette.text }}> | ||
| 215 | {environment.environmentLabel} | ||
| 216 | </Text> | ||
| 217 | <Text className="text-[13px]" style={{ color: palette.muted }}> | ||
| 218 | {environment.displayUrl} | ||
| 219 | </Text> | ||
| 220 | <Text | ||
| 221 | className="text-[11px] font-bold uppercase" | ||
| 222 | style={{ color: palette.muted, letterSpacing: 1.05 }} | ||
| 223 | > | ||
| 224 | {environment.connectionState} | ||
| 225 | </Text> | ||
| 226 | </View> | ||
| 227 | |||
| 228 | {environment.connectionError ? ( | ||
| 229 | <Text className="text-[12px] leading-[18px]" style={{ color: palette.muted }}> | ||
| 230 | {environment.connectionError} | ||
| 231 | </Text> | ||
| 232 | ) : null} | ||
| 233 | |||
| 234 | <ActionButton | ||
| 235 | label="Remove" | ||
| 236 | onPress={() => props.onRemoveEnvironment(environment.environmentId)} | ||
| 237 | palette={palette} | ||
| 238 | tone="danger" | ||
| 239 | /> | ||
| 240 | </View> | ||
| 241 | ))} | ||
| 242 | </View> | 981 | </View> |
| 243 | ) : null} | 982 | <Text |
| 244 | </View> | 983 | className="text-center text-[14px] leading-[20px]" |
| 245 | </ScrollView> | 984 | style={{ color: palette.muted }} |
| 246 | </View> | 985 | > |
| 247 | </Modal> | 986 | No backends connected yet.{"\n"}Tap{" "} |
| 987 | <Text className="font-t3-bold" style={{ color: palette.text }}> | ||
| 988 | + | ||
| 989 | </Text>{" "} | ||
| 990 | to add one. | ||
| 991 | </Text> | ||
| 992 | </View> | ||
| 993 | )} | ||
| 994 | </BottomSheetScrollView> | ||
| 995 | </BottomSheetView> | ||
| 996 | |||
| 997 | {/* Nested: Add backend */} | ||
| 998 | <AddBackendSheet | ||
| 999 | visible={addSheetVisible} | ||
| 1000 | startWithScanner={addSheetStartWithScanner} | ||
| 1001 | connectionState={props.connectionState} | ||
| 1002 | connectionError={props.connectionError} | ||
| 1003 | palette={palette} | ||
| 1004 | bottomInset={insets.bottom} | ||
| 1005 | onDismiss={() => setAddSheetVisible(false)} | ||
| 1006 | onChangePairingUrl={props.onChangePairingUrl} | ||
| 1007 | onConnect={props.onConnect} | ||
| 1008 | /> | ||
| 1009 | |||
| 1010 | {/* Nested: Edit backend */} | ||
| 1011 | <EnvironmentDetailSheet | ||
| 1012 | environment={editingEnvironment} | ||
| 1013 | palette={palette} | ||
| 1014 | bottomInset={insets.bottom} | ||
| 1015 | onDismiss={() => setEditingEnvironment(null)} | ||
| 1016 | onSave={(environmentId, updates) => { | ||
| 1017 | void props.onUpdateEnvironment(environmentId, updates); | ||
| 1018 | }} | ||
| 1019 | onRemove={props.onRemoveEnvironment} | ||
| 1020 | /> | ||
| 1021 | </BottomSheetModal> | ||
| 248 | ); | 1022 | ); |
| 249 | } | 1023 | } |
| @@ -0,0 +1,171 @@ | |||
| 1 | import { SymbolView } from "expo-symbols"; | ||
| 2 | import { useMemo } from "react"; | ||
| 3 | import { Pressable, ScrollView, View } from "react-native"; | ||
| 4 | import { useSafeAreaInsets } from "react-native-safe-area-context"; | ||
| 5 | |||
| 6 | import { AppText as Text } from "../../components/AppText"; | ||
| 7 | import { EmptyState } from "../../components/EmptyState"; | ||
| 8 | import { StatusPill } from "../../components/StatusPill"; | ||
| 9 | import { groupProjectsByRepository } from "../../lib/repositoryGroups"; | ||
| 10 | import type { ScopedMobileProject, ScopedMobileThread } from "../../lib/scopedEntities"; | ||
| 11 | import { scopedProjectKey } from "../../lib/scopedEntities"; | ||
| 12 | import { relativeTime } from "../../lib/time"; | ||
| 13 | import { ConnectionStatusDot } from "../connection/ConnectionStatusDot"; | ||
| 14 | import { threadStatusTone } from "../threads/threadPresentation"; | ||
| 15 | |||
| 16 | export function HomeScreen(props: { | ||
| 17 | readonly projects: ReadonlyArray<ScopedMobileProject>; | ||
| 18 | readonly threads: ReadonlyArray<ScopedMobileThread>; | ||
| 19 | readonly connectionState: "ready" | "connecting" | "reconnecting" | "disconnected" | "idle"; | ||
| 20 | readonly connectionPulse: boolean; | ||
| 21 | readonly onOpenConnectionEditor: () => void; | ||
| 22 | readonly onOpenNewTask: () => void; | ||
| 23 | readonly onSelectThread: (thread: ScopedMobileThread) => void; | ||
| 24 | }) { | ||
| 25 | const insets = useSafeAreaInsets(); | ||
| 26 | const repositoryGroups = useMemo( | ||
| 27 | () => groupProjectsByRepository({ projects: props.projects, threads: props.threads }), | ||
| 28 | [props.projects, props.threads], | ||
| 29 | ); | ||
| 30 | const projectLabelsByKey = useMemo(() => { | ||
| 31 | const map = new Map< | ||
| 32 | string, | ||
| 33 | { | ||
| 34 | readonly projectTitle: string; | ||
| 35 | readonly environmentLabel: string; | ||
| 36 | } | ||
| 37 | >(); | ||
| 38 | |||
| 39 | for (const group of repositoryGroups) { | ||
| 40 | const primaryProjectTitle = group.projects[0]?.project.title ?? group.title; | ||
| 41 | for (const projectGroup of group.projects) { | ||
| 42 | map.set(scopedProjectKey(projectGroup.project.environmentId, projectGroup.project.id), { | ||
| 43 | projectTitle: primaryProjectTitle, | ||
| 44 | environmentLabel: projectGroup.project.environmentLabel, | ||
| 45 | }); | ||
| 46 | } | ||
| 47 | } | ||
| 48 | |||
| 49 | return map; | ||
| 50 | }, [repositoryGroups]); | ||
| 51 | |||
| 52 | return ( | ||
| 53 | <View style={{ flex: 1, backgroundColor: "#f6f4ef" }}> | ||
| 54 | <ScrollView | ||
| 55 | showsVerticalScrollIndicator={false} | ||
| 56 | contentContainerStyle={{ | ||
| 57 | paddingBottom: Math.max(insets.bottom, 24) + 92, | ||
| 58 | paddingHorizontal: 20, | ||
| 59 | paddingTop: insets.top + 6, | ||
| 60 | gap: 16, | ||
| 61 | }} | ||
| 62 | > | ||
| 63 | <View className="flex-row items-center justify-between"> | ||
| 64 | <Text className="text-[34px] font-t3-bold" style={{ color: "#171717" }}> | ||
| 65 | Recents | ||
| 66 | </Text> | ||
| 67 | |||
| 68 | <Pressable | ||
| 69 | onPress={props.onOpenConnectionEditor} | ||
| 70 | className="h-14 min-w-[56px] items-center justify-center rounded-full px-4" | ||
| 71 | style={{ backgroundColor: "rgba(255,255,255,0.9)" }} | ||
| 72 | > | ||
| 73 | <ConnectionStatusDot state={props.connectionState} pulse={props.connectionPulse} /> | ||
| 74 | </Pressable> | ||
| 75 | </View> | ||
| 76 | |||
| 77 | {props.threads.length === 0 ? ( | ||
| 78 | <EmptyState | ||
| 79 | title="No threads yet" | ||
| 80 | detail="Create a task to start a new coding session in one of your connected projects." | ||
| 81 | /> | ||
| 82 | ) : ( | ||
| 83 | <View className="overflow-hidden rounded-[24px]" style={{ backgroundColor: "#ffffff" }}> | ||
| 84 | {props.threads.map((thread, index) => { | ||
| 85 | const projectKey = scopedProjectKey(thread.environmentId, thread.projectId); | ||
| 86 | const projectLabel = projectLabelsByKey.get(projectKey); | ||
| 87 | |||
| 88 | return ( | ||
| 89 | <Pressable | ||
| 90 | key={`${thread.environmentId}:${thread.id}`} | ||
| 91 | onPress={() => props.onSelectThread(thread)} | ||
| 92 | style={{ | ||
| 93 | paddingHorizontal: 16, | ||
| 94 | paddingVertical: 13, | ||
| 95 | borderTopWidth: index === 0 ? 0 : 1, | ||
| 96 | borderTopColor: "rgba(23,23,23,0.06)", | ||
| 97 | }} | ||
| 98 | > | ||
| 99 | <View className="flex-row items-start justify-between gap-3"> | ||
| 100 | <View className="flex-1 gap-1.5"> | ||
| 101 | <Text | ||
| 102 | className="text-[17px] font-t3-bold leading-[24px]" | ||
| 103 | numberOfLines={2} | ||
| 104 | style={{ color: "#171717" }} | ||
| 105 | > | ||
| 106 | {thread.title} | ||
| 107 | </Text> | ||
| 108 | </View> | ||
| 109 | <StatusPill {...threadStatusTone(thread)} size="compact" /> | ||
| 110 | </View> | ||
| 111 | |||
| 112 | <View className="mt-2 flex-row items-center justify-between gap-3"> | ||
| 113 | <View className="flex-1 flex-row items-center gap-2"> | ||
| 114 | <Text | ||
| 115 | className="text-[13px] font-medium leading-[18px]" | ||
| 116 | numberOfLines={1} | ||
| 117 | style={{ color: "#57534e", flexShrink: 1 }} | ||
| 118 | > | ||
| 119 | {projectLabel?.projectTitle ?? thread.title} | ||
| 120 | </Text> | ||
| 121 | <View | ||
| 122 | className="h-1 w-1 rounded-full" | ||
| 123 | style={{ backgroundColor: "rgba(120,113,108,0.45)" }} | ||
| 124 | /> | ||
| 125 | <Text | ||
| 126 | className="text-[12px] font-t3-bold uppercase tracking-[0.4px]" | ||
| 127 | numberOfLines={1} | ||
| 128 | style={{ color: "#78716c", flexShrink: 1 }} | ||
| 129 | > | ||
| 130 | {projectLabel?.environmentLabel ?? "Local"} | ||
| 131 | </Text> | ||
| 132 | </View> | ||
| 133 | <Text | ||
| 134 | className="text-[12px] font-t3-bold" | ||
| 135 | style={{ color: "#78716c", fontVariant: ["tabular-nums"] }} | ||
| 136 | > | ||
| 137 | {relativeTime(thread.updatedAt ?? thread.createdAt)} | ||
| 138 | </Text> | ||
| 139 | </View> | ||
| 140 | </Pressable> | ||
| 141 | ); | ||
| 142 | })} | ||
| 143 | </View> | ||
| 144 | )} | ||
| 145 | </ScrollView> | ||
| 146 | |||
| 147 | <View | ||
| 148 | pointerEvents="box-none" | ||
| 149 | style={{ | ||
| 150 | position: "absolute", | ||
| 151 | right: 20, | ||
| 152 | bottom: Math.max(insets.bottom, 18), | ||
| 153 | }} | ||
| 154 | > | ||
| 155 | <Pressable | ||
| 156 | onPress={props.onOpenNewTask} | ||
| 157 | className="flex-row items-center gap-3 rounded-full px-6 py-4" | ||
| 158 | style={{ | ||
| 159 | backgroundColor: "#171717", | ||
| 160 | boxShadow: "0 20px 36px rgba(23,23,23,0.18)", | ||
| 161 | }} | ||
| 162 | > | ||
| 163 | <SymbolView name="square.and.pencil" size={20} tintColor="#fafaf9" type="monochrome" /> | ||
| 164 | <Text className="text-[18px] font-t3-bold" style={{ color: "#fafaf9" }}> | ||
| 165 | New task | ||
| 166 | </Text> | ||
| 167 | </Pressable> | ||
| 168 | </View> | ||
| 169 | </View> | ||
| 170 | ); | ||
| 171 | } | ||
| @@ -0,0 +1,1270 @@ | |||
| 1 | import { | ||
| 2 | BottomSheetBackdrop, | ||
| 3 | BottomSheetModal, | ||
| 4 | BottomSheetScrollView, | ||
| 5 | BottomSheetView, | ||
| 6 | } from "@gorhom/bottom-sheet"; | ||
| 7 | import type { | ||
| 8 | ClaudeCodeEffort, | ||
| 9 | GitBranch, | ||
| 10 | ModelSelection, | ||
| 11 | ProviderInteractionMode, | ||
| 12 | RuntimeMode, | ||
| 13 | ServerConfig as T3ServerConfig, | ||
| 14 | } from "@t3tools/contracts"; | ||
| 15 | import { | ||
| 16 | CLAUDE_CODE_EFFORT_OPTIONS, | ||
| 17 | DEFAULT_PROVIDER_INTERACTION_MODE, | ||
| 18 | DEFAULT_RUNTIME_MODE, | ||
| 19 | } from "@t3tools/contracts"; | ||
| 20 | import { SymbolView } from "expo-symbols"; | ||
| 21 | import { type ReactNode, useCallback, useEffect, useMemo, useRef, useState } from "react"; | ||
| 22 | import { | ||
| 23 | Image, | ||
| 24 | KeyboardAvoidingView, | ||
| 25 | Platform, | ||
| 26 | Pressable, | ||
| 27 | ScrollView, | ||
| 28 | View, | ||
| 29 | } from "react-native"; | ||
| 30 | import Reanimated, { SlideInLeft, SlideInRight, SlideOutLeft, SlideOutRight } from "react-native-reanimated"; | ||
| 31 | import { useSafeAreaInsets } from "react-native-safe-area-context"; | ||
| 32 | |||
| 33 | import { AppText as Text, AppTextInput as TextInput } from "../../components/AppText"; | ||
| 34 | import type { DraftComposerImageAttachment } from "../../lib/composerImages"; | ||
| 35 | import { pickComposerImages } from "../../lib/composerImages"; | ||
| 36 | import { groupProjectsByRepository } from "../../lib/repositoryGroups"; | ||
| 37 | import type { ScopedMobileProject, ScopedMobileThread } from "../../lib/scopedEntities"; | ||
| 38 | import { scopedProjectKey } from "../../lib/scopedEntities"; | ||
| 39 | |||
| 40 | type WorkspaceMode = "local" | "worktree"; | ||
| 41 | |||
| 42 | type ModelOption = { | ||
| 43 | readonly key: string; | ||
| 44 | readonly label: string; | ||
| 45 | readonly subtitle: string; | ||
| 46 | readonly providerKey: string; | ||
| 47 | readonly providerLabel: string; | ||
| 48 | readonly selection: ModelSelection; | ||
| 49 | }; | ||
| 50 | |||
| 51 | type ProviderGroup = { | ||
| 52 | readonly providerKey: string; | ||
| 53 | readonly providerLabel: string; | ||
| 54 | readonly models: ReadonlyArray<ModelOption>; | ||
| 55 | }; | ||
| 56 | |||
| 57 | function normalizeSelectedWorktreePath( | ||
| 58 | project: ScopedMobileProject, | ||
| 59 | branch: GitBranch, | ||
| 60 | ): string | null { | ||
| 61 | if (!branch.worktreePath) { | ||
| 62 | return null; | ||
| 63 | } | ||
| 64 | |||
| 65 | return branch.worktreePath === project.workspaceRoot ? null : branch.worktreePath; | ||
| 66 | } | ||
| 67 | |||
| 68 | function branchBadgeLabel(input: { | ||
| 69 | readonly branch: GitBranch; | ||
| 70 | readonly project: ScopedMobileProject | null; | ||
| 71 | }): string | null { | ||
| 72 | if (input.branch.current) { | ||
| 73 | return "current"; | ||
| 74 | } | ||
| 75 | if (input.branch.worktreePath && input.branch.worktreePath !== input.project?.workspaceRoot) { | ||
| 76 | return "worktree"; | ||
| 77 | } | ||
| 78 | if (input.branch.isDefault) { | ||
| 79 | return "default"; | ||
| 80 | } | ||
| 81 | if (input.branch.isRemote) { | ||
| 82 | return "remote"; | ||
| 83 | } | ||
| 84 | return null; | ||
| 85 | } | ||
| 86 | |||
| 87 | function providerDisplayLabel(provider: string): string { | ||
| 88 | if (provider === "codex") return "Codex"; | ||
| 89 | if (provider === "claudeAgent") return "Claude"; | ||
| 90 | return provider; | ||
| 91 | } | ||
| 92 | |||
| 93 | function buildModelOptions( | ||
| 94 | config: T3ServerConfig | null | undefined, | ||
| 95 | fallbackModelSelection: ModelSelection | null, | ||
| 96 | ): ReadonlyArray<ModelOption> { | ||
| 97 | const options = new Map<string, ModelOption>(); | ||
| 98 | |||
| 99 | for (const provider of config?.providers ?? []) { | ||
| 100 | if (!provider.enabled || !provider.installed || provider.auth.status === "unauthenticated") { | ||
| 101 | continue; | ||
| 102 | } | ||
| 103 | |||
| 104 | const pLabel = providerDisplayLabel(provider.provider); | ||
| 105 | for (const model of provider.models) { | ||
| 106 | const key = `${provider.provider}:${model.slug}`; | ||
| 107 | options.set(key, { | ||
| 108 | key, | ||
| 109 | label: model.name, | ||
| 110 | subtitle: pLabel, | ||
| 111 | providerKey: provider.provider, | ||
| 112 | providerLabel: pLabel, | ||
| 113 | selection: { | ||
| 114 | provider: provider.provider, | ||
| 115 | model: model.slug, | ||
| 116 | }, | ||
| 117 | }); | ||
| 118 | } | ||
| 119 | } | ||
| 120 | |||
| 121 | if (fallbackModelSelection) { | ||
| 122 | const key = `${fallbackModelSelection.provider}:${fallbackModelSelection.model}`; | ||
| 123 | if (!options.has(key)) { | ||
| 124 | const pLabel = providerDisplayLabel(fallbackModelSelection.provider); | ||
| 125 | options.set(key, { | ||
| 126 | key, | ||
| 127 | label: fallbackModelSelection.model, | ||
| 128 | subtitle: pLabel, | ||
| 129 | providerKey: fallbackModelSelection.provider, | ||
| 130 | providerLabel: pLabel, | ||
| 131 | selection: fallbackModelSelection, | ||
| 132 | }); | ||
| 133 | } | ||
| 134 | } | ||
| 135 | |||
| 136 | return [...options.values()]; | ||
| 137 | } | ||
| 138 | |||
| 139 | function groupByProvider(options: ReadonlyArray<ModelOption>): ReadonlyArray<ProviderGroup> { | ||
| 140 | const groups = new Map<string, { providerLabel: string; models: ModelOption[] }>(); | ||
| 141 | for (const option of options) { | ||
| 142 | const existing = groups.get(option.providerKey); | ||
| 143 | if (existing) { | ||
| 144 | existing.models.push(option); | ||
| 145 | } else { | ||
| 146 | groups.set(option.providerKey, { | ||
| 147 | providerLabel: option.providerLabel, | ||
| 148 | models: [option], | ||
| 149 | }); | ||
| 150 | } | ||
| 151 | } | ||
| 152 | return [...groups.entries()].map(([providerKey, group]) => ({ | ||
| 153 | providerKey, | ||
| 154 | providerLabel: group.providerLabel, | ||
| 155 | models: group.models, | ||
| 156 | })); | ||
| 157 | } | ||
| 158 | |||
| 159 | function compactModelLabel(option: ModelOption | null): string { | ||
| 160 | if (!option) { | ||
| 161 | return "AI"; | ||
| 162 | } | ||
| 163 | |||
| 164 | const versionMatch = option.selection.model.match(/(\d+(?:\.\d+)?)/); | ||
| 165 | if (versionMatch?.[1]) { | ||
| 166 | return versionMatch[1]; | ||
| 167 | } | ||
| 168 | |||
| 169 | const lastToken = option.label.split(/\s+/).at(-1)?.trim(); | ||
| 170 | return lastToken && lastToken.length <= 6 ? lastToken : "AI"; | ||
| 171 | } | ||
| 172 | |||
| 173 | function ControlPill(props: { | ||
| 174 | readonly icon: React.ComponentProps<typeof SymbolView>["name"]; | ||
| 175 | readonly label?: string; | ||
| 176 | readonly onPress: () => void; | ||
| 177 | readonly variant?: "circle" | "pill" | "primary"; | ||
| 178 | readonly disabled?: boolean; | ||
| 179 | }) { | ||
| 180 | const variant = props.variant ?? "circle"; | ||
| 181 | const backgroundColor = | ||
| 182 | variant === "primary" | ||
| 183 | ? props.disabled | ||
| 184 | ? "rgba(23,23,23,0.14)" | ||
| 185 | : "#171717" | ||
| 186 | : "rgba(23,23,23,0.06)"; | ||
| 187 | const iconTintColor = variant === "primary" ? "#fafaf9" : "#171717"; | ||
| 188 | const textColor = variant === "primary" ? "#fafaf9" : "#171717"; | ||
| 189 | |||
| 190 | return ( | ||
| 191 | <Pressable | ||
| 192 | onPress={props.onPress} | ||
| 193 | disabled={props.disabled} | ||
| 194 | className={ | ||
| 195 | variant === "circle" | ||
| 196 | ? "h-11 w-11 items-center justify-center rounded-full" | ||
| 197 | : variant === "primary" | ||
| 198 | ? "h-11 flex-row items-center justify-center gap-2 rounded-full px-5" | ||
| 199 | : "h-11 flex-row items-center justify-center gap-2 rounded-full px-3.5" | ||
| 200 | } | ||
| 201 | style={{ backgroundColor }} | ||
| 202 | > | ||
| 203 | <SymbolView name={props.icon} size={16} tintColor={iconTintColor} type="monochrome" /> | ||
| 204 | {props.label ? ( | ||
| 205 | <Text className="text-center text-[12px] font-t3-bold" style={{ color: textColor }}> | ||
| 206 | {props.label} | ||
| 207 | </Text> | ||
| 208 | ) : null} | ||
| 209 | </Pressable> | ||
| 210 | ); | ||
| 211 | } | ||
| 212 | |||
| 213 | function BottomSelectorSheet(props: { | ||
| 214 | readonly visible: boolean; | ||
| 215 | readonly title: string; | ||
| 216 | readonly onClose: () => void; | ||
| 217 | readonly children: ReactNode; | ||
| 218 | readonly snapPoints?: ReadonlyArray<string | number>; | ||
| 219 | }) { | ||
| 220 | const sheetRef = useRef<BottomSheetModal>(null); | ||
| 221 | const snapPoints = useMemo<(string | number)[]>( | ||
| 222 | () => [...(props.snapPoints ?? ["72%"])], | ||
| 223 | [props.snapPoints], | ||
| 224 | ); | ||
| 225 | const renderBackdrop = useCallback( | ||
| 226 | (backdropProps: React.ComponentProps<typeof BottomSheetBackdrop>) => ( | ||
| 227 | <BottomSheetBackdrop | ||
| 228 | {...backdropProps} | ||
| 229 | appearsOnIndex={0} | ||
| 230 | disappearsOnIndex={-1} | ||
| 231 | opacity={0.18} | ||
| 232 | pressBehavior="close" | ||
| 233 | /> | ||
| 234 | ), | ||
| 235 | [], | ||
| 236 | ); | ||
| 237 | |||
| 238 | useEffect(() => { | ||
| 239 | const sheet = sheetRef.current; | ||
| 240 | if (!sheet) { | ||
| 241 | return; | ||
| 242 | } | ||
| 243 | |||
| 244 | if (props.visible) { | ||
| 245 | sheet.present(); | ||
| 246 | return; | ||
| 247 | } | ||
| 248 | |||
| 249 | sheet.dismiss(); | ||
| 250 | }, [props.visible]); | ||
| 251 | |||
| 252 | return ( | ||
| 253 | <BottomSheetModal | ||
| 254 | ref={sheetRef} | ||
| 255 | stackBehavior="push" | ||
| 256 | index={0} | ||
| 257 | snapPoints={snapPoints} | ||
| 258 | enableDynamicSizing={false} | ||
| 259 | enablePanDownToClose | ||
| 260 | backdropComponent={renderBackdrop} | ||
| 261 | onDismiss={props.onClose} | ||
| 262 | backgroundStyle={{ backgroundColor: "rgba(248,246,241,0.98)" }} | ||
| 263 | handleIndicatorStyle={{ backgroundColor: "rgba(120,113,108,0.32)" }} | ||
| 264 | > | ||
| 265 | <BottomSheetView style={{ flex: 1 }} className="px-5 pt-1"> | ||
| 266 | <View className="items-center pb-4"> | ||
| 267 | <Text className="text-[24px] font-t3-bold" style={{ color: "#171717" }}> | ||
| 268 | {props.title} | ||
| 269 | </Text> | ||
| 270 | </View> | ||
| 271 | {props.children} | ||
| 272 | </BottomSheetView> | ||
| 273 | </BottomSheetModal> | ||
| 274 | ); | ||
| 275 | } | ||
| 276 | |||
| 277 | // --------------------------------------------------------------------------- | ||
| 278 | // Main NewTaskSheet — two-step: project picker → task form | ||
| 279 | // --------------------------------------------------------------------------- | ||
| 280 | |||
| 281 | export function NewTaskSheet(props: { | ||
| 282 | readonly visible: boolean; | ||
| 283 | readonly projects: ReadonlyArray<ScopedMobileProject>; | ||
| 284 | readonly threads: ReadonlyArray<ScopedMobileThread>; | ||
| 285 | readonly serverConfigByEnvironmentId: Readonly<Record<string, T3ServerConfig | null>>; | ||
| 286 | readonly onRequestClose: () => void; | ||
| 287 | readonly onCreateThreadWithOptions: (input: { | ||
| 288 | readonly project: ScopedMobileProject; | ||
| 289 | readonly modelSelection: ModelSelection; | ||
| 290 | readonly envMode: WorkspaceMode; | ||
| 291 | readonly branch: string | null; | ||
| 292 | readonly worktreePath: string | null; | ||
| 293 | readonly runtimeMode: RuntimeMode; | ||
| 294 | readonly interactionMode: ProviderInteractionMode; | ||
| 295 | readonly initialMessageText: string; | ||
| 296 | readonly initialAttachments: ReadonlyArray<DraftComposerImageAttachment>; | ||
| 297 | }) => Promise<void>; | ||
| 298 | readonly onListProjectBranches: ( | ||
| 299 | project: ScopedMobileProject, | ||
| 300 | ) => Promise<ReadonlyArray<GitBranch>>; | ||
| 301 | }) { | ||
| 302 | const insets = useSafeAreaInsets(); | ||
| 303 | const sheetRef = useRef<BottomSheetModal>(null); | ||
| 304 | |||
| 305 | // Step state | ||
| 306 | const [step, setStep] = useState<"project" | "task">("project"); | ||
| 307 | |||
| 308 | // Project picker state | ||
| 309 | const repositoryGroups = useMemo( | ||
| 310 | () => groupProjectsByRepository({ projects: props.projects, threads: props.threads }), | ||
| 311 | [props.projects, props.threads], | ||
| 312 | ); | ||
| 313 | const logicalProjects = useMemo( | ||
| 314 | () => | ||
| 315 | repositoryGroups | ||
| 316 | .map((group) => { | ||
| 317 | const primaryProject = group.projects[0]?.project; | ||
| 318 | if (!primaryProject) return null; | ||
| 319 | return { key: group.key, project: primaryProject }; | ||
| 320 | }) | ||
| 321 | .filter((entry) => entry !== null), | ||
| 322 | [repositoryGroups], | ||
| 323 | ); | ||
| 324 | |||
| 325 | // Task form state | ||
| 326 | const [selectedEnvironmentId, setSelectedEnvironmentId] = useState( | ||
| 327 | props.projects[0]?.environmentId ?? "", | ||
| 328 | ); | ||
| 329 | const [selectedProjectKey, setSelectedProjectKey] = useState<string | null>(null); | ||
| 330 | const [selectedModelKey, setSelectedModelKey] = useState<string | null>(null); | ||
| 331 | const [workspaceMode, setWorkspaceMode] = useState<WorkspaceMode>("local"); | ||
| 332 | const [selectedBranchName, setSelectedBranchName] = useState<string | null>(null); | ||
| 333 | const [selectedWorktreePath, setSelectedWorktreePath] = useState<string | null>(null); | ||
| 334 | const [prompt, setPrompt] = useState(""); | ||
| 335 | const [attachments, setAttachments] = useState<ReadonlyArray<DraftComposerImageAttachment>>([]); | ||
| 336 | const [submitting, setSubmitting] = useState(false); | ||
| 337 | const [projectPickerVisible, setProjectPickerVisible] = useState(false); | ||
| 338 | const [environmentPickerVisible, setEnvironmentPickerVisible] = useState(false); | ||
| 339 | const [modelPickerVisible, setModelPickerVisible] = useState(false); | ||
| 340 | const [optionsPickerVisible, setOptionsPickerVisible] = useState(false); | ||
| 341 | const [branchPickerVisible, setBranchPickerVisible] = useState(false); | ||
| 342 | const [branchQuery, setBranchQuery] = useState(""); | ||
| 343 | const [branchesLoading, setBranchesLoading] = useState(false); | ||
| 344 | const [availableBranches, setAvailableBranches] = useState<ReadonlyArray<GitBranch>>([]); | ||
| 345 | const [runtimeMode, setRuntimeMode] = useState<RuntimeMode>(DEFAULT_RUNTIME_MODE); | ||
| 346 | const [interactionMode, setInteractionMode] = | ||
| 347 | useState<ProviderInteractionMode>(DEFAULT_PROVIDER_INTERACTION_MODE); | ||
| 348 | const [effort, setEffort] = useState<ClaudeCodeEffort>("high"); | ||
| 349 | const [fastMode, setFastMode] = useState(false); | ||
| 350 | const [contextWindow, setContextWindow] = useState("1M"); | ||
| 351 | const [expandedProvider, setExpandedProvider] = useState<string | null>(null); | ||
| 352 | |||
| 353 | const environments = useMemo( | ||
| 354 | () => | ||
| 355 | [ | ||
| 356 | ...new Map( | ||
| 357 | props.projects.map((project) => [project.environmentId, project.environmentLabel]), | ||
| 358 | ).entries(), | ||
| 359 | ].map(([environmentId, environmentLabel]) => ({ | ||
| 360 | environmentId, | ||
| 361 | environmentLabel, | ||
| 362 | })), | ||
| 363 | [props.projects], | ||
| 364 | ); | ||
| 365 | |||
| 366 | const projectsForEnvironment = useMemo( | ||
| 367 | () => props.projects.filter((project) => project.environmentId === selectedEnvironmentId), | ||
| 368 | [props.projects, selectedEnvironmentId], | ||
| 369 | ); | ||
| 370 | const logicalProjectsForEnvironment = useMemo( | ||
| 371 | () => | ||
| 372 | groupProjectsByRepository({ projects: projectsForEnvironment, threads: [] }) | ||
| 373 | .map((group) => group.projects[0]?.project ?? null) | ||
| 374 | .filter((project) => project !== null), | ||
| 375 | [projectsForEnvironment], | ||
| 376 | ); | ||
| 377 | |||
| 378 | const selectedProject = | ||
| 379 | projectsForEnvironment.find( | ||
| 380 | (project) => scopedProjectKey(project.environmentId, project.id) === selectedProjectKey, | ||
| 381 | ) ?? | ||
| 382 | projectsForEnvironment[0] ?? | ||
| 383 | null; | ||
| 384 | |||
| 385 | const modelOptions = useMemo( | ||
| 386 | () => | ||
| 387 | buildModelOptions( | ||
| 388 | selectedProject | ||
| 389 | ? (props.serverConfigByEnvironmentId[selectedProject.environmentId] ?? null) | ||
| 390 | : null, | ||
| 391 | selectedProject?.defaultModelSelection ?? null, | ||
| 392 | ), | ||
| 393 | [props.serverConfigByEnvironmentId, selectedProject], | ||
| 394 | ); | ||
| 395 | |||
| 396 | const selectedModel = | ||
| 397 | modelOptions.find((option) => option.key === selectedModelKey)?.selection ?? | ||
| 398 | selectedProject?.defaultModelSelection ?? | ||
| 399 | modelOptions[0]?.selection ?? | ||
| 400 | null; | ||
| 401 | const selectedModelOption = | ||
| 402 | modelOptions.find( | ||
| 403 | (option) => | ||
| 404 | selectedModel && | ||
| 405 | option.selection.provider === selectedModel.provider && | ||
| 406 | option.selection.model === selectedModel.model, | ||
| 407 | ) ?? null; | ||
| 408 | |||
| 409 | const providerGroups = useMemo(() => groupByProvider(modelOptions), [modelOptions]); | ||
| 410 | |||
| 411 | const filteredBranches = useMemo(() => { | ||
| 412 | const query = branchQuery.trim().toLowerCase(); | ||
| 413 | if (query.length === 0) return availableBranches; | ||
| 414 | return availableBranches.filter((branch) => branch.name.toLowerCase().includes(query)); | ||
| 415 | }, [availableBranches, branchQuery]); | ||
| 416 | |||
| 417 | const headerSubtitle = useMemo(() => { | ||
| 418 | if (!selectedProject) return null; | ||
| 419 | if (workspaceMode === "worktree") { | ||
| 420 | return `${selectedProject.title} @ ${selectedBranchName ?? "Select base branch"}`; | ||
| 421 | } | ||
| 422 | if (selectedBranchName) { | ||
| 423 | return `${selectedProject.title} @ ${selectedBranchName}`; | ||
| 424 | } | ||
| 425 | return `${selectedProject.title} · ${selectedProject.environmentLabel}`; | ||
| 426 | }, [selectedBranchName, selectedProject, workspaceMode]); | ||
| 427 | |||
| 428 | const renderBackdrop = useCallback( | ||
| 429 | (backdropProps: React.ComponentProps<typeof BottomSheetBackdrop>) => ( | ||
| 430 | <BottomSheetBackdrop | ||
| 431 | {...backdropProps} | ||
| 432 | appearsOnIndex={0} | ||
| 433 | disappearsOnIndex={-1} | ||
| 434 | opacity={0.18} | ||
| 435 | pressBehavior="close" | ||
| 436 | /> | ||
| 437 | ), | ||
| 438 | [], | ||
| 439 | ); | ||
| 440 | |||
| 441 | // Present / dismiss / reset | ||
| 442 | useEffect(() => { | ||
| 443 | const sheet = sheetRef.current; | ||
| 444 | if (!sheet) return; | ||
| 445 | |||
| 446 | if (props.visible) { | ||
| 447 | // Reset state | ||
| 448 | setPrompt(""); | ||
| 449 | setAttachments([]); | ||
| 450 | setSelectedModelKey(null); | ||
| 451 | setSelectedBranchName(null); | ||
| 452 | setSelectedWorktreePath(null); | ||
| 453 | setWorkspaceMode("local"); | ||
| 454 | setRuntimeMode(DEFAULT_RUNTIME_MODE); | ||
| 455 | setInteractionMode(DEFAULT_PROVIDER_INTERACTION_MODE); | ||
| 456 | setEffort("high"); | ||
| 457 | setFastMode(false); | ||
| 458 | setContextWindow("1M"); | ||
| 459 | setExpandedProvider(null); | ||
| 460 | setSubmitting(false); | ||
| 461 | |||
| 462 | // If only one project, skip to step 2 | ||
| 463 | if (logicalProjects.length === 1) { | ||
| 464 | const only = logicalProjects[0]!; | ||
| 465 | setSelectedEnvironmentId(only.project.environmentId); | ||
| 466 | setSelectedProjectKey(scopedProjectKey(only.project.environmentId, only.project.id)); | ||
| 467 | setStep("task"); | ||
| 468 | } else { | ||
| 469 | setStep("project"); | ||
| 470 | } | ||
| 471 | |||
| 472 | sheet.present(); | ||
| 473 | } else { | ||
| 474 | sheet.dismiss(); | ||
| 475 | } | ||
| 476 | // eslint-disable-next-line react-hooks/exhaustive-deps | ||
| 477 | }, [props.visible]); | ||
| 478 | |||
| 479 | // Step transitions | ||
| 480 | const goToTask = useCallback((project: ScopedMobileProject) => { | ||
| 481 | setSelectedEnvironmentId(project.environmentId); | ||
| 482 | setSelectedProjectKey(scopedProjectKey(project.environmentId, project.id)); | ||
| 483 | setStep("task"); | ||
| 484 | }, []); | ||
| 485 | |||
| 486 | const goBackToProjects = useCallback(() => { | ||
| 487 | setStep("project"); | ||
| 488 | }, []); | ||
| 489 | |||
| 490 | // Actions | ||
| 491 | async function handlePickImages(): Promise<void> { | ||
| 492 | const result = await pickComposerImages({ existingCount: attachments.length }); | ||
| 493 | if (result.images.length > 0) { | ||
| 494 | setAttachments((current) => [...current, ...result.images]); | ||
| 495 | } | ||
| 496 | } | ||
| 497 | |||
| 498 | async function openBranchPicker(): Promise<void> { | ||
| 499 | if (!selectedProject) return; | ||
| 500 | setBranchPickerVisible(true); | ||
| 501 | setBranchesLoading(true); | ||
| 502 | try { | ||
| 503 | const branches = await props.onListProjectBranches(selectedProject); | ||
| 504 | setAvailableBranches(branches); | ||
| 505 | if (workspaceMode === "worktree" && !selectedBranchName) { | ||
| 506 | const preferredBranch = | ||
| 507 | branches.find((branch) => branch.current)?.name ?? | ||
| 508 | branches.find((branch) => branch.isDefault)?.name ?? | ||
| 509 | null; | ||
| 510 | if (preferredBranch) setSelectedBranchName(preferredBranch); | ||
| 511 | } | ||
| 512 | } finally { | ||
| 513 | setBranchesLoading(false); | ||
| 514 | } | ||
| 515 | } | ||
| 516 | |||
| 517 | async function handleStart(): Promise<void> { | ||
| 518 | if ( | ||
| 519 | !selectedProject || | ||
| 520 | !selectedModel || | ||
| 521 | prompt.trim().length === 0 || | ||
| 522 | submitting || | ||
| 523 | (workspaceMode === "worktree" && !selectedBranchName) | ||
| 524 | ) { | ||
| 525 | return; | ||
| 526 | } | ||
| 527 | |||
| 528 | setSubmitting(true); | ||
| 529 | try { | ||
| 530 | const modelWithOptions: ModelSelection = | ||
| 531 | selectedModel.provider === "claudeAgent" | ||
| 532 | ? { | ||
| 533 | ...selectedModel, | ||
| 534 | options: { effort, fastMode: fastMode || undefined, contextWindow }, | ||
| 535 | } | ||
| 536 | : selectedModel.provider === "codex" | ||
| 537 | ? { ...selectedModel, options: { fastMode: fastMode || undefined } } | ||
| 538 | : selectedModel; | ||
| 539 | |||
| 540 | await props.onCreateThreadWithOptions({ | ||
| 541 | project: selectedProject, | ||
| 542 | modelSelection: modelWithOptions, | ||
| 543 | envMode: workspaceMode, | ||
| 544 | branch: selectedBranchName, | ||
| 545 | worktreePath: workspaceMode === "worktree" ? null : selectedWorktreePath, | ||
| 546 | runtimeMode, | ||
| 547 | interactionMode, | ||
| 548 | initialMessageText: prompt.trim(), | ||
| 549 | initialAttachments: attachments, | ||
| 550 | }); | ||
| 551 | props.onRequestClose(); | ||
| 552 | } finally { | ||
| 553 | setSubmitting(false); | ||
| 554 | } | ||
| 555 | } | ||
| 556 | |||
| 557 | return ( | ||
| 558 | <> | ||
| 559 | <BottomSheetModal | ||
| 560 | ref={sheetRef} | ||
| 561 | stackBehavior="push" | ||
| 562 | index={0} | ||
| 563 | snapPoints={["92%"]} | ||
| 564 | enableDynamicSizing={false} | ||
| 565 | enablePanDownToClose | ||
| 566 | backdropComponent={renderBackdrop} | ||
| 567 | onDismiss={props.onRequestClose} | ||
| 568 | backgroundStyle={{ backgroundColor: "rgba(250,248,242,0.98)" }} | ||
| 569 | handleIndicatorStyle={{ backgroundColor: "rgba(120,113,108,0.32)" }} | ||
| 570 | > | ||
| 571 | <BottomSheetView style={{ flex: 1, overflow: "hidden" }}> | ||
| 572 | {step === "project" ? ( | ||
| 573 | <Reanimated.View | ||
| 574 | key="step-project" | ||
| 575 | entering={SlideInLeft.duration(280)} | ||
| 576 | exiting={SlideOutLeft.duration(280)} | ||
| 577 | style={{ flex: 1 }} | ||
| 578 | > | ||
| 579 | <View className="items-center gap-1 px-5 pb-4 pt-1"> | ||
| 580 | <Text | ||
| 581 | className="text-[12px] font-t3-bold uppercase" | ||
| 582 | style={{ color: "#78716c", letterSpacing: 1 }} | ||
| 583 | > | ||
| 584 | New task | ||
| 585 | </Text> | ||
| 586 | <Text className="text-[28px] font-t3-bold" style={{ color: "#171717" }}> | ||
| 587 | Choose project | ||
| 588 | </Text> | ||
| 589 | </View> | ||
| 590 | |||
| 591 | <BottomSheetScrollView | ||
| 592 | showsVerticalScrollIndicator={false} | ||
| 593 | contentContainerStyle={{ | ||
| 594 | paddingHorizontal: 20, | ||
| 595 | paddingBottom: Math.max(insets.bottom, 18) + 18, | ||
| 596 | }} | ||
| 597 | > | ||
| 598 | <View | ||
| 599 | className="overflow-hidden rounded-[24px]" | ||
| 600 | style={{ backgroundColor: "#ffffff" }} | ||
| 601 | > | ||
| 602 | {logicalProjects.map((entry, index) => ( | ||
| 603 | <Pressable | ||
| 604 | key={entry.key} | ||
| 605 | onPress={() => goToTask(entry.project)} | ||
| 606 | style={{ | ||
| 607 | paddingHorizontal: 16, | ||
| 608 | paddingVertical: 18, | ||
| 609 | borderTopWidth: index === 0 ? 0 : 1, | ||
| 610 | borderTopColor: "rgba(23,23,23,0.06)", | ||
| 611 | }} | ||
| 612 | > | ||
| 613 | <View className="flex-row items-center justify-between gap-3"> | ||
| 614 | <View className="flex-1"> | ||
| 615 | <Text | ||
| 616 | className="text-[18px] font-t3-bold" | ||
| 617 | style={{ color: "#171717" }} | ||
| 618 | > | ||
| 619 | {entry.project.title} | ||
| 620 | </Text> | ||
| 621 | </View> | ||
| 622 | <SymbolView | ||
| 623 | name="chevron.right" | ||
| 624 | size={14} | ||
| 625 | tintColor="rgba(23,23,23,0.25)" | ||
| 626 | type="monochrome" | ||
| 627 | /> | ||
| 628 | </View> | ||
| 629 | </Pressable> | ||
| 630 | ))} | ||
| 631 | </View> | ||
| 632 | </BottomSheetScrollView> | ||
| 633 | </Reanimated.View> | ||
| 634 | ) : ( | ||
| 635 | <Reanimated.View | ||
| 636 | key="step-task" | ||
| 637 | entering={SlideInRight.duration(280)} | ||
| 638 | exiting={SlideOutRight.duration(280)} | ||
| 639 | style={{ flex: 1 }} | ||
| 640 | > | ||
| 641 | <KeyboardAvoidingView | ||
| 642 | behavior={Platform.OS === "ios" ? "padding" : undefined} | ||
| 643 | style={{ flex: 1 }} | ||
| 644 | > | ||
| 645 | <View className="flex-1"> | ||
| 646 | {/* Header with back button */} | ||
| 647 | <View className="items-center gap-1 px-5 pb-3 pt-1"> | ||
| 648 | {logicalProjects.length > 1 ? ( | ||
| 649 | <Pressable | ||
| 650 | className="absolute left-3 top-1 h-9 w-9 items-center justify-center rounded-full" | ||
| 651 | style={{ backgroundColor: "rgba(23,23,23,0.06)", zIndex: 1 }} | ||
| 652 | onPress={goBackToProjects} | ||
| 653 | > | ||
| 654 | <SymbolView | ||
| 655 | name="chevron.left" | ||
| 656 | size={16} | ||
| 657 | tintColor="#171717" | ||
| 658 | type="monochrome" | ||
| 659 | weight="medium" | ||
| 660 | /> | ||
| 661 | </Pressable> | ||
| 662 | ) : null} | ||
| 663 | <Text | ||
| 664 | className="text-[12px] font-t3-bold uppercase" | ||
| 665 | style={{ color: "#78716c", letterSpacing: 1 }} | ||
| 666 | > | ||
| 667 | New task | ||
| 668 | </Text> | ||
| 669 | <Text className="text-[28px] font-t3-bold" style={{ color: "#171717" }}> | ||
| 670 | {selectedProject?.title ?? "New task"} | ||
| 671 | </Text> | ||
| 672 | </View> | ||
| 673 | |||
| 674 | <BottomSheetScrollView | ||
| 675 | keyboardShouldPersistTaps="handled" | ||
| 676 | showsVerticalScrollIndicator={false} | ||
| 677 | contentContainerStyle={{ | ||
| 678 | paddingHorizontal: 20, | ||
| 679 | paddingTop: 8, | ||
| 680 | paddingBottom: 20, | ||
| 681 | minHeight: 420, | ||
| 682 | }} | ||
| 683 | > | ||
| 684 | <TextInput | ||
| 685 | multiline | ||
| 686 | value={prompt} | ||
| 687 | onChangeText={setPrompt} | ||
| 688 | placeholderTextColor="#9ca3af" | ||
| 689 | placeholder={ | ||
| 690 | selectedProject | ||
| 691 | ? `Describe a coding task in ${selectedProject.title}` | ||
| 692 | : "Describe a coding task" | ||
| 693 | } | ||
| 694 | textAlignVertical="top" | ||
| 695 | className="min-h-[260px] border-0 bg-transparent px-0 py-0 text-[18px] leading-[28px]" | ||
| 696 | style={{ | ||
| 697 | borderWidth: 0, | ||
| 698 | backgroundColor: "transparent", | ||
| 699 | color: "#171717", | ||
| 700 | fontSize: 18, | ||
| 701 | lineHeight: 28, | ||
| 702 | minHeight: 280, | ||
| 703 | }} | ||
| 704 | /> | ||
| 705 | |||
| 706 | {attachments.length > 0 ? ( | ||
| 707 | <ScrollView horizontal showsHorizontalScrollIndicator={false}> | ||
| 708 | <View className="flex-row gap-3"> | ||
| 709 | {attachments.map((attachment) => ( | ||
| 710 | <View key={attachment.id} className="gap-2"> | ||
| 711 | <Image | ||
| 712 | source={{ uri: attachment.previewUri }} | ||
| 713 | className="h-[88px] w-[88px] rounded-[20px]" | ||
| 714 | resizeMode="cover" | ||
| 715 | /> | ||
| 716 | <Pressable | ||
| 717 | className="items-center rounded-full px-3 py-2" | ||
| 718 | style={{ backgroundColor: "rgba(23,23,23,0.06)" }} | ||
| 719 | onPress={() => | ||
| 720 | setAttachments((current) => | ||
| 721 | current.filter((candidate) => candidate.id !== attachment.id), | ||
| 722 | ) | ||
| 723 | } | ||
| 724 | > | ||
| 725 | <Text | ||
| 726 | className="text-[12px] font-t3-bold" | ||
| 727 | style={{ color: "#171717" }} | ||
| 728 | > | ||
| 729 | Remove | ||
| 730 | </Text> | ||
| 731 | </Pressable> | ||
| 732 | </View> | ||
| 733 | ))} | ||
| 734 | </View> | ||
| 735 | </ScrollView> | ||
| 736 | ) : null} | ||
| 737 | </BottomSheetScrollView> | ||
| 738 | |||
| 739 | <View | ||
| 740 | style={{ | ||
| 741 | borderTopWidth: 1, | ||
| 742 | borderTopColor: "rgba(23,23,23,0.08)", | ||
| 743 | paddingBottom: Math.max(insets.bottom, 10), | ||
| 744 | }} | ||
| 745 | > | ||
| 746 | <View className="flex-row items-center justify-between gap-2 px-4 pb-1 pt-4"> | ||
| 747 | <ControlPill icon="plus" onPress={() => void handlePickImages()} /> | ||
| 748 | <ControlPill | ||
| 749 | icon="dial.low" | ||
| 750 | label={compactModelLabel(selectedModelOption)} | ||
| 751 | onPress={() => setModelPickerVisible(true)} | ||
| 752 | variant="pill" | ||
| 753 | /> | ||
| 754 | <ControlPill | ||
| 755 | icon="slider.horizontal.3" | ||
| 756 | onPress={() => setOptionsPickerVisible(true)} | ||
| 757 | /> | ||
| 758 | <ControlPill | ||
| 759 | icon="desktopcomputer" | ||
| 760 | onPress={() => setEnvironmentPickerVisible(true)} | ||
| 761 | /> | ||
| 762 | <ControlPill | ||
| 763 | icon="point.topleft.down.curvedto.point.bottomright.up" | ||
| 764 | onPress={() => void openBranchPicker()} | ||
| 765 | /> | ||
| 766 | <ControlPill | ||
| 767 | icon="arrow.up" | ||
| 768 | label={submitting ? "Starting" : "Start"} | ||
| 769 | onPress={() => void handleStart()} | ||
| 770 | variant="primary" | ||
| 771 | disabled={ | ||
| 772 | !selectedProject || | ||
| 773 | !selectedModel || | ||
| 774 | prompt.trim().length === 0 || | ||
| 775 | submitting || | ||
| 776 | (workspaceMode === "worktree" && !selectedBranchName) | ||
| 777 | } | ||
| 778 | /> | ||
| 779 | </View> | ||
| 780 | </View> | ||
| 781 | </View> | ||
| 782 | </KeyboardAvoidingView> | ||
| 783 | </Reanimated.View> | ||
| 784 | )} | ||
| 785 | </BottomSheetView> | ||
| 786 | </BottomSheetModal> | ||
| 787 | |||
| 788 | {/* Sub-pickers (environment, project, model, branch) */} | ||
| 789 | <BottomSelectorSheet | ||
| 790 | visible={environmentPickerVisible} | ||
| 791 | title="Environment" | ||
| 792 | onClose={() => setEnvironmentPickerVisible(false)} | ||
| 793 | > | ||
| 794 | <ScrollView showsVerticalScrollIndicator={false}> | ||
| 795 | <View className="overflow-hidden rounded-[24px]" style={{ backgroundColor: "#ffffff" }}> | ||
| 796 | {environments.map((environment, index) => ( | ||
| 797 | <Pressable | ||
| 798 | key={environment.environmentId} | ||
| 799 | onPress={() => { | ||
| 800 | setSelectedEnvironmentId(environment.environmentId); | ||
| 801 | setSelectedProjectKey(null); | ||
| 802 | setSelectedBranchName(null); | ||
| 803 | setSelectedWorktreePath(null); | ||
| 804 | setEnvironmentPickerVisible(false); | ||
| 805 | }} | ||
| 806 | style={{ | ||
| 807 | paddingHorizontal: 16, | ||
| 808 | paddingVertical: 18, | ||
| 809 | borderTopWidth: index === 0 ? 0 : 1, | ||
| 810 | borderTopColor: "rgba(23,23,23,0.06)", | ||
| 811 | }} | ||
| 812 | > | ||
| 813 | <Text className="text-[17px] font-medium" style={{ color: "#171717" }}> | ||
| 814 | {environment.environmentLabel} | ||
| 815 | </Text> | ||
| 816 | </Pressable> | ||
| 817 | ))} | ||
| 818 | </View> | ||
| 819 | </ScrollView> | ||
| 820 | </BottomSelectorSheet> | ||
| 821 | |||
| 822 | <BottomSelectorSheet | ||
| 823 | visible={projectPickerVisible} | ||
| 824 | title="Project" | ||
| 825 | onClose={() => setProjectPickerVisible(false)} | ||
| 826 | > | ||
| 827 | <ScrollView showsVerticalScrollIndicator={false}> | ||
| 828 | <View className="overflow-hidden rounded-[24px]" style={{ backgroundColor: "#ffffff" }}> | ||
| 829 | {logicalProjectsForEnvironment.map((project, index) => ( | ||
| 830 | <Pressable | ||
| 831 | key={`${project.environmentId}:${project.id}`} | ||
| 832 | onPress={() => { | ||
| 833 | setSelectedProjectKey(scopedProjectKey(project.environmentId, project.id)); | ||
| 834 | setSelectedBranchName(null); | ||
| 835 | setSelectedWorktreePath(null); | ||
| 836 | setProjectPickerVisible(false); | ||
| 837 | }} | ||
| 838 | style={{ | ||
| 839 | paddingHorizontal: 16, | ||
| 840 | paddingVertical: 18, | ||
| 841 | borderTopWidth: index === 0 ? 0 : 1, | ||
| 842 | borderTopColor: "rgba(23,23,23,0.06)", | ||
| 843 | }} | ||
| 844 | > | ||
| 845 | <Text className="text-[17px] font-medium" style={{ color: "#171717" }}> | ||
| 846 | {project.title} | ||
| 847 | </Text> | ||
| 848 | </Pressable> | ||
| 849 | ))} | ||
| 850 | </View> | ||
| 851 | </ScrollView> | ||
| 852 | </BottomSelectorSheet> | ||
| 853 | |||
| 854 | {/* Model picker — provider + model only */} | ||
| 855 | <BottomSelectorSheet | ||
| 856 | visible={modelPickerVisible} | ||
| 857 | title="Model" | ||
| 858 | onClose={() => { | ||
| 859 | setModelPickerVisible(false); | ||
| 860 | setExpandedProvider(null); | ||
| 861 | }} | ||
| 862 | > | ||
| 863 | <ScrollView showsVerticalScrollIndicator={false}> | ||
| 864 | <View className="gap-2"> | ||
| 865 | {providerGroups.map((group) => { | ||
| 866 | const isExpanded = expandedProvider === group.providerKey; | ||
| 867 | const hasSelected = group.models.some( | ||
| 868 | (m) => | ||
| 869 | selectedModel && | ||
| 870 | m.selection.provider === selectedModel.provider && | ||
| 871 | m.selection.model === selectedModel.model, | ||
| 872 | ); | ||
| 873 | |||
| 874 | return ( | ||
| 875 | <View key={group.providerKey}> | ||
| 876 | <Pressable | ||
| 877 | onPress={() => | ||
| 878 | setExpandedProvider(isExpanded ? null : group.providerKey) | ||
| 879 | } | ||
| 880 | className="flex-row items-center justify-between rounded-[16px] px-4 py-3.5" | ||
| 881 | style={{ | ||
| 882 | backgroundColor: isExpanded | ||
| 883 | ? "rgba(23,23,23,0.06)" | ||
| 884 | : hasSelected | ||
| 885 | ? "rgba(23,23,23,0.04)" | ||
| 886 | : "#ffffff", | ||
| 887 | borderWidth: hasSelected && !isExpanded ? 1 : 0, | ||
| 888 | borderColor: "rgba(23,23,23,0.08)", | ||
| 889 | }} | ||
| 890 | > | ||
| 891 | <View className="flex-row items-center gap-2.5"> | ||
| 892 | <Text className="text-[17px] font-t3-bold" style={{ color: "#171717" }}> | ||
| 893 | {group.providerLabel} | ||
| 894 | </Text> | ||
| 895 | {hasSelected && !isExpanded ? ( | ||
| 896 | <Text className="text-[13px]" style={{ color: "#78716c" }}> | ||
| 897 | {selectedModelOption?.label} | ||
| 898 | </Text> | ||
| 899 | ) : null} | ||
| 900 | </View> | ||
| 901 | <SymbolView | ||
| 902 | name={isExpanded ? "chevron.up" : "chevron.right"} | ||
| 903 | size={14} | ||
| 904 | tintColor="rgba(23,23,23,0.25)" | ||
| 905 | type="monochrome" | ||
| 906 | /> | ||
| 907 | </Pressable> | ||
| 908 | |||
| 909 | {isExpanded ? ( | ||
| 910 | <View | ||
| 911 | className="mt-1.5 overflow-hidden rounded-[14px]" | ||
| 912 | style={{ backgroundColor: "#ffffff" }} | ||
| 913 | > | ||
| 914 | {group.models.map((option, index) => { | ||
| 915 | const isSelected = | ||
| 916 | selectedModel && | ||
| 917 | option.selection.provider === selectedModel.provider && | ||
| 918 | option.selection.model === selectedModel.model; | ||
| 919 | return ( | ||
| 920 | <Pressable | ||
| 921 | key={option.key} | ||
| 922 | onPress={() => { | ||
| 923 | setSelectedModelKey(option.key); | ||
| 924 | setModelPickerVisible(false); | ||
| 925 | setExpandedProvider(null); | ||
| 926 | }} | ||
| 927 | style={{ | ||
| 928 | paddingHorizontal: 16, | ||
| 929 | paddingVertical: 14, | ||
| 930 | borderTopWidth: index === 0 ? 0 : 1, | ||
| 931 | borderTopColor: "rgba(23,23,23,0.06)", | ||
| 932 | backgroundColor: isSelected ? "rgba(23,23,23,0.04)" : "transparent", | ||
| 933 | }} | ||
| 934 | > | ||
| 935 | <View className="flex-row items-center justify-between"> | ||
| 936 | <Text className="text-[16px] font-medium" style={{ color: "#171717" }}> | ||
| 937 | {option.label} | ||
| 938 | </Text> | ||
| 939 | {isSelected ? ( | ||
| 940 | <SymbolView name="checkmark" size={16} tintColor="#2563eb" type="monochrome" /> | ||
| 941 | ) : null} | ||
| 942 | </View> | ||
| 943 | </Pressable> | ||
| 944 | ); | ||
| 945 | })} | ||
| 946 | </View> | ||
| 947 | ) : null} | ||
| 948 | </View> | ||
| 949 | ); | ||
| 950 | })} | ||
| 951 | </View> | ||
| 952 | </ScrollView> | ||
| 953 | </BottomSelectorSheet> | ||
| 954 | |||
| 955 | {/* Options picker — effort, fast mode, context window, mode, access */} | ||
| 956 | <BottomSelectorSheet | ||
| 957 | visible={optionsPickerVisible} | ||
| 958 | title="Options" | ||
| 959 | onClose={() => setOptionsPickerVisible(false)} | ||
| 960 | snapPoints={["82%"]} | ||
| 961 | > | ||
| 962 | <ScrollView showsVerticalScrollIndicator={false}> | ||
| 963 | <View className="gap-5 pb-4"> | ||
| 964 | {/* ── Effort ── */} | ||
| 965 | <View className="gap-2"> | ||
| 966 | <Text | ||
| 967 | className="text-[11px] font-t3-bold uppercase" | ||
| 968 | style={{ color: "#78716c", letterSpacing: 0.8 }} | ||
| 969 | > | ||
| 970 | Effort | ||
| 971 | </Text> | ||
| 972 | <View className="overflow-hidden rounded-[14px]" style={{ backgroundColor: "#ffffff" }}> | ||
| 973 | {CLAUDE_CODE_EFFORT_OPTIONS.map((level, index) => ( | ||
| 974 | <Pressable | ||
| 975 | key={level} | ||
| 976 | onPress={() => setEffort(level)} | ||
| 977 | style={{ | ||
| 978 | paddingHorizontal: 16, | ||
| 979 | paddingVertical: 13, | ||
| 980 | borderTopWidth: index === 0 ? 0 : 1, | ||
| 981 | borderTopColor: "rgba(23,23,23,0.06)", | ||
| 982 | }} | ||
| 983 | > | ||
| 984 | <View className="flex-row items-center justify-between"> | ||
| 985 | <Text className="text-[15px] font-medium capitalize" style={{ color: "#171717" }}> | ||
| 986 | {level}{level === "high" ? " (default)" : ""} | ||
| 987 | </Text> | ||
| 988 | {effort === level ? ( | ||
| 989 | <SymbolView name="checkmark" size={15} tintColor="#2563eb" type="monochrome" /> | ||
| 990 | ) : null} | ||
| 991 | </View> | ||
| 992 | </Pressable> | ||
| 993 | ))} | ||
| 994 | </View> | ||
| 995 | </View> | ||
| 996 | |||
| 997 | {/* ── Fast Mode ── */} | ||
| 998 | <View className="gap-2"> | ||
| 999 | <Text | ||
| 1000 | className="text-[11px] font-t3-bold uppercase" | ||
| 1001 | style={{ color: "#78716c", letterSpacing: 0.8 }} | ||
| 1002 | > | ||
| 1003 | Fast Mode | ||
| 1004 | </Text> | ||
| 1005 | <View className="overflow-hidden rounded-[14px]" style={{ backgroundColor: "#ffffff" }}> | ||
| 1006 | {([false, true] as const).map((value, index) => ( | ||
| 1007 | <Pressable | ||
| 1008 | key={String(value)} | ||
| 1009 | onPress={() => setFastMode(value)} | ||
| 1010 | style={{ | ||
| 1011 | paddingHorizontal: 16, | ||
| 1012 | paddingVertical: 13, | ||
| 1013 | borderTopWidth: index === 0 ? 0 : 1, | ||
| 1014 | borderTopColor: "rgba(23,23,23,0.06)", | ||
| 1015 | }} | ||
| 1016 | > | ||
| 1017 | <View className="flex-row items-center justify-between"> | ||
| 1018 | <Text className="text-[15px] font-medium" style={{ color: "#171717" }}> | ||
| 1019 | {value ? "On" : "Off"} | ||
| 1020 | </Text> | ||
| 1021 | {fastMode === value ? ( | ||
| 1022 | <SymbolView name="checkmark" size={15} tintColor="#2563eb" type="monochrome" /> | ||
| 1023 | ) : null} | ||
| 1024 | </View> | ||
| 1025 | </Pressable> | ||
| 1026 | ))} | ||
| 1027 | </View> | ||
| 1028 | </View> | ||
| 1029 | |||
| 1030 | {/* ── Context Window ── */} | ||
| 1031 | <View className="gap-2"> | ||
| 1032 | <Text | ||
| 1033 | className="text-[11px] font-t3-bold uppercase" | ||
| 1034 | style={{ color: "#78716c", letterSpacing: 0.8 }} | ||
| 1035 | > | ||
| 1036 | Context Window | ||
| 1037 | </Text> | ||
| 1038 | <View className="overflow-hidden rounded-[14px]" style={{ backgroundColor: "#ffffff" }}> | ||
| 1039 | {(["200k", "1M"] as const).map((value, index) => ( | ||
| 1040 | <Pressable | ||
| 1041 | key={value} | ||
| 1042 | onPress={() => setContextWindow(value)} | ||
| 1043 | style={{ | ||
| 1044 | paddingHorizontal: 16, | ||
| 1045 | paddingVertical: 13, | ||
| 1046 | borderTopWidth: index === 0 ? 0 : 1, | ||
| 1047 | borderTopColor: "rgba(23,23,23,0.06)", | ||
| 1048 | }} | ||
| 1049 | > | ||
| 1050 | <View className="flex-row items-center justify-between"> | ||
| 1051 | <Text className="text-[15px] font-medium" style={{ color: "#171717" }}> | ||
| 1052 | {value}{value === "1M" ? " (default)" : ""} | ||
| 1053 | </Text> | ||
| 1054 | {contextWindow === value ? ( | ||
| 1055 | <SymbolView name="checkmark" size={15} tintColor="#2563eb" type="monochrome" /> | ||
| 1056 | ) : null} | ||
| 1057 | </View> | ||
| 1058 | </Pressable> | ||
| 1059 | ))} | ||
| 1060 | </View> | ||
| 1061 | </View> | ||
| 1062 | |||
| 1063 | {/* ── Mode ── */} | ||
| 1064 | <View className="gap-2"> | ||
| 1065 | <Text | ||
| 1066 | className="text-[11px] font-t3-bold uppercase" | ||
| 1067 | style={{ color: "#78716c", letterSpacing: 0.8 }} | ||
| 1068 | > | ||
| 1069 | Mode | ||
| 1070 | </Text> | ||
| 1071 | <View className="overflow-hidden rounded-[14px]" style={{ backgroundColor: "#ffffff" }}> | ||
| 1072 | {( | ||
| 1073 | [ | ||
| 1074 | { value: "default" as const, label: "Chat" }, | ||
| 1075 | { value: "plan" as const, label: "Plan" }, | ||
| 1076 | ] as const | ||
| 1077 | ).map((option, index) => ( | ||
| 1078 | <Pressable | ||
| 1079 | key={option.value} | ||
| 1080 | onPress={() => setInteractionMode(option.value)} | ||
| 1081 | style={{ | ||
| 1082 | paddingHorizontal: 16, | ||
| 1083 | paddingVertical: 13, | ||
| 1084 | borderTopWidth: index === 0 ? 0 : 1, | ||
| 1085 | borderTopColor: "rgba(23,23,23,0.06)", | ||
| 1086 | }} | ||
| 1087 | > | ||
| 1088 | <View className="flex-row items-center justify-between"> | ||
| 1089 | <Text className="text-[15px] font-medium" style={{ color: "#171717" }}> | ||
| 1090 | {option.label} | ||
| 1091 | </Text> | ||
| 1092 | {interactionMode === option.value ? ( | ||
| 1093 | <SymbolView name="checkmark" size={15} tintColor="#2563eb" type="monochrome" /> | ||
| 1094 | ) : null} | ||
| 1095 | </View> | ||
| 1096 | </Pressable> | ||
| 1097 | ))} | ||
| 1098 | </View> | ||
| 1099 | </View> | ||
| 1100 | |||
| 1101 | {/* ── Access ── */} | ||
| 1102 | <View className="gap-2"> | ||
| 1103 | <Text | ||
| 1104 | className="text-[11px] font-t3-bold uppercase" | ||
| 1105 | style={{ color: "#78716c", letterSpacing: 0.8 }} | ||
| 1106 | > | ||
| 1107 | Access | ||
| 1108 | </Text> | ||
| 1109 | <View className="overflow-hidden rounded-[14px]" style={{ backgroundColor: "#ffffff" }}> | ||
| 1110 | {( | ||
| 1111 | [ | ||
| 1112 | { value: "approval-required" as const, label: "Supervised", desc: "Ask before commands and file changes" }, | ||
| 1113 | { value: "auto-accept-edits" as const, label: "Auto-accept edits", desc: "Auto-approve edits, ask before other actions" }, | ||
| 1114 | { value: "full-access" as const, label: "Full access", desc: "Allow commands and edits without prompts" }, | ||
| 1115 | ] as const | ||
| 1116 | ).map((option, index) => ( | ||
| 1117 | <Pressable | ||
| 1118 | key={option.value} | ||
| 1119 | onPress={() => setRuntimeMode(option.value)} | ||
| 1120 | style={{ | ||
| 1121 | paddingHorizontal: 16, | ||
| 1122 | paddingVertical: 13, | ||
| 1123 | borderTopWidth: index === 0 ? 0 : 1, | ||
| 1124 | borderTopColor: "rgba(23,23,23,0.06)", | ||
| 1125 | }} | ||
| 1126 | > | ||
| 1127 | <View className="flex-row items-center justify-between gap-3"> | ||
| 1128 | <View className="flex-1 gap-0.5"> | ||
| 1129 | <Text className="text-[15px] font-medium" style={{ color: "#171717" }}> | ||
| 1130 | {option.label} | ||
| 1131 | </Text> | ||
| 1132 | <Text className="text-[12px]" style={{ color: "#78716c" }}> | ||
| 1133 | {option.desc} | ||
| 1134 | </Text> | ||
| 1135 | </View> | ||
| 1136 | {runtimeMode === option.value ? ( | ||
| 1137 | <SymbolView name="checkmark" size={15} tintColor="#2563eb" type="monochrome" /> | ||
| 1138 | ) : null} | ||
| 1139 | </View> | ||
| 1140 | </Pressable> | ||
| 1141 | ))} | ||
| 1142 | </View> | ||
| 1143 | </View> | ||
| 1144 | </View> | ||
| 1145 | </ScrollView> | ||
| 1146 | </BottomSelectorSheet> | ||
| 1147 | |||
| 1148 | <BottomSelectorSheet | ||
| 1149 | visible={branchPickerVisible} | ||
| 1150 | title="Workspace" | ||
| 1151 | onClose={() => setBranchPickerVisible(false)} | ||
| 1152 | > | ||
| 1153 | <View className="gap-4"> | ||
| 1154 | <View className="flex-row gap-2"> | ||
| 1155 | <Pressable | ||
| 1156 | onPress={() => setWorkspaceMode("local")} | ||
| 1157 | className="flex-1 rounded-full px-4 py-3" | ||
| 1158 | style={{ | ||
| 1159 | backgroundColor: | ||
| 1160 | workspaceMode === "local" ? "rgba(23,23,23,0.08)" : "rgba(23,23,23,0.04)", | ||
| 1161 | }} | ||
| 1162 | > | ||
| 1163 | <Text className="text-center text-[14px] font-t3-bold" style={{ color: "#171717" }}> | ||
| 1164 | Current checkout | ||
| 1165 | </Text> | ||
| 1166 | </Pressable> | ||
| 1167 | <Pressable | ||
| 1168 | onPress={() => { | ||
| 1169 | setWorkspaceMode("worktree"); | ||
| 1170 | setSelectedWorktreePath(null); | ||
| 1171 | }} | ||
| 1172 | className="flex-1 rounded-full px-4 py-3" | ||
| 1173 | style={{ | ||
| 1174 | backgroundColor: | ||
| 1175 | workspaceMode === "worktree" ? "rgba(23,23,23,0.08)" : "rgba(23,23,23,0.04)", | ||
| 1176 | }} | ||
| 1177 | > | ||
| 1178 | <Text className="text-center text-[14px] font-t3-bold" style={{ color: "#171717" }}> | ||
| 1179 | New worktree | ||
| 1180 | </Text> | ||
| 1181 | </Pressable> | ||
| 1182 | </View> | ||
| 1183 | |||
| 1184 | <TextInput | ||
| 1185 | value={branchQuery} | ||
| 1186 | onChangeText={setBranchQuery} | ||
| 1187 | placeholder="Search branches" | ||
| 1188 | className="bg-white" | ||
| 1189 | /> | ||
| 1190 | |||
| 1191 | <ScrollView showsVerticalScrollIndicator={false}> | ||
| 1192 | <View className="overflow-hidden rounded-[24px]" style={{ backgroundColor: "#ffffff" }}> | ||
| 1193 | {branchesLoading ? ( | ||
| 1194 | <View className="px-4 py-5"> | ||
| 1195 | <Text className="text-[15px] font-medium" style={{ color: "#78716c" }}> | ||
| 1196 | Loading branches... | ||
| 1197 | </Text> | ||
| 1198 | </View> | ||
| 1199 | ) : ( | ||
| 1200 | filteredBranches.map((branch, index) => { | ||
| 1201 | const normalizedWorktreePath = selectedProject | ||
| 1202 | ? normalizeSelectedWorktreePath(selectedProject, branch) | ||
| 1203 | : null; | ||
| 1204 | const selected = | ||
| 1205 | selectedBranchName === branch.name && | ||
| 1206 | (workspaceMode === "worktree" | ||
| 1207 | ? selectedWorktreePath === null | ||
| 1208 | : (selectedWorktreePath ?? null) === normalizedWorktreePath); | ||
| 1209 | const badge = branchBadgeLabel({ branch, project: selectedProject }); | ||
| 1210 | |||
| 1211 | return ( | ||
| 1212 | <Pressable | ||
| 1213 | key={branch.name} | ||
| 1214 | onPress={() => { | ||
| 1215 | setSelectedBranchName(branch.name); | ||
| 1216 | setSelectedWorktreePath( | ||
| 1217 | workspaceMode === "worktree" ? null : normalizedWorktreePath, | ||
| 1218 | ); | ||
| 1219 | setBranchPickerVisible(false); | ||
| 1220 | }} | ||
| 1221 | style={{ | ||
| 1222 | paddingHorizontal: 16, | ||
| 1223 | paddingVertical: 18, | ||
| 1224 | borderTopWidth: index === 0 ? 0 : 1, | ||
| 1225 | borderTopColor: "rgba(23,23,23,0.06)", | ||
| 1226 | }} | ||
| 1227 | > | ||
| 1228 | <View className="flex-row items-center justify-between gap-3"> | ||
| 1229 | <View className="flex-1 gap-1"> | ||
| 1230 | <Text className="text-[17px] font-medium" style={{ color: "#171717" }}> | ||
| 1231 | {branch.name} | ||
| 1232 | </Text> | ||
| 1233 | <Text className="text-[13px] font-medium" style={{ color: "#78716c" }}> | ||
| 1234 | {workspaceMode === "worktree" | ||
| 1235 | ? "Base branch for new worktree" | ||
| 1236 | : normalizedWorktreePath | ||
| 1237 | ? "Existing worktree" | ||
| 1238 | : "Current checkout"} | ||
| 1239 | </Text> | ||
| 1240 | </View> | ||
| 1241 | <View className="items-end gap-1"> | ||
| 1242 | {badge ? ( | ||
| 1243 | <Text | ||
| 1244 | className="text-[11px] font-medium" | ||
| 1245 | style={{ color: "rgba(23,23,23,0.35)" }} | ||
| 1246 | > | ||
| 1247 | {badge} | ||
| 1248 | </Text> | ||
| 1249 | ) : null} | ||
| 1250 | {selected ? ( | ||
| 1251 | <SymbolView | ||
| 1252 | name="checkmark" | ||
| 1253 | size={18} | ||
| 1254 | tintColor="#2563eb" | ||
| 1255 | type="monochrome" | ||
| 1256 | /> | ||
| 1257 | ) : null} | ||
| 1258 | </View> | ||
| 1259 | </View> | ||
| 1260 | </Pressable> | ||
| 1261 | ); | ||
| 1262 | }) | ||
| 1263 | )} | ||
| 1264 | </View> | ||
| 1265 | </ScrollView> | ||
| 1266 | </View> | ||
| 1267 | </BottomSelectorSheet> | ||
| 1268 | </> | ||
| 1269 | ); | ||
| 1270 | } | ||
| @@ -0,0 +1,129 @@ | |||
| 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, 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 type { ScopedMobileProject, ScopedMobileThread } from "../../lib/scopedEntities"; | ||
| 14 | |||
| 15 | export function ProjectPickerSheet(props: { | ||
| 16 | readonly visible: boolean; | ||
| 17 | readonly projects: ReadonlyArray<ScopedMobileProject>; | ||
| 18 | readonly threads: ReadonlyArray<ScopedMobileThread>; | ||
| 19 | readonly onClose: () => void; | ||
| 20 | readonly onSelectProject: (project: ScopedMobileProject) => void; | ||
| 21 | }) { | ||
| 22 | const insets = useSafeAreaInsets(); | ||
| 23 | const sheetRef = useRef<BottomSheetModal>(null); | ||
| 24 | const repositoryGroups = useMemo( | ||
| 25 | () => groupProjectsByRepository({ projects: props.projects, threads: props.threads }), | ||
| 26 | [props.projects, props.threads], | ||
| 27 | ); | ||
| 28 | const logicalProjects = useMemo( | ||
| 29 | () => | ||
| 30 | repositoryGroups | ||
| 31 | .map((group) => { | ||
| 32 | const primaryProject = group.projects[0]?.project; | ||
| 33 | if (!primaryProject) { | ||
| 34 | return null; | ||
| 35 | } | ||
| 36 | |||
| 37 | return { | ||
| 38 | key: group.key, | ||
| 39 | project: primaryProject, | ||
| 40 | }; | ||
| 41 | }) | ||
| 42 | .filter((entry) => entry !== null), | ||
| 43 | [repositoryGroups], | ||
| 44 | ); | ||
| 45 | const renderBackdrop = useCallback( | ||
| 46 | (backdropProps: React.ComponentProps<typeof BottomSheetBackdrop>) => ( | ||
| 47 | <BottomSheetBackdrop | ||
| 48 | {...backdropProps} | ||
| 49 | appearsOnIndex={0} | ||
| 50 | disappearsOnIndex={-1} | ||
| 51 | opacity={0.18} | ||
| 52 | pressBehavior="close" | ||
| 53 | /> | ||
| 54 | ), | ||
| 55 | [], | ||
| 56 | ); | ||
| 57 | |||
| 58 | useEffect(() => { | ||
| 59 | const sheet = sheetRef.current; | ||
| 60 | if (!sheet) { | ||
| 61 | return; | ||
| 62 | } | ||
| 63 | |||
| 64 | if (props.visible) { | ||
| 65 | sheet.present(); | ||
| 66 | return; | ||
| 67 | } | ||
| 68 | |||
| 69 | sheet.dismiss(); | ||
| 70 | }, [props.visible]); | ||
| 71 | |||
| 72 | return ( | ||
| 73 | <BottomSheetModal | ||
| 74 | ref={sheetRef} | ||
| 75 | index={0} | ||
| 76 | snapPoints={["88%"]} | ||
| 77 | enableDynamicSizing={false} | ||
| 78 | enablePanDownToClose | ||
| 79 | backdropComponent={renderBackdrop} | ||
| 80 | onDismiss={props.onClose} | ||
| 81 | backgroundStyle={{ backgroundColor: "rgba(250,248,242,0.98)" }} | ||
| 82 | handleIndicatorStyle={{ backgroundColor: "rgba(120,113,108,0.35)" }} | ||
| 83 | > | ||
| 84 | <BottomSheetView className="px-4 pt-1"> | ||
| 85 | <View className="mb-4 items-center gap-1"> | ||
| 86 | <Text | ||
| 87 | className="text-[12px] font-t3-bold uppercase" | ||
| 88 | style={{ color: "#78716c", letterSpacing: 1 }} | ||
| 89 | > | ||
| 90 | New task | ||
| 91 | </Text> | ||
| 92 | <Text className="text-[28px] font-t3-bold" style={{ color: "#171717" }}> | ||
| 93 | Choose project | ||
| 94 | </Text> | ||
| 95 | </View> | ||
| 96 | |||
| 97 | <BottomSheetScrollView | ||
| 98 | showsVerticalScrollIndicator={false} | ||
| 99 | contentContainerStyle={{ | ||
| 100 | paddingBottom: Math.max(insets.bottom, 18) + 18, | ||
| 101 | }} | ||
| 102 | > | ||
| 103 | <View className="overflow-hidden rounded-[24px]" style={{ backgroundColor: "#ffffff" }}> | ||
| 104 | {logicalProjects.map((entry, index) => ( | ||
| 105 | <Pressable | ||
| 106 | key={entry.key} | ||
| 107 | onPress={() => props.onSelectProject(entry.project)} | ||
| 108 | style={{ | ||
| 109 | paddingHorizontal: 16, | ||
| 110 | paddingVertical: 18, | ||
| 111 | borderTopWidth: index === 0 ? 0 : 1, | ||
| 112 | borderTopColor: "rgba(23,23,23,0.06)", | ||
| 113 | }} | ||
| 114 | > | ||
| 115 | <View className="flex-row items-center justify-between gap-3"> | ||
| 116 | <View className="flex-1"> | ||
| 117 | <Text className="text-[18px] font-t3-bold" style={{ color: "#171717" }}> | ||
| 118 | {entry.project.title} | ||
| 119 | </Text> | ||
| 120 | </View> | ||
| 121 | </View> | ||
| 122 | </Pressable> | ||
| 123 | ))} | ||
| 124 | </View> | ||
| 125 | </BottomSheetScrollView> | ||
| 126 | </BottomSheetView> | ||
| 127 | </BottomSheetModal> | ||
| 128 | ); | ||
| 129 | } | ||
| @@ -6,6 +6,7 @@ import { Image, Pressable, ScrollView, View } from "react-native"; · 5 unmodified lines | |||
| 6 | import { AppText as Text, AppTextInput as TextInput } from "../../components/AppText"; | 6 | import { AppText as Text, AppTextInput as TextInput } from "../../components/AppText"; |
| 7 | import { cx } from "../../lib/classNames"; | 7 | import { cx } from "../../lib/classNames"; |
| 8 | import type { DraftComposerImageAttachment } from "../../lib/composerImages"; | 8 | import type { DraftComposerImageAttachment } from "../../lib/composerImages"; |
| 9 | import type { MobileLayoutVariant } from "../../lib/mobileLayout"; | ||
| 9 | import type { RemoteClientConnectionState } from "../../lib/remoteClient"; | 10 | import type { RemoteClientConnectionState } from "../../lib/remoteClient"; |
| 10 | 11 | ||
| 11 | function ComposerAction(props: { | 12 | function ComposerAction(props: { |
| @@ -47,6 +48,7 @@ export interface ThreadComposerProps { · 35 unmodified lines | |||
| 47 | readonly selectedThread: OrchestrationThread; | 48 | readonly selectedThread: OrchestrationThread; |
| 48 | readonly queueCount: number; | 49 | readonly queueCount: number; |
| 49 | readonly activeThreadBusy: boolean; | 50 | readonly activeThreadBusy: boolean; |
| 51 | readonly layoutVariant?: MobileLayoutVariant; | ||
| 50 | readonly onChangeDraftMessage: (value: string) => void; | 52 | readonly onChangeDraftMessage: (value: string) => void; |
| 51 | readonly onPickDraftImages: () => Promise<void>; | 53 | readonly onPickDraftImages: () => Promise<void>; |
| 52 | readonly onPasteIntoDraft: () => Promise<void>; | 54 | readonly onPasteIntoDraft: () => Promise<void>; |
| @@ -64,13 +66,17 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer · 11 unmodified lines | |||
| 64 | props.selectedThread.session?.status === "running" || | 66 | props.selectedThread.session?.status === "running" || |
| 65 | props.selectedThread.session?.status === "starting" || | 67 | props.selectedThread.session?.status === "starting" || |
| 66 | props.queueCount > 0; | 68 | props.queueCount > 0; |
| 69 | const isSplitLayout = props.layoutVariant === "split"; | ||
| 67 | 70 | ||
| 68 | return ( | 71 | return ( |
| 69 | <View | 72 | <View |
| 70 | className="gap-3 border-t border-slate-200 bg-white/92 px-4 pt-3.5 dark:border-white/6 dark:bg-slate-950/92" | 73 | className="gap-3 border-t border-slate-200 bg-stone-50/96 px-4 pt-3.5 dark:border-white/6 dark:bg-slate-950/92" |
| 71 | style={{ paddingBottom: (props.bottomInset ?? 0) + 16 }} | 74 | style={{ paddingBottom: (props.bottomInset ?? 0) + 16 }} |
| 72 | > | 75 | > |
| 73 | <View className="rounded-[24px] border border-slate-200 bg-white px-3.5 py-3 dark:border-white/8 dark:bg-slate-900"> | 76 | <View |
| 77 | className="w-full self-center rounded-[24px] border border-slate-200 bg-white px-3.5 py-3 dark:border-white/8 dark:bg-slate-900" | ||
| 78 | style={{ maxWidth: isSplitLayout ? 960 : undefined }} | ||
| 79 | > | ||
| 74 | <TextInput | 80 | <TextInput |
| 75 | multiline | 81 | multiline |
| 76 | value={props.draftMessage} | 82 | value={props.draftMessage} |
| @@ -84,19 +90,19 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer · 7 unmodified lines | |||
| 84 | <View className="flex-row flex-wrap gap-2.5"> | 90 | <View className="flex-row flex-wrap gap-2.5"> |
| 85 | <ComposerAction | 91 | <ComposerAction |
| 86 | icon="photo.on.rectangle" | 92 | icon="photo.on.rectangle" |
| 87 | backgroundClassName="bg-slate-200 dark:bg-slate-800" | 93 | backgroundClassName="bg-stone-200 dark:bg-slate-800" |
| 88 | iconTintColor="#0f172a" | 94 | iconTintColor="#0f172a" |
| 89 | onPress={() => void props.onPickDraftImages()} | 95 | onPress={() => void props.onPickDraftImages()} |
| 90 | /> | 96 | /> |
| 91 | <ComposerAction | 97 | <ComposerAction |
| 92 | icon="doc.on.clipboard" | 98 | icon="doc.on.clipboard" |
| 93 | backgroundClassName="bg-slate-200 dark:bg-slate-800" | 99 | backgroundClassName="bg-stone-200 dark:bg-slate-800" |
| 94 | iconTintColor="#0f172a" | 100 | iconTintColor="#0f172a" |
| 95 | onPress={() => void props.onPasteIntoDraft()} | 101 | onPress={() => void props.onPasteIntoDraft()} |
| 96 | /> | 102 | /> |
| 97 | <ComposerAction | 103 | <ComposerAction |
| 98 | icon="arrow.clockwise" | 104 | icon="arrow.clockwise" |
| 99 | backgroundClassName="bg-slate-200 dark:bg-slate-800" | 105 | backgroundClassName="bg-stone-200 dark:bg-slate-800" |
| 100 | iconTintColor="#0f172a" | 106 | iconTintColor="#0f172a" |
| 101 | onPress={() => void props.onRefresh()} | 107 | onPress={() => void props.onRefresh()} |
| 102 | /> | 108 | /> |
| @@ -117,7 +123,7 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer · 14 unmodified lines | |||
| 117 | : "paperplane.fill" | 123 | : "paperplane.fill" |
| 118 | } | 124 | } |
| 119 | backgroundClassName={cx( | 125 | backgroundClassName={cx( |
| 120 | canSend ? "bg-orange-500" : "bg-slate-200 dark:bg-slate-700/60", | 126 | canSend ? "bg-neutral-900" : "bg-slate-200 dark:bg-slate-700/60", |
| 121 | )} | 127 | )} |
| 122 | iconTintColor="#ffffff" | 128 | iconTintColor="#ffffff" |
| 123 | disabled={!canSend} | 129 | disabled={!canSend} |
| @@ -127,7 +133,11 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer · 3 unmodified lines | |||
| 127 | </View> | 133 | </View> |
| 128 | </View> | 134 | </View> |
| 129 | {props.draftAttachments.length > 0 ? ( | 135 | {props.draftAttachments.length > 0 ? ( |
| 130 | <ScrollView horizontal showsHorizontalScrollIndicator={false}> | 136 | <ScrollView |
| 137 | horizontal | ||
| 138 | showsHorizontalScrollIndicator={false} | ||
| 139 | style={isSplitLayout ? { alignSelf: "center", width: "100%", maxWidth: 960 } : undefined} | ||
| 140 | > | ||
| 131 | <View className="flex-row gap-2"> | 141 | <View className="flex-row gap-2"> |
| 132 | {props.draftAttachments.map((image) => ( | 142 | {props.draftAttachments.map((image) => ( |
| 133 | <View key={image.id} className="gap-2"> | 143 | <View key={image.id} className="gap-2"> |
| @@ -150,7 +160,10 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer · 16 unmodified lines | |||
| 150 | </ScrollView> | 160 | </ScrollView> |
| 151 | ) : null} | 161 | ) : null} |
| 152 | {props.queueCount > 0 ? ( | 162 | {props.queueCount > 0 ? ( |
| 153 | <Text className="font-t3-medium text-xs leading-[18px] text-slate-500 dark:text-slate-400"> | 163 | <Text |
| 164 | className="self-center font-t3-medium text-xs leading-[18px] text-slate-500 dark:text-slate-400" | ||
| 165 | style={{ maxWidth: 960, width: "100%" }} | ||
| 166 | > | ||
| 154 | {props.queueCount} queued message{props.queueCount === 1 ? "" : "s"} will send | 167 | {props.queueCount} queued message{props.queueCount === 1 ? "" : "s"} will send |
| 155 | automatically. | 168 | automatically. |
| 156 | </Text> | 169 | </Text> |
| @@ -1,23 +1,39 @@ | |||
| 1 | import type { ApprovalRequestId, ProviderApprovalDecision } from "@t3tools/contracts"; | 1 | import type { |
| 2 | ApprovalRequestId, | ||
| 3 | GitBranch, | ||
| 4 | GitRunStackedActionResult, | ||
| 5 | GitStatusResult, | ||
| 6 | ProviderApprovalDecision, | ||
| 7 | } from "@t3tools/contracts"; | ||
| 8 | import type { GitActionRequestInput } from "@t3tools/client-runtime"; | ||
| 2 | import * as Haptics from "expo-haptics"; | 9 | import * as Haptics from "expo-haptics"; |
| 3 | import { useEffect, useRef, useState } from "react"; | 10 | import { SymbolView } from "expo-symbols"; |
| 11 | import { useCallback, useEffect, useMemo, useRef, useState } from "react"; | ||
| 4 | import { | 12 | import { |
| 5 | KeyboardAvoidingView, | 13 | KeyboardAvoidingView, |
| 6 | Modal, | 14 | Modal, |
| 7 | Platform, | 15 | Platform, |
| 8 | Pressable, | 16 | Pressable, |
| 9 | useColorScheme, | 17 | useColorScheme, |
| 18 | useWindowDimensions, | ||
| 10 | View, | 19 | View, |
| 11 | } from "react-native"; | 20 | } from "react-native"; |
| 21 | import { Gesture, GestureDetector } from "react-native-gesture-handler"; | ||
| 12 | import { useSafeAreaInsets } from "react-native-safe-area-context"; | 22 | import { useSafeAreaInsets } from "react-native-safe-area-context"; |
| 13 | import Animated from "react-native-reanimated"; | 23 | import Animated, { |
| 24 | interpolate, | ||
| 25 | runOnJS, | ||
| 26 | useAnimatedStyle, | ||
| 27 | useSharedValue, | ||
| 28 | withSpring, | ||
| 29 | withTiming, | ||
| 30 | } from "react-native-reanimated"; | ||
| 14 | 31 | ||
| 15 | import { AppText as Text, AppTextInput as TextInput } from "../../components/AppText"; | 32 | import { AppText as Text, AppTextInput as TextInput } from "../../components/AppText"; |
| 16 | import { ErrorBanner } from "../../components/ErrorBanner"; | ||
| 17 | import { GlassSafeAreaView } from "../../components/GlassSafeAreaView"; | 33 | import { GlassSafeAreaView } from "../../components/GlassSafeAreaView"; |
| 18 | import type { StatusTone } from "../../components/StatusPill"; | 34 | import type { StatusTone } from "../../components/StatusPill"; |
| 19 | import { ConnectionStatusDot } from "../connection/ConnectionStatusDot"; | ||
| 20 | import type { DraftComposerImageAttachment } from "../../lib/composerImages"; | 35 | import type { DraftComposerImageAttachment } from "../../lib/composerImages"; |
| 36 | import type { MobileLayoutVariant } from "../../lib/mobileLayout"; | ||
| 21 | import type { ScopedMobileThread } from "../../lib/scopedEntities"; | 37 | import type { ScopedMobileThread } from "../../lib/scopedEntities"; |
| 22 | import type { | 38 | import type { |
| 23 | PendingApproval, | 39 | PendingApproval, |
| @@ -26,6 +42,7 @@ import type { · 2 unmodified lines | |||
| 26 | ThreadFeedEntry, | 42 | ThreadFeedEntry, |
| 27 | } from "../../lib/threadActivity"; | 43 | } from "../../lib/threadActivity"; |
| 28 | import { PendingApprovalCard } from "./PendingApprovalCard"; | 44 | import { PendingApprovalCard } from "./PendingApprovalCard"; |
| 45 | import { ThreadGitControls } from "./ThreadGitControls"; | ||
| 29 | import { PendingUserInputCard } from "./PendingUserInputCard"; | 46 | import { PendingUserInputCard } from "./PendingUserInputCard"; |
| 30 | import { ThreadComposer } from "./ThreadComposer"; | 47 | import { ThreadComposer } from "./ThreadComposer"; |
| 31 | import { ThreadFeed } from "./ThreadFeed"; | 48 | import { ThreadFeed } from "./ThreadFeed"; |
| @@ -48,14 +65,32 @@ export interface ThreadDetailScreenProps { · 16 unmodified lines | |||
| 48 | readonly draftAttachments: ReadonlyArray<DraftComposerImageAttachment>; | 65 | readonly draftAttachments: ReadonlyArray<DraftComposerImageAttachment>; |
| 49 | readonly connectionStateLabel: "ready" | "connecting" | "reconnecting" | "disconnected" | "idle"; | 66 | readonly connectionStateLabel: "ready" | "connecting" | "reconnecting" | "disconnected" | "idle"; |
| 50 | readonly activeThreadBusy: boolean; | 67 | readonly activeThreadBusy: boolean; |
| 68 | readonly selectedThreadGitStatus: GitStatusResult | null; | ||
| 69 | readonly gitOperationLabel: string | null; | ||
| 51 | readonly selectedThreadQueueCount: number; | 70 | readonly selectedThreadQueueCount: number; |
| 71 | readonly layoutVariant?: MobileLayoutVariant; | ||
| 52 | readonly onBack: () => void; | 72 | readonly onBack: () => void; |
| 73 | readonly onOpenDrawer: () => void; | ||
| 53 | readonly onOpenConnectionEditor: () => void; | 74 | readonly onOpenConnectionEditor: () => void; |
| 54 | readonly onChangeDraftMessage: (value: string) => void; | 75 | readonly onChangeDraftMessage: (value: string) => void; |
| 55 | readonly onPickDraftImages: () => Promise<void>; | 76 | readonly onPickDraftImages: () => Promise<void>; |
| 56 | readonly onPasteIntoDraft: () => Promise<void>; | 77 | readonly onPasteIntoDraft: () => Promise<void>; |
| 57 | readonly onRemoveDraftImage: (imageId: string) => void; | 78 | readonly onRemoveDraftImage: (imageId: string) => void; |
| 58 | readonly onRefresh: () => Promise<void>; | 79 | readonly onRefresh: () => Promise<void>; |
| 80 | readonly onRefreshSelectedThreadGitStatus: (options?: { | ||
| 81 | readonly quiet?: boolean; | ||
| 82 | }) => Promise<void>; | ||
| 83 | readonly onListSelectedThreadBranches: () => Promise<ReadonlyArray<GitBranch>>; | ||
| 84 | readonly onCheckoutSelectedThreadBranch: (branch: string) => Promise<void>; | ||
| 85 | readonly onCreateSelectedThreadBranch: (branch: string) => Promise<void>; | ||
| 86 | readonly onCreateSelectedThreadWorktree: (input: { | ||
| 87 | readonly baseBranch: string; | ||
| 88 | readonly newBranch: string; | ||
| 89 | }) => Promise<void>; | ||
| 90 | readonly onPullSelectedThreadBranch: () => Promise<void>; | ||
| 91 | readonly onRunSelectedThreadGitAction: ( | ||
| 92 | input: GitActionRequestInput, | ||
| 93 | ) => Promise<GitRunStackedActionResult | null>; | ||
| 59 | readonly onRenameThread: (title: string) => Promise<void>; | 94 | readonly onRenameThread: (title: string) => Promise<void>; |
| 60 | readonly onStopThread: () => Promise<void>; | 95 | readonly onStopThread: () => Promise<void>; |
| 61 | readonly onSendMessage: () => void; | 96 | readonly onSendMessage: () => void; |
| @@ -94,16 +129,6 @@ function latestStreamingAssistantMessage( · 32 unmodified lines | |||
| 94 | return null; | 129 | return null; |
| 95 | } | 130 | } |
| 96 | 131 | ||
| 97 | function useRenameDraftSync(threadId: string, threadTitle: string) { | ||
| 98 | const [renameDraft, setRenameDraft] = useState(threadTitle); | ||
| 99 | |||
| 100 | useEffect(() => { | ||
| 101 | setRenameDraft(threadTitle); | ||
| 102 | }, [threadId, threadTitle]); | ||
| 103 | |||
| 104 | return [renameDraft, setRenameDraft] as const; | ||
| 105 | } | ||
| 106 | |||
| 107 | function useStreamingHaptics(threadId: string, feed: ReadonlyArray<ThreadFeedEntry>) { | 132 | function useStreamingHaptics(threadId: string, feed: ReadonlyArray<ThreadFeedEntry>) { |
| 108 | const lastStreamingAssistantRef = useRef<{ | 133 | const lastStreamingAssistantRef = useRef<{ |
| 109 | readonly id: string; | 134 | readonly id: string; |
| @@ -155,28 +180,117 @@ function useStreamingHaptics(threadId: string, feed: ReadonlyArray<ThreadFeedEnt · 45 unmodified lines | |||
| 155 | } | 180 | } |
| 156 | 181 | ||
| 157 | export function ThreadDetailScreen(props: ThreadDetailScreenProps) { | 182 | export function ThreadDetailScreen(props: ThreadDetailScreenProps) { |
| 183 | const { onBack, onOpenDrawer, onRefresh, onRefreshSelectedThreadGitStatus } = props; | ||
| 158 | const isDarkMode = useColorScheme() === "dark"; | 184 | const isDarkMode = useColorScheme() === "dark"; |
| 159 | const insets = useSafeAreaInsets(); | 185 | const insets = useSafeAreaInsets(); |
| 186 | const { width } = useWindowDimensions(); | ||
| 160 | const agentLabel = `${props.selectedThread.modelSelection.provider} agent`; | 187 | const agentLabel = `${props.selectedThread.modelSelection.provider} agent`; |
| 161 | const headerOverlayHeight = insets.top + 70; | 188 | const headerOverlayHeight = insets.top + 118; |
| 162 | const composerBottomInset = Math.max(insets.bottom, 12); | 189 | const composerBottomInset = Math.max(insets.bottom, 12); |
| 163 | const screenBackgroundColor = isDarkMode ? "#020617" : "#f8fafc"; | 190 | const screenBackgroundColor = isDarkMode ? "#0c1118" : "#f7f7f5"; |
| 164 | const modalBackdropColor = isDarkMode ? "rgba(2,6,23,0.68)" : "rgba(15,23,42,0.22)"; | 191 | const modalBackdropColor = isDarkMode ? "rgba(2,6,23,0.68)" : "rgba(15,23,42,0.22)"; |
| 165 | const modalPanelColor = isDarkMode ? "#111827" : "#ffffff"; | 192 | const modalPanelColor = isDarkMode ? "#111827" : "#ffffff"; |
| 166 | const modalBorderColor = isDarkMode ? "rgba(255,255,255,0.08)" : "#e2e8f0"; | 193 | const modalBorderColor = isDarkMode ? "rgba(255,255,255,0.08)" : "#e2e8f0"; |
| 167 | const modalMutedColor = isDarkMode ? "#94a3b8" : "#64748b"; | 194 | const modalMutedColor = isDarkMode ? "#94a3b8" : "#64748b"; |
| 168 | const modalPrimaryColor = isDarkMode ? "#f8fafc" : "#020617"; | 195 | const modalPrimaryColor = isDarkMode ? "#f8fafc" : "#020617"; |
| 169 | const connectionPulse = props.activeThreadBusy; | ||
| 170 | const [renameVisible, setRenameVisible] = useState(false); | 196 | const [renameVisible, setRenameVisible] = useState(false); |
| 171 | const [renameDraft, setRenameDraft] = useRenameDraftSync( | 197 | const [refreshing, setRefreshing] = useState(false); |
| 172 | props.selectedThread.id, | 198 | const [renameDraft, setRenameDraft] = useState(props.selectedThread.title); |
| 173 | props.selectedThread.title, | ||
| 174 | ); | ||
| 175 | const showHeader = props.showHeader ?? true; | 199 | const showHeader = props.showHeader ?? true; |
| 176 | const showContent = props.showContent ?? true; | 200 | const showContent = props.showContent ?? true; |
| 201 | const layoutVariant = props.layoutVariant ?? "compact"; | ||
| 202 | const isSplitLayout = layoutVariant === "split"; | ||
| 203 | const edgeSwipeTranslation = useSharedValue(0); | ||
| 177 | 204 | ||
| 178 | useStreamingHaptics(props.selectedThread.id, props.selectedThreadFeed); | 205 | useStreamingHaptics(props.selectedThread.id, props.selectedThreadFeed); |
| 179 | 206 | ||
| 207 | const completeDrawerGesture = useCallback(() => { | ||
| 208 | void Haptics.selectionAsync(); | ||
| 209 | onOpenDrawer(); | ||
| 210 | }, [onOpenDrawer]); | ||
| 211 | |||
| 212 | const completeBackGesture = useCallback(() => { | ||
| 213 | void Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light); | ||
| 214 | onBack(); | ||
| 215 | }, [onBack]); | ||
| 216 | |||
| 217 | const handleRefresh = useCallback(async (): Promise<void> => { | ||
| 218 | if (refreshing) { | ||
| 219 | return; | ||
| 220 | } | ||
| 221 | |||
| 222 | setRefreshing(true); | ||
| 223 | try { | ||
| 224 | await onRefresh(); | ||
| 225 | await onRefreshSelectedThreadGitStatus(); | ||
| 226 | } finally { | ||
| 227 | setRefreshing(false); | ||
| 228 | } | ||
| 229 | }, [onRefresh, onRefreshSelectedThreadGitStatus, refreshing]); | ||
| 230 | |||
| 231 | const edgeSwipeGesture = useMemo( | ||
| 232 | () => | ||
| 233 | Gesture.Pan() | ||
| 234 | .enabled(!isSplitLayout) | ||
| 235 | .hitSlop({ left: 0, width: 40 }) | ||
| 236 | .activeOffsetX([10, 999]) | ||
| 237 | .failOffsetY([-24, 24]) | ||
| 238 | .onUpdate((event) => { | ||
| 239 | edgeSwipeTranslation.value = Math.max(event.translationX, 0); | ||
| 240 | }) | ||
| 241 | .onEnd((event) => { | ||
| 242 | const translationX = Math.max(event.translationX, 0); | ||
| 243 | const shouldOpenDrawer = event.y < headerOverlayHeight && translationX > 56; | ||
| 244 | const shouldGoBack = translationX > Math.min(width * 0.26, 120); | ||
| 245 | |||
| 246 | if (shouldOpenDrawer) { | ||
| 247 | edgeSwipeTranslation.value = withSpring(0, { | ||
| 248 | damping: 20, | ||
| 249 | stiffness: 220, | ||
| 250 | }); | ||
| 251 | runOnJS(completeDrawerGesture)(); | ||
| 252 | return; | ||
| 253 | } | ||
| 254 | |||
| 255 | if (shouldGoBack) { | ||
| 256 | edgeSwipeTranslation.value = withTiming(width, { duration: 180 }, (finished) => { | ||
| 257 | if (!finished) { | ||
| 258 | return; | ||
| 259 | } | ||
| 260 | |||
| 261 | edgeSwipeTranslation.value = 0; | ||
| 262 | runOnJS(completeBackGesture)(); | ||
| 263 | }); | ||
| 264 | return; | ||
| 265 | } | ||
| 266 | |||
| 267 | edgeSwipeTranslation.value = withSpring(0, { | ||
| 268 | damping: 20, | ||
| 269 | stiffness: 220, | ||
| 270 | }); | ||
| 271 | }), | ||
| 272 | [ | ||
| 273 | completeBackGesture, | ||
| 274 | completeDrawerGesture, | ||
| 275 | edgeSwipeTranslation, | ||
| 276 | headerOverlayHeight, | ||
| 277 | isSplitLayout, | ||
| 278 | width, | ||
| 279 | ], | ||
| 280 | ); | ||
| 281 | |||
| 282 | const edgeSwipeStyle = useAnimatedStyle(() => { | ||
| 283 | const borderRadius = interpolate(edgeSwipeTranslation.value, [0, width], [0, 28], "clamp"); | ||
| 284 | |||
| 285 | return { | ||
| 286 | flex: 1, | ||
| 287 | transform: [{ translateX: edgeSwipeTranslation.value }], | ||
| 288 | borderTopLeftRadius: borderRadius, | ||
| 289 | borderBottomLeftRadius: borderRadius, | ||
| 290 | overflow: "hidden", | ||
| 291 | }; | ||
| 292 | }); | ||
| 293 | |||
| 180 | async function handleSubmitRename(): Promise<void> { | 294 | async function handleSubmitRename(): Promise<void> { |
| 181 | const trimmed = renameDraft.trim(); | 295 | const trimmed = renameDraft.trim(); |
| 182 | if (trimmed.length === 0) { | 296 | if (trimmed.length === 0) { |
| @@ -188,216 +302,243 @@ export function ThreadDetailScreen(props: ThreadDetailScreenProps) { · 5 unmodified lines | |||
| 188 | } | 302 | } |
| 189 | 303 | ||
| 190 | return ( | 304 | return ( |
| 191 | <KeyboardAvoidingView | 305 | <GestureDetector gesture={edgeSwipeGesture}> |
| 192 | behavior={Platform.OS === "ios" ? "padding" : undefined} | 306 | <Animated.View style={edgeSwipeStyle}> |
| 193 | style={{ flex: 1, backgroundColor: screenBackgroundColor }} | 307 | <KeyboardAvoidingView |
| 194 | > | 308 | behavior={Platform.OS === "ios" ? "padding" : undefined} |
| 195 | {showHeader ? ( | 309 | style={{ flex: 1, backgroundColor: screenBackgroundColor }} |
| 196 | <View className="absolute inset-x-0 top-0 z-20"> | ||
| 197 | <GlassSafeAreaView | ||
| 198 | leftSlot={ | ||
| 199 | <Pressable | ||
| 200 | className="min-w-[76px] items-center rounded-[14px] bg-slate-200 px-2.5 py-2.5 dark:bg-slate-800" | ||
| 201 | onPress={props.onBack} | ||
| 202 | > | ||
| 203 | <Text className="font-t3-bold text-[13px] text-slate-950 dark:text-slate-50"> | ||
| 204 | Back | ||
| 205 | </Text> | ||
| 206 | </Pressable> | ||
| 207 | } | ||
| 208 | centerSlot={ | ||
| 209 | <View className="items-center gap-1"> | ||
| 210 | <Pressable onLongPress={() => setRenameVisible(true)}> | ||
| 211 | <Animated.Text | ||
| 212 | numberOfLines={1} | ||
| 213 | style={{ | ||
| 214 | color: isDarkMode ? "#f8fafc" : "#020617", | ||
| 215 | fontSize: 18, | ||
| 216 | fontWeight: "800", | ||
| 217 | lineHeight: 22, | ||
| 218 | }} | ||
| 219 | > | ||
| 220 | {props.selectedThread.title} | ||
| 221 | </Animated.Text> | ||
| 222 | </Pressable> | ||
| 223 | <Text | ||
| 224 | className="text-[11px] font-bold uppercase" | ||
| 225 | style={{ color: modalMutedColor, letterSpacing: 1.05 }} | ||
| 226 | > | ||
| 227 | {props.activeWorkDurationLabel ? props.activeWorkDurationLabel : ""} | ||
| 228 | </Text> | ||
| 229 | </View> | ||
| 230 | } | ||
| 231 | rightSlot={ | ||
| 232 | <Pressable className="rounded-full px-3 py-2" onPress={props.onOpenConnectionEditor}> | ||
| 233 | <ConnectionStatusDot state={props.connectionStateLabel} pulse={connectionPulse} /> | ||
| 234 | </Pressable> | ||
| 235 | } | ||
| 236 | /> | ||
| 237 | </View> | ||
| 238 | ) : null} | ||
| 239 | |||
| 240 | {showContent && props.connectionError ? ( | ||
| 241 | <View | ||
| 242 | className="mx-4 mt-3" | ||
| 243 | style={{ | ||
| 244 | paddingTop: props.activeWorkDurationLabel | ||
| 245 | ? headerOverlayHeight + 12 | ||
| 246 | : headerOverlayHeight + 6, | ||
| 247 | }} | ||
| 248 | > | 310 | > |
| 249 | <ErrorBanner message={props.connectionError} /> | 311 | {showHeader ? ( |
| 250 | </View> | 312 | <View className="absolute inset-x-0 top-0 z-20"> |
| 251 | ) : null} | 313 | <View |
| 252 | 314 | style={{ | |
| 253 | {showContent ? ( | 315 | backgroundColor: isDarkMode ? "rgba(12,17,24,0.94)" : "rgba(247,247,245,0.94)", |
| 254 | <> | 316 | borderBottomWidth: 1, |
| 255 | <View style={{ flex: 1, minHeight: 0 }}> | 317 | borderBottomColor: isDarkMode ? "rgba(255,255,255,0.08)" : "rgba(23,23,23,0.06)", |
| 256 | <ThreadFeed | 318 | }} |
| 257 | threadId={props.selectedThread.id} | 319 | > |
| 258 | feed={props.selectedThreadFeed} | 320 | <GlassSafeAreaView |
| 259 | httpBaseUrl={props.httpBaseUrl} | 321 | leftSlot={ |
| 260 | bearerToken={props.bearerToken} | 322 | isSplitLayout ? null : ( |
| 261 | agentLabel={agentLabel} | 323 | <Pressable |
| 262 | contentTopInset={headerOverlayHeight + 20} | 324 | className="h-11 w-11 items-center justify-center rounded-full" |
| 263 | contentBottomInset={composerBottomInset + 20} | 325 | style={{ |
| 264 | /> | 326 | backgroundColor: isDarkMode |
| 265 | </View> | 327 | ? "rgba(255,255,255,0.06)" |
| 266 | 328 | : "rgba(23,23,23,0.05)", | |
| 267 | {props.activePendingApproval || props.activePendingUserInput ? ( | 329 | }} |
| 268 | <View className="gap-3 px-4 pb-3" style={{ flexShrink: 0 }}> | 330 | onPress={props.onBack} |
| 269 | {props.activePendingApproval ? ( | 331 | > |
| 270 | <PendingApprovalCard | 332 | <SymbolView |
| 271 | approval={props.activePendingApproval} | 333 | name="chevron.left" |
| 272 | respondingApprovalId={props.respondingApprovalId} | 334 | size={18} |
| 273 | onRespond={props.onRespondToApproval} | 335 | tintColor={isDarkMode ? "#fafaf9" : "#171717"} |
| 274 | /> | 336 | type="monochrome" |
| 275 | ) : null} | 337 | /> |
| 276 | {props.activePendingUserInput ? ( | 338 | </Pressable> |
| 277 | <PendingUserInputCard | 339 | ) |
| 278 | pendingUserInput={props.activePendingUserInput} | 340 | } |
| 279 | drafts={props.activePendingUserInputDrafts} | 341 | centerSlot={ |
| 280 | answers={props.activePendingUserInputAnswers} | 342 | <View className="items-center gap-1"> |
| 281 | respondingUserInputId={props.respondingUserInputId} | 343 | <Pressable |
| 282 | onSelectOption={props.onSelectUserInputOption} | 344 | onLongPress={() => { |
| 283 | onChangeCustomAnswer={props.onChangeUserInputCustomAnswer} | 345 | setRenameDraft(props.selectedThread.title); |
| 284 | onSubmit={props.onSubmitUserInput} | 346 | setRenameVisible(true); |
| 347 | }} | ||
| 348 | > | ||
| 349 | <Animated.Text | ||
| 350 | numberOfLines={1} | ||
| 351 | style={{ | ||
| 352 | color: isDarkMode ? "#f8fafc" : "#020617", | ||
| 353 | fontSize: 18, | ||
| 354 | fontWeight: "800", | ||
| 355 | lineHeight: 22, | ||
| 356 | }} | ||
| 357 | > | ||
| 358 | {props.selectedThread.title} | ||
| 359 | </Animated.Text> | ||
| 360 | </Pressable> | ||
| 361 | <Text | ||
| 362 | className="text-[11px] font-bold uppercase" | ||
| 363 | style={{ color: modalMutedColor, letterSpacing: 1.05 }} | ||
| 364 | > | ||
| 365 | {props.activeWorkDurationLabel ? props.activeWorkDurationLabel : ""} | ||
| 366 | </Text> | ||
| 367 | </View> | ||
| 368 | } | ||
| 369 | rightSlot={ | ||
| 370 | <ThreadGitControls | ||
| 371 | currentBranch={props.selectedThread.branch} | ||
| 372 | currentWorktreePath={props.selectedThread.worktreePath} | ||
| 373 | gitStatus={props.selectedThreadGitStatus} | ||
| 374 | gitOperationLabel={props.gitOperationLabel} | ||
| 375 | onRefreshStatus={props.onRefreshSelectedThreadGitStatus} | ||
| 376 | onListBranches={props.onListSelectedThreadBranches} | ||
| 377 | onCheckoutBranch={props.onCheckoutSelectedThreadBranch} | ||
| 378 | onCreateBranch={props.onCreateSelectedThreadBranch} | ||
| 379 | onCreateWorktree={props.onCreateSelectedThreadWorktree} | ||
| 380 | onPull={props.onPullSelectedThreadBranch} | ||
| 381 | onRunAction={props.onRunSelectedThreadGitAction} | ||
| 382 | /> | ||
| 383 | } | ||
| 285 | /> | 384 | /> |
| 286 | ) : null} | 385 | </View> |
| 287 | </View> | 386 | </View> |
| 288 | ) : null} | 387 | ) : null} |
| 289 | 388 | ||
| 290 | <ThreadComposer | 389 | {showContent ? ( |
| 291 | draftMessage={props.draftMessage} | 390 | <> |
| 292 | draftAttachments={props.draftAttachments} | 391 | <View style={{ flex: 1, minHeight: 0 }}> |
| 293 | placeholder="Ask the repo agent, or run a command…" | 392 | <ThreadFeed |
| 294 | connectionState={props.connectionStateLabel} | 393 | threadId={props.selectedThread.id} |
| 295 | selectedThread={props.selectedThread} | 394 | feed={props.selectedThreadFeed} |
| 296 | queueCount={props.selectedThreadQueueCount} | 395 | httpBaseUrl={props.httpBaseUrl} |
| 297 | activeThreadBusy={props.activeThreadBusy} | 396 | bearerToken={props.bearerToken} |
| 298 | bottomInset={composerBottomInset} | 397 | agentLabel={agentLabel} |
| 299 | onChangeDraftMessage={props.onChangeDraftMessage} | 398 | contentTopInset={headerOverlayHeight + 20} |
| 300 | onPickDraftImages={props.onPickDraftImages} | 399 | contentBottomInset={composerBottomInset + 20} |
| 301 | onPasteIntoDraft={props.onPasteIntoDraft} | 400 | layoutVariant={layoutVariant} |
| 302 | onRemoveDraftImage={props.onRemoveDraftImage} | 401 | refreshing={refreshing} |
| 303 | onRefresh={props.onRefresh} | 402 | onRefresh={() => void handleRefresh()} |
| 304 | onStopThread={props.onStopThread} | 403 | /> |
| 305 | onSendMessage={props.onSendMessage} | 404 | </View> |
| 306 | /> | ||
| 307 | </> | ||
| 308 | ) : ( | ||
| 309 | <View style={{ flex: 1 }} /> | ||
| 310 | )} | ||
| 311 | |||
| 312 | <Modal | ||
| 313 | transparent | ||
| 314 | animationType="fade" | ||
| 315 | visible={renameVisible} | ||
| 316 | onRequestClose={() => setRenameVisible(false)} | ||
| 317 | > | ||
| 318 | <View | ||
| 319 | className="flex-1 items-center justify-center px-5" | ||
| 320 | style={{ backgroundColor: modalBackdropColor }} | ||
| 321 | > | ||
| 322 | <View | ||
| 323 | className="w-full gap-4 px-4 py-4" | ||
| 324 | style={{ | ||
| 325 | maxWidth: 420, | ||
| 326 | borderWidth: 1, | ||
| 327 | borderColor: modalBorderColor, | ||
| 328 | backgroundColor: modalPanelColor, | ||
| 329 | }} | ||
| 330 | > | ||
| 331 | <View className="gap-2"> | ||
| 332 | <Text | ||
| 333 | className="text-[11px] font-bold uppercase" | ||
| 334 | style={{ color: modalMutedColor, letterSpacing: 1.2 }} | ||
| 335 | > | ||
| 336 | Thread name | ||
| 337 | </Text> | ||
| 338 | <Text | ||
| 339 | className="text-[20px] font-extrabold leading-[24px]" | ||
| 340 | style={{ color: modalPrimaryColor }} | ||
| 341 | > | ||
| 342 | Rename thread | ||
| 343 | </Text> | ||
| 344 | </View> | ||
| 345 | 405 | ||
| 346 | <TextInput | 406 | {props.activePendingApproval || props.activePendingUserInput ? ( |
| 347 | autoFocus | 407 | <View className="gap-3 px-4 pb-3" style={{ flexShrink: 0 }}> |
| 348 | value={renameDraft} | 408 | {props.activePendingApproval ? ( |
| 349 | onChangeText={setRenameDraft} | 409 | <PendingApprovalCard |
| 350 | placeholder="Thread title" | 410 | approval={props.activePendingApproval} |
| 351 | className="min-h-[56px] px-4 py-3 text-[15px]" | 411 | respondingApprovalId={props.respondingApprovalId} |
| 352 | style={{ | 412 | onRespond={props.onRespondToApproval} |
| 353 | borderWidth: 1, | 413 | /> |
| 354 | borderColor: modalBorderColor, | 414 | ) : null} |
| 355 | backgroundColor: isDarkMode ? "#0f172a" : "#ffffff", | 415 | {props.activePendingUserInput ? ( |
| 356 | color: modalPrimaryColor, | 416 | <PendingUserInputCard |
| 357 | }} | 417 | pendingUserInput={props.activePendingUserInput} |
| 358 | onSubmitEditing={() => { | 418 | drafts={props.activePendingUserInputDrafts} |
| 359 | void handleSubmitRename(); | 419 | answers={props.activePendingUserInputAnswers} |
| 360 | }} | 420 | respondingUserInputId={props.respondingUserInputId} |
| 361 | /> | 421 | onSelectOption={props.onSelectUserInputOption} |
| 362 | 422 | onChangeCustomAnswer={props.onChangeUserInputCustomAnswer} | |
| 363 | <View className="flex-row gap-3"> | 423 | onSubmit={props.onSubmitUserInput} |
| 364 | <Pressable | 424 | /> |
| 365 | className="min-h-[48px] flex-1 items-center justify-center px-4 py-3" | 425 | ) : null} |
| 426 | </View> | ||
| 427 | ) : null} | ||
| 428 | |||
| 429 | <ThreadComposer | ||
| 430 | draftMessage={props.draftMessage} | ||
| 431 | draftAttachments={props.draftAttachments} | ||
| 432 | placeholder="Ask the repo agent, or run a command…" | ||
| 433 | connectionState={props.connectionStateLabel} | ||
| 434 | selectedThread={props.selectedThread} | ||
| 435 | queueCount={props.selectedThreadQueueCount} | ||
| 436 | activeThreadBusy={props.activeThreadBusy} | ||
| 437 | layoutVariant={layoutVariant} | ||
| 438 | bottomInset={composerBottomInset} | ||
| 439 | onChangeDraftMessage={props.onChangeDraftMessage} | ||
| 440 | onPickDraftImages={props.onPickDraftImages} | ||
| 441 | onPasteIntoDraft={props.onPasteIntoDraft} | ||
| 442 | onRemoveDraftImage={props.onRemoveDraftImage} | ||
| 443 | onRefresh={props.onRefresh} | ||
| 444 | onStopThread={props.onStopThread} | ||
| 445 | onSendMessage={props.onSendMessage} | ||
| 446 | /> | ||
| 447 | </> | ||
| 448 | ) : ( | ||
| 449 | <View style={{ flex: 1 }} /> | ||
| 450 | )} | ||
| 451 | |||
| 452 | <Modal | ||
| 453 | transparent | ||
| 454 | animationType="fade" | ||
| 455 | visible={renameVisible} | ||
| 456 | onRequestClose={() => setRenameVisible(false)} | ||
| 457 | > | ||
| 458 | <View | ||
| 459 | className="flex-1 items-center justify-center px-5" | ||
| 460 | style={{ backgroundColor: modalBackdropColor }} | ||
| 461 | > | ||
| 462 | <View | ||
| 463 | className="w-full gap-4 px-4 py-4" | ||
| 366 | style={{ | 464 | style={{ |
| 465 | maxWidth: 420, | ||
| 367 | borderWidth: 1, | 466 | borderWidth: 1, |
| 368 | borderColor: modalBorderColor, | 467 | borderColor: modalBorderColor, |
| 369 | backgroundColor: isDarkMode ? "#1f2937" : "#f8fafc", | 468 | backgroundColor: modalPanelColor, |
| 370 | }} | ||
| 371 | onPress={() => { | ||
| 372 | setRenameDraft(props.selectedThread.title); | ||
| 373 | setRenameVisible(false); | ||
| 374 | }} | ||
| 375 | > | ||
| 376 | <Text | ||
| 377 | className="text-sm font-extrabold uppercase" | ||
| 378 | style={{ color: modalPrimaryColor, letterSpacing: 1 }} | ||
| 379 | > | ||
| 380 | Cancel | ||
| 381 | </Text> | ||
| 382 | </Pressable> | ||
| 383 | <Pressable | ||
| 384 | className="min-h-[48px] flex-1 items-center justify-center px-4 py-3" | ||
| 385 | style={{ backgroundColor: modalPrimaryColor }} | ||
| 386 | onPress={() => { | ||
| 387 | void handleSubmitRename(); | ||
| 388 | }} | 469 | }} |
| 389 | > | 470 | > |
| 390 | <Text | 471 | <View className="gap-2"> |
| 391 | className="text-sm font-extrabold uppercase" | 472 | <Text |
| 392 | style={{ color: isDarkMode ? "#020617" : "#f8fafc", letterSpacing: 1 }} | 473 | className="text-[11px] font-bold uppercase" |
| 393 | > | 474 | style={{ color: modalMutedColor, letterSpacing: 1.2 }} |
| 394 | Save | 475 | > |
| 395 | </Text> | 476 | Thread name |
| 396 | </Pressable> | 477 | </Text> |
| 478 | <Text | ||
| 479 | className="text-[20px] font-extrabold leading-[24px]" | ||
| 480 | style={{ color: modalPrimaryColor }} | ||
| 481 | > | ||
| 482 | Rename thread | ||
| 483 | </Text> | ||
| 484 | </View> | ||
| 485 | |||
| 486 | <TextInput | ||
| 487 | value={renameDraft} | ||
| 488 | onChangeText={setRenameDraft} | ||
| 489 | placeholder="Thread title" | ||
| 490 | className="min-h-[56px] px-4 py-3 text-[15px]" | ||
| 491 | style={{ | ||
| 492 | borderWidth: 1, | ||
| 493 | borderColor: modalBorderColor, | ||
| 494 | backgroundColor: isDarkMode ? "#0f172a" : "#ffffff", | ||
| 495 | color: modalPrimaryColor, | ||
| 496 | }} | ||
| 497 | onSubmitEditing={() => { | ||
| 498 | void handleSubmitRename(); | ||
| 499 | }} | ||
| 500 | /> | ||
| 501 | |||
| 502 | <View className="flex-row gap-3"> | ||
| 503 | <Pressable | ||
| 504 | className="min-h-[48px] flex-1 items-center justify-center px-4 py-3" | ||
| 505 | style={{ | ||
| 506 | borderWidth: 1, | ||
| 507 | borderColor: modalBorderColor, | ||
| 508 | backgroundColor: isDarkMode ? "#1f2937" : "#f8fafc", | ||
| 509 | }} | ||
| 510 | onPress={() => { | ||
| 511 | setRenameDraft(props.selectedThread.title); | ||
| 512 | setRenameVisible(false); | ||
| 513 | }} | ||
| 514 | > | ||
| 515 | <Text | ||
| 516 | className="text-sm font-extrabold uppercase" | ||
| 517 | style={{ color: modalPrimaryColor, letterSpacing: 1 }} | ||
| 518 | > | ||
| 519 | Cancel | ||
| 520 | </Text> | ||
| 521 | </Pressable> | ||
| 522 | <Pressable | ||
| 523 | className="min-h-[48px] flex-1 items-center justify-center px-4 py-3" | ||
| 524 | style={{ backgroundColor: modalPrimaryColor }} | ||
| 525 | onPress={() => { | ||
| 526 | void handleSubmitRename(); | ||
| 527 | }} | ||
| 528 | > | ||
| 529 | <Text | ||
| 530 | className="text-sm font-extrabold uppercase" | ||
| 531 | style={{ color: isDarkMode ? "#020617" : "#f8fafc", letterSpacing: 1 }} | ||
| 532 | > | ||
| 533 | Save | ||
| 534 | </Text> | ||
| 535 | </Pressable> | ||
| 536 | </View> | ||
| 537 | </View> | ||
| 397 | </View> | 538 | </View> |
| 398 | </View> | 539 | </Modal> |
| 399 | </View> | 540 | </KeyboardAvoidingView> |
| 400 | </Modal> | 541 | </Animated.View> |
| 401 | </KeyboardAvoidingView> | 542 | </GestureDetector> |
| 402 | ); | 543 | ); |
| 403 | } | 544 | } |
| @@ -1,11 +1,23 @@ | |||
| 1 | import * as Clipboard from "expo-clipboard"; | ||
| 2 | import * as Haptics from "expo-haptics"; | ||
| 1 | import { FlashList, type FlashListRef, type ListRenderItemInfo } from "@shopify/flash-list"; | 3 | import { FlashList, type FlashListRef, type ListRenderItemInfo } from "@shopify/flash-list"; |
| 2 | import { memo, useCallback, useEffect, useRef } from "react"; | 4 | import { SymbolView } from "expo-symbols"; |
| 5 | import { memo, useCallback, useEffect, useState, useRef } from "react"; | ||
| 3 | import Markdown from "react-native-markdown-display"; | 6 | import Markdown from "react-native-markdown-display"; |
| 4 | import { Image, type NativeScrollEvent, type NativeSyntheticEvent, View } from "react-native"; | 7 | import { |
| 8 | Image, | ||
| 9 | Pressable, | ||
| 10 | RefreshControl, | ||
| 11 | ScrollView, | ||
| 12 | type NativeScrollEvent, | ||
| 13 | type NativeSyntheticEvent, | ||
| 14 | View, | ||
| 15 | } from "react-native"; | ||
| 5 | 16 | ||
| 6 | import { AppText as Text } from "../../components/AppText"; | 17 | import { AppText as Text } from "../../components/AppText"; |
| 7 | import { EmptyState } from "../../components/EmptyState"; | 18 | import { EmptyState } from "../../components/EmptyState"; |
| 8 | import { cx } from "../../lib/classNames"; | 19 | import { cx } from "../../lib/classNames"; |
| 20 | import type { MobileLayoutVariant } from "../../lib/mobileLayout"; | ||
| 9 | import type { ThreadFeedEntry } from "../../lib/threadActivity"; | 21 | import type { ThreadFeedEntry } from "../../lib/threadActivity"; |
| 10 | import { relativeTime } from "../../lib/time"; | 22 | import { relativeTime } from "../../lib/time"; |
| 11 | import { messageImageUrl } from "./threadPresentation"; | 23 | import { messageImageUrl } from "./threadPresentation"; |
| @@ -18,6 +30,9 @@ export interface ThreadFeedProps { · 6 unmodified lines | |||
| 18 | readonly agentLabel: string; | 30 | readonly agentLabel: string; |
| 19 | readonly contentTopInset?: number; | 31 | readonly contentTopInset?: number; |
| 20 | readonly contentBottomInset?: number; | 32 | readonly contentBottomInset?: number; |
| 33 | readonly refreshing?: boolean; | ||
| 34 | readonly onRefresh?: () => void; | ||
| 35 | readonly layoutVariant?: MobileLayoutVariant; | ||
| 21 | } | 36 | } |
| 22 | 37 | ||
| 23 | function stripShellWrapper(value: string): string { | 38 | function stripShellWrapper(value: string): string { |
| @@ -31,13 +46,18 @@ function compactActivityDetail(detail: string | null): string | null { · 7 unmodified lines | |||
| 31 | return null; | 46 | return null; |
| 32 | } | 47 | } |
| 33 | 48 | ||
| 34 | const cleaned = stripShellWrapper(detail); | 49 | const cleaned = stripShellWrapper(detail).replace(/\s+/g, " ").trim(); |
| 35 | return cleaned.length > 0 ? cleaned : null; | 50 | return cleaned.length > 0 ? cleaned : null; |
| 36 | } | 51 | } |
| 37 | 52 | ||
| 53 | function normalizeCompactActivityLabel(value: string): string { | ||
| 54 | return value.replace(/\s+(?:started|complete|completed)\s*$/i, "").trim(); | ||
| 55 | } | ||
| 56 | |||
| 38 | function buildActivityRows( | 57 | function buildActivityRows( |
| 39 | activities: ReadonlyArray<{ | 58 | activities: ReadonlyArray<{ |
| 40 | readonly id: string; | 59 | readonly id: string; |
| 60 | readonly createdAt: string; | ||
| 41 | readonly summary: string; | 61 | readonly summary: string; |
| 42 | readonly detail: string | null; | 62 | readonly detail: string | null; |
| 43 | readonly status: string | null; | 63 | readonly status: string | null; |
| @@ -45,19 +65,20 @@ function buildActivityRows( · 1 unmodified line | |||
| 45 | ) { | 65 | ) { |
| 46 | const rows: Array<{ | 66 | const rows: Array<{ |
| 47 | id: string; | 67 | id: string; |
| 48 | label: string; | 68 | createdAt: string; |
| 69 | summary: string; | ||
| 49 | detail: string | null; | 70 | detail: string | null; |
| 50 | status: string | null; | 71 | status: string | null; |
| 51 | }> = []; | 72 | }> = []; |
| 52 | 73 | ||
| 53 | for (const activity of activities) { | 74 | for (const activity of activities) { |
| 54 | const detail = compactActivityDetail(activity.detail); | 75 | const detail = compactActivityDetail(activity.detail); |
| 55 | const label = detail ?? activity.summary; | ||
| 56 | const previous = rows.at(-1); | 76 | const previous = rows.at(-1); |
| 57 | 77 | ||
| 58 | if (previous && previous.label === label) { | 78 | if (previous && previous.summary === activity.summary) { |
| 59 | rows[rows.length - 1] = { | 79 | rows[rows.length - 1] = { |
| 60 | ...previous, | 80 | ...previous, |
| 81 | createdAt: activity.createdAt, | ||
| 61 | detail, | 82 | detail, |
| 62 | status: activity.status ?? previous.status, | 83 | status: activity.status ?? previous.status, |
| 63 | }; | 84 | }; |
| @@ -66,7 +87,8 @@ function buildActivityRows( · 2 unmodified lines | |||
| 66 | 87 | ||
| 67 | rows.push({ | 88 | rows.push({ |
| 68 | id: activity.id, | 89 | id: activity.id, |
| 69 | label, | 90 | createdAt: activity.createdAt, |
| 91 | summary: activity.summary, | ||
| 70 | detail, | 92 | detail, |
| 71 | status: activity.status, | 93 | status: activity.status, |
| 72 | }); | 94 | }); |
| @@ -75,6 +97,8 @@ function buildActivityRows( · 2 unmodified lines | |||
| 75 | return rows; | 97 | return rows; |
| 76 | } | 98 | } |
| 77 | 99 | ||
| 100 | const MAX_VISIBLE_WORK_LOG_ENTRIES = 6; | ||
| 101 | |||
| 78 | const MARKDOWN_BASE = { | 102 | const MARKDOWN_BASE = { |
| 79 | body: { | 103 | body: { |
| 80 | color: "#020617", | 104 | color: "#020617", |
| @@ -144,7 +168,12 @@ const ASSISTANT_MARKDOWN_STYLES = { · 63 unmodified lines | |||
| 144 | 168 | ||
| 145 | function renderFeedEntry( | 169 | function renderFeedEntry( |
| 146 | info: ListRenderItemInfo<ThreadFeedEntry>, | 170 | info: ListRenderItemInfo<ThreadFeedEntry>, |
| 147 | props: Pick<ThreadFeedProps, "agentLabel" | "bearerToken" | "httpBaseUrl">, | 171 | props: Pick<ThreadFeedProps, "bearerToken" | "httpBaseUrl"> & { |
| 172 | readonly copiedRowId: string | null; | ||
| 173 | readonly expandedWorkGroups: Record<string, boolean>; | ||
| 174 | readonly onCopyWorkRow: (rowId: string, value: string) => void; | ||
| 175 | readonly onToggleWorkGroup: (groupId: string) => void; | ||
| 176 | }, | ||
| 148 | ) { | 177 | ) { |
| 149 | const entry = info.item; | 178 | const entry = info.item; |
| 150 | 179 | ||
| @@ -152,29 +181,54 @@ function renderFeedEntry( · 1 unmodified line | |||
| 152 | const { message } = entry; | 181 | const { message } = entry; |
| 153 | const isUser = message.role === "user"; | 182 | const isUser = message.role === "user"; |
| 154 | const markdownStyles = isUser ? USER_MARKDOWN_STYLES : ASSISTANT_MARKDOWN_STYLES; | 183 | const markdownStyles = isUser ? USER_MARKDOWN_STYLES : ASSISTANT_MARKDOWN_STYLES; |
| 184 | const timestampLabel = `${relativeTime(message.createdAt)}${message.streaming ? " • live" : ""}`; | ||
| 185 | const attachments = message.attachments ?? []; | ||
| 155 | 186 | ||
| 156 | return ( | 187 | if (isUser) { |
| 157 | <View | 188 | return ( |
| 158 | className={cx( | 189 | <View className="mb-3.5 items-end gap-1.5"> |
| 159 | "mb-3.5 gap-2.5 rounded-[22px] border px-4 py-4", | 190 | <View className="max-w-[85%] gap-2 rounded-[22px] rounded-br-[10px] border border-orange-300/60 bg-orange-100/70 px-4 py-4 dark:border-orange-300/22 dark:bg-orange-300/14"> |
| 160 | isUser | 191 | {message.text.trim().length > 0 ? ( |
| 161 | ? "border-orange-300/60 bg-orange-100/70 dark:border-orange-300/22 dark:bg-orange-300/14" | 192 | <Markdown style={markdownStyles}>{message.text}</Markdown> |
| 162 | : "border-slate-200 bg-slate-100/80 dark:border-white/6 dark:bg-slate-900/80", | 193 | ) : null} |
| 163 | )} | 194 | {attachments.map((attachment) => { |
| 164 | > | 195 | const uri = messageImageUrl(props.httpBaseUrl, attachment.id); |
| 165 | <View className="flex-row justify-between gap-3"> | 196 | if (!uri) { |
| 166 | <Text className="font-t3-bold text-[13px] text-slate-950 dark:text-slate-50"> | 197 | return null; |
| 167 | {isUser ? "You" : props.agentLabel} | 198 | } |
| 168 | </Text> | 199 | |
| 169 | <Text className="font-t3-medium text-xs text-slate-500 dark:text-slate-500"> | 200 | return ( |
| 170 | {relativeTime(message.createdAt)} | 201 | <Image |
| 171 | {message.streaming ? " • live" : ""} | 202 | key={attachment.id} |
| 203 | source={{ | ||
| 204 | uri, | ||
| 205 | ...(props.bearerToken | ||
| 206 | ? { | ||
| 207 | headers: { | ||
| 208 | Authorization: `Bearer ${props.bearerToken}`, | ||
| 209 | }, | ||
| 210 | } | ||
| 211 | : {}), | ||
| 212 | }} | ||
| 213 | className="aspect-[1.3] w-full rounded-[18px] bg-slate-200 dark:bg-slate-800" | ||
| 214 | resizeMode="cover" | ||
| 215 | /> | ||
| 216 | ); | ||
| 217 | })} | ||
| 218 | </View> | ||
| 219 | <Text className="px-1 text-right font-t3-medium text-xs text-slate-500 dark:text-slate-500"> | ||
| 220 | {timestampLabel} | ||
| 172 | </Text> | 221 | </Text> |
| 173 | </View> | 222 | </View> |
| 223 | ); | ||
| 224 | } | ||
| 225 | |||
| 226 | return ( | ||
| 227 | <View className="mb-3.5 gap-1.5 px-1"> | ||
| 174 | {message.text.trim().length > 0 ? ( | 228 | {message.text.trim().length > 0 ? ( |
| 175 | <Markdown style={markdownStyles}>{message.text}</Markdown> | 229 | <Markdown style={markdownStyles}>{message.text}</Markdown> |
| 176 | ) : null} | 230 | ) : null} |
| 177 | {message.attachments?.map((attachment) => { | 231 | {attachments.map((attachment) => { |
| 178 | const uri = messageImageUrl(props.httpBaseUrl, attachment.id); | 232 | const uri = messageImageUrl(props.httpBaseUrl, attachment.id); |
| 179 | if (!uri) { | 233 | if (!uri) { |
| 180 | return null; | 234 | return null; |
| @@ -198,65 +252,94 @@ function renderFeedEntry( · 17 unmodified lines | |||
| 198 | /> | 252 | /> |
| 199 | ); | 253 | ); |
| 200 | })} | 254 | })} |
| 255 | <Text className="font-t3-medium text-xs text-slate-500 dark:text-slate-500"> | ||
| 256 | {timestampLabel} | ||
| 257 | </Text> | ||
| 201 | </View> | 258 | </View> |
| 202 | ); | 259 | ); |
| 203 | } | 260 | } |
| 204 | 261 | ||
| 205 | if (entry.type === "queued-message") { | 262 | if (entry.type === "queued-message") { |
| 206 | return ( | 263 | return ( |
| 207 | <View className="mb-3.5 gap-2.5 rounded-[22px] border border-sky-300/60 bg-sky-100/75 px-4 py-4 dark:border-sky-300/20 dark:bg-sky-400/10"> | 264 | <View className="mb-3.5 gap-1.5 items-end"> |
| 208 | <View className="flex-row justify-between gap-3"> | 265 | <View className="max-w-[85%] gap-2 rounded-[22px] rounded-br-[10px] border border-sky-300/60 bg-sky-100/75 px-4 py-4 dark:border-sky-300/20 dark:bg-sky-400/10"> |
| 209 | <Text className="font-t3-bold text-[13px] text-slate-950 dark:text-slate-50"> | 266 | <Text className="font-sans text-[15px] leading-[22px] text-slate-950 dark:text-slate-50"> |
| 210 | {entry.sending ? "Sending next" : "Queued"} | 267 | {entry.queuedMessage.text} |
| 211 | </Text> | ||
| 212 | <Text className="font-t3-medium text-xs text-slate-500 dark:text-slate-500"> | ||
| 213 | {entry.sending ? "dispatching" : `${relativeTime(entry.createdAt)} • pending`} | ||
| 214 | </Text> | 268 | </Text> |
| 269 | {entry.queuedMessage.attachments.length > 0 ? ( | ||
| 270 | <Text className="font-t3-medium text-xs text-slate-500 dark:text-slate-500"> | ||
| 271 | {entry.queuedMessage.attachments.length} image | ||
| 272 | {entry.queuedMessage.attachments.length === 1 ? "" : "s"} attached | ||
| 273 | </Text> | ||
| 274 | ) : null} | ||
| 215 | </View> | 275 | </View> |
| 216 | <Text className="font-sans text-[15px] leading-[22px] text-slate-950 dark:text-slate-50"> | 276 | <Text className="px-1 text-right font-t3-medium text-xs text-slate-500 dark:text-slate-500"> |
| 217 | {entry.queuedMessage.text} | 277 | {entry.sending ? "dispatching" : `${relativeTime(entry.createdAt)} • pending`} |
| 218 | </Text> | 278 | </Text> |
| 219 | {entry.queuedMessage.attachments.length > 0 ? ( | ||
| 220 | <Text className="font-t3-medium text-xs text-slate-500 dark:text-slate-500"> | ||
| 221 | {entry.queuedMessage.attachments.length} image | ||
| 222 | {entry.queuedMessage.attachments.length === 1 ? "" : "s"} attached | ||
| 223 | </Text> | ||
| 224 | ) : null} | ||
| 225 | </View> | 279 | </View> |
| 226 | ); | 280 | ); |
| 227 | } | 281 | } |
| 228 | 282 | ||
| 229 | const rows = buildActivityRows(entry.activities); | 283 | const rows = buildActivityRows(entry.activities); |
| 284 | const isExpanded = props.expandedWorkGroups[entry.id] ?? false; | ||
| 285 | const hasOverflow = rows.length > MAX_VISIBLE_WORK_LOG_ENTRIES; | ||
| 286 | const visibleRows = hasOverflow && !isExpanded ? rows.slice(-MAX_VISIBLE_WORK_LOG_ENTRIES) : rows; | ||
| 287 | const hiddenCount = rows.length - visibleRows.length; | ||
| 288 | const showHeader = hasOverflow; | ||
| 230 | 289 | ||
| 231 | return ( | 290 | return ( |
| 232 | <View className="mb-3.5 gap-3 rounded-[22px] border border-slate-200/80 bg-slate-50/85 px-4 py-4 dark:border-white/4 dark:bg-white/[0.025]"> | 291 | <View className="mb-3 rounded-[20px] border border-slate-200/80 bg-slate-50/85 px-3 py-2 dark:border-white/4 dark:bg-white/[0.025]"> |
| 233 | <View className="flex-row items-center justify-between gap-3 pb-0.5"> | 292 | {showHeader ? ( |
| 234 | <Text className="font-t3-bold text-[11px] uppercase tracking-[0.8px] text-slate-500 dark:text-slate-500"> | 293 | <View className="mb-1.5 flex-row items-center justify-between gap-3 px-0.5"> |
| 235 | Command center | 294 | <Text className="font-t3-bold text-[10px] uppercase tracking-[0.8px] text-slate-500 dark:text-slate-500"> |
| 236 | </Text> | 295 | Tool calls ({rows.length}) |
| 237 | <Text className="font-t3-medium text-[11px] text-slate-500 dark:text-slate-500"> | 296 | </Text> |
| 238 | {relativeTime(entry.createdAt)} | 297 | <Pressable onPress={() => props.onToggleWorkGroup(entry.id)}> |
| 239 | </Text> | 298 | <Text className="font-t3-medium text-[10px] uppercase tracking-[0.8px] text-slate-500 dark:text-slate-500"> |
| 240 | </View> | 299 | {isExpanded ? "Show less" : `Show ${hiddenCount} more`} |
| 241 | {rows.map((row, index) => ( | 300 | </Text> |
| 301 | </Pressable> | ||
| 302 | </View> | ||
| 303 | ) : null} | ||
| 304 | {visibleRows.map((row, index) => ( | ||
| 242 | <View | 305 | <View |
| 243 | key={row.id} | 306 | key={row.id} |
| 244 | className={cx( | 307 | className={cx( |
| 245 | "gap-1.5 py-2", | 308 | "flex-row items-center gap-2 rounded-lg px-1 py-1", |
| 246 | index > 0 && "border-t border-slate-200/80 dark:border-white/4", | 309 | index > 0 && "border-t border-slate-200/80 dark:border-white/4", |
| 247 | )} | 310 | )} |
| 248 | > | 311 | > |
| 249 | <View className="flex-row items-start justify-between gap-3"> | 312 | <View className="items-center justify-center pt-0.5"> |
| 250 | <Text className="flex-1 font-t3-medium text-[14px] text-slate-700 dark:text-slate-200"> | 313 | <SymbolView name="terminal" size={13} tintColor="#64748b" type="monochrome" /> |
| 251 | {row.label} | ||
| 252 | </Text> | ||
| 253 | <Text className="font-t3-medium text-[11px] capitalize text-slate-500 dark:text-slate-500"> | ||
| 254 | {row.status ? row.status.replaceAll("_", " ") : "done"} | ||
| 255 | </Text> | ||
| 256 | </View> | 314 | </View> |
| 257 | {row.detail && row.detail !== row.label ? ( | 315 | <ScrollView |
| 258 | <Text className="font-sans text-xs leading-[18px] text-slate-500 dark:text-slate-500"> | 316 | horizontal |
| 259 | {row.detail} | 317 | nestedScrollEnabled |
| 318 | directionalLockEnabled | ||
| 319 | showsHorizontalScrollIndicator={false} | ||
| 320 | bounces={false} | ||
| 321 | className="flex-1" | ||
| 322 | contentContainerStyle={{ paddingRight: 12 }} | ||
| 323 | style={{ flex: 1 }} | ||
| 324 | > | ||
| 325 | <Text | ||
| 326 | className="text-[11px] leading-[18px] text-slate-500 dark:text-slate-500" | ||
| 327 | onLongPress={() => { | ||
| 328 | const copyValue = row.detail ?? normalizeCompactActivityLabel(row.summary); | ||
| 329 | props.onCopyWorkRow(row.id, copyValue); | ||
| 330 | }} | ||
| 331 | style={{ | ||
| 332 | fontFamily: "ui-monospace, SFMono-Regular, SF Mono, Menlo, Consolas, monospace", | ||
| 333 | }} | ||
| 334 | > | ||
| 335 | {row.detail | ||
| 336 | ? `${normalizeCompactActivityLabel(row.summary)} - ${row.detail}` | ||
| 337 | : normalizeCompactActivityLabel(row.summary)} | ||
| 338 | </Text> | ||
| 339 | </ScrollView> | ||
| 340 | {props.copiedRowId === row.id ? ( | ||
| 341 | <Text className="shrink-0 font-t3-medium text-[10px] uppercase tracking-[0.8px] text-emerald-600 dark:text-emerald-400"> | ||
| 342 | Copied | ||
| 260 | </Text> | 343 | </Text> |
| 261 | ) : null} | 344 | ) : null} |
| 262 | </View> | 345 | </View> |
| @@ -307,14 +390,71 @@ function useAutoScrollToLatest( · 44 unmodified lines | |||
| 307 | 390 | ||
| 308 | export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { | 391 | export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { |
| 309 | const listRef = useRef<FlashListRef<ThreadFeedEntry>>(null); | 392 | const listRef = useRef<FlashListRef<ThreadFeedEntry>>(null); |
| 393 | const copyFeedbackTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null); | ||
| 394 | const [copiedRowId, setCopiedRowId] = useState<string | null>(null); | ||
| 395 | const [expandedWorkGroups, setExpandedWorkGroups] = useState<Record<string, boolean>>({}); | ||
| 310 | const updateFollowLatest = useAutoScrollToLatest(listRef, props.threadId, props.feed); | 396 | const updateFollowLatest = useAutoScrollToLatest(listRef, props.threadId, props.feed); |
| 397 | const horizontalPadding = props.layoutVariant === "split" ? 20 : 16; | ||
| 398 | |||
| 399 | useEffect(() => { | ||
| 400 | setCopiedRowId(null); | ||
| 401 | setExpandedWorkGroups({}); | ||
| 402 | }, [props.threadId]); | ||
| 403 | |||
| 404 | useEffect(() => { | ||
| 405 | return () => { | ||
| 406 | if (copyFeedbackTimeoutRef.current) { | ||
| 407 | clearTimeout(copyFeedbackTimeoutRef.current); | ||
| 408 | } | ||
| 409 | }; | ||
| 410 | }, []); | ||
| 411 | |||
| 412 | const onCopyWorkRow = useCallback((rowId: string, value: string) => { | ||
| 413 | void Clipboard.setStringAsync(value); | ||
| 414 | void Haptics.selectionAsync(); | ||
| 415 | setCopiedRowId(rowId); | ||
| 416 | if (copyFeedbackTimeoutRef.current) { | ||
| 417 | clearTimeout(copyFeedbackTimeoutRef.current); | ||
| 418 | } | ||
| 419 | copyFeedbackTimeoutRef.current = setTimeout(() => { | ||
| 420 | setCopiedRowId((current) => (current === rowId ? null : current)); | ||
| 421 | copyFeedbackTimeoutRef.current = null; | ||
| 422 | }, 1200); | ||
| 423 | }, []); | ||
| 424 | |||
| 425 | const onToggleWorkGroup = useCallback((groupId: string) => { | ||
| 426 | setExpandedWorkGroups((current) => ({ | ||
| 427 | ...current, | ||
| 428 | [groupId]: !(current[groupId] ?? false), | ||
| 429 | })); | ||
| 430 | }, []); | ||
| 431 | const renderItem = useCallback( | ||
| 432 | (info: ListRenderItemInfo<ThreadFeedEntry>) => | ||
| 433 | renderFeedEntry(info, { | ||
| 434 | bearerToken: props.bearerToken, | ||
| 435 | copiedRowId, | ||
| 436 | httpBaseUrl: props.httpBaseUrl, | ||
| 437 | expandedWorkGroups, | ||
| 438 | onCopyWorkRow, | ||
| 439 | onToggleWorkGroup, | ||
| 440 | }), | ||
| 441 | [ | ||
| 442 | copiedRowId, | ||
| 443 | expandedWorkGroups, | ||
| 444 | onCopyWorkRow, | ||
| 445 | onToggleWorkGroup, | ||
| 446 | props.bearerToken, | ||
| 447 | props.httpBaseUrl, | ||
| 448 | ], | ||
| 449 | ); | ||
| 311 | 450 | ||
| 312 | if (props.feed.length === 0) { | 451 | if (props.feed.length === 0) { |
| 313 | return ( | 452 | return ( |
| 314 | <View | 453 | <View |
| 315 | className="flex-1 px-4" | 454 | className="flex-1" |
| 316 | style={{ | 455 | style={{ |
| 317 | minHeight: 0, | 456 | minHeight: 0, |
| 457 | paddingHorizontal: horizontalPadding, | ||
| 318 | paddingTop: props.contentTopInset ?? 18, | 458 | paddingTop: props.contentTopInset ?? 18, |
| 319 | paddingBottom: props.contentBottomInset ?? 18, | 459 | paddingBottom: props.contentBottomInset ?? 18, |
| 320 | }} | 460 | }} |
| @@ -334,18 +474,23 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { · 13 unmodified lines | |||
| 334 | key={props.threadId} | 474 | key={props.threadId} |
| 335 | style={{ flex: 1 }} | 475 | style={{ flex: 1 }} |
| 336 | data={props.feed} | 476 | data={props.feed} |
| 337 | renderItem={(info) => renderFeedEntry(info, props)} | 477 | renderItem={renderItem} |
| 338 | keyExtractor={(entry) => `${entry.type}:${entry.id}`} | 478 | keyExtractor={(entry) => `${entry.type}:${entry.id}`} |
| 339 | keyboardShouldPersistTaps="handled" | 479 | keyboardShouldPersistTaps="handled" |
| 340 | onScroll={updateFollowLatest} | 480 | onScroll={updateFollowLatest} |
| 341 | scrollEventThrottle={16} | 481 | scrollEventThrottle={16} |
| 482 | refreshControl={ | ||
| 483 | props.onRefresh ? ( | ||
| 484 | <RefreshControl refreshing={props.refreshing ?? false} onRefresh={props.onRefresh} /> | ||
| 485 | ) : undefined | ||
| 486 | } | ||
| 342 | maintainVisibleContentPosition={{ | 487 | maintainVisibleContentPosition={{ |
| 343 | autoscrollToBottomThreshold: 0.2, | 488 | autoscrollToBottomThreshold: 0.2, |
| 344 | animateAutoScrollToBottom: true, | 489 | animateAutoScrollToBottom: true, |
| 345 | startRenderingFromBottom: true, | 490 | startRenderingFromBottom: true, |
| 346 | }} | 491 | }} |
| 347 | contentContainerStyle={{ | 492 | contentContainerStyle={{ |
| 348 | paddingHorizontal: 16, | 493 | paddingHorizontal: horizontalPadding, |
| 349 | paddingTop: props.contentTopInset ?? 18, | 494 | paddingTop: props.contentTopInset ?? 18, |
| 350 | paddingBottom: props.contentBottomInset ?? 18, | 495 | paddingBottom: props.contentBottomInset ?? 18, |
| 351 | }} | 496 | }} |
| @@ -0,0 +1,1259 @@ | |||
| 1 | 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, | ||
| 13 | requiresDefaultBranchConfirmation, | ||
| 14 | resolveDefaultBranchActionDialogCopy, | ||
| 15 | resolveQuickAction, | ||
| 16 | } from "@t3tools/client-runtime"; | ||
| 17 | import { resolveAutoFeatureBranchName, sanitizeFeatureBranchName } from "@t3tools/shared/git"; | ||
| 18 | import { SymbolView } from "expo-symbols"; | ||
| 19 | import type { ComponentProps, RefObject } from "react"; | ||
| 20 | import { useCallback, useEffect, useMemo, useRef, useState } from "react"; | ||
| 21 | import { Alert, Linking, Pressable, 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 | |||
| 27 | type PendingDefaultBranchAction = { | ||
| 28 | readonly action: Extract< | ||
| 29 | GitActionRequestInput["action"], | ||
| 30 | "push" | "create_pr" | "commit_push" | "commit_push_pr" | ||
| 31 | >; | ||
| 32 | readonly branchName: string; | ||
| 33 | readonly includesCommit: boolean; | ||
| 34 | readonly commitMessage?: string; | ||
| 35 | readonly filePaths?: readonly string[]; | ||
| 36 | }; | ||
| 37 | |||
| 38 | function useSheetVisibility(sheetRef: RefObject<BottomSheetModal | null>, visible: boolean) { | ||
| 39 | useEffect(() => { | ||
| 40 | const sheet = sheetRef.current; | ||
| 41 | if (!sheet) { | ||
| 42 | return; | ||
| 43 | } | ||
| 44 | |||
| 45 | if (visible) { | ||
| 46 | sheet.present(); | ||
| 47 | return; | ||
| 48 | } | ||
| 49 | |||
| 50 | sheet.dismiss(); | ||
| 51 | }, [sheetRef, visible]); | ||
| 52 | } | ||
| 53 | |||
| 54 | function statusSummary(gitStatus: GitStatusResult | null): string { | ||
| 55 | if (!gitStatus) { | ||
| 56 | return "Loading branch status…"; | ||
| 57 | } | ||
| 58 | |||
| 59 | if (!gitStatus.isRepo) { | ||
| 60 | return "Not a git repository"; | ||
| 61 | } | ||
| 62 | |||
| 63 | const parts: string[] = []; | ||
| 64 | if (gitStatus.hasWorkingTreeChanges) { | ||
| 65 | parts.push( | ||
| 66 | `${gitStatus.workingTree.files.length} file${gitStatus.workingTree.files.length === 1 ? "" : "s"} changed`, | ||
| 67 | ); | ||
| 68 | } else { | ||
| 69 | parts.push("Clean"); | ||
| 70 | } | ||
| 71 | if (gitStatus.aheadCount > 0) { | ||
| 72 | parts.push(`${gitStatus.aheadCount} ahead`); | ||
| 73 | } | ||
| 74 | if (gitStatus.behindCount > 0) { | ||
| 75 | parts.push(`${gitStatus.behindCount} behind`); | ||
| 76 | } | ||
| 77 | if (gitStatus.pr?.state === "open") { | ||
| 78 | parts.push(`PR #${gitStatus.pr.number} open`); | ||
| 79 | } | ||
| 80 | |||
| 81 | return parts.join(" · "); | ||
| 82 | } | ||
| 83 | |||
| 84 | function deriveAvailableBranchLabel( | ||
| 85 | currentBranch: string | null, | ||
| 86 | gitStatus: GitStatusResult | null, | ||
| 87 | ): string { | ||
| 88 | return gitStatus?.branch ?? currentBranch ?? "Detached HEAD"; | ||
| 89 | } | ||
| 90 | |||
| 91 | function truncateMiddle(value: string, maxLength: number): string { | ||
| 92 | if (value.length <= maxLength) { | ||
| 93 | return value; | ||
| 94 | } | ||
| 95 | |||
| 96 | const headLength = Math.ceil((maxLength - 1) / 2); | ||
| 97 | const tailLength = Math.floor((maxLength - 1) / 2); | ||
| 98 | return `${value.slice(0, headLength)}…${value.slice(value.length - tailLength)}`; | ||
| 99 | } | ||
| 100 | |||
| 101 | function compactMenuBranchLabel(branch: string): string { | ||
| 102 | return truncateMiddle(branch, 24); | ||
| 103 | } | ||
| 104 | |||
| 105 | function compactMenuStatus(gitStatus: GitStatusResult | null): string { | ||
| 106 | if (!gitStatus) { | ||
| 107 | return "Checking status"; | ||
| 108 | } | ||
| 109 | if (!gitStatus.isRepo) { | ||
| 110 | return "Not a repo"; | ||
| 111 | } | ||
| 112 | |||
| 113 | const parts: string[] = []; | ||
| 114 | if (gitStatus.hasWorkingTreeChanges) { | ||
| 115 | parts.push(`${gitStatus.workingTree.files.length} changed`); | ||
| 116 | } else if (gitStatus.aheadCount === 0 && gitStatus.behindCount === 0) { | ||
| 117 | parts.push("Clean"); | ||
| 118 | } | ||
| 119 | if (gitStatus.aheadCount > 0) { | ||
| 120 | parts.push(`${gitStatus.aheadCount} ahead`); | ||
| 121 | } | ||
| 122 | if (gitStatus.behindCount > 0) { | ||
| 123 | parts.push(`${gitStatus.behindCount} behind`); | ||
| 124 | } | ||
| 125 | if (gitStatus.pr?.state === "open") { | ||
| 126 | parts.push(`PR #${gitStatus.pr.number}`); | ||
| 127 | } | ||
| 128 | |||
| 129 | return parts.join(" · "); | ||
| 130 | } | ||
| 131 | |||
| 132 | function triggerStatusLabel( | ||
| 133 | gitStatus: GitStatusResult | null, | ||
| 134 | gitOperationLabel: string | null, | ||
| 135 | ): string { | ||
| 136 | if (gitOperationLabel) { | ||
| 137 | return "Working"; | ||
| 138 | } | ||
| 139 | if (!gitStatus) { | ||
| 140 | return "Checking"; | ||
| 141 | } | ||
| 142 | if (!gitStatus.isRepo) { | ||
| 143 | return "No repo"; | ||
| 144 | } | ||
| 145 | if (gitStatus.hasWorkingTreeChanges) { | ||
| 146 | return `${gitStatus.workingTree.files.length} changed`; | ||
| 147 | } | ||
| 148 | if (gitStatus.behindCount > 0) { | ||
| 149 | return `${gitStatus.behindCount} behind`; | ||
| 150 | } | ||
| 151 | if (gitStatus.aheadCount > 0) { | ||
| 152 | return `${gitStatus.aheadCount} ahead`; | ||
| 153 | } | ||
| 154 | if (gitStatus.pr?.state === "open") { | ||
| 155 | return "PR open"; | ||
| 156 | } | ||
| 157 | return "Clean"; | ||
| 158 | } | ||
| 159 | |||
| 160 | function menuItemIconName( | ||
| 161 | icon: "commit" | "push" | "pr", | ||
| 162 | ): ComponentProps<typeof SymbolView>["name"] { | ||
| 163 | if (icon === "commit") return "checkmark.circle"; | ||
| 164 | if (icon === "push") return "arrow.up.circle"; | ||
| 165 | return "arrow.up.right.circle"; | ||
| 166 | } | ||
| 167 | |||
| 168 | function SheetActionButton(props: { | ||
| 169 | readonly icon: ComponentProps<typeof SymbolView>["name"]; | ||
| 170 | readonly label: string; | ||
| 171 | readonly disabled?: boolean; | ||
| 172 | readonly tone?: "primary" | "secondary" | "danger"; | ||
| 173 | readonly onPress: () => void; | ||
| 174 | }) { | ||
| 175 | const tone = props.tone ?? "secondary"; | ||
| 176 | const colors = | ||
| 177 | tone === "primary" | ||
| 178 | ? { | ||
| 179 | backgroundColor: "#171717", | ||
| 180 | borderColor: "transparent", | ||
| 181 | textColor: "#fafaf9", | ||
| 182 | } | ||
| 183 | : tone === "danger" | ||
| 184 | ? { | ||
| 185 | backgroundColor: "#fff1f2", | ||
| 186 | borderColor: "rgba(225,29,72,0.12)", | ||
| 187 | textColor: "#be123c", | ||
| 188 | } | ||
| 189 | : { | ||
| 190 | backgroundColor: "#ffffff", | ||
| 191 | borderColor: "rgba(23,23,23,0.08)", | ||
| 192 | textColor: "#171717", | ||
| 193 | }; | ||
| 194 | |||
| 195 | return ( | ||
| 196 | <Pressable | ||
| 197 | className="min-h-[48px] flex-1 flex-row items-center justify-center gap-2 rounded-[18px] px-4 py-3" | ||
| 198 | disabled={props.disabled} | ||
| 199 | style={{ | ||
| 200 | backgroundColor: colors.backgroundColor, | ||
| 201 | borderWidth: tone === "primary" ? 0 : 1, | ||
| 202 | borderColor: colors.borderColor, | ||
| 203 | opacity: props.disabled ? 0.45 : 1, | ||
| 204 | }} | ||
| 205 | onPress={props.onPress} | ||
| 206 | > | ||
| 207 | <SymbolView name={props.icon} size={16} tintColor={colors.textColor} type="monochrome" /> | ||
| 208 | <Text | ||
| 209 | className="text-[12px] font-t3-bold uppercase" | ||
| 210 | style={{ color: colors.textColor, letterSpacing: 0.9 }} | ||
| 211 | > | ||
| 212 | {props.label} | ||
| 213 | </Text> | ||
| 214 | </Pressable> | ||
| 215 | ); | ||
| 216 | } | ||
| 217 | |||
| 218 | function MetaCard(props: { readonly label: string; readonly value: string }) { | ||
| 219 | return ( | ||
| 220 | <View | ||
| 221 | className="rounded-[18px] border px-4 py-3" | ||
| 222 | style={{ backgroundColor: "#ffffff", borderColor: "rgba(23,23,23,0.08)" }} | ||
| 223 | > | ||
| 224 | <Text | ||
| 225 | className="text-[11px] font-t3-bold uppercase" | ||
| 226 | style={{ color: "#78716c", letterSpacing: 0.9 }} | ||
| 227 | > | ||
| 228 | {props.label} | ||
| 229 | </Text> | ||
| 230 | <Text | ||
| 231 | selectable | ||
| 232 | className="text-[13px] font-medium" | ||
| 233 | numberOfLines={1} | ||
| 234 | style={{ color: "#292524" }} | ||
| 235 | > | ||
| 236 | {props.value} | ||
| 237 | </Text> | ||
| 238 | </View> | ||
| 239 | ); | ||
| 240 | } | ||
| 241 | |||
| 242 | function SheetListRow(props: { | ||
| 243 | readonly icon: ComponentProps<typeof SymbolView>["name"]; | ||
| 244 | readonly title: string; | ||
| 245 | readonly subtitle?: string | null; | ||
| 246 | readonly disabled?: boolean; | ||
| 247 | readonly onPress: () => void; | ||
| 248 | }) { | ||
| 249 | return ( | ||
| 250 | <Pressable | ||
| 251 | className="flex-row items-center gap-3 px-1 py-3" | ||
| 252 | disabled={props.disabled} | ||
| 253 | style={{ opacity: props.disabled ? 0.45 : 1 }} | ||
| 254 | onPress={props.onPress} | ||
| 255 | > | ||
| 256 | <View | ||
| 257 | className="h-9 w-9 items-center justify-center rounded-full" | ||
| 258 | style={{ backgroundColor: "rgba(23,23,23,0.05)" }} | ||
| 259 | > | ||
| 260 | <SymbolView name={props.icon} size={16} tintColor="#171717" type="monochrome" /> | ||
| 261 | </View> | ||
| 262 | <View className="flex-1 gap-0.5"> | ||
| 263 | <Text className="text-[16px] font-t3-bold" style={{ color: "#171717" }}> | ||
| 264 | {props.title} | ||
| 265 | </Text> | ||
| 266 | {props.subtitle ? ( | ||
| 267 | <Text className="text-[12px] leading-[17px]" style={{ color: "#78716c" }}> | ||
| 268 | {props.subtitle} | ||
| 269 | </Text> | ||
| 270 | ) : null} | ||
| 271 | </View> | ||
| 272 | <SymbolView name="chevron.right" size={13} tintColor="#a8a29e" type="monochrome" /> | ||
| 273 | </Pressable> | ||
| 274 | ); | ||
| 275 | } | ||
| 276 | |||
| 277 | export function ThreadGitControls(props: { | ||
| 278 | readonly currentBranch: string | null; | ||
| 279 | readonly currentWorktreePath: string | null; | ||
| 280 | readonly gitStatus: GitStatusResult | null; | ||
| 281 | readonly gitOperationLabel: string | null; | ||
| 282 | readonly onRefreshStatus: (options?: { readonly quiet?: boolean }) => Promise<void>; | ||
| 283 | readonly onListBranches: () => Promise<ReadonlyArray<GitBranch>>; | ||
| 284 | readonly onCheckoutBranch: (branch: string) => Promise<void>; | ||
| 285 | readonly onCreateBranch: (branch: string) => Promise<void>; | ||
| 286 | readonly onCreateWorktree: (input: { | ||
| 287 | readonly baseBranch: string; | ||
| 288 | readonly newBranch: string; | ||
| 289 | }) => Promise<void>; | ||
| 290 | readonly onPull: () => Promise<void>; | ||
| 291 | readonly onRunAction: (input: GitActionRequestInput) => Promise<GitRunStackedActionResult | null>; | ||
| 292 | }) { | ||
| 293 | const insets = useSafeAreaInsets(); | ||
| 294 | const { | ||
| 295 | currentWorktreePath, | ||
| 296 | gitOperationLabel, | ||
| 297 | gitStatus, | ||
| 298 | onCheckoutBranch, | ||
| 299 | onCreateBranch, | ||
| 300 | onCreateWorktree, | ||
| 301 | onListBranches, | ||
| 302 | onPull, | ||
| 303 | onRefreshStatus, | ||
| 304 | onRunAction, | ||
| 305 | } = props; | ||
| 306 | const gitSheetRef = useRef<BottomSheetModal>(null); | ||
| 307 | const branchSheetRef = useRef<BottomSheetModal>(null); | ||
| 308 | const commitSheetRef = useRef<BottomSheetModal>(null); | ||
| 309 | const defaultBranchSheetRef = useRef<BottomSheetModal>(null); | ||
| 310 | const [gitSheetVisible, setGitSheetVisible] = useState(false); | ||
| 311 | const [branchSheetVisible, setBranchSheetVisible] = useState(false); | ||
| 312 | const [commitSheetVisible, setCommitSheetVisible] = useState(false); | ||
| 313 | const [defaultBranchSheetVisible, setDefaultBranchSheetVisible] = useState(false); | ||
| 314 | const [isEditingFiles, setIsEditingFiles] = useState(false); | ||
| 315 | const [availableBranches, setAvailableBranches] = useState<ReadonlyArray<GitBranch>>([]); | ||
| 316 | const [branchesLoading, setBranchesLoading] = useState(false); | ||
| 317 | const [newBranchName, setNewBranchName] = useState(""); | ||
| 318 | const [worktreeBaseBranch, setWorktreeBaseBranch] = useState(""); | ||
| 319 | const [worktreeBranchName, setWorktreeBranchName] = useState(""); | ||
| 320 | const [dialogCommitMessage, setDialogCommitMessage] = useState(""); | ||
| 321 | const [excludedFiles, setExcludedFiles] = useState<ReadonlySet<string>>(new Set()); | ||
| 322 | const [pendingDefaultBranchAction, setPendingDefaultBranchAction] = | ||
| 323 | useState<PendingDefaultBranchAction | null>(null); | ||
| 324 | const currentBranchLabel = deriveAvailableBranchLabel(props.currentBranch, gitStatus); | ||
| 325 | const busy = gitOperationLabel !== null; | ||
| 326 | const isRepo = gitStatus?.isRepo ?? true; | ||
| 327 | const hasOriginRemote = gitStatus?.hasOriginRemote ?? false; | ||
| 328 | const isDefaultBranch = gitStatus?.isDefaultBranch ?? false; | ||
| 329 | const quickAction = useMemo( | ||
| 330 | () => | ||
| 331 | isRepo | ||
| 332 | ? resolveQuickAction(gitStatus, busy, isDefaultBranch, hasOriginRemote) | ||
| 333 | : { | ||
| 334 | label: "Git unavailable", | ||
| 335 | disabled: true, | ||
| 336 | kind: "show_hint" as const, | ||
| 337 | hint: "This workspace is not a git repository.", | ||
| 338 | }, | ||
| 339 | [busy, gitStatus, hasOriginRemote, isDefaultBranch, isRepo], | ||
| 340 | ); | ||
| 341 | const menuItems = useMemo( | ||
| 342 | () => (isRepo ? buildMenuItems(gitStatus, busy, hasOriginRemote) : []), | ||
| 343 | [busy, gitStatus, hasOriginRemote, isRepo], | ||
| 344 | ); | ||
| 345 | const quickActionHint = quickAction.disabled | ||
| 346 | ? (quickAction.hint ?? "This action is unavailable.") | ||
| 347 | : null; | ||
| 348 | const triggerStatus = triggerStatusLabel(gitStatus, gitOperationLabel); | ||
| 349 | const allFiles = gitStatus?.workingTree.files ?? []; | ||
| 350 | const selectedFiles = allFiles.filter((file) => !excludedFiles.has(file.path)); | ||
| 351 | const allSelected = excludedFiles.size === 0; | ||
| 352 | const noneSelected = selectedFiles.length === 0; | ||
| 353 | const selectedInsertions = selectedFiles.reduce((sum, file) => sum + file.insertions, 0); | ||
| 354 | const selectedDeletions = selectedFiles.reduce((sum, file) => sum + file.deletions, 0); | ||
| 355 | const selectedFilePreview = selectedFiles.slice(0, 3); | ||
| 356 | const renderBackdrop = useCallback( | ||
| 357 | (backdropProps: ComponentProps<typeof BottomSheetBackdrop>) => ( | ||
| 358 | <BottomSheetBackdrop | ||
| 359 | {...backdropProps} | ||
| 360 | appearsOnIndex={0} | ||
| 361 | disappearsOnIndex={-1} | ||
| 362 | opacity={0.18} | ||
| 363 | pressBehavior="close" | ||
| 364 | /> | ||
| 365 | ), | ||
| 366 | [], | ||
| 367 | ); | ||
| 368 | const gitSnapPoints = useMemo<(string | number)[]>(() => ["72%"], []); | ||
| 369 | const branchSnapPoints = useMemo<(string | number)[]>(() => ["86%"], []); | ||
| 370 | const commitSnapPoints = useMemo<(string | number)[]>(() => ["88%"], []); | ||
| 371 | const defaultBranchSnapPoints = useMemo<(string | number)[]>(() => ["40%"], []); | ||
| 372 | const pendingDefaultBranchActionCopy = pendingDefaultBranchAction | ||
| 373 | ? resolveDefaultBranchActionDialogCopy({ | ||
| 374 | action: pendingDefaultBranchAction.action, | ||
| 375 | branchName: pendingDefaultBranchAction.branchName, | ||
| 376 | includesCommit: pendingDefaultBranchAction.includesCommit, | ||
| 377 | }) | ||
| 378 | : null; | ||
| 379 | const disabledExistingBranches = useMemo( | ||
| 380 | () => | ||
| 381 | new Set( | ||
| 382 | availableBranches | ||
| 383 | .filter( | ||
| 384 | (branch) => branch.worktreePath !== null && branch.worktreePath !== currentWorktreePath, | ||
| 385 | ) | ||
| 386 | .map((branch) => branch.name), | ||
| 387 | ), | ||
| 388 | [availableBranches, currentWorktreePath], | ||
| 389 | ); | ||
| 390 | const sheetMenuItems = useMemo( | ||
| 391 | () => | ||
| 392 | menuItems.map((item) => ({ | ||
| 393 | item, | ||
| 394 | disabledReason: getGitActionDisabledReason({ | ||
| 395 | item, | ||
| 396 | gitStatus, | ||
| 397 | isBusy: busy, | ||
| 398 | hasOriginRemote, | ||
| 399 | }), | ||
| 400 | })), | ||
| 401 | [busy, gitStatus, hasOriginRemote, menuItems], | ||
| 402 | ); | ||
| 403 | const nativeMenuActions = useMemo< | ||
| 404 | Array<{ | ||
| 405 | id: string; | ||
| 406 | title: string; | ||
| 407 | subtitle?: string; | ||
| 408 | state?: "off"; | ||
| 409 | image?: string; | ||
| 410 | preferredElementSize?: "small"; | ||
| 411 | attributes?: { | ||
| 412 | disabled?: boolean; | ||
| 413 | }; | ||
| 414 | }> | ||
| 415 | >( | ||
| 416 | () => [ | ||
| 417 | { | ||
| 418 | id: "branch", | ||
| 419 | title: compactMenuBranchLabel(currentBranchLabel), | ||
| 420 | subtitle: compactMenuStatus(gitStatus), | ||
| 421 | image: "point.topleft.down.curvedto.point.bottomright.up", | ||
| 422 | state: "off", | ||
| 423 | preferredElementSize: "small", | ||
| 424 | attributes: { disabled: true }, | ||
| 425 | }, | ||
| 426 | { | ||
| 427 | id: "quick", | ||
| 428 | title: quickAction.label, | ||
| 429 | image: | ||
| 430 | quickAction.kind === "run_pull" | ||
| 431 | ? "arrow.down.circle" | ||
| 432 | : quickAction.kind === "open_pr" | ||
| 433 | ? "arrow.up.right.circle" | ||
| 434 | : quickAction.action === "commit" | ||
| 435 | ? "checkmark.circle" | ||
| 436 | : quickAction.action === "push" || quickAction.action === "commit_push" | ||
| 437 | ? "arrow.up.circle" | ||
| 438 | : "arrow.up.right.circle", | ||
| 439 | ...(quickActionHint ? { subtitle: quickActionHint } : {}), | ||
| 440 | preferredElementSize: "small", | ||
| 441 | ...(quickAction.disabled ? { attributes: { disabled: true } } : {}), | ||
| 442 | }, | ||
| 443 | { | ||
| 444 | id: "more", | ||
| 445 | title: "More", | ||
| 446 | image: "ellipsis.circle", | ||
| 447 | subtitle: "Commit, files, branches", | ||
| 448 | preferredElementSize: "small", | ||
| 449 | }, | ||
| 450 | ], | ||
| 451 | [ | ||
| 452 | currentBranchLabel, | ||
| 453 | gitStatus, | ||
| 454 | quickAction.action, | ||
| 455 | quickAction.disabled, | ||
| 456 | quickAction.kind, | ||
| 457 | quickAction.label, | ||
| 458 | quickActionHint, | ||
| 459 | ], | ||
| 460 | ); | ||
| 461 | |||
| 462 | useSheetVisibility(gitSheetRef, gitSheetVisible); | ||
| 463 | useSheetVisibility(branchSheetRef, branchSheetVisible); | ||
| 464 | useSheetVisibility(commitSheetRef, commitSheetVisible); | ||
| 465 | useSheetVisibility(defaultBranchSheetRef, defaultBranchSheetVisible); | ||
| 466 | |||
| 467 | useEffect(() => { | ||
| 468 | if (!gitSheetVisible) { | ||
| 469 | return; | ||
| 470 | } | ||
| 471 | void onRefreshStatus({ quiet: true }); | ||
| 472 | }, [gitSheetVisible, onRefreshStatus]); | ||
| 473 | |||
| 474 | useEffect(() => { | ||
| 475 | if (!branchSheetVisible) { | ||
| 476 | return; | ||
| 477 | } | ||
| 478 | |||
| 479 | setWorktreeBaseBranch(currentBranchLabel === "Detached HEAD" ? "main" : currentBranchLabel); | ||
| 480 | setBranchesLoading(true); | ||
| 481 | void onListBranches() | ||
| 482 | .then((branches) => setAvailableBranches(branches)) | ||
| 483 | .finally(() => setBranchesLoading(false)); | ||
| 484 | }, [branchSheetVisible, currentBranchLabel, onListBranches]); | ||
| 485 | |||
| 486 | const closeTransientSheets = useCallback(() => { | ||
| 487 | setGitSheetVisible(false); | ||
| 488 | setCommitSheetVisible(false); | ||
| 489 | setDefaultBranchSheetVisible(false); | ||
| 490 | }, []); | ||
| 491 | |||
| 492 | const openCommitSheet = useCallback(() => { | ||
| 493 | setExcludedFiles(new Set()); | ||
| 494 | setIsEditingFiles(false); | ||
| 495 | setDialogCommitMessage(""); | ||
| 496 | setCommitSheetVisible(true); | ||
| 497 | }, []); | ||
| 498 | |||
| 499 | const openExistingPr = useCallback(async () => { | ||
| 500 | const prUrl = gitStatus?.pr?.state === "open" ? gitStatus.pr.url : null; | ||
| 501 | if (!prUrl) { | ||
| 502 | Alert.alert("No open PR", "This branch does not have an open pull request."); | ||
| 503 | return; | ||
| 504 | } | ||
| 505 | try { | ||
| 506 | await Linking.openURL(prUrl); | ||
| 507 | } catch (error) { | ||
| 508 | Alert.alert( | ||
| 509 | "Unable to open PR", | ||
| 510 | error instanceof Error ? error.message : "An error occurred.", | ||
| 511 | ); | ||
| 512 | } | ||
| 513 | }, [gitStatus]); | ||
| 514 | |||
| 515 | const runActionWithPrompt = useCallback( | ||
| 516 | async (input: GitActionRequestInput) => { | ||
| 517 | const confirmableAction = | ||
| 518 | input.action === "push" || | ||
| 519 | input.action === "create_pr" || | ||
| 520 | input.action === "commit_push" || | ||
| 521 | input.action === "commit_push_pr" | ||
| 522 | ? input.action | ||
| 523 | : null; | ||
| 524 | const branchName = gitStatus?.branch; | ||
| 525 | if ( | ||
| 526 | branchName && | ||
| 527 | confirmableAction && | ||
| 528 | !input.featureBranch && | ||
| 529 | requiresDefaultBranchConfirmation(input.action, isDefaultBranch) | ||
| 530 | ) { | ||
| 531 | setPendingDefaultBranchAction({ | ||
| 532 | action: confirmableAction, | ||
| 533 | branchName, | ||
| 534 | includesCommit: input.action === "commit_push" || input.action === "commit_push_pr", | ||
| 535 | ...(input.commitMessage ? { commitMessage: input.commitMessage } : {}), | ||
| 536 | ...(input.filePaths ? { filePaths: input.filePaths } : {}), | ||
| 537 | }); | ||
| 538 | setDefaultBranchSheetVisible(true); | ||
| 539 | return; | ||
| 540 | } | ||
| 541 | |||
| 542 | closeTransientSheets(); | ||
| 543 | await onRunAction(input); | ||
| 544 | }, | ||
| 545 | [closeTransientSheets, gitStatus, isDefaultBranch, onRunAction], | ||
| 546 | ); | ||
| 547 | |||
| 548 | const continuePendingDefaultBranchAction = useCallback(async () => { | ||
| 549 | if (!pendingDefaultBranchAction) { | ||
| 550 | return; | ||
| 551 | } | ||
| 552 | const action = pendingDefaultBranchAction; | ||
| 553 | setPendingDefaultBranchAction(null); | ||
| 554 | setDefaultBranchSheetVisible(false); | ||
| 555 | await onRunAction({ | ||
| 556 | action: action.action, | ||
| 557 | ...(action.commitMessage ? { commitMessage: action.commitMessage } : {}), | ||
| 558 | ...(action.filePaths ? { filePaths: action.filePaths } : {}), | ||
| 559 | }); | ||
| 560 | }, [onRunAction, pendingDefaultBranchAction]); | ||
| 561 | |||
| 562 | const movePendingActionToFeatureBranch = useCallback(async () => { | ||
| 563 | if (!pendingDefaultBranchAction) { | ||
| 564 | return; | ||
| 565 | } | ||
| 566 | |||
| 567 | const action = pendingDefaultBranchAction; | ||
| 568 | setPendingDefaultBranchAction(null); | ||
| 569 | setDefaultBranchSheetVisible(false); | ||
| 570 | |||
| 571 | if (action.includesCommit) { | ||
| 572 | await onRunAction({ | ||
| 573 | action: action.action, | ||
| 574 | featureBranch: true, | ||
| 575 | ...(action.commitMessage ? { commitMessage: action.commitMessage } : {}), | ||
| 576 | ...(action.filePaths ? { filePaths: action.filePaths } : {}), | ||
| 577 | }); | ||
| 578 | return; | ||
| 579 | } | ||
| 580 | |||
| 581 | const branches = await onListBranches(); | ||
| 582 | const branchName = resolveAutoFeatureBranchName( | ||
| 583 | branches.filter((branch) => !branch.isRemote).map((branch) => branch.name), | ||
| 584 | ); | ||
| 585 | await onCreateBranch(branchName); | ||
| 586 | await onRunAction({ action: action.action }); | ||
| 587 | }, [onCreateBranch, onListBranches, onRunAction, pendingDefaultBranchAction]); | ||
| 588 | |||
| 589 | const runQuickAction = useCallback(async () => { | ||
| 590 | if (quickAction.kind === "open_pr") { | ||
| 591 | await openExistingPr(); | ||
| 592 | return; | ||
| 593 | } | ||
| 594 | if (quickAction.kind === "run_pull") { | ||
| 595 | await onPull(); | ||
| 596 | return; | ||
| 597 | } | ||
| 598 | if (quickAction.kind === "run_action" && quickAction.action) { | ||
| 599 | await runActionWithPrompt({ action: quickAction.action }); | ||
| 600 | } | ||
| 601 | }, [onPull, openExistingPr, quickAction, runActionWithPrompt]); | ||
| 602 | |||
| 603 | const runCommitAction = useCallback( | ||
| 604 | async (featureBranch: boolean) => { | ||
| 605 | const commitMessage = dialogCommitMessage.trim(); | ||
| 606 | setGitSheetVisible(false); | ||
| 607 | setCommitSheetVisible(false); | ||
| 608 | await onRunAction({ | ||
| 609 | action: "commit", | ||
| 610 | featureBranch, | ||
| 611 | ...(commitMessage ? { commitMessage } : {}), | ||
| 612 | ...(!allSelected ? { filePaths: selectedFiles.map((file) => file.path) } : {}), | ||
| 613 | }); | ||
| 614 | setDialogCommitMessage(""); | ||
| 615 | setExcludedFiles(new Set()); | ||
| 616 | }, | ||
| 617 | [allSelected, dialogCommitMessage, onRunAction, selectedFiles], | ||
| 618 | ); | ||
| 619 | |||
| 620 | const onPressMenuItem = useCallback( | ||
| 621 | async (item: (typeof menuItems)[number]) => { | ||
| 622 | if (item.disabled) { | ||
| 623 | return; | ||
| 624 | } | ||
| 625 | if (item.kind === "open_pr") { | ||
| 626 | await openExistingPr(); | ||
| 627 | return; | ||
| 628 | } | ||
| 629 | if (item.dialogAction === "commit") { | ||
| 630 | openCommitSheet(); | ||
| 631 | return; | ||
| 632 | } | ||
| 633 | if (item.dialogAction === "push") { | ||
| 634 | await runActionWithPrompt({ action: "push" }); | ||
| 635 | return; | ||
| 636 | } | ||
| 637 | if (item.dialogAction === "create_pr") { | ||
| 638 | await runActionWithPrompt({ action: "create_pr" }); | ||
| 639 | } | ||
| 640 | }, | ||
| 641 | [openCommitSheet, openExistingPr, runActionWithPrompt], | ||
| 642 | ); | ||
| 643 | |||
| 644 | const onPressNativeMenuAction = useCallback( | ||
| 645 | ({ nativeEvent }: { nativeEvent: { event: string } }) => { | ||
| 646 | if (nativeEvent.event === "quick") { | ||
| 647 | void runQuickAction(); | ||
| 648 | return; | ||
| 649 | } | ||
| 650 | if (nativeEvent.event === "more") { | ||
| 651 | setGitSheetVisible(true); | ||
| 652 | } | ||
| 653 | }, | ||
| 654 | [runQuickAction], | ||
| 655 | ); | ||
| 656 | |||
| 657 | return ( | ||
| 658 | <> | ||
| 659 | <MenuView actions={nativeMenuActions} onPressAction={onPressNativeMenuAction}> | ||
| 660 | <Pressable | ||
| 661 | className="flex-row items-center gap-2 rounded-full px-3 py-2" | ||
| 662 | style={{ backgroundColor: "rgba(23,23,23,0.05)" }} | ||
| 663 | > | ||
| 664 | <SymbolView | ||
| 665 | name="point.topleft.down.curvedto.point.bottomright.up" | ||
| 666 | size={14} | ||
| 667 | tintColor="#171717" | ||
| 668 | type="monochrome" | ||
| 669 | /> | ||
| 670 | <View className="shrink"> | ||
| 671 | <Text | ||
| 672 | className="text-[11px] font-t3-bold uppercase" | ||
| 673 | numberOfLines={1} | ||
| 674 | style={{ color: "#78716c", letterSpacing: 0.8 }} | ||
| 675 | > | ||
| 676 | Git | ||
| 677 | </Text> | ||
| 678 | <Text | ||
| 679 | className="text-[12px] font-t3-bold" | ||
| 680 | numberOfLines={1} | ||
| 681 | style={{ color: "#171717" }} | ||
| 682 | > | ||
| 683 | {triggerStatus} | ||
| 684 | </Text> | ||
| 685 | </View> | ||
| 686 | <SymbolView | ||
| 687 | name="chevron.down" | ||
| 688 | size={11} | ||
| 689 | tintColor="#78716c" | ||
| 690 | type="monochrome" | ||
| 691 | weight="medium" | ||
| 692 | /> | ||
| 693 | </Pressable> | ||
| 694 | </MenuView> | ||
| 695 | |||
| 696 | <BottomSheetModal | ||
| 697 | ref={gitSheetRef} | ||
| 698 | index={0} | ||
| 699 | snapPoints={gitSnapPoints} | ||
| 700 | enableDynamicSizing={false} | ||
| 701 | enablePanDownToClose | ||
| 702 | backdropComponent={renderBackdrop} | ||
| 703 | onDismiss={() => setGitSheetVisible(false)} | ||
| 704 | backgroundStyle={{ backgroundColor: "rgba(247,247,245,0.98)" }} | ||
| 705 | handleIndicatorStyle={{ backgroundColor: "rgba(120,113,108,0.32)" }} | ||
| 706 | > | ||
| 707 | <BottomSheetView style={{ flex: 1 }}> | ||
| 708 | <BottomSheetScrollView | ||
| 709 | showsVerticalScrollIndicator={false} | ||
| 710 | contentContainerStyle={{ | ||
| 711 | paddingHorizontal: 20, | ||
| 712 | paddingTop: 8, | ||
| 713 | paddingBottom: Math.max(insets.bottom, 18) + 8, | ||
| 714 | gap: 14, | ||
| 715 | }} | ||
| 716 | > | ||
| 717 | <View className="flex-row items-start justify-between gap-3"> | ||
| 718 | <View className="flex-1 gap-1"> | ||
| 719 | <Text | ||
| 720 | className="text-[11px] font-t3-bold uppercase" | ||
| 721 | style={{ color: "#78716c", letterSpacing: 1 }} | ||
| 722 | > | ||
| 723 | Branch | ||
| 724 | </Text> | ||
| 725 | <Text className="text-[24px] font-t3-bold" style={{ color: "#171717" }}> | ||
| 726 | {currentBranchLabel} | ||
| 727 | </Text> | ||
| 728 | <Text | ||
| 729 | className="text-[13px] font-medium leading-[19px]" | ||
| 730 | style={{ color: "#57534e" }} | ||
| 731 | > | ||
| 732 | {statusSummary(gitStatus)} | ||
| 733 | </Text> | ||
| 734 | </View> | ||
| 735 | <Pressable | ||
| 736 | className="rounded-full p-2" | ||
| 737 | disabled={busy} | ||
| 738 | style={{ backgroundColor: "rgba(23,23,23,0.05)", opacity: busy ? 0.45 : 1 }} | ||
| 739 | onPress={() => void onRefreshStatus()} | ||
| 740 | > | ||
| 741 | <SymbolView | ||
| 742 | name="arrow.clockwise" | ||
| 743 | size={16} | ||
| 744 | tintColor="#171717" | ||
| 745 | type="monochrome" | ||
| 746 | weight="medium" | ||
| 747 | /> | ||
| 748 | </Pressable> | ||
| 749 | </View> | ||
| 750 | |||
| 751 | <View className="gap-3"> | ||
| 752 | <View | ||
| 753 | className="overflow-hidden rounded-[22px] border px-4 py-1" | ||
| 754 | style={{ backgroundColor: "#ffffff", borderColor: "rgba(23,23,23,0.08)" }} | ||
| 755 | > | ||
| 756 | {sheetMenuItems.map(({ item, disabledReason }, index) => ( | ||
| 757 | <View key={`${item.id}-${item.label}`}> | ||
| 758 | {index > 0 ? ( | ||
| 759 | <View | ||
| 760 | className="ml-12 h-px" | ||
| 761 | style={{ backgroundColor: "rgba(23,23,23,0.08)" }} | ||
| 762 | /> | ||
| 763 | ) : null} | ||
| 764 | <SheetListRow | ||
| 765 | icon={menuItemIconName(item.icon)} | ||
| 766 | title={item.label} | ||
| 767 | subtitle={disabledReason} | ||
| 768 | disabled={item.disabled} | ||
| 769 | onPress={() => { | ||
| 770 | void onPressMenuItem(item); | ||
| 771 | }} | ||
| 772 | /> | ||
| 773 | </View> | ||
| 774 | ))} | ||
| 775 | {(gitStatus?.behindCount ?? 0) > 0 ? ( | ||
| 776 | <> | ||
| 777 | <View | ||
| 778 | className="ml-12 h-px" | ||
| 779 | style={{ backgroundColor: "rgba(23,23,23,0.08)" }} | ||
| 780 | /> | ||
| 781 | <SheetListRow | ||
| 782 | icon="arrow.down.circle" | ||
| 783 | title="Pull latest" | ||
| 784 | subtitle="Sync this branch with upstream" | ||
| 785 | disabled={busy || !isRepo} | ||
| 786 | onPress={() => { | ||
| 787 | void onPull(); | ||
| 788 | }} | ||
| 789 | /> | ||
| 790 | </> | ||
| 791 | ) : null} | ||
| 792 | <View className="ml-12 h-px" style={{ backgroundColor: "rgba(23,23,23,0.08)" }} /> | ||
| 793 | <SheetListRow | ||
| 794 | icon="point.topleft.down.curvedto.point.bottomright.up" | ||
| 795 | title="Branches & worktrees" | ||
| 796 | subtitle="Switch branch, create branch, or move to a worktree" | ||
| 797 | disabled={busy || !isRepo} | ||
| 798 | onPress={() => setBranchSheetVisible(true)} | ||
| 799 | /> | ||
| 800 | </View> | ||
| 801 | </View> | ||
| 802 | |||
| 803 | {currentWorktreePath ? <MetaCard label="Worktree" value={currentWorktreePath} /> : null} | ||
| 804 | |||
| 805 | {gitOperationLabel ? ( | ||
| 806 | <View className="self-start"> | ||
| 807 | <StatusPill | ||
| 808 | label={gitOperationLabel} | ||
| 809 | pillClassName="bg-slate-900/8" | ||
| 810 | textClassName="text-slate-700" | ||
| 811 | size="compact" | ||
| 812 | /> | ||
| 813 | </View> | ||
| 814 | ) : null} | ||
| 815 | </BottomSheetScrollView> | ||
| 816 | </BottomSheetView> | ||
| 817 | </BottomSheetModal> | ||
| 818 | |||
| 819 | <BottomSheetModal | ||
| 820 | ref={commitSheetRef} | ||
| 821 | stackBehavior="push" | ||
| 822 | index={0} | ||
| 823 | snapPoints={commitSnapPoints} | ||
| 824 | enableDynamicSizing={false} | ||
| 825 | enablePanDownToClose | ||
| 826 | backdropComponent={renderBackdrop} | ||
| 827 | onDismiss={() => setCommitSheetVisible(false)} | ||
| 828 | backgroundStyle={{ backgroundColor: "rgba(247,247,245,0.98)" }} | ||
| 829 | handleIndicatorStyle={{ backgroundColor: "rgba(120,113,108,0.32)" }} | ||
| 830 | > | ||
| 831 | <BottomSheetView style={{ flex: 1 }}> | ||
| 832 | <BottomSheetScrollView | ||
| 833 | keyboardShouldPersistTaps="handled" | ||
| 834 | showsVerticalScrollIndicator={false} | ||
| 835 | contentContainerStyle={{ | ||
| 836 | paddingHorizontal: 20, | ||
| 837 | paddingTop: 8, | ||
| 838 | paddingBottom: Math.max(insets.bottom, 18) + 8, | ||
| 839 | gap: 16, | ||
| 840 | }} | ||
| 841 | > | ||
| 842 | <View className="gap-1"> | ||
| 843 | <Text className="text-[24px] font-t3-bold" style={{ color: "#171717" }}> | ||
| 844 | Commit changes | ||
| 845 | </Text> | ||
| 846 | <Text className="text-[13px] font-medium leading-[19px]" style={{ color: "#57534e" }}> | ||
| 847 | Review the file set, optionally write a message, then commit here or on a new | ||
| 848 | feature branch. | ||
| 849 | </Text> | ||
| 850 | </View> | ||
| 851 | |||
| 852 | <View | ||
| 853 | className="gap-3 rounded-[22px] border px-4 py-4" | ||
| 854 | style={{ backgroundColor: "#ffffff", borderColor: "rgba(23,23,23,0.08)" }} | ||
| 855 | > | ||
| 856 | <View className="flex-row items-center justify-between gap-3"> | ||
| 857 | <Text className="text-[13px] font-medium" style={{ color: "#78716c" }}> | ||
| 858 | Branch | ||
| 859 | </Text> | ||
| 860 | <Text className="text-[15px] font-t3-bold" style={{ color: "#171717" }}> | ||
| 861 | {gitStatus?.branch ?? "(detached HEAD)"} | ||
| 862 | </Text> | ||
| 863 | </View> | ||
| 864 | {isDefaultBranch ? ( | ||
| 865 | <Text className="text-[12px] leading-[18px]" style={{ color: "#b45309" }}> | ||
| 866 | Warning: this is the default branch. | ||
| 867 | </Text> | ||
| 868 | ) : null} | ||
| 869 | </View> | ||
| 870 | |||
| 871 | <View | ||
| 872 | className="gap-3 rounded-[22px] border px-4 py-4" | ||
| 873 | style={{ backgroundColor: "#ffffff", borderColor: "rgba(23,23,23,0.08)" }} | ||
| 874 | > | ||
| 875 | <View className="flex-row items-center justify-between gap-3"> | ||
| 876 | <View className="gap-1"> | ||
| 877 | <Text className="text-[16px] font-t3-bold" style={{ color: "#171717" }}> | ||
| 878 | Files | ||
| 879 | </Text> | ||
| 880 | <Text className="text-[12px] leading-[18px]" style={{ color: "#78716c" }}> | ||
| 881 | {selectedFiles.length} selected · +{selectedInsertions} / -{selectedDeletions} | ||
| 882 | </Text> | ||
| 883 | </View> | ||
| 884 | <View className="flex-row items-center gap-2"> | ||
| 885 | {!allSelected && isEditingFiles ? ( | ||
| 886 | <Pressable | ||
| 887 | className="rounded-full px-3 py-2" | ||
| 888 | style={{ backgroundColor: "rgba(23,23,23,0.05)" }} | ||
| 889 | onPress={() => setExcludedFiles(new Set())} | ||
| 890 | > | ||
| 891 | <Text | ||
| 892 | className="text-[11px] font-t3-bold uppercase" | ||
| 893 | style={{ color: "#171717" }} | ||
| 894 | > | ||
| 895 | Reset | ||
| 896 | </Text> | ||
| 897 | </Pressable> | ||
| 898 | ) : null} | ||
| 899 | <Pressable | ||
| 900 | className="rounded-full px-3 py-2" | ||
| 901 | style={{ backgroundColor: "rgba(23,23,23,0.05)" }} | ||
| 902 | onPress={() => setIsEditingFiles((current) => !current)} | ||
| 903 | > | ||
| 904 | <Text | ||
| 905 | className="text-[11px] font-t3-bold uppercase" | ||
| 906 | style={{ color: "#171717" }} | ||
| 907 | > | ||
| 908 | {isEditingFiles ? "Done" : "Edit"} | ||
| 909 | </Text> | ||
| 910 | </Pressable> | ||
| 911 | </View> | ||
| 912 | </View> | ||
| 913 | |||
| 914 | {allFiles.length === 0 ? ( | ||
| 915 | <Text className="text-[13px] leading-[19px]" style={{ color: "#57534e" }}> | ||
| 916 | No changed files are available to commit. | ||
| 917 | </Text> | ||
| 918 | ) : !isEditingFiles ? ( | ||
| 919 | <View className="gap-2"> | ||
| 920 | {selectedFilePreview.map((file) => ( | ||
| 921 | <View key={file.path} className="flex-row items-center justify-between gap-3"> | ||
| 922 | <Text | ||
| 923 | className="flex-1 text-[13px] font-medium" | ||
| 924 | numberOfLines={1} | ||
| 925 | style={{ color: "#292524" }} | ||
| 926 | > | ||
| 927 | {file.path} | ||
| 928 | </Text> | ||
| 929 | <Text className="text-[12px] font-t3-bold" style={{ color: "#10b981" }}> | ||
| 930 | +{file.insertions} | ||
| 931 | </Text> | ||
| 932 | <Text className="text-[12px] font-t3-bold" style={{ color: "#f43f5e" }}> | ||
| 933 | -{file.deletions} | ||
| 934 | </Text> | ||
| 935 | </View> | ||
| 936 | ))} | ||
| 937 | {selectedFiles.length > selectedFilePreview.length ? ( | ||
| 938 | <Text className="text-[12px] leading-[17px]" style={{ color: "#78716c" }}> | ||
| 939 | +{selectedFiles.length - selectedFilePreview.length} more files | ||
| 940 | </Text> | ||
| 941 | ) : null} | ||
| 942 | </View> | ||
| 943 | ) : ( | ||
| 944 | <View className="gap-2"> | ||
| 945 | {allFiles.map((file) => { | ||
| 946 | const included = !excludedFiles.has(file.path); | ||
| 947 | return ( | ||
| 948 | <Pressable | ||
| 949 | key={file.path} | ||
| 950 | className="rounded-[18px] border px-4 py-3" | ||
| 951 | style={{ | ||
| 952 | backgroundColor: included ? "#fcfbf8" : "rgba(120,113,108,0.08)", | ||
| 953 | borderColor: included ? "rgba(23,23,23,0.08)" : "rgba(120,113,108,0.15)", | ||
| 954 | }} | ||
| 955 | onPress={() => { | ||
| 956 | setExcludedFiles((current) => { | ||
| 957 | const next = new Set(current); | ||
| 958 | if (next.has(file.path)) { | ||
| 959 | next.delete(file.path); | ||
| 960 | } else { | ||
| 961 | next.add(file.path); | ||
| 962 | } | ||
| 963 | return next; | ||
| 964 | }); | ||
| 965 | }} | ||
| 966 | > | ||
| 967 | <View className="flex-row items-start justify-between gap-3"> | ||
| 968 | <View className="flex-1 gap-1"> | ||
| 969 | <Text | ||
| 970 | selectable | ||
| 971 | className="text-[13px] font-t3-bold" | ||
| 972 | style={{ color: included ? "#171717" : "#78716c" }} | ||
| 973 | > | ||
| 974 | {file.path} | ||
| 975 | </Text> | ||
| 976 | {!included ? ( | ||
| 977 | <Text | ||
| 978 | className="text-[11px] leading-[16px]" | ||
| 979 | style={{ color: "#78716c" }} | ||
| 980 | > | ||
| 981 | Excluded from this commit | ||
| 982 | </Text> | ||
| 983 | ) : null} | ||
| 984 | </View> | ||
| 985 | <View className="items-end gap-1"> | ||
| 986 | <Text className="text-[12px] font-t3-bold" style={{ color: "#10b981" }}> | ||
| 987 | +{file.insertions} | ||
| 988 | </Text> | ||
| 989 | <Text className="text-[12px] font-t3-bold" style={{ color: "#f43f5e" }}> | ||
| 990 | -{file.deletions} | ||
| 991 | </Text> | ||
| 992 | </View> | ||
| 993 | </View> | ||
| 994 | </Pressable> | ||
| 995 | ); | ||
| 996 | })} | ||
| 997 | </View> | ||
| 998 | )} | ||
| 999 | </View> | ||
| 1000 | |||
| 1001 | <View className="gap-2"> | ||
| 1002 | <Text className="text-[13px] font-t3-bold" style={{ color: "#171717" }}> | ||
| 1003 | Commit message | ||
| 1004 | </Text> | ||
| 1005 | <TextInput | ||
| 1006 | multiline | ||
| 1007 | value={dialogCommitMessage} | ||
| 1008 | onChangeText={setDialogCommitMessage} | ||
| 1009 | placeholder="Leave empty to auto-generate" | ||
| 1010 | textAlignVertical="top" | ||
| 1011 | className="min-h-[128px] rounded-[20px] border border-slate-200 bg-white px-4 py-3.5 font-sans text-[15px] text-slate-950" | ||
| 1012 | style={{ | ||
| 1013 | minHeight: 128, | ||
| 1014 | borderWidth: 1, | ||
| 1015 | borderColor: "rgba(23,23,23,0.08)", | ||
| 1016 | backgroundColor: "#ffffff", | ||
| 1017 | color: "#171717", | ||
| 1018 | }} | ||
| 1019 | /> | ||
| 1020 | </View> | ||
| 1021 | |||
| 1022 | <View className="flex-row gap-3"> | ||
| 1023 | <View className="flex-1"> | ||
| 1024 | <SheetActionButton | ||
| 1025 | icon="arrow.branch" | ||
| 1026 | label="Commit on new branch" | ||
| 1027 | disabled={noneSelected || busy} | ||
| 1028 | onPress={() => { | ||
| 1029 | void runCommitAction(true); | ||
| 1030 | }} | ||
| 1031 | /> | ||
| 1032 | </View> | ||
| 1033 | <View className="flex-1"> | ||
| 1034 | <SheetActionButton | ||
| 1035 | icon="checkmark.circle" | ||
| 1036 | label="Commit" | ||
| 1037 | tone="primary" | ||
| 1038 | disabled={noneSelected || busy} | ||
| 1039 | onPress={() => { | ||
| 1040 | void runCommitAction(false); | ||
| 1041 | }} | ||
| 1042 | /> | ||
| 1043 | </View> | ||
| 1044 | </View> | ||
| 1045 | </BottomSheetScrollView> | ||
| 1046 | </BottomSheetView> | ||
| 1047 | </BottomSheetModal> | ||
| 1048 | |||
| 1049 | <BottomSheetModal | ||
| 1050 | ref={defaultBranchSheetRef} | ||
| 1051 | stackBehavior="push" | ||
| 1052 | index={0} | ||
| 1053 | snapPoints={defaultBranchSnapPoints} | ||
| 1054 | enableDynamicSizing={false} | ||
| 1055 | enablePanDownToClose | ||
| 1056 | backdropComponent={renderBackdrop} | ||
| 1057 | onDismiss={() => setDefaultBranchSheetVisible(false)} | ||
| 1058 | backgroundStyle={{ backgroundColor: "rgba(247,247,245,0.98)" }} | ||
| 1059 | handleIndicatorStyle={{ backgroundColor: "rgba(120,113,108,0.32)" }} | ||
| 1060 | > | ||
| 1061 | <BottomSheetView | ||
| 1062 | className="gap-4 px-5 pt-2" | ||
| 1063 | style={{ paddingBottom: Math.max(insets.bottom, 18) + 8 }} | ||
| 1064 | > | ||
| 1065 | <View className="gap-1"> | ||
| 1066 | <Text className="text-[22px] font-t3-bold" style={{ color: "#171717" }}> | ||
| 1067 | {pendingDefaultBranchActionCopy?.title ?? "Run action on default branch?"} | ||
| 1068 | </Text> | ||
| 1069 | <Text className="text-[13px] font-medium leading-[19px]" style={{ color: "#57534e" }}> | ||
| 1070 | {pendingDefaultBranchActionCopy?.description ?? "Choose how to continue."} | ||
| 1071 | </Text> | ||
| 1072 | </View> | ||
| 1073 | |||
| 1074 | <View className="gap-3"> | ||
| 1075 | <SheetActionButton | ||
| 1076 | icon="arrow.right.circle" | ||
| 1077 | label={pendingDefaultBranchActionCopy?.continueLabel ?? "Continue"} | ||
| 1078 | onPress={() => { | ||
| 1079 | void continuePendingDefaultBranchAction(); | ||
| 1080 | }} | ||
| 1081 | /> | ||
| 1082 | <SheetActionButton | ||
| 1083 | icon="arrow.branch" | ||
| 1084 | label="Feature branch & continue" | ||
| 1085 | tone="primary" | ||
| 1086 | onPress={() => { | ||
| 1087 | void movePendingActionToFeatureBranch(); | ||
| 1088 | }} | ||
| 1089 | /> | ||
| 1090 | </View> | ||
| 1091 | </BottomSheetView> | ||
| 1092 | </BottomSheetModal> | ||
| 1093 | |||
| 1094 | <BottomSheetModal | ||
| 1095 | ref={branchSheetRef} | ||
| 1096 | stackBehavior="push" | ||
| 1097 | index={0} | ||
| 1098 | snapPoints={branchSnapPoints} | ||
| 1099 | enableDynamicSizing={false} | ||
| 1100 | enablePanDownToClose | ||
| 1101 | backdropComponent={renderBackdrop} | ||
| 1102 | onDismiss={() => setBranchSheetVisible(false)} | ||
| 1103 | backgroundStyle={{ backgroundColor: "rgba(247,247,245,0.98)" }} | ||
| 1104 | handleIndicatorStyle={{ backgroundColor: "rgba(120,113,108,0.32)" }} | ||
| 1105 | > | ||
| 1106 | <BottomSheetView style={{ flex: 1 }}> | ||
| 1107 | <BottomSheetScrollView | ||
| 1108 | showsVerticalScrollIndicator={false} | ||
| 1109 | contentContainerStyle={{ | ||
| 1110 | paddingHorizontal: 20, | ||
| 1111 | paddingTop: 8, | ||
| 1112 | paddingBottom: Math.max(insets.bottom, 18) + 8, | ||
| 1113 | gap: 16, | ||
| 1114 | }} | ||
| 1115 | > | ||
| 1116 | <View className="gap-1"> | ||
| 1117 | <Text className="text-[22px] font-t3-bold" style={{ color: "#171717" }}> | ||
| 1118 | Branches & worktrees | ||
| 1119 | </Text> | ||
| 1120 | <Text className="text-[13px] font-medium leading-[19px]" style={{ color: "#57534e" }}> | ||
| 1121 | Switch this thread, create a branch, or move work onto its own worktree. | ||
| 1122 | </Text> | ||
| 1123 | </View> | ||
| 1124 | |||
| 1125 | <View className="gap-2 rounded-[18px] border border-black/8 bg-white px-4 py-4"> | ||
| 1126 | <Text | ||
| 1127 | className="text-[11px] font-t3-bold uppercase" | ||
| 1128 | style={{ color: "#57534e", letterSpacing: 1 }} | ||
| 1129 | > | ||
| 1130 | New branch | ||
| 1131 | </Text> | ||
| 1132 | <TextInput | ||
| 1133 | value={newBranchName} | ||
| 1134 | onChangeText={setNewBranchName} | ||
| 1135 | placeholder="feature/mobile-polish" | ||
| 1136 | className="rounded-[18px] border border-slate-200 bg-white px-3.5 py-3 font-sans text-[15px] text-slate-950" | ||
| 1137 | /> | ||
| 1138 | <SheetActionButton | ||
| 1139 | icon="plus" | ||
| 1140 | label="Create & checkout" | ||
| 1141 | tone="primary" | ||
| 1142 | disabled={busy || newBranchName.trim().length === 0} | ||
| 1143 | onPress={() => { | ||
| 1144 | const branch = sanitizeFeatureBranchName(newBranchName.trim()); | ||
| 1145 | if (branch.length === 0) { | ||
| 1146 | return; | ||
| 1147 | } | ||
| 1148 | void onCreateBranch(branch).then(() => { | ||
| 1149 | setNewBranchName(""); | ||
| 1150 | setBranchSheetVisible(false); | ||
| 1151 | }); | ||
| 1152 | }} | ||
| 1153 | /> | ||
| 1154 | </View> | ||
| 1155 | |||
| 1156 | <View className="gap-2 rounded-[18px] border border-black/8 bg-white px-4 py-4"> | ||
| 1157 | <Text | ||
| 1158 | className="text-[11px] font-t3-bold uppercase" | ||
| 1159 | style={{ color: "#57534e", letterSpacing: 1 }} | ||
| 1160 | > | ||
| 1161 | New worktree | ||
| 1162 | </Text> | ||
| 1163 | <TextInput | ||
| 1164 | value={worktreeBaseBranch} | ||
| 1165 | onChangeText={setWorktreeBaseBranch} | ||
| 1166 | placeholder="main" | ||
| 1167 | className="rounded-[18px] border border-slate-200 bg-white px-3.5 py-3 font-sans text-[15px] text-slate-950" | ||
| 1168 | /> | ||
| 1169 | <TextInput | ||
| 1170 | value={worktreeBranchName} | ||
| 1171 | onChangeText={setWorktreeBranchName} | ||
| 1172 | placeholder="feature/mobile-thread" | ||
| 1173 | className="rounded-[18px] border border-slate-200 bg-white px-3.5 py-3 font-sans text-[15px] text-slate-950" | ||
| 1174 | /> | ||
| 1175 | <SheetActionButton | ||
| 1176 | icon="square.split.2x1" | ||
| 1177 | label="Create worktree" | ||
| 1178 | tone="primary" | ||
| 1179 | disabled={ | ||
| 1180 | busy || | ||
| 1181 | worktreeBaseBranch.trim().length === 0 || | ||
| 1182 | worktreeBranchName.trim().length === 0 | ||
| 1183 | } | ||
| 1184 | onPress={() => { | ||
| 1185 | const baseBranch = worktreeBaseBranch.trim(); | ||
| 1186 | const newBranch = worktreeBranchName.trim(); | ||
| 1187 | if (baseBranch.length === 0 || newBranch.length === 0) { | ||
| 1188 | return; | ||
| 1189 | } | ||
| 1190 | void onCreateWorktree({ | ||
| 1191 | baseBranch, | ||
| 1192 | newBranch, | ||
| 1193 | }).then(() => { | ||
| 1194 | setWorktreeBranchName(""); | ||
| 1195 | setBranchSheetVisible(false); | ||
| 1196 | }); | ||
| 1197 | }} | ||
| 1198 | /> | ||
| 1199 | </View> | ||
| 1200 | |||
| 1201 | <View className="gap-2"> | ||
| 1202 | <Text | ||
| 1203 | className="text-[11px] font-t3-bold uppercase" | ||
| 1204 | style={{ color: "#57534e", letterSpacing: 1 }} | ||
| 1205 | > | ||
| 1206 | Existing branches | ||
| 1207 | </Text> | ||
| 1208 | {branchesLoading ? ( | ||
| 1209 | <Text className="text-[13px] font-medium" style={{ color: "#57534e" }}> | ||
| 1210 | Loading branches… | ||
| 1211 | </Text> | ||
| 1212 | ) : null} | ||
| 1213 | {!branchesLoading && availableBranches.length === 0 ? ( | ||
| 1214 | <Text className="text-[13px] font-medium" style={{ color: "#57534e" }}> | ||
| 1215 | No local branches found. | ||
| 1216 | </Text> | ||
| 1217 | ) : null} | ||
| 1218 | {availableBranches.map((branch) => { | ||
| 1219 | const disabled = disabledExistingBranches.has(branch.name); | ||
| 1220 | const subtitle = branch.worktreePath | ||
| 1221 | ? branch.worktreePath === currentWorktreePath | ||
| 1222 | ? "Checked out in this thread" | ||
| 1223 | : "Checked out in another worktree" | ||
| 1224 | : branch.isDefault | ||
| 1225 | ? "Default branch" | ||
| 1226 | : "Local branch"; | ||
| 1227 | |||
| 1228 | return ( | ||
| 1229 | <Pressable | ||
| 1230 | key={branch.name} | ||
| 1231 | className="gap-1 rounded-[18px] border px-4 py-3" | ||
| 1232 | disabled={busy || disabled} | ||
| 1233 | style={{ | ||
| 1234 | backgroundColor: "#ffffff", | ||
| 1235 | borderColor: branch.current ? "rgba(23,23,23,0.25)" : "rgba(23,23,23,0.08)", | ||
| 1236 | opacity: busy || disabled ? 0.45 : 1, | ||
| 1237 | }} | ||
| 1238 | onPress={() => { | ||
| 1239 | void onCheckoutBranch(branch.name).then(() => { | ||
| 1240 | setBranchSheetVisible(false); | ||
| 1241 | }); | ||
| 1242 | }} | ||
| 1243 | > | ||
| 1244 | <Text className="text-[15px] font-t3-bold" style={{ color: "#171717" }}> | ||
| 1245 | {branch.name} | ||
| 1246 | </Text> | ||
| 1247 | <Text className="text-[12px] font-medium" style={{ color: "#57534e" }}> | ||
| 1248 | {subtitle} | ||
| 1249 | </Text> | ||
| 1250 | </Pressable> | ||
| 1251 | ); | ||
| 1252 | })} | ||
| 1253 | </View> | ||
| 1254 | </BottomSheetScrollView> | ||
| 1255 | </BottomSheetView> | ||
| 1256 | </BottomSheetModal> | ||
| 1257 | </> | ||
| 1258 | ); | ||
| 1259 | } | ||
| @@ -1,25 +1,30 @@ | |||
| 1 | import { SymbolView } from "expo-symbols"; | 1 | import { SymbolView } from "expo-symbols"; |
| 2 | import { useRef, useState } from "react"; | 2 | import { useCallback, useMemo, useRef, useState } from "react"; |
| 3 | import { Pressable, ScrollView, View, type View as RNView } from "react-native"; | 3 | import { |
| 4 | import { useColorScheme } from "react-native"; | 4 | Modal, |
| 5 | import Animated, { FadeInDown, FadeOutUp, LinearTransition } from "react-native-reanimated"; | 5 | Pressable, |
| 6 | import Svg, { Path } from "react-native-svg"; | 6 | RefreshControl, |
| 7 | 7 | ScrollView, | |
| 8 | View, | ||
| 9 | useColorScheme, | ||
| 10 | type View as RNView, | ||
| 11 | } from "react-native"; | ||
| 12 | import Animated, { FadeInDown, LinearTransition } from "react-native-reanimated"; | ||
| 13 | |||
| 14 | import { BrandMark } from "../../components/BrandMark"; | ||
| 8 | import { AppText as Text } from "../../components/AppText"; | 15 | import { AppText as Text } from "../../components/AppText"; |
| 9 | import { EmptyState } from "../../components/EmptyState"; | 16 | import { EmptyState } from "../../components/EmptyState"; |
| 10 | import { ErrorBanner } from "../../components/ErrorBanner"; | ||
| 11 | import { GlassSafeAreaView } from "../../components/GlassSafeAreaView"; | 17 | import { GlassSafeAreaView } from "../../components/GlassSafeAreaView"; |
| 12 | import { StatusPill } from "../../components/StatusPill"; | 18 | import { StatusPill } from "../../components/StatusPill"; |
| 13 | import { cx } from "../../lib/classNames"; | 19 | import type { MobileLayout } from "../../lib/mobileLayout"; |
| 14 | import { sortCopy } from "../../lib/arrayCompat"; | ||
| 15 | import { | 20 | import { |
| 16 | scopedProjectKey, | ||
| 17 | scopedThreadKey, | 21 | scopedThreadKey, |
| 18 | type ScopedMobileProject, | 22 | type ScopedMobileProject, |
| 19 | type ScopedMobileThread, | 23 | type ScopedMobileThread, |
| 20 | } from "../../lib/scopedEntities"; | 24 | } from "../../lib/scopedEntities"; |
| 21 | import type { RemoteClientConnectionState } from "../../lib/remoteClient"; | 25 | import type { RemoteClientConnectionState } from "../../lib/remoteClient"; |
| 22 | import { relativeTime } from "../../lib/time"; | 26 | import { relativeTime } from "../../lib/time"; |
| 27 | import { groupProjectsByRepository, type MobileRepositoryGroup } from "../../lib/repositoryGroups"; | ||
| 23 | import { ConnectionStatusDot } from "../connection/ConnectionStatusDot"; | 28 | import { ConnectionStatusDot } from "../connection/ConnectionStatusDot"; |
| 24 | import { lastConversationLine, threadStatusTone } from "./threadPresentation"; | 29 | import { lastConversationLine, threadStatusTone } from "./threadPresentation"; |
| 25 | 30 | ||
| @@ -38,7 +43,9 @@ export interface ThreadListScreenProps { · 12 unmodified lines | |||
| 38 | readonly connectedEnvironmentCount: number; | 43 | readonly connectedEnvironmentCount: number; |
| 39 | readonly hasClient: boolean; | 44 | readonly hasClient: boolean; |
| 40 | readonly hasServerConfig: boolean; | 45 | readonly hasServerConfig: boolean; |
| 46 | readonly layout: MobileLayout; | ||
| 41 | readonly hiddenThreadKey?: string | null; | 47 | readonly hiddenThreadKey?: string | null; |
| 48 | readonly selectedThreadKey?: string | null; | ||
| 42 | readonly connectionError: string | null; | 49 | readonly connectionError: string | null; |
| 43 | readonly onOpenConnectionEditor: () => void; | 50 | readonly onOpenConnectionEditor: () => void; |
| 44 | readonly onRefresh: () => Promise<void>; | 51 | readonly onRefresh: () => Promise<void>; |
| @@ -56,246 +63,90 @@ export interface TransitionSourceFrame { · 11 unmodified lines | |||
| 56 | readonly height: number; | 63 | readonly height: number; |
| 57 | } | 64 | } |
| 58 | 65 | ||
| 59 | function T3Wordmark(props: { readonly color: string }) { | 66 | function ActionButton(props: { |
| 60 | return ( | 67 | readonly icon: React.ComponentProps<typeof SymbolView>["name"]; |
| 61 | <Svg | 68 | readonly label: string; |
| 62 | accessibilityLabel="T3" | 69 | readonly primary?: boolean; |
| 63 | width={36} | ||
| 64 | height={21.5} | ||
| 65 | viewBox="15.5309 37 94.3941 56.96" | ||
| 66 | fill="none" | ||
| 67 | > | ||
| 68 | <Path | ||
| 69 | d="M33.4509 93V47.56H15.5309V37H64.3309V47.56H46.4109V93H33.4509ZM86.7253 93.96C82.832 93.96 78.9653 93.4533 75.1253 92.44C71.2853 91.3733 68.032 89.88 65.3653 87.96L70.4053 78.04C72.5386 79.5867 75.0186 80.8133 77.8453 81.72C80.672 82.6267 83.5253 83.08 86.4053 83.08C89.6586 83.08 92.2186 82.44 94.0853 81.16C95.952 79.88 96.8853 78.12 96.8853 75.88C96.8853 73.7467 96.0586 72.0667 94.4053 70.84C92.752 69.6133 90.0853 69 86.4053 69H80.4853V60.44L96.0853 42.76L97.5253 47.4H68.1653V37H107.365V45.4L91.8453 63.08L85.2853 59.32H89.0453C95.9253 59.32 101.125 60.8667 104.645 63.96C108.165 67.0533 109.925 71.0267 109.925 75.88C109.925 79.0267 109.099 81.9867 107.445 84.76C105.792 87.48 103.259 89.6933 99.8453 91.4C96.432 93.1067 92.0586 93.96 86.7253 93.96Z" | ||
| 70 | fill={props.color} | ||
| 71 | /> | ||
| 72 | </Svg> | ||
| 73 | ); | ||
| 74 | } | ||
| 75 | |||
| 76 | type GlyphName = | ||
| 77 | | "link" | ||
| 78 | | "stack" | ||
| 79 | | "folder" | ||
| 80 | | "refresh" | ||
| 81 | | "arrow-up-right" | ||
| 82 | | "chevron-down" | ||
| 83 | | "chevron-up" | ||
| 84 | | "device" | ||
| 85 | | "spark"; | ||
| 86 | |||
| 87 | const GLYPH_SYMBOL_NAMES = { | ||
| 88 | link: "link", | ||
| 89 | stack: "square.stack.3d.up", | ||
| 90 | folder: "folder", | ||
| 91 | refresh: "arrow.clockwise", | ||
| 92 | "arrow-up-right": "arrow.up.right", | ||
| 93 | "chevron-down": "chevron.down", | ||
| 94 | "chevron-up": "chevron.up", | ||
| 95 | device: "iphone", | ||
| 96 | spark: "sparkles", | ||
| 97 | } as const; | ||
| 98 | |||
| 99 | const GLYPH_PATHS: Record<GlyphName, string> = { | ||
| 100 | link: "M6.1 9.9 9.9 6.1M5.2 11.8H4a2.8 2.8 0 1 1 0-5.6h1.2M10.8 4.2H12a2.8 2.8 0 1 1 0 5.6h-1.2", | ||
| 101 | stack: "M2.5 5.2 8 2.5l5.5 2.7L8 7.9 2.5 5.2ZM2.5 8 8 10.7 13.5 8M2.5 10.8 8 13.5l5.5-2.7", | ||
| 102 | folder: "M1.8 4.5h4l1.4 1.6h7v5.7a1 1 0 0 1-1 1H2.8a1 1 0 0 1-1-1V4.5Z", | ||
| 103 | refresh: "M13.2 5.2V2.8M13.2 2.8h-2.4M13.2 2.8 10.8 5.2M12.3 8A4.3 4.3 0 1 1 8 3.7", | ||
| 104 | "arrow-up-right": "M5 11 11 5M6 5h5v5", | ||
| 105 | "chevron-down": "m3.5 5.8 4.5 4.4 4.5-4.4", | ||
| 106 | "chevron-up": "m3.5 10.2 4.5-4.4 4.5 4.4", | ||
| 107 | device: | ||
| 108 | "M4.2 2.5h7.6a1 1 0 0 1 1 1v9a1 1 0 0 1-1 1H4.2a1 1 0 0 1-1-1v-9a1 1 0 0 1 1-1ZM6.4 11.7h3.2", | ||
| 109 | spark: "M8 2.4 9.1 6.9 13.6 8l-4.5 1.1L8 13.6 6.9 9.1 2.4 8l4.5-1.1L8 2.4Z", | ||
| 110 | }; | ||
| 111 | |||
| 112 | const GLYPH_STROKE_JOIN: Partial<Record<GlyphName, "round">> = { | ||
| 113 | folder: "round", | ||
| 114 | }; | ||
| 115 | |||
| 116 | function Glyph(props: { | ||
| 117 | readonly name: GlyphName; | ||
| 118 | readonly color: string; | ||
| 119 | readonly size?: number; | ||
| 120 | }) { | ||
| 121 | const size = props.size ?? 16; | ||
| 122 | const strokeWidth = 1.8; | ||
| 123 | const symbolName = GLYPH_SYMBOL_NAMES[props.name]; | ||
| 124 | const path = GLYPH_PATHS[props.name]; | ||
| 125 | const strokeLinejoin = GLYPH_STROKE_JOIN[props.name] ?? "round"; | ||
| 126 | |||
| 127 | const fallback = ( | ||
| 128 | <Svg width={size} height={size} viewBox="0 0 16 16" fill="none"> | ||
| 129 | <Path | ||
| 130 | d={path} | ||
| 131 | stroke={props.color} | ||
| 132 | strokeWidth={strokeWidth} | ||
| 133 | strokeLinecap="round" | ||
| 134 | strokeLinejoin={strokeLinejoin} | ||
| 135 | /> | ||
| 136 | </Svg> | ||
| 137 | ); | ||
| 138 | |||
| 139 | return ( | ||
| 140 | <SymbolView | ||
| 141 | name={symbolName} | ||
| 142 | fallback={fallback} | ||
| 143 | size={size} | ||
| 144 | tintColor={props.color} | ||
| 145 | type="monochrome" | ||
| 146 | weight="medium" | ||
| 147 | /> | ||
| 148 | ); | ||
| 149 | } | ||
| 150 | |||
| 151 | function ExpandThreadsToggle(props: { | ||
| 152 | readonly expanded: boolean; | ||
| 153 | readonly hiddenCount: number; | ||
| 154 | readonly palette: ReturnType<typeof makePalette>; | ||
| 155 | readonly onPress: () => void; | 70 | readonly onPress: () => void; |
| 156 | }) { | 71 | }) { |
| 157 | const label = props.expanded ? "Show less" : `Show ${props.hiddenCount} more`; | ||
| 158 | |||
| 159 | return ( | 72 | return ( |
| 160 | <Pressable className="items-center px-1 pb-4 pt-3" onPress={props.onPress}> | 73 | <Pressable |
| 161 | <View className="w-full flex-row items-center gap-3"> | 74 | className="min-h-[50px] flex-1 flex-row items-center justify-center gap-2 rounded-[16px] px-3 py-3" |
| 162 | <View className="h-px flex-1" style={{ backgroundColor: props.palette.border }} /> | 75 | style={{ |
| 163 | <View | 76 | backgroundColor: props.primary ? "#171717" : "#ffffff", |
| 164 | className="flex-row items-center gap-2 px-3 py-2" | 77 | borderWidth: props.primary ? 0 : 1, |
| 165 | style={{ | 78 | borderColor: props.primary ? "transparent" : "rgba(23,23,23,0.08)", |
| 166 | borderWidth: 1, | 79 | }} |
| 167 | borderColor: props.palette.border, | 80 | onPress={props.onPress} |
| 168 | backgroundColor: props.palette.panelAlt, | 81 | > |
| 169 | }} | 82 | <SymbolView |
| 170 | > | 83 | name={props.icon} |
| 171 | <Text | 84 | size={18} |
| 172 | className="text-[11px] font-bold uppercase" | 85 | tintColor={props.primary ? "#fafaf9" : "#171717"} |
| 173 | style={{ color: props.palette.muted, letterSpacing: 1.05 }} | 86 | type="monochrome" |
| 174 | > | 87 | weight="medium" |
| 175 | {label} | 88 | /> |
| 176 | </Text> | 89 | <Text |
| 177 | <Glyph | 90 | className="text-[13px] font-t3-bold uppercase" |
| 178 | name={props.expanded ? "chevron-up" : "chevron-down"} | 91 | style={{ |
| 179 | color={props.palette.muted} | 92 | color: props.primary ? "#fafaf9" : "#171717", |
| 180 | size={14} | 93 | letterSpacing: 0.9, |
| 181 | /> | 94 | }} |
| 182 | </View> | 95 | > |
| 183 | <View className="h-px flex-1" style={{ backgroundColor: props.palette.border }} /> | 96 | {props.label} |
| 184 | </View> | 97 | </Text> |
| 185 | </Pressable> | 98 | </Pressable> |
| 186 | ); | 99 | ); |
| 187 | } | 100 | } |
| 188 | 101 | ||
| 189 | function toneStyles(label: string) { | 102 | function StatCard(props: { |
| 190 | const value = label.toLowerCase(); | ||
| 191 | |||
| 192 | if (value.includes("error") || value.includes("disconnect")) { | ||
| 193 | return { | ||
| 194 | dot: "bg-rose-500", | ||
| 195 | line: "bg-rose-300/80", | ||
| 196 | chip: "bg-rose-100/90", | ||
| 197 | text: "text-rose-700", | ||
| 198 | }; | ||
| 199 | } | ||
| 200 | |||
| 201 | if (value.includes("run")) { | ||
| 202 | return { | ||
| 203 | dot: "bg-orange-500", | ||
| 204 | line: "bg-orange-300/80", | ||
| 205 | chip: "bg-orange-100/90", | ||
| 206 | text: "text-orange-700", | ||
| 207 | }; | ||
| 208 | } | ||
| 209 | |||
| 210 | if (value.includes("connect") || value.includes("ready") || value.includes("start")) { | ||
| 211 | return { | ||
| 212 | dot: "bg-sky-500", | ||
| 213 | line: "bg-sky-300/80", | ||
| 214 | chip: "bg-sky-100/90", | ||
| 215 | text: "text-sky-700", | ||
| 216 | }; | ||
| 217 | } | ||
| 218 | |||
| 219 | return { | ||
| 220 | dot: "bg-slate-500", | ||
| 221 | line: "bg-slate-300/80", | ||
| 222 | chip: "bg-slate-200/90", | ||
| 223 | text: "text-slate-700", | ||
| 224 | }; | ||
| 225 | } | ||
| 226 | |||
| 227 | function StatCell(props: { | ||
| 228 | readonly icon: React.ComponentProps<typeof Glyph>["name"]; | ||
| 229 | readonly label: string; | 103 | readonly label: string; |
| 230 | readonly value: string; | 104 | readonly value: string; |
| 231 | readonly palette: ReturnType<typeof makePalette>; | 105 | readonly icon: React.ComponentProps<typeof SymbolView>["name"]; |
| 232 | }) { | 106 | }) { |
| 233 | return ( | 107 | return ( |
| 234 | <View className="flex-1 gap-2"> | 108 | <View |
| 109 | className="flex-1 gap-2 rounded-[18px] px-4 py-4" | ||
| 110 | style={{ | ||
| 111 | backgroundColor: "#ffffff", | ||
| 112 | borderWidth: 1, | ||
| 113 | borderColor: "rgba(23,23,23,0.08)", | ||
| 114 | }} | ||
| 115 | > | ||
| 235 | <View className="flex-row items-center gap-2"> | 116 | <View className="flex-row items-center gap-2"> |
| 236 | <Glyph name={props.icon} color={props.palette.muted} size={15} /> | 117 | <SymbolView name={props.icon} size={15} tintColor="#57534e" type="monochrome" /> |
| 237 | <Text | 118 | <Text |
| 238 | className="text-[11px] font-bold uppercase" | 119 | className="text-[11px] font-t3-bold uppercase" |
| 239 | style={{ color: props.palette.muted, letterSpacing: 1.1 }} | 120 | style={{ color: "#57534e", letterSpacing: 1 }} |
| 240 | > | 121 | > |
| 241 | {props.label} | 122 | {props.label} |
| 242 | </Text> | 123 | </Text> |
| 243 | </View> | 124 | </View> |
| 244 | <Text | 125 | <Text className="text-[28px] font-t3-bold" style={{ color: "#171717" }}> |
| 245 | className="text-[32px] font-extrabold leading-[34px]" | ||
| 246 | style={{ color: props.palette.text }} | ||
| 247 | > | ||
| 248 | {props.value} | 126 | {props.value} |
| 249 | </Text> | 127 | </Text> |
| 250 | </View> | 128 | </View> |
| 251 | ); | 129 | ); |
| 252 | } | 130 | } |
| 253 | 131 | ||
| 254 | function ProjectActionButton(props: { | ||
| 255 | readonly palette: ReturnType<typeof makePalette>; | ||
| 256 | readonly label: string; | ||
| 257 | readonly icon: React.ComponentProps<typeof Glyph>["name"]; | ||
| 258 | readonly onPress: () => void; | ||
| 259 | }) { | ||
| 260 | return ( | ||
| 261 | <Pressable | ||
| 262 | className="min-h-[40px] flex-row items-center justify-center gap-2 px-3 py-2" | ||
| 263 | style={{ | ||
| 264 | backgroundColor: props.palette.actionSecondary, | ||
| 265 | borderWidth: 1, | ||
| 266 | borderColor: props.palette.border, | ||
| 267 | }} | ||
| 268 | onPress={props.onPress} | ||
| 269 | > | ||
| 270 | <Glyph name={props.icon} color={props.palette.actionSecondaryText} size={16} /> | ||
| 271 | <Text | ||
| 272 | className="text-[11px] font-extrabold uppercase" | ||
| 273 | style={{ color: props.palette.actionSecondaryText, letterSpacing: 1.05 }} | ||
| 274 | > | ||
| 275 | {props.label} | ||
| 276 | </Text> | ||
| 277 | </Pressable> | ||
| 278 | ); | ||
| 279 | } | ||
| 280 | |||
| 281 | function ThreadRow(props: { | 132 | function ThreadRow(props: { |
| 282 | readonly thread: ScopedMobileThread; | 133 | readonly thread: ScopedMobileThread; |
| 283 | readonly isLast: boolean; | ||
| 284 | readonly hidden?: boolean; | 134 | readonly hidden?: boolean; |
| 135 | readonly selected?: boolean; | ||
| 285 | readonly onPress: (sourceFrame: TransitionSourceFrame | null) => void; | 136 | readonly onPress: (sourceFrame: TransitionSourceFrame | null) => void; |
| 286 | readonly palette: ReturnType<typeof makePalette>; | ||
| 287 | }) { | 137 | }) { |
| 288 | const containerRef = useRef<RNView>(null); | 138 | const containerRef = useRef<RNView>(null); |
| 289 | const threadTone = threadStatusTone(props.thread); | 139 | const tone = threadStatusTone(props.thread); |
| 290 | const styles = toneStyles(threadTone.label); | 140 | const backgroundColor = props.selected ? "rgba(249,115,22,0.09)" : "#ffffff"; |
| 141 | const borderColor = props.selected ? "rgba(249,115,22,0.28)" : "rgba(23,23,23,0.08)"; | ||
| 291 | 142 | ||
| 292 | return ( | 143 | return ( |
| 293 | <Pressable | 144 | <Pressable |
| 294 | ref={containerRef} | 145 | ref={containerRef} |
| 295 | className="overflow-hidden px-1 py-4" | 146 | className="gap-3 rounded-[18px] border px-4 py-4" |
| 296 | style={{ | 147 | style={{ |
| 297 | borderBottomWidth: props.isLast ? 0 : 1, | 148 | backgroundColor, |
| 298 | borderBottomColor: props.palette.border, | 149 | borderColor, |
| 299 | opacity: props.hidden ? 0 : 1, | 150 | opacity: props.hidden ? 0 : 1, |
| 300 | }} | 151 | }} |
| 301 | onPress={() => { | 152 | onPress={() => { |
| @@ -308,394 +159,379 @@ function ThreadRow(props: { · 6 unmodified lines | |||
| 308 | }); | 159 | }); |
| 309 | }} | 160 | }} |
| 310 | > | 161 | > |
| 311 | <View className="gap-3"> | 162 | <View className="flex-row items-start justify-between gap-3"> |
| 312 | <View className="flex-row items-start gap-4"> | 163 | <View className="flex-1 gap-2"> |
| 313 | <View className="flex-1 gap-2"> | 164 | <Text className="text-[17px] font-t3-bold" style={{ color: "#171717" }}> |
| 314 | <View className="flex-row items-center gap-2.5"> | 165 | {props.thread.title} |
| 315 | <View | 166 | </Text> |
| 316 | className={cx("h-2.5 w-2.5", styles.dot)} | 167 | <Text className="text-[13px] font-medium leading-[19px]" style={{ color: "#57534e" }}> |
| 317 | style={{ borderRadius: 2, opacity: 0.88 }} | 168 | {lastConversationLine(props.thread)} |
| 318 | /> | 169 | </Text> |
| 319 | <Animated.Text | ||
| 320 | numberOfLines={1} | ||
| 321 | style={{ | ||
| 322 | flex: 1, | ||
| 323 | color: props.palette.text, | ||
| 324 | fontSize: 18, | ||
| 325 | fontWeight: "800", | ||
| 326 | lineHeight: 22, | ||
| 327 | }} | ||
| 328 | > | ||
| 329 | {props.thread.title} | ||
| 330 | </Animated.Text> | ||
| 331 | </View> | ||
| 332 | |||
| 333 | <Text | ||
| 334 | numberOfLines={2} | ||
| 335 | className="text-[14px] leading-[20px]" | ||
| 336 | style={{ color: props.palette.muted }} | ||
| 337 | > | ||
| 338 | {lastConversationLine(props.thread)} | ||
| 339 | </Text> | ||
| 340 | </View> | ||
| 341 | |||
| 342 | <View className="items-end gap-2"> | ||
| 343 | <StatusPill {...threadTone} /> | ||
| 344 | </View> | ||
| 345 | </View> | ||
| 346 | |||
| 347 | <Text | ||
| 348 | className="text-[11px] font-bold uppercase" | ||
| 349 | style={{ color: props.palette.muted, letterSpacing: 1.05 }} | ||
| 350 | > | ||
| 351 | {props.thread.environmentLabel} · {props.thread.modelSelection.provider} ·{" "} | ||
| 352 | {props.thread.modelSelection.model} ·{" "} | ||
| 353 | {relativeTime(props.thread.updatedAt ?? props.thread.createdAt)} | ||
| 354 | </Text> | ||
| 355 | |||
| 356 | <View className="h-[2px] overflow-hidden" style={{ backgroundColor: props.palette.rule }}> | ||
| 357 | <View className={cx("h-full", styles.line)} style={{ width: "34%" }} /> | ||
| 358 | </View> | 170 | </View> |
| 171 | <StatusPill {...tone} /> | ||
| 359 | </View> | 172 | </View> |
| 173 | |||
| 174 | <Text | ||
| 175 | className="text-[11px] font-t3-bold uppercase" | ||
| 176 | style={{ color: "#78716c", letterSpacing: 0.9 }} | ||
| 177 | > | ||
| 178 | {props.thread.environmentLabel} · {props.thread.modelSelection.provider} ·{" "} | ||
| 179 | {relativeTime(props.thread.updatedAt ?? props.thread.createdAt)} | ||
| 180 | </Text> | ||
| 360 | </Pressable> | 181 | </Pressable> |
| 361 | ); | 182 | ); |
| 362 | } | 183 | } |
| 363 | 184 | ||
| 364 | function SectionPanel(props: { | 185 | function ProjectSection(props: { |
| 365 | readonly children: React.ReactNode; | 186 | readonly project: ScopedMobileProject; |
| 366 | readonly palette: ReturnType<typeof makePalette>; | 187 | readonly threads: ReadonlyArray<ScopedMobileThread>; |
| 188 | readonly hiddenThreadKey?: string | null; | ||
| 189 | readonly selectedThreadKey?: string | null; | ||
| 190 | readonly onCreateThread: (project: ScopedMobileProject) => Promise<void>; | ||
| 191 | readonly onSelectThread: ( | ||
| 192 | thread: ScopedMobileThread, | ||
| 193 | sourceFrame: TransitionSourceFrame | null, | ||
| 194 | ) => void; | ||
| 367 | }) { | 195 | }) { |
| 196 | const [expanded, setExpanded] = useState(false); | ||
| 197 | const visibleThreads = expanded ? props.threads : props.threads.slice(0, 2); | ||
| 198 | const hiddenCount = Math.max(props.threads.length - visibleThreads.length, 0); | ||
| 199 | |||
| 368 | return ( | 200 | return ( |
| 369 | <View | 201 | <View |
| 370 | style={{ | 202 | className="gap-3 rounded-[22px] border px-4 py-4" |
| 371 | borderWidth: 1, | 203 | style={{ borderColor: "rgba(23,23,23,0.08)" }} |
| 372 | borderColor: props.palette.border, | ||
| 373 | backgroundColor: props.palette.panel, | ||
| 374 | }} | ||
| 375 | > | 204 | > |
| 376 | {props.children} | 205 | <View className="flex-row items-start justify-between gap-3"> |
| 206 | <View className="flex-1 gap-1"> | ||
| 207 | <Text className="text-[15px] font-t3-bold" style={{ color: "#171717" }}> | ||
| 208 | {props.project.environmentLabel} | ||
| 209 | </Text> | ||
| 210 | <Text className="text-[12px] font-medium leading-[18px]" style={{ color: "#57534e" }}> | ||
| 211 | {props.project.workspaceRoot} | ||
| 212 | </Text> | ||
| 213 | </View> | ||
| 214 | <Pressable | ||
| 215 | className="rounded-full px-3 py-2" | ||
| 216 | style={{ backgroundColor: "rgba(23,23,23,0.06)" }} | ||
| 217 | onPress={() => void props.onCreateThread(props.project)} | ||
| 218 | > | ||
| 219 | <Text | ||
| 220 | className="text-[11px] font-t3-bold uppercase" | ||
| 221 | style={{ color: "#171717", letterSpacing: 0.9 }} | ||
| 222 | > | ||
| 223 | New thread | ||
| 224 | </Text> | ||
| 225 | </Pressable> | ||
| 226 | </View> | ||
| 227 | |||
| 228 | {props.threads.length === 0 ? ( | ||
| 229 | <EmptyState | ||
| 230 | title="No threads yet" | ||
| 231 | detail="Start a thread in this environment to bring it onto the mobile board." | ||
| 232 | /> | ||
| 233 | ) : ( | ||
| 234 | <View className="gap-3"> | ||
| 235 | {visibleThreads.map((thread) => ( | ||
| 236 | <ThreadRow | ||
| 237 | key={scopedThreadKey(thread.environmentId, thread.id)} | ||
| 238 | thread={thread} | ||
| 239 | hidden={props.hiddenThreadKey === scopedThreadKey(thread.environmentId, thread.id)} | ||
| 240 | selected={ | ||
| 241 | props.selectedThreadKey === scopedThreadKey(thread.environmentId, thread.id) | ||
| 242 | } | ||
| 243 | onPress={(sourceFrame) => props.onSelectThread(thread, sourceFrame)} | ||
| 244 | /> | ||
| 245 | ))} | ||
| 246 | |||
| 247 | {hiddenCount > 0 ? ( | ||
| 248 | <Pressable | ||
| 249 | className="items-center rounded-[16px] px-3 py-3" | ||
| 250 | style={{ backgroundColor: "rgba(23,23,23,0.04)" }} | ||
| 251 | onPress={() => setExpanded((current) => !current)} | ||
| 252 | > | ||
| 253 | <Text | ||
| 254 | className="text-[11px] font-t3-bold uppercase" | ||
| 255 | style={{ color: "#57534e", letterSpacing: 1 }} | ||
| 256 | > | ||
| 257 | {expanded ? "Show less" : `Show ${hiddenCount} more`} | ||
| 258 | </Text> | ||
| 259 | </Pressable> | ||
| 260 | ) : null} | ||
| 261 | </View> | ||
| 262 | )} | ||
| 377 | </View> | 263 | </View> |
| 378 | ); | 264 | ); |
| 379 | } | 265 | } |
| 380 | 266 | ||
| 381 | function orderThreadsByRecency(threads: ReadonlyArray<ScopedMobileThread>) { | 267 | function CreateThreadModal(props: { |
| 382 | return sortCopy(threads, (left, right) => { | 268 | readonly group: MobileRepositoryGroup | null; |
| 383 | const leftTime = new Date(left.updatedAt ?? left.createdAt).getTime(); | 269 | readonly visible: boolean; |
| 384 | const rightTime = new Date(right.updatedAt ?? right.createdAt).getTime(); | 270 | readonly onClose: () => void; |
| 385 | return rightTime - leftTime; | 271 | readonly onSelectProject: (project: ScopedMobileProject) => Promise<void>; |
| 386 | }); | 272 | }) { |
| 387 | } | 273 | if (!props.group) { |
| 388 | 274 | return null; | |
| 389 | function makePalette(isDarkMode: boolean) { | ||
| 390 | if (isDarkMode) { | ||
| 391 | return { | ||
| 392 | canvas: "#11100e", | ||
| 393 | panel: "#1a1815", | ||
| 394 | panelAlt: "#221f1b", | ||
| 395 | border: "rgba(255,255,255,0.08)", | ||
| 396 | text: "#f5f1ea", | ||
| 397 | muted: "#a69e92", | ||
| 398 | rule: "rgba(255,255,255,0.1)", | ||
| 399 | tabActive: "#f5f1ea", | ||
| 400 | tabActiveText: "#171512", | ||
| 401 | tabInactive: "#2a2723", | ||
| 402 | tabInactiveText: "#b2ab9f", | ||
| 403 | rail: "#201d19", | ||
| 404 | action: "#f5f1ea", | ||
| 405 | actionText: "#171512", | ||
| 406 | actionSecondary: "#2b2823", | ||
| 407 | actionSecondaryText: "#f5f1ea", | ||
| 408 | }; | ||
| 409 | } | 275 | } |
| 410 | 276 | ||
| 411 | return { | ||
| 412 | canvas: "#efe7dc", | ||
| 413 | panel: "#fbf7f1", | ||
| 414 | panelAlt: "#f3ede3", | ||
| 415 | border: "#d8cec1", | ||
| 416 | text: "#1f1b17", | ||
| 417 | muted: "#8a8175", | ||
| 418 | rule: "#ddd2c5", | ||
| 419 | tabActive: "#2c2a2d", | ||
| 420 | tabActiveText: "#f8f4ee", | ||
| 421 | tabInactive: "#ffffff", | ||
| 422 | tabInactiveText: "#b0a79b", | ||
| 423 | rail: "#f3ede3", | ||
| 424 | action: "#2c2a2d", | ||
| 425 | actionText: "#f8f4ee", | ||
| 426 | actionSecondary: "#ffffff", | ||
| 427 | actionSecondaryText: "#1f1b17", | ||
| 428 | }; | ||
| 429 | } | ||
| 430 | |||
| 431 | export function ThreadListScreen(props: ThreadListScreenProps) { | ||
| 432 | const isDarkMode = useColorScheme() === "dark"; | ||
| 433 | const palette = makePalette(isDarkMode); | ||
| 434 | const [expandedProjectIds, setExpandedProjectIds] = useState<ReadonlySet<string>>(new Set()); | ||
| 435 | const groupedProjects = props.projects.map((project) => ({ | ||
| 436 | project, | ||
| 437 | scopedProjectId: scopedProjectKey(project.environmentId, project.id), | ||
| 438 | threads: props.threads.filter( | ||
| 439 | (thread) => thread.environmentId === project.environmentId && thread.projectId === project.id, | ||
| 440 | ), | ||
| 441 | })); | ||
| 442 | |||
| 443 | return ( | 277 | return ( |
| 444 | <View className="flex-1" style={{ backgroundColor: palette.canvas }}> | 278 | <Modal transparent animationType="slide" visible={props.visible} onRequestClose={props.onClose}> |
| 445 | <View className="absolute inset-x-0 top-0 z-20"> | 279 | <View className="flex-1 justify-end" style={{ backgroundColor: "rgba(10,10,10,0.24)" }}> |
| 446 | <GlassSafeAreaView | 280 | <View className="gap-4 rounded-t-[28px] bg-stone-50 px-5 pb-8 pt-5"> |
| 447 | leftSlot={ | 281 | <View className="gap-1"> |
| 448 | <View className="flex-row items-center gap-2"> | 282 | <Text className="text-[22px] font-t3-bold" style={{ color: "#171717" }}> |
| 449 | <T3Wordmark color={isDarkMode ? "#ffffff" : "#020617"} /> | 283 | New thread in {props.group.title} |
| 450 | <Text | 284 | </Text> |
| 451 | className="text-[16px] font-extrabold uppercase" | 285 | <Text className="text-[13px] font-medium leading-[19px]" style={{ color: "#57534e" }}> |
| 452 | style={{ | 286 | Choose which environment should own the new thread. |
| 453 | color: isDarkMode ? "rgba(255,255,255,0.72)" : "#020617", | 287 | </Text> |
| 454 | letterSpacing: 1.1, | 288 | </View> |
| 289 | |||
| 290 | <ScrollView showsVerticalScrollIndicator={false} contentContainerStyle={{ gap: 12 }}> | ||
| 291 | {props.group.projects.map(({ project, threads }) => ( | ||
| 292 | <Pressable | ||
| 293 | key={project.environmentId + project.id} | ||
| 294 | className="gap-2 rounded-[20px] border bg-white px-4 py-4" | ||
| 295 | style={{ borderColor: "rgba(23,23,23,0.08)" }} | ||
| 296 | onPress={() => { | ||
| 297 | void props.onSelectProject(project).then(props.onClose); | ||
| 455 | }} | 298 | }} |
| 456 | > | 299 | > |
| 457 | Code | 300 | <View className="flex-row items-start justify-between gap-3"> |
| 458 | </Text> | 301 | <View className="flex-1 gap-1"> |
| 459 | </View> | 302 | <Text className="text-[16px] font-t3-bold" style={{ color: "#171717" }}> |
| 460 | } | 303 | {project.environmentLabel} |
| 461 | /> | 304 | </Text> |
| 462 | </View> | ||
| 463 | |||
| 464 | <ScrollView className="flex-1" showsVerticalScrollIndicator={false}> | ||
| 465 | <View className="overflow-hidden px-5 pb-12 pt-[132px]"> | ||
| 466 | <View className="gap-4"> | ||
| 467 | {props.connectionError ? <ErrorBanner message={props.connectionError} /> : null} | ||
| 468 | |||
| 469 | <SectionPanel palette={palette}> | ||
| 470 | <View className="gap-5 px-4 py-4"> | ||
| 471 | <View className="flex-row items-start justify-between gap-4"> | ||
| 472 | <View className="flex-1 gap-2"> | ||
| 473 | <Text | 305 | <Text |
| 474 | className="text-[11px] font-bold uppercase" | 306 | className="text-[12px] font-medium leading-[18px]" |
| 475 | style={{ color: palette.muted, letterSpacing: 1.3 }} | 307 | style={{ color: "#57534e" }} |
| 476 | > | 308 | > |
| 477 | Control board | 309 | {project.workspaceRoot} |
| 478 | </Text> | 310 | </Text> |
| 479 | </View> | 311 | </View> |
| 480 | 312 | <View | |
| 481 | <View className="items-end gap-2"> | 313 | className="rounded-full px-3 py-2" |
| 482 | <ConnectionStatusDot | 314 | style={{ backgroundColor: "rgba(23,23,23,0.06)" }} |
| 483 | state={props.connectionState} | 315 | > |
| 484 | pulse={props.connectionPulse} | ||
| 485 | /> | ||
| 486 | </View> | ||
| 487 | </View> | ||
| 488 | |||
| 489 | <View className="gap-5"> | ||
| 490 | <View className="flex-row gap-6"> | ||
| 491 | <StatCell | ||
| 492 | icon="stack" | ||
| 493 | label="Threads" | ||
| 494 | value={String(props.threads.length)} | ||
| 495 | palette={palette} | ||
| 496 | /> | ||
| 497 | <StatCell | ||
| 498 | icon="link" | ||
| 499 | label="Backends" | ||
| 500 | value={String(props.connectedEnvironmentCount)} | ||
| 501 | palette={palette} | ||
| 502 | /> | ||
| 503 | <StatCell | ||
| 504 | icon="folder" | ||
| 505 | label="Projects" | ||
| 506 | value={String(groupedProjects.length)} | ||
| 507 | palette={palette} | ||
| 508 | /> | ||
| 509 | </View> | ||
| 510 | |||
| 511 | <View className="flex-row items-center gap-2"> | ||
| 512 | <Glyph name="device" color={palette.muted} size={14} /> | ||
| 513 | <Text | 316 | <Text |
| 514 | className="text-[11px] font-bold uppercase" | 317 | className="text-[11px] font-t3-bold uppercase" |
| 515 | style={{ color: palette.muted, letterSpacing: 1.05 }} | 318 | style={{ color: "#171717", letterSpacing: 0.9 }} |
| 516 | > | 319 | > |
| 517 | {props.hasClient ? "Connected" : "Awaiting connection"} | 320 | {threads.length} thread{threads.length === 1 ? "" : "s"} |
| 518 | </Text> | 321 | </Text> |
| 519 | </View> | 322 | </View> |
| 520 | |||
| 521 | <View className="flex-row gap-3"> | ||
| 522 | <Pressable | ||
| 523 | accessibilityLabel={ | ||
| 524 | props.hasClient ? "Edit remote link" : "Connect to server" | ||
| 525 | } | ||
| 526 | className="min-h-[52px] flex-1 flex-row items-center justify-center gap-2 px-3 py-3" | ||
| 527 | style={{ backgroundColor: palette.action }} | ||
| 528 | onPress={props.onOpenConnectionEditor} | ||
| 529 | > | ||
| 530 | <Glyph | ||
| 531 | name={props.hasClient ? "link" : "device"} | ||
| 532 | color={palette.actionText} | ||
| 533 | size={18} | ||
| 534 | /> | ||
| 535 | <Text | ||
| 536 | className="text-[13px] font-extrabold uppercase" | ||
| 537 | style={{ color: palette.actionText, letterSpacing: 1 }} | ||
| 538 | > | ||
| 539 | {props.hasClient ? "Link" : "Connect"} | ||
| 540 | </Text> | ||
| 541 | </Pressable> | ||
| 542 | |||
| 543 | {props.hasClient ? ( | ||
| 544 | <Pressable | ||
| 545 | accessibilityLabel="Refresh threads" | ||
| 546 | className="min-h-[52px] flex-1 flex-row items-center justify-center gap-2 px-3 py-3" | ||
| 547 | style={{ | ||
| 548 | backgroundColor: palette.actionSecondary, | ||
| 549 | borderWidth: 1, | ||
| 550 | borderColor: palette.border, | ||
| 551 | }} | ||
| 552 | onPress={() => void props.onRefresh()} | ||
| 553 | > | ||
| 554 | <Glyph name="refresh" color={palette.actionSecondaryText} size={18} /> | ||
| 555 | <Text | ||
| 556 | className="text-[13px] font-extrabold uppercase" | ||
| 557 | style={{ color: palette.actionSecondaryText, letterSpacing: 1 }} | ||
| 558 | > | ||
| 559 | Refresh | ||
| 560 | </Text> | ||
| 561 | </Pressable> | ||
| 562 | ) : null} | ||
| 563 | </View> | ||
| 564 | </View> | 323 | </View> |
| 565 | </View> | 324 | </Pressable> |
| 566 | </SectionPanel> | 325 | ))} |
| 326 | </ScrollView> | ||
| 567 | 327 | ||
| 568 | {props.threads.length === 0 ? ( | 328 | <ActionButton icon="xmark" label="Close" onPress={props.onClose} /> |
| 569 | <EmptyState | 329 | </View> |
| 570 | title={props.hasClient ? "No threads yet" : "No connection yet"} | 330 | </View> |
| 571 | detail={ | 331 | </Modal> |
| 572 | props.hasClient | 332 | ); |
| 573 | ? "Create a thread below or resume one in another T3 client, then refresh this screen." | 333 | } |
| 574 | : "Connect this mobile app to your T3 server to load active threads." | ||
| 575 | } | ||
| 576 | /> | ||
| 577 | ) : null} | ||
| 578 | |||
| 579 | {groupedProjects.map(({ project, scopedProjectId, threads }) => { | ||
| 580 | const sortedThreads = orderThreadsByRecency(threads); | ||
| 581 | const latestThread = sortedThreads[0]; | ||
| 582 | const remainingThreads = sortedThreads.slice(1); | ||
| 583 | const isExpanded = expandedProjectIds.has(scopedProjectId); | ||
| 584 | 334 | ||
| 585 | return ( | 335 | export function ThreadListScreen(props: ThreadListScreenProps) { |
| 586 | <SectionPanel key={scopedProjectId} palette={palette}> | 336 | const isDarkMode = useColorScheme() === "dark"; |
| 587 | <View className="gap-4"> | 337 | const repositoryGroups = useMemo( |
| 588 | <View | 338 | () => groupProjectsByRepository({ projects: props.projects, threads: props.threads }), |
| 589 | className="gap-2 px-4 py-4" | 339 | [props.projects, props.threads], |
| 590 | style={{ borderBottomWidth: 1, borderBottomColor: palette.border }} | 340 | ); |
| 591 | > | 341 | const [createTarget, setCreateTarget] = useState<MobileRepositoryGroup | null>(null); |
| 592 | <View className="flex-row items-start justify-between gap-3"> | 342 | const [refreshing, setRefreshing] = useState(false); |
| 593 | <View className="flex-1 gap-1.5"> | 343 | const isSplitLayout = props.layout.usesSplitView; |
| 594 | <Glyph name="folder" color={palette.muted} size={15} /> | 344 | const contentHorizontalPadding = isSplitLayout ? 18 : 20; |
| 595 | <Text | 345 | const panelBorderColor = isSplitLayout ? "rgba(23,23,23,0.08)" : "transparent"; |
| 596 | className="text-[22px] font-extrabold leading-[26px]" | 346 | const panelBackgroundColor = isDarkMode ? "#0c1118" : "#f7f7f5"; |
| 597 | style={{ color: palette.text }} | 347 | const refreshThreads = props.onRefresh; |
| 598 | > | 348 | |
| 599 | {project.title} | 349 | const handleRefresh = useCallback(async (): Promise<void> => { |
| 600 | </Text> | 350 | if (refreshing) { |
| 601 | </View> | 351 | return; |
| 352 | } | ||
| 353 | |||
| 354 | setRefreshing(true); | ||
| 355 | try { | ||
| 356 | await refreshThreads(); | ||
| 357 | } finally { | ||
| 358 | setRefreshing(false); | ||
| 359 | } | ||
| 360 | }, [refreshThreads, refreshing]); | ||
| 602 | 361 | ||
| 603 | <View | 362 | return ( |
| 604 | className="flex-row items-center gap-2 px-3 py-2" | 363 | <View |
| 605 | style={{ backgroundColor: palette.tabActive }} | 364 | className="flex-1" |
| 606 | > | 365 | style={{ |
| 607 | <Glyph name="stack" color={palette.tabActiveText} size={14} /> | 366 | backgroundColor: panelBackgroundColor, |
| 608 | <Text | 367 | borderRadius: isSplitLayout ? 32 : 0, |
| 609 | className="text-[11px] font-bold uppercase" | 368 | borderWidth: isSplitLayout ? 1 : 0, |
| 610 | style={{ color: palette.tabActiveText, letterSpacing: 1.1 }} | 369 | borderColor: panelBorderColor, |
| 611 | > | 370 | overflow: "hidden", |
| 612 | {threads.length} | 371 | }} |
| 613 | </Text> | 372 | > |
| 614 | </View> | 373 | <View className="absolute inset-x-0 top-0 z-20"> |
| 615 | </View> | 374 | <GlassSafeAreaView |
| 375 | leftSlot={props.showBrandWordmark ? <BrandMark compact dark={isDarkMode} /> : null} | ||
| 376 | /> | ||
| 377 | </View> | ||
| 616 | 378 | ||
| 617 | <Text className="text-[13px] leading-[18px]" style={{ color: palette.muted }}> | 379 | <ScrollView |
| 618 | {project.environmentLabel} · {project.workspaceRoot} | 380 | className="flex-1" |
| 619 | </Text> | 381 | showsVerticalScrollIndicator={false} |
| 382 | refreshControl={ | ||
| 383 | <RefreshControl refreshing={refreshing} onRefresh={() => void handleRefresh()} /> | ||
| 384 | } | ||
| 385 | contentContainerStyle={{ | ||
| 386 | gap: 16, | ||
| 387 | paddingHorizontal: contentHorizontalPadding, | ||
| 388 | paddingBottom: isSplitLayout ? 32 : 48, | ||
| 389 | paddingTop: isSplitLayout ? 112 : 124, | ||
| 390 | }} | ||
| 391 | > | ||
| 392 | <Animated.View | ||
| 393 | entering={FadeInDown.duration(260)} | ||
| 394 | layout={LinearTransition.springify().damping(18).stiffness(180)} | ||
| 395 | className="gap-4 rounded-[26px] px-4 py-4" | ||
| 396 | style={{ | ||
| 397 | backgroundColor: "rgba(255,255,255,0.94)", | ||
| 398 | borderWidth: 1, | ||
| 399 | borderColor: "rgba(23,23,23,0.08)", | ||
| 400 | }} | ||
| 401 | > | ||
| 402 | <View className="flex-row items-start justify-between gap-4"> | ||
| 403 | <View className="flex-1 gap-2"> | ||
| 404 | <Text | ||
| 405 | className="text-[11px] font-t3-bold uppercase" | ||
| 406 | style={{ color: "#78716c", letterSpacing: 1.1 }} | ||
| 407 | > | ||
| 408 | {props.heroTitle} | ||
| 409 | </Text> | ||
| 410 | <Text className="text-[26px] font-t3-bold" style={{ color: "#171717" }}> | ||
| 411 | {isSplitLayout ? "Native workspace" : "Repo board"} | ||
| 412 | </Text> | ||
| 413 | <Text className="text-[13px] font-medium leading-[19px]" style={{ color: "#57534e" }}> | ||
| 414 | {isSplitLayout | ||
| 415 | ? "Keep your repositories visible while you move between active threads." | ||
| 416 | : "Your connected repositories, grouped by identity across environments."} | ||
| 417 | </Text> | ||
| 418 | </View> | ||
| 419 | <ConnectionStatusDot state={props.connectionState} pulse={props.connectionPulse} /> | ||
| 420 | </View> | ||
| 620 | 421 | ||
| 621 | <ProjectActionButton | 422 | <View className="flex-row gap-3"> |
| 622 | palette={palette} | 423 | <StatCard label="Repos" value={String(repositoryGroups.length)} icon="shippingbox" /> |
| 623 | label="New thread" | 424 | <StatCard |
| 624 | icon="spark" | 425 | label="Threads" |
| 625 | onPress={() => void props.onCreateThread(project)} | 426 | value={String(props.threads.length)} |
| 626 | /> | 427 | icon="square.stack.3d.up" |
| 627 | </View> | 428 | /> |
| 429 | <StatCard label="Envs" value={String(props.connectedEnvironmentCount)} icon="link" /> | ||
| 430 | </View> | ||
| 628 | 431 | ||
| 629 | <Animated.View className="px-4 py-1" layout={LinearTransition.duration(220)}> | 432 | <View className="flex-row gap-3"> |
| 630 | {latestThread ? ( | 433 | <ActionButton |
| 631 | <ThreadRow | 434 | icon={props.hasClient ? "link" : "iphone"} |
| 632 | key={scopedThreadKey(latestThread.environmentId, latestThread.id)} | 435 | label={props.hasClient ? "Edit link" : "Connect"} |
| 633 | thread={latestThread} | 436 | primary |
| 634 | hidden={ | 437 | onPress={props.onOpenConnectionEditor} |
| 635 | props.hiddenThreadKey === | 438 | /> |
| 636 | scopedThreadKey(latestThread.environmentId, latestThread.id) | 439 | <ActionButton |
| 637 | } | 440 | icon="arrow.clockwise" |
| 638 | isLast={!isExpanded || remainingThreads.length === 0} | 441 | label="Refresh" |
| 639 | onPress={(sourceFrame) => props.onSelectThread(latestThread, sourceFrame)} | 442 | onPress={() => void handleRefresh()} |
| 640 | palette={palette} | 443 | /> |
| 641 | /> | 444 | </View> |
| 642 | ) : ( | 445 | </Animated.View> |
| 643 | <View className="px-1 py-6"> | 446 | |
| 644 | <EmptyState | 447 | {repositoryGroups.length === 0 ? ( |
| 645 | title="No threads in this project" | 448 | <EmptyState |
| 646 | detail="Start a new thread here to begin working from mobile." | 449 | title={props.hasClient ? "No repositories yet" : "No connection yet"} |
| 647 | /> | 450 | detail={ |
| 648 | </View> | 451 | props.hasClient |
| 649 | )} | 452 | ? "Connect an environment with indexed projects, then refresh this screen." |
| 453 | : "Connect this phone to a T3 environment to load repositories and threads." | ||
| 454 | } | ||
| 455 | /> | ||
| 456 | ) : null} | ||
| 457 | |||
| 458 | {repositoryGroups.map((group, index) => ( | ||
| 459 | <Animated.View | ||
| 460 | key={group.key} | ||
| 461 | entering={FadeInDown.duration(260).delay(40 * Math.min(index, 6))} | ||
| 462 | layout={LinearTransition.springify().damping(18).stiffness(180)} | ||
| 463 | className="gap-4 rounded-[26px] px-4 py-4" | ||
| 464 | style={{ | ||
| 465 | backgroundColor: "rgba(255,255,255,0.94)", | ||
| 466 | borderWidth: 1, | ||
| 467 | borderColor: "rgba(23,23,23,0.08)", | ||
| 468 | }} | ||
| 469 | > | ||
| 470 | <View className="flex-row items-start justify-between gap-4"> | ||
| 471 | <View className="flex-1 gap-1"> | ||
| 472 | <Text className="text-[22px] font-t3-bold" style={{ color: "#171717" }}> | ||
| 473 | {group.title} | ||
| 474 | </Text> | ||
| 475 | {group.subtitle ? ( | ||
| 476 | <Text | ||
| 477 | className="text-[13px] font-medium leading-[19px]" | ||
| 478 | style={{ color: "#57534e" }} | ||
| 479 | > | ||
| 480 | {group.subtitle} | ||
| 481 | </Text> | ||
| 482 | ) : null} | ||
| 483 | <Text | ||
| 484 | className="text-[11px] font-t3-bold uppercase" | ||
| 485 | style={{ color: "#78716c", letterSpacing: 0.9 }} | ||
| 486 | > | ||
| 487 | {group.projectCount} environment{group.projectCount === 1 ? "" : "s"} ·{" "} | ||
| 488 | {group.threadCount} active thread{group.threadCount === 1 ? "" : "s"} | ||
| 489 | </Text> | ||
| 490 | </View> | ||
| 650 | 491 | ||
| 651 | {isExpanded && remainingThreads.length > 0 ? ( | 492 | <Pressable |
| 652 | <Animated.View | 493 | className="rounded-full px-3 py-2" |
| 653 | entering={FadeInDown.duration(220)} | 494 | style={{ backgroundColor: "#171717" }} |
| 654 | exiting={FadeOutUp.duration(180)} | 495 | onPress={() => { |
| 655 | layout={LinearTransition.duration(220)} | 496 | if (group.projects.length === 1) { |
| 656 | > | 497 | void props.onCreateThread(group.projects[0]!.project); |
| 657 | {remainingThreads.map((thread, index) => ( | 498 | return; |
| 658 | <ThreadRow | 499 | } |
| 659 | key={scopedThreadKey(thread.environmentId, thread.id)} | 500 | setCreateTarget(group); |
| 660 | thread={thread} | 501 | }} |
| 661 | hidden={ | 502 | > |
| 662 | props.hiddenThreadKey === | 503 | <Text |
| 663 | scopedThreadKey(thread.environmentId, thread.id) | 504 | className="text-[11px] font-t3-bold uppercase" |
| 664 | } | 505 | style={{ color: "#fafaf9", letterSpacing: 0.9 }} |
| 665 | isLast={index === remainingThreads.length - 1} | 506 | > |
| 666 | onPress={(sourceFrame) => props.onSelectThread(thread, sourceFrame)} | 507 | New thread |
| 667 | palette={palette} | 508 | </Text> |
| 668 | /> | 509 | </Pressable> |
| 669 | ))} | 510 | </View> |
| 670 | </Animated.View> | ||
| 671 | ) : null} | ||
| 672 | 511 | ||
| 673 | {remainingThreads.length > 0 ? ( | 512 | <View className="gap-3"> |
| 674 | <ExpandThreadsToggle | 513 | {group.projects.map(({ project, threads }) => ( |
| 675 | expanded={isExpanded} | 514 | <ProjectSection |
| 676 | hiddenCount={remainingThreads.length} | 515 | key={project.environmentId + project.id} |
| 677 | palette={palette} | 516 | project={project} |
| 678 | onPress={() => { | 517 | threads={threads} |
| 679 | setExpandedProjectIds((current) => { | 518 | hiddenThreadKey={props.hiddenThreadKey} |
| 680 | const next = new Set(current); | 519 | selectedThreadKey={props.selectedThreadKey} |
| 681 | if (next.has(scopedProjectId)) { | 520 | onCreateThread={props.onCreateThread} |
| 682 | next.delete(scopedProjectId); | 521 | onSelectThread={props.onSelectThread} |
| 683 | } else { | 522 | /> |
| 684 | next.add(scopedProjectId); | 523 | ))} |
| 685 | } | 524 | </View> |
| 686 | return next; | 525 | </Animated.View> |
| 687 | }); | 526 | ))} |
| 688 | }} | ||
| 689 | /> | ||
| 690 | ) : null} | ||
| 691 | </Animated.View> | ||
| 692 | </View> | ||
| 693 | </SectionPanel> | ||
| 694 | ); | ||
| 695 | })} | ||
| 696 | </View> | ||
| 697 | </View> | ||
| 698 | </ScrollView> | 527 | </ScrollView> |
| 528 | |||
| 529 | <CreateThreadModal | ||
| 530 | group={createTarget} | ||
| 531 | visible={createTarget !== null} | ||
| 532 | onClose={() => setCreateTarget(null)} | ||
| 533 | onSelectProject={props.onCreateThread} | ||
| 534 | /> | ||
| 699 | </View> | 535 | </View> |
| 700 | ); | 536 | ); |
| 701 | } | 537 | } |
| @@ -0,0 +1,250 @@ | |||
| 1 | import { SymbolView } from "expo-symbols"; | ||
| 2 | import { useCallback, useEffect, useMemo, useState } from "react"; | ||
| 3 | import { Modal, Pressable, ScrollView, useWindowDimensions, View } from "react-native"; | ||
| 4 | import { Gesture, GestureDetector } from "react-native-gesture-handler"; | ||
| 5 | import { useSafeAreaInsets } from "react-native-safe-area-context"; | ||
| 6 | import Animated, { | ||
| 7 | runOnJS, | ||
| 8 | useAnimatedStyle, | ||
| 9 | useSharedValue, | ||
| 10 | withTiming, | ||
| 11 | } from "react-native-reanimated"; | ||
| 12 | |||
| 13 | import { AppText as Text } from "../../components/AppText"; | ||
| 14 | import { StatusPill } from "../../components/StatusPill"; | ||
| 15 | import { groupProjectsByRepository } from "../../lib/repositoryGroups"; | ||
| 16 | import type { ScopedMobileProject, ScopedMobileThread } from "../../lib/scopedEntities"; | ||
| 17 | import { scopedThreadKey } from "../../lib/scopedEntities"; | ||
| 18 | import { relativeTime } from "../../lib/time"; | ||
| 19 | import { threadStatusTone } from "./threadPresentation"; | ||
| 20 | |||
| 21 | function compareThreadActivity(left: ScopedMobileThread, right: ScopedMobileThread): number { | ||
| 22 | return ( | ||
| 23 | new Date(right.updatedAt ?? right.createdAt).getTime() - | ||
| 24 | new Date(left.updatedAt ?? left.createdAt).getTime() || left.title.localeCompare(right.title) | ||
| 25 | ); | ||
| 26 | } | ||
| 27 | |||
| 28 | export function ThreadNavigationDrawer(props: { | ||
| 29 | readonly visible: boolean; | ||
| 30 | readonly projects: ReadonlyArray<ScopedMobileProject>; | ||
| 31 | readonly threads: ReadonlyArray<ScopedMobileThread>; | ||
| 32 | readonly selectedThreadKey: string | null; | ||
| 33 | readonly onClose: () => void; | ||
| 34 | readonly onSelectThread: (thread: ScopedMobileThread) => void; | ||
| 35 | readonly onStartNewTask: () => void; | ||
| 36 | }) { | ||
| 37 | const insets = useSafeAreaInsets(); | ||
| 38 | const { width } = useWindowDimensions(); | ||
| 39 | const drawerWidth = Math.min(width * 0.84, 360); | ||
| 40 | const [mounted, setMounted] = useState(props.visible); | ||
| 41 | const translateX = useSharedValue(-drawerWidth); | ||
| 42 | const overlayOpacity = useSharedValue(0); | ||
| 43 | const repositoryGroups = useMemo( | ||
| 44 | () => groupProjectsByRepository({ projects: props.projects, threads: props.threads }), | ||
| 45 | [props.projects, props.threads], | ||
| 46 | ); | ||
| 47 | const groupedThreads = useMemo( | ||
| 48 | () => | ||
| 49 | repositoryGroups.map((group) => ({ | ||
| 50 | key: group.key, | ||
| 51 | title: group.projects[0]?.project.title ?? group.title, | ||
| 52 | threads: group.projects | ||
| 53 | .flatMap((projectGroup) => projectGroup.threads) | ||
| 54 | .sort(compareThreadActivity), // oxlint-disable-line eslint-plugin-unicorn/no-array-sort | ||
| 55 | })), | ||
| 56 | [repositoryGroups], | ||
| 57 | ); | ||
| 58 | |||
| 59 | useEffect(() => { | ||
| 60 | if (props.visible) { | ||
| 61 | setMounted(true); | ||
| 62 | translateX.value = withTiming(0, { duration: 240 }); | ||
| 63 | overlayOpacity.value = withTiming(1, { duration: 220 }); | ||
| 64 | return; | ||
| 65 | } | ||
| 66 | |||
| 67 | overlayOpacity.value = withTiming(0, { duration: 180 }); | ||
| 68 | translateX.value = withTiming(-drawerWidth, { duration: 220 }, (finished) => { | ||
| 69 | if (finished) { | ||
| 70 | runOnJS(setMounted)(false); | ||
| 71 | } | ||
| 72 | }); | ||
| 73 | }, [drawerWidth, overlayOpacity, props.visible, translateX]); | ||
| 74 | |||
| 75 | const closeDrawer = useCallback(() => { | ||
| 76 | props.onClose(); | ||
| 77 | }, [props]); | ||
| 78 | |||
| 79 | const panGesture = useMemo( | ||
| 80 | () => | ||
| 81 | Gesture.Pan() | ||
| 82 | .activeOffsetX([-12, 12]) | ||
| 83 | .failOffsetY([-24, 24]) | ||
| 84 | .onUpdate((event) => { | ||
| 85 | translateX.value = Math.min(0, event.translationX); | ||
| 86 | }) | ||
| 87 | .onEnd((event) => { | ||
| 88 | const shouldClose = event.translationX < -drawerWidth * 0.2 || event.velocityX < -500; | ||
| 89 | if (shouldClose) { | ||
| 90 | runOnJS(closeDrawer)(); | ||
| 91 | return; | ||
| 92 | } | ||
| 93 | |||
| 94 | translateX.value = withTiming(0, { duration: 180 }); | ||
| 95 | }), | ||
| 96 | [closeDrawer, drawerWidth, translateX], | ||
| 97 | ); | ||
| 98 | |||
| 99 | const drawerStyle = useAnimatedStyle(() => ({ | ||
| 100 | transform: [{ translateX: translateX.value }], | ||
| 101 | })); | ||
| 102 | |||
| 103 | const backdropStyle = useAnimatedStyle(() => ({ | ||
| 104 | opacity: overlayOpacity.value, | ||
| 105 | })); | ||
| 106 | |||
| 107 | if (!mounted) { | ||
| 108 | return null; | ||
| 109 | } | ||
| 110 | |||
| 111 | return ( | ||
| 112 | <Modal transparent visible={mounted} onRequestClose={props.onClose} statusBarTranslucent> | ||
| 113 | <View style={{ flex: 1 }}> | ||
| 114 | <Animated.View | ||
| 115 | pointerEvents="none" | ||
| 116 | style={[ | ||
| 117 | { | ||
| 118 | position: "absolute", | ||
| 119 | left: 0, | ||
| 120 | right: 0, | ||
| 121 | top: 0, | ||
| 122 | bottom: 0, | ||
| 123 | backgroundColor: "rgba(15,23,42,0.22)", | ||
| 124 | }, | ||
| 125 | backdropStyle, | ||
| 126 | ]} | ||
| 127 | /> | ||
| 128 | <Pressable | ||
| 129 | style={{ position: "absolute", left: 0, right: 0, top: 0, bottom: 0 }} | ||
| 130 | onPress={props.onClose} | ||
| 131 | /> | ||
| 132 | |||
| 133 | <GestureDetector gesture={panGesture}> | ||
| 134 | <Animated.View | ||
| 135 | style={[ | ||
| 136 | { | ||
| 137 | position: "absolute", | ||
| 138 | left: 0, | ||
| 139 | top: 0, | ||
| 140 | bottom: 0, | ||
| 141 | width: drawerWidth, | ||
| 142 | backgroundColor: "rgba(250,248,242,0.99)", | ||
| 143 | paddingTop: insets.top + 10, | ||
| 144 | paddingBottom: Math.max(insets.bottom, 18), | ||
| 145 | boxShadow: "20px 0 36px rgba(15,23,42,0.12)", | ||
| 146 | }, | ||
| 147 | drawerStyle, | ||
| 148 | ]} | ||
| 149 | > | ||
| 150 | <View className="flex-row items-center justify-between px-4 pb-5"> | ||
| 151 | <Text className="text-[26px] font-t3-bold" style={{ color: "#171717" }}> | ||
| 152 | Threads | ||
| 153 | </Text> | ||
| 154 | <Pressable | ||
| 155 | onPress={() => { | ||
| 156 | props.onClose(); | ||
| 157 | props.onStartNewTask(); | ||
| 158 | }} | ||
| 159 | className="h-11 w-11 items-center justify-center rounded-full" | ||
| 160 | style={{ backgroundColor: "#171717" }} | ||
| 161 | > | ||
| 162 | <SymbolView | ||
| 163 | name="square.and.pencil" | ||
| 164 | size={17} | ||
| 165 | tintColor="#fafaf9" | ||
| 166 | type="monochrome" | ||
| 167 | /> | ||
| 168 | </Pressable> | ||
| 169 | </View> | ||
| 170 | |||
| 171 | <ScrollView | ||
| 172 | showsVerticalScrollIndicator={false} | ||
| 173 | contentContainerStyle={{ | ||
| 174 | gap: 20, | ||
| 175 | paddingHorizontal: 14, | ||
| 176 | paddingBottom: Math.max(insets.bottom, 18) + 12, | ||
| 177 | }} | ||
| 178 | > | ||
| 179 | {groupedThreads.map((group) => ( | ||
| 180 | <View key={group.key} className="gap-3"> | ||
| 181 | <Text | ||
| 182 | className="px-1 text-[15px] font-t3-bold" | ||
| 183 | style={{ color: "#78716c", letterSpacing: -0.2 }} | ||
| 184 | > | ||
| 185 | {group.title} | ||
| 186 | </Text> | ||
| 187 | |||
| 188 | <View | ||
| 189 | className="overflow-hidden rounded-[22px]" | ||
| 190 | style={{ backgroundColor: "#ffffff" }} | ||
| 191 | > | ||
| 192 | {group.threads.length === 0 ? ( | ||
| 193 | <View className="px-4 py-4"> | ||
| 194 | <Text className="text-[14px] font-medium" style={{ color: "#a8a29e" }}> | ||
| 195 | No threads yet | ||
| 196 | </Text> | ||
| 197 | </View> | ||
| 198 | ) : ( | ||
| 199 | group.threads.map((thread, index) => { | ||
| 200 | const threadKey = scopedThreadKey(thread.environmentId, thread.id); | ||
| 201 | const selected = props.selectedThreadKey === threadKey; | ||
| 202 | |||
| 203 | return ( | ||
| 204 | <Pressable | ||
| 205 | key={threadKey} | ||
| 206 | onPress={() => { | ||
| 207 | props.onSelectThread(thread); | ||
| 208 | props.onClose(); | ||
| 209 | }} | ||
| 210 | style={{ | ||
| 211 | paddingHorizontal: 16, | ||
| 212 | paddingVertical: 15, | ||
| 213 | borderTopWidth: index === 0 ? 0 : 1, | ||
| 214 | borderTopColor: "rgba(23,23,23,0.06)", | ||
| 215 | backgroundColor: selected ? "rgba(23,23,23,0.04)" : "transparent", | ||
| 216 | }} | ||
| 217 | > | ||
| 218 | <View className="flex-row items-start justify-between gap-3"> | ||
| 219 | <View className="flex-1 gap-1"> | ||
| 220 | <Text | ||
| 221 | className="text-[16px] font-t3-bold" | ||
| 222 | numberOfLines={1} | ||
| 223 | style={{ color: "#171717" }} | ||
| 224 | > | ||
| 225 | {thread.title} | ||
| 226 | </Text> | ||
| 227 | <Text | ||
| 228 | className="text-[13px] font-medium" | ||
| 229 | numberOfLines={1} | ||
| 230 | style={{ color: "#78716c" }} | ||
| 231 | > | ||
| 232 | {relativeTime(thread.updatedAt ?? thread.createdAt)} | ||
| 233 | </Text> | ||
| 234 | </View> | ||
| 235 | <StatusPill {...threadStatusTone(thread)} /> | ||
| 236 | </View> | ||
| 237 | </Pressable> | ||
| 238 | ); | ||
| 239 | }) | ||
| 240 | )} | ||
| 241 | </View> | ||
| 242 | </View> | ||
| 243 | ))} | ||
| 244 | </ScrollView> | ||
| 245 | </Animated.View> | ||
| 246 | </GestureDetector> | ||
| 247 | </View> | ||
| 248 | </Modal> | ||
| 249 | ); | ||
| 250 | } | ||
| @@ -76,5 +76,5 @@ export function screenTitle(config: T3ServerConfig | null, serverUrl: string | n · 75 unmodified lines | |||
| 76 | const candidate = segments.at(-1) ?? config.cwd; | 76 | const candidate = segments.at(-1) ?? config.cwd; |
| 77 | return /^t3[-_\s]?code$/i.test(candidate) ? "T3 Code" : candidate; | 77 | return /^t3[-_\s]?code$/i.test(candidate) ? "T3 Code" : candidate; |
| 78 | } | 78 | } |
| 79 | return serverUrl ?? "T3 Remote"; | 79 | return serverUrl ?? "T3 Code"; |
| 80 | } | 80 | } |
| @@ -0,0 +1,36 @@ | |||
| 1 | import { describe, expect, it } from "vitest"; | ||
| 2 | |||
| 3 | import { deriveMobileLayout } from "./mobileLayout"; | ||
| 4 | |||
| 5 | describe("deriveMobileLayout", () => { | ||
| 6 | it("keeps phones in compact mode", () => { | ||
| 7 | expect(deriveMobileLayout({ width: 393, height: 852 })).toEqual({ | ||
| 8 | variant: "compact", | ||
| 9 | usesSplitView: false, | ||
| 10 | listPaneWidth: null, | ||
| 11 | shellPadding: 0, | ||
| 12 | }); | ||
| 13 | }); | ||
| 14 | |||
| 15 | it("avoids split mode in short landscape phone layouts", () => { | ||
| 16 | expect(deriveMobileLayout({ width: 852, height: 393 }).variant).toBe("compact"); | ||
| 17 | }); | ||
| 18 | |||
| 19 | it("enables split mode on tablet-sized displays", () => { | ||
| 20 | expect(deriveMobileLayout({ width: 744, height: 1133 })).toMatchObject({ | ||
| 21 | variant: "split", | ||
| 22 | usesSplitView: true, | ||
| 23 | listPaneWidth: 320, | ||
| 24 | shellPadding: 14, | ||
| 25 | }); | ||
| 26 | }); | ||
| 27 | |||
| 28 | it("caps the sidebar width on large tablets", () => { | ||
| 29 | expect(deriveMobileLayout({ width: 1366, height: 1024 })).toMatchObject({ | ||
| 30 | variant: "split", | ||
| 31 | usesSplitView: true, | ||
| 32 | listPaneWidth: 420, | ||
| 33 | shellPadding: 20, | ||
| 34 | }); | ||
| 35 | }); | ||
| 36 | }); | ||
| @@ -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 | } | ||
| @@ -0,0 +1,104 @@ | |||
| 1 | import { describe, expect, it } from "vitest"; | ||
| 2 | |||
| 3 | import { EventId, ProjectId } from "@t3tools/contracts"; | ||
| 4 | import type { OrchestrationReadModel } from "@t3tools/contracts"; | ||
| 5 | |||
| 6 | import { applyRealtimeEvent } from "./orchestration"; | ||
| 7 | |||
| 8 | const baseSnapshot: OrchestrationReadModel = { | ||
| 9 | snapshotSequence: 0, | ||
| 10 | updatedAt: "2026-04-01T00:00:00.000Z", | ||
| 11 | projects: [], | ||
| 12 | threads: [], | ||
| 13 | }; | ||
| 14 | |||
| 15 | const baseEventFields = { | ||
| 16 | eventId: EventId.makeUnsafe("event-1"), | ||
| 17 | commandId: null, | ||
| 18 | causationEventId: null, | ||
| 19 | correlationId: null, | ||
| 20 | metadata: {}, | ||
| 21 | } as const; | ||
| 22 | |||
| 23 | describe("applyRealtimeEvent project identity handling", () => { | ||
| 24 | it("preserves repository identity on project.created", () => { | ||
| 25 | const next = applyRealtimeEvent(baseSnapshot, { | ||
| 26 | ...baseEventFields, | ||
| 27 | sequence: 1, | ||
| 28 | occurredAt: "2026-04-01T01:00:00.000Z", | ||
| 29 | aggregateKind: "project", | ||
| 30 | aggregateId: ProjectId.makeUnsafe("project-1"), | ||
| 31 | type: "project.created", | ||
| 32 | payload: { | ||
| 33 | projectId: ProjectId.makeUnsafe("project-1"), | ||
| 34 | title: "T3 Code", | ||
| 35 | workspaceRoot: "/repo", | ||
| 36 | repositoryIdentity: { | ||
| 37 | canonicalKey: "github.com/t3tools/t3code", | ||
| 38 | locator: { | ||
| 39 | source: "git-remote", | ||
| 40 | remoteName: "origin", | ||
| 41 | remoteUrl: "git@github.com:t3tools/t3code.git", | ||
| 42 | }, | ||
| 43 | provider: "github", | ||
| 44 | owner: "t3tools", | ||
| 45 | name: "t3code", | ||
| 46 | }, | ||
| 47 | defaultModelSelection: null, | ||
| 48 | scripts: [], | ||
| 49 | createdAt: "2026-04-01T01:00:00.000Z", | ||
| 50 | updatedAt: "2026-04-01T01:00:00.000Z", | ||
| 51 | }, | ||
| 52 | }); | ||
| 53 | |||
| 54 | expect(next.projects[0]?.repositoryIdentity?.canonicalKey).toBe("github.com/t3tools/t3code"); | ||
| 55 | }); | ||
| 56 | |||
| 57 | it("applies repository identity updates on project.meta-updated", () => { | ||
| 58 | const seeded = applyRealtimeEvent(baseSnapshot, { | ||
| 59 | ...baseEventFields, | ||
| 60 | sequence: 1, | ||
| 61 | occurredAt: "2026-04-01T01:00:00.000Z", | ||
| 62 | aggregateKind: "project", | ||
| 63 | aggregateId: ProjectId.makeUnsafe("project-1"), | ||
| 64 | type: "project.created", | ||
| 65 | payload: { | ||
| 66 | projectId: ProjectId.makeUnsafe("project-1"), | ||
| 67 | title: "T3 Code", | ||
| 68 | workspaceRoot: "/repo", | ||
| 69 | repositoryIdentity: null, | ||
| 70 | defaultModelSelection: null, | ||
| 71 | scripts: [], | ||
| 72 | createdAt: "2026-04-01T01:00:00.000Z", | ||
| 73 | updatedAt: "2026-04-01T01:00:00.000Z", | ||
| 74 | }, | ||
| 75 | }); | ||
| 76 | |||
| 77 | const next = applyRealtimeEvent(seeded, { | ||
| 78 | ...baseEventFields, | ||
| 79 | eventId: EventId.makeUnsafe("event-2"), | ||
| 80 | sequence: 2, | ||
| 81 | occurredAt: "2026-04-01T02:00:00.000Z", | ||
| 82 | aggregateKind: "project", | ||
| 83 | aggregateId: ProjectId.makeUnsafe("project-1"), | ||
| 84 | type: "project.meta-updated", | ||
| 85 | payload: { | ||
| 86 | projectId: ProjectId.makeUnsafe("project-1"), | ||
| 87 | repositoryIdentity: { | ||
| 88 | canonicalKey: "github.com/t3tools/t3code", | ||
| 89 | locator: { | ||
| 90 | source: "git-remote", | ||
| 91 | remoteName: "origin", | ||
| 92 | remoteUrl: "git@github.com:t3tools/t3code.git", | ||
| 93 | }, | ||
| 94 | provider: "github", | ||
| 95 | owner: "t3tools", | ||
| 96 | name: "t3code", | ||
| 97 | }, | ||
| 98 | updatedAt: "2026-04-01T02:00:00.000Z", | ||
| 99 | }, | ||
| 100 | }); | ||
| 101 | |||
| 102 | expect(next.projects[0]?.repositoryIdentity?.canonicalKey).toBe("github.com/t3tools/t3code"); | ||
| 103 | }); | ||
| 104 | }); | ||
| @@ -71,6 +71,7 @@ export function applyRealtimeEvent( · 70 unmodified lines | |||
| 71 | id: event.payload.projectId, | 71 | id: event.payload.projectId, |
| 72 | title: event.payload.title, | 72 | title: event.payload.title, |
| 73 | workspaceRoot: event.payload.workspaceRoot, | 73 | workspaceRoot: event.payload.workspaceRoot, |
| 74 | repositoryIdentity: event.payload.repositoryIdentity ?? null, | ||
| 74 | defaultModelSelection: event.payload.defaultModelSelection, | 75 | defaultModelSelection: event.payload.defaultModelSelection, |
| 75 | scripts: event.payload.scripts, | 76 | scripts: event.payload.scripts, |
| 76 | createdAt: event.payload.createdAt, | 77 | createdAt: event.payload.createdAt, |
| @@ -88,6 +89,9 @@ export function applyRealtimeEvent( · 11 unmodified lines | |||
| 88 | ...(event.payload.workspaceRoot !== undefined | 89 | ...(event.payload.workspaceRoot !== undefined |
| 89 | ? { workspaceRoot: event.payload.workspaceRoot } | 90 | ? { workspaceRoot: event.payload.workspaceRoot } |
| 90 | : {}), | 91 | : {}), |
| 92 | ...(event.payload.repositoryIdentity !== undefined | ||
| 93 | ? { repositoryIdentity: event.payload.repositoryIdentity } | ||
| 94 | : {}), | ||
| 91 | ...(event.payload.defaultModelSelection !== undefined | 95 | ...(event.payload.defaultModelSelection !== undefined |
| 92 | ? { defaultModelSelection: event.payload.defaultModelSelection } | 96 | ? { defaultModelSelection: event.payload.defaultModelSelection } |
| 93 | : {}), | 97 | : {}), |
| @@ -0,0 +1,25 @@ | |||
| 1 | import { describe, expect, it } from "vitest"; | ||
| 2 | |||
| 3 | import { extractPairingUrlFromQrPayload } from "./pairingQr"; | ||
| 4 | |||
| 5 | describe("extractPairingUrlFromQrPayload", () => { | ||
| 6 | it("trims raw pairing urls from qr payloads", () => { | ||
| 7 | expect( | ||
| 8 | extractPairingUrlFromQrPayload(" https://remote.example.com/pair#token=pairing-token "), | ||
| 9 | ).toBe("https://remote.example.com/pair#token=pairing-token"); | ||
| 10 | }); | ||
| 11 | |||
| 12 | it("unwraps mobile deep links that carry an encoded pairing url", () => { | ||
| 13 | expect( | ||
| 14 | extractPairingUrlFromQrPayload( | ||
| 15 | "t3code://pair?pairingUrl=https%3A%2F%2Fremote.example.com%2Fpair%23token%3Dpairing-token", | ||
| 16 | ), | ||
| 17 | ).toBe("https://remote.example.com/pair#token=pairing-token"); | ||
| 18 | }); | ||
| 19 | |||
| 20 | it("rejects empty qr payloads", () => { | ||
| 21 | expect(() => extractPairingUrlFromQrPayload(" ")).toThrow( | ||
| 22 | "Scanned QR code did not contain a pairing URL.", | ||
| 23 | ); | ||
| 24 | }); | ||
| 25 | }); | ||
| @@ -0,0 +1,22 @@ | |||
| 1 | const MOBILE_PAIRING_URL_PARAM = "pairingUrl"; | ||
| 2 | |||
| 3 | export function extractPairingUrlFromQrPayload(payload: string): string { | ||
| 4 | const trimmed = payload.trim(); | ||
| 5 | if (!trimmed) { | ||
| 6 | throw new Error("Scanned QR code did not contain a pairing URL."); | ||
| 7 | } | ||
| 8 | |||
| 9 | try { | ||
| 10 | const url = new URL(trimmed); | ||
| 11 | if (url.protocol === "t3code:") { | ||
| 12 | const pairingUrl = url.searchParams.get(MOBILE_PAIRING_URL_PARAM)?.trim() ?? ""; | ||
| 13 | if (pairingUrl.length > 0) { | ||
| 14 | return pairingUrl; | ||
| 15 | } | ||
| 16 | } | ||
| 17 | } catch { | ||
| 18 | // Treat non-URL payloads as raw pairing-url text so the normal input validation can decide. | ||
| 19 | } | ||
| 20 | |||
| 21 | return trimmed; | ||
| 22 | } | ||
| @@ -1,5 +1,19 @@ | |||
| 1 | import { | 1 | import { |
| 2 | type ClientOrchestrationCommand, | 2 | type ClientOrchestrationCommand, |
| 3 | type GitCheckoutInput, | ||
| 4 | type GitCheckoutResult, | ||
| 5 | type GitCreateBranchInput, | ||
| 6 | type GitCreateBranchResult, | ||
| 7 | type GitCreateWorktreeInput, | ||
| 8 | type GitCreateWorktreeResult, | ||
| 9 | type GitListBranchesInput, | ||
| 10 | type GitListBranchesResult, | ||
| 11 | type GitPullInput, | ||
| 12 | type GitPullResult, | ||
| 13 | type GitRunStackedActionInput, | ||
| 14 | type GitRunStackedActionResult, | ||
| 15 | type GitStatusInput, | ||
| 16 | type GitStatusResult, | ||
| 3 | ORCHESTRATION_WS_METHODS, | 17 | ORCHESTRATION_WS_METHODS, |
| 4 | type OrchestrationEvent, | 18 | type OrchestrationEvent, |
| 5 | type OrchestrationReadModel, | 19 | type OrchestrationReadModel, |
| @@ -260,6 +274,54 @@ export class RemoteClient { · 254 unmodified lines | |||
| 260 | ); | 274 | ); |
| 261 | } | 275 | } |
| 262 | 276 | ||
| 277 | async gitRefreshStatus(input: GitStatusInput): Promise<GitStatusResult> { | ||
| 278 | return await this.request( | ||
| 279 | WS_METHODS.gitRefreshStatus, | ||
| 280 | input, | ||
| 281 | "Request timed out: git.refreshStatus", | ||
| 282 | ); | ||
| 283 | } | ||
| 284 | |||
| 285 | async gitListBranches(input: GitListBranchesInput): Promise<GitListBranchesResult> { | ||
| 286 | return await this.request( | ||
| 287 | WS_METHODS.gitListBranches, | ||
| 288 | input, | ||
| 289 | "Request timed out: git.listBranches", | ||
| 290 | ); | ||
| 291 | } | ||
| 292 | |||
| 293 | async gitCreateBranch(input: GitCreateBranchInput): Promise<GitCreateBranchResult> { | ||
| 294 | return await this.request( | ||
| 295 | WS_METHODS.gitCreateBranch, | ||
| 296 | input, | ||
| 297 | "Request timed out: git.createBranch", | ||
| 298 | ); | ||
| 299 | } | ||
| 300 | |||
| 301 | async gitCheckout(input: GitCheckoutInput): Promise<GitCheckoutResult> { | ||
| 302 | return await this.request(WS_METHODS.gitCheckout, input, "Request timed out: git.checkout"); | ||
| 303 | } | ||
| 304 | |||
| 305 | async gitCreateWorktree(input: GitCreateWorktreeInput): Promise<GitCreateWorktreeResult> { | ||
| 306 | return await this.request( | ||
| 307 | WS_METHODS.gitCreateWorktree, | ||
| 308 | input, | ||
| 309 | "Request timed out: git.createWorktree", | ||
| 310 | ); | ||
| 311 | } | ||
| 312 | |||
| 313 | async gitPull(input: GitPullInput): Promise<GitPullResult> { | ||
| 314 | return await this.request(WS_METHODS.gitPull, input, "Request timed out: git.pull"); | ||
| 315 | } | ||
| 316 | |||
| 317 | async gitRunStackedAction(input: GitRunStackedActionInput): Promise<GitRunStackedActionResult> { | ||
| 318 | return await this.request( | ||
| 319 | WS_METHODS.gitRunStackedAction, | ||
| 320 | input, | ||
| 321 | "Request timed out: git.runStackedAction", | ||
| 322 | ); | ||
| 323 | } | ||
| 324 | |||
| 263 | private emit(event: RemoteClientEvent): void { | 325 | private emit(event: RemoteClientEvent): void { |
| 264 | for (const listener of this.listeners) { | 326 | for (const listener of this.listeners) { |
| 265 | try { | 327 | try { |
| @@ -0,0 +1,136 @@ | |||
| 1 | import { describe, expect, it } from "vitest"; | ||
| 2 | |||
| 3 | import { ProjectId, ThreadId } from "@t3tools/contracts"; | ||
| 4 | |||
| 5 | import type { ScopedMobileProject, ScopedMobileThread } from "./scopedEntities"; | ||
| 6 | import { groupProjectsByRepository } from "./repositoryGroups"; | ||
| 7 | |||
| 8 | function makeProject( | ||
| 9 | input: Partial<ScopedMobileProject> & | ||
| 10 | Pick<ScopedMobileProject, "environmentId" | "environmentLabel" | "id" | "title">, | ||
| 11 | ): ScopedMobileProject { | ||
| 12 | return { | ||
| 13 | workspaceRoot: `/workspaces/${input.id}`, | ||
| 14 | repositoryIdentity: null, | ||
| 15 | defaultModelSelection: null, | ||
| 16 | scripts: [], | ||
| 17 | createdAt: "2026-04-01T00:00:00.000Z", | ||
| 18 | updatedAt: "2026-04-01T00:00:00.000Z", | ||
| 19 | deletedAt: null, | ||
| 20 | ...input, | ||
| 21 | }; | ||
| 22 | } | ||
| 23 | |||
| 24 | function makeThread( | ||
| 25 | input: Partial<ScopedMobileThread> & | ||
| 26 | Pick< | ||
| 27 | ScopedMobileThread, | ||
| 28 | "environmentId" | "environmentLabel" | "id" | "projectId" | "title" | "modelSelection" | ||
| 29 | >, | ||
| 30 | ): ScopedMobileThread { | ||
| 31 | return { | ||
| 32 | runtimeMode: "full-access", | ||
| 33 | interactionMode: "default", | ||
| 34 | branch: null, | ||
| 35 | worktreePath: null, | ||
| 36 | latestTurn: null, | ||
| 37 | createdAt: "2026-04-01T00:00:00.000Z", | ||
| 38 | updatedAt: "2026-04-01T00:00:00.000Z", | ||
| 39 | archivedAt: null, | ||
| 40 | deletedAt: null, | ||
| 41 | messages: [], | ||
| 42 | proposedPlans: [], | ||
| 43 | activities: [], | ||
| 44 | checkpoints: [], | ||
| 45 | session: null, | ||
| 46 | ...input, | ||
| 47 | }; | ||
| 48 | } | ||
| 49 | |||
| 50 | describe("groupProjectsByRepository", () => { | ||
| 51 | it("groups projects across environments by repository identity", () => { | ||
| 52 | const repoIdentity = { | ||
| 53 | canonicalKey: "github.com/t3tools/t3code", | ||
| 54 | locator: { | ||
| 55 | source: "git-remote" as const, | ||
| 56 | remoteName: "origin", | ||
| 57 | remoteUrl: "git@github.com:t3tools/t3code.git", | ||
| 58 | }, | ||
| 59 | provider: "github", | ||
| 60 | owner: "t3tools", | ||
| 61 | name: "t3code", | ||
| 62 | displayName: "T3 Code", | ||
| 63 | }; | ||
| 64 | |||
| 65 | const projects = [ | ||
| 66 | makeProject({ | ||
| 67 | environmentId: "env-local", | ||
| 68 | environmentLabel: "Local", | ||
| 69 | id: ProjectId.makeUnsafe("project-local"), | ||
| 70 | title: "T3 Code", | ||
| 71 | repositoryIdentity: repoIdentity, | ||
| 72 | }), | ||
| 73 | makeProject({ | ||
| 74 | environmentId: "env-staging", | ||
| 75 | environmentLabel: "Staging", | ||
| 76 | id: ProjectId.makeUnsafe("project-staging"), | ||
| 77 | title: "T3 Code", | ||
| 78 | repositoryIdentity: repoIdentity, | ||
| 79 | }), | ||
| 80 | ]; | ||
| 81 | |||
| 82 | const threads = [ | ||
| 83 | makeThread({ | ||
| 84 | environmentId: "env-staging", | ||
| 85 | environmentLabel: "Staging", | ||
| 86 | id: ThreadId.makeUnsafe("thread-2"), | ||
| 87 | projectId: ProjectId.makeUnsafe("project-staging"), | ||
| 88 | title: "Fix reconnect flow", | ||
| 89 | modelSelection: { provider: "codex", model: "gpt-5.4" }, | ||
| 90 | updatedAt: "2026-04-02T12:00:00.000Z", | ||
| 91 | }), | ||
| 92 | makeThread({ | ||
| 93 | environmentId: "env-local", | ||
| 94 | environmentLabel: "Local", | ||
| 95 | id: ThreadId.makeUnsafe("thread-1"), | ||
| 96 | projectId: ProjectId.makeUnsafe("project-local"), | ||
| 97 | title: "Polish mobile shell", | ||
| 98 | modelSelection: { provider: "codex", model: "gpt-5.4" }, | ||
| 99 | updatedAt: "2026-04-03T12:00:00.000Z", | ||
| 100 | }), | ||
| 101 | ]; | ||
| 102 | |||
| 103 | const groups = groupProjectsByRepository({ projects, threads }); | ||
| 104 | |||
| 105 | expect(groups).toHaveLength(1); | ||
| 106 | expect(groups[0]).toMatchObject({ | ||
| 107 | key: "github.com/t3tools/t3code", | ||
| 108 | title: "T3 Code", | ||
| 109 | subtitle: "t3tools/t3code", | ||
| 110 | projectCount: 2, | ||
| 111 | threadCount: 2, | ||
| 112 | }); | ||
| 113 | expect(groups[0]?.projects.map((entry) => entry.project.environmentLabel)).toEqual([ | ||
| 114 | "Local", | ||
| 115 | "Staging", | ||
| 116 | ]); | ||
| 117 | }); | ||
| 118 | |||
| 119 | it("falls back to a scoped project key when repository identity is unavailable", () => { | ||
| 120 | const projects = [ | ||
| 121 | makeProject({ | ||
| 122 | environmentId: "env-local", | ||
| 123 | environmentLabel: "Local", | ||
| 124 | id: ProjectId.makeUnsafe("project-local"), | ||
| 125 | title: "Scratchpad", | ||
| 126 | }), | ||
| 127 | ]; | ||
| 128 | |||
| 129 | const groups = groupProjectsByRepository({ projects, threads: [] }); | ||
| 130 | |||
| 131 | expect(groups).toHaveLength(1); | ||
| 132 | expect(groups[0]?.key).toBe("env-local:project-local"); | ||
| 133 | expect(groups[0]?.title).toBe("Scratchpad"); | ||
| 134 | expect(groups[0]?.subtitle).toBeNull(); | ||
| 135 | }); | ||
| 136 | }); | ||
| @@ -0,0 +1,135 @@ | |||
| 1 | import type { RepositoryIdentity } from "@t3tools/contracts"; | ||
| 2 | |||
| 3 | import { sortCopy } from "./arrayCompat"; | ||
| 4 | import type { ScopedMobileProject, ScopedMobileThread } from "./scopedEntities"; | ||
| 5 | import { scopedProjectKey } from "./scopedEntities"; | ||
| 6 | |||
| 7 | export interface MobileRepositoryProjectGroup { | ||
| 8 | readonly key: string; | ||
| 9 | readonly project: ScopedMobileProject; | ||
| 10 | readonly threads: ReadonlyArray<ScopedMobileThread>; | ||
| 11 | readonly latestActivityAt: string; | ||
| 12 | } | ||
| 13 | |||
| 14 | export interface MobileRepositoryGroup { | ||
| 15 | readonly key: string; | ||
| 16 | readonly title: string; | ||
| 17 | readonly subtitle: string | null; | ||
| 18 | readonly repositoryIdentity: RepositoryIdentity | null; | ||
| 19 | readonly projectCount: number; | ||
| 20 | readonly threadCount: number; | ||
| 21 | readonly latestActivityAt: string; | ||
| 22 | readonly projects: ReadonlyArray<MobileRepositoryProjectGroup>; | ||
| 23 | } | ||
| 24 | |||
| 25 | function compareIsoDateDescending(left: string, right: string): number { | ||
| 26 | return new Date(right).getTime() - new Date(left).getTime(); | ||
| 27 | } | ||
| 28 | |||
| 29 | function deriveRepositoryGroupKey(project: ScopedMobileProject): string { | ||
| 30 | return ( | ||
| 31 | project.repositoryIdentity?.canonicalKey ?? scopedProjectKey(project.environmentId, project.id) | ||
| 32 | ); | ||
| 33 | } | ||
| 34 | |||
| 35 | function deriveRepositoryTitle(project: ScopedMobileProject): string { | ||
| 36 | const identity = project.repositoryIdentity; | ||
| 37 | return identity?.displayName ?? identity?.name ?? project.title; | ||
| 38 | } | ||
| 39 | |||
| 40 | function deriveRepositorySubtitle(identity: RepositoryIdentity | null | undefined): string | null { | ||
| 41 | if (!identity) { | ||
| 42 | return null; | ||
| 43 | } | ||
| 44 | if (identity.owner && identity.name) { | ||
| 45 | return `${identity.owner}/${identity.name}`; | ||
| 46 | } | ||
| 47 | return identity.canonicalKey; | ||
| 48 | } | ||
| 49 | |||
| 50 | function deriveProjectLatestActivity( | ||
| 51 | project: ScopedMobileProject, | ||
| 52 | threads: ReadonlyArray<ScopedMobileThread>, | ||
| 53 | ): string { | ||
| 54 | const latestThread = threads[0]; | ||
| 55 | return latestThread?.updatedAt ?? latestThread?.createdAt ?? project.updatedAt; | ||
| 56 | } | ||
| 57 | |||
| 58 | export function groupProjectsByRepository(input: { | ||
| 59 | readonly projects: ReadonlyArray<ScopedMobileProject>; | ||
| 60 | readonly threads: ReadonlyArray<ScopedMobileThread>; | ||
| 61 | }): ReadonlyArray<MobileRepositoryGroup> { | ||
| 62 | const threadsByProjectKey = new Map<string, ScopedMobileThread[]>(); | ||
| 63 | |||
| 64 | for (const thread of input.threads) { | ||
| 65 | const key = scopedProjectKey(thread.environmentId, thread.projectId); | ||
| 66 | const existing = threadsByProjectKey.get(key); | ||
| 67 | if (existing) { | ||
| 68 | existing.push(thread); | ||
| 69 | } else { | ||
| 70 | threadsByProjectKey.set(key, [thread]); | ||
| 71 | } | ||
| 72 | } | ||
| 73 | |||
| 74 | const grouped = new Map<string, MobileRepositoryGroup>(); | ||
| 75 | |||
| 76 | for (const project of input.projects) { | ||
| 77 | const key = deriveRepositoryGroupKey(project); | ||
| 78 | const projectKey = scopedProjectKey(project.environmentId, project.id); | ||
| 79 | const threads = sortCopy(threadsByProjectKey.get(projectKey) ?? [], (left, right) => | ||
| 80 | compareIsoDateDescending( | ||
| 81 | left.updatedAt ?? left.createdAt, | ||
| 82 | right.updatedAt ?? right.createdAt, | ||
| 83 | ), | ||
| 84 | ); | ||
| 85 | const latestActivityAt = deriveProjectLatestActivity(project, threads); | ||
| 86 | const projectGroup: MobileRepositoryProjectGroup = { | ||
| 87 | key: projectKey, | ||
| 88 | project, | ||
| 89 | threads, | ||
| 90 | latestActivityAt, | ||
| 91 | }; | ||
| 92 | |||
| 93 | const existing = grouped.get(key); | ||
| 94 | if (!existing) { | ||
| 95 | grouped.set(key, { | ||
| 96 | key, | ||
| 97 | title: deriveRepositoryTitle(project), | ||
| 98 | subtitle: deriveRepositorySubtitle(project.repositoryIdentity), | ||
| 99 | repositoryIdentity: project.repositoryIdentity ?? null, | ||
| 100 | projectCount: 1, | ||
| 101 | threadCount: threads.length, | ||
| 102 | latestActivityAt, | ||
| 103 | projects: [projectGroup], | ||
| 104 | }); | ||
| 105 | continue; | ||
| 106 | } | ||
| 107 | |||
| 108 | grouped.set(key, { | ||
| 109 | ...existing, | ||
| 110 | title: existing.repositoryIdentity ? existing.title : deriveRepositoryTitle(project), | ||
| 111 | subtitle: existing.subtitle ?? deriveRepositorySubtitle(project.repositoryIdentity), | ||
| 112 | repositoryIdentity: existing.repositoryIdentity ?? project.repositoryIdentity ?? null, | ||
| 113 | projectCount: existing.projectCount + 1, | ||
| 114 | threadCount: existing.threadCount + threads.length, | ||
| 115 | latestActivityAt: | ||
| 116 | compareIsoDateDescending(existing.latestActivityAt, latestActivityAt) > 0 | ||
| 117 | ? latestActivityAt | ||
| 118 | : existing.latestActivityAt, | ||
| 119 | projects: sortCopy( | ||
| 120 | [...existing.projects, projectGroup], | ||
| 121 | (left, right) => | ||
| 122 | compareIsoDateDescending(left.latestActivityAt, right.latestActivityAt) || | ||
| 123 | left.project.environmentLabel.localeCompare(right.project.environmentLabel) || | ||
| 124 | left.project.title.localeCompare(right.project.title), | ||
| 125 | ), | ||
| 126 | }); | ||
| 127 | } | ||
| 128 | |||
| 129 | return sortCopy( | ||
| 130 | [...grouped.values()], | ||
| 131 | (left, right) => | ||
| 132 | compareIsoDateDescending(left.latestActivityAt, right.latestActivityAt) || | ||
| 133 | left.title.localeCompare(right.title), | ||
| 134 | ); | ||
| 135 | } | ||
| @@ -2,7 +2,7 @@ import { Platform } from "react-native"; · 1 unmodified line | |||
| 2 | 2 | ||
| 3 | import type { SavedRemoteConnection } from "./connection"; | 3 | import type { SavedRemoteConnection } from "./connection"; |
| 4 | 4 | ||
| 5 | const CONNECTIONS_KEY = "t3remote:connections"; | 5 | const CONNECTIONS_KEY = "t3code:connections"; |
| 6 | const memoryStorage = new Map<string, string>(); | 6 | const memoryStorage = new Map<string, string>(); |
| 7 | 7 | ||
| 8 | type AsyncStorageModule = typeof import("@react-native-async-storage/async-storage"); | 8 | type AsyncStorageModule = typeof import("@react-native-async-storage/async-storage"); |
| @@ -89,7 +89,7 @@ async function removeStorageItem(key: string): Promise<void> { · 80 unmodified lines | |||
| 89 | } | 89 | } |
| 90 | 90 | ||
| 91 | function connectionTokenKey(environmentId: string): string { | 91 | function connectionTokenKey(environmentId: string): string { |
| 92 | return `t3remote:bearer-token:${environmentId}`; | 92 | return `t3code:bearer-token:${environmentId}`; |
| 93 | } | 93 | } |
| 94 | 94 | ||
| 95 | async function loadSecureToken(environmentId: string): Promise<string> { | 95 | async function loadSecureToken(environmentId: string): Promise<string> { |
| @@ -0,0 +1,14 @@ | |||
| 1 | const WORKTREE_BRANCH_PREFIX = "t3code"; | ||
| 2 | |||
| 3 | function randomHexToken(): string { | ||
| 4 | const uuid = globalThis.crypto?.randomUUID?.(); | ||
| 5 | if (uuid) { | ||
| 6 | return uuid.replace(/-/g, "").slice(0, 8).toLowerCase(); | ||
| 7 | } | ||
| 8 | |||
| 9 | return Math.random().toString(16).slice(2, 10).padEnd(8, "0"); | ||
| 10 | } | ||
| 11 | |||
| 12 | export function buildTemporaryWorktreeBranchName(): string { | ||
| 13 | return `${WORKTREE_BRANCH_PREFIX}/${randomHexToken()}`; | ||
| 14 | } | ||
| @@ -1,363 +1,16 @@ | |||
| 1 | import type { | 1 | export { |
| 2 | GitRunStackedActionResult, | 2 | buildGitActionProgressStages, |
| 3 | GitStackedAction, | 3 | buildMenuItems, |
| 4 | GitStatusResult, | 4 | getGitActionDisabledReason, |
| 5 | } from "@t3tools/contracts"; | 5 | type GitActionIconName, |
| 6 | type GitActionMenuItem, | ||
| 7 | type GitQuickAction, | ||
| 8 | type DefaultBranchConfirmableAction, | ||
| 9 | requiresDefaultBranchConfirmation, | ||
| 10 | resolveDefaultBranchActionDialogCopy, | ||
| 11 | resolveLiveThreadBranchUpdate, | ||
| 12 | resolveQuickAction, | ||
| 13 | resolveThreadBranchUpdate, | ||
| 14 | } from "@t3tools/client-runtime"; | ||
| 6 | 15 | ||
| 7 | export type GitActionIconName = "commit" | "push" | "pr"; | ||
| 8 | |||
| 9 | export type GitDialogAction = "commit" | "push" | "create_pr"; | ||
| 10 | |||
| 11 | export interface GitActionMenuItem { | ||
| 12 | id: "commit" | "push" | "pr"; | ||
| 13 | label: string; | ||
| 14 | disabled: boolean; | ||
| 15 | icon: GitActionIconName; | ||
| 16 | kind: "open_dialog" | "open_pr"; | ||
| 17 | dialogAction?: GitDialogAction; | ||
| 18 | } | ||
| 19 | |||
| 20 | export interface GitQuickAction { | ||
| 21 | label: string; | ||
| 22 | disabled: boolean; | ||
| 23 | kind: "run_action" | "run_pull" | "open_pr" | "show_hint"; | ||
| 24 | action?: GitStackedAction; | ||
| 25 | hint?: string; | ||
| 26 | } | ||
| 27 | |||
| 28 | export interface DefaultBranchActionDialogCopy { | ||
| 29 | title: string; | ||
| 30 | description: string; | ||
| 31 | continueLabel: string; | ||
| 32 | } | ||
| 33 | |||
| 34 | export type DefaultBranchConfirmableAction = | ||
| 35 | | "push" | ||
| 36 | | "create_pr" | ||
| 37 | | "commit_push" | ||
| 38 | | "commit_push_pr"; | ||
| 39 | |||
| 40 | export function buildGitActionProgressStages(input: { | ||
| 41 | action: GitStackedAction; | ||
| 42 | hasCustomCommitMessage: boolean; | ||
| 43 | hasWorkingTreeChanges: boolean; | ||
| 44 | pushTarget?: string; | ||
| 45 | featureBranch?: boolean; | ||
| 46 | shouldPushBeforePr?: boolean; | ||
| 47 | }): string[] { | ||
| 48 | const branchStages = input.featureBranch ? ["Preparing feature branch..."] : []; | ||
| 49 | const pushStage = input.pushTarget ? `Pushing to ${input.pushTarget}...` : "Pushing..."; | ||
| 50 | const prStages = [ | ||
| 51 | "Preparing PR...", | ||
| 52 | "Generating PR content...", | ||
| 53 | "Creating GitHub pull request...", | ||
| 54 | ]; | ||
| 55 | |||
| 56 | if (input.action === "push") { | ||
| 57 | return [pushStage]; | ||
| 58 | } | ||
| 59 | if (input.action === "create_pr") { | ||
| 60 | return input.shouldPushBeforePr ? [pushStage, ...prStages] : prStages; | ||
| 61 | } | ||
| 62 | |||
| 63 | const shouldIncludeCommitStages = input.action === "commit" || input.hasWorkingTreeChanges; | ||
| 64 | const commitStages = !shouldIncludeCommitStages | ||
| 65 | ? [] | ||
| 66 | : input.hasCustomCommitMessage | ||
| 67 | ? ["Committing..."] | ||
| 68 | : ["Generating commit message...", "Committing..."]; | ||
| 69 | if (input.action === "commit") { | ||
| 70 | return [...branchStages, ...commitStages]; | ||
| 71 | } | ||
| 72 | if (input.action === "commit_push") { | ||
| 73 | return [...branchStages, ...commitStages, pushStage]; | ||
| 74 | } | ||
| 75 | return [...branchStages, ...commitStages, pushStage, ...prStages]; | ||
| 76 | } | ||
| 77 | |||
| 78 | export function buildMenuItems( | ||
| 79 | gitStatus: GitStatusResult | null, | ||
| 80 | isBusy: boolean, | ||
| 81 | hasOriginRemote = true, | ||
| 82 | ): GitActionMenuItem[] { | ||
| 83 | if (!gitStatus) return []; | ||
| 84 | |||
| 85 | const hasBranch = gitStatus.branch !== null; | ||
| 86 | const hasChanges = gitStatus.hasWorkingTreeChanges; | ||
| 87 | const hasOpenPr = gitStatus.pr?.state === "open"; | ||
| 88 | const isBehind = gitStatus.behindCount > 0; | ||
| 89 | const canPushWithoutUpstream = hasOriginRemote && !gitStatus.hasUpstream; | ||
| 90 | const canCommit = !isBusy && hasChanges; | ||
| 91 | const canPush = | ||
| 92 | !isBusy && | ||
| 93 | hasBranch && | ||
| 94 | !hasChanges && | ||
| 95 | !isBehind && | ||
| 96 | gitStatus.aheadCount > 0 && | ||
| 97 | (gitStatus.hasUpstream || canPushWithoutUpstream); | ||
| 98 | const canCreatePr = | ||
| 99 | !isBusy && | ||
| 100 | hasBranch && | ||
| 101 | !hasChanges && | ||
| 102 | !hasOpenPr && | ||
| 103 | gitStatus.aheadCount > 0 && | ||
| 104 | !isBehind && | ||
| 105 | (gitStatus.hasUpstream || canPushWithoutUpstream); | ||
| 106 | const canOpenPr = !isBusy && hasOpenPr; | ||
| 107 | |||
| 108 | return [ | ||
| 109 | { | ||
| 110 | id: "commit", | ||
| 111 | label: "Commit", | ||
| 112 | disabled: !canCommit, | ||
| 113 | icon: "commit", | ||
| 114 | kind: "open_dialog", | ||
| 115 | dialogAction: "commit", | ||
| 116 | }, | ||
| 117 | { | ||
| 118 | id: "push", | ||
| 119 | label: "Push", | ||
| 120 | disabled: !canPush, | ||
| 121 | icon: "push", | ||
| 122 | kind: "open_dialog", | ||
| 123 | dialogAction: "push", | ||
| 124 | }, | ||
| 125 | hasOpenPr | ||
| 126 | ? { | ||
| 127 | id: "pr", | ||
| 128 | label: "View PR", | ||
| 129 | disabled: !canOpenPr, | ||
| 130 | icon: "pr", | ||
| 131 | kind: "open_pr", | ||
| 132 | } | ||
| 133 | : { | ||
| 134 | id: "pr", | ||
| 135 | label: "Create PR", | ||
| 136 | disabled: !canCreatePr, | ||
| 137 | icon: "pr", | ||
| 138 | kind: "open_dialog", | ||
| 139 | dialogAction: "create_pr", | ||
| 140 | }, | ||
| 141 | ]; | ||
| 142 | } | ||
| 143 | |||
| 144 | export function resolveQuickAction( | ||
| 145 | gitStatus: GitStatusResult | null, | ||
| 146 | isBusy: boolean, | ||
| 147 | isDefaultBranch = false, | ||
| 148 | hasOriginRemote = true, | ||
| 149 | ): GitQuickAction { | ||
| 150 | if (isBusy) { | ||
| 151 | return { label: "Commit", disabled: true, kind: "show_hint", hint: "Git action in progress." }; | ||
| 152 | } | ||
| 153 | |||
| 154 | if (!gitStatus) { | ||
| 155 | return { | ||
| 156 | label: "Commit", | ||
| 157 | disabled: true, | ||
| 158 | kind: "show_hint", | ||
| 159 | hint: "Git status is unavailable.", | ||
| 160 | }; | ||
| 161 | } | ||
| 162 | |||
| 163 | const hasBranch = gitStatus.branch !== null; | ||
| 164 | const hasChanges = gitStatus.hasWorkingTreeChanges; | ||
| 165 | const hasOpenPr = gitStatus.pr?.state === "open"; | ||
| 166 | const isAhead = gitStatus.aheadCount > 0; | ||
| 167 | const isBehind = gitStatus.behindCount > 0; | ||
| 168 | const isDiverged = isAhead && isBehind; | ||
| 169 | |||
| 170 | if (!hasBranch) { | ||
| 171 | return { | ||
| 172 | label: "Commit", | ||
| 173 | disabled: true, | ||
| 174 | kind: "show_hint", | ||
| 175 | hint: "Create and checkout a branch before pushing or opening a PR.", | ||
| 176 | }; | ||
| 177 | } | ||
| 178 | |||
| 179 | if (hasChanges) { | ||
| 180 | if (!gitStatus.hasUpstream && !hasOriginRemote) { | ||
| 181 | return { label: "Commit", disabled: false, kind: "run_action", action: "commit" }; | ||
| 182 | } | ||
| 183 | if (hasOpenPr || isDefaultBranch) { | ||
| 184 | return { label: "Commit & push", disabled: false, kind: "run_action", action: "commit_push" }; | ||
| 185 | } | ||
| 186 | return { | ||
| 187 | label: "Commit, push & PR", | ||
| 188 | disabled: false, | ||
| 189 | kind: "run_action", | ||
| 190 | action: "commit_push_pr", | ||
| 191 | }; | ||
| 192 | } | ||
| 193 | |||
| 194 | if (!gitStatus.hasUpstream) { | ||
| 195 | if (!hasOriginRemote) { | ||
| 196 | if (hasOpenPr && !isAhead) { | ||
| 197 | return { label: "View PR", disabled: false, kind: "open_pr" }; | ||
| 198 | } | ||
| 199 | return { | ||
| 200 | label: "Push", | ||
| 201 | disabled: true, | ||
| 202 | kind: "show_hint", | ||
| 203 | hint: 'Add an "origin" remote before pushing or creating a PR.', | ||
| 204 | }; | ||
| 205 | } | ||
| 206 | if (!isAhead) { | ||
| 207 | if (hasOpenPr) { | ||
| 208 | return { label: "View PR", disabled: false, kind: "open_pr" }; | ||
| 209 | } | ||
| 210 | return { | ||
| 211 | label: "Push", | ||
| 212 | disabled: true, | ||
| 213 | kind: "show_hint", | ||
| 214 | hint: "No local commits to push.", | ||
| 215 | }; | ||
| 216 | } | ||
| 217 | if (hasOpenPr || isDefaultBranch) { | ||
| 218 | return { | ||
| 219 | label: "Push", | ||
| 220 | disabled: false, | ||
| 221 | kind: "run_action", | ||
| 222 | action: isDefaultBranch ? "commit_push" : "push", | ||
| 223 | }; | ||
| 224 | } | ||
| 225 | return { | ||
| 226 | label: "Push & create PR", | ||
| 227 | disabled: false, | ||
| 228 | kind: "run_action", | ||
| 229 | action: "create_pr", | ||
| 230 | }; | ||
| 231 | } | ||
| 232 | |||
| 233 | if (isDiverged) { | ||
| 234 | return { | ||
| 235 | label: "Sync branch", | ||
| 236 | disabled: true, | ||
| 237 | kind: "show_hint", | ||
| 238 | hint: "Branch has diverged from upstream. Rebase/merge first.", | ||
| 239 | }; | ||
| 240 | } | ||
| 241 | |||
| 242 | if (isBehind) { | ||
| 243 | return { | ||
| 244 | label: "Pull", | ||
| 245 | disabled: false, | ||
| 246 | kind: "run_pull", | ||
| 247 | }; | ||
| 248 | } | ||
| 249 | |||
| 250 | if (isAhead) { | ||
| 251 | if (hasOpenPr || isDefaultBranch) { | ||
| 252 | return { | ||
| 253 | label: "Push", | ||
| 254 | disabled: false, | ||
| 255 | kind: "run_action", | ||
| 256 | action: isDefaultBranch ? "commit_push" : "push", | ||
| 257 | }; | ||
| 258 | } | ||
| 259 | return { | ||
| 260 | label: "Push & create PR", | ||
| 261 | disabled: false, | ||
| 262 | kind: "run_action", | ||
| 263 | action: "create_pr", | ||
| 264 | }; | ||
| 265 | } | ||
| 266 | |||
| 267 | if (hasOpenPr && gitStatus.hasUpstream) { | ||
| 268 | return { label: "View PR", disabled: false, kind: "open_pr" }; | ||
| 269 | } | ||
| 270 | |||
| 271 | return { | ||
| 272 | label: "Commit", | ||
| 273 | disabled: true, | ||
| 274 | kind: "show_hint", | ||
| 275 | hint: "Branch is up to date. No action needed.", | ||
| 276 | }; | ||
| 277 | } | ||
| 278 | |||
| 279 | export function requiresDefaultBranchConfirmation( | ||
| 280 | action: GitStackedAction, | ||
| 281 | isDefaultBranch: boolean, | ||
| 282 | ): boolean { | ||
| 283 | if (!isDefaultBranch) return false; | ||
| 284 | return ( | ||
| 285 | action === "push" || | ||
| 286 | action === "create_pr" || | ||
| 287 | action === "commit_push" || | ||
| 288 | action === "commit_push_pr" | ||
| 289 | ); | ||
| 290 | } | ||
| 291 | |||
| 292 | export function resolveDefaultBranchActionDialogCopy(input: { | ||
| 293 | action: DefaultBranchConfirmableAction; | ||
| 294 | branchName: string; | ||
| 295 | includesCommit: boolean; | ||
| 296 | }): DefaultBranchActionDialogCopy { | ||
| 297 | const branchLabel = input.branchName; | ||
| 298 | const suffix = ` on "${branchLabel}". You can continue on this branch or create a feature branch and run the same action there.`; | ||
| 299 | |||
| 300 | if (input.action === "push" || input.action === "commit_push") { | ||
| 301 | if (input.includesCommit) { | ||
| 302 | return { | ||
| 303 | title: "Commit & push to default branch?", | ||
| 304 | description: `This action will commit and push changes${suffix}`, | ||
| 305 | continueLabel: `Commit & push to ${branchLabel}`, | ||
| 306 | }; | ||
| 307 | } | ||
| 308 | return { | ||
| 309 | title: "Push to default branch?", | ||
| 310 | description: `This action will push local commits${suffix}`, | ||
| 311 | continueLabel: `Push to ${branchLabel}`, | ||
| 312 | }; | ||
| 313 | } | ||
| 314 | |||
| 315 | if (input.includesCommit) { | ||
| 316 | return { | ||
| 317 | title: "Commit, push & create PR from default branch?", | ||
| 318 | description: `This action will commit, push, and create a PR${suffix}`, | ||
| 319 | continueLabel: `Commit, push & create PR`, | ||
| 320 | }; | ||
| 321 | } | ||
| 322 | return { | ||
| 323 | title: "Push & create PR from default branch?", | ||
| 324 | description: `This action will push local commits and create a PR${suffix}`, | ||
| 325 | continueLabel: "Push & create PR", | ||
| 326 | }; | ||
| 327 | } | ||
| 328 | |||
| 329 | export function resolveThreadBranchUpdate( | ||
| 330 | result: GitRunStackedActionResult, | ||
| 331 | ): { branch: string } | null { | ||
| 332 | if (result.branch.status !== "created" || !result.branch.name) { | ||
| 333 | return null; | ||
| 334 | } | ||
| 335 | |||
| 336 | return { | ||
| 337 | branch: result.branch.name, | ||
| 338 | }; | ||
| 339 | } | ||
| 340 | |||
| 341 | export function resolveLiveThreadBranchUpdate(input: { | ||
| 342 | threadBranch: string | null; | ||
| 343 | gitStatus: GitStatusResult | null; | ||
| 344 | }): { branch: string | null } | null { | ||
| 345 | if (!input.gitStatus) { | ||
| 346 | return null; | ||
| 347 | } | ||
| 348 | |||
| 349 | if (input.gitStatus.branch === null && input.threadBranch !== null) { | ||
| 350 | return null; | ||
| 351 | } | ||
| 352 | |||
| 353 | if (input.threadBranch === input.gitStatus.branch) { | ||
| 354 | return null; | ||
| 355 | } | ||
| 356 | |||
| 357 | return { | ||
| 358 | branch: input.gitStatus.branch, | ||
| 359 | }; | ||
| 360 | } | ||
| 361 | |||
| 362 | // Re-export from shared for backwards compatibility in this module's exports | ||
| 363 | export { resolveAutoFeatureBranchName } from "@t3tools/shared/git"; | 16 | export { resolveAutoFeatureBranchName } from "@t3tools/shared/git"; |
| @@ -12,6 +12,7 @@ import { GitHubIcon } from "./Icons"; · 11 unmodified lines | |||
| 12 | import { | 12 | import { |
| 13 | buildGitActionProgressStages, | 13 | buildGitActionProgressStages, |
| 14 | buildMenuItems, | 14 | buildMenuItems, |
| 15 | getGitActionDisabledReason, | ||
| 15 | type GitActionIconName, | 16 | type GitActionIconName, |
| 16 | type GitActionMenuItem, | 17 | type GitActionMenuItem, |
| 17 | type GitQuickAction, | 18 | type GitQuickAction, |
| @@ -117,74 +118,6 @@ function resolveProgressDescription(progress: ActiveGitActionProgress): string | · 99 unmodified lines | |||
| 117 | return formatElapsedDescription(progress.hookStartedAtMs ?? progress.phaseStartedAtMs); | 118 | return formatElapsedDescription(progress.hookStartedAtMs ?? progress.phaseStartedAtMs); |
| 118 | } | 119 | } |
| 119 | 120 | ||
| 120 | function getMenuActionDisabledReason({ | ||
| 121 | item, | ||
| 122 | gitStatus, | ||
| 123 | isBusy, | ||
| 124 | hasOriginRemote, | ||
| 125 | }: { | ||
| 126 | item: GitActionMenuItem; | ||
| 127 | gitStatus: GitStatusResult | null; | ||
| 128 | isBusy: boolean; | ||
| 129 | hasOriginRemote: boolean; | ||
| 130 | }): string | null { | ||
| 131 | if (!item.disabled) return null; | ||
| 132 | if (isBusy) return "Git action in progress."; | ||
| 133 | if (!gitStatus) return "Git status is unavailable."; | ||
| 134 | |||
| 135 | const hasBranch = gitStatus.branch !== null; | ||
| 136 | const hasChanges = gitStatus.hasWorkingTreeChanges; | ||
| 137 | const hasOpenPr = gitStatus.pr?.state === "open"; | ||
| 138 | const isAhead = gitStatus.aheadCount > 0; | ||
| 139 | const isBehind = gitStatus.behindCount > 0; | ||
| 140 | |||
| 141 | if (item.id === "commit") { | ||
| 142 | if (!hasChanges) { | ||
| 143 | return "Worktree is clean. Make changes before committing."; | ||
| 144 | } | ||
| 145 | return "Commit is currently unavailable."; | ||
| 146 | } | ||
| 147 | |||
| 148 | if (item.id === "push") { | ||
| 149 | if (!hasBranch) { | ||
| 150 | return "Detached HEAD: checkout a branch before pushing."; | ||
| 151 | } | ||
| 152 | if (hasChanges) { | ||
| 153 | return "Commit or stash local changes before pushing."; | ||
| 154 | } | ||
| 155 | if (isBehind) { | ||
| 156 | return "Branch is behind upstream. Pull/rebase before pushing."; | ||
| 157 | } | ||
| 158 | if (!gitStatus.hasUpstream && !hasOriginRemote) { | ||
| 159 | return 'Add an "origin" remote before pushing.'; | ||
| 160 | } | ||
| 161 | if (!isAhead) { | ||
| 162 | return "No local commits to push."; | ||
| 163 | } | ||
| 164 | return "Push is currently unavailable."; | ||
| 165 | } | ||
| 166 | |||
| 167 | if (hasOpenPr) { | ||
| 168 | return "View PR is currently unavailable."; | ||
| 169 | } | ||
| 170 | if (!hasBranch) { | ||
| 171 | return "Detached HEAD: checkout a branch before creating a PR."; | ||
| 172 | } | ||
| 173 | if (hasChanges) { | ||
| 174 | return "Commit local changes before creating a PR."; | ||
| 175 | } | ||
| 176 | if (!gitStatus.hasUpstream && !hasOriginRemote) { | ||
| 177 | return 'Add an "origin" remote before creating a PR.'; | ||
| 178 | } | ||
| 179 | if (!isAhead) { | ||
| 180 | return "No local commits to include in a PR."; | ||
| 181 | } | ||
| 182 | if (isBehind) { | ||
| 183 | return "Branch is behind upstream. Pull/rebase before creating a PR."; | ||
| 184 | } | ||
| 185 | return "Create PR is currently unavailable."; | ||
| 186 | } | ||
| 187 | |||
| 188 | const COMMIT_DIALOG_TITLE = "Commit changes"; | 121 | const COMMIT_DIALOG_TITLE = "Commit changes"; |
| 189 | const COMMIT_DIALOG_DESCRIPTION = | 122 | const COMMIT_DIALOG_DESCRIPTION = |
| 190 | "Review and confirm your commit. Leave the message blank to auto-generate one."; | 123 | "Review and confirm your commit. Leave the message blank to auto-generate one."; |
| @@ -912,7 +845,7 @@ export default function GitActionsControl({ · 721 unmodified lines | |||
| 912 | </MenuTrigger> | 845 | </MenuTrigger> |
| 913 | <MenuPopup align="end" className="w-full"> | 846 | <MenuPopup align="end" className="w-full"> |
| 914 | {gitActionMenuItems.map((item) => { | 847 | {gitActionMenuItems.map((item) => { |
| 915 | const disabledReason = getMenuActionDisabledReason({ | 848 | const disabledReason = getGitActionDisabledReason({ |
| 916 | item, | 849 | item, |
| 917 | gitStatus: gitStatusForActions, | 850 | gitStatus: gitStatusForActions, |
| 918 | isBusy: isGitActionRunning, | 851 | isBusy: isGitActionRunning, |
| @@ -284,7 +284,7 @@ export function resolveThreadRowClassName(input: { · 283 unmodified lines | |||
| 284 | isSelected: boolean; | 284 | isSelected: boolean; |
| 285 | }): string { | 285 | }): string { |
| 286 | const baseClassName = | 286 | const baseClassName = |
| 287 | "h-7 w-full translate-x-0 cursor-pointer justify-start px-2 text-left select-none focus-visible:ring-1 focus-visible:ring-inset focus-visible:ring-ring"; | 287 | "h-6 w-full translate-x-0 cursor-pointer justify-start px-2 text-left select-none focus-visible:ring-1 focus-visible:ring-inset focus-visible:ring-ring sm:h-7"; |
| 288 | 288 | ||
| 289 | if (input.isSelected && input.isActive) { | 289 | if (input.isSelected && input.isActive) { |
| 290 | return cn( | 290 | return cn( |
| @@ -410,7 +410,7 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: SidebarThreadRowP · 409 unmodified lines | |||
| 410 | ); | 410 | ); |
| 411 | const threadEnvironmentLabel = isRemoteThread | 411 | const threadEnvironmentLabel = isRemoteThread |
| 412 | ? (remoteEnvLabel ?? remoteEnvSavedLabel ?? "Remote") | 412 | ? (remoteEnvLabel ?? remoteEnvSavedLabel ?? "Remote") |
| 413 | : null; | 413 | : "Local"; |
| 414 | // For grouped projects, the thread may belong to a different environment | 414 | // For grouped projects, the thread may belong to a different environment |
| 415 | // than the representative project. Look up the thread's own project cwd | 415 | // than the representative project. Look up the thread's own project cwd |
| 416 | // so git status (and thus PR detection) queries the correct path. | 416 | // so git status (and thus PR detection) queries the correct path. |
| @@ -715,21 +715,16 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: SidebarThreadRowP · 298 unmodified lines | |||
| 715 | ) : null} | 715 | ) : null} |
| 716 | <span className={threadMetaClassName}> | 716 | <span className={threadMetaClassName}> |
| 717 | <span className="inline-flex items-center gap-1"> | 717 | <span className="inline-flex items-center gap-1"> |
| 718 | {isRemoteThread && ( | 718 | <span |
| 719 | <Tooltip> | 719 | title={threadEnvironmentLabel} |
| 720 | <TooltipTrigger | 720 | className={`inline-flex max-w-18 items-center truncate rounded-full border border-border/70 bg-background/85 px-1.5 py-0.5 text-[9px] font-medium tracking-[0.08em] ${ |
| 721 | render={ | 721 | isHighlighted |
| 722 | <span | 722 | ? "text-foreground/72 dark:text-foreground/82" |
| 723 | aria-label={threadEnvironmentLabel ?? "Remote"} | 723 | : "text-muted-foreground/60" |
| 724 | className="inline-flex items-center justify-center" | 724 | }`} |
| 725 | /> | 725 | > |
| 726 | } | 726 | {threadEnvironmentLabel} |
| 727 | > | 727 | </span> |
| 728 | <CloudIcon className="size-3 text-muted-foreground/40" /> | ||
| 729 | </TooltipTrigger> | ||
| 730 | <TooltipPopup side="top">{threadEnvironmentLabel}</TooltipPopup> | ||
| 731 | </Tooltip> | ||
| 732 | )} | ||
| 733 | {jumpLabel ? ( | 728 | {jumpLabel ? ( |
| 734 | <span | 729 | <span |
| 735 | className="inline-flex h-5 items-center rounded-full border border-border/80 bg-background/90 px-1.5 font-mono text-[10px] font-medium tracking-tight text-foreground shadow-sm" | 730 | className="inline-flex h-5 items-center rounded-full border border-border/80 bg-background/90 px-1.5 font-mono text-[10px] font-medium tracking-tight text-foreground shadow-sm" |
| @@ -848,7 +843,7 @@ const SidebarProjectThreadList = memo(function SidebarProjectThreadList( · 112 unmodified lines | |||
| 848 | return ( | 843 | return ( |
| 849 | <SidebarMenuSub | 844 | <SidebarMenuSub |
| 850 | ref={attachThreadListAutoAnimateRef} | 845 | ref={attachThreadListAutoAnimateRef} |
| 851 | className="mx-1 my-0 w-full translate-x-0 gap-0.5 overflow-hidden px-1.5 py-0" | 846 | className="mx-0.5 my-0 w-full translate-x-0 gap-0.5 overflow-hidden px-1 py-0 sm:mx-1 sm:px-1.5" |
| 852 | > | 847 | > |
| 853 | {shouldShowThreadPanel && showEmptyThreadState ? ( | 848 | {shouldShowThreadPanel && showEmptyThreadState ? ( |
| 854 | <SidebarMenuSubItem className="w-full" data-thread-selection-safe> | 849 | <SidebarMenuSubItem className="w-full" data-thread-selection-safe> |
| @@ -1685,7 +1680,7 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec · 830 unmodified lines | |||
| 1685 | <SidebarMenuButton | 1680 | <SidebarMenuButton |
| 1686 | ref={isManualProjectSorting ? dragHandleProps?.setActivatorNodeRef : undefined} | 1681 | ref={isManualProjectSorting ? dragHandleProps?.setActivatorNodeRef : undefined} |
| 1687 | size="sm" | 1682 | size="sm" |
| 1688 | className={`gap-2 px-2 py-1.5 text-left hover:bg-accent group-hover/project-header:bg-accent group-hover/project-header:text-sidebar-accent-foreground ${ | 1683 | className={`gap-2 px-2 py-1 text-left hover:bg-accent group-hover/project-header:bg-accent group-hover/project-header:text-sidebar-accent-foreground sm:py-1.5 ${ |
| 1689 | isManualProjectSorting ? "cursor-grab active:cursor-grabbing" : "cursor-pointer" | 1684 | isManualProjectSorting ? "cursor-grab active:cursor-grabbing" : "cursor-pointer" |
| 1690 | }`} | 1685 | }`} |
| 1691 | {...(isManualProjectSorting && dragHandleProps ? dragHandleProps.attributes : {})} | 1686 | {...(isManualProjectSorting && dragHandleProps ? dragHandleProps.attributes : {})} |
| @@ -69,17 +69,20 @@ export const ChatHeader = memo(function ChatHeader({ · 68 unmodified lines | |||
| 69 | onToggleDiff, | 69 | onToggleDiff, |
| 70 | }: ChatHeaderProps) { | 70 | }: ChatHeaderProps) { |
| 71 | return ( | 71 | return ( |
| 72 | <div className="@container/header-actions flex min-w-0 flex-1 items-center gap-2"> | 72 | <div className="@container/header-actions flex min-w-0 flex-1 flex-col gap-2 sm:flex-row sm:items-center sm:justify-between"> |
| 73 | <div className="flex min-w-0 flex-1 items-center gap-2 overflow-hidden sm:gap-3"> | 73 | <div className="flex min-w-0 flex-wrap items-center gap-2 overflow-hidden sm:flex-1 sm:flex-nowrap sm:gap-3"> |
| 74 | <SidebarTrigger className="size-7 shrink-0 md:hidden" /> | 74 | <SidebarTrigger className="size-7 shrink-0 md:hidden" /> |
| 75 | <h2 | 75 | <h2 |
| 76 | className="min-w-0 shrink truncate text-sm font-medium text-foreground" | 76 | className="min-w-0 flex-1 basis-40 truncate text-sm font-medium text-foreground" |
| 77 | title={activeThreadTitle} | 77 | title={activeThreadTitle} |
| 78 | > | 78 | > |
| 79 | {activeThreadTitle} | 79 | {activeThreadTitle} |
| 80 | </h2> | 80 | </h2> |
| 81 | {activeProjectName && ( | 81 | {activeProjectName && ( |
| 82 | <Badge variant="outline" className="min-w-0 shrink overflow-hidden"> | 82 | <Badge |
| 83 | variant="outline" | ||
| 84 | className="min-w-0 max-w-full shrink overflow-hidden sm:max-w-56" | ||
| 85 | > | ||
| 83 | <span className="min-w-0 truncate">{activeProjectName}</span> | 86 | <span className="min-w-0 truncate">{activeProjectName}</span> |
| 84 | </Badge> | 87 | </Badge> |
| 85 | )} | 88 | )} |
| @@ -89,7 +92,7 @@ export const ChatHeader = memo(function ChatHeader({ · 3 unmodified lines | |||
| 89 | </Badge> | 92 | </Badge> |
| 90 | )} | 93 | )} |
| 91 | </div> | 94 | </div> |
| 92 | <div className="flex shrink-0 items-center justify-end gap-2 @3xl/header-actions:gap-3"> | 95 | <div className="flex min-w-0 flex-wrap items-center justify-start gap-2 sm:shrink-0 sm:justify-end @3xl/header-actions:gap-3"> |
| 93 | {activeProjectScripts && ( | 96 | {activeProjectScripts && ( |
| 94 | <ProjectScriptsControl | 97 | <ProjectScriptsControl |
| 95 | scripts={activeProjectScripts} | 98 | scripts={activeProjectScripts} |
| @@ -45,14 +45,18 @@ · 44 unmodified lines | |||
| 45 | "version": "0.0.0", | 45 | "version": "0.0.0", |
| 46 | "dependencies": { | 46 | "dependencies": { |
| 47 | "@expo-google-fonts/dm-sans": "^0.4.2", | 47 | "@expo-google-fonts/dm-sans": "^0.4.2", |
| 48 | "@gorhom/bottom-sheet": "^5", | ||
| 48 | "@react-native-async-storage/async-storage": "2.2.0", | 49 | "@react-native-async-storage/async-storage": "2.2.0", |
| 49 | "@react-native-masked-view/masked-view": "0.3.2", | 50 | "@react-native-masked-view/masked-view": "0.3.2", |
| 51 | "@react-native-menu/menu": "^2.0.0", | ||
| 50 | "@sethwebster/react-ethereal-input": "^0.4.0", | 52 | "@sethwebster/react-ethereal-input": "^0.4.0", |
| 51 | "@shopify/flash-list": "2.0.2", | 53 | "@shopify/flash-list": "2.0.2", |
| 54 | "@t3tools/client-runtime": "workspace:*", | ||
| 52 | "@t3tools/contracts": "workspace:*", | 55 | "@t3tools/contracts": "workspace:*", |
| 53 | "@t3tools/shared": "workspace:*", | 56 | "@t3tools/shared": "workspace:*", |
| 54 | "effect": "catalog:", | 57 | "effect": "catalog:", |
| 55 | "expo": "~55.0.8", | 58 | "expo": "~55.0.8", |
| 59 | "expo-camera": "~55.0.15", | ||
| 56 | "expo-clipboard": "~55.0.9", | 60 | "expo-clipboard": "~55.0.9", |
| 57 | "expo-constants": "^55.0.9", | 61 | "expo-constants": "^55.0.9", |
| 58 | "expo-font": "^55.0.4", | 62 | "expo-font": "^55.0.4", |
| @@ -68,6 +72,7 @@ · 9 unmodified lines | |||
| 68 | "react": "19.2.0", | 72 | "react": "19.2.0", |
| 69 | "react-native": "0.83.4", | 73 | "react-native": "0.83.4", |
| 70 | "react-native-css": "3.0.7", | 74 | "react-native-css": "3.0.7", |
| 75 | "react-native-gesture-handler": "~2.31.1", | ||
| 71 | "react-native-markdown-display": "^7.0.2", | 76 | "react-native-markdown-display": "^7.0.2", |
| 72 | "react-native-reanimated": "4.2.1", | 77 | "react-native-reanimated": "4.2.1", |
| 73 | "react-native-safe-area-context": "~5.6.2", | 78 | "react-native-safe-area-context": "~5.6.2", |
| @@ -499,6 +504,8 @@ · 425 unmodified lines | |||
| 499 | 504 | ||
| 500 | "@effect/vitest": ["@effect/vitest@4.0.0-beta.45", "", { "peerDependencies": { "effect": "^4.0.0-beta.45", "vitest": "^3.0.0 || ^4.0.0" } }, "sha512-38JA5Z6c2FoEhpVKbl7rjTrMU8Al5T30Kwtb6N3B9kOkyr2SsmvNsP9T65bh03IBh3ak9U//acrkmjS3hLQwKA=="], | 505 | "@effect/vitest": ["@effect/vitest@4.0.0-beta.45", "", { "peerDependencies": { "effect": "^4.0.0-beta.45", "vitest": "^3.0.0 || ^4.0.0" } }, "sha512-38JA5Z6c2FoEhpVKbl7rjTrMU8Al5T30Kwtb6N3B9kOkyr2SsmvNsP9T65bh03IBh3ak9U//acrkmjS3hLQwKA=="], |
| 501 | 506 | ||
| 507 | "@egjs/hammerjs": ["@egjs/hammerjs@2.0.17", "", { "dependencies": { "@types/hammerjs": "^2.0.36" } }, "sha512-XQsZgjm2EcVUiZQf11UBJQfmZeEmOW8DpI1gsFeln6w0ae0ii4dMQEQ0kjl6DspdWX1aGY1/loyXnP0JS06e/A=="], | ||
| 508 | |||
| 502 | "@electron/get": ["@electron/get@2.0.3", "", { "dependencies": { "debug": "^4.1.1", "env-paths": "^2.2.0", "fs-extra": "^8.1.0", "got": "^11.8.5", "progress": "^2.0.3", "semver": "^6.2.0", "sumchecker": "^3.0.1" }, "optionalDependencies": { "global-agent": "^3.0.0" } }, "sha512-Qkzpg2s9GnVV2I2BjRksUi43U5e6+zaQMcjoJy0C+C5oxaKl+fmckGDQFtRpZpZV0NQekuZZ+tGz7EA9TVnQtQ=="], | 509 | "@electron/get": ["@electron/get@2.0.3", "", { "dependencies": { "debug": "^4.1.1", "env-paths": "^2.2.0", "fs-extra": "^8.1.0", "got": "^11.8.5", "progress": "^2.0.3", "semver": "^6.2.0", "sumchecker": "^3.0.1" }, "optionalDependencies": { "global-agent": "^3.0.0" } }, "sha512-Qkzpg2s9GnVV2I2BjRksUi43U5e6+zaQMcjoJy0C+C5oxaKl+fmckGDQFtRpZpZV0NQekuZZ+tGz7EA9TVnQtQ=="], |
| 503 | 510 | ||
| 504 | "@emmetio/abbreviation": ["@emmetio/abbreviation@2.3.3", "", { "dependencies": { "@emmetio/scanner": "^1.0.4" } }, "sha512-mgv58UrU3rh4YgbE/TzgLQwJ3pFsHHhCLqY20aJq+9comytTXUDNGG/SMtSeMJdkpxgXSXunBGLD8Boka3JyVA=="], | 511 | "@emmetio/abbreviation": ["@emmetio/abbreviation@2.3.3", "", { "dependencies": { "@emmetio/scanner": "^1.0.4" } }, "sha512-mgv58UrU3rh4YgbE/TzgLQwJ3pFsHHhCLqY20aJq+9comytTXUDNGG/SMtSeMJdkpxgXSXunBGLD8Boka3JyVA=="], |
| @@ -647,6 +654,10 @@ · 142 unmodified lines | |||
| 647 | 654 | ||
| 648 | "@formkit/auto-animate": ["@formkit/auto-animate@0.9.0", "", {}, "sha512-VhP4zEAacXS3dfTpJpJ88QdLqMTcabMg0jwpOSxZ/VzfQVfl3GkZSCZThhGC5uhq/TxPHPzW0dzr4H9Bb1OgKA=="], | 655 | "@formkit/auto-animate": ["@formkit/auto-animate@0.9.0", "", {}, "sha512-VhP4zEAacXS3dfTpJpJ88QdLqMTcabMg0jwpOSxZ/VzfQVfl3GkZSCZThhGC5uhq/TxPHPzW0dzr4H9Bb1OgKA=="], |
| 649 | 656 | ||
| 657 | "@gorhom/bottom-sheet": ["@gorhom/bottom-sheet@5.2.9", "", { "dependencies": { "@gorhom/portal": "1.0.14", "invariant": "^2.2.4" }, "peerDependencies": { "@types/react": "*", "@types/react-native": "*", "react": "*", "react-native": "*", "react-native-gesture-handler": ">=2.16.1", "react-native-reanimated": ">=3.16.0 || >=4.0.0-" }, "optionalPeers": ["@types/react", "@types/react-native"] }, "sha512-YwieCsEnTQnN2QW4VBKfCGszzxaw2ID7FydusEgqo7qB817fZ45N88kptcuNwZFnnauCjdyzKdrVBWmLmpl9oQ=="], | ||
| 658 | |||
| 659 | "@gorhom/portal": ["@gorhom/portal@1.0.14", "", { "dependencies": { "nanoid": "^3.3.1" }, "peerDependencies": { "react": "*", "react-native": "*" } }, "sha512-MXyL4xvCjmgaORr/rtryDNFy3kU4qUbKlwtQqqsygd0xX3mhKjOLn6mQK8wfu0RkoE0pBE0nAasRoHua+/QZ7A=="], | ||
| 660 | |||
| 650 | "@hono/node-server": ["@hono/node-server@1.19.14", "", { "peerDependencies": { "hono": "^4" } }, "sha512-GwtvgtXxnWsucXvbQXkRgqksiH2Qed37H9xHZocE5sA3N8O8O8/8FA3uclQXxXVzc9XBZuEOMK7+r02FmSpHtw=="], | 661 | "@hono/node-server": ["@hono/node-server@1.19.14", "", { "peerDependencies": { "hono": "^4" } }, "sha512-GwtvgtXxnWsucXvbQXkRgqksiH2Qed37H9xHZocE5sA3N8O8O8/8FA3uclQXxXVzc9XBZuEOMK7+r02FmSpHtw=="], |
| 651 | 662 | ||
| 652 | "@img/colour": ["@img/colour@1.1.0", "", {}, "sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ=="], | 663 | "@img/colour": ["@img/colour@1.1.0", "", {}, "sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ=="], |
| @@ -907,6 +918,8 @@ · 254 unmodified lines | |||
| 907 | 918 | ||
| 908 | "@react-native-masked-view/masked-view": ["@react-native-masked-view/masked-view@0.3.2", "", { "peerDependencies": { "react": ">=16", "react-native": ">=0.57" } }, "sha512-XwuQoW7/GEgWRMovOQtX3A4PrXhyaZm0lVUiY8qJDvdngjLms9Cpdck6SmGAUNqQwcj2EadHC1HwL0bEyoa/SQ=="], | 919 | "@react-native-masked-view/masked-view": ["@react-native-masked-view/masked-view@0.3.2", "", { "peerDependencies": { "react": ">=16", "react-native": ">=0.57" } }, "sha512-XwuQoW7/GEgWRMovOQtX3A4PrXhyaZm0lVUiY8qJDvdngjLms9Cpdck6SmGAUNqQwcj2EadHC1HwL0bEyoa/SQ=="], |
| 909 | 920 | ||
| 921 | "@react-native-menu/menu": ["@react-native-menu/menu@2.0.0", "", { "peerDependencies": { "react": "*", "react-native": "*" } }, "sha512-hb8Mirw6aKPGONhgo52IiNpwHtISVrgCT3rMdFX1qS7eOFNzOcQh8d2UDnaH5zVpxN+QuvWtaaiRMGFpIjzdtA=="], | ||
| 922 | |||
| 910 | "@react-native/assets-registry": ["@react-native/assets-registry@0.83.4", "", {}, "sha512-aqKtpbJDSQeSX/Dwv0yMe1/Rd2QfXi12lnyZDXNn/OEKz59u6+LuPBVgO/9CRyclHmdlvwg8c7PJ9eX2ZMnjWg=="], | 923 | "@react-native/assets-registry": ["@react-native/assets-registry@0.83.4", "", {}, "sha512-aqKtpbJDSQeSX/Dwv0yMe1/Rd2QfXi12lnyZDXNn/OEKz59u6+LuPBVgO/9CRyclHmdlvwg8c7PJ9eX2ZMnjWg=="], |
| 911 | 924 | ||
| 912 | "@react-native/babel-plugin-codegen": ["@react-native/babel-plugin-codegen@0.83.4", "", { "dependencies": { "@babel/traverse": "^7.25.3", "@react-native/codegen": "0.83.4" } }, "sha512-UFsK+c1rvT84XZfzpmwKePsc5nTr5LK7hh18TI0DooNlVcztDbMDsQZpDnhO/gmk7aTbWEqO5AB3HJ7tvGp+Jg=="], | 925 | "@react-native/babel-plugin-codegen": ["@react-native/babel-plugin-codegen@0.83.4", "", { "dependencies": { "@babel/traverse": "^7.25.3", "@react-native/codegen": "0.83.4" } }, "sha512-UFsK+c1rvT84XZfzpmwKePsc5nTr5LK7hh18TI0DooNlVcztDbMDsQZpDnhO/gmk7aTbWEqO5AB3HJ7tvGp+Jg=="], |
| @@ -1109,12 +1122,16 @@ · 196 unmodified lines | |||
| 1109 | 1122 | ||
| 1110 | "@types/deep-eql": ["@types/deep-eql@4.0.2", "", {}, "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw=="], | 1123 | "@types/deep-eql": ["@types/deep-eql@4.0.2", "", {}, "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw=="], |
| 1111 | 1124 | ||
| 1125 | "@types/emscripten": ["@types/emscripten@1.41.5", "", {}, "sha512-cMQm7pxu6BxtHyqJ7mQZ2kXWV5SLmugybFdHCBbJ5eHzOo6VhBckEgAT3//rP5FwPHNPeEiq4SmQ5ucBwsOo4Q=="], | ||
| 1126 | |||
| 1112 | "@types/estree": ["@types/estree@1.0.8", "", {}, "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w=="], | 1127 | "@types/estree": ["@types/estree@1.0.8", "", {}, "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w=="], |
| 1113 | 1128 | ||
| 1114 | "@types/estree-jsx": ["@types/estree-jsx@1.0.5", "", { "dependencies": { "@types/estree": "*" } }, "sha512-52CcUVNFyfb1A2ALocQw/Dd1BQFNmSdkuC3BkZ6iqhdMfQz7JWOFRuJFloOzjk+6WijU56m9oKXFAXc7o3Towg=="], | 1129 | "@types/estree-jsx": ["@types/estree-jsx@1.0.5", "", { "dependencies": { "@types/estree": "*" } }, "sha512-52CcUVNFyfb1A2ALocQw/Dd1BQFNmSdkuC3BkZ6iqhdMfQz7JWOFRuJFloOzjk+6WijU56m9oKXFAXc7o3Towg=="], |
| 1115 | 1130 | ||
| 1116 | "@types/graceful-fs": ["@types/graceful-fs@4.1.9", "", { "dependencies": { "@types/node": "*" } }, "sha512-olP3sd1qOEe5dXTSaFvQG+02VdRXcdytWLAZsAq1PecU8uqQAhkrnbli7DagjtXKW/Bl7YJbUsa8MPcuc8LHEQ=="], | 1131 | "@types/graceful-fs": ["@types/graceful-fs@4.1.9", "", { "dependencies": { "@types/node": "*" } }, "sha512-olP3sd1qOEe5dXTSaFvQG+02VdRXcdytWLAZsAq1PecU8uqQAhkrnbli7DagjtXKW/Bl7YJbUsa8MPcuc8LHEQ=="], |
| 1117 | 1132 | ||
| 1133 | "@types/hammerjs": ["@types/hammerjs@2.0.46", "", {}, "sha512-ynRvcq6wvqexJ9brDMS4BnBLzmr0e14d6ZJTEShTBWKymQiHwlAyGu0ZPEFI2Fh1U53F7tN9ufClWM5KvqkKOw=="], | ||
| 1134 | |||
| 1118 | "@types/hast": ["@types/hast@3.0.4", "", { "dependencies": { "@types/unist": "*" } }, "sha512-WPs+bbQw5aCj+x6laNGWLH3wviHtoCv/P3+otBhbOhJgG8qtpdAMlTCxLtsTWA7LH1Oh/bFCHsBn0TPS5m30EQ=="], | 1135 | "@types/hast": ["@types/hast@3.0.4", "", { "dependencies": { "@types/unist": "*" } }, "sha512-WPs+bbQw5aCj+x6laNGWLH3wviHtoCv/P3+otBhbOhJgG8qtpdAMlTCxLtsTWA7LH1Oh/bFCHsBn0TPS5m30EQ=="], |
| 1119 | 1136 | ||
| 1120 | "@types/http-cache-semantics": ["@types/http-cache-semantics@4.2.0", "", {}, "sha512-L3LgimLHXtGkWikKnsPg0/VFx9OGZaC+eN1u4r+OB1XRqH3meBIAVC2zr1WdMH+RHmnRkqliQAOHNJ/E0j/e0Q=="], | 1137 | "@types/http-cache-semantics": ["@types/http-cache-semantics@4.2.0", "", {}, "sha512-L3LgimLHXtGkWikKnsPg0/VFx9OGZaC+eN1u4r+OB1XRqH3meBIAVC2zr1WdMH+RHmnRkqliQAOHNJ/E0j/e0Q=="], |
| @@ -1141,6 +1158,8 @@ · 20 unmodified lines | |||
| 1141 | 1158 | ||
| 1142 | "@types/react-dom": ["@types/react-dom@19.2.3", "", { "peerDependencies": { "@types/react": "^19.2.0" } }, "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ=="], | 1159 | "@types/react-dom": ["@types/react-dom@19.2.3", "", { "peerDependencies": { "@types/react": "^19.2.0" } }, "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ=="], |
| 1143 | 1160 | ||
| 1161 | "@types/react-test-renderer": ["@types/react-test-renderer@19.1.0", "", { "dependencies": { "@types/react": "*" } }, "sha512-XD0WZrHqjNrxA/MaR9O22w/RNidWR9YZmBdRGI7wcnWGrv/3dA8wKCJ8m63Sn+tLJhcjmuhOi629N66W6kgWzQ=="], | ||
| 1162 | |||
| 1144 | "@types/responselike": ["@types/responselike@1.0.3", "", { "dependencies": { "@types/node": "*" } }, "sha512-H/+L+UkTV33uf49PH5pCAUBVPNj2nDBXTN+qS1dOwyyg24l3CcicicCA7ca+HMvJBZcFgl5r8e+RR6elsb4Lyw=="], | 1163 | "@types/responselike": ["@types/responselike@1.0.3", "", { "dependencies": { "@types/node": "*" } }, "sha512-H/+L+UkTV33uf49PH5pCAUBVPNj2nDBXTN+qS1dOwyyg24l3CcicicCA7ca+HMvJBZcFgl5r8e+RR6elsb4Lyw=="], |
| 1145 | 1164 | ||
| 1146 | "@types/stack-utils": ["@types/stack-utils@2.0.3", "", {}, "sha512-9aEbYZ3TbYMznPdcdr3SmIrLXwC/AKZXQeCf9Pgao5CKb8CyHuEX5jzWPTkvregvhRJHcpRO6BFoGW9ycaOkYw=="], | 1165 | "@types/stack-utils": ["@types/stack-utils@2.0.3", "", {}, "sha512-9aEbYZ3TbYMznPdcdr3SmIrLXwC/AKZXQeCf9Pgao5CKb8CyHuEX5jzWPTkvregvhRJHcpRO6BFoGW9ycaOkYw=="], |
| @@ -1279,6 +1298,8 @@ · 132 unmodified lines | |||
| 1279 | 1298 | ||
| 1280 | "balanced-match": ["balanced-match@4.0.4", "", {}, "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA=="], | 1299 | "balanced-match": ["balanced-match@4.0.4", "", {}, "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA=="], |
| 1281 | 1300 | ||
| 1301 | "barcode-detector": ["barcode-detector@3.1.2", "", { "dependencies": { "zxing-wasm": "3.0.2" } }, "sha512-Q5kjXpVH5I3ItykNzbWmfWnNryFN1ZTWp10k9/PKJuS0RnoKR7jTrHEJODR4fn04bRomq7TJwie/Dr9fj/GoGQ=="], | ||
| 1302 | |||
| 1282 | "base64-js": ["base64-js@1.5.1", "", {}, "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA=="], | 1303 | "base64-js": ["base64-js@1.5.1", "", {}, "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA=="], |
| 1283 | 1304 | ||
| 1284 | "baseline-browser-mapping": ["baseline-browser-mapping@2.10.18", "", { "bin": { "baseline-browser-mapping": "dist/cli.cjs" } }, "sha512-VSnGQAOLtP5mib/DPyg2/t+Tlv65NTBz83BJBJvmLVHHuKJVaDOBvJJykiT5TR++em5nfAySPccDZDa4oSrn8A=="], | 1305 | "baseline-browser-mapping": ["baseline-browser-mapping@2.10.18", "", { "bin": { "baseline-browser-mapping": "dist/cli.cjs" } }, "sha512-VSnGQAOLtP5mib/DPyg2/t+Tlv65NTBz83BJBJvmLVHHuKJVaDOBvJJykiT5TR++em5nfAySPccDZDa4oSrn8A=="], |
| @@ -1567,6 +1588,8 @@ · 282 unmodified lines | |||
| 1567 | 1588 | ||
| 1568 | "expo-asset": ["expo-asset@55.0.15", "", { "dependencies": { "@expo/image-utils": "^0.8.13", "expo-constants": "~55.0.14" }, "peerDependencies": { "expo": "*", "react": "*", "react-native": "*" } }, "sha512-d3FIpHJ6ZngYXxRItYWBGT5H8Wkk7/l4fMe8Mmd2xDyKrO0/CM7c8r/J5M71D+BJr5P3My8wertGYZXHSiZYxQ=="], | 1589 | "expo-asset": ["expo-asset@55.0.15", "", { "dependencies": { "@expo/image-utils": "^0.8.13", "expo-constants": "~55.0.14" }, "peerDependencies": { "expo": "*", "react": "*", "react-native": "*" } }, "sha512-d3FIpHJ6ZngYXxRItYWBGT5H8Wkk7/l4fMe8Mmd2xDyKrO0/CM7c8r/J5M71D+BJr5P3My8wertGYZXHSiZYxQ=="], |
| 1569 | 1590 | ||
| 1591 | "expo-camera": ["expo-camera@55.0.15", "", { "dependencies": { "barcode-detector": "^3.0.0" }, "peerDependencies": { "expo": "*", "react": "*", "react-native": "*", "react-native-web": "*" }, "optionalPeers": ["react-native-web"] }, "sha512-WRVsZf+2p7EsxudwyiUMYijJS8M98t/BVP6yG7N+08JSUotkGjmZcemom1gM36uy27P8QsSVP0hD+FravmQiBA=="], | ||
| 1592 | |||
| 1570 | "expo-clipboard": ["expo-clipboard@55.0.13", "", { "peerDependencies": { "expo": "*", "react": "*", "react-native": "*" } }, "sha512-PrOmmuVsGW4bAkNQmGKtxMXj3invsfN+jfIKmQxHwE/dn7ODqwFWviUTa+PMUjP3XZmYCDLyu/i0GLeu7HF9Ew=="], | 1593 | "expo-clipboard": ["expo-clipboard@55.0.13", "", { "peerDependencies": { "expo": "*", "react": "*", "react-native": "*" } }, "sha512-PrOmmuVsGW4bAkNQmGKtxMXj3invsfN+jfIKmQxHwE/dn7ODqwFWviUTa+PMUjP3XZmYCDLyu/i0GLeu7HF9Ew=="], |
| 1571 | 1594 | ||
| 1572 | "expo-constants": ["expo-constants@55.0.14", "", { "dependencies": { "@expo/config": "~55.0.15", "@expo/env": "~2.1.1" }, "peerDependencies": { "expo": "*", "react-native": "*" } }, "sha512-l23QVQCYBPKT5zbxxZdJeuhiunadvWdjcQ9+GC8h+02jCoLmWRk20064nCINnQTP3Hf+uLPteUiwYrJd0e446w=="], | 1595 | "expo-constants": ["expo-constants@55.0.14", "", { "dependencies": { "@expo/config": "~55.0.15", "@expo/env": "~2.1.1" }, "peerDependencies": { "expo": "*", "react-native": "*" } }, "sha512-l23QVQCYBPKT5zbxxZdJeuhiunadvWdjcQ9+GC8h+02jCoLmWRk20064nCINnQTP3Hf+uLPteUiwYrJd0e446w=="], |
| @@ -1747,6 +1770,8 @@ · 174 unmodified lines | |||
| 1747 | 1770 | ||
| 1748 | "hermes-parser": ["hermes-parser@0.32.1", "", { "dependencies": { "hermes-estree": "0.32.1" } }, "sha512-175dz634X/W5AiwrpLdoMl/MOb17poLHyIqgyExlE8D9zQ1OPnoORnGMB5ltRKnpvQzBjMYvT2rN/sHeIfZW5Q=="], | 1771 | "hermes-parser": ["hermes-parser@0.32.1", "", { "dependencies": { "hermes-estree": "0.32.1" } }, "sha512-175dz634X/W5AiwrpLdoMl/MOb17poLHyIqgyExlE8D9zQ1OPnoORnGMB5ltRKnpvQzBjMYvT2rN/sHeIfZW5Q=="], |
| 1749 | 1772 | ||
| 1773 | "hoist-non-react-statics": ["hoist-non-react-statics@3.3.2", "", { "dependencies": { "react-is": "^16.7.0" } }, "sha512-/gGivxi8JPKWNm/W0jSmzcMPpfpPLc3dY/6GxhX2hQ9iGj3aDfklV4ET7NjKpSinLpJ5vafa9iiGIEZg10SfBw=="], | ||
| 1774 | |||
| 1750 | "hono": ["hono@4.12.12", "", {}, "sha512-p1JfQMKaceuCbpJKAPKVqyqviZdS0eUxH9v82oWo1kb9xjQ5wA6iP3FNVAPDFlz5/p7d45lO+BpSk1tuSZMF4Q=="], | 1775 | "hono": ["hono@4.12.12", "", {}, "sha512-p1JfQMKaceuCbpJKAPKVqyqviZdS0eUxH9v82oWo1kb9xjQ5wA6iP3FNVAPDFlz5/p7d45lO+BpSk1tuSZMF4Q=="], |
| 1751 | 1776 | ||
| 1752 | "hookable": ["hookable@6.1.0", "", {}, "sha512-ZoKZSJgu8voGK2geJS+6YtYjvIzu9AOM/KZXsBxr83uhLL++e9pEv/dlgwgy3dvHg06kTz6JOh1hk3C8Ceiymw=="], | 1777 | "hookable": ["hookable@6.1.0", "", {}, "sha512-ZoKZSJgu8voGK2geJS+6YtYjvIzu9AOM/KZXsBxr83uhLL++e9pEv/dlgwgy3dvHg06kTz6JOh1hk3C8Ceiymw=="], |
| @@ -2333,6 +2358,8 @@ · 580 unmodified lines | |||
| 2333 | 2358 | ||
| 2334 | "react-native-fit-image": ["react-native-fit-image@1.5.5", "", { "dependencies": { "prop-types": "^15.5.10" } }, "sha512-Wl3Vq2DQzxgsWKuW4USfck9zS7YzhvLNPpkwUUCF90bL32e1a0zOVQ3WsJILJOwzmPdHfzZmWasiiAUNBkhNkg=="], | 2359 | "react-native-fit-image": ["react-native-fit-image@1.5.5", "", { "dependencies": { "prop-types": "^15.5.10" } }, "sha512-Wl3Vq2DQzxgsWKuW4USfck9zS7YzhvLNPpkwUUCF90bL32e1a0zOVQ3WsJILJOwzmPdHfzZmWasiiAUNBkhNkg=="], |
| 2335 | 2360 | ||
| 2361 | "react-native-gesture-handler": ["react-native-gesture-handler@2.31.1", "", { "dependencies": { "@egjs/hammerjs": "^2.0.17", "@types/react-test-renderer": "^19.1.0", "hoist-non-react-statics": "^3.3.0", "invariant": "^2.2.4" }, "peerDependencies": { "react": "*", "react-native": "*" } }, "sha512-wQDlECdEzHhYKTnQXFnSqWUtJ5TS3MGQi7EWvQczTnEVKfk6XVSBecnpWAoI/CqlYQ7IWMJEyutY6BxwEBoxeg=="], | ||
| 2362 | |||
| 2336 | "react-native-is-edge-to-edge": ["react-native-is-edge-to-edge@1.2.1", "", { "peerDependencies": { "react": "*", "react-native": "*" } }, "sha512-FLbPWl/MyYQWz+KwqOZsSyj2JmLKglHatd3xLZWskXOpRaio4LfEDEz8E/A6uD8QoTHW6Aobw1jbEwK7KMgR7Q=="], | 2363 | "react-native-is-edge-to-edge": ["react-native-is-edge-to-edge@1.2.1", "", { "peerDependencies": { "react": "*", "react-native": "*" } }, "sha512-FLbPWl/MyYQWz+KwqOZsSyj2JmLKglHatd3xLZWskXOpRaio4LfEDEz8E/A6uD8QoTHW6Aobw1jbEwK7KMgR7Q=="], |
| 2337 | 2364 | ||
| 2338 | "react-native-markdown-display": ["react-native-markdown-display@7.0.2", "", { "dependencies": { "css-to-react-native": "^3.0.0", "markdown-it": "^10.0.0", "prop-types": "^15.7.2", "react-native-fit-image": "^1.5.5" }, "peerDependencies": { "react": ">=16.2.0", "react-native": ">=0.50.4" } }, "sha512-Mn4wotMvMfLAwbX/huMLt202W5DsdpMO/kblk+6eUs55S57VVNni1gzZCh5qpznYLjIQELNh50VIozEfY6fvaQ=="], | 2365 | "react-native-markdown-display": ["react-native-markdown-display@7.0.2", "", { "dependencies": { "css-to-react-native": "^3.0.0", "markdown-it": "^10.0.0", "prop-types": "^15.7.2", "react-native-fit-image": "^1.5.5" }, "peerDependencies": { "react": ">=16.2.0", "react-native": ">=0.50.4" } }, "sha512-Mn4wotMvMfLAwbX/huMLt202W5DsdpMO/kblk+6eUs55S57VVNni1gzZCh5qpznYLjIQELNh50VIozEfY6fvaQ=="], |
| @@ -2815,6 +2842,8 @@ · 476 unmodified lines | |||
| 2815 | 2842 | ||
| 2816 | "zwitch": ["zwitch@2.0.4", "", {}, "sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A=="], | 2843 | "zwitch": ["zwitch@2.0.4", "", {}, "sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A=="], |
| 2817 | 2844 | ||
| 2845 | "zxing-wasm": ["zxing-wasm@3.0.2", "", { "dependencies": { "@types/emscripten": "^1.41.5", "type-fest": "^5.5.0" } }, "sha512-2YMAriaYHX9wrBY2k7H0epSo+dyCaCZg/vOtt+nEDXM9ul480gkXz/9SkwpOeHcD2H5qqDG8lWDSBwpTcZpa6w=="], | ||
| 2846 | |||
| 2818 | "@astrojs/language-server/@astrojs/compiler": ["@astrojs/compiler@2.13.1", "", {}, "sha512-f3FN83d2G/v32ipNClRKgYv30onQlMZX1vCeZMjPsMMPl1mDpmbl0+N5BYo4S/ofzqJyS5hvwacEo0CCVDn/Qg=="], | 2847 | "@astrojs/language-server/@astrojs/compiler": ["@astrojs/compiler@2.13.1", "", {}, "sha512-f3FN83d2G/v32ipNClRKgYv30onQlMZX1vCeZMjPsMMPl1mDpmbl0+N5BYo4S/ofzqJyS5hvwacEo0CCVDn/Qg=="], |
| 2819 | 2848 | ||
| 2820 | "@babel/code-frame/@babel/helper-validator-identifier": ["@babel/helper-validator-identifier@7.28.5", "", {}, "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q=="], | 2849 | "@babel/code-frame/@babel/helper-validator-identifier": ["@babel/helper-validator-identifier@7.28.5", "", {}, "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q=="], |
| @@ -2971,6 +3000,8 @@ · 150 unmodified lines | |||
| 2971 | 3000 | ||
| 2972 | "h3/cookie-es": ["cookie-es@1.2.3", "", {}, "sha512-lXVyvUvrNXblMqzIRrxHb57UUVmqsSWlxqt3XIjCkUP0wDAf6uicO6KMbEgYrMNtEvWgWHwe42CKxPu9MYAnWw=="], | 3001 | "h3/cookie-es": ["cookie-es@1.2.3", "", {}, "sha512-lXVyvUvrNXblMqzIRrxHb57UUVmqsSWlxqt3XIjCkUP0wDAf6uicO6KMbEgYrMNtEvWgWHwe42CKxPu9MYAnWw=="], |
| 2973 | 3002 | ||
| 3003 | "hoist-non-react-statics/react-is": ["react-is@16.13.1", "", {}, "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ=="], | ||
| 3004 | |||
| 2974 | "hosted-git-info/lru-cache": ["lru-cache@10.4.3", "", {}, "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ=="], | 3005 | "hosted-git-info/lru-cache": ["lru-cache@10.4.3", "", {}, "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ=="], |
| 2975 | 3006 | ||
| 2976 | "istanbul-lib-instrument/semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="], | 3007 | "istanbul-lib-instrument/semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="], |
| @@ -3,10 +3,15 @@ · 2 unmodified lines | |||
| 3 | "version": "0.0.0-alpha.1", | 3 | "version": "0.0.0-alpha.1", |
| 4 | "private": true, | 4 | "private": true, |
| 5 | "type": "module", | 5 | "type": "module", |
| 6 | "main": "./src/index.ts", | ||
| 7 | "types": "./src/index.ts", | ||
| 6 | "exports": { | 8 | "exports": { |
| 7 | ".": { | 9 | ".": { |
| 8 | "types": "./src/index.ts", | 10 | "types": "./src/index.ts", |
| 9 | "import": "./src/index.ts" | 11 | "react-native": "./src/index.ts", |
| 12 | "import": "./src/index.ts", | ||
| 13 | "require": "./src/index.ts", | ||
| 14 | "default": "./src/index.ts" | ||
| 10 | } | 15 | } |
| 11 | }, | 16 | }, |
| 12 | "scripts": { | 17 | "scripts": { |
| @@ -0,0 +1,430 @@ | |||
| 1 | import type { | ||
| 2 | GitRunStackedActionInput, | ||
| 3 | GitRunStackedActionResult, | ||
| 4 | GitStackedAction, | ||
| 5 | GitStatusResult, | ||
| 6 | } from "@t3tools/contracts"; | ||
| 7 | |||
| 8 | export type GitActionIconName = "commit" | "push" | "pr"; | ||
| 9 | |||
| 10 | export type GitDialogAction = "commit" | "push" | "create_pr"; | ||
| 11 | |||
| 12 | export interface GitActionMenuItem { | ||
| 13 | id: "commit" | "push" | "pr"; | ||
| 14 | label: string; | ||
| 15 | disabled: boolean; | ||
| 16 | icon: GitActionIconName; | ||
| 17 | kind: "open_dialog" | "open_pr"; | ||
| 18 | dialogAction?: GitDialogAction; | ||
| 19 | } | ||
| 20 | |||
| 21 | export interface GitQuickAction { | ||
| 22 | label: string; | ||
| 23 | disabled: boolean; | ||
| 24 | kind: "run_action" | "run_pull" | "open_pr" | "show_hint"; | ||
| 25 | action?: GitStackedAction; | ||
| 26 | hint?: string; | ||
| 27 | } | ||
| 28 | |||
| 29 | export interface DefaultBranchActionDialogCopy { | ||
| 30 | title: string; | ||
| 31 | description: string; | ||
| 32 | continueLabel: string; | ||
| 33 | } | ||
| 34 | |||
| 35 | export type DefaultBranchConfirmableAction = | ||
| 36 | | "push" | ||
| 37 | | "create_pr" | ||
| 38 | | "commit_push" | ||
| 39 | | "commit_push_pr"; | ||
| 40 | |||
| 41 | export type GitActionRequestInput = Pick< | ||
| 42 | GitRunStackedActionInput, | ||
| 43 | "action" | "commitMessage" | "featureBranch" | "filePaths" | ||
| 44 | >; | ||
| 45 | |||
| 46 | export function buildGitActionProgressStages(input: { | ||
| 47 | action: GitStackedAction; | ||
| 48 | hasCustomCommitMessage: boolean; | ||
| 49 | hasWorkingTreeChanges: boolean; | ||
| 50 | pushTarget?: string; | ||
| 51 | featureBranch?: boolean; | ||
| 52 | shouldPushBeforePr?: boolean; | ||
| 53 | }): string[] { | ||
| 54 | const branchStages = input.featureBranch ? ["Preparing feature branch..."] : []; | ||
| 55 | const pushStage = input.pushTarget ? `Pushing to ${input.pushTarget}...` : "Pushing..."; | ||
| 56 | const prStages = [ | ||
| 57 | "Preparing PR...", | ||
| 58 | "Generating PR content...", | ||
| 59 | "Creating GitHub pull request...", | ||
| 60 | ]; | ||
| 61 | |||
| 62 | if (input.action === "push") { | ||
| 63 | return [pushStage]; | ||
| 64 | } | ||
| 65 | if (input.action === "create_pr") { | ||
| 66 | return input.shouldPushBeforePr ? [pushStage, ...prStages] : prStages; | ||
| 67 | } | ||
| 68 | |||
| 69 | const shouldIncludeCommitStages = input.action === "commit" || input.hasWorkingTreeChanges; | ||
| 70 | const commitStages = !shouldIncludeCommitStages | ||
| 71 | ? [] | ||
| 72 | : input.hasCustomCommitMessage | ||
| 73 | ? ["Committing..."] | ||
| 74 | : ["Generating commit message...", "Committing..."]; | ||
| 75 | if (input.action === "commit") { | ||
| 76 | return [...branchStages, ...commitStages]; | ||
| 77 | } | ||
| 78 | if (input.action === "commit_push") { | ||
| 79 | return [...branchStages, ...commitStages, pushStage]; | ||
| 80 | } | ||
| 81 | return [...branchStages, ...commitStages, pushStage, ...prStages]; | ||
| 82 | } | ||
| 83 | |||
| 84 | export function buildMenuItems( | ||
| 85 | gitStatus: GitStatusResult | null, | ||
| 86 | isBusy: boolean, | ||
| 87 | hasOriginRemote = true, | ||
| 88 | ): GitActionMenuItem[] { | ||
| 89 | if (!gitStatus) return []; | ||
| 90 | |||
| 91 | const hasBranch = gitStatus.branch !== null; | ||
| 92 | const hasChanges = gitStatus.hasWorkingTreeChanges; | ||
| 93 | const hasOpenPr = gitStatus.pr?.state === "open"; | ||
| 94 | const isBehind = gitStatus.behindCount > 0; | ||
| 95 | const canPushWithoutUpstream = hasOriginRemote && !gitStatus.hasUpstream; | ||
| 96 | const canCommit = !isBusy && hasChanges; | ||
| 97 | const canPush = | ||
| 98 | !isBusy && | ||
| 99 | hasBranch && | ||
| 100 | !hasChanges && | ||
| 101 | !isBehind && | ||
| 102 | gitStatus.aheadCount > 0 && | ||
| 103 | (gitStatus.hasUpstream || canPushWithoutUpstream); | ||
| 104 | const canCreatePr = | ||
| 105 | !isBusy && | ||
| 106 | hasBranch && | ||
| 107 | !hasChanges && | ||
| 108 | !hasOpenPr && | ||
| 109 | gitStatus.aheadCount > 0 && | ||
| 110 | !isBehind && | ||
| 111 | (gitStatus.hasUpstream || canPushWithoutUpstream); | ||
| 112 | const canOpenPr = !isBusy && hasOpenPr; | ||
| 113 | |||
| 114 | return [ | ||
| 115 | { | ||
| 116 | id: "commit", | ||
| 117 | label: "Commit", | ||
| 118 | disabled: !canCommit, | ||
| 119 | icon: "commit", | ||
| 120 | kind: "open_dialog", | ||
| 121 | dialogAction: "commit", | ||
| 122 | }, | ||
| 123 | { | ||
| 124 | id: "push", | ||
| 125 | label: "Push", | ||
| 126 | disabled: !canPush, | ||
| 127 | icon: "push", | ||
| 128 | kind: "open_dialog", | ||
| 129 | dialogAction: "push", | ||
| 130 | }, | ||
| 131 | hasOpenPr | ||
| 132 | ? { | ||
| 133 | id: "pr", | ||
| 134 | label: "View PR", | ||
| 135 | disabled: !canOpenPr, | ||
| 136 | icon: "pr", | ||
| 137 | kind: "open_pr", | ||
| 138 | } | ||
| 139 | : { | ||
| 140 | id: "pr", | ||
| 141 | label: "Create PR", | ||
| 142 | disabled: !canCreatePr, | ||
| 143 | icon: "pr", | ||
| 144 | kind: "open_dialog", | ||
| 145 | dialogAction: "create_pr", | ||
| 146 | }, | ||
| 147 | ]; | ||
| 148 | } | ||
| 149 | |||
| 150 | export function resolveQuickAction( | ||
| 151 | gitStatus: GitStatusResult | null, | ||
| 152 | isBusy: boolean, | ||
| 153 | isDefaultBranch = false, | ||
| 154 | hasOriginRemote = true, | ||
| 155 | ): GitQuickAction { | ||
| 156 | if (isBusy) { | ||
| 157 | return { label: "Commit", disabled: true, kind: "show_hint", hint: "Git action in progress." }; | ||
| 158 | } | ||
| 159 | |||
| 160 | if (!gitStatus) { | ||
| 161 | return { | ||
| 162 | label: "Commit", | ||
| 163 | disabled: true, | ||
| 164 | kind: "show_hint", | ||
| 165 | hint: "Git status is unavailable.", | ||
| 166 | }; | ||
| 167 | } | ||
| 168 | |||
| 169 | const hasBranch = gitStatus.branch !== null; | ||
| 170 | const hasChanges = gitStatus.hasWorkingTreeChanges; | ||
| 171 | const hasOpenPr = gitStatus.pr?.state === "open"; | ||
| 172 | const isAhead = gitStatus.aheadCount > 0; | ||
| 173 | const isBehind = gitStatus.behindCount > 0; | ||
| 174 | const isDiverged = isAhead && isBehind; | ||
| 175 | |||
| 176 | if (!hasBranch) { | ||
| 177 | return { | ||
| 178 | label: "Commit", | ||
| 179 | disabled: true, | ||
| 180 | kind: "show_hint", | ||
| 181 | hint: "Create and checkout a branch before pushing or opening a PR.", | ||
| 182 | }; | ||
| 183 | } | ||
| 184 | |||
| 185 | if (hasChanges) { | ||
| 186 | if (!gitStatus.hasUpstream && !hasOriginRemote) { | ||
| 187 | return { label: "Commit", disabled: false, kind: "run_action", action: "commit" }; | ||
| 188 | } | ||
| 189 | if (hasOpenPr || isDefaultBranch) { | ||
| 190 | return { label: "Commit & push", disabled: false, kind: "run_action", action: "commit_push" }; | ||
| 191 | } | ||
| 192 | return { | ||
| 193 | label: "Commit, push & PR", | ||
| 194 | disabled: false, | ||
| 195 | kind: "run_action", | ||
| 196 | action: "commit_push_pr", | ||
| 197 | }; | ||
| 198 | } | ||
| 199 | |||
| 200 | if (!gitStatus.hasUpstream) { | ||
| 201 | if (!hasOriginRemote) { | ||
| 202 | if (hasOpenPr && !isAhead) { | ||
| 203 | return { label: "View PR", disabled: false, kind: "open_pr" }; | ||
| 204 | } | ||
| 205 | return { | ||
| 206 | label: "Push", | ||
| 207 | disabled: true, | ||
| 208 | kind: "show_hint", | ||
| 209 | hint: 'Add an "origin" remote before pushing or creating a PR.', | ||
| 210 | }; | ||
| 211 | } | ||
| 212 | if (!isAhead) { | ||
| 213 | if (hasOpenPr) { | ||
| 214 | return { label: "View PR", disabled: false, kind: "open_pr" }; | ||
| 215 | } | ||
| 216 | return { | ||
| 217 | label: "Push", | ||
| 218 | disabled: true, | ||
| 219 | kind: "show_hint", | ||
| 220 | hint: "No local commits to push.", | ||
| 221 | }; | ||
| 222 | } | ||
| 223 | if (hasOpenPr || isDefaultBranch) { | ||
| 224 | return { | ||
| 225 | label: "Push", | ||
| 226 | disabled: false, | ||
| 227 | kind: "run_action", | ||
| 228 | action: isDefaultBranch ? "commit_push" : "push", | ||
| 229 | }; | ||
| 230 | } | ||
| 231 | return { | ||
| 232 | label: "Push & create PR", | ||
| 233 | disabled: false, | ||
| 234 | kind: "run_action", | ||
| 235 | action: "create_pr", | ||
| 236 | }; | ||
| 237 | } | ||
| 238 | |||
| 239 | if (isDiverged) { | ||
| 240 | return { | ||
| 241 | label: "Sync branch", | ||
| 242 | disabled: true, | ||
| 243 | kind: "show_hint", | ||
| 244 | hint: "Branch has diverged from upstream. Rebase/merge first.", | ||
| 245 | }; | ||
| 246 | } | ||
| 247 | |||
| 248 | if (isBehind) { | ||
| 249 | return { | ||
| 250 | label: "Pull", | ||
| 251 | disabled: false, | ||
| 252 | kind: "run_pull", | ||
| 253 | }; | ||
| 254 | } | ||
| 255 | |||
| 256 | if (isAhead) { | ||
| 257 | if (hasOpenPr || isDefaultBranch) { | ||
| 258 | return { | ||
| 259 | label: "Push", | ||
| 260 | disabled: false, | ||
| 261 | kind: "run_action", | ||
| 262 | action: isDefaultBranch ? "commit_push" : "push", | ||
| 263 | }; | ||
| 264 | } | ||
| 265 | return { | ||
| 266 | label: "Push & create PR", | ||
| 267 | disabled: false, | ||
| 268 | kind: "run_action", | ||
| 269 | action: "create_pr", | ||
| 270 | }; | ||
| 271 | } | ||
| 272 | |||
| 273 | if (hasOpenPr && gitStatus.hasUpstream) { | ||
| 274 | return { label: "View PR", disabled: false, kind: "open_pr" }; | ||
| 275 | } | ||
| 276 | |||
| 277 | return { | ||
| 278 | label: "Commit", | ||
| 279 | disabled: true, | ||
| 280 | kind: "show_hint", | ||
| 281 | hint: "Branch is up to date. No action needed.", | ||
| 282 | }; | ||
| 283 | } | ||
| 284 | |||
| 285 | export function getGitActionDisabledReason(input: { | ||
| 286 | item: GitActionMenuItem; | ||
| 287 | gitStatus: GitStatusResult | null; | ||
| 288 | isBusy: boolean; | ||
| 289 | hasOriginRemote: boolean; | ||
| 290 | }): string | null { | ||
| 291 | const { item, gitStatus, isBusy, hasOriginRemote } = input; | ||
| 292 | if (!item.disabled) return null; | ||
| 293 | if (isBusy) return "Git action in progress."; | ||
| 294 | if (!gitStatus) return "Git status is unavailable."; | ||
| 295 | |||
| 296 | const hasBranch = gitStatus.branch !== null; | ||
| 297 | const hasChanges = gitStatus.hasWorkingTreeChanges; | ||
| 298 | const hasOpenPr = gitStatus.pr?.state === "open"; | ||
| 299 | const isAhead = gitStatus.aheadCount > 0; | ||
| 300 | const isBehind = gitStatus.behindCount > 0; | ||
| 301 | |||
| 302 | if (item.id === "commit") { | ||
| 303 | if (!hasChanges) { | ||
| 304 | return "Worktree is clean. Make changes before committing."; | ||
| 305 | } | ||
| 306 | return "Commit is currently unavailable."; | ||
| 307 | } | ||
| 308 | |||
| 309 | if (item.id === "push") { | ||
| 310 | if (!hasBranch) { | ||
| 311 | return "Detached HEAD: checkout a branch before pushing."; | ||
| 312 | } | ||
| 313 | if (hasChanges) { | ||
| 314 | return "Commit or stash local changes before pushing."; | ||
| 315 | } | ||
| 316 | if (isBehind) { | ||
| 317 | return "Branch is behind upstream. Pull/rebase before pushing."; | ||
| 318 | } | ||
| 319 | if (!gitStatus.hasUpstream && !hasOriginRemote) { | ||
| 320 | return 'Add an "origin" remote before pushing.'; | ||
| 321 | } | ||
| 322 | if (!isAhead) { | ||
| 323 | return "No local commits to push."; | ||
| 324 | } | ||
| 325 | return "Push is currently unavailable."; | ||
| 326 | } | ||
| 327 | |||
| 328 | if (hasOpenPr) { | ||
| 329 | return "View PR is currently unavailable."; | ||
| 330 | } | ||
| 331 | if (!hasBranch) { | ||
| 332 | return "Detached HEAD: checkout a branch before creating a PR."; | ||
| 333 | } | ||
| 334 | if (hasChanges) { | ||
| 335 | return "Commit local changes before creating a PR."; | ||
| 336 | } | ||
| 337 | if (!gitStatus.hasUpstream && !hasOriginRemote) { | ||
| 338 | return 'Add an "origin" remote before creating a PR.'; | ||
| 339 | } | ||
| 340 | if (!isAhead) { | ||
| 341 | return "No local commits to include in a PR."; | ||
| 342 | } | ||
| 343 | if (isBehind) { | ||
| 344 | return "Branch is behind upstream. Pull/rebase before creating a PR."; | ||
| 345 | } | ||
| 346 | return "Create PR is currently unavailable."; | ||
| 347 | } | ||
| 348 | |||
| 349 | export function requiresDefaultBranchConfirmation( | ||
| 350 | action: GitStackedAction, | ||
| 351 | isDefaultBranch: boolean, | ||
| 352 | ): boolean { | ||
| 353 | if (!isDefaultBranch) return false; | ||
| 354 | return ( | ||
| 355 | action === "push" || | ||
| 356 | action === "create_pr" || | ||
| 357 | action === "commit_push" || | ||
| 358 | action === "commit_push_pr" | ||
| 359 | ); | ||
| 360 | } | ||
| 361 | |||
| 362 | export function resolveDefaultBranchActionDialogCopy(input: { | ||
| 363 | action: DefaultBranchConfirmableAction; | ||
| 364 | branchName: string; | ||
| 365 | includesCommit: boolean; | ||
| 366 | }): DefaultBranchActionDialogCopy { | ||
| 367 | const branchLabel = input.branchName; | ||
| 368 | const suffix = ` on "${branchLabel}". You can continue on this branch or create a feature branch and run the same action there.`; | ||
| 369 | |||
| 370 | if (input.action === "push" || input.action === "commit_push") { | ||
| 371 | if (input.includesCommit) { | ||
| 372 | return { | ||
| 373 | title: "Commit & push to default branch?", | ||
| 374 | description: `This action will commit and push changes${suffix}`, | ||
| 375 | continueLabel: `Commit & push to ${branchLabel}`, | ||
| 376 | }; | ||
| 377 | } | ||
| 378 | return { | ||
| 379 | title: "Push to default branch?", | ||
| 380 | description: `This action will push local commits${suffix}`, | ||
| 381 | continueLabel: `Push to ${branchLabel}`, | ||
| 382 | }; | ||
| 383 | } | ||
| 384 | |||
| 385 | if (input.includesCommit) { | ||
| 386 | return { | ||
| 387 | title: "Commit, push & create PR from default branch?", | ||
| 388 | description: `This action will commit, push, and create a PR${suffix}`, | ||
| 389 | continueLabel: "Commit, push & create PR", | ||
| 390 | }; | ||
| 391 | } | ||
| 392 | return { | ||
| 393 | title: "Push & create PR from default branch?", | ||
| 394 | description: `This action will push local commits and create a PR${suffix}`, | ||
| 395 | continueLabel: "Push & create PR", | ||
| 396 | }; | ||
| 397 | } | ||
| 398 | |||
| 399 | export function resolveThreadBranchUpdate( | ||
| 400 | result: GitRunStackedActionResult, | ||
| 401 | ): { branch: string } | null { | ||
| 402 | if (result.branch.status !== "created" || !result.branch.name) { | ||
| 403 | return null; | ||
| 404 | } | ||
| 405 | |||
| 406 | return { | ||
| 407 | branch: result.branch.name, | ||
| 408 | }; | ||
| 409 | } | ||
| 410 | |||
| 411 | export function resolveLiveThreadBranchUpdate(input: { | ||
| 412 | threadBranch: string | null; | ||
| 413 | gitStatus: GitStatusResult | null; | ||
| 414 | }): { branch: string | null } | null { | ||
| 415 | if (!input.gitStatus) { | ||
| 416 | return null; | ||
| 417 | } | ||
| 418 | |||
| 419 | if (input.gitStatus.branch === null && input.threadBranch !== null) { | ||
| 420 | return null; | ||
| 421 | } | ||
| 422 | |||
| 423 | if (input.threadBranch === input.gitStatus.branch) { | ||
| 424 | return null; | ||
| 425 | } | ||
| 426 | |||
| 427 | return { | ||
| 428 | branch: input.gitStatus.branch, | ||
| 429 | }; | ||
| 430 | } | ||
| @@ -1,2 +1,3 @@ | |||
| 1 | export * from "./knownEnvironment"; | 1 | export * from "./knownEnvironment"; |
| 2 | export * from "./scoped"; | 2 | export * from "./scoped"; |
| 3 | export * from "./gitActions"; | ||