History

Add mobile remote connection and git workflows

- 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

Julius Marminge committed 7 months ago

Commit ac017351 parent 93cf43150 files · +6868 −1743Browse files

Unified · Split

50 files
  1. apps/mobile/TODO.md +15 −0
  2. apps/mobile/app.json +16 −8
  3. apps/mobile/assets/android-icon-background.png +0 −0
  4. apps/mobile/assets/android-icon-foreground.png +0 −0
  5. apps/mobile/assets/android-icon-monochrome.png +0 −0
  6. apps/mobile/assets/favicon.png +0 −0
  7. apps/mobile/assets/icon.png +0 −0
  8. apps/mobile/assets/splash-icon.png +0 −0
  9. apps/mobile/index.ts +1 −0
  10. apps/mobile/metro.config.js +4 −0
  11. apps/mobile/nativewind-env.d.ts +2 −0
  12. apps/mobile/package.json +5 −0
  13. apps/mobile/src/app/App.tsx +21 −2
  14. apps/mobile/src/app/MobileAppShell.tsx +56 −222
  15. apps/mobile/src/app/useRemoteAppState.ts +562 −22
  16. apps/mobile/src/components/AppText.tsx +10 −8
  17. apps/mobile/src/components/BrandMark.tsx +60 −0
  18. apps/mobile/src/components/LoadingScreen.tsx +3 −1
  19. apps/mobile/src/components/StatusPill.tsx +22 −3
  20. apps/mobile/src/features/connection/ConnectionSheet.tsx +939 −165
  21. apps/mobile/src/features/home/HomeScreen.tsx +171 −0
  22. apps/mobile/src/features/threads/NewTaskSheet.tsx +1270 −0
  23. apps/mobile/src/features/threads/ProjectPickerSheet.tsx +129 −0
  24. apps/mobile/src/features/threads/ThreadComposer.tsx +21 −8
  25. apps/mobile/src/features/threads/ThreadDetailScreen.tsx +362 −221
  26. apps/mobile/src/features/threads/ThreadFeed.tsx +209 −64
  27. apps/mobile/src/features/threads/ThreadGitControls.tsx +1259 −0
  28. apps/mobile/src/features/threads/ThreadListScreen.tsx +403 −567
  29. apps/mobile/src/features/threads/ThreadNavigationDrawer.tsx +250 −0
  30. apps/mobile/src/features/threads/threadPresentation.ts +1 −1
  31. apps/mobile/src/lib/mobileLayout.test.ts +36 −0
  32. apps/mobile/src/lib/mobileLayout.ts +37 −0
  33. apps/mobile/src/lib/orchestration.test.ts +104 −0
  34. apps/mobile/src/lib/orchestration.ts +4 −0
  35. apps/mobile/src/lib/pairingQr.test.ts +25 −0
  36. apps/mobile/src/lib/pairingQr.ts +22 −0
  37. apps/mobile/src/lib/remoteClient.ts +62 −0
  38. apps/mobile/src/lib/repositoryGroups.test.ts +136 −0
  39. apps/mobile/src/lib/repositoryGroups.ts +135 −0
  40. apps/mobile/src/lib/storage.ts +2 −2
  41. apps/mobile/src/lib/worktrees.ts +14 −0
  42. apps/web/src/components/GitActionsControl.logic.ts +14 −361
  43. apps/web/src/components/GitActionsControl.tsx +2 −69
  44. apps/web/src/components/Sidebar.logic.ts +1 −1
  45. apps/web/src/components/Sidebar.tsx +13 −18
  46. apps/web/src/components/chat/ChatHeader.tsx +8 −5
  47. bun.lock +31 −0
  48. packages/client-runtime/package.json +6 −1
  49. packages/client-runtime/src/gitActions.ts +430 −0
  50. packages/client-runtime/src/index.ts +1 −0
apps/mobile/TODO.md+15−0
@@ -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
apps/mobile/app.json+16−8
@@ -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": 017 "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": true30 "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}
apps/mobile/assets/android-icon-background.png+0−0

Binary file not shown.

apps/mobile/assets/android-icon-foreground.png+0−0

Binary file not shown.

apps/mobile/assets/android-icon-monochrome.png+0−0

Binary file not shown.

apps/mobile/assets/favicon.png+0−0

Binary file not shown.

apps/mobile/assets/icon.png+0−0

Binary file not shown.

apps/mobile/assets/splash-icon.png+0−0

Binary file not shown.

apps/mobile/index.ts+1−0
@@ -1,3 +1,4 @@
1import "react-native-gesture-handler";
1import { registerRootComponent } from "expo";2import { registerRootComponent } from "expo";
2 3
3import App from "./App";4import App from "./App";
apps/mobile/metro.config.js+4−0
@@ -1,8 +1,12 @@
1const path = require("node:path");
1const { getDefaultConfig } = require("expo/metro-config");2const { getDefaultConfig } = require("expo/metro-config");
2const { withNativewind } = require("nativewind/metro");3const { withNativewind } = require("nativewind/metro");
3 4
4/** @type {import("expo/metro-config").MetroConfig} */5/** @type {import("expo/metro-config").MetroConfig} */
5const config = getDefaultConfig(__dirname);6const config = getDefaultConfig(__dirname);
7const workspaceRoot = path.resolve(__dirname, "../..");
8
9config.watchFolders = [...new Set([...(config.watchFolders ?? []), workspaceRoot])];
6 10
7module.exports = withNativewind(config, {11module.exports = withNativewind(config, {
8 input: "./global.css",12 input: "./global.css",
apps/mobile/nativewind-env.d.ts+2−0
@@ -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
5declare module "*.css";
apps/mobile/package.json+5−0
@@ -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",
apps/mobile/src/app/App.tsx+21−2
@@ -1,6 +1,14 @@
1import { useColorScheme } from "react-native";1import { useColorScheme } from "react-native";
2import "./../../global.css";2import "./../../global.css";
3 3
4import {
5 DMSans_400Regular,
6 DMSans_500Medium,
7 DMSans_700Bold,
8 useFonts,
9} from "@expo-google-fonts/dm-sans";
10import { BottomSheetModalProvider } from "@gorhom/bottom-sheet";
11import { GestureHandlerRootView } from "react-native-gesture-handler";
4import { SafeAreaProvider } from "react-native-safe-area-context";12import { SafeAreaProvider } from "react-native-safe-area-context";
5 13
6import { LoadingScreen } from "../components/LoadingScreen";14import { LoadingScreen } from "../components/LoadingScreen";
@@ -10,10 +18,15 @@ import { useRemoteAppState } from "./useRemoteAppState"; · 3 unmodified lines
10export default function App() {18export 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}
apps/mobile/src/app/MobileAppShell.tsx+56−222
@@ -1,198 +1,28 @@
1import { useCallback, useEffect, useRef, useState } from "react";1import { useState } from "react";
2import { StatusBar, useWindowDimensions, View } from "react-native";2import { StatusBar, View } from "react-native";
3import Animated, {
4 Easing,
5 interpolate,
6 runOnJS,
7 useAnimatedStyle,
8 useSharedValue,
9 withTiming,
10} from "react-native-reanimated";
11 3
12import { ConnectionSheet } from "../features/connection/ConnectionSheet";4import { ConnectionSheet } from "../features/connection/ConnectionSheet";
5import { HomeScreen } from "../features/home/HomeScreen";
6import { NewTaskSheet } from "../features/threads/NewTaskSheet";
13import { ThreadDetailScreen } from "../features/threads/ThreadDetailScreen";7import { ThreadDetailScreen } from "../features/threads/ThreadDetailScreen";
14import { ThreadListScreen, type TransitionSourceFrame } from "../features/threads/ThreadListScreen";8import { ThreadNavigationDrawer } from "../features/threads/ThreadNavigationDrawer";
15import type { ScopedMobileThread } from "../lib/scopedEntities";9import { scopedThreadKey } from "../lib/scopedEntities";
16import type { RemoteAppModel } from "./useRemoteAppState";10import type { RemoteAppModel } from "./useRemoteAppState";
17 11
18function 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
37function 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
49function 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
173export function MobileAppShell(props: {12export 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.selectedThread25 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 translucent77 translucent
238 />78 />
239 79
240 <View style={{ flex: 1 }} pointerEvents={app.selectedThread ? "none" : "auto"}>80 {app.selectedThread && selectedThreadDetailProps ? (
241 <ThreadListScreen81 <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 <ConnectionSheet94 <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}
apps/mobile/src/app/useRemoteAppState.ts+562−22
@@ -1,17 +1,28 @@
1import { useCallback, useEffect, useMemo, useRef, useState } from "react";1import { useCallback, useEffect, useMemo, useRef, useState } from "react";
2import { Alert } from "react-native";2import { Alert } from "react-native";
3 3
4import { buildGitActionProgressStages, type GitActionRequestInput } from "@t3tools/client-runtime";
4import {5import {
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";
22import {
23 dedupeRemoteBranchesWithLocalMatches,
24 sanitizeFeatureBranchName,
25} from "@t3tools/shared/git";
15import { deriveActiveWorkStartedAt, formatElapsed } from "@t3tools/shared/orchestrationTiming";26import { deriveActiveWorkStartedAt, formatElapsed } from "@t3tools/shared/orchestrationTiming";
16 27
17import { connectionTone } from "../features/connection/connectionTone";28import { connectionTone } from "../features/connection/connectionTone";
@@ -46,6 +57,7 @@ import { · 28 unmodified lines
46} from "../lib/threadActivity";57} from "../lib/threadActivity";
47import { sortCopy } from "../lib/arrayCompat";58import { sortCopy } from "../lib/arrayCompat";
48import { newClientId } from "../lib/clientId";59import { newClientId } from "../lib/clientId";
60import { buildTemporaryWorktreeBranchName } from "../lib/worktrees";
49 61
50export interface ConnectedEnvironmentSummary {62export 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
207function 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
150function deriveOverallConnectionState(217function 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 = selectedThread917 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,
apps/mobile/src/components/AppText.tsx+10−8
@@ -55,6 +55,7 @@ function activeToken(token: string, isDarkMode: boolean) { · 54 unmodified lines
55function resolveTextStyle(className: string | undefined, isDarkMode: boolean): TextStyle {55function 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
apps/mobile/src/components/BrandMark.tsx+60−0
@@ -0,0 +1,60 @@
1import { Image, View } from "react-native";
2
3import { AppText as Text } from "./AppText";
4
5const BRAND_MARK_SOURCE = require("../../../../assets/dev/blueprint-ios-1024.png");
6
7export 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}
apps/mobile/src/components/LoadingScreen.tsx+3−1
@@ -2,6 +2,7 @@ import { ActivityIndicator, StatusBar, View } from "react-native"; · 1 unmodified line
2import { useSafeAreaInsets } from "react-native-safe-area-context";2import { useSafeAreaInsets } from "react-native-safe-area-context";
3 3
4import { AppText as Text } from "./AppText";4import { AppText as Text } from "./AppText";
5import { BrandMark } from "./BrandMark";
5 6
6export function LoadingScreen(props: { readonly isDarkMode: boolean; readonly message: string }) {7export 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 translucent16 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}
apps/mobile/src/components/StatusPill.tsx+22−3
@@ -9,10 +9,29 @@ export interface StatusTone { · 8 unmodified lines
9 readonly textClassName: string;9 readonly textClassName: string;
10}10}
11 11
12export function StatusPill(props: StatusTone) {12export 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}
apps/mobile/src/features/connection/ConnectionSheet.tsx+939−165
@@ -1,11 +1,23 @@
1import { Modal, Pressable, ScrollView, View } from "react-native";1import {
2import { useColorScheme } from "react-native";2 BottomSheetBackdrop,
3 BottomSheetModal,
4 BottomSheetScrollView,
5 BottomSheetView,
6} from "@gorhom/bottom-sheet";
7import { MenuView } from "@react-native-menu/menu";
8import { CameraView, type BarcodeScanningResult, useCameraPermissions } from "expo-camera";
9import { SymbolView } from "expo-symbols";
10import { useCallback, useEffect, useMemo, useRef, useState } from "react";
11import { Linking, Platform, Pressable, View, useColorScheme } from "react-native";
12import { useSafeAreaInsets } from "react-native-safe-area-context";
3 13
14import type { ConnectedEnvironmentSummary } from "../../app/useRemoteAppState";
4import { AppText as Text, AppTextInput as TextInput } from "../../components/AppText";15import { AppText as Text, AppTextInput as TextInput } from "../../components/AppText";
5import { ErrorBanner } from "../../components/ErrorBanner";16import { ErrorBanner } from "../../components/ErrorBanner";
6import type { ConnectedEnvironmentSummary } from "../../app/useRemoteAppState";
7import type { RemoteConnectionInput } from "../../lib/connection";17import type { RemoteConnectionInput } from "../../lib/connection";
18import { extractPairingUrlFromQrPayload } from "../../lib/pairingQr";
8import type { RemoteClientConnectionState } from "../../lib/remoteClient";19import type { RemoteClientConnectionState } from "../../lib/remoteClient";
20import { ConnectionStatusDot } from "./ConnectionStatusDot";
9 21
10export interface ConnectionSheetProps {22export 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
42function 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
62function 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
23function makePalette(isDarkMode: boolean) {81function 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
63function ActionButton(props: {147// ---------------------------------------------------------------------------
148// Shared button
149// ---------------------------------------------------------------------------
150
151function 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 <Pressable194 <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 <Text218 <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
102export function ConnectionSheet(props: ConnectionSheetProps) {228// ---------------------------------------------------------------------------
103 const isDarkMode = useColorScheme() === "dark";229// Environment card (compact single row)
104 const palette = makePalette(isDarkMode);230// ---------------------------------------------------------------------------
231
232function 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
293function 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 <Modal339 <BottomSheetModal
108 animationType="slide"340 ref={sheetRef}
109 presentationStyle="pageSheet"341 stackBehavior="push"
110 allowSwipeDismissal342 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
456function 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 <View615 <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 <Text624 <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 backend628 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 a631 <>
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 <Text703 <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 URL707 >
150 </Text>708 Host
709 </Text>
710 <View className="flex-row items-center gap-2">
711 <View className="flex-1">
151 <TextInput712 <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 <ActionButton732 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
799const 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
812export 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 <View971 <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 <Text975 <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 backends979 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}
apps/mobile/src/features/home/HomeScreen.tsx+171−0
@@ -0,0 +1,171 @@
1import { SymbolView } from "expo-symbols";
2import { useMemo } from "react";
3import { Pressable, ScrollView, View } from "react-native";
4import { useSafeAreaInsets } from "react-native-safe-area-context";
5
6import { AppText as Text } from "../../components/AppText";
7import { EmptyState } from "../../components/EmptyState";
8import { StatusPill } from "../../components/StatusPill";
9import { groupProjectsByRepository } from "../../lib/repositoryGroups";
10import type { ScopedMobileProject, ScopedMobileThread } from "../../lib/scopedEntities";
11import { scopedProjectKey } from "../../lib/scopedEntities";
12import { relativeTime } from "../../lib/time";
13import { ConnectionStatusDot } from "../connection/ConnectionStatusDot";
14import { threadStatusTone } from "../threads/threadPresentation";
15
16export 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}
apps/mobile/src/features/threads/NewTaskSheet.tsx+1270−0
@@ -0,0 +1,1270 @@
1import {
2 BottomSheetBackdrop,
3 BottomSheetModal,
4 BottomSheetScrollView,
5 BottomSheetView,
6} from "@gorhom/bottom-sheet";
7import type {
8 ClaudeCodeEffort,
9 GitBranch,
10 ModelSelection,
11 ProviderInteractionMode,
12 RuntimeMode,
13 ServerConfig as T3ServerConfig,
14} from "@t3tools/contracts";
15import {
16 CLAUDE_CODE_EFFORT_OPTIONS,
17 DEFAULT_PROVIDER_INTERACTION_MODE,
18 DEFAULT_RUNTIME_MODE,
19} from "@t3tools/contracts";
20import { SymbolView } from "expo-symbols";
21import { type ReactNode, useCallback, useEffect, useMemo, useRef, useState } from "react";
22import {
23 Image,
24 KeyboardAvoidingView,
25 Platform,
26 Pressable,
27 ScrollView,
28 View,
29} from "react-native";
30import Reanimated, { SlideInLeft, SlideInRight, SlideOutLeft, SlideOutRight } from "react-native-reanimated";
31import { useSafeAreaInsets } from "react-native-safe-area-context";
32
33import { AppText as Text, AppTextInput as TextInput } from "../../components/AppText";
34import type { DraftComposerImageAttachment } from "../../lib/composerImages";
35import { pickComposerImages } from "../../lib/composerImages";
36import { groupProjectsByRepository } from "../../lib/repositoryGroups";
37import type { ScopedMobileProject, ScopedMobileThread } from "../../lib/scopedEntities";
38import { scopedProjectKey } from "../../lib/scopedEntities";
39
40type WorkspaceMode = "local" | "worktree";
41
42type 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
51type ProviderGroup = {
52 readonly providerKey: string;
53 readonly providerLabel: string;
54 readonly models: ReadonlyArray<ModelOption>;
55};
56
57function 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
68function 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
87function providerDisplayLabel(provider: string): string {
88 if (provider === "codex") return "Codex";
89 if (provider === "claudeAgent") return "Claude";
90 return provider;
91}
92
93function 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
139function 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
159function 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
173function 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
213function 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
281export 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}
apps/mobile/src/features/threads/ProjectPickerSheet.tsx+129−0
@@ -0,0 +1,129 @@
1import {
2 BottomSheetBackdrop,
3 BottomSheetModal,
4 BottomSheetScrollView,
5 BottomSheetView,
6} from "@gorhom/bottom-sheet";
7import { useCallback, useEffect, useMemo, useRef } from "react";
8import { Pressable, View } from "react-native";
9import { useSafeAreaInsets } from "react-native-safe-area-context";
10
11import { AppText as Text } from "../../components/AppText";
12import { groupProjectsByRepository } from "../../lib/repositoryGroups";
13import type { ScopedMobileProject, ScopedMobileThread } from "../../lib/scopedEntities";
14
15export 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}
apps/mobile/src/features/threads/ThreadComposer.tsx+21−8
@@ -6,6 +6,7 @@ import { Image, Pressable, ScrollView, View } from "react-native"; · 5 unmodified lines
6import { AppText as Text, AppTextInput as TextInput } from "../../components/AppText";6import { AppText as Text, AppTextInput as TextInput } from "../../components/AppText";
7import { cx } from "../../lib/classNames";7import { cx } from "../../lib/classNames";
8import type { DraftComposerImageAttachment } from "../../lib/composerImages";8import type { DraftComposerImageAttachment } from "../../lib/composerImages";
9import type { MobileLayoutVariant } from "../../lib/mobileLayout";
9import type { RemoteClientConnectionState } from "../../lib/remoteClient";10import type { RemoteClientConnectionState } from "../../lib/remoteClient";
10 11
11function ComposerAction(props: {12function 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 <View72 <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 <TextInput80 <TextInput
75 multiline81 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 <ComposerAction91 <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 <ComposerAction97 <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 <ComposerAction103 <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 send167 {props.queueCount} queued message{props.queueCount === 1 ? "" : "s"} will send
155 automatically.168 automatically.
156 </Text>169 </Text>
apps/mobile/src/features/threads/ThreadDetailScreen.tsx+362−221
@@ -1,23 +1,39 @@
1import type { ApprovalRequestId, ProviderApprovalDecision } from "@t3tools/contracts";1import type {
2 ApprovalRequestId,
3 GitBranch,
4 GitRunStackedActionResult,
5 GitStatusResult,
6 ProviderApprovalDecision,
7} from "@t3tools/contracts";
8import type { GitActionRequestInput } from "@t3tools/client-runtime";
2import * as Haptics from "expo-haptics";9import * as Haptics from "expo-haptics";
3import { useEffect, useRef, useState } from "react";10import { SymbolView } from "expo-symbols";
11import { useCallback, useEffect, useMemo, useRef, useState } from "react";
4import {12import {
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";
21import { Gesture, GestureDetector } from "react-native-gesture-handler";
12import { useSafeAreaInsets } from "react-native-safe-area-context";22import { useSafeAreaInsets } from "react-native-safe-area-context";
13import Animated from "react-native-reanimated";23import Animated, {
24 interpolate,
25 runOnJS,
26 useAnimatedStyle,
27 useSharedValue,
28 withSpring,
29 withTiming,
30} from "react-native-reanimated";
14 31
15import { AppText as Text, AppTextInput as TextInput } from "../../components/AppText";32import { AppText as Text, AppTextInput as TextInput } from "../../components/AppText";
16import { ErrorBanner } from "../../components/ErrorBanner";
17import { GlassSafeAreaView } from "../../components/GlassSafeAreaView";33import { GlassSafeAreaView } from "../../components/GlassSafeAreaView";
18import type { StatusTone } from "../../components/StatusPill";34import type { StatusTone } from "../../components/StatusPill";
19import { ConnectionStatusDot } from "../connection/ConnectionStatusDot";
20import type { DraftComposerImageAttachment } from "../../lib/composerImages";35import type { DraftComposerImageAttachment } from "../../lib/composerImages";
36import type { MobileLayoutVariant } from "../../lib/mobileLayout";
21import type { ScopedMobileThread } from "../../lib/scopedEntities";37import type { ScopedMobileThread } from "../../lib/scopedEntities";
22import type {38import 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";
28import { PendingApprovalCard } from "./PendingApprovalCard";44import { PendingApprovalCard } from "./PendingApprovalCard";
45import { ThreadGitControls } from "./ThreadGitControls";
29import { PendingUserInputCard } from "./PendingUserInputCard";46import { PendingUserInputCard } from "./PendingUserInputCard";
30import { ThreadComposer } from "./ThreadComposer";47import { ThreadComposer } from "./ThreadComposer";
31import { ThreadFeed } from "./ThreadFeed";48import { 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
97function 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
107function useStreamingHaptics(threadId: string, feed: ReadonlyArray<ThreadFeedEntry>) {132function 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
157export function ThreadDetailScreen(props: ThreadDetailScreenProps) {182export 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 <KeyboardAvoidingView305 <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 <ThreadFeed318 }}
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 <PendingApprovalCard332 <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 <PendingUserInputCard339 )
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 <ThreadComposer389 {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 <TextInput406 {props.activePendingApproval || props.activePendingUserInput ? (
347 autoFocus407 <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 <Pressable424 />
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 <Text471 <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 Save475 >
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}
apps/mobile/src/features/threads/ThreadFeed.tsx+209−64
@@ -1,11 +1,23 @@
1import * as Clipboard from "expo-clipboard";
2import * as Haptics from "expo-haptics";
1import { FlashList, type FlashListRef, type ListRenderItemInfo } from "@shopify/flash-list";3import { FlashList, type FlashListRef, type ListRenderItemInfo } from "@shopify/flash-list";
2import { memo, useCallback, useEffect, useRef } from "react";4import { SymbolView } from "expo-symbols";
5import { memo, useCallback, useEffect, useState, useRef } from "react";
3import Markdown from "react-native-markdown-display";6import Markdown from "react-native-markdown-display";
4import { Image, type NativeScrollEvent, type NativeSyntheticEvent, View } from "react-native";7import {
8 Image,
9 Pressable,
10 RefreshControl,
11 ScrollView,
12 type NativeScrollEvent,
13 type NativeSyntheticEvent,
14 View,
15} from "react-native";
5 16
6import { AppText as Text } from "../../components/AppText";17import { AppText as Text } from "../../components/AppText";
7import { EmptyState } from "../../components/EmptyState";18import { EmptyState } from "../../components/EmptyState";
8import { cx } from "../../lib/classNames";19import { cx } from "../../lib/classNames";
20import type { MobileLayoutVariant } from "../../lib/mobileLayout";
9import type { ThreadFeedEntry } from "../../lib/threadActivity";21import type { ThreadFeedEntry } from "../../lib/threadActivity";
10import { relativeTime } from "../../lib/time";22import { relativeTime } from "../../lib/time";
11import { messageImageUrl } from "./threadPresentation";23import { 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
23function stripShellWrapper(value: string): string {38function 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
53function normalizeCompactActivityLabel(value: string): string {
54 return value.replace(/\s+(?:started|complete|completed)\s*$/i, "").trim();
55}
56
38function buildActivityRows(57function 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
100const MAX_VISIBLE_WORK_LOG_ENTRIES = 6;
101
78const MARKDOWN_BASE = {102const 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
145function renderFeedEntry(169function 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 <View188 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 isUser191 {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 center294 <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 <View305 <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
308export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) {391export 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 <View453 <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 }}
apps/mobile/src/features/threads/ThreadGitControls.tsx+1259−0
@@ -0,0 +1,1259 @@
1import {
2 BottomSheetBackdrop,
3 BottomSheetModal,
4 BottomSheetScrollView,
5 BottomSheetView,
6} from "@gorhom/bottom-sheet";
7import { MenuView } from "@react-native-menu/menu";
8import type { GitBranch, GitRunStackedActionResult, GitStatusResult } from "@t3tools/contracts";
9import {
10 buildMenuItems,
11 getGitActionDisabledReason,
12 type GitActionRequestInput,
13 requiresDefaultBranchConfirmation,
14 resolveDefaultBranchActionDialogCopy,
15 resolveQuickAction,
16} from "@t3tools/client-runtime";
17import { resolveAutoFeatureBranchName, sanitizeFeatureBranchName } from "@t3tools/shared/git";
18import { SymbolView } from "expo-symbols";
19import type { ComponentProps, RefObject } from "react";
20import { useCallback, useEffect, useMemo, useRef, useState } from "react";
21import { Alert, Linking, Pressable, View } from "react-native";
22import { useSafeAreaInsets } from "react-native-safe-area-context";
23
24import { AppText as Text, AppTextInput as TextInput } from "../../components/AppText";
25import { StatusPill } from "../../components/StatusPill";
26
27type 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
38function 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
54function 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
84function deriveAvailableBranchLabel(
85 currentBranch: string | null,
86 gitStatus: GitStatusResult | null,
87): string {
88 return gitStatus?.branch ?? currentBranch ?? "Detached HEAD";
89}
90
91function 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
101function compactMenuBranchLabel(branch: string): string {
102 return truncateMiddle(branch, 24);
103}
104
105function 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
132function 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
160function 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
168function 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
218function 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
242function 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
277export 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}
apps/mobile/src/features/threads/ThreadListScreen.tsx+403−567
@@ -1,25 +1,30 @@
1import { SymbolView } from "expo-symbols";1import { SymbolView } from "expo-symbols";
2import { useRef, useState } from "react";2import { useCallback, useMemo, useRef, useState } from "react";
3import { Pressable, ScrollView, View, type View as RNView } from "react-native";3import {
4import { useColorScheme } from "react-native";4 Modal,
5import Animated, { FadeInDown, FadeOutUp, LinearTransition } from "react-native-reanimated";5 Pressable,
6import Svg, { Path } from "react-native-svg";6 RefreshControl,
7 7 ScrollView,
8 View,
9 useColorScheme,
10 type View as RNView,
11} from "react-native";
12import Animated, { FadeInDown, LinearTransition } from "react-native-reanimated";
13
14import { BrandMark } from "../../components/BrandMark";
8import { AppText as Text } from "../../components/AppText";15import { AppText as Text } from "../../components/AppText";
9import { EmptyState } from "../../components/EmptyState";16import { EmptyState } from "../../components/EmptyState";
10import { ErrorBanner } from "../../components/ErrorBanner";
11import { GlassSafeAreaView } from "../../components/GlassSafeAreaView";17import { GlassSafeAreaView } from "../../components/GlassSafeAreaView";
12import { StatusPill } from "../../components/StatusPill";18import { StatusPill } from "../../components/StatusPill";
13import { cx } from "../../lib/classNames";19import type { MobileLayout } from "../../lib/mobileLayout";
14import { sortCopy } from "../../lib/arrayCompat";
15import {20import {
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";
21import type { RemoteClientConnectionState } from "../../lib/remoteClient";25import type { RemoteClientConnectionState } from "../../lib/remoteClient";
22import { relativeTime } from "../../lib/time";26import { relativeTime } from "../../lib/time";
27import { groupProjectsByRepository, type MobileRepositoryGroup } from "../../lib/repositoryGroups";
23import { ConnectionStatusDot } from "../connection/ConnectionStatusDot";28import { ConnectionStatusDot } from "../connection/ConnectionStatusDot";
24import { lastConversationLine, threadStatusTone } from "./threadPresentation";29import { 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
59function T3Wordmark(props: { readonly color: string }) {66function ActionButton(props: {
60 return (67 readonly icon: React.ComponentProps<typeof SymbolView>["name"];
61 <Svg68 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
76type GlyphName =
77 | "link"
78 | "stack"
79 | "folder"
80 | "refresh"
81 | "arrow-up-right"
82 | "chevron-down"
83 | "chevron-up"
84 | "device"
85 | "spark";
86
87const 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
99const 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
112const GLYPH_STROKE_JOIN: Partial<Record<GlyphName, "round">> = {
113 folder: "round",
114};
115
116function 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
151function 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 <View76 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 <Text84 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 <Glyph90 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
189function toneStyles(label: string) {102function 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
227function 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 <Text118 <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 <Text125 <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
254function 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
281function ThreadRow(props: {132function 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 <Pressable144 <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 <View166 </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
364function SectionPanel(props: {185function 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 <View201 <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
381function orderThreadsByRecency(threads: ReadonlyArray<ScopedMobileThread>) {267function 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;
389function 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
431export 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 <GlassSafeAreaView280 <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 <Text284 </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 Code300 <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 <Text305 <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 board309 {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 <ConnectionStatusDot314 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 <Text316 <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 <EmptyState329 </View>
570 title={props.hasClient ? "No threads yet" : "No connection yet"}330 </View>
571 detail={331 </Modal>
572 props.hasClient332 );
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 (335export 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 <View338 () => 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 <Text345 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 <View362 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 <Text367 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 <ProjectActionButton422 <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 <ThreadRow434 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 <EmptyState447 {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.View493 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 <ThreadRow499 }
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 <ExpandThreadsToggle513 {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}
apps/mobile/src/features/threads/ThreadNavigationDrawer.tsx+250−0
@@ -0,0 +1,250 @@
1import { SymbolView } from "expo-symbols";
2import { useCallback, useEffect, useMemo, useState } from "react";
3import { Modal, Pressable, ScrollView, useWindowDimensions, View } from "react-native";
4import { Gesture, GestureDetector } from "react-native-gesture-handler";
5import { useSafeAreaInsets } from "react-native-safe-area-context";
6import Animated, {
7 runOnJS,
8 useAnimatedStyle,
9 useSharedValue,
10 withTiming,
11} from "react-native-reanimated";
12
13import { AppText as Text } from "../../components/AppText";
14import { StatusPill } from "../../components/StatusPill";
15import { groupProjectsByRepository } from "../../lib/repositoryGroups";
16import type { ScopedMobileProject, ScopedMobileThread } from "../../lib/scopedEntities";
17import { scopedThreadKey } from "../../lib/scopedEntities";
18import { relativeTime } from "../../lib/time";
19import { threadStatusTone } from "./threadPresentation";
20
21function 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
28export 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}
apps/mobile/src/features/threads/threadPresentation.ts+1−1
@@ -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}
apps/mobile/src/lib/mobileLayout.test.ts+36−0
@@ -0,0 +1,36 @@
1import { describe, expect, it } from "vitest";
2
3import { deriveMobileLayout } from "./mobileLayout";
4
5describe("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});
apps/mobile/src/lib/mobileLayout.ts+37−0
@@ -0,0 +1,37 @@
1function clamp(value: number, min: number, max: number): number {
2 return Math.min(Math.max(value, min), max);
3}
4
5export type MobileLayoutVariant = "compact" | "split";
6
7export interface MobileLayout {
8 readonly variant: MobileLayoutVariant;
9 readonly usesSplitView: boolean;
10 readonly listPaneWidth: number | null;
11 readonly shellPadding: number;
12}
13
14export function deriveMobileLayout(input: {
15 readonly width: number;
16 readonly height: number;
17}): MobileLayout {
18 const { width, height } = input;
19 const shortestEdge = Math.min(width, height);
20 const wideEnoughForSplit = width >= 900 || (width >= 700 && shortestEdge >= 700);
21
22 if (!wideEnoughForSplit) {
23 return {
24 variant: "compact",
25 usesSplitView: false,
26 listPaneWidth: null,
27 shellPadding: 0,
28 };
29 }
30
31 return {
32 variant: "split",
33 usesSplitView: true,
34 listPaneWidth: clamp(Math.round(width * 0.34), 320, 420),
35 shellPadding: width >= 1180 ? 20 : 14,
36 };
37}
apps/mobile/src/lib/orchestration.test.ts+104−0
@@ -0,0 +1,104 @@
1import { describe, expect, it } from "vitest";
2
3import { EventId, ProjectId } from "@t3tools/contracts";
4import type { OrchestrationReadModel } from "@t3tools/contracts";
5
6import { applyRealtimeEvent } from "./orchestration";
7
8const baseSnapshot: OrchestrationReadModel = {
9 snapshotSequence: 0,
10 updatedAt: "2026-04-01T00:00:00.000Z",
11 projects: [],
12 threads: [],
13};
14
15const baseEventFields = {
16 eventId: EventId.makeUnsafe("event-1"),
17 commandId: null,
18 causationEventId: null,
19 correlationId: null,
20 metadata: {},
21} as const;
22
23describe("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});
apps/mobile/src/lib/orchestration.ts+4−0
@@ -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 !== undefined89 ...(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 !== undefined95 ...(event.payload.defaultModelSelection !== undefined
92 ? { defaultModelSelection: event.payload.defaultModelSelection }96 ? { defaultModelSelection: event.payload.defaultModelSelection }
93 : {}),97 : {}),
apps/mobile/src/lib/pairingQr.test.ts+25−0
@@ -0,0 +1,25 @@
1import { describe, expect, it } from "vitest";
2
3import { extractPairingUrlFromQrPayload } from "./pairingQr";
4
5describe("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});
apps/mobile/src/lib/pairingQr.ts+22−0
@@ -0,0 +1,22 @@
1const MOBILE_PAIRING_URL_PARAM = "pairingUrl";
2
3export 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}
apps/mobile/src/lib/remoteClient.ts+62−0
@@ -1,5 +1,19 @@
1import {1import {
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 {
apps/mobile/src/lib/repositoryGroups.test.ts+136−0
@@ -0,0 +1,136 @@
1import { describe, expect, it } from "vitest";
2
3import { ProjectId, ThreadId } from "@t3tools/contracts";
4
5import type { ScopedMobileProject, ScopedMobileThread } from "./scopedEntities";
6import { groupProjectsByRepository } from "./repositoryGroups";
7
8function 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
24function 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
50describe("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});
apps/mobile/src/lib/repositoryGroups.ts+135−0
@@ -0,0 +1,135 @@
1import type { RepositoryIdentity } from "@t3tools/contracts";
2
3import { sortCopy } from "./arrayCompat";
4import type { ScopedMobileProject, ScopedMobileThread } from "./scopedEntities";
5import { scopedProjectKey } from "./scopedEntities";
6
7export interface MobileRepositoryProjectGroup {
8 readonly key: string;
9 readonly project: ScopedMobileProject;
10 readonly threads: ReadonlyArray<ScopedMobileThread>;
11 readonly latestActivityAt: string;
12}
13
14export 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
25function compareIsoDateDescending(left: string, right: string): number {
26 return new Date(right).getTime() - new Date(left).getTime();
27}
28
29function deriveRepositoryGroupKey(project: ScopedMobileProject): string {
30 return (
31 project.repositoryIdentity?.canonicalKey ?? scopedProjectKey(project.environmentId, project.id)
32 );
33}
34
35function deriveRepositoryTitle(project: ScopedMobileProject): string {
36 const identity = project.repositoryIdentity;
37 return identity?.displayName ?? identity?.name ?? project.title;
38}
39
40function 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
50function 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
58export 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}
apps/mobile/src/lib/storage.ts+2−2
@@ -2,7 +2,7 @@ import { Platform } from "react-native"; · 1 unmodified line
2 2
3import type { SavedRemoteConnection } from "./connection";3import type { SavedRemoteConnection } from "./connection";
4 4
5const CONNECTIONS_KEY = "t3remote:connections";5const CONNECTIONS_KEY = "t3code:connections";
6const memoryStorage = new Map<string, string>();6const memoryStorage = new Map<string, string>();
7 7
8type AsyncStorageModule = typeof import("@react-native-async-storage/async-storage");8type 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
91function connectionTokenKey(environmentId: string): string {91function connectionTokenKey(environmentId: string): string {
92 return `t3remote:bearer-token:${environmentId}`;92 return `t3code:bearer-token:${environmentId}`;
93}93}
94 94
95async function loadSecureToken(environmentId: string): Promise<string> {95async function loadSecureToken(environmentId: string): Promise<string> {
apps/mobile/src/lib/worktrees.ts+14−0
@@ -0,0 +1,14 @@
1const WORKTREE_BRANCH_PREFIX = "t3code";
2
3function 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
12export function buildTemporaryWorktreeBranchName(): string {
13 return `${WORKTREE_BRANCH_PREFIX}/${randomHexToken()}`;
14}
apps/web/src/components/GitActionsControl.logic.ts+14−361
@@ -1,363 +1,16 @@
1import type {1export {
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
7export type GitActionIconName = "commit" | "push" | "pr";
8
9export type GitDialogAction = "commit" | "push" | "create_pr";
10
11export 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
20export 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
28export interface DefaultBranchActionDialogCopy {
29 title: string;
30 description: string;
31 continueLabel: string;
32}
33
34export type DefaultBranchConfirmableAction =
35 | "push"
36 | "create_pr"
37 | "commit_push"
38 | "commit_push_pr";
39
40export 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
78export 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
144export 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
279export 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
292export 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
329export 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
341export 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
363export { resolveAutoFeatureBranchName } from "@t3tools/shared/git";16export { resolveAutoFeatureBranchName } from "@t3tools/shared/git";
apps/web/src/components/GitActionsControl.tsx+2−69
@@ -12,6 +12,7 @@ import { GitHubIcon } from "./Icons"; · 11 unmodified lines
12import {12import {
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
120function 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
188const COMMIT_DIALOG_TITLE = "Commit changes";121const COMMIT_DIALOG_TITLE = "Commit changes";
189const COMMIT_DIALOG_DESCRIPTION =122const 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,
apps/web/src/components/Sidebar.logic.ts+1−1
@@ -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(
apps/web/src/components/Sidebar.tsx+13−18
@@ -410,7 +410,7 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: SidebarThreadRowP · 409 unmodified lines
410 );410 );
411 const threadEnvironmentLabel = isRemoteThread411 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 environment414 // For grouped projects, the thread may belong to a different environment
415 // than the representative project. Look up the thread's own project cwd415 // 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 <TooltipTrigger720 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 <span722 ? "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 <span729 <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 <SidebarMenuSub844 <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 <SidebarMenuButton1680 <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 : {})}
apps/web/src/components/chat/ChatHeader.tsx+8−5
@@ -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 <h275 <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 <ProjectScriptsControl97 <ProjectScriptsControl
95 scripts={activeProjectScripts}98 scripts={activeProjectScripts}
bun.lock+31−0
@@ -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=="],
packages/client-runtime/package.json+6−1
@@ -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": {
packages/client-runtime/src/gitActions.ts+430−0
@@ -0,0 +1,430 @@
1import type {
2 GitRunStackedActionInput,
3 GitRunStackedActionResult,
4 GitStackedAction,
5 GitStatusResult,
6} from "@t3tools/contracts";
7
8export type GitActionIconName = "commit" | "push" | "pr";
9
10export type GitDialogAction = "commit" | "push" | "create_pr";
11
12export 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
21export 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
29export interface DefaultBranchActionDialogCopy {
30 title: string;
31 description: string;
32 continueLabel: string;
33}
34
35export type DefaultBranchConfirmableAction =
36 | "push"
37 | "create_pr"
38 | "commit_push"
39 | "commit_push_pr";
40
41export type GitActionRequestInput = Pick<
42 GitRunStackedActionInput,
43 "action" | "commitMessage" | "featureBranch" | "filePaths"
44>;
45
46export 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
84export 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
150export 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
285export 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
349export 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
362export 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
399export 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
411export 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}
packages/client-runtime/src/index.ts+1−0
@@ -1,2 +1,3 @@
1export * from "./knownEnvironment";1export * from "./knownEnvironment";
2export * from "./scoped";2export * from "./scoped";
3export * from "./gitActions";