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 @@
11 {
22 "expo": {
3− "name": "T3 Remote",
4− "slug": "t3-remote",
5− "scheme": "t3remote",
3+ "name": "T3 Code",
4+ "slug": "t3-code",
5+ "scheme": "t3code",
66 "version": "0.1.0",
77 "runtimeVersion": {
88 "policy": "appVersion"
@@ -12,7 +12,7 @@ · 3 unmodified lines
1212 "userInterfaceStyle": "automatic",
1313 "updates": {
1414 "enabled": true,
15− "url": "https://u.expo.dev/525f875b-b769-4d0b-88d7-c55e97e42943",
15+ "url": "https://u.expo.dev/d763fcb8-d37c-41ea-a773-b54a0ab4a454",
1616 "checkAutomatically": "ON_LOAD",
1717 "fallbackToCacheTimeout": 0
1818 },
@@ -24,7 +24,7 @@ · 5 unmodified lines
2424 "ios": {
2525 "icon": "./assets/icon.png",
2626 "supportsTablet": true,
27− "bundleIdentifier": "com.t3tools.t3remote",
27+ "bundleIdentifier": "com.t3tools.t3code",
2828 "infoPlist": {
2929 "NSAppTransportSecurity": {
3030 "NSAllowsArbitraryLoads": true
@@ -34,7 +34,7 @@ · 3 unmodified lines
3434 },
3535 "android": {
3636 "icon": "./assets/icon.png",
37− "package": "com.t3tools.t3remote",
37+ "package": "com.t3tools.t3code",
3838 "usesCleartextTraffic": true,
3939 "adaptiveIcon": {
4040 "backgroundColor": "#E6F4FE",
@@ -48,6 +48,13 @@ · 7 unmodified lines
4848 "favicon": "./assets/favicon.png"
4949 },
5050 "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+ ],
5158 [
5259 "expo-splash-screen",
5360 {
@@ -61,8 +68,9 @@ · 7 unmodified lines
6168 ],
6269 "extra": {
6370 "eas": {
64− "projectId": "525f875b-b769-4d0b-88d7-c55e97e42943"
71+ "projectId": "d763fcb8-d37c-41ea-a773-b54a0ab4a454"
6572 }
66− }
73+ },
74+ "owner": "pingdotgg"
6775 }
6876 }
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 @@
1+import "react-native-gesture-handler";
12 import { registerRootComponent } from "expo";
23
34 import App from "./App";
apps/mobile/metro.config.js+4−0
@@ -1,8 +1,12 @@
1+const path = require("node:path");
12 const { getDefaultConfig } = require("expo/metro-config");
23 const { withNativewind } = require("nativewind/metro");
34
45 /** @type {import("expo/metro-config").MetroConfig} */
56 const config = getDefaultConfig(__dirname);
7+const workspaceRoot = path.resolve(__dirname, "../..");
8+
9+config.watchFolders = [...new Set([...(config.watchFolders ?? []), workspaceRoot])];
610
711 module.exports = withNativewind(config, {
812 input: "./global.css",
apps/mobile/nativewind-env.d.ts+2−0
@@ -1,3 +1,5 @@
11 /// <reference types="react-native-css/types" />
22
33 // NOTE: This file is generated by react-native-css / NativeWind integration.
4+
5+declare module "*.css";
apps/mobile/package.json+5−0
@@ -13,14 +13,18 @@ · 12 unmodified lines
1313 },
1414 "dependencies": {
1515 "@expo-google-fonts/dm-sans": "^0.4.2",
16+ "@gorhom/bottom-sheet": "^5",
1617 "@react-native-async-storage/async-storage": "2.2.0",
1718 "@react-native-masked-view/masked-view": "0.3.2",
19+ "@react-native-menu/menu": "^2.0.0",
1820 "@sethwebster/react-ethereal-input": "^0.4.0",
1921 "@shopify/flash-list": "2.0.2",
22+ "@t3tools/client-runtime": "workspace:*",
2023 "@t3tools/contracts": "workspace:*",
2124 "@t3tools/shared": "workspace:*",
2225 "effect": "catalog:",
2326 "expo": "~55.0.8",
27+ "expo-camera": "~55.0.15",
2428 "expo-clipboard": "~55.0.9",
2529 "expo-constants": "^55.0.9",
2630 "expo-font": "^55.0.4",
@@ -36,6 +40,7 @@ · 9 unmodified lines
3640 "react": "19.2.0",
3741 "react-native": "0.83.4",
3842 "react-native-css": "3.0.7",
43+ "react-native-gesture-handler": "~2.31.1",
3944 "react-native-markdown-display": "^7.0.2",
4045 "react-native-reanimated": "4.2.1",
4146 "react-native-safe-area-context": "~5.6.2",
apps/mobile/src/app/App.tsx+21−2
@@ -1,6 +1,14 @@
11 import { useColorScheme } from "react-native";
22 import "./../../global.css";
33
4+import {
5+ DMSans_400Regular,
6+ DMSans_500Medium,
7+ DMSans_700Bold,
8+ useFonts,
9+} from "@expo-google-fonts/dm-sans";
10+import { BottomSheetModalProvider } from "@gorhom/bottom-sheet";
11+import { GestureHandlerRootView } from "react-native-gesture-handler";
412 import { SafeAreaProvider } from "react-native-safe-area-context";
513
614 import { LoadingScreen } from "../components/LoadingScreen";
@@ -10,10 +18,15 @@ import { useRemoteAppState } from "./useRemoteAppState"; · 3 unmodified lines
1018 export default function App() {
1119 const colorScheme = useColorScheme();
1220 const isDarkMode = colorScheme !== "light";
21+ const [fontsLoaded] = useFonts({
22+ DMSans_400Regular,
23+ DMSans_500Medium,
24+ DMSans_700Bold,
25+ });
1326 const app = useRemoteAppState();
1427 let content;
1528
16− if (app.isLoadingSavedConnection) {
29+ if (!fontsLoaded || app.isLoadingSavedConnection) {
1730 content = <LoadingScreen isDarkMode={isDarkMode} message="Loading remote workspace…" />;
1831 } else if (app.reconnectingScreenVisible) {
1932 content = <LoadingScreen isDarkMode={isDarkMode} message="Reconnecting…" />;
@@ -21,5 +34,11 @@ export default function App() { · 1 unmodified line
2134 content = <MobileAppShell app={app} isDarkMode={isDarkMode} />;
2235 }
2336
24− return <SafeAreaProvider>{content}</SafeAreaProvider>;
37+ return (
38+ <GestureHandlerRootView style={{ flex: 1 }}>
39+ <BottomSheetModalProvider>
40+ <SafeAreaProvider>{content}</SafeAreaProvider>
41+ </BottomSheetModalProvider>
42+ </GestureHandlerRootView>
43+ );
2544 }
apps/mobile/src/app/MobileAppShell.tsx+56−222
@@ -1,198 +1,28 @@
1−import { useCallback, useEffect, useRef, useState } from "react";
2−import { StatusBar, useWindowDimensions, View } from "react-native";
3−import Animated, {
4− Easing,
5− interpolate,
6− runOnJS,
7− useAnimatedStyle,
8− useSharedValue,
9− withTiming,
10−} from "react-native-reanimated";
1+import { useState } from "react";
2+import { StatusBar, View } from "react-native";
113
124 import { ConnectionSheet } from "../features/connection/ConnectionSheet";
5+import { HomeScreen } from "../features/home/HomeScreen";
6+import { NewTaskSheet } from "../features/threads/NewTaskSheet";
137 import { ThreadDetailScreen } from "../features/threads/ThreadDetailScreen";
14−import { ThreadListScreen, type TransitionSourceFrame } from "../features/threads/ThreadListScreen";
15−import type { ScopedMobileThread } from "../lib/scopedEntities";
8+import { ThreadNavigationDrawer } from "../features/threads/ThreadNavigationDrawer";
9+import { scopedThreadKey } from "../lib/scopedEntities";
1610 import type { RemoteAppModel } from "./useRemoteAppState";
1711
18−function revealCenter(
19− sourceFrame: TransitionSourceFrame | null,
20− width: number,
21− height: number,
22−): { readonly x: number; readonly y: number } {
23− "worklet";
24− if (!sourceFrame) {
25− return {
26− x: width / 2,
27− y: height / 2,
28− };
29− }
30−
31− return {
32− x: sourceFrame.x + sourceFrame.width / 2,
33− y: sourceFrame.y + sourceFrame.height / 2,
34− };
35−}
36−
37−function revealRadius(centerX: number, centerY: number, width: number, height: number): number {
38− "worklet";
39− const distances = [
40− Math.hypot(centerX, centerY),
41− Math.hypot(width - centerX, centerY),
42− Math.hypot(centerX, height - centerY),
43− Math.hypot(width - centerX, height - centerY),
44− ];
45−
46− return Math.max(...distances);
47−}
48−
49−function useRevealTransition(
50− selectedThread: ScopedMobileThread | null,
51− onSelectThread: (thread: ScopedMobileThread) => void,
52− onBackFromThread: () => void,
53− width: number,
54− height: number,
55−) {
56− const [transitionSource, setTransitionSource] = useState<TransitionSourceFrame | null>(null);
57− const [transitionPhase, setTransitionPhase] = useState<"idle" | "opening" | "closing">("idle");
58− const revealProgress = useSharedValue(1);
59− const openingAnimationFrameRef = useRef<number | null>(null);
60−
61− const revealMaskStyle = useAnimatedStyle(() => {
62− const center = revealCenter(transitionSource, width, height);
63− const startRadius = transitionSource
64− ? Math.max(transitionSource.width, transitionSource.height) * 0.3
65− : 20;
66− const endRadius = revealRadius(center.x, center.y, width, height);
67− const radius = startRadius + (endRadius - startRadius) * revealProgress.value;
68−
69− return {
70− position: "absolute",
71− left: center.x - radius,
72− top: center.y - radius,
73− width: radius * 2,
74− height: radius * 2,
75− borderRadius: radius,
76− overflow: "hidden",
77− opacity: interpolate(revealProgress.value, [0, 0.12, 1], [0, 1, 1]),
78− };
79− });
80−
81− const revealContentStyle = useAnimatedStyle(() => {
82− const center = revealCenter(transitionSource, width, height);
83− const startRadius = transitionSource
84− ? Math.max(transitionSource.width, transitionSource.height) * 0.3
85− : 20;
86− const endRadius = revealRadius(center.x, center.y, width, height);
87− const radius = startRadius + (endRadius - startRadius) * revealProgress.value;
88−
89− return {
90− width,
91− height,
92− transform: [{ translateX: -(center.x - radius) }, { translateY: -(center.y - radius) }],
93− };
94− });
95−
96− const handleSelectThread = useCallback(
97− (thread: ScopedMobileThread, sourceFrame: TransitionSourceFrame | null): void => {
98− setTransitionSource(sourceFrame);
99− setTransitionPhase("opening");
100− revealProgress.value = 0;
101− onSelectThread(thread);
102− },
103− [onSelectThread, revealProgress],
104− );
105−
106− const handleBackFromThread = useCallback((): void => {
107− if (!selectedThread) {
108− return;
109− }
110−
111− setTransitionPhase("closing");
112− revealProgress.value = withTiming(
113− 0,
114− {
115− duration: 260,
116− easing: Easing.inOut(Easing.cubic),
117− },
118− (finished) => {
119− if (!finished) {
120− return;
121− }
122− runOnJS(onBackFromThread)();
123− runOnJS(setTransitionPhase)("idle");
124− runOnJS(setTransitionSource)(null);
125− revealProgress.value = 1;
126− },
127− );
128− }, [onBackFromThread, revealProgress, selectedThread]);
129−
130− useEffect(() => {
131− if (transitionPhase !== "opening" || !selectedThread) {
132− return;
133− }
134−
135− if (openingAnimationFrameRef.current !== null) {
136− cancelAnimationFrame(openingAnimationFrameRef.current);
137− }
138−
139− openingAnimationFrameRef.current = requestAnimationFrame(() => {
140− openingAnimationFrameRef.current = null;
141− revealProgress.value = withTiming(
142− 1,
143− {
144− duration: 420,
145− easing: Easing.out(Easing.cubic),
146− },
147− (finished) => {
148− if (!finished) {
149− return;
150− }
151− runOnJS(setTransitionPhase)("idle");
152− },
153− );
154− });
155−
156− return () => {
157− if (openingAnimationFrameRef.current !== null) {
158− cancelAnimationFrame(openingAnimationFrameRef.current);
159− openingAnimationFrameRef.current = null;
160− }
161− };
162− }, [selectedThread, revealProgress, transitionPhase]);
163−
164− return {
165− transitionPhase,
166− revealMaskStyle,
167− revealContentStyle,
168− handleSelectThread,
169− handleBackFromThread,
170− };
171−}
172−
17312 export function MobileAppShell(props: {
17413 readonly app: RemoteAppModel;
17514 readonly isDarkMode: boolean;
17615 }) {
17716 const { app } = props;
178− const { width, height } = useWindowDimensions();
179− const backgroundColor = props.isDarkMode ? "#020617" : "#f8fafc";
17+ const [threadsSheetVisible, setThreadsSheetVisible] = useState(false);
18+ const [newTaskVisible, setNewTaskVisible] = useState(false);
19+ const backgroundColor = props.isDarkMode ? "#020617" : "#f6f4ef";
18020
181− const {
182− transitionPhase,
183− revealMaskStyle,
184− revealContentStyle,
185− handleSelectThread,
186− handleBackFromThread,
187− } = useRevealTransition(
188− app.selectedThread,
189− app.onSelectThread,
190− app.onBackFromThread,
191− width,
192− height,
193− );
21+ const selectedThreadKey = app.selectedThread
22+ ? scopedThreadKey(app.selectedThread.environmentId, app.selectedThread.id)
23+ : null;
19424
195− const sharedDetailProps = app.selectedThread
25+ const selectedThreadDetailProps = app.selectedThread
19626 ? {
19727 selectedThread: app.selectedThread,
19828 screenTone: app.screenTone,
@@ -211,14 +41,24 @@ export function MobileAppShell(props: { · 12 unmodified lines
21141 draftAttachments: app.draftAttachments,
21242 connectionStateLabel: app.connectionState,
21343 activeThreadBusy: app.activeThreadBusy,
44+ selectedThreadGitStatus: app.selectedThreadGitStatus,
45+ gitOperationLabel: app.gitOperationLabel,
21446 selectedThreadQueueCount: app.selectedThreadQueueCount,
215− onBack: handleBackFromThread,
47+ onBack: app.onBackFromThread,
48+ onOpenDrawer: () => setThreadsSheetVisible(true),
21649 onOpenConnectionEditor: app.onOpenConnectionEditor,
21750 onChangeDraftMessage: app.onChangeDraftMessage,
21851 onPickDraftImages: app.onPickDraftImages,
21952 onPasteIntoDraft: app.onPasteIntoDraft,
22053 onRemoveDraftImage: app.onRemoveDraftImage,
22154 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,
22262 onRenameThread: app.onRenameThread,
22363 onStopThread: app.onStopThread,
22464 onSendMessage: app.onSendMessage,
@@ -237,47 +77,19 @@ export function MobileAppShell(props: { · 12 unmodified lines
23777 translucent
23878 />
23979
240− <View style={{ flex: 1 }} pointerEvents={app.selectedThread ? "none" : "auto"}>
241− <ThreadListScreen
242− heroTitle={app.heroTitle}
243− showBrandWordmark={app.showBrandWordmark}
244− screenTone={app.screenTone}
245− connectionState={app.connectionState}
246− connectionPulse={app.hasRemoteActivity}
80+ {app.selectedThread && selectedThreadDetailProps ? (
81+ <ThreadDetailScreen {...selectedThreadDetailProps} />
82+ ) : (
83+ <HomeScreen
24784 projects={app.projects}
24885 threads={app.threads}
249− connectedEnvironmentCount={app.connectedEnvironmentCount}
250− hasClient={app.hasClient}
251− hasServerConfig={app.serverConfig !== null}
252− hiddenThreadKey={
253− app.selectedThread
254− ? `${app.selectedThread.environmentId}:${app.selectedThread.id}`
255− : null
256− }
257− connectionError={app.connectionError}
86+ connectionState={app.connectionState}
87+ connectionPulse={app.hasRemoteActivity}
25888 onOpenConnectionEditor={app.onOpenConnectionEditor}
259− onRefresh={app.onRefresh}
260− onCreateThread={app.onCreateThread}
261− onSelectThread={handleSelectThread}
89+ onOpenNewTask={() => setNewTaskVisible(true)}
90+ onSelectThread={app.onSelectThread}
26291 />
263− </View>
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}
92+ )}
28193
28294 <ConnectionSheet
28395 visible={app.connectionSheetRequired}
@@ -288,9 +100,31 @@ export function MobileAppShell(props: { · 4 unmodified lines
288100 onRequestClose={app.onRequestCloseConnectionEditor}
289101 onChangePairingUrl={app.onChangeConnectionPairingUrl}
290102 onConnect={app.onConnectPress}
291− onClose={app.onCloseConnectionEditor}
103+ onUpdateEnvironment={app.onUpdateEnvironment}
292104 onRemoveEnvironment={app.onRemoveEnvironmentPress}
293105 />
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}
294128 </View>
295129 );
296130 }
apps/mobile/src/app/useRemoteAppState.ts+562−22
@@ -1,17 +1,28 @@
11 import { useCallback, useEffect, useMemo, useRef, useState } from "react";
22 import { Alert } from "react-native";
33
4+import { buildGitActionProgressStages, type GitActionRequestInput } from "@t3tools/client-runtime";
45 import {
56 ApprovalRequestId,
67 CommandId,
78 DEFAULT_PROVIDER_INTERACTION_MODE,
89 DEFAULT_RUNTIME_MODE,
10+ type GitBranch,
11+ type GitRunStackedActionResult,
12+ type GitStatusResult,
913 MessageId,
14+ type ModelSelection,
1015 type OrchestrationReadModel,
1116 type ProviderApprovalDecision,
17+ type ProviderInteractionMode,
18+ type RuntimeMode,
1219 type ServerConfig as T3ServerConfig,
1320 ThreadId,
1421 } from "@t3tools/contracts";
22+import {
23+ dedupeRemoteBranchesWithLocalMatches,
24+ sanitizeFeatureBranchName,
25+} from "@t3tools/shared/git";
1526 import { deriveActiveWorkStartedAt, formatElapsed } from "@t3tools/shared/orchestrationTiming";
1627
1728 import { connectionTone } from "../features/connection/connectionTone";
@@ -46,6 +57,7 @@ import { · 28 unmodified lines
4657 } from "../lib/threadActivity";
4758 import { sortCopy } from "../lib/arrayCompat";
4859 import { newClientId } from "../lib/clientId";
60+import { buildTemporaryWorktreeBranchName } from "../lib/worktrees";
4961
5062 export interface ConnectedEnvironmentSummary {
5163 readonly environmentId: string;
@@ -65,6 +77,7 @@ export interface RemoteAppModel { · 13 unmodified lines
6577 readonly connectedEnvironments: ReadonlyArray<ConnectedEnvironmentSummary>;
6678 readonly connectedEnvironmentCount: number;
6779 readonly serverConfig: T3ServerConfig | null;
80+ readonly serverConfigByEnvironmentId: Readonly<Record<string, T3ServerConfig | null>>;
6881 readonly projects: ReadonlyArray<ScopedMobileProject>;
6982 readonly threads: ReadonlyArray<ScopedMobileThread>;
7083 readonly selectedThread: ScopedMobileThread | null;
@@ -81,6 +94,8 @@ export interface RemoteAppModel { · 10 unmodified lines
8194 readonly draftAttachments: ReadonlyArray<DraftComposerImageAttachment>;
8295 readonly screenTone: ReturnType<typeof connectionTone>;
8396 readonly activeThreadBusy: boolean;
97+ readonly selectedThreadGitStatus: GitStatusResult | null;
98+ readonly gitOperationLabel: string | null;
8499 readonly hasRemoteActivity: boolean;
85100 readonly selectedEnvironmentBaseUrl: string | null;
86101 readonly selectedEnvironmentBearerToken: string | null;
@@ -92,15 +107,57 @@ export interface RemoteAppModel { · 5 unmodified lines
92107 readonly onRequestCloseConnectionEditor: () => void;
93108 readonly onChangeConnectionPairingUrl: (pairingUrl: string) => void;
94109 readonly onConnectPress: () => void;
110+ readonly onUpdateEnvironment: (
111+ environmentId: string,
112+ updates: { readonly label: string; readonly displayUrl: string },
113+ ) => Promise<void>;
95114 readonly onRemoveEnvironmentPress: (environmentId: string) => void;
96115 readonly onRefresh: () => Promise<void>;
97116 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>;
98128 readonly onSelectThread: (thread: ScopedMobileThread) => void;
99129 readonly onBackFromThread: () => void;
100130 readonly onChangeDraftMessage: (value: string) => void;
101131 readonly onPickDraftImages: () => Promise<void>;
102132 readonly onPasteIntoDraft: () => Promise<void>;
103133 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>;
104161 readonly onSendMessage: () => void;
105162 readonly onRenameThread: (title: string) => Promise<void>;
106163 readonly onStopThread: () => Promise<void>;
@@ -147,6 +204,16 @@ function firstNonNull<T>(values: ReadonlyArray<T | null | undefined>): T | null · 40 unmodified lines
147204 return null;
148205 }
149206
207+function deriveThreadTitleFromPrompt(value: string): string {
208+ const trimmed = value.trim();
209+ if (trimmed.length === 0) {
210+ return "New thread";
211+ }
212+
213+ const compact = trimmed.replace(/\s+/g, " ");
214+ return compact.length <= 72 ? compact : `${compact.slice(0, 69).trimEnd()}...`;
215+}
216+
150217 function deriveOverallConnectionState(
151218 environments: ReadonlyArray<ConnectedEnvironmentSummary>,
152219 ): RemoteClientConnectionState {
@@ -268,6 +335,10 @@ export function useRemoteAppState(): RemoteAppModel { · 115 unmodified lines
268335 null,
269336 );
270337 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);
271342 const [queuedMessagesByThreadKey, setQueuedMessagesByThreadKey] = useState<
272343 Record<string, ReadonlyArray<QueuedThreadMessage>>
273344 >({});
@@ -516,6 +587,333 @@ export function useRemoteAppState(): RemoteAppModel { · 242 unmodified lines
516587 setSelectedThreadRef(null);
517588 }, [selectedThread, selectedThreadRef]);
518589
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+
519917 const selectedThreadKey = selectedThread
520918 ? scopedThreadKey(selectedThread.environmentId, selectedThread.id)
521919 : null;
@@ -740,15 +1138,115 @@ export function useRemoteAppState(): RemoteAppModel { · 218 unmodified lines
7401138 }
7411139 }),
7421140 );
743− }, [savedConnectionsById, selectedThread]);
744−
745− const onCreateThread = useCallback(
746− async (project: ScopedMobileProject) => {
747− const client = clientsRef.current.get(project.environmentId);
1141+ if (selectedThread) {
1142+ await refreshSelectedThreadGitStatus({ quiet: true });
1143+ }
1144+ }, [refreshSelectedThreadGitStatus, savedConnectionsById, selectedThread]);
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);
7481159 if (!client) {
7491160 return;
7501161 }
7511162
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) => {
7521250 const latestProjectThread =
7531251 threads.find(
7541252 (thread) =>
@@ -761,28 +1259,19 @@ export function useRemoteAppState(): RemoteAppModel { · 6 unmodified lines
7611259 return;
7621260 }
7631261
764− const threadId = ThreadId.makeUnsafe(newClientId("thread"));
765− const createdAt = new Date().toISOString();
766− await client.dispatchCommand({
767− type: "thread.create",
768− commandId: CommandId.makeUnsafe(newClientId("command")),
769− threadId,
770− projectId: project.id,
771− title: "New thread",
1262+ await onCreateThreadWithOptions({
1263+ project,
7721264 modelSelection,
773− runtimeMode: DEFAULT_RUNTIME_MODE,
774− interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE,
1265+ envMode: "local",
7751266 branch: null,
7761267 worktreePath: null,
777− createdAt,
778− });
779− setSelectedThreadRef({
780− environmentId: project.environmentId,
781− threadId,
1268+ runtimeMode: DEFAULT_RUNTIME_MODE,
1269+ interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE,
1270+ initialMessageText: "",
1271+ initialAttachments: [],
7821272 });
783− await onRefresh();
7841273 },
785− [onRefresh, threads],
1274+ [onCreateThreadWithOptions, threads],
7861275 );
7871276
7881277 const onConnectPress = useCallback(() => {
@@ -825,6 +1314,31 @@ export function useRemoteAppState(): RemoteAppModel { · 36 unmodified lines
8251314 [disconnectEnvironment, savedConnectionsById],
8261315 );
8271316
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+
8281342 const onSendMessage = useCallback(() => {
8291343 if (!selectedThread) {
8301344 return;
@@ -1105,6 +1619,16 @@ export function useRemoteAppState(): RemoteAppModel { · 274 unmodified lines
11051619 const serverConfig =
11061620 selectedEnvironmentRuntime?.serverConfig ??
11071621 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+ );
11081632 const hasClient = connectedEnvironments.length > 0;
11091633
11101634 return {
@@ -1117,6 +1641,7 @@ export function useRemoteAppState(): RemoteAppModel { · 6 unmodified lines
11171641 connectedEnvironments,
11181642 connectedEnvironmentCount: connectedEnvironments.length,
11191643 serverConfig,
1644+ serverConfigByEnvironmentId,
11201645 projects,
11211646 threads,
11221647 selectedThread,
@@ -1133,6 +1658,8 @@ export function useRemoteAppState(): RemoteAppModel { · 10 unmodified lines
11331658 draftAttachments,
11341659 screenTone,
11351660 activeThreadBusy,
1661+ selectedThreadGitStatus,
1662+ gitOperationLabel,
11361663 hasRemoteActivity,
11371664 selectedEnvironmentBaseUrl: selectedEnvironmentConnection?.httpBaseUrl ?? null,
11381665 selectedEnvironmentBearerToken: selectedEnvironmentConnection?.bearerToken ?? null,
@@ -1148,9 +1675,11 @@ export function useRemoteAppState(): RemoteAppModel { · 9 unmodified lines
11481675 },
11491676 onChangeConnectionPairingUrl: (pairingUrl: string) => setConnectionInput({ pairingUrl }),
11501677 onConnectPress,
1678+ onUpdateEnvironment,
11511679 onRemoveEnvironmentPress,
11521680 onRefresh,
11531681 onCreateThread,
1682+ onCreateThreadWithOptions,
11541683 onSelectThread: (thread) =>
11551684 setSelectedThreadRef({
11561685 environmentId: thread.environmentId,
@@ -1161,6 +1690,17 @@ export function useRemoteAppState(): RemoteAppModel { · 4 unmodified lines
11611690 onPickDraftImages,
11621691 onPasteIntoDraft,
11631692 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,
11641704 onSendMessage,
11651705 onRenameThread,
11661706 onStopThread,
apps/mobile/src/components/AppText.tsx+10−8
@@ -55,6 +55,7 @@ function activeToken(token: string, isDarkMode: boolean) { · 54 unmodified lines
5555 function resolveTextStyle(className: string | undefined, isDarkMode: boolean): TextStyle {
5656 const style: TextStyle = {
5757 color: isDarkMode ? "#f8fafc" : "#020617",
58+ fontFamily: "DMSans_400Regular",
5859 };
5960
6061 let hasLeadingNone = false;
@@ -70,37 +71,37 @@ function resolveTextStyle(className: string | undefined, isDarkMode: boolean): T · 9 unmodified lines
7071 }
7172
7273 if (token === "font-sans") {
73− style.fontWeight = "400";
74+ style.fontFamily = "DMSans_400Regular";
7475 continue;
7576 }
7677
7778 if (token === "font-medium") {
78− style.fontWeight = "500";
79+ style.fontFamily = "DMSans_500Medium";
7980 continue;
8081 }
8182
8283 if (token === "font-bold") {
83− style.fontWeight = "700";
84+ style.fontFamily = "DMSans_700Bold";
8485 continue;
8586 }
8687
8788 if (token === "font-extrabold") {
88− style.fontWeight = "800";
89+ style.fontFamily = "DMSans_700Bold";
8990 continue;
9091 }
9192
9293 if (token === "font-t3-medium") {
93− style.fontWeight = "500";
94+ style.fontFamily = "DMSans_500Medium";
9495 continue;
9596 }
9697
9798 if (token === "font-t3-bold") {
98− style.fontWeight = "700";
99+ style.fontFamily = "DMSans_700Bold";
99100 continue;
100101 }
101102
102103 if (token === "font-t3-extrabold") {
103− style.fontWeight = "800";
104+ style.fontFamily = "DMSans_700Bold";
104105 continue;
105106 }
106107
@@ -197,6 +198,7 @@ function resolveTextInputStyle( · 90 unmodified lines
197198 paddingHorizontal: 14,
198199 paddingVertical: 12,
199200 fontSize: 15,
201+ fontFamily: "DMSans_400Regular",
200202 minHeight: 54,
201203 };
202204
@@ -252,7 +254,7 @@ function resolveTextInputStyle( · 49 unmodified lines
252254 }
253255
254256 if (token === "font-sans") {
255− style.fontWeight = "400";
257+ style.fontFamily = "DMSans_400Regular";
256258 continue;
257259 }
258260
apps/mobile/src/components/BrandMark.tsx+60−0
@@ -0,0 +1,60 @@
1+import { Image, View } from "react-native";
2+
3+import { AppText as Text } from "./AppText";
4+
5+const BRAND_MARK_SOURCE = require("../../../../assets/dev/blueprint-ios-1024.png");
6+
7+export function BrandMark(props: {
8+ readonly compact?: boolean;
9+ readonly stageLabel?: string;
10+ readonly dark?: boolean;
11+}) {
12+ const compact = props.compact ?? false;
13+ const iconSize = compact ? 32 : 44;
14+ const stageLabel = props.stageLabel ?? "Alpha";
15+ const textColor = props.dark ? "#f5f5f4" : "#171717";
16+ const mutedColor = props.dark ? "rgba(245,245,244,0.72)" : "rgba(23,23,23,0.6)";
17+ const stageBackground = props.dark ? "rgba(255,255,255,0.08)" : "rgba(23,23,23,0.06)";
18+
19+ return (
20+ <View className="flex-row items-center gap-3">
21+ <Image
22+ source={BRAND_MARK_SOURCE}
23+ accessibilityIgnoresInvertColors
24+ style={{
25+ width: iconSize,
26+ height: iconSize,
27+ borderRadius: compact ? 10 : 14,
28+ }}
29+ />
30+ <View className="gap-1">
31+ <View className="flex-row items-center gap-2">
32+ <Text
33+ className="text-[17px] font-t3-bold"
34+ style={{ color: textColor, letterSpacing: -0.4 }}
35+ >
36+ T3 Code
37+ </Text>
38+ <View
39+ className="rounded-full px-2 py-1"
40+ style={{
41+ backgroundColor: stageBackground,
42+ }}
43+ >
44+ <Text
45+ className="text-[10px] font-t3-bold uppercase"
46+ style={{ color: mutedColor, letterSpacing: 1.1 }}
47+ >
48+ {stageLabel}
49+ </Text>
50+ </View>
51+ </View>
52+ {!compact ? (
53+ <Text className="text-[12px] font-medium" style={{ color: mutedColor }}>
54+ Mobile control surface for your live coding environments
55+ </Text>
56+ ) : null}
57+ </View>
58+ </View>
59+ );
60+}
apps/mobile/src/components/LoadingScreen.tsx+3−1
@@ -2,6 +2,7 @@ import { ActivityIndicator, StatusBar, View } from "react-native"; · 1 unmodified line
22 import { useSafeAreaInsets } from "react-native-safe-area-context";
33
44 import { AppText as Text } from "./AppText";
5+import { BrandMark } from "./BrandMark";
56
67 export function LoadingScreen(props: { readonly isDarkMode: boolean; readonly message: string }) {
78 const insets = useSafeAreaInsets();
@@ -14,7 +15,8 @@ export function LoadingScreen(props: { readonly isDarkMode: boolean; readonly me · 6 unmodified lines
1415 backgroundColor={backgroundColor}
1516 translucent
1617 />
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} />
1820 <ActivityIndicator size="large" />
1921 <Text className="font-t3-bold text-lg text-slate-950 dark:text-slate-50">
2022 {props.message}
apps/mobile/src/components/StatusPill.tsx+22−3
@@ -9,10 +9,29 @@ export interface StatusTone { · 8 unmodified lines
99 readonly textClassName: string;
1010 }
1111
12−export function StatusPill(props: StatusTone) {
12+export function StatusPill(
13+ props: StatusTone & {
14+ readonly size?: "default" | "compact";
15+ },
16+) {
17+ const size = props.size ?? "default";
1318 return (
14− <View className={cx("rounded-full px-3 py-1.5", props.pillClassName)}>
15− <Text className={cx("font-t3-bold text-xs", props.textClassName)}>{props.label}</Text>
19+ <View
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>
1635 </View>
1736 );
1837 }
apps/mobile/src/features/connection/ConnectionSheet.tsx+939−165
@@ -1,11 +1,23 @@
1−import { Modal, Pressable, ScrollView, View } from "react-native";
2−import { useColorScheme } from "react-native";
1+import {
2+ BottomSheetBackdrop,
3+ BottomSheetModal,
4+ BottomSheetScrollView,
5+ BottomSheetView,
6+} from "@gorhom/bottom-sheet";
7+import { MenuView } from "@react-native-menu/menu";
8+import { CameraView, type BarcodeScanningResult, useCameraPermissions } from "expo-camera";
9+import { SymbolView } from "expo-symbols";
10+import { useCallback, useEffect, useMemo, useRef, useState } from "react";
11+import { Linking, Platform, Pressable, View, useColorScheme } from "react-native";
12+import { useSafeAreaInsets } from "react-native-safe-area-context";
313
14+import type { ConnectedEnvironmentSummary } from "../../app/useRemoteAppState";
415 import { AppText as Text, AppTextInput as TextInput } from "../../components/AppText";
516 import { ErrorBanner } from "../../components/ErrorBanner";
6−import type { ConnectedEnvironmentSummary } from "../../app/useRemoteAppState";
717 import type { RemoteConnectionInput } from "../../lib/connection";
18+import { extractPairingUrlFromQrPayload } from "../../lib/pairingQr";
819 import type { RemoteClientConnectionState } from "../../lib/remoteClient";
20+import { ConnectionStatusDot } from "./ConnectionStatusDot";
921
1022 export interface ConnectionSheetProps {
1123 readonly visible: boolean;
@@ -16,82 +28,196 @@ export interface ConnectionSheetProps { · 4 unmodified lines
1628 readonly onRequestClose: () => void;
1729 readonly onChangePairingUrl: (pairingUrl: string) => void;
1830 readonly onConnect: () => void;
19− readonly onClose: () => void;
31+ readonly onUpdateEnvironment: (
32+ environmentId: string,
33+ updates: { readonly label: string; readonly displayUrl: string },
34+ ) => Promise<void>;
2035 readonly onRemoveEnvironment: (environmentId: string) => void;
2136 }
2237
38+// ---------------------------------------------------------------------------
39+// Helpers
40+// ---------------------------------------------------------------------------
41+
42+function parsePairingUrl(url: string): { host: string; code: string } {
43+ const trimmed = url.trim();
44+ if (!trimmed) return { host: "", code: "" };
45+
46+ try {
47+ const parsed = new URL(trimmed);
48+ const hashParams = new URLSearchParams(parsed.hash.slice(1));
49+ const hashToken = hashParams.get("token");
50+ const queryToken = parsed.searchParams.get("token");
51+ const code = hashToken || queryToken || "";
52+
53+ parsed.hash = "";
54+ parsed.search = "";
55+ parsed.pathname = "/";
56+ return { host: parsed.toString().replace(/\/$/, ""), code };
57+ } catch {
58+ return { host: trimmed, code: "" };
59+ }
60+}
61+
62+function buildPairingUrl(host: string, code: string): string {
63+ const h = host.trim();
64+ const c = code.trim();
65+ if (!h) return "";
66+ if (!c) return h;
67+
68+ try {
69+ const url = new URL(h.includes("://") ? h : `https://${h}`);
70+ url.hash = new URLSearchParams([["token", c]]).toString();
71+ return url.toString();
72+ } catch {
73+ return `${h}#token=${c}`;
74+ }
75+}
76+
77+// ---------------------------------------------------------------------------
78+// Palette
79+// ---------------------------------------------------------------------------
80+
2381 function makePalette(isDarkMode: boolean) {
2482 if (isDarkMode) {
2583 return {
26− sheet: "#151618",
27− panel: "#1d1e21",
28− border: "rgba(255,255,255,0.08)",
29− text: "#f4f3ef",
30− muted: "#8f918f",
31− input: "#1f2230",
32− inputText: "#f8fafc",
33− placeholder: "#6b7280",
34− action: "#f97316",
35− actionText: "#fff7ed",
36− secondary: "#303440",
37− secondaryText: "#f8fafc",
38− danger: "#381624",
84+ sheet: "rgba(18,20,23,0.98)",
85+ card: "#1f2329",
86+ cardAlt: "#242a31",
87+ border: "rgba(255,255,255,0.06)",
88+ text: "#f8fafc",
89+ muted: "#94a3b8",
90+ subtle: "#cbd5e1",
91+ accent: "#f59e0b",
92+ accentSubtle: "rgba(245,158,11,0.12)",
93+ iconBg: "rgba(245,158,11,0.10)",
94+ inputBackground: "#171b20",
95+ inputBorder: "rgba(255,255,255,0.06)",
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)",
39102 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)",
41112 };
42113 }
43114
44115 return {
45− sheet: "#f2ece4",
46− panel: "#fbf7f1",
47− border: "#d7cdbf",
48− text: "#1f1b17",
49− muted: "#847b71",
50− input: "#ffffff",
51− inputText: "#1f2937",
116+ sheet: "rgba(246,244,239,0.98)",
117+ card: "#fffdf9",
118+ cardAlt: "#f8f4ec",
119+ border: "rgba(23,23,23,0.06)",
120+ text: "#171717",
121+ muted: "#78716c",
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",
52135 placeholder: "#94a3b8",
53− action: "#2c2a2d",
54− actionText: "#f8f4ee",
55− secondary: "#ffffff",
56− secondaryText: "#1f1b17",
57− danger: "#fde7e7",
58− dangerText: "#a11d33",
59− accent: "#9a6b30",
136+ cardShadow: {
137+ shadowColor: "rgba(23,23,23,0.08)",
138+ shadowOffset: { width: 0, height: 4 },
139+ shadowOpacity: 1,
140+ shadowRadius: 16,
141+ elevation: 3,
142+ } as const,
143+ separator: "rgba(23,23,23,0.04)",
60144 };
61145 }
62146
63−function ActionButton(props: {
147+// ---------------------------------------------------------------------------
148+// Shared button
149+// ---------------------------------------------------------------------------
150+
151+function SheetButton(props: {
152+ readonly icon: React.ComponentProps<typeof SymbolView>["name"];
64153 readonly label: string;
65− readonly onPress: () => void;
66− readonly palette: ReturnType<typeof makePalette>;
154+ readonly disabled?: boolean;
67155 readonly tone?: "primary" | "secondary" | "danger";
156+ readonly compact?: boolean;
157+ readonly palette: ReturnType<typeof makePalette>;
158+ readonly onPress: () => void;
68159 }) {
69160 const tone = props.tone ?? "secondary";
70− const styles =
161+ const colors =
71162 tone === "primary"
72163 ? {
73− backgroundColor: props.palette.action,
74− color: props.palette.actionText,
164+ backgroundColor: props.palette.primaryButton,
165+ borderColor: "transparent",
166+ textColor: props.palette.primaryButtonText,
75167 }
76168 : tone === "danger"
77169 ? {
78− backgroundColor: props.palette.danger,
79− color: props.palette.dangerText,
170+ backgroundColor: props.palette.dangerButton,
171+ borderColor: props.palette.dangerBorder,
172+ textColor: props.palette.dangerText,
80173 }
81174 : {
82− backgroundColor: props.palette.secondary,
83− color: props.palette.secondaryText,
175+ backgroundColor: props.palette.secondaryButton,
176+ borderColor: props.palette.border,
177+ textColor: props.palette.secondaryButtonText,
84178 };
85179
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+
86193 return (
87194 <Pressable
88− className="min-h-[52px] items-center justify-center px-4 py-3"
89− style={{ backgroundColor: styles.backgroundColor }}
195+ className={
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}
90201 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+ ]}
91211 >
212+ <SymbolView
213+ name={props.icon}
214+ size={props.compact ? 13 : 14}
215+ tintColor={colors.textColor}
216+ type="monochrome"
217+ />
92218 <Text
93− className="text-sm font-extrabold uppercase"
94− style={{ color: styles.color, letterSpacing: 1 }}
219+ className="text-[12px] font-t3-bold uppercase"
220+ style={{ color: colors.textColor, letterSpacing: 0.8 }}
95221 >
96222 {props.label}
97223 </Text>
@@ -99,151 +225,799 @@ function ActionButton(props: { · 1 unmodified line
99225 );
100226 }
101227
102−export function ConnectionSheet(props: ConnectionSheetProps) {
103− const isDarkMode = useColorScheme() === "dark";
104− const palette = makePalette(isDarkMode);
228+// ---------------------------------------------------------------------------
229+// Environment card (compact single row)
230+// ---------------------------------------------------------------------------
231+
232+function EnvironmentCard(props: {
233+ readonly environment: ConnectedEnvironmentSummary;
234+ readonly palette: ReturnType<typeof makePalette>;
235+ readonly onPress: () => void;
236+ readonly onRemove: (environmentId: string) => void;
237+}) {
238+ return (
239+ <Pressable
240+ className="flex-row items-center gap-3 rounded-[18px] px-4 py-3.5"
241+ style={[
242+ {
243+ backgroundColor: props.palette.card,
244+ borderWidth: 1,
245+ borderColor: props.palette.border,
246+ },
247+ props.palette.cardShadow,
248+ ]}
249+ onPress={props.onPress}
250+ >
251+ <ConnectionStatusDot state={props.environment.connectionState} pulse={false} size={8} />
252+
253+ <View className="flex-1 gap-0.5">
254+ <Text
255+ className="text-[16px] font-t3-bold leading-[21px]"
256+ style={{ color: props.palette.text }}
257+ numberOfLines={1}
258+ >
259+ {props.environment.environmentLabel}
260+ </Text>
261+ <Text
262+ className="text-[12px] leading-[16px]"
263+ style={{ color: props.palette.muted }}
264+ numberOfLines={1}
265+ >
266+ {props.environment.displayUrl}
267+ </Text>
268+ </View>
269+
270+ <Pressable
271+ className="h-9 w-9 items-center justify-center rounded-[12px]"
272+ style={{
273+ backgroundColor: props.palette.dangerButton,
274+ borderWidth: 1,
275+ borderColor: props.palette.dangerBorder,
276+ }}
277+ onPress={(e) => {
278+ e.stopPropagation();
279+ props.onRemove(props.environment.environmentId);
280+ }}
281+ hitSlop={4}
282+ >
283+ <SymbolView name="trash" size={13} tintColor={props.palette.dangerText} type="monochrome" />
284+ </Pressable>
285+ </Pressable>
286+ );
287+}
288+
289+// ---------------------------------------------------------------------------
290+// Edit backend (nested sheet)
291+// ---------------------------------------------------------------------------
292+
293+function EnvironmentDetailSheet(props: {
294+ readonly environment: ConnectedEnvironmentSummary | null;
295+ readonly palette: ReturnType<typeof makePalette>;
296+ readonly bottomInset: number;
297+ readonly onDismiss: () => void;
298+ readonly onSave: (
299+ environmentId: string,
300+ updates: { readonly label: string; readonly displayUrl: string },
301+ ) => void;
302+ readonly onRemove: (environmentId: string) => void;
303+}) {
304+ const sheetRef = useRef<BottomSheetModal>(null);
305+ const [label, setLabel] = useState("");
306+ const [displayUrl, setDisplayUrl] = useState("");
307+ const env = props.environment;
308+
309+ useEffect(() => {
310+ const sheet = sheetRef.current;
311+ if (!sheet) return;
312+ if (env) {
313+ setLabel(env.environmentLabel);
314+ setDisplayUrl(env.displayUrl);
315+ sheet.present();
316+ } else {
317+ sheet.dismiss();
318+ }
319+ }, [env]);
320+
321+ const renderBackdrop = useCallback(
322+ (backdropProps: React.ComponentProps<typeof BottomSheetBackdrop>) => (
323+ <BottomSheetBackdrop
324+ {...backdropProps}
325+ appearsOnIndex={0}
326+ disappearsOnIndex={-1}
327+ opacity={0.24}
328+ pressBehavior="close"
329+ />
330+ ),
331+ [],
332+ );
333+
334+ const hasChanges =
335+ env !== null &&
336+ (label.trim() !== env.environmentLabel || displayUrl.trim() !== env.displayUrl);
105337
106338 return (
107− <Modal
108− animationType="slide"
109− presentationStyle="pageSheet"
110− allowSwipeDismissal
111− visible={props.visible}
112− onRequestClose={props.onRequestClose}
339+ <BottomSheetModal
340+ ref={sheetRef}
341+ stackBehavior="push"
342+ index={0}
343+ snapPoints={["52%"]}
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)" }}
113350 >
114− <View className="flex-1" style={{ backgroundColor: palette.sheet }}>
115− <ScrollView bounces={false} showsVerticalScrollIndicator={false}>
116− <View className="gap-5 px-5 py-5">
351+ <BottomSheetView style={{ flex: 1 }}>
352+ <View className="gap-5 px-5 pt-1">
353+ <View className="flex-row items-center gap-3">
354+ {env ? (
355+ <ConnectionStatusDot state={env.connectionState} pulse={false} size={8} />
356+ ) : null}
357+ <Text
358+ className="text-[24px] font-t3-bold"
359+ style={{ color: props.palette.text, letterSpacing: -0.2 }}
360+ >
361+ Edit backend
362+ </Text>
363+ </View>
364+
365+ <View className="gap-2">
366+ <Text
367+ className="text-[11px] font-t3-bold uppercase"
368+ style={{ color: props.palette.muted, letterSpacing: 0.8 }}
369+ >
370+ Label
371+ </Text>
372+ <TextInput
373+ autoCapitalize="words"
374+ autoCorrect={false}
375+ placeholder="My MacBook Pro"
376+ placeholderTextColor={props.palette.placeholder}
377+ value={label}
378+ onChangeText={setLabel}
379+ className="rounded-[14px] px-4 py-3.5 text-[15px]"
380+ style={{
381+ backgroundColor: props.palette.inputBackground,
382+ borderWidth: 1,
383+ borderColor: props.palette.inputBorder,
384+ color: props.palette.text,
385+ }}
386+ />
387+ </View>
388+
389+ <View className="gap-2">
390+ <Text
391+ className="text-[11px] font-t3-bold uppercase"
392+ style={{ color: props.palette.muted, letterSpacing: 0.8 }}
393+ >
394+ Host
395+ </Text>
396+ <TextInput
397+ autoCapitalize="none"
398+ autoCorrect={false}
399+ keyboardType="url"
400+ placeholder="http://192.168.1.100:8080/"
401+ placeholderTextColor={props.palette.placeholder}
402+ value={displayUrl}
403+ onChangeText={setDisplayUrl}
404+ className="rounded-[14px] px-4 py-3.5 text-[15px]"
405+ style={{
406+ backgroundColor: props.palette.inputBackground,
407+ borderWidth: 1,
408+ borderColor: props.palette.inputBorder,
409+ color: props.palette.text,
410+ }}
411+ />
412+ </View>
413+
414+ <View
415+ className="flex-row gap-3"
416+ style={{ paddingBottom: Math.max(props.bottomInset, 16) }}
417+ >
418+ <View className="flex-1">
419+ <SheetButton
420+ icon="checkmark"
421+ label="Save"
422+ disabled={!hasChanges}
423+ palette={props.palette}
424+ tone="primary"
425+ onPress={() => {
426+ if (env) {
427+ props.onSave(env.environmentId, {
428+ label: label.trim(),
429+ displayUrl: displayUrl.trim(),
430+ });
431+ props.onDismiss();
432+ }
433+ }}
434+ />
435+ </View>
436+ <View className="flex-1">
437+ <SheetButton
438+ icon="xmark"
439+ label="Cancel"
440+ palette={props.palette}
441+ tone="secondary"
442+ onPress={props.onDismiss}
443+ />
444+ </View>
445+ </View>
446+ </View>
447+ </BottomSheetView>
448+ </BottomSheetModal>
449+ );
450+}
451+
452+// ---------------------------------------------------------------------------
453+// Add backend (nested sheet — manual entry + optional QR scanner)
454+// ---------------------------------------------------------------------------
455+
456+function AddBackendSheet(props: {
457+ readonly visible: boolean;
458+ readonly startWithScanner: boolean;
459+ readonly connectionState: RemoteClientConnectionState;
460+ readonly connectionError: string | null;
461+ readonly palette: ReturnType<typeof makePalette>;
462+ readonly bottomInset: number;
463+ readonly onDismiss: () => void;
464+ readonly onChangePairingUrl: (url: string) => void;
465+ readonly onConnect: () => void;
466+}) {
467+ const sheetRef = useRef<BottomSheetModal>(null);
468+ const [cameraPermission, requestCameraPermission] = useCameraPermissions();
469+ const [hostInput, setHostInput] = useState("");
470+ const [codeInput, setCodeInput] = useState("");
471+ const [scannerVisible, setScannerVisible] = useState(false);
472+ const [scannerError, setScannerError] = useState<string | null>(null);
473+ const [scannerPaused, setScannerPaused] = useState(false);
474+
475+ const connectDisabled =
476+ props.connectionState === "connecting" || hostInput.trim().length === 0;
477+
478+ const snapPoints = useMemo<(string | number)[]>(
479+ () => [scannerVisible ? "82%" : "54%"],
480+ [scannerVisible],
481+ );
482+
483+ // Present / dismiss
484+ useEffect(() => {
485+ const sheet = sheetRef.current;
486+ if (!sheet) return;
487+ if (props.visible) {
488+ setHostInput("");
489+ setCodeInput("");
490+ setScannerError(null);
491+ setScannerPaused(false);
492+
493+ if (props.startWithScanner) {
494+ setScannerVisible(true);
495+ if (!cameraPermission?.granted && cameraPermission?.canAskAgain !== false) {
496+ void requestCameraPermission().catch(() => {});
497+ }
498+ } else {
499+ setScannerVisible(false);
500+ }
501+
502+ sheet.present();
503+ } else {
504+ sheet.dismiss();
505+ }
506+ // eslint-disable-next-line react-hooks/exhaustive-deps
507+ }, [props.visible]);
508+
509+ // Sync fields → parent
510+ const syncUrl = useCallback(
511+ (host: string, code: string) => {
512+ props.onChangePairingUrl(buildPairingUrl(host, code));
513+ },
514+ [props.onChangePairingUrl],
515+ );
516+
517+ const handleHostChange = useCallback(
518+ (v: string) => {
519+ setHostInput(v);
520+ syncUrl(v, codeInput);
521+ },
522+ [codeInput, syncUrl],
523+ );
524+
525+ const handleCodeChange = useCallback(
526+ (v: string) => {
527+ setCodeInput(v);
528+ syncUrl(hostInput, v);
529+ },
530+ [hostInput, syncUrl],
531+ );
532+
533+ const handleToggleScanner = useCallback(() => {
534+ setScannerVisible((v) => {
535+ if (!v) {
536+ setScannerError(null);
537+ setScannerPaused(false);
538+ if (!cameraPermission?.granted && cameraPermission?.canAskAgain !== false) {
539+ void requestCameraPermission().catch(() => {});
540+ }
541+ }
542+ return !v;
543+ });
544+ }, [cameraPermission?.canAskAgain, cameraPermission?.granted, requestCameraPermission]);
545+
546+ const handleBarcodeScanned = useCallback(
547+ (result: BarcodeScanningResult) => {
548+ if (scannerPaused) return;
549+ setScannerPaused(true);
550+ try {
551+ const url = extractPairingUrlFromQrPayload(result.data);
552+ const { host, code } = parsePairingUrl(url);
553+ setHostInput(host);
554+ setCodeInput(code);
555+ props.onChangePairingUrl(url);
556+ setScannerVisible(false);
557+ } catch (error) {
558+ setScannerError(
559+ error instanceof Error
560+ ? error.message
561+ : "Could not read a pairing URL from that QR code.",
562+ );
563+ setScannerPaused(false);
564+ }
565+ },
566+ [props.onChangePairingUrl, scannerPaused],
567+ );
568+
569+ const renderBackdrop = useCallback(
570+ (backdropProps: React.ComponentProps<typeof BottomSheetBackdrop>) => (
571+ <BottomSheetBackdrop
572+ {...backdropProps}
573+ appearsOnIndex={0}
574+ disappearsOnIndex={-1}
575+ opacity={0.24}
576+ pressBehavior="close"
577+ />
578+ ),
579+ [],
580+ );
581+
582+ return (
583+ <BottomSheetModal
584+ ref={sheetRef}
585+ stackBehavior="push"
586+ index={0}
587+ snapPoints={snapPoints}
588+ enableDynamicSizing={false}
589+ enablePanDownToClose
590+ backdropComponent={renderBackdrop}
591+ onDismiss={props.onDismiss}
592+ backgroundStyle={{ backgroundColor: props.palette.sheet }}
593+ handleIndicatorStyle={{ backgroundColor: "rgba(120,113,108,0.32)" }}
594+ >
595+ <BottomSheetView style={{ flex: 1 }}>
596+ <BottomSheetScrollView
597+ keyboardShouldPersistTaps="handled"
598+ showsVerticalScrollIndicator={false}
599+ contentContainerStyle={{
600+ paddingHorizontal: 20,
601+ paddingTop: 4,
602+ paddingBottom: Math.max(props.bottomInset, 16),
603+ gap: 20,
604+ }}
605+ >
606+ <Text
607+ className="text-[24px] font-t3-bold"
608+ style={{ color: props.palette.text, letterSpacing: -0.2 }}
609+ >
610+ Add backend
611+ </Text>
612+
613+ {/* QR scanner */}
614+ {scannerVisible ? (
117615 <View
118− className="gap-4 px-4 py-4"
616+ className="gap-3 rounded-[18px] p-3"
119617 style={{
618+ backgroundColor: props.palette.cardAlt,
120619 borderWidth: 1,
121− borderColor: palette.border,
122− backgroundColor: palette.panel,
620+ borderColor: props.palette.border,
123621 }}
124622 >
125− <View className="gap-2">
126− <Text
127− className="text-[11px] font-bold uppercase"
128− style={{ color: palette.accent, letterSpacing: 1.3 }}
129− >
130− Pair environment
131− </Text>
623+ {!cameraPermission ? (
132624 <Text
133− className="text-[20px] font-extrabold leading-[24px]"
134− style={{ color: palette.text }}
625+ className="text-[13px] leading-[18px]"
626+ style={{ color: props.palette.subtle }}
135627 >
136− Add another backend
628+ Checking camera permission…
137629 </Text>
138− <Text className="text-[14px] leading-[20px]" style={{ color: palette.muted }}>
139− Paste the pairing link from the web or desktop app. The mobile client will keep a
140− separate live connection for each paired environment.
141− </Text>
142− </View>
630+ ) : cameraPermission.granted ? (
631+ <>
632+ <View
633+ className="overflow-hidden rounded-[14px]"
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}
143701
144− <View className="gap-2">
145− <Text
146− className="text-[11px] font-bold uppercase"
147− style={{ color: palette.muted, letterSpacing: 1.15 }}
148− >
149− Pairing URL
150− </Text>
702+ {/* Host field */}
703+ <View className="gap-1.5">
704+ <Text
705+ className="text-[11px] font-t3-bold uppercase"
706+ style={{ color: props.palette.muted, letterSpacing: 0.8 }}
707+ >
708+ Host
709+ </Text>
710+ <View className="flex-row items-center gap-2">
711+ <View className="flex-1">
151712 <TextInput
152713 autoCapitalize="none"
153714 autoCorrect={false}
154715 keyboardType="url"
155− placeholder="https://backend.example.com/pair#token=..."
156− placeholderTextColor={palette.placeholder}
157− className="min-h-[56px] px-4 py-3 text-[15px]"
716+ placeholder="192.168.1.100:8080"
717+ placeholderTextColor={props.palette.placeholder}
718+ value={hostInput}
719+ onChangeText={handleHostChange}
720+ className="rounded-[14px] px-4 py-3.5 text-[15px]"
158721 style={{
722+ backgroundColor: props.palette.inputBackground,
159723 borderWidth: 1,
160− borderColor: palette.border,
161− backgroundColor: palette.input,
162− color: palette.inputText,
724+ borderColor: props.palette.inputBorder,
725+ color: props.palette.text,
163726 }}
164− value={props.connectionInput.pairingUrl}
165− onChangeText={props.onChangePairingUrl}
166727 />
167728 </View>
168−
169− <View className="flex-row gap-3">
170− <View className="flex-1">
171− <ActionButton
172− label={props.connectionState === "connecting" ? "Pairing…" : "Add backend"}
173− onPress={props.onConnect}
174− palette={palette}
175− tone="primary"
176− />
177− </View>
178− {props.connectedEnvironments.length > 0 ? (
179− <View className="flex-1">
180− <ActionButton label="Close" onPress={props.onClose} palette={palette} />
181− </View>
182− ) : null}
183− </View>
729+ <Pressable
730+ className="h-[50px] w-[50px] items-center justify-center rounded-[14px]"
731+ style={{
732+ backgroundColor: scannerVisible
733+ ? props.palette.accent
734+ : props.palette.secondaryButton,
735+ borderWidth: scannerVisible ? 0 : 1,
736+ borderColor: props.palette.border,
737+ }}
738+ onPress={handleToggleScanner}
739+ >
740+ <SymbolView
741+ name={scannerVisible ? "xmark" : "qrcode.viewfinder"}
742+ size={20}
743+ tintColor={
744+ scannerVisible
745+ ? props.palette.primaryButtonText
746+ : props.palette.secondaryButtonText
747+ }
748+ type="monochrome"
749+ />
750+ </Pressable>
184751 </View>
752+ </View>
753+
754+ {/* Pairing code field */}
755+ <View className="gap-1.5">
756+ <Text
757+ className="text-[11px] font-t3-bold uppercase"
758+ style={{ color: props.palette.muted, letterSpacing: 0.8 }}
759+ >
760+ Pairing code
761+ </Text>
762+ <TextInput
763+ autoCapitalize="none"
764+ autoCorrect={false}
765+ placeholder="abc-123-xyz"
766+ placeholderTextColor={props.palette.placeholder}
767+ value={codeInput}
768+ onChangeText={handleCodeChange}
769+ className="rounded-[14px] px-4 py-3.5 text-[15px]"
770+ style={{
771+ backgroundColor: props.palette.inputBackground,
772+ borderWidth: 1,
773+ borderColor: props.palette.inputBorder,
774+ color: props.palette.text,
775+ }}
776+ />
777+ </View>
778+
779+ {props.connectionError ? <ErrorBanner message={props.connectionError} /> : null}
780+
781+ <SheetButton
782+ icon="plus"
783+ label={props.connectionState === "connecting" ? "Pairing…" : "Add backend"}
784+ disabled={connectDisabled}
785+ palette={props.palette}
786+ tone="primary"
787+ onPress={props.onConnect}
788+ />
789+ </BottomSheetScrollView>
790+ </BottomSheetView>
791+ </BottomSheetModal>
792+ );
793+}
794+
795+// ---------------------------------------------------------------------------
796+// Main connection sheet
797+// ---------------------------------------------------------------------------
798+
799+const ADD_MENU_ACTIONS = [
800+ {
801+ id: "scan_qr",
802+ title: "Scan QR Code",
803+ image: "qrcode.viewfinder",
804+ },
805+ {
806+ id: "enter_manually",
807+ title: "Enter Manually",
808+ image: "keyboard",
809+ },
810+] as const;
811+
812+export function ConnectionSheet(props: ConnectionSheetProps) {
813+ const insets = useSafeAreaInsets();
814+ const isDarkMode = useColorScheme() === "dark";
815+ const palette = makePalette(isDarkMode);
816+ const sheetRef = useRef<BottomSheetModal>(null);
817+
818+ const [editingEnvironment, setEditingEnvironment] =
819+ useState<ConnectedEnvironmentSummary | null>(null);
820+ const [addSheetVisible, setAddSheetVisible] = useState(false);
821+ const [addSheetStartWithScanner, setAddSheetStartWithScanner] = useState(false);
822+
823+ const dismissible = props.connectedEnvironments.length > 0;
824+ const hasEnvironments = props.connectedEnvironments.length > 0;
825+
826+ const snapPoints = useMemo<(string | number)[]>(
827+ () => [hasEnvironments ? "52%" : "38%"],
828+ [hasEnvironments],
829+ );
830+
831+ const renderBackdrop = useCallback(
832+ (backdropProps: React.ComponentProps<typeof BottomSheetBackdrop>) => (
833+ <BottomSheetBackdrop
834+ {...backdropProps}
835+ appearsOnIndex={0}
836+ disappearsOnIndex={-1}
837+ opacity={0.18}
838+ pressBehavior={dismissible ? "close" : "none"}
839+ />
840+ ),
841+ [dismissible],
842+ );
843+
844+ useEffect(() => {
845+ const sheet = sheetRef.current;
846+ if (!sheet) return;
847+ if (props.visible) {
848+ sheet.present();
849+ } else {
850+ sheet.dismiss();
851+ }
852+ }, [props.visible]);
853+
854+ useEffect(() => {
855+ if (!props.visible) {
856+ setEditingEnvironment(null);
857+ setAddSheetVisible(false);
858+ }
859+ }, [props.visible]);
860+
861+ // When there are no backends, open the add flow automatically
862+ useEffect(() => {
863+ if (props.visible && !dismissible) {
864+ setAddSheetStartWithScanner(false);
865+ setAddSheetVisible(true);
866+ }
867+ }, [props.visible, dismissible]);
868+
869+ const handleMenuAction = useCallback(
870+ ({ nativeEvent }: { nativeEvent: { event: string } }) => {
871+ if (nativeEvent.event === "scan_qr") {
872+ setAddSheetStartWithScanner(true);
873+ setAddSheetVisible(true);
874+ } else if (nativeEvent.event === "enter_manually") {
875+ setAddSheetStartWithScanner(false);
876+ setAddSheetVisible(true);
877+ }
878+ },
879+ [],
880+ );
185881
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>
187940
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+ >
189971 <View
190− className="gap-3 px-4 py-4"
191− style={{
192− borderWidth: 1,
193− borderColor: palette.border,
194− backgroundColor: palette.panel,
195− }}
972+ className="h-12 w-12 items-center justify-center rounded-[16px]"
973+ style={{ backgroundColor: palette.iconBg }}
196974 >
197− <Text
198− className="text-[11px] font-bold uppercase"
199− style={{ color: palette.accent, letterSpacing: 1.3 }}
200− >
201− Connected backends
202− </Text>
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− ))}
975+ <SymbolView
976+ name="point.3.connected.trianglepath.dotted"
977+ size={20}
978+ tintColor={palette.accent}
979+ type="monochrome"
980+ />
242981 </View>
243− ) : null}
244− </View>
245− </ScrollView>
246− </View>
247− </Modal>
982+ <Text
983+ className="text-center text-[14px] leading-[20px]"
984+ style={{ color: palette.muted }}
985+ >
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>
2481022 );
2491023 }
apps/mobile/src/features/home/HomeScreen.tsx+171−0
@@ -0,0 +1,171 @@
1+import { SymbolView } from "expo-symbols";
2+import { useMemo } from "react";
3+import { Pressable, ScrollView, View } from "react-native";
4+import { useSafeAreaInsets } from "react-native-safe-area-context";
5+
6+import { AppText as Text } from "../../components/AppText";
7+import { EmptyState } from "../../components/EmptyState";
8+import { StatusPill } from "../../components/StatusPill";
9+import { groupProjectsByRepository } from "../../lib/repositoryGroups";
10+import type { ScopedMobileProject, ScopedMobileThread } from "../../lib/scopedEntities";
11+import { scopedProjectKey } from "../../lib/scopedEntities";
12+import { relativeTime } from "../../lib/time";
13+import { ConnectionStatusDot } from "../connection/ConnectionStatusDot";
14+import { threadStatusTone } from "../threads/threadPresentation";
15+
16+export function HomeScreen(props: {
17+ readonly projects: ReadonlyArray<ScopedMobileProject>;
18+ readonly threads: ReadonlyArray<ScopedMobileThread>;
19+ readonly connectionState: "ready" | "connecting" | "reconnecting" | "disconnected" | "idle";
20+ readonly connectionPulse: boolean;
21+ readonly onOpenConnectionEditor: () => void;
22+ readonly onOpenNewTask: () => void;
23+ readonly onSelectThread: (thread: ScopedMobileThread) => void;
24+}) {
25+ const insets = useSafeAreaInsets();
26+ const repositoryGroups = useMemo(
27+ () => groupProjectsByRepository({ projects: props.projects, threads: props.threads }),
28+ [props.projects, props.threads],
29+ );
30+ const projectLabelsByKey = useMemo(() => {
31+ const map = new Map<
32+ string,
33+ {
34+ readonly projectTitle: string;
35+ readonly environmentLabel: string;
36+ }
37+ >();
38+
39+ for (const group of repositoryGroups) {
40+ const primaryProjectTitle = group.projects[0]?.project.title ?? group.title;
41+ for (const projectGroup of group.projects) {
42+ map.set(scopedProjectKey(projectGroup.project.environmentId, projectGroup.project.id), {
43+ projectTitle: primaryProjectTitle,
44+ environmentLabel: projectGroup.project.environmentLabel,
45+ });
46+ }
47+ }
48+
49+ return map;
50+ }, [repositoryGroups]);
51+
52+ return (
53+ <View style={{ flex: 1, backgroundColor: "#f6f4ef" }}>
54+ <ScrollView
55+ showsVerticalScrollIndicator={false}
56+ contentContainerStyle={{
57+ paddingBottom: Math.max(insets.bottom, 24) + 92,
58+ paddingHorizontal: 20,
59+ paddingTop: insets.top + 6,
60+ gap: 16,
61+ }}
62+ >
63+ <View className="flex-row items-center justify-between">
64+ <Text className="text-[34px] font-t3-bold" style={{ color: "#171717" }}>
65+ Recents
66+ </Text>
67+
68+ <Pressable
69+ onPress={props.onOpenConnectionEditor}
70+ className="h-14 min-w-[56px] items-center justify-center rounded-full px-4"
71+ style={{ backgroundColor: "rgba(255,255,255,0.9)" }}
72+ >
73+ <ConnectionStatusDot state={props.connectionState} pulse={props.connectionPulse} />
74+ </Pressable>
75+ </View>
76+
77+ {props.threads.length === 0 ? (
78+ <EmptyState
79+ title="No threads yet"
80+ detail="Create a task to start a new coding session in one of your connected projects."
81+ />
82+ ) : (
83+ <View className="overflow-hidden rounded-[24px]" style={{ backgroundColor: "#ffffff" }}>
84+ {props.threads.map((thread, index) => {
85+ const projectKey = scopedProjectKey(thread.environmentId, thread.projectId);
86+ const projectLabel = projectLabelsByKey.get(projectKey);
87+
88+ return (
89+ <Pressable
90+ key={`${thread.environmentId}:${thread.id}`}
91+ onPress={() => props.onSelectThread(thread)}
92+ style={{
93+ paddingHorizontal: 16,
94+ paddingVertical: 13,
95+ borderTopWidth: index === 0 ? 0 : 1,
96+ borderTopColor: "rgba(23,23,23,0.06)",
97+ }}
98+ >
99+ <View className="flex-row items-start justify-between gap-3">
100+ <View className="flex-1 gap-1.5">
101+ <Text
102+ className="text-[17px] font-t3-bold leading-[24px]"
103+ numberOfLines={2}
104+ style={{ color: "#171717" }}
105+ >
106+ {thread.title}
107+ </Text>
108+ </View>
109+ <StatusPill {...threadStatusTone(thread)} size="compact" />
110+ </View>
111+
112+ <View className="mt-2 flex-row items-center justify-between gap-3">
113+ <View className="flex-1 flex-row items-center gap-2">
114+ <Text
115+ className="text-[13px] font-medium leading-[18px]"
116+ numberOfLines={1}
117+ style={{ color: "#57534e", flexShrink: 1 }}
118+ >
119+ {projectLabel?.projectTitle ?? thread.title}
120+ </Text>
121+ <View
122+ className="h-1 w-1 rounded-full"
123+ style={{ backgroundColor: "rgba(120,113,108,0.45)" }}
124+ />
125+ <Text
126+ className="text-[12px] font-t3-bold uppercase tracking-[0.4px]"
127+ numberOfLines={1}
128+ style={{ color: "#78716c", flexShrink: 1 }}
129+ >
130+ {projectLabel?.environmentLabel ?? "Local"}
131+ </Text>
132+ </View>
133+ <Text
134+ className="text-[12px] font-t3-bold"
135+ style={{ color: "#78716c", fontVariant: ["tabular-nums"] }}
136+ >
137+ {relativeTime(thread.updatedAt ?? thread.createdAt)}
138+ </Text>
139+ </View>
140+ </Pressable>
141+ );
142+ })}
143+ </View>
144+ )}
145+ </ScrollView>
146+
147+ <View
148+ pointerEvents="box-none"
149+ style={{
150+ position: "absolute",
151+ right: 20,
152+ bottom: Math.max(insets.bottom, 18),
153+ }}
154+ >
155+ <Pressable
156+ onPress={props.onOpenNewTask}
157+ className="flex-row items-center gap-3 rounded-full px-6 py-4"
158+ style={{
159+ backgroundColor: "#171717",
160+ boxShadow: "0 20px 36px rgba(23,23,23,0.18)",
161+ }}
162+ >
163+ <SymbolView name="square.and.pencil" size={20} tintColor="#fafaf9" type="monochrome" />
164+ <Text className="text-[18px] font-t3-bold" style={{ color: "#fafaf9" }}>
165+ New task
166+ </Text>
167+ </Pressable>
168+ </View>
169+ </View>
170+ );
171+}
apps/mobile/src/features/threads/NewTaskSheet.tsx+1270−0
@@ -0,0 +1,1270 @@
1+import {
2+ BottomSheetBackdrop,
3+ BottomSheetModal,
4+ BottomSheetScrollView,
5+ BottomSheetView,
6+} from "@gorhom/bottom-sheet";
7+import type {
8+ ClaudeCodeEffort,
9+ GitBranch,
10+ ModelSelection,
11+ ProviderInteractionMode,
12+ RuntimeMode,
13+ ServerConfig as T3ServerConfig,
14+} from "@t3tools/contracts";
15+import {
16+ CLAUDE_CODE_EFFORT_OPTIONS,
17+ DEFAULT_PROVIDER_INTERACTION_MODE,
18+ DEFAULT_RUNTIME_MODE,
19+} from "@t3tools/contracts";
20+import { SymbolView } from "expo-symbols";
21+import { type ReactNode, useCallback, useEffect, useMemo, useRef, useState } from "react";
22+import {
23+ Image,
24+ KeyboardAvoidingView,
25+ Platform,
26+ Pressable,
27+ ScrollView,
28+ View,
29+} from "react-native";
30+import Reanimated, { SlideInLeft, SlideInRight, SlideOutLeft, SlideOutRight } from "react-native-reanimated";
31+import { useSafeAreaInsets } from "react-native-safe-area-context";
32+
33+import { AppText as Text, AppTextInput as TextInput } from "../../components/AppText";
34+import type { DraftComposerImageAttachment } from "../../lib/composerImages";
35+import { pickComposerImages } from "../../lib/composerImages";
36+import { groupProjectsByRepository } from "../../lib/repositoryGroups";
37+import type { ScopedMobileProject, ScopedMobileThread } from "../../lib/scopedEntities";
38+import { scopedProjectKey } from "../../lib/scopedEntities";
39+
40+type WorkspaceMode = "local" | "worktree";
41+
42+type ModelOption = {
43+ readonly key: string;
44+ readonly label: string;
45+ readonly subtitle: string;
46+ readonly providerKey: string;
47+ readonly providerLabel: string;
48+ readonly selection: ModelSelection;
49+};
50+
51+type ProviderGroup = {
52+ readonly providerKey: string;
53+ readonly providerLabel: string;
54+ readonly models: ReadonlyArray<ModelOption>;
55+};
56+
57+function normalizeSelectedWorktreePath(
58+ project: ScopedMobileProject,
59+ branch: GitBranch,
60+): string | null {
61+ if (!branch.worktreePath) {
62+ return null;
63+ }
64+
65+ return branch.worktreePath === project.workspaceRoot ? null : branch.worktreePath;
66+}
67+
68+function branchBadgeLabel(input: {
69+ readonly branch: GitBranch;
70+ readonly project: ScopedMobileProject | null;
71+}): string | null {
72+ if (input.branch.current) {
73+ return "current";
74+ }
75+ if (input.branch.worktreePath && input.branch.worktreePath !== input.project?.workspaceRoot) {
76+ return "worktree";
77+ }
78+ if (input.branch.isDefault) {
79+ return "default";
80+ }
81+ if (input.branch.isRemote) {
82+ return "remote";
83+ }
84+ return null;
85+}
86+
87+function providerDisplayLabel(provider: string): string {
88+ if (provider === "codex") return "Codex";
89+ if (provider === "claudeAgent") return "Claude";
90+ return provider;
91+}
92+
93+function buildModelOptions(
94+ config: T3ServerConfig | null | undefined,
95+ fallbackModelSelection: ModelSelection | null,
96+): ReadonlyArray<ModelOption> {
97+ const options = new Map<string, ModelOption>();
98+
99+ for (const provider of config?.providers ?? []) {
100+ if (!provider.enabled || !provider.installed || provider.auth.status === "unauthenticated") {
101+ continue;
102+ }
103+
104+ const pLabel = providerDisplayLabel(provider.provider);
105+ for (const model of provider.models) {
106+ const key = `${provider.provider}:${model.slug}`;
107+ options.set(key, {
108+ key,
109+ label: model.name,
110+ subtitle: pLabel,
111+ providerKey: provider.provider,
112+ providerLabel: pLabel,
113+ selection: {
114+ provider: provider.provider,
115+ model: model.slug,
116+ },
117+ });
118+ }
119+ }
120+
121+ if (fallbackModelSelection) {
122+ const key = `${fallbackModelSelection.provider}:${fallbackModelSelection.model}`;
123+ if (!options.has(key)) {
124+ const pLabel = providerDisplayLabel(fallbackModelSelection.provider);
125+ options.set(key, {
126+ key,
127+ label: fallbackModelSelection.model,
128+ subtitle: pLabel,
129+ providerKey: fallbackModelSelection.provider,
130+ providerLabel: pLabel,
131+ selection: fallbackModelSelection,
132+ });
133+ }
134+ }
135+
136+ return [...options.values()];
137+}
138+
139+function groupByProvider(options: ReadonlyArray<ModelOption>): ReadonlyArray<ProviderGroup> {
140+ const groups = new Map<string, { providerLabel: string; models: ModelOption[] }>();
141+ for (const option of options) {
142+ const existing = groups.get(option.providerKey);
143+ if (existing) {
144+ existing.models.push(option);
145+ } else {
146+ groups.set(option.providerKey, {
147+ providerLabel: option.providerLabel,
148+ models: [option],
149+ });
150+ }
151+ }
152+ return [...groups.entries()].map(([providerKey, group]) => ({
153+ providerKey,
154+ providerLabel: group.providerLabel,
155+ models: group.models,
156+ }));
157+}
158+
159+function compactModelLabel(option: ModelOption | null): string {
160+ if (!option) {
161+ return "AI";
162+ }
163+
164+ const versionMatch = option.selection.model.match(/(\d+(?:\.\d+)?)/);
165+ if (versionMatch?.[1]) {
166+ return versionMatch[1];
167+ }
168+
169+ const lastToken = option.label.split(/\s+/).at(-1)?.trim();
170+ return lastToken && lastToken.length <= 6 ? lastToken : "AI";
171+}
172+
173+function ControlPill(props: {
174+ readonly icon: React.ComponentProps<typeof SymbolView>["name"];
175+ readonly label?: string;
176+ readonly onPress: () => void;
177+ readonly variant?: "circle" | "pill" | "primary";
178+ readonly disabled?: boolean;
179+}) {
180+ const variant = props.variant ?? "circle";
181+ const backgroundColor =
182+ variant === "primary"
183+ ? props.disabled
184+ ? "rgba(23,23,23,0.14)"
185+ : "#171717"
186+ : "rgba(23,23,23,0.06)";
187+ const iconTintColor = variant === "primary" ? "#fafaf9" : "#171717";
188+ const textColor = variant === "primary" ? "#fafaf9" : "#171717";
189+
190+ return (
191+ <Pressable
192+ onPress={props.onPress}
193+ disabled={props.disabled}
194+ className={
195+ variant === "circle"
196+ ? "h-11 w-11 items-center justify-center rounded-full"
197+ : variant === "primary"
198+ ? "h-11 flex-row items-center justify-center gap-2 rounded-full px-5"
199+ : "h-11 flex-row items-center justify-center gap-2 rounded-full px-3.5"
200+ }
201+ style={{ backgroundColor }}
202+ >
203+ <SymbolView name={props.icon} size={16} tintColor={iconTintColor} type="monochrome" />
204+ {props.label ? (
205+ <Text className="text-center text-[12px] font-t3-bold" style={{ color: textColor }}>
206+ {props.label}
207+ </Text>
208+ ) : null}
209+ </Pressable>
210+ );
211+}
212+
213+function BottomSelectorSheet(props: {
214+ readonly visible: boolean;
215+ readonly title: string;
216+ readonly onClose: () => void;
217+ readonly children: ReactNode;
218+ readonly snapPoints?: ReadonlyArray<string | number>;
219+}) {
220+ const sheetRef = useRef<BottomSheetModal>(null);
221+ const snapPoints = useMemo<(string | number)[]>(
222+ () => [...(props.snapPoints ?? ["72%"])],
223+ [props.snapPoints],
224+ );
225+ const renderBackdrop = useCallback(
226+ (backdropProps: React.ComponentProps<typeof BottomSheetBackdrop>) => (
227+ <BottomSheetBackdrop
228+ {...backdropProps}
229+ appearsOnIndex={0}
230+ disappearsOnIndex={-1}
231+ opacity={0.18}
232+ pressBehavior="close"
233+ />
234+ ),
235+ [],
236+ );
237+
238+ useEffect(() => {
239+ const sheet = sheetRef.current;
240+ if (!sheet) {
241+ return;
242+ }
243+
244+ if (props.visible) {
245+ sheet.present();
246+ return;
247+ }
248+
249+ sheet.dismiss();
250+ }, [props.visible]);
251+
252+ return (
253+ <BottomSheetModal
254+ ref={sheetRef}
255+ stackBehavior="push"
256+ index={0}
257+ snapPoints={snapPoints}
258+ enableDynamicSizing={false}
259+ enablePanDownToClose
260+ backdropComponent={renderBackdrop}
261+ onDismiss={props.onClose}
262+ backgroundStyle={{ backgroundColor: "rgba(248,246,241,0.98)" }}
263+ handleIndicatorStyle={{ backgroundColor: "rgba(120,113,108,0.32)" }}
264+ >
265+ <BottomSheetView style={{ flex: 1 }} className="px-5 pt-1">
266+ <View className="items-center pb-4">
267+ <Text className="text-[24px] font-t3-bold" style={{ color: "#171717" }}>
268+ {props.title}
269+ </Text>
270+ </View>
271+ {props.children}
272+ </BottomSheetView>
273+ </BottomSheetModal>
274+ );
275+}
276+
277+// ---------------------------------------------------------------------------
278+// Main NewTaskSheet — two-step: project picker → task form
279+// ---------------------------------------------------------------------------
280+
281+export function NewTaskSheet(props: {
282+ readonly visible: boolean;
283+ readonly projects: ReadonlyArray<ScopedMobileProject>;
284+ readonly threads: ReadonlyArray<ScopedMobileThread>;
285+ readonly serverConfigByEnvironmentId: Readonly<Record<string, T3ServerConfig | null>>;
286+ readonly onRequestClose: () => void;
287+ readonly onCreateThreadWithOptions: (input: {
288+ readonly project: ScopedMobileProject;
289+ readonly modelSelection: ModelSelection;
290+ readonly envMode: WorkspaceMode;
291+ readonly branch: string | null;
292+ readonly worktreePath: string | null;
293+ readonly runtimeMode: RuntimeMode;
294+ readonly interactionMode: ProviderInteractionMode;
295+ readonly initialMessageText: string;
296+ readonly initialAttachments: ReadonlyArray<DraftComposerImageAttachment>;
297+ }) => Promise<void>;
298+ readonly onListProjectBranches: (
299+ project: ScopedMobileProject,
300+ ) => Promise<ReadonlyArray<GitBranch>>;
301+}) {
302+ const insets = useSafeAreaInsets();
303+ const sheetRef = useRef<BottomSheetModal>(null);
304+
305+ // Step state
306+ const [step, setStep] = useState<"project" | "task">("project");
307+
308+ // Project picker state
309+ const repositoryGroups = useMemo(
310+ () => groupProjectsByRepository({ projects: props.projects, threads: props.threads }),
311+ [props.projects, props.threads],
312+ );
313+ const logicalProjects = useMemo(
314+ () =>
315+ repositoryGroups
316+ .map((group) => {
317+ const primaryProject = group.projects[0]?.project;
318+ if (!primaryProject) return null;
319+ return { key: group.key, project: primaryProject };
320+ })
321+ .filter((entry) => entry !== null),
322+ [repositoryGroups],
323+ );
324+
325+ // Task form state
326+ const [selectedEnvironmentId, setSelectedEnvironmentId] = useState(
327+ props.projects[0]?.environmentId ?? "",
328+ );
329+ const [selectedProjectKey, setSelectedProjectKey] = useState<string | null>(null);
330+ const [selectedModelKey, setSelectedModelKey] = useState<string | null>(null);
331+ const [workspaceMode, setWorkspaceMode] = useState<WorkspaceMode>("local");
332+ const [selectedBranchName, setSelectedBranchName] = useState<string | null>(null);
333+ const [selectedWorktreePath, setSelectedWorktreePath] = useState<string | null>(null);
334+ const [prompt, setPrompt] = useState("");
335+ const [attachments, setAttachments] = useState<ReadonlyArray<DraftComposerImageAttachment>>([]);
336+ const [submitting, setSubmitting] = useState(false);
337+ const [projectPickerVisible, setProjectPickerVisible] = useState(false);
338+ const [environmentPickerVisible, setEnvironmentPickerVisible] = useState(false);
339+ const [modelPickerVisible, setModelPickerVisible] = useState(false);
340+ const [optionsPickerVisible, setOptionsPickerVisible] = useState(false);
341+ const [branchPickerVisible, setBranchPickerVisible] = useState(false);
342+ const [branchQuery, setBranchQuery] = useState("");
343+ const [branchesLoading, setBranchesLoading] = useState(false);
344+ const [availableBranches, setAvailableBranches] = useState<ReadonlyArray<GitBranch>>([]);
345+ const [runtimeMode, setRuntimeMode] = useState<RuntimeMode>(DEFAULT_RUNTIME_MODE);
346+ const [interactionMode, setInteractionMode] =
347+ useState<ProviderInteractionMode>(DEFAULT_PROVIDER_INTERACTION_MODE);
348+ const [effort, setEffort] = useState<ClaudeCodeEffort>("high");
349+ const [fastMode, setFastMode] = useState(false);
350+ const [contextWindow, setContextWindow] = useState("1M");
351+ const [expandedProvider, setExpandedProvider] = useState<string | null>(null);
352+
353+ const environments = useMemo(
354+ () =>
355+ [
356+ ...new Map(
357+ props.projects.map((project) => [project.environmentId, project.environmentLabel]),
358+ ).entries(),
359+ ].map(([environmentId, environmentLabel]) => ({
360+ environmentId,
361+ environmentLabel,
362+ })),
363+ [props.projects],
364+ );
365+
366+ const projectsForEnvironment = useMemo(
367+ () => props.projects.filter((project) => project.environmentId === selectedEnvironmentId),
368+ [props.projects, selectedEnvironmentId],
369+ );
370+ const logicalProjectsForEnvironment = useMemo(
371+ () =>
372+ groupProjectsByRepository({ projects: projectsForEnvironment, threads: [] })
373+ .map((group) => group.projects[0]?.project ?? null)
374+ .filter((project) => project !== null),
375+ [projectsForEnvironment],
376+ );
377+
378+ const selectedProject =
379+ projectsForEnvironment.find(
380+ (project) => scopedProjectKey(project.environmentId, project.id) === selectedProjectKey,
381+ ) ??
382+ projectsForEnvironment[0] ??
383+ null;
384+
385+ const modelOptions = useMemo(
386+ () =>
387+ buildModelOptions(
388+ selectedProject
389+ ? (props.serverConfigByEnvironmentId[selectedProject.environmentId] ?? null)
390+ : null,
391+ selectedProject?.defaultModelSelection ?? null,
392+ ),
393+ [props.serverConfigByEnvironmentId, selectedProject],
394+ );
395+
396+ const selectedModel =
397+ modelOptions.find((option) => option.key === selectedModelKey)?.selection ??
398+ selectedProject?.defaultModelSelection ??
399+ modelOptions[0]?.selection ??
400+ null;
401+ const selectedModelOption =
402+ modelOptions.find(
403+ (option) =>
404+ selectedModel &&
405+ option.selection.provider === selectedModel.provider &&
406+ option.selection.model === selectedModel.model,
407+ ) ?? null;
408+
409+ const providerGroups = useMemo(() => groupByProvider(modelOptions), [modelOptions]);
410+
411+ const filteredBranches = useMemo(() => {
412+ const query = branchQuery.trim().toLowerCase();
413+ if (query.length === 0) return availableBranches;
414+ return availableBranches.filter((branch) => branch.name.toLowerCase().includes(query));
415+ }, [availableBranches, branchQuery]);
416+
417+ const headerSubtitle = useMemo(() => {
418+ if (!selectedProject) return null;
419+ if (workspaceMode === "worktree") {
420+ return `${selectedProject.title} @ ${selectedBranchName ?? "Select base branch"}`;
421+ }
422+ if (selectedBranchName) {
423+ return `${selectedProject.title} @ ${selectedBranchName}`;
424+ }
425+ return `${selectedProject.title} · ${selectedProject.environmentLabel}`;
426+ }, [selectedBranchName, selectedProject, workspaceMode]);
427+
428+ const renderBackdrop = useCallback(
429+ (backdropProps: React.ComponentProps<typeof BottomSheetBackdrop>) => (
430+ <BottomSheetBackdrop
431+ {...backdropProps}
432+ appearsOnIndex={0}
433+ disappearsOnIndex={-1}
434+ opacity={0.18}
435+ pressBehavior="close"
436+ />
437+ ),
438+ [],
439+ );
440+
441+ // Present / dismiss / reset
442+ useEffect(() => {
443+ const sheet = sheetRef.current;
444+ if (!sheet) return;
445+
446+ if (props.visible) {
447+ // Reset state
448+ setPrompt("");
449+ setAttachments([]);
450+ setSelectedModelKey(null);
451+ setSelectedBranchName(null);
452+ setSelectedWorktreePath(null);
453+ setWorkspaceMode("local");
454+ setRuntimeMode(DEFAULT_RUNTIME_MODE);
455+ setInteractionMode(DEFAULT_PROVIDER_INTERACTION_MODE);
456+ setEffort("high");
457+ setFastMode(false);
458+ setContextWindow("1M");
459+ setExpandedProvider(null);
460+ setSubmitting(false);
461+
462+ // If only one project, skip to step 2
463+ if (logicalProjects.length === 1) {
464+ const only = logicalProjects[0]!;
465+ setSelectedEnvironmentId(only.project.environmentId);
466+ setSelectedProjectKey(scopedProjectKey(only.project.environmentId, only.project.id));
467+ setStep("task");
468+ } else {
469+ setStep("project");
470+ }
471+
472+ sheet.present();
473+ } else {
474+ sheet.dismiss();
475+ }
476+ // eslint-disable-next-line react-hooks/exhaustive-deps
477+ }, [props.visible]);
478+
479+ // Step transitions
480+ const goToTask = useCallback((project: ScopedMobileProject) => {
481+ setSelectedEnvironmentId(project.environmentId);
482+ setSelectedProjectKey(scopedProjectKey(project.environmentId, project.id));
483+ setStep("task");
484+ }, []);
485+
486+ const goBackToProjects = useCallback(() => {
487+ setStep("project");
488+ }, []);
489+
490+ // Actions
491+ async function handlePickImages(): Promise<void> {
492+ const result = await pickComposerImages({ existingCount: attachments.length });
493+ if (result.images.length > 0) {
494+ setAttachments((current) => [...current, ...result.images]);
495+ }
496+ }
497+
498+ async function openBranchPicker(): Promise<void> {
499+ if (!selectedProject) return;
500+ setBranchPickerVisible(true);
501+ setBranchesLoading(true);
502+ try {
503+ const branches = await props.onListProjectBranches(selectedProject);
504+ setAvailableBranches(branches);
505+ if (workspaceMode === "worktree" && !selectedBranchName) {
506+ const preferredBranch =
507+ branches.find((branch) => branch.current)?.name ??
508+ branches.find((branch) => branch.isDefault)?.name ??
509+ null;
510+ if (preferredBranch) setSelectedBranchName(preferredBranch);
511+ }
512+ } finally {
513+ setBranchesLoading(false);
514+ }
515+ }
516+
517+ async function handleStart(): Promise<void> {
518+ if (
519+ !selectedProject ||
520+ !selectedModel ||
521+ prompt.trim().length === 0 ||
522+ submitting ||
523+ (workspaceMode === "worktree" && !selectedBranchName)
524+ ) {
525+ return;
526+ }
527+
528+ setSubmitting(true);
529+ try {
530+ const modelWithOptions: ModelSelection =
531+ selectedModel.provider === "claudeAgent"
532+ ? {
533+ ...selectedModel,
534+ options: { effort, fastMode: fastMode || undefined, contextWindow },
535+ }
536+ : selectedModel.provider === "codex"
537+ ? { ...selectedModel, options: { fastMode: fastMode || undefined } }
538+ : selectedModel;
539+
540+ await props.onCreateThreadWithOptions({
541+ project: selectedProject,
542+ modelSelection: modelWithOptions,
543+ envMode: workspaceMode,
544+ branch: selectedBranchName,
545+ worktreePath: workspaceMode === "worktree" ? null : selectedWorktreePath,
546+ runtimeMode,
547+ interactionMode,
548+ initialMessageText: prompt.trim(),
549+ initialAttachments: attachments,
550+ });
551+ props.onRequestClose();
552+ } finally {
553+ setSubmitting(false);
554+ }
555+ }
556+
557+ return (
558+ <>
559+ <BottomSheetModal
560+ ref={sheetRef}
561+ stackBehavior="push"
562+ index={0}
563+ snapPoints={["92%"]}
564+ enableDynamicSizing={false}
565+ enablePanDownToClose
566+ backdropComponent={renderBackdrop}
567+ onDismiss={props.onRequestClose}
568+ backgroundStyle={{ backgroundColor: "rgba(250,248,242,0.98)" }}
569+ handleIndicatorStyle={{ backgroundColor: "rgba(120,113,108,0.32)" }}
570+ >
571+ <BottomSheetView style={{ flex: 1, overflow: "hidden" }}>
572+ {step === "project" ? (
573+ <Reanimated.View
574+ key="step-project"
575+ entering={SlideInLeft.duration(280)}
576+ exiting={SlideOutLeft.duration(280)}
577+ style={{ flex: 1 }}
578+ >
579+ <View className="items-center gap-1 px-5 pb-4 pt-1">
580+ <Text
581+ className="text-[12px] font-t3-bold uppercase"
582+ style={{ color: "#78716c", letterSpacing: 1 }}
583+ >
584+ New task
585+ </Text>
586+ <Text className="text-[28px] font-t3-bold" style={{ color: "#171717" }}>
587+ Choose project
588+ </Text>
589+ </View>
590+
591+ <BottomSheetScrollView
592+ showsVerticalScrollIndicator={false}
593+ contentContainerStyle={{
594+ paddingHorizontal: 20,
595+ paddingBottom: Math.max(insets.bottom, 18) + 18,
596+ }}
597+ >
598+ <View
599+ className="overflow-hidden rounded-[24px]"
600+ style={{ backgroundColor: "#ffffff" }}
601+ >
602+ {logicalProjects.map((entry, index) => (
603+ <Pressable
604+ key={entry.key}
605+ onPress={() => goToTask(entry.project)}
606+ style={{
607+ paddingHorizontal: 16,
608+ paddingVertical: 18,
609+ borderTopWidth: index === 0 ? 0 : 1,
610+ borderTopColor: "rgba(23,23,23,0.06)",
611+ }}
612+ >
613+ <View className="flex-row items-center justify-between gap-3">
614+ <View className="flex-1">
615+ <Text
616+ className="text-[18px] font-t3-bold"
617+ style={{ color: "#171717" }}
618+ >
619+ {entry.project.title}
620+ </Text>
621+ </View>
622+ <SymbolView
623+ name="chevron.right"
624+ size={14}
625+ tintColor="rgba(23,23,23,0.25)"
626+ type="monochrome"
627+ />
628+ </View>
629+ </Pressable>
630+ ))}
631+ </View>
632+ </BottomSheetScrollView>
633+ </Reanimated.View>
634+ ) : (
635+ <Reanimated.View
636+ key="step-task"
637+ entering={SlideInRight.duration(280)}
638+ exiting={SlideOutRight.duration(280)}
639+ style={{ flex: 1 }}
640+ >
641+ <KeyboardAvoidingView
642+ behavior={Platform.OS === "ios" ? "padding" : undefined}
643+ style={{ flex: 1 }}
644+ >
645+ <View className="flex-1">
646+ {/* Header with back button */}
647+ <View className="items-center gap-1 px-5 pb-3 pt-1">
648+ {logicalProjects.length > 1 ? (
649+ <Pressable
650+ className="absolute left-3 top-1 h-9 w-9 items-center justify-center rounded-full"
651+ style={{ backgroundColor: "rgba(23,23,23,0.06)", zIndex: 1 }}
652+ onPress={goBackToProjects}
653+ >
654+ <SymbolView
655+ name="chevron.left"
656+ size={16}
657+ tintColor="#171717"
658+ type="monochrome"
659+ weight="medium"
660+ />
661+ </Pressable>
662+ ) : null}
663+ <Text
664+ className="text-[12px] font-t3-bold uppercase"
665+ style={{ color: "#78716c", letterSpacing: 1 }}
666+ >
667+ New task
668+ </Text>
669+ <Text className="text-[28px] font-t3-bold" style={{ color: "#171717" }}>
670+ {selectedProject?.title ?? "New task"}
671+ </Text>
672+ </View>
673+
674+ <BottomSheetScrollView
675+ keyboardShouldPersistTaps="handled"
676+ showsVerticalScrollIndicator={false}
677+ contentContainerStyle={{
678+ paddingHorizontal: 20,
679+ paddingTop: 8,
680+ paddingBottom: 20,
681+ minHeight: 420,
682+ }}
683+ >
684+ <TextInput
685+ multiline
686+ value={prompt}
687+ onChangeText={setPrompt}
688+ placeholderTextColor="#9ca3af"
689+ placeholder={
690+ selectedProject
691+ ? `Describe a coding task in ${selectedProject.title}`
692+ : "Describe a coding task"
693+ }
694+ textAlignVertical="top"
695+ className="min-h-[260px] border-0 bg-transparent px-0 py-0 text-[18px] leading-[28px]"
696+ style={{
697+ borderWidth: 0,
698+ backgroundColor: "transparent",
699+ color: "#171717",
700+ fontSize: 18,
701+ lineHeight: 28,
702+ minHeight: 280,
703+ }}
704+ />
705+
706+ {attachments.length > 0 ? (
707+ <ScrollView horizontal showsHorizontalScrollIndicator={false}>
708+ <View className="flex-row gap-3">
709+ {attachments.map((attachment) => (
710+ <View key={attachment.id} className="gap-2">
711+ <Image
712+ source={{ uri: attachment.previewUri }}
713+ className="h-[88px] w-[88px] rounded-[20px]"
714+ resizeMode="cover"
715+ />
716+ <Pressable
717+ className="items-center rounded-full px-3 py-2"
718+ style={{ backgroundColor: "rgba(23,23,23,0.06)" }}
719+ onPress={() =>
720+ setAttachments((current) =>
721+ current.filter((candidate) => candidate.id !== attachment.id),
722+ )
723+ }
724+ >
725+ <Text
726+ className="text-[12px] font-t3-bold"
727+ style={{ color: "#171717" }}
728+ >
729+ Remove
730+ </Text>
731+ </Pressable>
732+ </View>
733+ ))}
734+ </View>
735+ </ScrollView>
736+ ) : null}
737+ </BottomSheetScrollView>
738+
739+ <View
740+ style={{
741+ borderTopWidth: 1,
742+ borderTopColor: "rgba(23,23,23,0.08)",
743+ paddingBottom: Math.max(insets.bottom, 10),
744+ }}
745+ >
746+ <View className="flex-row items-center justify-between gap-2 px-4 pb-1 pt-4">
747+ <ControlPill icon="plus" onPress={() => void handlePickImages()} />
748+ <ControlPill
749+ icon="dial.low"
750+ label={compactModelLabel(selectedModelOption)}
751+ onPress={() => setModelPickerVisible(true)}
752+ variant="pill"
753+ />
754+ <ControlPill
755+ icon="slider.horizontal.3"
756+ onPress={() => setOptionsPickerVisible(true)}
757+ />
758+ <ControlPill
759+ icon="desktopcomputer"
760+ onPress={() => setEnvironmentPickerVisible(true)}
761+ />
762+ <ControlPill
763+ icon="point.topleft.down.curvedto.point.bottomright.up"
764+ onPress={() => void openBranchPicker()}
765+ />
766+ <ControlPill
767+ icon="arrow.up"
768+ label={submitting ? "Starting" : "Start"}
769+ onPress={() => void handleStart()}
770+ variant="primary"
771+ disabled={
772+ !selectedProject ||
773+ !selectedModel ||
774+ prompt.trim().length === 0 ||
775+ submitting ||
776+ (workspaceMode === "worktree" && !selectedBranchName)
777+ }
778+ />
779+ </View>
780+ </View>
781+ </View>
782+ </KeyboardAvoidingView>
783+ </Reanimated.View>
784+ )}
785+ </BottomSheetView>
786+ </BottomSheetModal>
787+
788+ {/* Sub-pickers (environment, project, model, branch) */}
789+ <BottomSelectorSheet
790+ visible={environmentPickerVisible}
791+ title="Environment"
792+ onClose={() => setEnvironmentPickerVisible(false)}
793+ >
794+ <ScrollView showsVerticalScrollIndicator={false}>
795+ <View className="overflow-hidden rounded-[24px]" style={{ backgroundColor: "#ffffff" }}>
796+ {environments.map((environment, index) => (
797+ <Pressable
798+ key={environment.environmentId}
799+ onPress={() => {
800+ setSelectedEnvironmentId(environment.environmentId);
801+ setSelectedProjectKey(null);
802+ setSelectedBranchName(null);
803+ setSelectedWorktreePath(null);
804+ setEnvironmentPickerVisible(false);
805+ }}
806+ style={{
807+ paddingHorizontal: 16,
808+ paddingVertical: 18,
809+ borderTopWidth: index === 0 ? 0 : 1,
810+ borderTopColor: "rgba(23,23,23,0.06)",
811+ }}
812+ >
813+ <Text className="text-[17px] font-medium" style={{ color: "#171717" }}>
814+ {environment.environmentLabel}
815+ </Text>
816+ </Pressable>
817+ ))}
818+ </View>
819+ </ScrollView>
820+ </BottomSelectorSheet>
821+
822+ <BottomSelectorSheet
823+ visible={projectPickerVisible}
824+ title="Project"
825+ onClose={() => setProjectPickerVisible(false)}
826+ >
827+ <ScrollView showsVerticalScrollIndicator={false}>
828+ <View className="overflow-hidden rounded-[24px]" style={{ backgroundColor: "#ffffff" }}>
829+ {logicalProjectsForEnvironment.map((project, index) => (
830+ <Pressable
831+ key={`${project.environmentId}:${project.id}`}
832+ onPress={() => {
833+ setSelectedProjectKey(scopedProjectKey(project.environmentId, project.id));
834+ setSelectedBranchName(null);
835+ setSelectedWorktreePath(null);
836+ setProjectPickerVisible(false);
837+ }}
838+ style={{
839+ paddingHorizontal: 16,
840+ paddingVertical: 18,
841+ borderTopWidth: index === 0 ? 0 : 1,
842+ borderTopColor: "rgba(23,23,23,0.06)",
843+ }}
844+ >
845+ <Text className="text-[17px] font-medium" style={{ color: "#171717" }}>
846+ {project.title}
847+ </Text>
848+ </Pressable>
849+ ))}
850+ </View>
851+ </ScrollView>
852+ </BottomSelectorSheet>
853+
854+ {/* Model picker — provider + model only */}
855+ <BottomSelectorSheet
856+ visible={modelPickerVisible}
857+ title="Model"
858+ onClose={() => {
859+ setModelPickerVisible(false);
860+ setExpandedProvider(null);
861+ }}
862+ >
863+ <ScrollView showsVerticalScrollIndicator={false}>
864+ <View className="gap-2">
865+ {providerGroups.map((group) => {
866+ const isExpanded = expandedProvider === group.providerKey;
867+ const hasSelected = group.models.some(
868+ (m) =>
869+ selectedModel &&
870+ m.selection.provider === selectedModel.provider &&
871+ m.selection.model === selectedModel.model,
872+ );
873+
874+ return (
875+ <View key={group.providerKey}>
876+ <Pressable
877+ onPress={() =>
878+ setExpandedProvider(isExpanded ? null : group.providerKey)
879+ }
880+ className="flex-row items-center justify-between rounded-[16px] px-4 py-3.5"
881+ style={{
882+ backgroundColor: isExpanded
883+ ? "rgba(23,23,23,0.06)"
884+ : hasSelected
885+ ? "rgba(23,23,23,0.04)"
886+ : "#ffffff",
887+ borderWidth: hasSelected && !isExpanded ? 1 : 0,
888+ borderColor: "rgba(23,23,23,0.08)",
889+ }}
890+ >
891+ <View className="flex-row items-center gap-2.5">
892+ <Text className="text-[17px] font-t3-bold" style={{ color: "#171717" }}>
893+ {group.providerLabel}
894+ </Text>
895+ {hasSelected && !isExpanded ? (
896+ <Text className="text-[13px]" style={{ color: "#78716c" }}>
897+ {selectedModelOption?.label}
898+ </Text>
899+ ) : null}
900+ </View>
901+ <SymbolView
902+ name={isExpanded ? "chevron.up" : "chevron.right"}
903+ size={14}
904+ tintColor="rgba(23,23,23,0.25)"
905+ type="monochrome"
906+ />
907+ </Pressable>
908+
909+ {isExpanded ? (
910+ <View
911+ className="mt-1.5 overflow-hidden rounded-[14px]"
912+ style={{ backgroundColor: "#ffffff" }}
913+ >
914+ {group.models.map((option, index) => {
915+ const isSelected =
916+ selectedModel &&
917+ option.selection.provider === selectedModel.provider &&
918+ option.selection.model === selectedModel.model;
919+ return (
920+ <Pressable
921+ key={option.key}
922+ onPress={() => {
923+ setSelectedModelKey(option.key);
924+ setModelPickerVisible(false);
925+ setExpandedProvider(null);
926+ }}
927+ style={{
928+ paddingHorizontal: 16,
929+ paddingVertical: 14,
930+ borderTopWidth: index === 0 ? 0 : 1,
931+ borderTopColor: "rgba(23,23,23,0.06)",
932+ backgroundColor: isSelected ? "rgba(23,23,23,0.04)" : "transparent",
933+ }}
934+ >
935+ <View className="flex-row items-center justify-between">
936+ <Text className="text-[16px] font-medium" style={{ color: "#171717" }}>
937+ {option.label}
938+ </Text>
939+ {isSelected ? (
940+ <SymbolView name="checkmark" size={16} tintColor="#2563eb" type="monochrome" />
941+ ) : null}
942+ </View>
943+ </Pressable>
944+ );
945+ })}
946+ </View>
947+ ) : null}
948+ </View>
949+ );
950+ })}
951+ </View>
952+ </ScrollView>
953+ </BottomSelectorSheet>
954+
955+ {/* Options picker — effort, fast mode, context window, mode, access */}
956+ <BottomSelectorSheet
957+ visible={optionsPickerVisible}
958+ title="Options"
959+ onClose={() => setOptionsPickerVisible(false)}
960+ snapPoints={["82%"]}
961+ >
962+ <ScrollView showsVerticalScrollIndicator={false}>
963+ <View className="gap-5 pb-4">
964+ {/* ── Effort ── */}
965+ <View className="gap-2">
966+ <Text
967+ className="text-[11px] font-t3-bold uppercase"
968+ style={{ color: "#78716c", letterSpacing: 0.8 }}
969+ >
970+ Effort
971+ </Text>
972+ <View className="overflow-hidden rounded-[14px]" style={{ backgroundColor: "#ffffff" }}>
973+ {CLAUDE_CODE_EFFORT_OPTIONS.map((level, index) => (
974+ <Pressable
975+ key={level}
976+ onPress={() => setEffort(level)}
977+ style={{
978+ paddingHorizontal: 16,
979+ paddingVertical: 13,
980+ borderTopWidth: index === 0 ? 0 : 1,
981+ borderTopColor: "rgba(23,23,23,0.06)",
982+ }}
983+ >
984+ <View className="flex-row items-center justify-between">
985+ <Text className="text-[15px] font-medium capitalize" style={{ color: "#171717" }}>
986+ {level}{level === "high" ? " (default)" : ""}
987+ </Text>
988+ {effort === level ? (
989+ <SymbolView name="checkmark" size={15} tintColor="#2563eb" type="monochrome" />
990+ ) : null}
991+ </View>
992+ </Pressable>
993+ ))}
994+ </View>
995+ </View>
996+
997+ {/* ── Fast Mode ── */}
998+ <View className="gap-2">
999+ <Text
1000+ className="text-[11px] font-t3-bold uppercase"
1001+ style={{ color: "#78716c", letterSpacing: 0.8 }}
1002+ >
1003+ Fast Mode
1004+ </Text>
1005+ <View className="overflow-hidden rounded-[14px]" style={{ backgroundColor: "#ffffff" }}>
1006+ {([false, true] as const).map((value, index) => (
1007+ <Pressable
1008+ key={String(value)}
1009+ onPress={() => setFastMode(value)}
1010+ style={{
1011+ paddingHorizontal: 16,
1012+ paddingVertical: 13,
1013+ borderTopWidth: index === 0 ? 0 : 1,
1014+ borderTopColor: "rgba(23,23,23,0.06)",
1015+ }}
1016+ >
1017+ <View className="flex-row items-center justify-between">
1018+ <Text className="text-[15px] font-medium" style={{ color: "#171717" }}>
1019+ {value ? "On" : "Off"}
1020+ </Text>
1021+ {fastMode === value ? (
1022+ <SymbolView name="checkmark" size={15} tintColor="#2563eb" type="monochrome" />
1023+ ) : null}
1024+ </View>
1025+ </Pressable>
1026+ ))}
1027+ </View>
1028+ </View>
1029+
1030+ {/* ── Context Window ── */}
1031+ <View className="gap-2">
1032+ <Text
1033+ className="text-[11px] font-t3-bold uppercase"
1034+ style={{ color: "#78716c", letterSpacing: 0.8 }}
1035+ >
1036+ Context Window
1037+ </Text>
1038+ <View className="overflow-hidden rounded-[14px]" style={{ backgroundColor: "#ffffff" }}>
1039+ {(["200k", "1M"] as const).map((value, index) => (
1040+ <Pressable
1041+ key={value}
1042+ onPress={() => setContextWindow(value)}
1043+ style={{
1044+ paddingHorizontal: 16,
1045+ paddingVertical: 13,
1046+ borderTopWidth: index === 0 ? 0 : 1,
1047+ borderTopColor: "rgba(23,23,23,0.06)",
1048+ }}
1049+ >
1050+ <View className="flex-row items-center justify-between">
1051+ <Text className="text-[15px] font-medium" style={{ color: "#171717" }}>
1052+ {value}{value === "1M" ? " (default)" : ""}
1053+ </Text>
1054+ {contextWindow === value ? (
1055+ <SymbolView name="checkmark" size={15} tintColor="#2563eb" type="monochrome" />
1056+ ) : null}
1057+ </View>
1058+ </Pressable>
1059+ ))}
1060+ </View>
1061+ </View>
1062+
1063+ {/* ── Mode ── */}
1064+ <View className="gap-2">
1065+ <Text
1066+ className="text-[11px] font-t3-bold uppercase"
1067+ style={{ color: "#78716c", letterSpacing: 0.8 }}
1068+ >
1069+ Mode
1070+ </Text>
1071+ <View className="overflow-hidden rounded-[14px]" style={{ backgroundColor: "#ffffff" }}>
1072+ {(
1073+ [
1074+ { value: "default" as const, label: "Chat" },
1075+ { value: "plan" as const, label: "Plan" },
1076+ ] as const
1077+ ).map((option, index) => (
1078+ <Pressable
1079+ key={option.value}
1080+ onPress={() => setInteractionMode(option.value)}
1081+ style={{
1082+ paddingHorizontal: 16,
1083+ paddingVertical: 13,
1084+ borderTopWidth: index === 0 ? 0 : 1,
1085+ borderTopColor: "rgba(23,23,23,0.06)",
1086+ }}
1087+ >
1088+ <View className="flex-row items-center justify-between">
1089+ <Text className="text-[15px] font-medium" style={{ color: "#171717" }}>
1090+ {option.label}
1091+ </Text>
1092+ {interactionMode === option.value ? (
1093+ <SymbolView name="checkmark" size={15} tintColor="#2563eb" type="monochrome" />
1094+ ) : null}
1095+ </View>
1096+ </Pressable>
1097+ ))}
1098+ </View>
1099+ </View>
1100+
1101+ {/* ── Access ── */}
1102+ <View className="gap-2">
1103+ <Text
1104+ className="text-[11px] font-t3-bold uppercase"
1105+ style={{ color: "#78716c", letterSpacing: 0.8 }}
1106+ >
1107+ Access
1108+ </Text>
1109+ <View className="overflow-hidden rounded-[14px]" style={{ backgroundColor: "#ffffff" }}>
1110+ {(
1111+ [
1112+ { value: "approval-required" as const, label: "Supervised", desc: "Ask before commands and file changes" },
1113+ { value: "auto-accept-edits" as const, label: "Auto-accept edits", desc: "Auto-approve edits, ask before other actions" },
1114+ { value: "full-access" as const, label: "Full access", desc: "Allow commands and edits without prompts" },
1115+ ] as const
1116+ ).map((option, index) => (
1117+ <Pressable
1118+ key={option.value}
1119+ onPress={() => setRuntimeMode(option.value)}
1120+ style={{
1121+ paddingHorizontal: 16,
1122+ paddingVertical: 13,
1123+ borderTopWidth: index === 0 ? 0 : 1,
1124+ borderTopColor: "rgba(23,23,23,0.06)",
1125+ }}
1126+ >
1127+ <View className="flex-row items-center justify-between gap-3">
1128+ <View className="flex-1 gap-0.5">
1129+ <Text className="text-[15px] font-medium" style={{ color: "#171717" }}>
1130+ {option.label}
1131+ </Text>
1132+ <Text className="text-[12px]" style={{ color: "#78716c" }}>
1133+ {option.desc}
1134+ </Text>
1135+ </View>
1136+ {runtimeMode === option.value ? (
1137+ <SymbolView name="checkmark" size={15} tintColor="#2563eb" type="monochrome" />
1138+ ) : null}
1139+ </View>
1140+ </Pressable>
1141+ ))}
1142+ </View>
1143+ </View>
1144+ </View>
1145+ </ScrollView>
1146+ </BottomSelectorSheet>
1147+
1148+ <BottomSelectorSheet
1149+ visible={branchPickerVisible}
1150+ title="Workspace"
1151+ onClose={() => setBranchPickerVisible(false)}
1152+ >
1153+ <View className="gap-4">
1154+ <View className="flex-row gap-2">
1155+ <Pressable
1156+ onPress={() => setWorkspaceMode("local")}
1157+ className="flex-1 rounded-full px-4 py-3"
1158+ style={{
1159+ backgroundColor:
1160+ workspaceMode === "local" ? "rgba(23,23,23,0.08)" : "rgba(23,23,23,0.04)",
1161+ }}
1162+ >
1163+ <Text className="text-center text-[14px] font-t3-bold" style={{ color: "#171717" }}>
1164+ Current checkout
1165+ </Text>
1166+ </Pressable>
1167+ <Pressable
1168+ onPress={() => {
1169+ setWorkspaceMode("worktree");
1170+ setSelectedWorktreePath(null);
1171+ }}
1172+ className="flex-1 rounded-full px-4 py-3"
1173+ style={{
1174+ backgroundColor:
1175+ workspaceMode === "worktree" ? "rgba(23,23,23,0.08)" : "rgba(23,23,23,0.04)",
1176+ }}
1177+ >
1178+ <Text className="text-center text-[14px] font-t3-bold" style={{ color: "#171717" }}>
1179+ New worktree
1180+ </Text>
1181+ </Pressable>
1182+ </View>
1183+
1184+ <TextInput
1185+ value={branchQuery}
1186+ onChangeText={setBranchQuery}
1187+ placeholder="Search branches"
1188+ className="bg-white"
1189+ />
1190+
1191+ <ScrollView showsVerticalScrollIndicator={false}>
1192+ <View className="overflow-hidden rounded-[24px]" style={{ backgroundColor: "#ffffff" }}>
1193+ {branchesLoading ? (
1194+ <View className="px-4 py-5">
1195+ <Text className="text-[15px] font-medium" style={{ color: "#78716c" }}>
1196+ Loading branches...
1197+ </Text>
1198+ </View>
1199+ ) : (
1200+ filteredBranches.map((branch, index) => {
1201+ const normalizedWorktreePath = selectedProject
1202+ ? normalizeSelectedWorktreePath(selectedProject, branch)
1203+ : null;
1204+ const selected =
1205+ selectedBranchName === branch.name &&
1206+ (workspaceMode === "worktree"
1207+ ? selectedWorktreePath === null
1208+ : (selectedWorktreePath ?? null) === normalizedWorktreePath);
1209+ const badge = branchBadgeLabel({ branch, project: selectedProject });
1210+
1211+ return (
1212+ <Pressable
1213+ key={branch.name}
1214+ onPress={() => {
1215+ setSelectedBranchName(branch.name);
1216+ setSelectedWorktreePath(
1217+ workspaceMode === "worktree" ? null : normalizedWorktreePath,
1218+ );
1219+ setBranchPickerVisible(false);
1220+ }}
1221+ style={{
1222+ paddingHorizontal: 16,
1223+ paddingVertical: 18,
1224+ borderTopWidth: index === 0 ? 0 : 1,
1225+ borderTopColor: "rgba(23,23,23,0.06)",
1226+ }}
1227+ >
1228+ <View className="flex-row items-center justify-between gap-3">
1229+ <View className="flex-1 gap-1">
1230+ <Text className="text-[17px] font-medium" style={{ color: "#171717" }}>
1231+ {branch.name}
1232+ </Text>
1233+ <Text className="text-[13px] font-medium" style={{ color: "#78716c" }}>
1234+ {workspaceMode === "worktree"
1235+ ? "Base branch for new worktree"
1236+ : normalizedWorktreePath
1237+ ? "Existing worktree"
1238+ : "Current checkout"}
1239+ </Text>
1240+ </View>
1241+ <View className="items-end gap-1">
1242+ {badge ? (
1243+ <Text
1244+ className="text-[11px] font-medium"
1245+ style={{ color: "rgba(23,23,23,0.35)" }}
1246+ >
1247+ {badge}
1248+ </Text>
1249+ ) : null}
1250+ {selected ? (
1251+ <SymbolView
1252+ name="checkmark"
1253+ size={18}
1254+ tintColor="#2563eb"
1255+ type="monochrome"
1256+ />
1257+ ) : null}
1258+ </View>
1259+ </View>
1260+ </Pressable>
1261+ );
1262+ })
1263+ )}
1264+ </View>
1265+ </ScrollView>
1266+ </View>
1267+ </BottomSelectorSheet>
1268+ </>
1269+ );
1270+}
apps/mobile/src/features/threads/ProjectPickerSheet.tsx+129−0
@@ -0,0 +1,129 @@
1+import {
2+ BottomSheetBackdrop,
3+ BottomSheetModal,
4+ BottomSheetScrollView,
5+ BottomSheetView,
6+} from "@gorhom/bottom-sheet";
7+import { useCallback, useEffect, useMemo, useRef } from "react";
8+import { Pressable, View } from "react-native";
9+import { useSafeAreaInsets } from "react-native-safe-area-context";
10+
11+import { AppText as Text } from "../../components/AppText";
12+import { groupProjectsByRepository } from "../../lib/repositoryGroups";
13+import type { ScopedMobileProject, ScopedMobileThread } from "../../lib/scopedEntities";
14+
15+export function ProjectPickerSheet(props: {
16+ readonly visible: boolean;
17+ readonly projects: ReadonlyArray<ScopedMobileProject>;
18+ readonly threads: ReadonlyArray<ScopedMobileThread>;
19+ readonly onClose: () => void;
20+ readonly onSelectProject: (project: ScopedMobileProject) => void;
21+}) {
22+ const insets = useSafeAreaInsets();
23+ const sheetRef = useRef<BottomSheetModal>(null);
24+ const repositoryGroups = useMemo(
25+ () => groupProjectsByRepository({ projects: props.projects, threads: props.threads }),
26+ [props.projects, props.threads],
27+ );
28+ const logicalProjects = useMemo(
29+ () =>
30+ repositoryGroups
31+ .map((group) => {
32+ const primaryProject = group.projects[0]?.project;
33+ if (!primaryProject) {
34+ return null;
35+ }
36+
37+ return {
38+ key: group.key,
39+ project: primaryProject,
40+ };
41+ })
42+ .filter((entry) => entry !== null),
43+ [repositoryGroups],
44+ );
45+ const renderBackdrop = useCallback(
46+ (backdropProps: React.ComponentProps<typeof BottomSheetBackdrop>) => (
47+ <BottomSheetBackdrop
48+ {...backdropProps}
49+ appearsOnIndex={0}
50+ disappearsOnIndex={-1}
51+ opacity={0.18}
52+ pressBehavior="close"
53+ />
54+ ),
55+ [],
56+ );
57+
58+ useEffect(() => {
59+ const sheet = sheetRef.current;
60+ if (!sheet) {
61+ return;
62+ }
63+
64+ if (props.visible) {
65+ sheet.present();
66+ return;
67+ }
68+
69+ sheet.dismiss();
70+ }, [props.visible]);
71+
72+ return (
73+ <BottomSheetModal
74+ ref={sheetRef}
75+ index={0}
76+ snapPoints={["88%"]}
77+ enableDynamicSizing={false}
78+ enablePanDownToClose
79+ backdropComponent={renderBackdrop}
80+ onDismiss={props.onClose}
81+ backgroundStyle={{ backgroundColor: "rgba(250,248,242,0.98)" }}
82+ handleIndicatorStyle={{ backgroundColor: "rgba(120,113,108,0.35)" }}
83+ >
84+ <BottomSheetView className="px-4 pt-1">
85+ <View className="mb-4 items-center gap-1">
86+ <Text
87+ className="text-[12px] font-t3-bold uppercase"
88+ style={{ color: "#78716c", letterSpacing: 1 }}
89+ >
90+ New task
91+ </Text>
92+ <Text className="text-[28px] font-t3-bold" style={{ color: "#171717" }}>
93+ Choose project
94+ </Text>
95+ </View>
96+
97+ <BottomSheetScrollView
98+ showsVerticalScrollIndicator={false}
99+ contentContainerStyle={{
100+ paddingBottom: Math.max(insets.bottom, 18) + 18,
101+ }}
102+ >
103+ <View className="overflow-hidden rounded-[24px]" style={{ backgroundColor: "#ffffff" }}>
104+ {logicalProjects.map((entry, index) => (
105+ <Pressable
106+ key={entry.key}
107+ onPress={() => props.onSelectProject(entry.project)}
108+ style={{
109+ paddingHorizontal: 16,
110+ paddingVertical: 18,
111+ borderTopWidth: index === 0 ? 0 : 1,
112+ borderTopColor: "rgba(23,23,23,0.06)",
113+ }}
114+ >
115+ <View className="flex-row items-center justify-between gap-3">
116+ <View className="flex-1">
117+ <Text className="text-[18px] font-t3-bold" style={{ color: "#171717" }}>
118+ {entry.project.title}
119+ </Text>
120+ </View>
121+ </View>
122+ </Pressable>
123+ ))}
124+ </View>
125+ </BottomSheetScrollView>
126+ </BottomSheetView>
127+ </BottomSheetModal>
128+ );
129+}
apps/mobile/src/features/threads/ThreadComposer.tsx+21−8
@@ -6,6 +6,7 @@ import { Image, Pressable, ScrollView, View } from "react-native"; · 5 unmodified lines
66 import { AppText as Text, AppTextInput as TextInput } from "../../components/AppText";
77 import { cx } from "../../lib/classNames";
88 import type { DraftComposerImageAttachment } from "../../lib/composerImages";
9+import type { MobileLayoutVariant } from "../../lib/mobileLayout";
910 import type { RemoteClientConnectionState } from "../../lib/remoteClient";
1011
1112 function ComposerAction(props: {
@@ -47,6 +48,7 @@ export interface ThreadComposerProps { · 35 unmodified lines
4748 readonly selectedThread: OrchestrationThread;
4849 readonly queueCount: number;
4950 readonly activeThreadBusy: boolean;
51+ readonly layoutVariant?: MobileLayoutVariant;
5052 readonly onChangeDraftMessage: (value: string) => void;
5153 readonly onPickDraftImages: () => Promise<void>;
5254 readonly onPasteIntoDraft: () => Promise<void>;
@@ -64,13 +66,17 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer · 11 unmodified lines
6466 props.selectedThread.session?.status === "running" ||
6567 props.selectedThread.session?.status === "starting" ||
6668 props.queueCount > 0;
69+ const isSplitLayout = props.layoutVariant === "split";
6770
6871 return (
6972 <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"
7174 style={{ paddingBottom: (props.bottomInset ?? 0) + 16 }}
7275 >
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+ >
7480 <TextInput
7581 multiline
7682 value={props.draftMessage}
@@ -84,19 +90,19 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer · 7 unmodified lines
8490 <View className="flex-row flex-wrap gap-2.5">
8591 <ComposerAction
8692 icon="photo.on.rectangle"
87− backgroundClassName="bg-slate-200 dark:bg-slate-800"
93+ backgroundClassName="bg-stone-200 dark:bg-slate-800"
8894 iconTintColor="#0f172a"
8995 onPress={() => void props.onPickDraftImages()}
9096 />
9197 <ComposerAction
9298 icon="doc.on.clipboard"
93− backgroundClassName="bg-slate-200 dark:bg-slate-800"
99+ backgroundClassName="bg-stone-200 dark:bg-slate-800"
94100 iconTintColor="#0f172a"
95101 onPress={() => void props.onPasteIntoDraft()}
96102 />
97103 <ComposerAction
98104 icon="arrow.clockwise"
99− backgroundClassName="bg-slate-200 dark:bg-slate-800"
105+ backgroundClassName="bg-stone-200 dark:bg-slate-800"
100106 iconTintColor="#0f172a"
101107 onPress={() => void props.onRefresh()}
102108 />
@@ -117,7 +123,7 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer · 14 unmodified lines
117123 : "paperplane.fill"
118124 }
119125 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",
121127 )}
122128 iconTintColor="#ffffff"
123129 disabled={!canSend}
@@ -127,7 +133,11 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer · 3 unmodified lines
127133 </View>
128134 </View>
129135 {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+ >
131141 <View className="flex-row gap-2">
132142 {props.draftAttachments.map((image) => (
133143 <View key={image.id} className="gap-2">
@@ -150,7 +160,10 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer · 16 unmodified lines
150160 </ScrollView>
151161 ) : null}
152162 {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+ >
154167 {props.queueCount} queued message{props.queueCount === 1 ? "" : "s"} will send
155168 automatically.
156169 </Text>
apps/mobile/src/features/threads/ThreadDetailScreen.tsx+362−221
@@ -1,23 +1,39 @@
1−import type { ApprovalRequestId, ProviderApprovalDecision } from "@t3tools/contracts";
1+import type {
2+ ApprovalRequestId,
3+ GitBranch,
4+ GitRunStackedActionResult,
5+ GitStatusResult,
6+ ProviderApprovalDecision,
7+} from "@t3tools/contracts";
8+import type { GitActionRequestInput } from "@t3tools/client-runtime";
29 import * as Haptics from "expo-haptics";
3−import { useEffect, useRef, useState } from "react";
10+import { SymbolView } from "expo-symbols";
11+import { useCallback, useEffect, useMemo, useRef, useState } from "react";
412 import {
513 KeyboardAvoidingView,
614 Modal,
715 Platform,
816 Pressable,
917 useColorScheme,
18+ useWindowDimensions,
1019 View,
1120 } from "react-native";
21+import { Gesture, GestureDetector } from "react-native-gesture-handler";
1222 import { useSafeAreaInsets } from "react-native-safe-area-context";
13−import Animated from "react-native-reanimated";
23+import Animated, {
24+ interpolate,
25+ runOnJS,
26+ useAnimatedStyle,
27+ useSharedValue,
28+ withSpring,
29+ withTiming,
30+} from "react-native-reanimated";
1431
1532 import { AppText as Text, AppTextInput as TextInput } from "../../components/AppText";
16−import { ErrorBanner } from "../../components/ErrorBanner";
1733 import { GlassSafeAreaView } from "../../components/GlassSafeAreaView";
1834 import type { StatusTone } from "../../components/StatusPill";
19−import { ConnectionStatusDot } from "../connection/ConnectionStatusDot";
2035 import type { DraftComposerImageAttachment } from "../../lib/composerImages";
36+import type { MobileLayoutVariant } from "../../lib/mobileLayout";
2137 import type { ScopedMobileThread } from "../../lib/scopedEntities";
2238 import type {
2339 PendingApproval,
@@ -26,6 +42,7 @@ import type { · 2 unmodified lines
2642 ThreadFeedEntry,
2743 } from "../../lib/threadActivity";
2844 import { PendingApprovalCard } from "./PendingApprovalCard";
45+import { ThreadGitControls } from "./ThreadGitControls";
2946 import { PendingUserInputCard } from "./PendingUserInputCard";
3047 import { ThreadComposer } from "./ThreadComposer";
3148 import { ThreadFeed } from "./ThreadFeed";
@@ -48,14 +65,32 @@ export interface ThreadDetailScreenProps { · 16 unmodified lines
4865 readonly draftAttachments: ReadonlyArray<DraftComposerImageAttachment>;
4966 readonly connectionStateLabel: "ready" | "connecting" | "reconnecting" | "disconnected" | "idle";
5067 readonly activeThreadBusy: boolean;
68+ readonly selectedThreadGitStatus: GitStatusResult | null;
69+ readonly gitOperationLabel: string | null;
5170 readonly selectedThreadQueueCount: number;
71+ readonly layoutVariant?: MobileLayoutVariant;
5272 readonly onBack: () => void;
73+ readonly onOpenDrawer: () => void;
5374 readonly onOpenConnectionEditor: () => void;
5475 readonly onChangeDraftMessage: (value: string) => void;
5576 readonly onPickDraftImages: () => Promise<void>;
5677 readonly onPasteIntoDraft: () => Promise<void>;
5778 readonly onRemoveDraftImage: (imageId: string) => void;
5879 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>;
5994 readonly onRenameThread: (title: string) => Promise<void>;
6095 readonly onStopThread: () => Promise<void>;
6196 readonly onSendMessage: () => void;
@@ -94,16 +129,6 @@ function latestStreamingAssistantMessage( · 32 unmodified lines
94129 return null;
95130 }
96131
97−function useRenameDraftSync(threadId: string, threadTitle: string) {
98− const [renameDraft, setRenameDraft] = useState(threadTitle);
99−
100− useEffect(() => {
101− setRenameDraft(threadTitle);
102− }, [threadId, threadTitle]);
103−
104− return [renameDraft, setRenameDraft] as const;
105−}
106−
107132 function useStreamingHaptics(threadId: string, feed: ReadonlyArray<ThreadFeedEntry>) {
108133 const lastStreamingAssistantRef = useRef<{
109134 readonly id: string;
@@ -155,28 +180,117 @@ function useStreamingHaptics(threadId: string, feed: ReadonlyArray<ThreadFeedEnt · 45 unmodified lines
155180 }
156181
157182 export function ThreadDetailScreen(props: ThreadDetailScreenProps) {
183+ const { onBack, onOpenDrawer, onRefresh, onRefreshSelectedThreadGitStatus } = props;
158184 const isDarkMode = useColorScheme() === "dark";
159185 const insets = useSafeAreaInsets();
186+ const { width } = useWindowDimensions();
160187 const agentLabel = `${props.selectedThread.modelSelection.provider} agent`;
161− const headerOverlayHeight = insets.top + 70;
188+ const headerOverlayHeight = insets.top + 118;
162189 const composerBottomInset = Math.max(insets.bottom, 12);
163− const screenBackgroundColor = isDarkMode ? "#020617" : "#f8fafc";
190+ const screenBackgroundColor = isDarkMode ? "#0c1118" : "#f7f7f5";
164191 const modalBackdropColor = isDarkMode ? "rgba(2,6,23,0.68)" : "rgba(15,23,42,0.22)";
165192 const modalPanelColor = isDarkMode ? "#111827" : "#ffffff";
166193 const modalBorderColor = isDarkMode ? "rgba(255,255,255,0.08)" : "#e2e8f0";
167194 const modalMutedColor = isDarkMode ? "#94a3b8" : "#64748b";
168195 const modalPrimaryColor = isDarkMode ? "#f8fafc" : "#020617";
169− const connectionPulse = props.activeThreadBusy;
170196 const [renameVisible, setRenameVisible] = useState(false);
171− const [renameDraft, setRenameDraft] = useRenameDraftSync(
172− props.selectedThread.id,
173− props.selectedThread.title,
174− );
197+ const [refreshing, setRefreshing] = useState(false);
198+ const [renameDraft, setRenameDraft] = useState(props.selectedThread.title);
175199 const showHeader = props.showHeader ?? true;
176200 const showContent = props.showContent ?? true;
201+ const layoutVariant = props.layoutVariant ?? "compact";
202+ const isSplitLayout = layoutVariant === "split";
203+ const edgeSwipeTranslation = useSharedValue(0);
177204
178205 useStreamingHaptics(props.selectedThread.id, props.selectedThreadFeed);
179206
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+
180294 async function handleSubmitRename(): Promise<void> {
181295 const trimmed = renameDraft.trim();
182296 if (trimmed.length === 0) {
@@ -188,216 +302,243 @@ export function ThreadDetailScreen(props: ThreadDetailScreenProps) { · 5 unmodified lines
188302 }
189303
190304 return (
191− <KeyboardAvoidingView
192− behavior={Platform.OS === "ios" ? "padding" : undefined}
193− style={{ flex: 1, backgroundColor: screenBackgroundColor }}
194− >
195− {showHeader ? (
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− }}
305+ <GestureDetector gesture={edgeSwipeGesture}>
306+ <Animated.View style={edgeSwipeStyle}>
307+ <KeyboardAvoidingView
308+ behavior={Platform.OS === "ios" ? "padding" : undefined}
309+ style={{ flex: 1, backgroundColor: screenBackgroundColor }}
248310 >
249− <ErrorBanner message={props.connectionError} />
250− </View>
251− ) : null}
252−
253− {showContent ? (
254− <>
255− <View style={{ flex: 1, minHeight: 0 }}>
256− <ThreadFeed
257− threadId={props.selectedThread.id}
258− feed={props.selectedThreadFeed}
259− httpBaseUrl={props.httpBaseUrl}
260− bearerToken={props.bearerToken}
261− agentLabel={agentLabel}
262− contentTopInset={headerOverlayHeight + 20}
263− contentBottomInset={composerBottomInset + 20}
264− />
265− </View>
266−
267− {props.activePendingApproval || props.activePendingUserInput ? (
268− <View className="gap-3 px-4 pb-3" style={{ flexShrink: 0 }}>
269− {props.activePendingApproval ? (
270− <PendingApprovalCard
271− approval={props.activePendingApproval}
272− respondingApprovalId={props.respondingApprovalId}
273− onRespond={props.onRespondToApproval}
274− />
275− ) : null}
276− {props.activePendingUserInput ? (
277− <PendingUserInputCard
278− pendingUserInput={props.activePendingUserInput}
279− drafts={props.activePendingUserInputDrafts}
280− answers={props.activePendingUserInputAnswers}
281− respondingUserInputId={props.respondingUserInputId}
282− onSelectOption={props.onSelectUserInputOption}
283− onChangeCustomAnswer={props.onChangeUserInputCustomAnswer}
284− onSubmit={props.onSubmitUserInput}
311+ {showHeader ? (
312+ <View className="absolute inset-x-0 top-0 z-20">
313+ <View
314+ style={{
315+ backgroundColor: isDarkMode ? "rgba(12,17,24,0.94)" : "rgba(247,247,245,0.94)",
316+ borderBottomWidth: 1,
317+ borderBottomColor: isDarkMode ? "rgba(255,255,255,0.08)" : "rgba(23,23,23,0.06)",
318+ }}
319+ >
320+ <GlassSafeAreaView
321+ leftSlot={
322+ isSplitLayout ? null : (
323+ <Pressable
324+ className="h-11 w-11 items-center justify-center rounded-full"
325+ style={{
326+ backgroundColor: isDarkMode
327+ ? "rgba(255,255,255,0.06)"
328+ : "rgba(23,23,23,0.05)",
329+ }}
330+ onPress={props.onBack}
331+ >
332+ <SymbolView
333+ name="chevron.left"
334+ size={18}
335+ tintColor={isDarkMode ? "#fafaf9" : "#171717"}
336+ type="monochrome"
337+ />
338+ </Pressable>
339+ )
340+ }
341+ centerSlot={
342+ <View className="items-center gap-1">
343+ <Pressable
344+ onLongPress={() => {
345+ setRenameDraft(props.selectedThread.title);
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+ }
285384 />
286− ) : null}
385+ </View>
287386 </View>
288387 ) : null}
289388
290− <ThreadComposer
291− draftMessage={props.draftMessage}
292− draftAttachments={props.draftAttachments}
293− placeholder="Ask the repo agent, or run a command…"
294− connectionState={props.connectionStateLabel}
295− selectedThread={props.selectedThread}
296− queueCount={props.selectedThreadQueueCount}
297− activeThreadBusy={props.activeThreadBusy}
298− bottomInset={composerBottomInset}
299− onChangeDraftMessage={props.onChangeDraftMessage}
300− onPickDraftImages={props.onPickDraftImages}
301− onPasteIntoDraft={props.onPasteIntoDraft}
302− onRemoveDraftImage={props.onRemoveDraftImage}
303− onRefresh={props.onRefresh}
304− onStopThread={props.onStopThread}
305− onSendMessage={props.onSendMessage}
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>
389+ {showContent ? (
390+ <>
391+ <View style={{ flex: 1, minHeight: 0 }}>
392+ <ThreadFeed
393+ threadId={props.selectedThread.id}
394+ feed={props.selectedThreadFeed}
395+ httpBaseUrl={props.httpBaseUrl}
396+ bearerToken={props.bearerToken}
397+ agentLabel={agentLabel}
398+ contentTopInset={headerOverlayHeight + 20}
399+ contentBottomInset={composerBottomInset + 20}
400+ layoutVariant={layoutVariant}
401+ refreshing={refreshing}
402+ onRefresh={() => void handleRefresh()}
403+ />
404+ </View>
345405
346− <TextInput
347− autoFocus
348− value={renameDraft}
349− onChangeText={setRenameDraft}
350− placeholder="Thread title"
351− className="min-h-[56px] px-4 py-3 text-[15px]"
352− style={{
353− borderWidth: 1,
354− borderColor: modalBorderColor,
355− backgroundColor: isDarkMode ? "#0f172a" : "#ffffff",
356− color: modalPrimaryColor,
357− }}
358− onSubmitEditing={() => {
359− void handleSubmitRename();
360− }}
361− />
362−
363− <View className="flex-row gap-3">
364− <Pressable
365− className="min-h-[48px] flex-1 items-center justify-center px-4 py-3"
406+ {props.activePendingApproval || props.activePendingUserInput ? (
407+ <View className="gap-3 px-4 pb-3" style={{ flexShrink: 0 }}>
408+ {props.activePendingApproval ? (
409+ <PendingApprovalCard
410+ approval={props.activePendingApproval}
411+ respondingApprovalId={props.respondingApprovalId}
412+ onRespond={props.onRespondToApproval}
413+ />
414+ ) : null}
415+ {props.activePendingUserInput ? (
416+ <PendingUserInputCard
417+ pendingUserInput={props.activePendingUserInput}
418+ drafts={props.activePendingUserInputDrafts}
419+ answers={props.activePendingUserInputAnswers}
420+ respondingUserInputId={props.respondingUserInputId}
421+ onSelectOption={props.onSelectUserInputOption}
422+ onChangeCustomAnswer={props.onChangeUserInputCustomAnswer}
423+ onSubmit={props.onSubmitUserInput}
424+ />
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"
366464 style={{
465+ maxWidth: 420,
367466 borderWidth: 1,
368467 borderColor: modalBorderColor,
369− backgroundColor: isDarkMode ? "#1f2937" : "#f8fafc",
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();
468+ backgroundColor: modalPanelColor,
388469 }}
389470 >
390− <Text
391− className="text-sm font-extrabold uppercase"
392− style={{ color: isDarkMode ? "#020617" : "#f8fafc", letterSpacing: 1 }}
393− >
394− Save
395− </Text>
396− </Pressable>
471+ <View className="gap-2">
472+ <Text
473+ className="text-[11px] font-bold uppercase"
474+ style={{ color: modalMutedColor, letterSpacing: 1.2 }}
475+ >
476+ Thread name
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>
397538 </View>
398− </View>
399− </View>
400− </Modal>
401− </KeyboardAvoidingView>
539+ </Modal>
540+ </KeyboardAvoidingView>
541+ </Animated.View>
542+ </GestureDetector>
402543 );
403544 }
apps/mobile/src/features/threads/ThreadFeed.tsx+209−64
@@ -1,11 +1,23 @@
1+import * as Clipboard from "expo-clipboard";
2+import * as Haptics from "expo-haptics";
13 import { FlashList, type FlashListRef, type ListRenderItemInfo } from "@shopify/flash-list";
2−import { memo, useCallback, useEffect, useRef } from "react";
4+import { SymbolView } from "expo-symbols";
5+import { memo, useCallback, useEffect, useState, useRef } from "react";
36 import Markdown from "react-native-markdown-display";
4−import { Image, type NativeScrollEvent, type NativeSyntheticEvent, View } from "react-native";
7+import {
8+ Image,
9+ Pressable,
10+ RefreshControl,
11+ ScrollView,
12+ type NativeScrollEvent,
13+ type NativeSyntheticEvent,
14+ View,
15+} from "react-native";
516
617 import { AppText as Text } from "../../components/AppText";
718 import { EmptyState } from "../../components/EmptyState";
819 import { cx } from "../../lib/classNames";
20+import type { MobileLayoutVariant } from "../../lib/mobileLayout";
921 import type { ThreadFeedEntry } from "../../lib/threadActivity";
1022 import { relativeTime } from "../../lib/time";
1123 import { messageImageUrl } from "./threadPresentation";
@@ -18,6 +30,9 @@ export interface ThreadFeedProps { · 6 unmodified lines
1830 readonly agentLabel: string;
1931 readonly contentTopInset?: number;
2032 readonly contentBottomInset?: number;
33+ readonly refreshing?: boolean;
34+ readonly onRefresh?: () => void;
35+ readonly layoutVariant?: MobileLayoutVariant;
2136 }
2237
2338 function stripShellWrapper(value: string): string {
@@ -31,13 +46,18 @@ function compactActivityDetail(detail: string | null): string | null { · 7 unmodified lines
3146 return null;
3247 }
3348
34− const cleaned = stripShellWrapper(detail);
49+ const cleaned = stripShellWrapper(detail).replace(/\s+/g, " ").trim();
3550 return cleaned.length > 0 ? cleaned : null;
3651 }
3752
53+function normalizeCompactActivityLabel(value: string): string {
54+ return value.replace(/\s+(?:started|complete|completed)\s*$/i, "").trim();
55+}
56+
3857 function buildActivityRows(
3958 activities: ReadonlyArray<{
4059 readonly id: string;
60+ readonly createdAt: string;
4161 readonly summary: string;
4262 readonly detail: string | null;
4363 readonly status: string | null;
@@ -45,19 +65,20 @@ function buildActivityRows( · 1 unmodified line
4565 ) {
4666 const rows: Array<{
4767 id: string;
48− label: string;
68+ createdAt: string;
69+ summary: string;
4970 detail: string | null;
5071 status: string | null;
5172 }> = [];
5273
5374 for (const activity of activities) {
5475 const detail = compactActivityDetail(activity.detail);
55− const label = detail ?? activity.summary;
5676 const previous = rows.at(-1);
5777
58− if (previous && previous.label === label) {
78+ if (previous && previous.summary === activity.summary) {
5979 rows[rows.length - 1] = {
6080 ...previous,
81+ createdAt: activity.createdAt,
6182 detail,
6283 status: activity.status ?? previous.status,
6384 };
@@ -66,7 +87,8 @@ function buildActivityRows( · 2 unmodified lines
6687
6788 rows.push({
6889 id: activity.id,
69− label,
90+ createdAt: activity.createdAt,
91+ summary: activity.summary,
7092 detail,
7193 status: activity.status,
7294 });
@@ -75,6 +97,8 @@ function buildActivityRows( · 2 unmodified lines
7597 return rows;
7698 }
7799
100+const MAX_VISIBLE_WORK_LOG_ENTRIES = 6;
101+
78102 const MARKDOWN_BASE = {
79103 body: {
80104 color: "#020617",
@@ -144,7 +168,12 @@ const ASSISTANT_MARKDOWN_STYLES = { · 63 unmodified lines
144168
145169 function renderFeedEntry(
146170 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+ },
148177 ) {
149178 const entry = info.item;
150179
@@ -152,29 +181,54 @@ function renderFeedEntry( · 1 unmodified line
152181 const { message } = entry;
153182 const isUser = message.role === "user";
154183 const markdownStyles = isUser ? USER_MARKDOWN_STYLES : ASSISTANT_MARKDOWN_STYLES;
184+ const timestampLabel = `${relativeTime(message.createdAt)}${message.streaming ? " • live" : ""}`;
185+ const attachments = message.attachments ?? [];
155186
156− return (
157− <View
158− className={cx(
159− "mb-3.5 gap-2.5 rounded-[22px] border px-4 py-4",
160− isUser
161− ? "border-orange-300/60 bg-orange-100/70 dark:border-orange-300/22 dark:bg-orange-300/14"
162− : "border-slate-200 bg-slate-100/80 dark:border-white/6 dark:bg-slate-900/80",
163− )}
164− >
165− <View className="flex-row justify-between gap-3">
166− <Text className="font-t3-bold text-[13px] text-slate-950 dark:text-slate-50">
167− {isUser ? "You" : props.agentLabel}
168− </Text>
169− <Text className="font-t3-medium text-xs text-slate-500 dark:text-slate-500">
170− {relativeTime(message.createdAt)}
171− {message.streaming ? " • live" : ""}
187+ if (isUser) {
188+ return (
189+ <View className="mb-3.5 items-end gap-1.5">
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">
191+ {message.text.trim().length > 0 ? (
192+ <Markdown style={markdownStyles}>{message.text}</Markdown>
193+ ) : null}
194+ {attachments.map((attachment) => {
195+ const uri = messageImageUrl(props.httpBaseUrl, attachment.id);
196+ if (!uri) {
197+ return null;
198+ }
199+
200+ return (
201+ <Image
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}
172221 </Text>
173222 </View>
223+ );
224+ }
225+
226+ return (
227+ <View className="mb-3.5 gap-1.5 px-1">
174228 {message.text.trim().length > 0 ? (
175229 <Markdown style={markdownStyles}>{message.text}</Markdown>
176230 ) : null}
177− {message.attachments?.map((attachment) => {
231+ {attachments.map((attachment) => {
178232 const uri = messageImageUrl(props.httpBaseUrl, attachment.id);
179233 if (!uri) {
180234 return null;
@@ -198,65 +252,94 @@ function renderFeedEntry( · 17 unmodified lines
198252 />
199253 );
200254 })}
255+ <Text className="font-t3-medium text-xs text-slate-500 dark:text-slate-500">
256+ {timestampLabel}
257+ </Text>
201258 </View>
202259 );
203260 }
204261
205262 if (entry.type === "queued-message") {
206263 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">
208− <View className="flex-row justify-between gap-3">
209− <Text className="font-t3-bold text-[13px] text-slate-950 dark:text-slate-50">
210− {entry.sending ? "Sending next" : "Queued"}
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`}
264+ <View className="mb-3.5 gap-1.5 items-end">
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">
266+ <Text className="font-sans text-[15px] leading-[22px] text-slate-950 dark:text-slate-50">
267+ {entry.queuedMessage.text}
214268 </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}
215275 </View>
216− <Text className="font-sans text-[15px] leading-[22px] text-slate-950 dark:text-slate-50">
217− {entry.queuedMessage.text}
276+ <Text className="px-1 text-right font-t3-medium text-xs text-slate-500 dark:text-slate-500">
277+ {entry.sending ? "dispatching" : `${relativeTime(entry.createdAt)} • pending`}
218278 </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}
225279 </View>
226280 );
227281 }
228282
229283 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;
230289
231290 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]">
233− <View className="flex-row items-center justify-between gap-3 pb-0.5">
234− <Text className="font-t3-bold text-[11px] uppercase tracking-[0.8px] text-slate-500 dark:text-slate-500">
235− Command center
236− </Text>
237− <Text className="font-t3-medium text-[11px] text-slate-500 dark:text-slate-500">
238− {relativeTime(entry.createdAt)}
239− </Text>
240− </View>
241− {rows.map((row, index) => (
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]">
292+ {showHeader ? (
293+ <View className="mb-1.5 flex-row items-center justify-between gap-3 px-0.5">
294+ <Text className="font-t3-bold text-[10px] uppercase tracking-[0.8px] text-slate-500 dark:text-slate-500">
295+ Tool calls ({rows.length})
296+ </Text>
297+ <Pressable onPress={() => props.onToggleWorkGroup(entry.id)}>
298+ <Text className="font-t3-medium text-[10px] uppercase tracking-[0.8px] text-slate-500 dark:text-slate-500">
299+ {isExpanded ? "Show less" : `Show ${hiddenCount} more`}
300+ </Text>
301+ </Pressable>
302+ </View>
303+ ) : null}
304+ {visibleRows.map((row, index) => (
242305 <View
243306 key={row.id}
244307 className={cx(
245− "gap-1.5 py-2",
308+ "flex-row items-center gap-2 rounded-lg px-1 py-1",
246309 index > 0 && "border-t border-slate-200/80 dark:border-white/4",
247310 )}
248311 >
249− <View className="flex-row items-start justify-between gap-3">
250− <Text className="flex-1 font-t3-medium text-[14px] text-slate-700 dark:text-slate-200">
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>
312+ <View className="items-center justify-center pt-0.5">
313+ <SymbolView name="terminal" size={13} tintColor="#64748b" type="monochrome" />
256314 </View>
257− {row.detail && row.detail !== row.label ? (
258− <Text className="font-sans text-xs leading-[18px] text-slate-500 dark:text-slate-500">
259− {row.detail}
315+ <ScrollView
316+ horizontal
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
260343 </Text>
261344 ) : null}
262345 </View>
@@ -307,14 +390,71 @@ function useAutoScrollToLatest( · 44 unmodified lines
307390
308391 export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) {
309392 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>>({});
310396 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+ );
311450
312451 if (props.feed.length === 0) {
313452 return (
314453 <View
315− className="flex-1 px-4"
454+ className="flex-1"
316455 style={{
317456 minHeight: 0,
457+ paddingHorizontal: horizontalPadding,
318458 paddingTop: props.contentTopInset ?? 18,
319459 paddingBottom: props.contentBottomInset ?? 18,
320460 }}
@@ -334,18 +474,23 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { · 13 unmodified lines
334474 key={props.threadId}
335475 style={{ flex: 1 }}
336476 data={props.feed}
337− renderItem={(info) => renderFeedEntry(info, props)}
477+ renderItem={renderItem}
338478 keyExtractor={(entry) => `${entry.type}:${entry.id}`}
339479 keyboardShouldPersistTaps="handled"
340480 onScroll={updateFollowLatest}
341481 scrollEventThrottle={16}
482+ refreshControl={
483+ props.onRefresh ? (
484+ <RefreshControl refreshing={props.refreshing ?? false} onRefresh={props.onRefresh} />
485+ ) : undefined
486+ }
342487 maintainVisibleContentPosition={{
343488 autoscrollToBottomThreshold: 0.2,
344489 animateAutoScrollToBottom: true,
345490 startRenderingFromBottom: true,
346491 }}
347492 contentContainerStyle={{
348− paddingHorizontal: 16,
493+ paddingHorizontal: horizontalPadding,
349494 paddingTop: props.contentTopInset ?? 18,
350495 paddingBottom: props.contentBottomInset ?? 18,
351496 }}
apps/mobile/src/features/threads/ThreadGitControls.tsx+1259−0
@@ -0,0 +1,1259 @@
1+import {
2+ BottomSheetBackdrop,
3+ BottomSheetModal,
4+ BottomSheetScrollView,
5+ BottomSheetView,
6+} from "@gorhom/bottom-sheet";
7+import { MenuView } from "@react-native-menu/menu";
8+import type { GitBranch, GitRunStackedActionResult, GitStatusResult } from "@t3tools/contracts";
9+import {
10+ buildMenuItems,
11+ getGitActionDisabledReason,
12+ type GitActionRequestInput,
13+ requiresDefaultBranchConfirmation,
14+ resolveDefaultBranchActionDialogCopy,
15+ resolveQuickAction,
16+} from "@t3tools/client-runtime";
17+import { resolveAutoFeatureBranchName, sanitizeFeatureBranchName } from "@t3tools/shared/git";
18+import { SymbolView } from "expo-symbols";
19+import type { ComponentProps, RefObject } from "react";
20+import { useCallback, useEffect, useMemo, useRef, useState } from "react";
21+import { Alert, Linking, Pressable, View } from "react-native";
22+import { useSafeAreaInsets } from "react-native-safe-area-context";
23+
24+import { AppText as Text, AppTextInput as TextInput } from "../../components/AppText";
25+import { StatusPill } from "../../components/StatusPill";
26+
27+type PendingDefaultBranchAction = {
28+ readonly action: Extract<
29+ GitActionRequestInput["action"],
30+ "push" | "create_pr" | "commit_push" | "commit_push_pr"
31+ >;
32+ readonly branchName: string;
33+ readonly includesCommit: boolean;
34+ readonly commitMessage?: string;
35+ readonly filePaths?: readonly string[];
36+};
37+
38+function useSheetVisibility(sheetRef: RefObject<BottomSheetModal | null>, visible: boolean) {
39+ useEffect(() => {
40+ const sheet = sheetRef.current;
41+ if (!sheet) {
42+ return;
43+ }
44+
45+ if (visible) {
46+ sheet.present();
47+ return;
48+ }
49+
50+ sheet.dismiss();
51+ }, [sheetRef, visible]);
52+}
53+
54+function statusSummary(gitStatus: GitStatusResult | null): string {
55+ if (!gitStatus) {
56+ return "Loading branch status…";
57+ }
58+
59+ if (!gitStatus.isRepo) {
60+ return "Not a git repository";
61+ }
62+
63+ const parts: string[] = [];
64+ if (gitStatus.hasWorkingTreeChanges) {
65+ parts.push(
66+ `${gitStatus.workingTree.files.length} file${gitStatus.workingTree.files.length === 1 ? "" : "s"} changed`,
67+ );
68+ } else {
69+ parts.push("Clean");
70+ }
71+ if (gitStatus.aheadCount > 0) {
72+ parts.push(`${gitStatus.aheadCount} ahead`);
73+ }
74+ if (gitStatus.behindCount > 0) {
75+ parts.push(`${gitStatus.behindCount} behind`);
76+ }
77+ if (gitStatus.pr?.state === "open") {
78+ parts.push(`PR #${gitStatus.pr.number} open`);
79+ }
80+
81+ return parts.join(" · ");
82+}
83+
84+function deriveAvailableBranchLabel(
85+ currentBranch: string | null,
86+ gitStatus: GitStatusResult | null,
87+): string {
88+ return gitStatus?.branch ?? currentBranch ?? "Detached HEAD";
89+}
90+
91+function truncateMiddle(value: string, maxLength: number): string {
92+ if (value.length <= maxLength) {
93+ return value;
94+ }
95+
96+ const headLength = Math.ceil((maxLength - 1) / 2);
97+ const tailLength = Math.floor((maxLength - 1) / 2);
98+ return `${value.slice(0, headLength)}…${value.slice(value.length - tailLength)}`;
99+}
100+
101+function compactMenuBranchLabel(branch: string): string {
102+ return truncateMiddle(branch, 24);
103+}
104+
105+function compactMenuStatus(gitStatus: GitStatusResult | null): string {
106+ if (!gitStatus) {
107+ return "Checking status";
108+ }
109+ if (!gitStatus.isRepo) {
110+ return "Not a repo";
111+ }
112+
113+ const parts: string[] = [];
114+ if (gitStatus.hasWorkingTreeChanges) {
115+ parts.push(`${gitStatus.workingTree.files.length} changed`);
116+ } else if (gitStatus.aheadCount === 0 && gitStatus.behindCount === 0) {
117+ parts.push("Clean");
118+ }
119+ if (gitStatus.aheadCount > 0) {
120+ parts.push(`${gitStatus.aheadCount} ahead`);
121+ }
122+ if (gitStatus.behindCount > 0) {
123+ parts.push(`${gitStatus.behindCount} behind`);
124+ }
125+ if (gitStatus.pr?.state === "open") {
126+ parts.push(`PR #${gitStatus.pr.number}`);
127+ }
128+
129+ return parts.join(" · ");
130+}
131+
132+function triggerStatusLabel(
133+ gitStatus: GitStatusResult | null,
134+ gitOperationLabel: string | null,
135+): string {
136+ if (gitOperationLabel) {
137+ return "Working";
138+ }
139+ if (!gitStatus) {
140+ return "Checking";
141+ }
142+ if (!gitStatus.isRepo) {
143+ return "No repo";
144+ }
145+ if (gitStatus.hasWorkingTreeChanges) {
146+ return `${gitStatus.workingTree.files.length} changed`;
147+ }
148+ if (gitStatus.behindCount > 0) {
149+ return `${gitStatus.behindCount} behind`;
150+ }
151+ if (gitStatus.aheadCount > 0) {
152+ return `${gitStatus.aheadCount} ahead`;
153+ }
154+ if (gitStatus.pr?.state === "open") {
155+ return "PR open";
156+ }
157+ return "Clean";
158+}
159+
160+function menuItemIconName(
161+ icon: "commit" | "push" | "pr",
162+): ComponentProps<typeof SymbolView>["name"] {
163+ if (icon === "commit") return "checkmark.circle";
164+ if (icon === "push") return "arrow.up.circle";
165+ return "arrow.up.right.circle";
166+}
167+
168+function SheetActionButton(props: {
169+ readonly icon: ComponentProps<typeof SymbolView>["name"];
170+ readonly label: string;
171+ readonly disabled?: boolean;
172+ readonly tone?: "primary" | "secondary" | "danger";
173+ readonly onPress: () => void;
174+}) {
175+ const tone = props.tone ?? "secondary";
176+ const colors =
177+ tone === "primary"
178+ ? {
179+ backgroundColor: "#171717",
180+ borderColor: "transparent",
181+ textColor: "#fafaf9",
182+ }
183+ : tone === "danger"
184+ ? {
185+ backgroundColor: "#fff1f2",
186+ borderColor: "rgba(225,29,72,0.12)",
187+ textColor: "#be123c",
188+ }
189+ : {
190+ backgroundColor: "#ffffff",
191+ borderColor: "rgba(23,23,23,0.08)",
192+ textColor: "#171717",
193+ };
194+
195+ return (
196+ <Pressable
197+ className="min-h-[48px] flex-1 flex-row items-center justify-center gap-2 rounded-[18px] px-4 py-3"
198+ disabled={props.disabled}
199+ style={{
200+ backgroundColor: colors.backgroundColor,
201+ borderWidth: tone === "primary" ? 0 : 1,
202+ borderColor: colors.borderColor,
203+ opacity: props.disabled ? 0.45 : 1,
204+ }}
205+ onPress={props.onPress}
206+ >
207+ <SymbolView name={props.icon} size={16} tintColor={colors.textColor} type="monochrome" />
208+ <Text
209+ className="text-[12px] font-t3-bold uppercase"
210+ style={{ color: colors.textColor, letterSpacing: 0.9 }}
211+ >
212+ {props.label}
213+ </Text>
214+ </Pressable>
215+ );
216+}
217+
218+function MetaCard(props: { readonly label: string; readonly value: string }) {
219+ return (
220+ <View
221+ className="rounded-[18px] border px-4 py-3"
222+ style={{ backgroundColor: "#ffffff", borderColor: "rgba(23,23,23,0.08)" }}
223+ >
224+ <Text
225+ className="text-[11px] font-t3-bold uppercase"
226+ style={{ color: "#78716c", letterSpacing: 0.9 }}
227+ >
228+ {props.label}
229+ </Text>
230+ <Text
231+ selectable
232+ className="text-[13px] font-medium"
233+ numberOfLines={1}
234+ style={{ color: "#292524" }}
235+ >
236+ {props.value}
237+ </Text>
238+ </View>
239+ );
240+}
241+
242+function SheetListRow(props: {
243+ readonly icon: ComponentProps<typeof SymbolView>["name"];
244+ readonly title: string;
245+ readonly subtitle?: string | null;
246+ readonly disabled?: boolean;
247+ readonly onPress: () => void;
248+}) {
249+ return (
250+ <Pressable
251+ className="flex-row items-center gap-3 px-1 py-3"
252+ disabled={props.disabled}
253+ style={{ opacity: props.disabled ? 0.45 : 1 }}
254+ onPress={props.onPress}
255+ >
256+ <View
257+ className="h-9 w-9 items-center justify-center rounded-full"
258+ style={{ backgroundColor: "rgba(23,23,23,0.05)" }}
259+ >
260+ <SymbolView name={props.icon} size={16} tintColor="#171717" type="monochrome" />
261+ </View>
262+ <View className="flex-1 gap-0.5">
263+ <Text className="text-[16px] font-t3-bold" style={{ color: "#171717" }}>
264+ {props.title}
265+ </Text>
266+ {props.subtitle ? (
267+ <Text className="text-[12px] leading-[17px]" style={{ color: "#78716c" }}>
268+ {props.subtitle}
269+ </Text>
270+ ) : null}
271+ </View>
272+ <SymbolView name="chevron.right" size={13} tintColor="#a8a29e" type="monochrome" />
273+ </Pressable>
274+ );
275+}
276+
277+export function ThreadGitControls(props: {
278+ readonly currentBranch: string | null;
279+ readonly currentWorktreePath: string | null;
280+ readonly gitStatus: GitStatusResult | null;
281+ readonly gitOperationLabel: string | null;
282+ readonly onRefreshStatus: (options?: { readonly quiet?: boolean }) => Promise<void>;
283+ readonly onListBranches: () => Promise<ReadonlyArray<GitBranch>>;
284+ readonly onCheckoutBranch: (branch: string) => Promise<void>;
285+ readonly onCreateBranch: (branch: string) => Promise<void>;
286+ readonly onCreateWorktree: (input: {
287+ readonly baseBranch: string;
288+ readonly newBranch: string;
289+ }) => Promise<void>;
290+ readonly onPull: () => Promise<void>;
291+ readonly onRunAction: (input: GitActionRequestInput) => Promise<GitRunStackedActionResult | null>;
292+}) {
293+ const insets = useSafeAreaInsets();
294+ const {
295+ currentWorktreePath,
296+ gitOperationLabel,
297+ gitStatus,
298+ onCheckoutBranch,
299+ onCreateBranch,
300+ onCreateWorktree,
301+ onListBranches,
302+ onPull,
303+ onRefreshStatus,
304+ onRunAction,
305+ } = props;
306+ const gitSheetRef = useRef<BottomSheetModal>(null);
307+ const branchSheetRef = useRef<BottomSheetModal>(null);
308+ const commitSheetRef = useRef<BottomSheetModal>(null);
309+ const defaultBranchSheetRef = useRef<BottomSheetModal>(null);
310+ const [gitSheetVisible, setGitSheetVisible] = useState(false);
311+ const [branchSheetVisible, setBranchSheetVisible] = useState(false);
312+ const [commitSheetVisible, setCommitSheetVisible] = useState(false);
313+ const [defaultBranchSheetVisible, setDefaultBranchSheetVisible] = useState(false);
314+ const [isEditingFiles, setIsEditingFiles] = useState(false);
315+ const [availableBranches, setAvailableBranches] = useState<ReadonlyArray<GitBranch>>([]);
316+ const [branchesLoading, setBranchesLoading] = useState(false);
317+ const [newBranchName, setNewBranchName] = useState("");
318+ const [worktreeBaseBranch, setWorktreeBaseBranch] = useState("");
319+ const [worktreeBranchName, setWorktreeBranchName] = useState("");
320+ const [dialogCommitMessage, setDialogCommitMessage] = useState("");
321+ const [excludedFiles, setExcludedFiles] = useState<ReadonlySet<string>>(new Set());
322+ const [pendingDefaultBranchAction, setPendingDefaultBranchAction] =
323+ useState<PendingDefaultBranchAction | null>(null);
324+ const currentBranchLabel = deriveAvailableBranchLabel(props.currentBranch, gitStatus);
325+ const busy = gitOperationLabel !== null;
326+ const isRepo = gitStatus?.isRepo ?? true;
327+ const hasOriginRemote = gitStatus?.hasOriginRemote ?? false;
328+ const isDefaultBranch = gitStatus?.isDefaultBranch ?? false;
329+ const quickAction = useMemo(
330+ () =>
331+ isRepo
332+ ? resolveQuickAction(gitStatus, busy, isDefaultBranch, hasOriginRemote)
333+ : {
334+ label: "Git unavailable",
335+ disabled: true,
336+ kind: "show_hint" as const,
337+ hint: "This workspace is not a git repository.",
338+ },
339+ [busy, gitStatus, hasOriginRemote, isDefaultBranch, isRepo],
340+ );
341+ const menuItems = useMemo(
342+ () => (isRepo ? buildMenuItems(gitStatus, busy, hasOriginRemote) : []),
343+ [busy, gitStatus, hasOriginRemote, isRepo],
344+ );
345+ const quickActionHint = quickAction.disabled
346+ ? (quickAction.hint ?? "This action is unavailable.")
347+ : null;
348+ const triggerStatus = triggerStatusLabel(gitStatus, gitOperationLabel);
349+ const allFiles = gitStatus?.workingTree.files ?? [];
350+ const selectedFiles = allFiles.filter((file) => !excludedFiles.has(file.path));
351+ const allSelected = excludedFiles.size === 0;
352+ const noneSelected = selectedFiles.length === 0;
353+ const selectedInsertions = selectedFiles.reduce((sum, file) => sum + file.insertions, 0);
354+ const selectedDeletions = selectedFiles.reduce((sum, file) => sum + file.deletions, 0);
355+ const selectedFilePreview = selectedFiles.slice(0, 3);
356+ const renderBackdrop = useCallback(
357+ (backdropProps: ComponentProps<typeof BottomSheetBackdrop>) => (
358+ <BottomSheetBackdrop
359+ {...backdropProps}
360+ appearsOnIndex={0}
361+ disappearsOnIndex={-1}
362+ opacity={0.18}
363+ pressBehavior="close"
364+ />
365+ ),
366+ [],
367+ );
368+ const gitSnapPoints = useMemo<(string | number)[]>(() => ["72%"], []);
369+ const branchSnapPoints = useMemo<(string | number)[]>(() => ["86%"], []);
370+ const commitSnapPoints = useMemo<(string | number)[]>(() => ["88%"], []);
371+ const defaultBranchSnapPoints = useMemo<(string | number)[]>(() => ["40%"], []);
372+ const pendingDefaultBranchActionCopy = pendingDefaultBranchAction
373+ ? resolveDefaultBranchActionDialogCopy({
374+ action: pendingDefaultBranchAction.action,
375+ branchName: pendingDefaultBranchAction.branchName,
376+ includesCommit: pendingDefaultBranchAction.includesCommit,
377+ })
378+ : null;
379+ const disabledExistingBranches = useMemo(
380+ () =>
381+ new Set(
382+ availableBranches
383+ .filter(
384+ (branch) => branch.worktreePath !== null && branch.worktreePath !== currentWorktreePath,
385+ )
386+ .map((branch) => branch.name),
387+ ),
388+ [availableBranches, currentWorktreePath],
389+ );
390+ const sheetMenuItems = useMemo(
391+ () =>
392+ menuItems.map((item) => ({
393+ item,
394+ disabledReason: getGitActionDisabledReason({
395+ item,
396+ gitStatus,
397+ isBusy: busy,
398+ hasOriginRemote,
399+ }),
400+ })),
401+ [busy, gitStatus, hasOriginRemote, menuItems],
402+ );
403+ const nativeMenuActions = useMemo<
404+ Array<{
405+ id: string;
406+ title: string;
407+ subtitle?: string;
408+ state?: "off";
409+ image?: string;
410+ preferredElementSize?: "small";
411+ attributes?: {
412+ disabled?: boolean;
413+ };
414+ }>
415+ >(
416+ () => [
417+ {
418+ id: "branch",
419+ title: compactMenuBranchLabel(currentBranchLabel),
420+ subtitle: compactMenuStatus(gitStatus),
421+ image: "point.topleft.down.curvedto.point.bottomright.up",
422+ state: "off",
423+ preferredElementSize: "small",
424+ attributes: { disabled: true },
425+ },
426+ {
427+ id: "quick",
428+ title: quickAction.label,
429+ image:
430+ quickAction.kind === "run_pull"
431+ ? "arrow.down.circle"
432+ : quickAction.kind === "open_pr"
433+ ? "arrow.up.right.circle"
434+ : quickAction.action === "commit"
435+ ? "checkmark.circle"
436+ : quickAction.action === "push" || quickAction.action === "commit_push"
437+ ? "arrow.up.circle"
438+ : "arrow.up.right.circle",
439+ ...(quickActionHint ? { subtitle: quickActionHint } : {}),
440+ preferredElementSize: "small",
441+ ...(quickAction.disabled ? { attributes: { disabled: true } } : {}),
442+ },
443+ {
444+ id: "more",
445+ title: "More",
446+ image: "ellipsis.circle",
447+ subtitle: "Commit, files, branches",
448+ preferredElementSize: "small",
449+ },
450+ ],
451+ [
452+ currentBranchLabel,
453+ gitStatus,
454+ quickAction.action,
455+ quickAction.disabled,
456+ quickAction.kind,
457+ quickAction.label,
458+ quickActionHint,
459+ ],
460+ );
461+
462+ useSheetVisibility(gitSheetRef, gitSheetVisible);
463+ useSheetVisibility(branchSheetRef, branchSheetVisible);
464+ useSheetVisibility(commitSheetRef, commitSheetVisible);
465+ useSheetVisibility(defaultBranchSheetRef, defaultBranchSheetVisible);
466+
467+ useEffect(() => {
468+ if (!gitSheetVisible) {
469+ return;
470+ }
471+ void onRefreshStatus({ quiet: true });
472+ }, [gitSheetVisible, onRefreshStatus]);
473+
474+ useEffect(() => {
475+ if (!branchSheetVisible) {
476+ return;
477+ }
478+
479+ setWorktreeBaseBranch(currentBranchLabel === "Detached HEAD" ? "main" : currentBranchLabel);
480+ setBranchesLoading(true);
481+ void onListBranches()
482+ .then((branches) => setAvailableBranches(branches))
483+ .finally(() => setBranchesLoading(false));
484+ }, [branchSheetVisible, currentBranchLabel, onListBranches]);
485+
486+ const closeTransientSheets = useCallback(() => {
487+ setGitSheetVisible(false);
488+ setCommitSheetVisible(false);
489+ setDefaultBranchSheetVisible(false);
490+ }, []);
491+
492+ const openCommitSheet = useCallback(() => {
493+ setExcludedFiles(new Set());
494+ setIsEditingFiles(false);
495+ setDialogCommitMessage("");
496+ setCommitSheetVisible(true);
497+ }, []);
498+
499+ const openExistingPr = useCallback(async () => {
500+ const prUrl = gitStatus?.pr?.state === "open" ? gitStatus.pr.url : null;
501+ if (!prUrl) {
502+ Alert.alert("No open PR", "This branch does not have an open pull request.");
503+ return;
504+ }
505+ try {
506+ await Linking.openURL(prUrl);
507+ } catch (error) {
508+ Alert.alert(
509+ "Unable to open PR",
510+ error instanceof Error ? error.message : "An error occurred.",
511+ );
512+ }
513+ }, [gitStatus]);
514+
515+ const runActionWithPrompt = useCallback(
516+ async (input: GitActionRequestInput) => {
517+ const confirmableAction =
518+ input.action === "push" ||
519+ input.action === "create_pr" ||
520+ input.action === "commit_push" ||
521+ input.action === "commit_push_pr"
522+ ? input.action
523+ : null;
524+ const branchName = gitStatus?.branch;
525+ if (
526+ branchName &&
527+ confirmableAction &&
528+ !input.featureBranch &&
529+ requiresDefaultBranchConfirmation(input.action, isDefaultBranch)
530+ ) {
531+ setPendingDefaultBranchAction({
532+ action: confirmableAction,
533+ branchName,
534+ includesCommit: input.action === "commit_push" || input.action === "commit_push_pr",
535+ ...(input.commitMessage ? { commitMessage: input.commitMessage } : {}),
536+ ...(input.filePaths ? { filePaths: input.filePaths } : {}),
537+ });
538+ setDefaultBranchSheetVisible(true);
539+ return;
540+ }
541+
542+ closeTransientSheets();
543+ await onRunAction(input);
544+ },
545+ [closeTransientSheets, gitStatus, isDefaultBranch, onRunAction],
546+ );
547+
548+ const continuePendingDefaultBranchAction = useCallback(async () => {
549+ if (!pendingDefaultBranchAction) {
550+ return;
551+ }
552+ const action = pendingDefaultBranchAction;
553+ setPendingDefaultBranchAction(null);
554+ setDefaultBranchSheetVisible(false);
555+ await onRunAction({
556+ action: action.action,
557+ ...(action.commitMessage ? { commitMessage: action.commitMessage } : {}),
558+ ...(action.filePaths ? { filePaths: action.filePaths } : {}),
559+ });
560+ }, [onRunAction, pendingDefaultBranchAction]);
561+
562+ const movePendingActionToFeatureBranch = useCallback(async () => {
563+ if (!pendingDefaultBranchAction) {
564+ return;
565+ }
566+
567+ const action = pendingDefaultBranchAction;
568+ setPendingDefaultBranchAction(null);
569+ setDefaultBranchSheetVisible(false);
570+
571+ if (action.includesCommit) {
572+ await onRunAction({
573+ action: action.action,
574+ featureBranch: true,
575+ ...(action.commitMessage ? { commitMessage: action.commitMessage } : {}),
576+ ...(action.filePaths ? { filePaths: action.filePaths } : {}),
577+ });
578+ return;
579+ }
580+
581+ const branches = await onListBranches();
582+ const branchName = resolveAutoFeatureBranchName(
583+ branches.filter((branch) => !branch.isRemote).map((branch) => branch.name),
584+ );
585+ await onCreateBranch(branchName);
586+ await onRunAction({ action: action.action });
587+ }, [onCreateBranch, onListBranches, onRunAction, pendingDefaultBranchAction]);
588+
589+ const runQuickAction = useCallback(async () => {
590+ if (quickAction.kind === "open_pr") {
591+ await openExistingPr();
592+ return;
593+ }
594+ if (quickAction.kind === "run_pull") {
595+ await onPull();
596+ return;
597+ }
598+ if (quickAction.kind === "run_action" && quickAction.action) {
599+ await runActionWithPrompt({ action: quickAction.action });
600+ }
601+ }, [onPull, openExistingPr, quickAction, runActionWithPrompt]);
602+
603+ const runCommitAction = useCallback(
604+ async (featureBranch: boolean) => {
605+ const commitMessage = dialogCommitMessage.trim();
606+ setGitSheetVisible(false);
607+ setCommitSheetVisible(false);
608+ await onRunAction({
609+ action: "commit",
610+ featureBranch,
611+ ...(commitMessage ? { commitMessage } : {}),
612+ ...(!allSelected ? { filePaths: selectedFiles.map((file) => file.path) } : {}),
613+ });
614+ setDialogCommitMessage("");
615+ setExcludedFiles(new Set());
616+ },
617+ [allSelected, dialogCommitMessage, onRunAction, selectedFiles],
618+ );
619+
620+ const onPressMenuItem = useCallback(
621+ async (item: (typeof menuItems)[number]) => {
622+ if (item.disabled) {
623+ return;
624+ }
625+ if (item.kind === "open_pr") {
626+ await openExistingPr();
627+ return;
628+ }
629+ if (item.dialogAction === "commit") {
630+ openCommitSheet();
631+ return;
632+ }
633+ if (item.dialogAction === "push") {
634+ await runActionWithPrompt({ action: "push" });
635+ return;
636+ }
637+ if (item.dialogAction === "create_pr") {
638+ await runActionWithPrompt({ action: "create_pr" });
639+ }
640+ },
641+ [openCommitSheet, openExistingPr, runActionWithPrompt],
642+ );
643+
644+ const onPressNativeMenuAction = useCallback(
645+ ({ nativeEvent }: { nativeEvent: { event: string } }) => {
646+ if (nativeEvent.event === "quick") {
647+ void runQuickAction();
648+ return;
649+ }
650+ if (nativeEvent.event === "more") {
651+ setGitSheetVisible(true);
652+ }
653+ },
654+ [runQuickAction],
655+ );
656+
657+ return (
658+ <>
659+ <MenuView actions={nativeMenuActions} onPressAction={onPressNativeMenuAction}>
660+ <Pressable
661+ className="flex-row items-center gap-2 rounded-full px-3 py-2"
662+ style={{ backgroundColor: "rgba(23,23,23,0.05)" }}
663+ >
664+ <SymbolView
665+ name="point.topleft.down.curvedto.point.bottomright.up"
666+ size={14}
667+ tintColor="#171717"
668+ type="monochrome"
669+ />
670+ <View className="shrink">
671+ <Text
672+ className="text-[11px] font-t3-bold uppercase"
673+ numberOfLines={1}
674+ style={{ color: "#78716c", letterSpacing: 0.8 }}
675+ >
676+ Git
677+ </Text>
678+ <Text
679+ className="text-[12px] font-t3-bold"
680+ numberOfLines={1}
681+ style={{ color: "#171717" }}
682+ >
683+ {triggerStatus}
684+ </Text>
685+ </View>
686+ <SymbolView
687+ name="chevron.down"
688+ size={11}
689+ tintColor="#78716c"
690+ type="monochrome"
691+ weight="medium"
692+ />
693+ </Pressable>
694+ </MenuView>
695+
696+ <BottomSheetModal
697+ ref={gitSheetRef}
698+ index={0}
699+ snapPoints={gitSnapPoints}
700+ enableDynamicSizing={false}
701+ enablePanDownToClose
702+ backdropComponent={renderBackdrop}
703+ onDismiss={() => setGitSheetVisible(false)}
704+ backgroundStyle={{ backgroundColor: "rgba(247,247,245,0.98)" }}
705+ handleIndicatorStyle={{ backgroundColor: "rgba(120,113,108,0.32)" }}
706+ >
707+ <BottomSheetView style={{ flex: 1 }}>
708+ <BottomSheetScrollView
709+ showsVerticalScrollIndicator={false}
710+ contentContainerStyle={{
711+ paddingHorizontal: 20,
712+ paddingTop: 8,
713+ paddingBottom: Math.max(insets.bottom, 18) + 8,
714+ gap: 14,
715+ }}
716+ >
717+ <View className="flex-row items-start justify-between gap-3">
718+ <View className="flex-1 gap-1">
719+ <Text
720+ className="text-[11px] font-t3-bold uppercase"
721+ style={{ color: "#78716c", letterSpacing: 1 }}
722+ >
723+ Branch
724+ </Text>
725+ <Text className="text-[24px] font-t3-bold" style={{ color: "#171717" }}>
726+ {currentBranchLabel}
727+ </Text>
728+ <Text
729+ className="text-[13px] font-medium leading-[19px]"
730+ style={{ color: "#57534e" }}
731+ >
732+ {statusSummary(gitStatus)}
733+ </Text>
734+ </View>
735+ <Pressable
736+ className="rounded-full p-2"
737+ disabled={busy}
738+ style={{ backgroundColor: "rgba(23,23,23,0.05)", opacity: busy ? 0.45 : 1 }}
739+ onPress={() => void onRefreshStatus()}
740+ >
741+ <SymbolView
742+ name="arrow.clockwise"
743+ size={16}
744+ tintColor="#171717"
745+ type="monochrome"
746+ weight="medium"
747+ />
748+ </Pressable>
749+ </View>
750+
751+ <View className="gap-3">
752+ <View
753+ className="overflow-hidden rounded-[22px] border px-4 py-1"
754+ style={{ backgroundColor: "#ffffff", borderColor: "rgba(23,23,23,0.08)" }}
755+ >
756+ {sheetMenuItems.map(({ item, disabledReason }, index) => (
757+ <View key={`${item.id}-${item.label}`}>
758+ {index > 0 ? (
759+ <View
760+ className="ml-12 h-px"
761+ style={{ backgroundColor: "rgba(23,23,23,0.08)" }}
762+ />
763+ ) : null}
764+ <SheetListRow
765+ icon={menuItemIconName(item.icon)}
766+ title={item.label}
767+ subtitle={disabledReason}
768+ disabled={item.disabled}
769+ onPress={() => {
770+ void onPressMenuItem(item);
771+ }}
772+ />
773+ </View>
774+ ))}
775+ {(gitStatus?.behindCount ?? 0) > 0 ? (
776+ <>
777+ <View
778+ className="ml-12 h-px"
779+ style={{ backgroundColor: "rgba(23,23,23,0.08)" }}
780+ />
781+ <SheetListRow
782+ icon="arrow.down.circle"
783+ title="Pull latest"
784+ subtitle="Sync this branch with upstream"
785+ disabled={busy || !isRepo}
786+ onPress={() => {
787+ void onPull();
788+ }}
789+ />
790+ </>
791+ ) : null}
792+ <View className="ml-12 h-px" style={{ backgroundColor: "rgba(23,23,23,0.08)" }} />
793+ <SheetListRow
794+ icon="point.topleft.down.curvedto.point.bottomright.up"
795+ title="Branches & worktrees"
796+ subtitle="Switch branch, create branch, or move to a worktree"
797+ disabled={busy || !isRepo}
798+ onPress={() => setBranchSheetVisible(true)}
799+ />
800+ </View>
801+ </View>
802+
803+ {currentWorktreePath ? <MetaCard label="Worktree" value={currentWorktreePath} /> : null}
804+
805+ {gitOperationLabel ? (
806+ <View className="self-start">
807+ <StatusPill
808+ label={gitOperationLabel}
809+ pillClassName="bg-slate-900/8"
810+ textClassName="text-slate-700"
811+ size="compact"
812+ />
813+ </View>
814+ ) : null}
815+ </BottomSheetScrollView>
816+ </BottomSheetView>
817+ </BottomSheetModal>
818+
819+ <BottomSheetModal
820+ ref={commitSheetRef}
821+ stackBehavior="push"
822+ index={0}
823+ snapPoints={commitSnapPoints}
824+ enableDynamicSizing={false}
825+ enablePanDownToClose
826+ backdropComponent={renderBackdrop}
827+ onDismiss={() => setCommitSheetVisible(false)}
828+ backgroundStyle={{ backgroundColor: "rgba(247,247,245,0.98)" }}
829+ handleIndicatorStyle={{ backgroundColor: "rgba(120,113,108,0.32)" }}
830+ >
831+ <BottomSheetView style={{ flex: 1 }}>
832+ <BottomSheetScrollView
833+ keyboardShouldPersistTaps="handled"
834+ showsVerticalScrollIndicator={false}
835+ contentContainerStyle={{
836+ paddingHorizontal: 20,
837+ paddingTop: 8,
838+ paddingBottom: Math.max(insets.bottom, 18) + 8,
839+ gap: 16,
840+ }}
841+ >
842+ <View className="gap-1">
843+ <Text className="text-[24px] font-t3-bold" style={{ color: "#171717" }}>
844+ Commit changes
845+ </Text>
846+ <Text className="text-[13px] font-medium leading-[19px]" style={{ color: "#57534e" }}>
847+ Review the file set, optionally write a message, then commit here or on a new
848+ feature branch.
849+ </Text>
850+ </View>
851+
852+ <View
853+ className="gap-3 rounded-[22px] border px-4 py-4"
854+ style={{ backgroundColor: "#ffffff", borderColor: "rgba(23,23,23,0.08)" }}
855+ >
856+ <View className="flex-row items-center justify-between gap-3">
857+ <Text className="text-[13px] font-medium" style={{ color: "#78716c" }}>
858+ Branch
859+ </Text>
860+ <Text className="text-[15px] font-t3-bold" style={{ color: "#171717" }}>
861+ {gitStatus?.branch ?? "(detached HEAD)"}
862+ </Text>
863+ </View>
864+ {isDefaultBranch ? (
865+ <Text className="text-[12px] leading-[18px]" style={{ color: "#b45309" }}>
866+ Warning: this is the default branch.
867+ </Text>
868+ ) : null}
869+ </View>
870+
871+ <View
872+ className="gap-3 rounded-[22px] border px-4 py-4"
873+ style={{ backgroundColor: "#ffffff", borderColor: "rgba(23,23,23,0.08)" }}
874+ >
875+ <View className="flex-row items-center justify-between gap-3">
876+ <View className="gap-1">
877+ <Text className="text-[16px] font-t3-bold" style={{ color: "#171717" }}>
878+ Files
879+ </Text>
880+ <Text className="text-[12px] leading-[18px]" style={{ color: "#78716c" }}>
881+ {selectedFiles.length} selected · +{selectedInsertions} / -{selectedDeletions}
882+ </Text>
883+ </View>
884+ <View className="flex-row items-center gap-2">
885+ {!allSelected && isEditingFiles ? (
886+ <Pressable
887+ className="rounded-full px-3 py-2"
888+ style={{ backgroundColor: "rgba(23,23,23,0.05)" }}
889+ onPress={() => setExcludedFiles(new Set())}
890+ >
891+ <Text
892+ className="text-[11px] font-t3-bold uppercase"
893+ style={{ color: "#171717" }}
894+ >
895+ Reset
896+ </Text>
897+ </Pressable>
898+ ) : null}
899+ <Pressable
900+ className="rounded-full px-3 py-2"
901+ style={{ backgroundColor: "rgba(23,23,23,0.05)" }}
902+ onPress={() => setIsEditingFiles((current) => !current)}
903+ >
904+ <Text
905+ className="text-[11px] font-t3-bold uppercase"
906+ style={{ color: "#171717" }}
907+ >
908+ {isEditingFiles ? "Done" : "Edit"}
909+ </Text>
910+ </Pressable>
911+ </View>
912+ </View>
913+
914+ {allFiles.length === 0 ? (
915+ <Text className="text-[13px] leading-[19px]" style={{ color: "#57534e" }}>
916+ No changed files are available to commit.
917+ </Text>
918+ ) : !isEditingFiles ? (
919+ <View className="gap-2">
920+ {selectedFilePreview.map((file) => (
921+ <View key={file.path} className="flex-row items-center justify-between gap-3">
922+ <Text
923+ className="flex-1 text-[13px] font-medium"
924+ numberOfLines={1}
925+ style={{ color: "#292524" }}
926+ >
927+ {file.path}
928+ </Text>
929+ <Text className="text-[12px] font-t3-bold" style={{ color: "#10b981" }}>
930+ +{file.insertions}
931+ </Text>
932+ <Text className="text-[12px] font-t3-bold" style={{ color: "#f43f5e" }}>
933+ -{file.deletions}
934+ </Text>
935+ </View>
936+ ))}
937+ {selectedFiles.length > selectedFilePreview.length ? (
938+ <Text className="text-[12px] leading-[17px]" style={{ color: "#78716c" }}>
939+ +{selectedFiles.length - selectedFilePreview.length} more files
940+ </Text>
941+ ) : null}
942+ </View>
943+ ) : (
944+ <View className="gap-2">
945+ {allFiles.map((file) => {
946+ const included = !excludedFiles.has(file.path);
947+ return (
948+ <Pressable
949+ key={file.path}
950+ className="rounded-[18px] border px-4 py-3"
951+ style={{
952+ backgroundColor: included ? "#fcfbf8" : "rgba(120,113,108,0.08)",
953+ borderColor: included ? "rgba(23,23,23,0.08)" : "rgba(120,113,108,0.15)",
954+ }}
955+ onPress={() => {
956+ setExcludedFiles((current) => {
957+ const next = new Set(current);
958+ if (next.has(file.path)) {
959+ next.delete(file.path);
960+ } else {
961+ next.add(file.path);
962+ }
963+ return next;
964+ });
965+ }}
966+ >
967+ <View className="flex-row items-start justify-between gap-3">
968+ <View className="flex-1 gap-1">
969+ <Text
970+ selectable
971+ className="text-[13px] font-t3-bold"
972+ style={{ color: included ? "#171717" : "#78716c" }}
973+ >
974+ {file.path}
975+ </Text>
976+ {!included ? (
977+ <Text
978+ className="text-[11px] leading-[16px]"
979+ style={{ color: "#78716c" }}
980+ >
981+ Excluded from this commit
982+ </Text>
983+ ) : null}
984+ </View>
985+ <View className="items-end gap-1">
986+ <Text className="text-[12px] font-t3-bold" style={{ color: "#10b981" }}>
987+ +{file.insertions}
988+ </Text>
989+ <Text className="text-[12px] font-t3-bold" style={{ color: "#f43f5e" }}>
990+ -{file.deletions}
991+ </Text>
992+ </View>
993+ </View>
994+ </Pressable>
995+ );
996+ })}
997+ </View>
998+ )}
999+ </View>
1000+
1001+ <View className="gap-2">
1002+ <Text className="text-[13px] font-t3-bold" style={{ color: "#171717" }}>
1003+ Commit message
1004+ </Text>
1005+ <TextInput
1006+ multiline
1007+ value={dialogCommitMessage}
1008+ onChangeText={setDialogCommitMessage}
1009+ placeholder="Leave empty to auto-generate"
1010+ textAlignVertical="top"
1011+ className="min-h-[128px] rounded-[20px] border border-slate-200 bg-white px-4 py-3.5 font-sans text-[15px] text-slate-950"
1012+ style={{
1013+ minHeight: 128,
1014+ borderWidth: 1,
1015+ borderColor: "rgba(23,23,23,0.08)",
1016+ backgroundColor: "#ffffff",
1017+ color: "#171717",
1018+ }}
1019+ />
1020+ </View>
1021+
1022+ <View className="flex-row gap-3">
1023+ <View className="flex-1">
1024+ <SheetActionButton
1025+ icon="arrow.branch"
1026+ label="Commit on new branch"
1027+ disabled={noneSelected || busy}
1028+ onPress={() => {
1029+ void runCommitAction(true);
1030+ }}
1031+ />
1032+ </View>
1033+ <View className="flex-1">
1034+ <SheetActionButton
1035+ icon="checkmark.circle"
1036+ label="Commit"
1037+ tone="primary"
1038+ disabled={noneSelected || busy}
1039+ onPress={() => {
1040+ void runCommitAction(false);
1041+ }}
1042+ />
1043+ </View>
1044+ </View>
1045+ </BottomSheetScrollView>
1046+ </BottomSheetView>
1047+ </BottomSheetModal>
1048+
1049+ <BottomSheetModal
1050+ ref={defaultBranchSheetRef}
1051+ stackBehavior="push"
1052+ index={0}
1053+ snapPoints={defaultBranchSnapPoints}
1054+ enableDynamicSizing={false}
1055+ enablePanDownToClose
1056+ backdropComponent={renderBackdrop}
1057+ onDismiss={() => setDefaultBranchSheetVisible(false)}
1058+ backgroundStyle={{ backgroundColor: "rgba(247,247,245,0.98)" }}
1059+ handleIndicatorStyle={{ backgroundColor: "rgba(120,113,108,0.32)" }}
1060+ >
1061+ <BottomSheetView
1062+ className="gap-4 px-5 pt-2"
1063+ style={{ paddingBottom: Math.max(insets.bottom, 18) + 8 }}
1064+ >
1065+ <View className="gap-1">
1066+ <Text className="text-[22px] font-t3-bold" style={{ color: "#171717" }}>
1067+ {pendingDefaultBranchActionCopy?.title ?? "Run action on default branch?"}
1068+ </Text>
1069+ <Text className="text-[13px] font-medium leading-[19px]" style={{ color: "#57534e" }}>
1070+ {pendingDefaultBranchActionCopy?.description ?? "Choose how to continue."}
1071+ </Text>
1072+ </View>
1073+
1074+ <View className="gap-3">
1075+ <SheetActionButton
1076+ icon="arrow.right.circle"
1077+ label={pendingDefaultBranchActionCopy?.continueLabel ?? "Continue"}
1078+ onPress={() => {
1079+ void continuePendingDefaultBranchAction();
1080+ }}
1081+ />
1082+ <SheetActionButton
1083+ icon="arrow.branch"
1084+ label="Feature branch & continue"
1085+ tone="primary"
1086+ onPress={() => {
1087+ void movePendingActionToFeatureBranch();
1088+ }}
1089+ />
1090+ </View>
1091+ </BottomSheetView>
1092+ </BottomSheetModal>
1093+
1094+ <BottomSheetModal
1095+ ref={branchSheetRef}
1096+ stackBehavior="push"
1097+ index={0}
1098+ snapPoints={branchSnapPoints}
1099+ enableDynamicSizing={false}
1100+ enablePanDownToClose
1101+ backdropComponent={renderBackdrop}
1102+ onDismiss={() => setBranchSheetVisible(false)}
1103+ backgroundStyle={{ backgroundColor: "rgba(247,247,245,0.98)" }}
1104+ handleIndicatorStyle={{ backgroundColor: "rgba(120,113,108,0.32)" }}
1105+ >
1106+ <BottomSheetView style={{ flex: 1 }}>
1107+ <BottomSheetScrollView
1108+ showsVerticalScrollIndicator={false}
1109+ contentContainerStyle={{
1110+ paddingHorizontal: 20,
1111+ paddingTop: 8,
1112+ paddingBottom: Math.max(insets.bottom, 18) + 8,
1113+ gap: 16,
1114+ }}
1115+ >
1116+ <View className="gap-1">
1117+ <Text className="text-[22px] font-t3-bold" style={{ color: "#171717" }}>
1118+ Branches & worktrees
1119+ </Text>
1120+ <Text className="text-[13px] font-medium leading-[19px]" style={{ color: "#57534e" }}>
1121+ Switch this thread, create a branch, or move work onto its own worktree.
1122+ </Text>
1123+ </View>
1124+
1125+ <View className="gap-2 rounded-[18px] border border-black/8 bg-white px-4 py-4">
1126+ <Text
1127+ className="text-[11px] font-t3-bold uppercase"
1128+ style={{ color: "#57534e", letterSpacing: 1 }}
1129+ >
1130+ New branch
1131+ </Text>
1132+ <TextInput
1133+ value={newBranchName}
1134+ onChangeText={setNewBranchName}
1135+ placeholder="feature/mobile-polish"
1136+ className="rounded-[18px] border border-slate-200 bg-white px-3.5 py-3 font-sans text-[15px] text-slate-950"
1137+ />
1138+ <SheetActionButton
1139+ icon="plus"
1140+ label="Create & checkout"
1141+ tone="primary"
1142+ disabled={busy || newBranchName.trim().length === 0}
1143+ onPress={() => {
1144+ const branch = sanitizeFeatureBranchName(newBranchName.trim());
1145+ if (branch.length === 0) {
1146+ return;
1147+ }
1148+ void onCreateBranch(branch).then(() => {
1149+ setNewBranchName("");
1150+ setBranchSheetVisible(false);
1151+ });
1152+ }}
1153+ />
1154+ </View>
1155+
1156+ <View className="gap-2 rounded-[18px] border border-black/8 bg-white px-4 py-4">
1157+ <Text
1158+ className="text-[11px] font-t3-bold uppercase"
1159+ style={{ color: "#57534e", letterSpacing: 1 }}
1160+ >
1161+ New worktree
1162+ </Text>
1163+ <TextInput
1164+ value={worktreeBaseBranch}
1165+ onChangeText={setWorktreeBaseBranch}
1166+ placeholder="main"
1167+ className="rounded-[18px] border border-slate-200 bg-white px-3.5 py-3 font-sans text-[15px] text-slate-950"
1168+ />
1169+ <TextInput
1170+ value={worktreeBranchName}
1171+ onChangeText={setWorktreeBranchName}
1172+ placeholder="feature/mobile-thread"
1173+ className="rounded-[18px] border border-slate-200 bg-white px-3.5 py-3 font-sans text-[15px] text-slate-950"
1174+ />
1175+ <SheetActionButton
1176+ icon="square.split.2x1"
1177+ label="Create worktree"
1178+ tone="primary"
1179+ disabled={
1180+ busy ||
1181+ worktreeBaseBranch.trim().length === 0 ||
1182+ worktreeBranchName.trim().length === 0
1183+ }
1184+ onPress={() => {
1185+ const baseBranch = worktreeBaseBranch.trim();
1186+ const newBranch = worktreeBranchName.trim();
1187+ if (baseBranch.length === 0 || newBranch.length === 0) {
1188+ return;
1189+ }
1190+ void onCreateWorktree({
1191+ baseBranch,
1192+ newBranch,
1193+ }).then(() => {
1194+ setWorktreeBranchName("");
1195+ setBranchSheetVisible(false);
1196+ });
1197+ }}
1198+ />
1199+ </View>
1200+
1201+ <View className="gap-2">
1202+ <Text
1203+ className="text-[11px] font-t3-bold uppercase"
1204+ style={{ color: "#57534e", letterSpacing: 1 }}
1205+ >
1206+ Existing branches
1207+ </Text>
1208+ {branchesLoading ? (
1209+ <Text className="text-[13px] font-medium" style={{ color: "#57534e" }}>
1210+ Loading branches…
1211+ </Text>
1212+ ) : null}
1213+ {!branchesLoading && availableBranches.length === 0 ? (
1214+ <Text className="text-[13px] font-medium" style={{ color: "#57534e" }}>
1215+ No local branches found.
1216+ </Text>
1217+ ) : null}
1218+ {availableBranches.map((branch) => {
1219+ const disabled = disabledExistingBranches.has(branch.name);
1220+ const subtitle = branch.worktreePath
1221+ ? branch.worktreePath === currentWorktreePath
1222+ ? "Checked out in this thread"
1223+ : "Checked out in another worktree"
1224+ : branch.isDefault
1225+ ? "Default branch"
1226+ : "Local branch";
1227+
1228+ return (
1229+ <Pressable
1230+ key={branch.name}
1231+ className="gap-1 rounded-[18px] border px-4 py-3"
1232+ disabled={busy || disabled}
1233+ style={{
1234+ backgroundColor: "#ffffff",
1235+ borderColor: branch.current ? "rgba(23,23,23,0.25)" : "rgba(23,23,23,0.08)",
1236+ opacity: busy || disabled ? 0.45 : 1,
1237+ }}
1238+ onPress={() => {
1239+ void onCheckoutBranch(branch.name).then(() => {
1240+ setBranchSheetVisible(false);
1241+ });
1242+ }}
1243+ >
1244+ <Text className="text-[15px] font-t3-bold" style={{ color: "#171717" }}>
1245+ {branch.name}
1246+ </Text>
1247+ <Text className="text-[12px] font-medium" style={{ color: "#57534e" }}>
1248+ {subtitle}
1249+ </Text>
1250+ </Pressable>
1251+ );
1252+ })}
1253+ </View>
1254+ </BottomSheetScrollView>
1255+ </BottomSheetView>
1256+ </BottomSheetModal>
1257+ </>
1258+ );
1259+}
apps/mobile/src/features/threads/ThreadListScreen.tsx+403−567
@@ -1,25 +1,30 @@
11 import { SymbolView } from "expo-symbols";
2−import { useRef, useState } from "react";
3−import { Pressable, ScrollView, View, type View as RNView } from "react-native";
4−import { useColorScheme } from "react-native";
5−import Animated, { FadeInDown, FadeOutUp, LinearTransition } from "react-native-reanimated";
6−import Svg, { Path } from "react-native-svg";
7−
2+import { useCallback, useMemo, useRef, useState } from "react";
3+import {
4+ Modal,
5+ Pressable,
6+ RefreshControl,
7+ ScrollView,
8+ View,
9+ useColorScheme,
10+ type View as RNView,
11+} from "react-native";
12+import Animated, { FadeInDown, LinearTransition } from "react-native-reanimated";
13+
14+import { BrandMark } from "../../components/BrandMark";
815 import { AppText as Text } from "../../components/AppText";
916 import { EmptyState } from "../../components/EmptyState";
10−import { ErrorBanner } from "../../components/ErrorBanner";
1117 import { GlassSafeAreaView } from "../../components/GlassSafeAreaView";
1218 import { StatusPill } from "../../components/StatusPill";
13−import { cx } from "../../lib/classNames";
14−import { sortCopy } from "../../lib/arrayCompat";
19+import type { MobileLayout } from "../../lib/mobileLayout";
1520 import {
16− scopedProjectKey,
1721 scopedThreadKey,
1822 type ScopedMobileProject,
1923 type ScopedMobileThread,
2024 } from "../../lib/scopedEntities";
2125 import type { RemoteClientConnectionState } from "../../lib/remoteClient";
2226 import { relativeTime } from "../../lib/time";
27+import { groupProjectsByRepository, type MobileRepositoryGroup } from "../../lib/repositoryGroups";
2328 import { ConnectionStatusDot } from "../connection/ConnectionStatusDot";
2429 import { lastConversationLine, threadStatusTone } from "./threadPresentation";
2530
@@ -38,7 +43,9 @@ export interface ThreadListScreenProps { · 12 unmodified lines
3843 readonly connectedEnvironmentCount: number;
3944 readonly hasClient: boolean;
4045 readonly hasServerConfig: boolean;
46+ readonly layout: MobileLayout;
4147 readonly hiddenThreadKey?: string | null;
48+ readonly selectedThreadKey?: string | null;
4249 readonly connectionError: string | null;
4350 readonly onOpenConnectionEditor: () => void;
4451 readonly onRefresh: () => Promise<void>;
@@ -56,246 +63,90 @@ export interface TransitionSourceFrame { · 11 unmodified lines
5663 readonly height: number;
5764 }
5865
59−function T3Wordmark(props: { readonly color: string }) {
60− return (
61− <Svg
62− accessibilityLabel="T3"
63− width={36}
64− height={21.5}
65− viewBox="15.5309 37 94.3941 56.96"
66− fill="none"
67− >
68− <Path
69− d="M33.4509 93V47.56H15.5309V37H64.3309V47.56H46.4109V93H33.4509ZM86.7253 93.96C82.832 93.96 78.9653 93.4533 75.1253 92.44C71.2853 91.3733 68.032 89.88 65.3653 87.96L70.4053 78.04C72.5386 79.5867 75.0186 80.8133 77.8453 81.72C80.672 82.6267 83.5253 83.08 86.4053 83.08C89.6586 83.08 92.2186 82.44 94.0853 81.16C95.952 79.88 96.8853 78.12 96.8853 75.88C96.8853 73.7467 96.0586 72.0667 94.4053 70.84C92.752 69.6133 90.0853 69 86.4053 69H80.4853V60.44L96.0853 42.76L97.5253 47.4H68.1653V37H107.365V45.4L91.8453 63.08L85.2853 59.32H89.0453C95.9253 59.32 101.125 60.8667 104.645 63.96C108.165 67.0533 109.925 71.0267 109.925 75.88C109.925 79.0267 109.099 81.9867 107.445 84.76C105.792 87.48 103.259 89.6933 99.8453 91.4C96.432 93.1067 92.0586 93.96 86.7253 93.96Z"
70− fill={props.color}
71− />
72− </Svg>
73− );
74−}
75−
76−type GlyphName =
77− | "link"
78− | "stack"
79− | "folder"
80− | "refresh"
81− | "arrow-up-right"
82− | "chevron-down"
83− | "chevron-up"
84− | "device"
85− | "spark";
86−
87−const GLYPH_SYMBOL_NAMES = {
88− link: "link",
89− stack: "square.stack.3d.up",
90− folder: "folder",
91− refresh: "arrow.clockwise",
92− "arrow-up-right": "arrow.up.right",
93− "chevron-down": "chevron.down",
94− "chevron-up": "chevron.up",
95− device: "iphone",
96− spark: "sparkles",
97−} as const;
98−
99−const GLYPH_PATHS: Record<GlyphName, string> = {
100− link: "M6.1 9.9 9.9 6.1M5.2 11.8H4a2.8 2.8 0 1 1 0-5.6h1.2M10.8 4.2H12a2.8 2.8 0 1 1 0 5.6h-1.2",
101− stack: "M2.5 5.2 8 2.5l5.5 2.7L8 7.9 2.5 5.2ZM2.5 8 8 10.7 13.5 8M2.5 10.8 8 13.5l5.5-2.7",
102− folder: "M1.8 4.5h4l1.4 1.6h7v5.7a1 1 0 0 1-1 1H2.8a1 1 0 0 1-1-1V4.5Z",
103− refresh: "M13.2 5.2V2.8M13.2 2.8h-2.4M13.2 2.8 10.8 5.2M12.3 8A4.3 4.3 0 1 1 8 3.7",
104− "arrow-up-right": "M5 11 11 5M6 5h5v5",
105− "chevron-down": "m3.5 5.8 4.5 4.4 4.5-4.4",
106− "chevron-up": "m3.5 10.2 4.5-4.4 4.5 4.4",
107− device:
108− "M4.2 2.5h7.6a1 1 0 0 1 1 1v9a1 1 0 0 1-1 1H4.2a1 1 0 0 1-1-1v-9a1 1 0 0 1 1-1ZM6.4 11.7h3.2",
109− spark: "M8 2.4 9.1 6.9 13.6 8l-4.5 1.1L8 13.6 6.9 9.1 2.4 8l4.5-1.1L8 2.4Z",
110−};
111−
112−const GLYPH_STROKE_JOIN: Partial<Record<GlyphName, "round">> = {
113− folder: "round",
114−};
115−
116−function Glyph(props: {
117− readonly name: GlyphName;
118− readonly color: string;
119− readonly size?: number;
120−}) {
121− const size = props.size ?? 16;
122− const strokeWidth = 1.8;
123− const symbolName = GLYPH_SYMBOL_NAMES[props.name];
124− const path = GLYPH_PATHS[props.name];
125− const strokeLinejoin = GLYPH_STROKE_JOIN[props.name] ?? "round";
126−
127− const fallback = (
128− <Svg width={size} height={size} viewBox="0 0 16 16" fill="none">
129− <Path
130− d={path}
131− stroke={props.color}
132− strokeWidth={strokeWidth}
133− strokeLinecap="round"
134− strokeLinejoin={strokeLinejoin}
135− />
136− </Svg>
137− );
138−
139− return (
140− <SymbolView
141− name={symbolName}
142− fallback={fallback}
143− size={size}
144− tintColor={props.color}
145− type="monochrome"
146− weight="medium"
147− />
148− );
149−}
150−
151−function ExpandThreadsToggle(props: {
152− readonly expanded: boolean;
153− readonly hiddenCount: number;
154− readonly palette: ReturnType<typeof makePalette>;
66+function ActionButton(props: {
67+ readonly icon: React.ComponentProps<typeof SymbolView>["name"];
68+ readonly label: string;
69+ readonly primary?: boolean;
15570 readonly onPress: () => void;
15671 }) {
157− const label = props.expanded ? "Show less" : `Show ${props.hiddenCount} more`;
158−
15972 return (
160− <Pressable className="items-center px-1 pb-4 pt-3" onPress={props.onPress}>
161− <View className="w-full flex-row items-center gap-3">
162− <View className="h-px flex-1" style={{ backgroundColor: props.palette.border }} />
163− <View
164− className="flex-row items-center gap-2 px-3 py-2"
165− style={{
166− borderWidth: 1,
167− borderColor: props.palette.border,
168− backgroundColor: props.palette.panelAlt,
169− }}
170− >
171− <Text
172− className="text-[11px] font-bold uppercase"
173− style={{ color: props.palette.muted, letterSpacing: 1.05 }}
174− >
175− {label}
176− </Text>
177− <Glyph
178− name={props.expanded ? "chevron-up" : "chevron-down"}
179− color={props.palette.muted}
180− size={14}
181− />
182− </View>
183− <View className="h-px flex-1" style={{ backgroundColor: props.palette.border }} />
184− </View>
73+ <Pressable
74+ className="min-h-[50px] flex-1 flex-row items-center justify-center gap-2 rounded-[16px] px-3 py-3"
75+ style={{
76+ backgroundColor: props.primary ? "#171717" : "#ffffff",
77+ borderWidth: props.primary ? 0 : 1,
78+ borderColor: props.primary ? "transparent" : "rgba(23,23,23,0.08)",
79+ }}
80+ onPress={props.onPress}
81+ >
82+ <SymbolView
83+ name={props.icon}
84+ size={18}
85+ tintColor={props.primary ? "#fafaf9" : "#171717"}
86+ type="monochrome"
87+ weight="medium"
88+ />
89+ <Text
90+ className="text-[13px] font-t3-bold uppercase"
91+ style={{
92+ color: props.primary ? "#fafaf9" : "#171717",
93+ letterSpacing: 0.9,
94+ }}
95+ >
96+ {props.label}
97+ </Text>
18598 </Pressable>
18699 );
187100 }
188101
189−function toneStyles(label: string) {
190− const value = label.toLowerCase();
191−
192− if (value.includes("error") || value.includes("disconnect")) {
193− return {
194− dot: "bg-rose-500",
195− line: "bg-rose-300/80",
196− chip: "bg-rose-100/90",
197− text: "text-rose-700",
198− };
199− }
200−
201− if (value.includes("run")) {
202− return {
203− dot: "bg-orange-500",
204− line: "bg-orange-300/80",
205− chip: "bg-orange-100/90",
206− text: "text-orange-700",
207− };
208− }
209−
210− if (value.includes("connect") || value.includes("ready") || value.includes("start")) {
211− return {
212− dot: "bg-sky-500",
213− line: "bg-sky-300/80",
214− chip: "bg-sky-100/90",
215− text: "text-sky-700",
216− };
217− }
218−
219− return {
220− dot: "bg-slate-500",
221− line: "bg-slate-300/80",
222− chip: "bg-slate-200/90",
223− text: "text-slate-700",
224− };
225−}
226−
227−function StatCell(props: {
228− readonly icon: React.ComponentProps<typeof Glyph>["name"];
102+function StatCard(props: {
229103 readonly label: string;
230104 readonly value: string;
231− readonly palette: ReturnType<typeof makePalette>;
105+ readonly icon: React.ComponentProps<typeof SymbolView>["name"];
232106 }) {
233107 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+ >
235116 <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" />
237118 <Text
238− className="text-[11px] font-bold uppercase"
239− style={{ color: props.palette.muted, letterSpacing: 1.1 }}
119+ className="text-[11px] font-t3-bold uppercase"
120+ style={{ color: "#57534e", letterSpacing: 1 }}
240121 >
241122 {props.label}
242123 </Text>
243124 </View>
244− <Text
245− className="text-[32px] font-extrabold leading-[34px]"
246− style={{ color: props.palette.text }}
247− >
125+ <Text className="text-[28px] font-t3-bold" style={{ color: "#171717" }}>
248126 {props.value}
249127 </Text>
250128 </View>
251129 );
252130 }
253131
254−function ProjectActionButton(props: {
255− readonly palette: ReturnType<typeof makePalette>;
256− readonly label: string;
257− readonly icon: React.ComponentProps<typeof Glyph>["name"];
258− readonly onPress: () => void;
259−}) {
260− return (
261− <Pressable
262− className="min-h-[40px] flex-row items-center justify-center gap-2 px-3 py-2"
263− style={{
264− backgroundColor: props.palette.actionSecondary,
265− borderWidth: 1,
266− borderColor: props.palette.border,
267− }}
268− onPress={props.onPress}
269− >
270− <Glyph name={props.icon} color={props.palette.actionSecondaryText} size={16} />
271− <Text
272− className="text-[11px] font-extrabold uppercase"
273− style={{ color: props.palette.actionSecondaryText, letterSpacing: 1.05 }}
274− >
275− {props.label}
276− </Text>
277− </Pressable>
278− );
279−}
280−
281132 function ThreadRow(props: {
282133 readonly thread: ScopedMobileThread;
283− readonly isLast: boolean;
284134 readonly hidden?: boolean;
135+ readonly selected?: boolean;
285136 readonly onPress: (sourceFrame: TransitionSourceFrame | null) => void;
286− readonly palette: ReturnType<typeof makePalette>;
287137 }) {
288138 const containerRef = useRef<RNView>(null);
289− const threadTone = threadStatusTone(props.thread);
290− const styles = toneStyles(threadTone.label);
139+ const tone = threadStatusTone(props.thread);
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)";
291142
292143 return (
293144 <Pressable
294145 ref={containerRef}
295− className="overflow-hidden px-1 py-4"
146+ className="gap-3 rounded-[18px] border px-4 py-4"
296147 style={{
297− borderBottomWidth: props.isLast ? 0 : 1,
298− borderBottomColor: props.palette.border,
148+ backgroundColor,
149+ borderColor,
299150 opacity: props.hidden ? 0 : 1,
300151 }}
301152 onPress={() => {
@@ -308,394 +159,379 @@ function ThreadRow(props: { · 6 unmodified lines
308159 });
309160 }}
310161 >
311− <View className="gap-3">
312− <View className="flex-row items-start gap-4">
313− <View className="flex-1 gap-2">
314− <View className="flex-row items-center gap-2.5">
315− <View
316− className={cx("h-2.5 w-2.5", styles.dot)}
317− style={{ borderRadius: 2, opacity: 0.88 }}
318− />
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%" }} />
162+ <View className="flex-row items-start justify-between gap-3">
163+ <View className="flex-1 gap-2">
164+ <Text className="text-[17px] font-t3-bold" style={{ color: "#171717" }}>
165+ {props.thread.title}
166+ </Text>
167+ <Text className="text-[13px] font-medium leading-[19px]" style={{ color: "#57534e" }}>
168+ {lastConversationLine(props.thread)}
169+ </Text>
358170 </View>
171+ <StatusPill {...tone} />
359172 </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>
360181 </Pressable>
361182 );
362183 }
363184
364−function SectionPanel(props: {
365− readonly children: React.ReactNode;
366− readonly palette: ReturnType<typeof makePalette>;
185+function ProjectSection(props: {
186+ readonly project: ScopedMobileProject;
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;
367195 }) {
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+
368200 return (
369201 <View
370− style={{
371− borderWidth: 1,
372− borderColor: props.palette.border,
373− backgroundColor: props.palette.panel,
374− }}
202+ className="gap-3 rounded-[22px] border px-4 py-4"
203+ style={{ borderColor: "rgba(23,23,23,0.08)" }}
375204 >
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+ )}
377263 </View>
378264 );
379265 }
380266
381−function orderThreadsByRecency(threads: ReadonlyArray<ScopedMobileThread>) {
382− return sortCopy(threads, (left, right) => {
383− const leftTime = new Date(left.updatedAt ?? left.createdAt).getTime();
384− const rightTime = new Date(right.updatedAt ?? right.createdAt).getTime();
385− return rightTime - leftTime;
386− });
387−}
388−
389−function makePalette(isDarkMode: boolean) {
390− if (isDarkMode) {
391− return {
392− canvas: "#11100e",
393− panel: "#1a1815",
394− panelAlt: "#221f1b",
395− border: "rgba(255,255,255,0.08)",
396− text: "#f5f1ea",
397− muted: "#a69e92",
398− rule: "rgba(255,255,255,0.1)",
399− tabActive: "#f5f1ea",
400− tabActiveText: "#171512",
401− tabInactive: "#2a2723",
402− tabInactiveText: "#b2ab9f",
403− rail: "#201d19",
404− action: "#f5f1ea",
405− actionText: "#171512",
406− actionSecondary: "#2b2823",
407− actionSecondaryText: "#f5f1ea",
408− };
267+function CreateThreadModal(props: {
268+ readonly group: MobileRepositoryGroup | null;
269+ readonly visible: boolean;
270+ readonly onClose: () => void;
271+ readonly onSelectProject: (project: ScopedMobileProject) => Promise<void>;
272+}) {
273+ if (!props.group) {
274+ return null;
409275 }
410276
411− return {
412− canvas: "#efe7dc",
413− panel: "#fbf7f1",
414− panelAlt: "#f3ede3",
415− border: "#d8cec1",
416− text: "#1f1b17",
417− muted: "#8a8175",
418− rule: "#ddd2c5",
419− tabActive: "#2c2a2d",
420− tabActiveText: "#f8f4ee",
421− tabInactive: "#ffffff",
422− tabInactiveText: "#b0a79b",
423− rail: "#f3ede3",
424− action: "#2c2a2d",
425− actionText: "#f8f4ee",
426− actionSecondary: "#ffffff",
427− actionSecondaryText: "#1f1b17",
428− };
429−}
430−
431−export function ThreadListScreen(props: ThreadListScreenProps) {
432− const isDarkMode = useColorScheme() === "dark";
433− const palette = makePalette(isDarkMode);
434− const [expandedProjectIds, setExpandedProjectIds] = useState<ReadonlySet<string>>(new Set());
435− const groupedProjects = props.projects.map((project) => ({
436− project,
437− scopedProjectId: scopedProjectKey(project.environmentId, project.id),
438− threads: props.threads.filter(
439− (thread) => thread.environmentId === project.environmentId && thread.projectId === project.id,
440− ),
441− }));
442−
443277 return (
444− <View className="flex-1" style={{ backgroundColor: palette.canvas }}>
445− <View className="absolute inset-x-0 top-0 z-20">
446− <GlassSafeAreaView
447− leftSlot={
448− <View className="flex-row items-center gap-2">
449− <T3Wordmark color={isDarkMode ? "#ffffff" : "#020617"} />
450− <Text
451− className="text-[16px] font-extrabold uppercase"
452− style={{
453− color: isDarkMode ? "rgba(255,255,255,0.72)" : "#020617",
454− letterSpacing: 1.1,
278+ <Modal transparent animationType="slide" visible={props.visible} onRequestClose={props.onClose}>
279+ <View className="flex-1 justify-end" style={{ backgroundColor: "rgba(10,10,10,0.24)" }}>
280+ <View className="gap-4 rounded-t-[28px] bg-stone-50 px-5 pb-8 pt-5">
281+ <View className="gap-1">
282+ <Text className="text-[22px] font-t3-bold" style={{ color: "#171717" }}>
283+ New thread in {props.group.title}
284+ </Text>
285+ <Text className="text-[13px] font-medium leading-[19px]" style={{ color: "#57534e" }}>
286+ Choose which environment should own the new thread.
287+ </Text>
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);
455298 }}
456299 >
457− Code
458− </Text>
459− </View>
460− }
461− />
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">
300+ <View className="flex-row items-start justify-between gap-3">
301+ <View className="flex-1 gap-1">
302+ <Text className="text-[16px] font-t3-bold" style={{ color: "#171717" }}>
303+ {project.environmentLabel}
304+ </Text>
473305 <Text
474− className="text-[11px] font-bold uppercase"
475− style={{ color: palette.muted, letterSpacing: 1.3 }}
306+ className="text-[12px] font-medium leading-[18px]"
307+ style={{ color: "#57534e" }}
476308 >
477− Control board
309+ {project.workspaceRoot}
478310 </Text>
479311 </View>
480−
481− <View className="items-end gap-2">
482− <ConnectionStatusDot
483− state={props.connectionState}
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} />
312+ <View
313+ className="rounded-full px-3 py-2"
314+ style={{ backgroundColor: "rgba(23,23,23,0.06)" }}
315+ >
513316 <Text
514− className="text-[11px] font-bold uppercase"
515− style={{ color: palette.muted, letterSpacing: 1.05 }}
317+ className="text-[11px] font-t3-bold uppercase"
318+ style={{ color: "#171717", letterSpacing: 0.9 }}
516319 >
517− {props.hasClient ? "Connected" : "Awaiting connection"}
320+ {threads.length} thread{threads.length === 1 ? "" : "s"}
518321 </Text>
519322 </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>
564323 </View>
565− </View>
566− </SectionPanel>
324+ </Pressable>
325+ ))}
326+ </ScrollView>
567327
568− {props.threads.length === 0 ? (
569− <EmptyState
570− title={props.hasClient ? "No threads yet" : "No connection yet"}
571− detail={
572− props.hasClient
573− ? "Create a thread below or resume one in another T3 client, then refresh this screen."
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);
328+ <ActionButton icon="xmark" label="Close" onPress={props.onClose} />
329+ </View>
330+ </View>
331+ </Modal>
332+ );
333+}
584334
585− return (
586− <SectionPanel key={scopedProjectId} palette={palette}>
587− <View className="gap-4">
588− <View
589− className="gap-2 px-4 py-4"
590− style={{ borderBottomWidth: 1, borderBottomColor: palette.border }}
591− >
592− <View className="flex-row items-start justify-between gap-3">
593− <View className="flex-1 gap-1.5">
594− <Glyph name="folder" color={palette.muted} size={15} />
595− <Text
596− className="text-[22px] font-extrabold leading-[26px]"
597− style={{ color: palette.text }}
598− >
599− {project.title}
600− </Text>
601− </View>
335+export function ThreadListScreen(props: ThreadListScreenProps) {
336+ const isDarkMode = useColorScheme() === "dark";
337+ const repositoryGroups = useMemo(
338+ () => groupProjectsByRepository({ projects: props.projects, threads: props.threads }),
339+ [props.projects, props.threads],
340+ );
341+ const [createTarget, setCreateTarget] = useState<MobileRepositoryGroup | null>(null);
342+ const [refreshing, setRefreshing] = useState(false);
343+ const isSplitLayout = props.layout.usesSplitView;
344+ const contentHorizontalPadding = isSplitLayout ? 18 : 20;
345+ const panelBorderColor = isSplitLayout ? "rgba(23,23,23,0.08)" : "transparent";
346+ const panelBackgroundColor = isDarkMode ? "#0c1118" : "#f7f7f5";
347+ const refreshThreads = props.onRefresh;
348+
349+ const handleRefresh = useCallback(async (): Promise<void> => {
350+ if (refreshing) {
351+ return;
352+ }
353+
354+ setRefreshing(true);
355+ try {
356+ await refreshThreads();
357+ } finally {
358+ setRefreshing(false);
359+ }
360+ }, [refreshThreads, refreshing]);
602361
603− <View
604− className="flex-row items-center gap-2 px-3 py-2"
605− style={{ backgroundColor: palette.tabActive }}
606− >
607− <Glyph name="stack" color={palette.tabActiveText} size={14} />
608− <Text
609− className="text-[11px] font-bold uppercase"
610− style={{ color: palette.tabActiveText, letterSpacing: 1.1 }}
611− >
612− {threads.length}
613− </Text>
614− </View>
615− </View>
362+ return (
363+ <View
364+ className="flex-1"
365+ style={{
366+ backgroundColor: panelBackgroundColor,
367+ borderRadius: isSplitLayout ? 32 : 0,
368+ borderWidth: isSplitLayout ? 1 : 0,
369+ borderColor: panelBorderColor,
370+ overflow: "hidden",
371+ }}
372+ >
373+ <View className="absolute inset-x-0 top-0 z-20">
374+ <GlassSafeAreaView
375+ leftSlot={props.showBrandWordmark ? <BrandMark compact dark={isDarkMode} /> : null}
376+ />
377+ </View>
616378
617− <Text className="text-[13px] leading-[18px]" style={{ color: palette.muted }}>
618− {project.environmentLabel} · {project.workspaceRoot}
619− </Text>
379+ <ScrollView
380+ className="flex-1"
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>
620421
621− <ProjectActionButton
622− palette={palette}
623− label="New thread"
624− icon="spark"
625− onPress={() => void props.onCreateThread(project)}
626− />
627− </View>
422+ <View className="flex-row gap-3">
423+ <StatCard label="Repos" value={String(repositoryGroups.length)} icon="shippingbox" />
424+ <StatCard
425+ label="Threads"
426+ value={String(props.threads.length)}
427+ icon="square.stack.3d.up"
428+ />
429+ <StatCard label="Envs" value={String(props.connectedEnvironmentCount)} icon="link" />
430+ </View>
628431
629− <Animated.View className="px-4 py-1" layout={LinearTransition.duration(220)}>
630− {latestThread ? (
631− <ThreadRow
632− key={scopedThreadKey(latestThread.environmentId, latestThread.id)}
633− thread={latestThread}
634− hidden={
635− props.hiddenThreadKey ===
636− scopedThreadKey(latestThread.environmentId, latestThread.id)
637− }
638− isLast={!isExpanded || remainingThreads.length === 0}
639− onPress={(sourceFrame) => props.onSelectThread(latestThread, sourceFrame)}
640− palette={palette}
641− />
642− ) : (
643− <View className="px-1 py-6">
644− <EmptyState
645− title="No threads in this project"
646− detail="Start a new thread here to begin working from mobile."
647− />
648− </View>
649− )}
432+ <View className="flex-row gap-3">
433+ <ActionButton
434+ icon={props.hasClient ? "link" : "iphone"}
435+ label={props.hasClient ? "Edit link" : "Connect"}
436+ primary
437+ onPress={props.onOpenConnectionEditor}
438+ />
439+ <ActionButton
440+ icon="arrow.clockwise"
441+ label="Refresh"
442+ onPress={() => void handleRefresh()}
443+ />
444+ </View>
445+ </Animated.View>
446+
447+ {repositoryGroups.length === 0 ? (
448+ <EmptyState
449+ title={props.hasClient ? "No repositories yet" : "No connection yet"}
450+ detail={
451+ props.hasClient
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>
650491
651− {isExpanded && remainingThreads.length > 0 ? (
652− <Animated.View
653− entering={FadeInDown.duration(220)}
654− exiting={FadeOutUp.duration(180)}
655− layout={LinearTransition.duration(220)}
656− >
657− {remainingThreads.map((thread, index) => (
658− <ThreadRow
659− key={scopedThreadKey(thread.environmentId, thread.id)}
660− thread={thread}
661− hidden={
662− props.hiddenThreadKey ===
663− scopedThreadKey(thread.environmentId, thread.id)
664− }
665− isLast={index === remainingThreads.length - 1}
666− onPress={(sourceFrame) => props.onSelectThread(thread, sourceFrame)}
667− palette={palette}
668− />
669− ))}
670− </Animated.View>
671− ) : null}
492+ <Pressable
493+ className="rounded-full px-3 py-2"
494+ style={{ backgroundColor: "#171717" }}
495+ onPress={() => {
496+ if (group.projects.length === 1) {
497+ void props.onCreateThread(group.projects[0]!.project);
498+ return;
499+ }
500+ setCreateTarget(group);
501+ }}
502+ >
503+ <Text
504+ className="text-[11px] font-t3-bold uppercase"
505+ style={{ color: "#fafaf9", letterSpacing: 0.9 }}
506+ >
507+ New thread
508+ </Text>
509+ </Pressable>
510+ </View>
672511
673− {remainingThreads.length > 0 ? (
674− <ExpandThreadsToggle
675− expanded={isExpanded}
676− hiddenCount={remainingThreads.length}
677− palette={palette}
678− onPress={() => {
679− setExpandedProjectIds((current) => {
680− const next = new Set(current);
681− if (next.has(scopedProjectId)) {
682− next.delete(scopedProjectId);
683− } else {
684− next.add(scopedProjectId);
685− }
686− return next;
687− });
688− }}
689− />
690− ) : null}
691− </Animated.View>
692− </View>
693− </SectionPanel>
694− );
695− })}
696− </View>
697− </View>
512+ <View className="gap-3">
513+ {group.projects.map(({ project, threads }) => (
514+ <ProjectSection
515+ key={project.environmentId + project.id}
516+ project={project}
517+ threads={threads}
518+ hiddenThreadKey={props.hiddenThreadKey}
519+ selectedThreadKey={props.selectedThreadKey}
520+ onCreateThread={props.onCreateThread}
521+ onSelectThread={props.onSelectThread}
522+ />
523+ ))}
524+ </View>
525+ </Animated.View>
526+ ))}
698527 </ScrollView>
528+
529+ <CreateThreadModal
530+ group={createTarget}
531+ visible={createTarget !== null}
532+ onClose={() => setCreateTarget(null)}
533+ onSelectProject={props.onCreateThread}
534+ />
699535 </View>
700536 );
701537 }
apps/mobile/src/features/threads/ThreadNavigationDrawer.tsx+250−0
@@ -0,0 +1,250 @@
1+import { SymbolView } from "expo-symbols";
2+import { useCallback, useEffect, useMemo, useState } from "react";
3+import { Modal, Pressable, ScrollView, useWindowDimensions, View } from "react-native";
4+import { Gesture, GestureDetector } from "react-native-gesture-handler";
5+import { useSafeAreaInsets } from "react-native-safe-area-context";
6+import Animated, {
7+ runOnJS,
8+ useAnimatedStyle,
9+ useSharedValue,
10+ withTiming,
11+} from "react-native-reanimated";
12+
13+import { AppText as Text } from "../../components/AppText";
14+import { StatusPill } from "../../components/StatusPill";
15+import { groupProjectsByRepository } from "../../lib/repositoryGroups";
16+import type { ScopedMobileProject, ScopedMobileThread } from "../../lib/scopedEntities";
17+import { scopedThreadKey } from "../../lib/scopedEntities";
18+import { relativeTime } from "../../lib/time";
19+import { threadStatusTone } from "./threadPresentation";
20+
21+function compareThreadActivity(left: ScopedMobileThread, right: ScopedMobileThread): number {
22+ return (
23+ new Date(right.updatedAt ?? right.createdAt).getTime() -
24+ new Date(left.updatedAt ?? left.createdAt).getTime() || left.title.localeCompare(right.title)
25+ );
26+}
27+
28+export function ThreadNavigationDrawer(props: {
29+ readonly visible: boolean;
30+ readonly projects: ReadonlyArray<ScopedMobileProject>;
31+ readonly threads: ReadonlyArray<ScopedMobileThread>;
32+ readonly selectedThreadKey: string | null;
33+ readonly onClose: () => void;
34+ readonly onSelectThread: (thread: ScopedMobileThread) => void;
35+ readonly onStartNewTask: () => void;
36+}) {
37+ const insets = useSafeAreaInsets();
38+ const { width } = useWindowDimensions();
39+ const drawerWidth = Math.min(width * 0.84, 360);
40+ const [mounted, setMounted] = useState(props.visible);
41+ const translateX = useSharedValue(-drawerWidth);
42+ const overlayOpacity = useSharedValue(0);
43+ const repositoryGroups = useMemo(
44+ () => groupProjectsByRepository({ projects: props.projects, threads: props.threads }),
45+ [props.projects, props.threads],
46+ );
47+ const groupedThreads = useMemo(
48+ () =>
49+ repositoryGroups.map((group) => ({
50+ key: group.key,
51+ title: group.projects[0]?.project.title ?? group.title,
52+ threads: group.projects
53+ .flatMap((projectGroup) => projectGroup.threads)
54+ .sort(compareThreadActivity), // oxlint-disable-line eslint-plugin-unicorn/no-array-sort
55+ })),
56+ [repositoryGroups],
57+ );
58+
59+ useEffect(() => {
60+ if (props.visible) {
61+ setMounted(true);
62+ translateX.value = withTiming(0, { duration: 240 });
63+ overlayOpacity.value = withTiming(1, { duration: 220 });
64+ return;
65+ }
66+
67+ overlayOpacity.value = withTiming(0, { duration: 180 });
68+ translateX.value = withTiming(-drawerWidth, { duration: 220 }, (finished) => {
69+ if (finished) {
70+ runOnJS(setMounted)(false);
71+ }
72+ });
73+ }, [drawerWidth, overlayOpacity, props.visible, translateX]);
74+
75+ const closeDrawer = useCallback(() => {
76+ props.onClose();
77+ }, [props]);
78+
79+ const panGesture = useMemo(
80+ () =>
81+ Gesture.Pan()
82+ .activeOffsetX([-12, 12])
83+ .failOffsetY([-24, 24])
84+ .onUpdate((event) => {
85+ translateX.value = Math.min(0, event.translationX);
86+ })
87+ .onEnd((event) => {
88+ const shouldClose = event.translationX < -drawerWidth * 0.2 || event.velocityX < -500;
89+ if (shouldClose) {
90+ runOnJS(closeDrawer)();
91+ return;
92+ }
93+
94+ translateX.value = withTiming(0, { duration: 180 });
95+ }),
96+ [closeDrawer, drawerWidth, translateX],
97+ );
98+
99+ const drawerStyle = useAnimatedStyle(() => ({
100+ transform: [{ translateX: translateX.value }],
101+ }));
102+
103+ const backdropStyle = useAnimatedStyle(() => ({
104+ opacity: overlayOpacity.value,
105+ }));
106+
107+ if (!mounted) {
108+ return null;
109+ }
110+
111+ return (
112+ <Modal transparent visible={mounted} onRequestClose={props.onClose} statusBarTranslucent>
113+ <View style={{ flex: 1 }}>
114+ <Animated.View
115+ pointerEvents="none"
116+ style={[
117+ {
118+ position: "absolute",
119+ left: 0,
120+ right: 0,
121+ top: 0,
122+ bottom: 0,
123+ backgroundColor: "rgba(15,23,42,0.22)",
124+ },
125+ backdropStyle,
126+ ]}
127+ />
128+ <Pressable
129+ style={{ position: "absolute", left: 0, right: 0, top: 0, bottom: 0 }}
130+ onPress={props.onClose}
131+ />
132+
133+ <GestureDetector gesture={panGesture}>
134+ <Animated.View
135+ style={[
136+ {
137+ position: "absolute",
138+ left: 0,
139+ top: 0,
140+ bottom: 0,
141+ width: drawerWidth,
142+ backgroundColor: "rgba(250,248,242,0.99)",
143+ paddingTop: insets.top + 10,
144+ paddingBottom: Math.max(insets.bottom, 18),
145+ boxShadow: "20px 0 36px rgba(15,23,42,0.12)",
146+ },
147+ drawerStyle,
148+ ]}
149+ >
150+ <View className="flex-row items-center justify-between px-4 pb-5">
151+ <Text className="text-[26px] font-t3-bold" style={{ color: "#171717" }}>
152+ Threads
153+ </Text>
154+ <Pressable
155+ onPress={() => {
156+ props.onClose();
157+ props.onStartNewTask();
158+ }}
159+ className="h-11 w-11 items-center justify-center rounded-full"
160+ style={{ backgroundColor: "#171717" }}
161+ >
162+ <SymbolView
163+ name="square.and.pencil"
164+ size={17}
165+ tintColor="#fafaf9"
166+ type="monochrome"
167+ />
168+ </Pressable>
169+ </View>
170+
171+ <ScrollView
172+ showsVerticalScrollIndicator={false}
173+ contentContainerStyle={{
174+ gap: 20,
175+ paddingHorizontal: 14,
176+ paddingBottom: Math.max(insets.bottom, 18) + 12,
177+ }}
178+ >
179+ {groupedThreads.map((group) => (
180+ <View key={group.key} className="gap-3">
181+ <Text
182+ className="px-1 text-[15px] font-t3-bold"
183+ style={{ color: "#78716c", letterSpacing: -0.2 }}
184+ >
185+ {group.title}
186+ </Text>
187+
188+ <View
189+ className="overflow-hidden rounded-[22px]"
190+ style={{ backgroundColor: "#ffffff" }}
191+ >
192+ {group.threads.length === 0 ? (
193+ <View className="px-4 py-4">
194+ <Text className="text-[14px] font-medium" style={{ color: "#a8a29e" }}>
195+ No threads yet
196+ </Text>
197+ </View>
198+ ) : (
199+ group.threads.map((thread, index) => {
200+ const threadKey = scopedThreadKey(thread.environmentId, thread.id);
201+ const selected = props.selectedThreadKey === threadKey;
202+
203+ return (
204+ <Pressable
205+ key={threadKey}
206+ onPress={() => {
207+ props.onSelectThread(thread);
208+ props.onClose();
209+ }}
210+ style={{
211+ paddingHorizontal: 16,
212+ paddingVertical: 15,
213+ borderTopWidth: index === 0 ? 0 : 1,
214+ borderTopColor: "rgba(23,23,23,0.06)",
215+ backgroundColor: selected ? "rgba(23,23,23,0.04)" : "transparent",
216+ }}
217+ >
218+ <View className="flex-row items-start justify-between gap-3">
219+ <View className="flex-1 gap-1">
220+ <Text
221+ className="text-[16px] font-t3-bold"
222+ numberOfLines={1}
223+ style={{ color: "#171717" }}
224+ >
225+ {thread.title}
226+ </Text>
227+ <Text
228+ className="text-[13px] font-medium"
229+ numberOfLines={1}
230+ style={{ color: "#78716c" }}
231+ >
232+ {relativeTime(thread.updatedAt ?? thread.createdAt)}
233+ </Text>
234+ </View>
235+ <StatusPill {...threadStatusTone(thread)} />
236+ </View>
237+ </Pressable>
238+ );
239+ })
240+ )}
241+ </View>
242+ </View>
243+ ))}
244+ </ScrollView>
245+ </Animated.View>
246+ </GestureDetector>
247+ </View>
248+ </Modal>
249+ );
250+}
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
7676 const candidate = segments.at(-1) ?? config.cwd;
7777 return /^t3[-_\s]?code$/i.test(candidate) ? "T3 Code" : candidate;
7878 }
79− return serverUrl ?? "T3 Remote";
79+ return serverUrl ?? "T3 Code";
8080 }
apps/mobile/src/lib/mobileLayout.test.ts+36−0
@@ -0,0 +1,36 @@
1+import { describe, expect, it } from "vitest";
2+
3+import { deriveMobileLayout } from "./mobileLayout";
4+
5+describe("deriveMobileLayout", () => {
6+ it("keeps phones in compact mode", () => {
7+ expect(deriveMobileLayout({ width: 393, height: 852 })).toEqual({
8+ variant: "compact",
9+ usesSplitView: false,
10+ listPaneWidth: null,
11+ shellPadding: 0,
12+ });
13+ });
14+
15+ it("avoids split mode in short landscape phone layouts", () => {
16+ expect(deriveMobileLayout({ width: 852, height: 393 }).variant).toBe("compact");
17+ });
18+
19+ it("enables split mode on tablet-sized displays", () => {
20+ expect(deriveMobileLayout({ width: 744, height: 1133 })).toMatchObject({
21+ variant: "split",
22+ usesSplitView: true,
23+ listPaneWidth: 320,
24+ shellPadding: 14,
25+ });
26+ });
27+
28+ it("caps the sidebar width on large tablets", () => {
29+ expect(deriveMobileLayout({ width: 1366, height: 1024 })).toMatchObject({
30+ variant: "split",
31+ usesSplitView: true,
32+ listPaneWidth: 420,
33+ shellPadding: 20,
34+ });
35+ });
36+});
apps/mobile/src/lib/mobileLayout.ts+37−0
@@ -0,0 +1,37 @@
1+function clamp(value: number, min: number, max: number): number {
2+ return Math.min(Math.max(value, min), max);
3+}
4+
5+export type MobileLayoutVariant = "compact" | "split";
6+
7+export interface MobileLayout {
8+ readonly variant: MobileLayoutVariant;
9+ readonly usesSplitView: boolean;
10+ readonly listPaneWidth: number | null;
11+ readonly shellPadding: number;
12+}
13+
14+export function deriveMobileLayout(input: {
15+ readonly width: number;
16+ readonly height: number;
17+}): MobileLayout {
18+ const { width, height } = input;
19+ const shortestEdge = Math.min(width, height);
20+ const wideEnoughForSplit = width >= 900 || (width >= 700 && shortestEdge >= 700);
21+
22+ if (!wideEnoughForSplit) {
23+ return {
24+ variant: "compact",
25+ usesSplitView: false,
26+ listPaneWidth: null,
27+ shellPadding: 0,
28+ };
29+ }
30+
31+ return {
32+ variant: "split",
33+ usesSplitView: true,
34+ listPaneWidth: clamp(Math.round(width * 0.34), 320, 420),
35+ shellPadding: width >= 1180 ? 20 : 14,
36+ };
37+}
apps/mobile/src/lib/orchestration.test.ts+104−0
@@ -0,0 +1,104 @@
1+import { describe, expect, it } from "vitest";
2+
3+import { EventId, ProjectId } from "@t3tools/contracts";
4+import type { OrchestrationReadModel } from "@t3tools/contracts";
5+
6+import { applyRealtimeEvent } from "./orchestration";
7+
8+const baseSnapshot: OrchestrationReadModel = {
9+ snapshotSequence: 0,
10+ updatedAt: "2026-04-01T00:00:00.000Z",
11+ projects: [],
12+ threads: [],
13+};
14+
15+const baseEventFields = {
16+ eventId: EventId.makeUnsafe("event-1"),
17+ commandId: null,
18+ causationEventId: null,
19+ correlationId: null,
20+ metadata: {},
21+} as const;
22+
23+describe("applyRealtimeEvent project identity handling", () => {
24+ it("preserves repository identity on project.created", () => {
25+ const next = applyRealtimeEvent(baseSnapshot, {
26+ ...baseEventFields,
27+ sequence: 1,
28+ occurredAt: "2026-04-01T01:00:00.000Z",
29+ aggregateKind: "project",
30+ aggregateId: ProjectId.makeUnsafe("project-1"),
31+ type: "project.created",
32+ payload: {
33+ projectId: ProjectId.makeUnsafe("project-1"),
34+ title: "T3 Code",
35+ workspaceRoot: "/repo",
36+ repositoryIdentity: {
37+ canonicalKey: "github.com/t3tools/t3code",
38+ locator: {
39+ source: "git-remote",
40+ remoteName: "origin",
41+ remoteUrl: "git@github.com:t3tools/t3code.git",
42+ },
43+ provider: "github",
44+ owner: "t3tools",
45+ name: "t3code",
46+ },
47+ defaultModelSelection: null,
48+ scripts: [],
49+ createdAt: "2026-04-01T01:00:00.000Z",
50+ updatedAt: "2026-04-01T01:00:00.000Z",
51+ },
52+ });
53+
54+ expect(next.projects[0]?.repositoryIdentity?.canonicalKey).toBe("github.com/t3tools/t3code");
55+ });
56+
57+ it("applies repository identity updates on project.meta-updated", () => {
58+ const seeded = applyRealtimeEvent(baseSnapshot, {
59+ ...baseEventFields,
60+ sequence: 1,
61+ occurredAt: "2026-04-01T01:00:00.000Z",
62+ aggregateKind: "project",
63+ aggregateId: ProjectId.makeUnsafe("project-1"),
64+ type: "project.created",
65+ payload: {
66+ projectId: ProjectId.makeUnsafe("project-1"),
67+ title: "T3 Code",
68+ workspaceRoot: "/repo",
69+ repositoryIdentity: null,
70+ defaultModelSelection: null,
71+ scripts: [],
72+ createdAt: "2026-04-01T01:00:00.000Z",
73+ updatedAt: "2026-04-01T01:00:00.000Z",
74+ },
75+ });
76+
77+ const next = applyRealtimeEvent(seeded, {
78+ ...baseEventFields,
79+ eventId: EventId.makeUnsafe("event-2"),
80+ sequence: 2,
81+ occurredAt: "2026-04-01T02:00:00.000Z",
82+ aggregateKind: "project",
83+ aggregateId: ProjectId.makeUnsafe("project-1"),
84+ type: "project.meta-updated",
85+ payload: {
86+ projectId: ProjectId.makeUnsafe("project-1"),
87+ repositoryIdentity: {
88+ canonicalKey: "github.com/t3tools/t3code",
89+ locator: {
90+ source: "git-remote",
91+ remoteName: "origin",
92+ remoteUrl: "git@github.com:t3tools/t3code.git",
93+ },
94+ provider: "github",
95+ owner: "t3tools",
96+ name: "t3code",
97+ },
98+ updatedAt: "2026-04-01T02:00:00.000Z",
99+ },
100+ });
101+
102+ expect(next.projects[0]?.repositoryIdentity?.canonicalKey).toBe("github.com/t3tools/t3code");
103+ });
104+});
apps/mobile/src/lib/orchestration.ts+4−0
@@ -71,6 +71,7 @@ export function applyRealtimeEvent( · 70 unmodified lines
7171 id: event.payload.projectId,
7272 title: event.payload.title,
7373 workspaceRoot: event.payload.workspaceRoot,
74+ repositoryIdentity: event.payload.repositoryIdentity ?? null,
7475 defaultModelSelection: event.payload.defaultModelSelection,
7576 scripts: event.payload.scripts,
7677 createdAt: event.payload.createdAt,
@@ -88,6 +89,9 @@ export function applyRealtimeEvent( · 11 unmodified lines
8889 ...(event.payload.workspaceRoot !== undefined
8990 ? { workspaceRoot: event.payload.workspaceRoot }
9091 : {}),
92+ ...(event.payload.repositoryIdentity !== undefined
93+ ? { repositoryIdentity: event.payload.repositoryIdentity }
94+ : {}),
9195 ...(event.payload.defaultModelSelection !== undefined
9296 ? { defaultModelSelection: event.payload.defaultModelSelection }
9397 : {}),
apps/mobile/src/lib/pairingQr.test.ts+25−0
@@ -0,0 +1,25 @@
1+import { describe, expect, it } from "vitest";
2+
3+import { extractPairingUrlFromQrPayload } from "./pairingQr";
4+
5+describe("extractPairingUrlFromQrPayload", () => {
6+ it("trims raw pairing urls from qr payloads", () => {
7+ expect(
8+ extractPairingUrlFromQrPayload(" https://remote.example.com/pair#token=pairing-token "),
9+ ).toBe("https://remote.example.com/pair#token=pairing-token");
10+ });
11+
12+ it("unwraps mobile deep links that carry an encoded pairing url", () => {
13+ expect(
14+ extractPairingUrlFromQrPayload(
15+ "t3code://pair?pairingUrl=https%3A%2F%2Fremote.example.com%2Fpair%23token%3Dpairing-token",
16+ ),
17+ ).toBe("https://remote.example.com/pair#token=pairing-token");
18+ });
19+
20+ it("rejects empty qr payloads", () => {
21+ expect(() => extractPairingUrlFromQrPayload(" ")).toThrow(
22+ "Scanned QR code did not contain a pairing URL.",
23+ );
24+ });
25+});
apps/mobile/src/lib/pairingQr.ts+22−0
@@ -0,0 +1,22 @@
1+const MOBILE_PAIRING_URL_PARAM = "pairingUrl";
2+
3+export function extractPairingUrlFromQrPayload(payload: string): string {
4+ const trimmed = payload.trim();
5+ if (!trimmed) {
6+ throw new Error("Scanned QR code did not contain a pairing URL.");
7+ }
8+
9+ try {
10+ const url = new URL(trimmed);
11+ if (url.protocol === "t3code:") {
12+ const pairingUrl = url.searchParams.get(MOBILE_PAIRING_URL_PARAM)?.trim() ?? "";
13+ if (pairingUrl.length > 0) {
14+ return pairingUrl;
15+ }
16+ }
17+ } catch {
18+ // Treat non-URL payloads as raw pairing-url text so the normal input validation can decide.
19+ }
20+
21+ return trimmed;
22+}
apps/mobile/src/lib/remoteClient.ts+62−0
@@ -1,5 +1,19 @@
11 import {
22 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,
317 ORCHESTRATION_WS_METHODS,
418 type OrchestrationEvent,
519 type OrchestrationReadModel,
@@ -260,6 +274,54 @@ export class RemoteClient { · 254 unmodified lines
260274 );
261275 }
262276
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+
263325 private emit(event: RemoteClientEvent): void {
264326 for (const listener of this.listeners) {
265327 try {
apps/mobile/src/lib/repositoryGroups.test.ts+136−0
@@ -0,0 +1,136 @@
1+import { describe, expect, it } from "vitest";
2+
3+import { ProjectId, ThreadId } from "@t3tools/contracts";
4+
5+import type { ScopedMobileProject, ScopedMobileThread } from "./scopedEntities";
6+import { groupProjectsByRepository } from "./repositoryGroups";
7+
8+function makeProject(
9+ input: Partial<ScopedMobileProject> &
10+ Pick<ScopedMobileProject, "environmentId" | "environmentLabel" | "id" | "title">,
11+): ScopedMobileProject {
12+ return {
13+ workspaceRoot: `/workspaces/${input.id}`,
14+ repositoryIdentity: null,
15+ defaultModelSelection: null,
16+ scripts: [],
17+ createdAt: "2026-04-01T00:00:00.000Z",
18+ updatedAt: "2026-04-01T00:00:00.000Z",
19+ deletedAt: null,
20+ ...input,
21+ };
22+}
23+
24+function makeThread(
25+ input: Partial<ScopedMobileThread> &
26+ Pick<
27+ ScopedMobileThread,
28+ "environmentId" | "environmentLabel" | "id" | "projectId" | "title" | "modelSelection"
29+ >,
30+): ScopedMobileThread {
31+ return {
32+ runtimeMode: "full-access",
33+ interactionMode: "default",
34+ branch: null,
35+ worktreePath: null,
36+ latestTurn: null,
37+ createdAt: "2026-04-01T00:00:00.000Z",
38+ updatedAt: "2026-04-01T00:00:00.000Z",
39+ archivedAt: null,
40+ deletedAt: null,
41+ messages: [],
42+ proposedPlans: [],
43+ activities: [],
44+ checkpoints: [],
45+ session: null,
46+ ...input,
47+ };
48+}
49+
50+describe("groupProjectsByRepository", () => {
51+ it("groups projects across environments by repository identity", () => {
52+ const repoIdentity = {
53+ canonicalKey: "github.com/t3tools/t3code",
54+ locator: {
55+ source: "git-remote" as const,
56+ remoteName: "origin",
57+ remoteUrl: "git@github.com:t3tools/t3code.git",
58+ },
59+ provider: "github",
60+ owner: "t3tools",
61+ name: "t3code",
62+ displayName: "T3 Code",
63+ };
64+
65+ const projects = [
66+ makeProject({
67+ environmentId: "env-local",
68+ environmentLabel: "Local",
69+ id: ProjectId.makeUnsafe("project-local"),
70+ title: "T3 Code",
71+ repositoryIdentity: repoIdentity,
72+ }),
73+ makeProject({
74+ environmentId: "env-staging",
75+ environmentLabel: "Staging",
76+ id: ProjectId.makeUnsafe("project-staging"),
77+ title: "T3 Code",
78+ repositoryIdentity: repoIdentity,
79+ }),
80+ ];
81+
82+ const threads = [
83+ makeThread({
84+ environmentId: "env-staging",
85+ environmentLabel: "Staging",
86+ id: ThreadId.makeUnsafe("thread-2"),
87+ projectId: ProjectId.makeUnsafe("project-staging"),
88+ title: "Fix reconnect flow",
89+ modelSelection: { provider: "codex", model: "gpt-5.4" },
90+ updatedAt: "2026-04-02T12:00:00.000Z",
91+ }),
92+ makeThread({
93+ environmentId: "env-local",
94+ environmentLabel: "Local",
95+ id: ThreadId.makeUnsafe("thread-1"),
96+ projectId: ProjectId.makeUnsafe("project-local"),
97+ title: "Polish mobile shell",
98+ modelSelection: { provider: "codex", model: "gpt-5.4" },
99+ updatedAt: "2026-04-03T12:00:00.000Z",
100+ }),
101+ ];
102+
103+ const groups = groupProjectsByRepository({ projects, threads });
104+
105+ expect(groups).toHaveLength(1);
106+ expect(groups[0]).toMatchObject({
107+ key: "github.com/t3tools/t3code",
108+ title: "T3 Code",
109+ subtitle: "t3tools/t3code",
110+ projectCount: 2,
111+ threadCount: 2,
112+ });
113+ expect(groups[0]?.projects.map((entry) => entry.project.environmentLabel)).toEqual([
114+ "Local",
115+ "Staging",
116+ ]);
117+ });
118+
119+ it("falls back to a scoped project key when repository identity is unavailable", () => {
120+ const projects = [
121+ makeProject({
122+ environmentId: "env-local",
123+ environmentLabel: "Local",
124+ id: ProjectId.makeUnsafe("project-local"),
125+ title: "Scratchpad",
126+ }),
127+ ];
128+
129+ const groups = groupProjectsByRepository({ projects, threads: [] });
130+
131+ expect(groups).toHaveLength(1);
132+ expect(groups[0]?.key).toBe("env-local:project-local");
133+ expect(groups[0]?.title).toBe("Scratchpad");
134+ expect(groups[0]?.subtitle).toBeNull();
135+ });
136+});
apps/mobile/src/lib/repositoryGroups.ts+135−0
@@ -0,0 +1,135 @@
1+import type { RepositoryIdentity } from "@t3tools/contracts";
2+
3+import { sortCopy } from "./arrayCompat";
4+import type { ScopedMobileProject, ScopedMobileThread } from "./scopedEntities";
5+import { scopedProjectKey } from "./scopedEntities";
6+
7+export interface MobileRepositoryProjectGroup {
8+ readonly key: string;
9+ readonly project: ScopedMobileProject;
10+ readonly threads: ReadonlyArray<ScopedMobileThread>;
11+ readonly latestActivityAt: string;
12+}
13+
14+export interface MobileRepositoryGroup {
15+ readonly key: string;
16+ readonly title: string;
17+ readonly subtitle: string | null;
18+ readonly repositoryIdentity: RepositoryIdentity | null;
19+ readonly projectCount: number;
20+ readonly threadCount: number;
21+ readonly latestActivityAt: string;
22+ readonly projects: ReadonlyArray<MobileRepositoryProjectGroup>;
23+}
24+
25+function compareIsoDateDescending(left: string, right: string): number {
26+ return new Date(right).getTime() - new Date(left).getTime();
27+}
28+
29+function deriveRepositoryGroupKey(project: ScopedMobileProject): string {
30+ return (
31+ project.repositoryIdentity?.canonicalKey ?? scopedProjectKey(project.environmentId, project.id)
32+ );
33+}
34+
35+function deriveRepositoryTitle(project: ScopedMobileProject): string {
36+ const identity = project.repositoryIdentity;
37+ return identity?.displayName ?? identity?.name ?? project.title;
38+}
39+
40+function deriveRepositorySubtitle(identity: RepositoryIdentity | null | undefined): string | null {
41+ if (!identity) {
42+ return null;
43+ }
44+ if (identity.owner && identity.name) {
45+ return `${identity.owner}/${identity.name}`;
46+ }
47+ return identity.canonicalKey;
48+}
49+
50+function deriveProjectLatestActivity(
51+ project: ScopedMobileProject,
52+ threads: ReadonlyArray<ScopedMobileThread>,
53+): string {
54+ const latestThread = threads[0];
55+ return latestThread?.updatedAt ?? latestThread?.createdAt ?? project.updatedAt;
56+}
57+
58+export function groupProjectsByRepository(input: {
59+ readonly projects: ReadonlyArray<ScopedMobileProject>;
60+ readonly threads: ReadonlyArray<ScopedMobileThread>;
61+}): ReadonlyArray<MobileRepositoryGroup> {
62+ const threadsByProjectKey = new Map<string, ScopedMobileThread[]>();
63+
64+ for (const thread of input.threads) {
65+ const key = scopedProjectKey(thread.environmentId, thread.projectId);
66+ const existing = threadsByProjectKey.get(key);
67+ if (existing) {
68+ existing.push(thread);
69+ } else {
70+ threadsByProjectKey.set(key, [thread]);
71+ }
72+ }
73+
74+ const grouped = new Map<string, MobileRepositoryGroup>();
75+
76+ for (const project of input.projects) {
77+ const key = deriveRepositoryGroupKey(project);
78+ const projectKey = scopedProjectKey(project.environmentId, project.id);
79+ const threads = sortCopy(threadsByProjectKey.get(projectKey) ?? [], (left, right) =>
80+ compareIsoDateDescending(
81+ left.updatedAt ?? left.createdAt,
82+ right.updatedAt ?? right.createdAt,
83+ ),
84+ );
85+ const latestActivityAt = deriveProjectLatestActivity(project, threads);
86+ const projectGroup: MobileRepositoryProjectGroup = {
87+ key: projectKey,
88+ project,
89+ threads,
90+ latestActivityAt,
91+ };
92+
93+ const existing = grouped.get(key);
94+ if (!existing) {
95+ grouped.set(key, {
96+ key,
97+ title: deriveRepositoryTitle(project),
98+ subtitle: deriveRepositorySubtitle(project.repositoryIdentity),
99+ repositoryIdentity: project.repositoryIdentity ?? null,
100+ projectCount: 1,
101+ threadCount: threads.length,
102+ latestActivityAt,
103+ projects: [projectGroup],
104+ });
105+ continue;
106+ }
107+
108+ grouped.set(key, {
109+ ...existing,
110+ title: existing.repositoryIdentity ? existing.title : deriveRepositoryTitle(project),
111+ subtitle: existing.subtitle ?? deriveRepositorySubtitle(project.repositoryIdentity),
112+ repositoryIdentity: existing.repositoryIdentity ?? project.repositoryIdentity ?? null,
113+ projectCount: existing.projectCount + 1,
114+ threadCount: existing.threadCount + threads.length,
115+ latestActivityAt:
116+ compareIsoDateDescending(existing.latestActivityAt, latestActivityAt) > 0
117+ ? latestActivityAt
118+ : existing.latestActivityAt,
119+ projects: sortCopy(
120+ [...existing.projects, projectGroup],
121+ (left, right) =>
122+ compareIsoDateDescending(left.latestActivityAt, right.latestActivityAt) ||
123+ left.project.environmentLabel.localeCompare(right.project.environmentLabel) ||
124+ left.project.title.localeCompare(right.project.title),
125+ ),
126+ });
127+ }
128+
129+ return sortCopy(
130+ [...grouped.values()],
131+ (left, right) =>
132+ compareIsoDateDescending(left.latestActivityAt, right.latestActivityAt) ||
133+ left.title.localeCompare(right.title),
134+ );
135+}
apps/mobile/src/lib/storage.ts+2−2
@@ -2,7 +2,7 @@ import { Platform } from "react-native"; · 1 unmodified line
22
33 import type { SavedRemoteConnection } from "./connection";
44
5−const CONNECTIONS_KEY = "t3remote:connections";
5+const CONNECTIONS_KEY = "t3code:connections";
66 const memoryStorage = new Map<string, string>();
77
88 type AsyncStorageModule = typeof import("@react-native-async-storage/async-storage");
@@ -89,7 +89,7 @@ async function removeStorageItem(key: string): Promise<void> { · 80 unmodified lines
8989 }
9090
9191 function connectionTokenKey(environmentId: string): string {
92− return `t3remote:bearer-token:${environmentId}`;
92+ return `t3code:bearer-token:${environmentId}`;
9393 }
9494
9595 async function loadSecureToken(environmentId: string): Promise<string> {
apps/mobile/src/lib/worktrees.ts+14−0
@@ -0,0 +1,14 @@
1+const WORKTREE_BRANCH_PREFIX = "t3code";
2+
3+function randomHexToken(): string {
4+ const uuid = globalThis.crypto?.randomUUID?.();
5+ if (uuid) {
6+ return uuid.replace(/-/g, "").slice(0, 8).toLowerCase();
7+ }
8+
9+ return Math.random().toString(16).slice(2, 10).padEnd(8, "0");
10+}
11+
12+export function buildTemporaryWorktreeBranchName(): string {
13+ return `${WORKTREE_BRANCH_PREFIX}/${randomHexToken()}`;
14+}
apps/web/src/components/GitActionsControl.logic.ts+14−361
@@ -1,363 +1,16 @@
1−import type {
2− GitRunStackedActionResult,
3− GitStackedAction,
4− GitStatusResult,
5−} from "@t3tools/contracts";
1+export {
2+ buildGitActionProgressStages,
3+ buildMenuItems,
4+ getGitActionDisabledReason,
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";
615
7−export type GitActionIconName = "commit" | "push" | "pr";
8−
9−export type GitDialogAction = "commit" | "push" | "create_pr";
10−
11−export interface GitActionMenuItem {
12− id: "commit" | "push" | "pr";
13− label: string;
14− disabled: boolean;
15− icon: GitActionIconName;
16− kind: "open_dialog" | "open_pr";
17− dialogAction?: GitDialogAction;
18−}
19−
20−export interface GitQuickAction {
21− label: string;
22− disabled: boolean;
23− kind: "run_action" | "run_pull" | "open_pr" | "show_hint";
24− action?: GitStackedAction;
25− hint?: string;
26−}
27−
28−export interface DefaultBranchActionDialogCopy {
29− title: string;
30− description: string;
31− continueLabel: string;
32−}
33−
34−export type DefaultBranchConfirmableAction =
35− | "push"
36− | "create_pr"
37− | "commit_push"
38− | "commit_push_pr";
39−
40−export function buildGitActionProgressStages(input: {
41− action: GitStackedAction;
42− hasCustomCommitMessage: boolean;
43− hasWorkingTreeChanges: boolean;
44− pushTarget?: string;
45− featureBranch?: boolean;
46− shouldPushBeforePr?: boolean;
47−}): string[] {
48− const branchStages = input.featureBranch ? ["Preparing feature branch..."] : [];
49− const pushStage = input.pushTarget ? `Pushing to ${input.pushTarget}...` : "Pushing...";
50− const prStages = [
51− "Preparing PR...",
52− "Generating PR content...",
53− "Creating GitHub pull request...",
54− ];
55−
56− if (input.action === "push") {
57− return [pushStage];
58− }
59− if (input.action === "create_pr") {
60− return input.shouldPushBeforePr ? [pushStage, ...prStages] : prStages;
61− }
62−
63− const shouldIncludeCommitStages = input.action === "commit" || input.hasWorkingTreeChanges;
64− const commitStages = !shouldIncludeCommitStages
65− ? []
66− : input.hasCustomCommitMessage
67− ? ["Committing..."]
68− : ["Generating commit message...", "Committing..."];
69− if (input.action === "commit") {
70− return [...branchStages, ...commitStages];
71− }
72− if (input.action === "commit_push") {
73− return [...branchStages, ...commitStages, pushStage];
74− }
75− return [...branchStages, ...commitStages, pushStage, ...prStages];
76−}
77−
78−export function buildMenuItems(
79− gitStatus: GitStatusResult | null,
80− isBusy: boolean,
81− hasOriginRemote = true,
82−): GitActionMenuItem[] {
83− if (!gitStatus) return [];
84−
85− const hasBranch = gitStatus.branch !== null;
86− const hasChanges = gitStatus.hasWorkingTreeChanges;
87− const hasOpenPr = gitStatus.pr?.state === "open";
88− const isBehind = gitStatus.behindCount > 0;
89− const canPushWithoutUpstream = hasOriginRemote && !gitStatus.hasUpstream;
90− const canCommit = !isBusy && hasChanges;
91− const canPush =
92− !isBusy &&
93− hasBranch &&
94− !hasChanges &&
95− !isBehind &&
96− gitStatus.aheadCount > 0 &&
97− (gitStatus.hasUpstream || canPushWithoutUpstream);
98− const canCreatePr =
99− !isBusy &&
100− hasBranch &&
101− !hasChanges &&
102− !hasOpenPr &&
103− gitStatus.aheadCount > 0 &&
104− !isBehind &&
105− (gitStatus.hasUpstream || canPushWithoutUpstream);
106− const canOpenPr = !isBusy && hasOpenPr;
107−
108− return [
109− {
110− id: "commit",
111− label: "Commit",
112− disabled: !canCommit,
113− icon: "commit",
114− kind: "open_dialog",
115− dialogAction: "commit",
116− },
117− {
118− id: "push",
119− label: "Push",
120− disabled: !canPush,
121− icon: "push",
122− kind: "open_dialog",
123− dialogAction: "push",
124− },
125− hasOpenPr
126− ? {
127− id: "pr",
128− label: "View PR",
129− disabled: !canOpenPr,
130− icon: "pr",
131− kind: "open_pr",
132− }
133− : {
134− id: "pr",
135− label: "Create PR",
136− disabled: !canCreatePr,
137− icon: "pr",
138− kind: "open_dialog",
139− dialogAction: "create_pr",
140− },
141− ];
142−}
143−
144−export function resolveQuickAction(
145− gitStatus: GitStatusResult | null,
146− isBusy: boolean,
147− isDefaultBranch = false,
148− hasOriginRemote = true,
149−): GitQuickAction {
150− if (isBusy) {
151− return { label: "Commit", disabled: true, kind: "show_hint", hint: "Git action in progress." };
152− }
153−
154− if (!gitStatus) {
155− return {
156− label: "Commit",
157− disabled: true,
158− kind: "show_hint",
159− hint: "Git status is unavailable.",
160− };
161− }
162−
163− const hasBranch = gitStatus.branch !== null;
164− const hasChanges = gitStatus.hasWorkingTreeChanges;
165− const hasOpenPr = gitStatus.pr?.state === "open";
166− const isAhead = gitStatus.aheadCount > 0;
167− const isBehind = gitStatus.behindCount > 0;
168− const isDiverged = isAhead && isBehind;
169−
170− if (!hasBranch) {
171− return {
172− label: "Commit",
173− disabled: true,
174− kind: "show_hint",
175− hint: "Create and checkout a branch before pushing or opening a PR.",
176− };
177− }
178−
179− if (hasChanges) {
180− if (!gitStatus.hasUpstream && !hasOriginRemote) {
181− return { label: "Commit", disabled: false, kind: "run_action", action: "commit" };
182− }
183− if (hasOpenPr || isDefaultBranch) {
184− return { label: "Commit & push", disabled: false, kind: "run_action", action: "commit_push" };
185− }
186− return {
187− label: "Commit, push & PR",
188− disabled: false,
189− kind: "run_action",
190− action: "commit_push_pr",
191− };
192− }
193−
194− if (!gitStatus.hasUpstream) {
195− if (!hasOriginRemote) {
196− if (hasOpenPr && !isAhead) {
197− return { label: "View PR", disabled: false, kind: "open_pr" };
198− }
199− return {
200− label: "Push",
201− disabled: true,
202− kind: "show_hint",
203− hint: 'Add an "origin" remote before pushing or creating a PR.',
204− };
205− }
206− if (!isAhead) {
207− if (hasOpenPr) {
208− return { label: "View PR", disabled: false, kind: "open_pr" };
209− }
210− return {
211− label: "Push",
212− disabled: true,
213− kind: "show_hint",
214− hint: "No local commits to push.",
215− };
216− }
217− if (hasOpenPr || isDefaultBranch) {
218− return {
219− label: "Push",
220− disabled: false,
221− kind: "run_action",
222− action: isDefaultBranch ? "commit_push" : "push",
223− };
224− }
225− return {
226− label: "Push & create PR",
227− disabled: false,
228− kind: "run_action",
229− action: "create_pr",
230− };
231− }
232−
233− if (isDiverged) {
234− return {
235− label: "Sync branch",
236− disabled: true,
237− kind: "show_hint",
238− hint: "Branch has diverged from upstream. Rebase/merge first.",
239− };
240− }
241−
242− if (isBehind) {
243− return {
244− label: "Pull",
245− disabled: false,
246− kind: "run_pull",
247− };
248− }
249−
250− if (isAhead) {
251− if (hasOpenPr || isDefaultBranch) {
252− return {
253− label: "Push",
254− disabled: false,
255− kind: "run_action",
256− action: isDefaultBranch ? "commit_push" : "push",
257− };
258− }
259− return {
260− label: "Push & create PR",
261− disabled: false,
262− kind: "run_action",
263− action: "create_pr",
264− };
265− }
266−
267− if (hasOpenPr && gitStatus.hasUpstream) {
268− return { label: "View PR", disabled: false, kind: "open_pr" };
269− }
270−
271− return {
272− label: "Commit",
273− disabled: true,
274− kind: "show_hint",
275− hint: "Branch is up to date. No action needed.",
276− };
277−}
278−
279−export function requiresDefaultBranchConfirmation(
280− action: GitStackedAction,
281− isDefaultBranch: boolean,
282−): boolean {
283− if (!isDefaultBranch) return false;
284− return (
285− action === "push" ||
286− action === "create_pr" ||
287− action === "commit_push" ||
288− action === "commit_push_pr"
289− );
290−}
291−
292−export function resolveDefaultBranchActionDialogCopy(input: {
293− action: DefaultBranchConfirmableAction;
294− branchName: string;
295− includesCommit: boolean;
296−}): DefaultBranchActionDialogCopy {
297− const branchLabel = input.branchName;
298− const suffix = ` on "${branchLabel}". You can continue on this branch or create a feature branch and run the same action there.`;
299−
300− if (input.action === "push" || input.action === "commit_push") {
301− if (input.includesCommit) {
302− return {
303− title: "Commit & push to default branch?",
304− description: `This action will commit and push changes${suffix}`,
305− continueLabel: `Commit & push to ${branchLabel}`,
306− };
307− }
308− return {
309− title: "Push to default branch?",
310− description: `This action will push local commits${suffix}`,
311− continueLabel: `Push to ${branchLabel}`,
312− };
313− }
314−
315− if (input.includesCommit) {
316− return {
317− title: "Commit, push & create PR from default branch?",
318− description: `This action will commit, push, and create a PR${suffix}`,
319− continueLabel: `Commit, push & create PR`,
320− };
321− }
322− return {
323− title: "Push & create PR from default branch?",
324− description: `This action will push local commits and create a PR${suffix}`,
325− continueLabel: "Push & create PR",
326− };
327−}
328−
329−export function resolveThreadBranchUpdate(
330− result: GitRunStackedActionResult,
331−): { branch: string } | null {
332− if (result.branch.status !== "created" || !result.branch.name) {
333− return null;
334− }
335−
336− return {
337− branch: result.branch.name,
338− };
339−}
340−
341−export function resolveLiveThreadBranchUpdate(input: {
342− threadBranch: string | null;
343− gitStatus: GitStatusResult | null;
344−}): { branch: string | null } | null {
345− if (!input.gitStatus) {
346− return null;
347− }
348−
349− if (input.gitStatus.branch === null && input.threadBranch !== null) {
350− return null;
351− }
352−
353− if (input.threadBranch === input.gitStatus.branch) {
354− return null;
355− }
356−
357− return {
358− branch: input.gitStatus.branch,
359− };
360−}
361−
362−// Re-export from shared for backwards compatibility in this module's exports
36316 export { resolveAutoFeatureBranchName } from "@t3tools/shared/git";
apps/web/src/components/GitActionsControl.tsx+2−69
@@ -12,6 +12,7 @@ import { GitHubIcon } from "./Icons"; · 11 unmodified lines
1212 import {
1313 buildGitActionProgressStages,
1414 buildMenuItems,
15+ getGitActionDisabledReason,
1516 type GitActionIconName,
1617 type GitActionMenuItem,
1718 type GitQuickAction,
@@ -117,74 +118,6 @@ function resolveProgressDescription(progress: ActiveGitActionProgress): string | · 99 unmodified lines
117118 return formatElapsedDescription(progress.hookStartedAtMs ?? progress.phaseStartedAtMs);
118119 }
119120
120−function getMenuActionDisabledReason({
121− item,
122− gitStatus,
123− isBusy,
124− hasOriginRemote,
125−}: {
126− item: GitActionMenuItem;
127− gitStatus: GitStatusResult | null;
128− isBusy: boolean;
129− hasOriginRemote: boolean;
130−}): string | null {
131− if (!item.disabled) return null;
132− if (isBusy) return "Git action in progress.";
133− if (!gitStatus) return "Git status is unavailable.";
134−
135− const hasBranch = gitStatus.branch !== null;
136− const hasChanges = gitStatus.hasWorkingTreeChanges;
137− const hasOpenPr = gitStatus.pr?.state === "open";
138− const isAhead = gitStatus.aheadCount > 0;
139− const isBehind = gitStatus.behindCount > 0;
140−
141− if (item.id === "commit") {
142− if (!hasChanges) {
143− return "Worktree is clean. Make changes before committing.";
144− }
145− return "Commit is currently unavailable.";
146− }
147−
148− if (item.id === "push") {
149− if (!hasBranch) {
150− return "Detached HEAD: checkout a branch before pushing.";
151− }
152− if (hasChanges) {
153− return "Commit or stash local changes before pushing.";
154− }
155− if (isBehind) {
156− return "Branch is behind upstream. Pull/rebase before pushing.";
157− }
158− if (!gitStatus.hasUpstream && !hasOriginRemote) {
159− return 'Add an "origin" remote before pushing.';
160− }
161− if (!isAhead) {
162− return "No local commits to push.";
163− }
164− return "Push is currently unavailable.";
165− }
166−
167− if (hasOpenPr) {
168− return "View PR is currently unavailable.";
169− }
170− if (!hasBranch) {
171− return "Detached HEAD: checkout a branch before creating a PR.";
172− }
173− if (hasChanges) {
174− return "Commit local changes before creating a PR.";
175− }
176− if (!gitStatus.hasUpstream && !hasOriginRemote) {
177− return 'Add an "origin" remote before creating a PR.';
178− }
179− if (!isAhead) {
180− return "No local commits to include in a PR.";
181− }
182− if (isBehind) {
183− return "Branch is behind upstream. Pull/rebase before creating a PR.";
184− }
185− return "Create PR is currently unavailable.";
186−}
187−
188121 const COMMIT_DIALOG_TITLE = "Commit changes";
189122 const COMMIT_DIALOG_DESCRIPTION =
190123 "Review and confirm your commit. Leave the message blank to auto-generate one.";
@@ -912,7 +845,7 @@ export default function GitActionsControl({ · 721 unmodified lines
912845 </MenuTrigger>
913846 <MenuPopup align="end" className="w-full">
914847 {gitActionMenuItems.map((item) => {
915− const disabledReason = getMenuActionDisabledReason({
848+ const disabledReason = getGitActionDisabledReason({
916849 item,
917850 gitStatus: gitStatusForActions,
918851 isBusy: isGitActionRunning,
apps/web/src/components/Sidebar.logic.ts+1−1
@@ -284,7 +284,7 @@ export function resolveThreadRowClassName(input: { · 283 unmodified lines
284284 isSelected: boolean;
285285 }): string {
286286 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";
288288
289289 if (input.isSelected && input.isActive) {
290290 return cn(
apps/web/src/components/Sidebar.tsx+13−18
@@ -410,7 +410,7 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: SidebarThreadRowP · 409 unmodified lines
410410 );
411411 const threadEnvironmentLabel = isRemoteThread
412412 ? (remoteEnvLabel ?? remoteEnvSavedLabel ?? "Remote")
413− : null;
413+ : "Local";
414414 // For grouped projects, the thread may belong to a different environment
415415 // than the representative project. Look up the thread's own project cwd
416416 // 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
715715 ) : null}
716716 <span className={threadMetaClassName}>
717717 <span className="inline-flex items-center gap-1">
718− {isRemoteThread && (
719− <Tooltip>
720− <TooltipTrigger
721− render={
722− <span
723− aria-label={threadEnvironmentLabel ?? "Remote"}
724− className="inline-flex items-center justify-center"
725− />
726− }
727− >
728− <CloudIcon className="size-3 text-muted-foreground/40" />
729− </TooltipTrigger>
730− <TooltipPopup side="top">{threadEnvironmentLabel}</TooltipPopup>
731− </Tooltip>
732− )}
718+ <span
719+ title={threadEnvironmentLabel}
720+ className={`inline-flex max-w-18 items-center truncate rounded-full border border-border/70 bg-background/85 px-1.5 py-0.5 text-[9px] font-medium tracking-[0.08em] ${
721+ isHighlighted
722+ ? "text-foreground/72 dark:text-foreground/82"
723+ : "text-muted-foreground/60"
724+ }`}
725+ >
726+ {threadEnvironmentLabel}
727+ </span>
733728 {jumpLabel ? (
734729 <span
735730 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
848843 return (
849844 <SidebarMenuSub
850845 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"
852847 >
853848 {shouldShowThreadPanel && showEmptyThreadState ? (
854849 <SidebarMenuSubItem className="w-full" data-thread-selection-safe>
@@ -1685,7 +1680,7 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec · 830 unmodified lines
16851680 <SidebarMenuButton
16861681 ref={isManualProjectSorting ? dragHandleProps?.setActivatorNodeRef : undefined}
16871682 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 ${
16891684 isManualProjectSorting ? "cursor-grab active:cursor-grabbing" : "cursor-pointer"
16901685 }`}
16911686 {...(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
6969 onToggleDiff,
7070 }: ChatHeaderProps) {
7171 return (
72− <div className="@container/header-actions flex min-w-0 flex-1 items-center gap-2">
73− <div className="flex min-w-0 flex-1 items-center gap-2 overflow-hidden sm:gap-3">
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-wrap items-center gap-2 overflow-hidden sm:flex-1 sm:flex-nowrap sm:gap-3">
7474 <SidebarTrigger className="size-7 shrink-0 md:hidden" />
7575 <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"
7777 title={activeThreadTitle}
7878 >
7979 {activeThreadTitle}
8080 </h2>
8181 {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+ >
8386 <span className="min-w-0 truncate">{activeProjectName}</span>
8487 </Badge>
8588 )}
@@ -89,7 +92,7 @@ export const ChatHeader = memo(function ChatHeader({ · 3 unmodified lines
8992 </Badge>
9093 )}
9194 </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">
9396 {activeProjectScripts && (
9497 <ProjectScriptsControl
9598 scripts={activeProjectScripts}
bun.lock+31−0
@@ -45,14 +45,18 @@ · 44 unmodified lines
4545 "version": "0.0.0",
4646 "dependencies": {
4747 "@expo-google-fonts/dm-sans": "^0.4.2",
48+ "@gorhom/bottom-sheet": "^5",
4849 "@react-native-async-storage/async-storage": "2.2.0",
4950 "@react-native-masked-view/masked-view": "0.3.2",
51+ "@react-native-menu/menu": "^2.0.0",
5052 "@sethwebster/react-ethereal-input": "^0.4.0",
5153 "@shopify/flash-list": "2.0.2",
54+ "@t3tools/client-runtime": "workspace:*",
5255 "@t3tools/contracts": "workspace:*",
5356 "@t3tools/shared": "workspace:*",
5457 "effect": "catalog:",
5558 "expo": "~55.0.8",
59+ "expo-camera": "~55.0.15",
5660 "expo-clipboard": "~55.0.9",
5761 "expo-constants": "^55.0.9",
5862 "expo-font": "^55.0.4",
@@ -68,6 +72,7 @@ · 9 unmodified lines
6872 "react": "19.2.0",
6973 "react-native": "0.83.4",
7074 "react-native-css": "3.0.7",
75+ "react-native-gesture-handler": "~2.31.1",
7176 "react-native-markdown-display": "^7.0.2",
7277 "react-native-reanimated": "4.2.1",
7378 "react-native-safe-area-context": "~5.6.2",
@@ -499,6 +504,8 @@ · 425 unmodified lines
499504
500505 "@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=="],
501506
507+ "@egjs/hammerjs": ["@egjs/hammerjs@2.0.17", "", { "dependencies": { "@types/hammerjs": "^2.0.36" } }, "sha512-XQsZgjm2EcVUiZQf11UBJQfmZeEmOW8DpI1gsFeln6w0ae0ii4dMQEQ0kjl6DspdWX1aGY1/loyXnP0JS06e/A=="],
508+
502509 "@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=="],
503510
504511 "@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
647654
648655 "@formkit/auto-animate": ["@formkit/auto-animate@0.9.0", "", {}, "sha512-VhP4zEAacXS3dfTpJpJ88QdLqMTcabMg0jwpOSxZ/VzfQVfl3GkZSCZThhGC5uhq/TxPHPzW0dzr4H9Bb1OgKA=="],
649656
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+
650661 "@hono/node-server": ["@hono/node-server@1.19.14", "", { "peerDependencies": { "hono": "^4" } }, "sha512-GwtvgtXxnWsucXvbQXkRgqksiH2Qed37H9xHZocE5sA3N8O8O8/8FA3uclQXxXVzc9XBZuEOMK7+r02FmSpHtw=="],
651662
652663 "@img/colour": ["@img/colour@1.1.0", "", {}, "sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ=="],
@@ -907,6 +918,8 @@ · 254 unmodified lines
907918
908919 "@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=="],
909920
921+ "@react-native-menu/menu": ["@react-native-menu/menu@2.0.0", "", { "peerDependencies": { "react": "*", "react-native": "*" } }, "sha512-hb8Mirw6aKPGONhgo52IiNpwHtISVrgCT3rMdFX1qS7eOFNzOcQh8d2UDnaH5zVpxN+QuvWtaaiRMGFpIjzdtA=="],
922+
910923 "@react-native/assets-registry": ["@react-native/assets-registry@0.83.4", "", {}, "sha512-aqKtpbJDSQeSX/Dwv0yMe1/Rd2QfXi12lnyZDXNn/OEKz59u6+LuPBVgO/9CRyclHmdlvwg8c7PJ9eX2ZMnjWg=="],
911924
912925 "@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
11091122
11101123 "@types/deep-eql": ["@types/deep-eql@4.0.2", "", {}, "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw=="],
11111124
1125+ "@types/emscripten": ["@types/emscripten@1.41.5", "", {}, "sha512-cMQm7pxu6BxtHyqJ7mQZ2kXWV5SLmugybFdHCBbJ5eHzOo6VhBckEgAT3//rP5FwPHNPeEiq4SmQ5ucBwsOo4Q=="],
1126+
11121127 "@types/estree": ["@types/estree@1.0.8", "", {}, "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w=="],
11131128
11141129 "@types/estree-jsx": ["@types/estree-jsx@1.0.5", "", { "dependencies": { "@types/estree": "*" } }, "sha512-52CcUVNFyfb1A2ALocQw/Dd1BQFNmSdkuC3BkZ6iqhdMfQz7JWOFRuJFloOzjk+6WijU56m9oKXFAXc7o3Towg=="],
11151130
11161131 "@types/graceful-fs": ["@types/graceful-fs@4.1.9", "", { "dependencies": { "@types/node": "*" } }, "sha512-olP3sd1qOEe5dXTSaFvQG+02VdRXcdytWLAZsAq1PecU8uqQAhkrnbli7DagjtXKW/Bl7YJbUsa8MPcuc8LHEQ=="],
11171132
1133+ "@types/hammerjs": ["@types/hammerjs@2.0.46", "", {}, "sha512-ynRvcq6wvqexJ9brDMS4BnBLzmr0e14d6ZJTEShTBWKymQiHwlAyGu0ZPEFI2Fh1U53F7tN9ufClWM5KvqkKOw=="],
1134+
11181135 "@types/hast": ["@types/hast@3.0.4", "", { "dependencies": { "@types/unist": "*" } }, "sha512-WPs+bbQw5aCj+x6laNGWLH3wviHtoCv/P3+otBhbOhJgG8qtpdAMlTCxLtsTWA7LH1Oh/bFCHsBn0TPS5m30EQ=="],
11191136
11201137 "@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
11411158
11421159 "@types/react-dom": ["@types/react-dom@19.2.3", "", { "peerDependencies": { "@types/react": "^19.2.0" } }, "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ=="],
11431160
1161+ "@types/react-test-renderer": ["@types/react-test-renderer@19.1.0", "", { "dependencies": { "@types/react": "*" } }, "sha512-XD0WZrHqjNrxA/MaR9O22w/RNidWR9YZmBdRGI7wcnWGrv/3dA8wKCJ8m63Sn+tLJhcjmuhOi629N66W6kgWzQ=="],
1162+
11441163 "@types/responselike": ["@types/responselike@1.0.3", "", { "dependencies": { "@types/node": "*" } }, "sha512-H/+L+UkTV33uf49PH5pCAUBVPNj2nDBXTN+qS1dOwyyg24l3CcicicCA7ca+HMvJBZcFgl5r8e+RR6elsb4Lyw=="],
11451164
11461165 "@types/stack-utils": ["@types/stack-utils@2.0.3", "", {}, "sha512-9aEbYZ3TbYMznPdcdr3SmIrLXwC/AKZXQeCf9Pgao5CKb8CyHuEX5jzWPTkvregvhRJHcpRO6BFoGW9ycaOkYw=="],
@@ -1279,6 +1298,8 @@ · 132 unmodified lines
12791298
12801299 "balanced-match": ["balanced-match@4.0.4", "", {}, "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA=="],
12811300
1301+ "barcode-detector": ["barcode-detector@3.1.2", "", { "dependencies": { "zxing-wasm": "3.0.2" } }, "sha512-Q5kjXpVH5I3ItykNzbWmfWnNryFN1ZTWp10k9/PKJuS0RnoKR7jTrHEJODR4fn04bRomq7TJwie/Dr9fj/GoGQ=="],
1302+
12821303 "base64-js": ["base64-js@1.5.1", "", {}, "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA=="],
12831304
12841305 "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
15671588
15681589 "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=="],
15691590
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+
15701593 "expo-clipboard": ["expo-clipboard@55.0.13", "", { "peerDependencies": { "expo": "*", "react": "*", "react-native": "*" } }, "sha512-PrOmmuVsGW4bAkNQmGKtxMXj3invsfN+jfIKmQxHwE/dn7ODqwFWviUTa+PMUjP3XZmYCDLyu/i0GLeu7HF9Ew=="],
15711594
15721595 "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
17471770
17481771 "hermes-parser": ["hermes-parser@0.32.1", "", { "dependencies": { "hermes-estree": "0.32.1" } }, "sha512-175dz634X/W5AiwrpLdoMl/MOb17poLHyIqgyExlE8D9zQ1OPnoORnGMB5ltRKnpvQzBjMYvT2rN/sHeIfZW5Q=="],
17491772
1773+ "hoist-non-react-statics": ["hoist-non-react-statics@3.3.2", "", { "dependencies": { "react-is": "^16.7.0" } }, "sha512-/gGivxi8JPKWNm/W0jSmzcMPpfpPLc3dY/6GxhX2hQ9iGj3aDfklV4ET7NjKpSinLpJ5vafa9iiGIEZg10SfBw=="],
1774+
17501775 "hono": ["hono@4.12.12", "", {}, "sha512-p1JfQMKaceuCbpJKAPKVqyqviZdS0eUxH9v82oWo1kb9xjQ5wA6iP3FNVAPDFlz5/p7d45lO+BpSk1tuSZMF4Q=="],
17511776
17521777 "hookable": ["hookable@6.1.0", "", {}, "sha512-ZoKZSJgu8voGK2geJS+6YtYjvIzu9AOM/KZXsBxr83uhLL++e9pEv/dlgwgy3dvHg06kTz6JOh1hk3C8Ceiymw=="],
@@ -2333,6 +2358,8 @@ · 580 unmodified lines
23332358
23342359 "react-native-fit-image": ["react-native-fit-image@1.5.5", "", { "dependencies": { "prop-types": "^15.5.10" } }, "sha512-Wl3Vq2DQzxgsWKuW4USfck9zS7YzhvLNPpkwUUCF90bL32e1a0zOVQ3WsJILJOwzmPdHfzZmWasiiAUNBkhNkg=="],
23352360
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+
23362363 "react-native-is-edge-to-edge": ["react-native-is-edge-to-edge@1.2.1", "", { "peerDependencies": { "react": "*", "react-native": "*" } }, "sha512-FLbPWl/MyYQWz+KwqOZsSyj2JmLKglHatd3xLZWskXOpRaio4LfEDEz8E/A6uD8QoTHW6Aobw1jbEwK7KMgR7Q=="],
23372364
23382365 "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
28152842
28162843 "zwitch": ["zwitch@2.0.4", "", {}, "sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A=="],
28172844
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+
28182847 "@astrojs/language-server/@astrojs/compiler": ["@astrojs/compiler@2.13.1", "", {}, "sha512-f3FN83d2G/v32ipNClRKgYv30onQlMZX1vCeZMjPsMMPl1mDpmbl0+N5BYo4S/ofzqJyS5hvwacEo0CCVDn/Qg=="],
28192848
28202849 "@babel/code-frame/@babel/helper-validator-identifier": ["@babel/helper-validator-identifier@7.28.5", "", {}, "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q=="],
@@ -2971,6 +3000,8 @@ · 150 unmodified lines
29713000
29723001 "h3/cookie-es": ["cookie-es@1.2.3", "", {}, "sha512-lXVyvUvrNXblMqzIRrxHb57UUVmqsSWlxqt3XIjCkUP0wDAf6uicO6KMbEgYrMNtEvWgWHwe42CKxPu9MYAnWw=="],
29733002
3003+ "hoist-non-react-statics/react-is": ["react-is@16.13.1", "", {}, "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ=="],
3004+
29743005 "hosted-git-info/lru-cache": ["lru-cache@10.4.3", "", {}, "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ=="],
29753006
29763007 "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
33 "version": "0.0.0-alpha.1",
44 "private": true,
55 "type": "module",
6+ "main": "./src/index.ts",
7+ "types": "./src/index.ts",
68 "exports": {
79 ".": {
810 "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"
1015 }
1116 },
1217 "scripts": {
packages/client-runtime/src/gitActions.ts+430−0
@@ -0,0 +1,430 @@
1+import type {
2+ GitRunStackedActionInput,
3+ GitRunStackedActionResult,
4+ GitStackedAction,
5+ GitStatusResult,
6+} from "@t3tools/contracts";
7+
8+export type GitActionIconName = "commit" | "push" | "pr";
9+
10+export type GitDialogAction = "commit" | "push" | "create_pr";
11+
12+export interface GitActionMenuItem {
13+ id: "commit" | "push" | "pr";
14+ label: string;
15+ disabled: boolean;
16+ icon: GitActionIconName;
17+ kind: "open_dialog" | "open_pr";
18+ dialogAction?: GitDialogAction;
19+}
20+
21+export interface GitQuickAction {
22+ label: string;
23+ disabled: boolean;
24+ kind: "run_action" | "run_pull" | "open_pr" | "show_hint";
25+ action?: GitStackedAction;
26+ hint?: string;
27+}
28+
29+export interface DefaultBranchActionDialogCopy {
30+ title: string;
31+ description: string;
32+ continueLabel: string;
33+}
34+
35+export type DefaultBranchConfirmableAction =
36+ | "push"
37+ | "create_pr"
38+ | "commit_push"
39+ | "commit_push_pr";
40+
41+export type GitActionRequestInput = Pick<
42+ GitRunStackedActionInput,
43+ "action" | "commitMessage" | "featureBranch" | "filePaths"
44+>;
45+
46+export function buildGitActionProgressStages(input: {
47+ action: GitStackedAction;
48+ hasCustomCommitMessage: boolean;
49+ hasWorkingTreeChanges: boolean;
50+ pushTarget?: string;
51+ featureBranch?: boolean;
52+ shouldPushBeforePr?: boolean;
53+}): string[] {
54+ const branchStages = input.featureBranch ? ["Preparing feature branch..."] : [];
55+ const pushStage = input.pushTarget ? `Pushing to ${input.pushTarget}...` : "Pushing...";
56+ const prStages = [
57+ "Preparing PR...",
58+ "Generating PR content...",
59+ "Creating GitHub pull request...",
60+ ];
61+
62+ if (input.action === "push") {
63+ return [pushStage];
64+ }
65+ if (input.action === "create_pr") {
66+ return input.shouldPushBeforePr ? [pushStage, ...prStages] : prStages;
67+ }
68+
69+ const shouldIncludeCommitStages = input.action === "commit" || input.hasWorkingTreeChanges;
70+ const commitStages = !shouldIncludeCommitStages
71+ ? []
72+ : input.hasCustomCommitMessage
73+ ? ["Committing..."]
74+ : ["Generating commit message...", "Committing..."];
75+ if (input.action === "commit") {
76+ return [...branchStages, ...commitStages];
77+ }
78+ if (input.action === "commit_push") {
79+ return [...branchStages, ...commitStages, pushStage];
80+ }
81+ return [...branchStages, ...commitStages, pushStage, ...prStages];
82+}
83+
84+export function buildMenuItems(
85+ gitStatus: GitStatusResult | null,
86+ isBusy: boolean,
87+ hasOriginRemote = true,
88+): GitActionMenuItem[] {
89+ if (!gitStatus) return [];
90+
91+ const hasBranch = gitStatus.branch !== null;
92+ const hasChanges = gitStatus.hasWorkingTreeChanges;
93+ const hasOpenPr = gitStatus.pr?.state === "open";
94+ const isBehind = gitStatus.behindCount > 0;
95+ const canPushWithoutUpstream = hasOriginRemote && !gitStatus.hasUpstream;
96+ const canCommit = !isBusy && hasChanges;
97+ const canPush =
98+ !isBusy &&
99+ hasBranch &&
100+ !hasChanges &&
101+ !isBehind &&
102+ gitStatus.aheadCount > 0 &&
103+ (gitStatus.hasUpstream || canPushWithoutUpstream);
104+ const canCreatePr =
105+ !isBusy &&
106+ hasBranch &&
107+ !hasChanges &&
108+ !hasOpenPr &&
109+ gitStatus.aheadCount > 0 &&
110+ !isBehind &&
111+ (gitStatus.hasUpstream || canPushWithoutUpstream);
112+ const canOpenPr = !isBusy && hasOpenPr;
113+
114+ return [
115+ {
116+ id: "commit",
117+ label: "Commit",
118+ disabled: !canCommit,
119+ icon: "commit",
120+ kind: "open_dialog",
121+ dialogAction: "commit",
122+ },
123+ {
124+ id: "push",
125+ label: "Push",
126+ disabled: !canPush,
127+ icon: "push",
128+ kind: "open_dialog",
129+ dialogAction: "push",
130+ },
131+ hasOpenPr
132+ ? {
133+ id: "pr",
134+ label: "View PR",
135+ disabled: !canOpenPr,
136+ icon: "pr",
137+ kind: "open_pr",
138+ }
139+ : {
140+ id: "pr",
141+ label: "Create PR",
142+ disabled: !canCreatePr,
143+ icon: "pr",
144+ kind: "open_dialog",
145+ dialogAction: "create_pr",
146+ },
147+ ];
148+}
149+
150+export function resolveQuickAction(
151+ gitStatus: GitStatusResult | null,
152+ isBusy: boolean,
153+ isDefaultBranch = false,
154+ hasOriginRemote = true,
155+): GitQuickAction {
156+ if (isBusy) {
157+ return { label: "Commit", disabled: true, kind: "show_hint", hint: "Git action in progress." };
158+ }
159+
160+ if (!gitStatus) {
161+ return {
162+ label: "Commit",
163+ disabled: true,
164+ kind: "show_hint",
165+ hint: "Git status is unavailable.",
166+ };
167+ }
168+
169+ const hasBranch = gitStatus.branch !== null;
170+ const hasChanges = gitStatus.hasWorkingTreeChanges;
171+ const hasOpenPr = gitStatus.pr?.state === "open";
172+ const isAhead = gitStatus.aheadCount > 0;
173+ const isBehind = gitStatus.behindCount > 0;
174+ const isDiverged = isAhead && isBehind;
175+
176+ if (!hasBranch) {
177+ return {
178+ label: "Commit",
179+ disabled: true,
180+ kind: "show_hint",
181+ hint: "Create and checkout a branch before pushing or opening a PR.",
182+ };
183+ }
184+
185+ if (hasChanges) {
186+ if (!gitStatus.hasUpstream && !hasOriginRemote) {
187+ return { label: "Commit", disabled: false, kind: "run_action", action: "commit" };
188+ }
189+ if (hasOpenPr || isDefaultBranch) {
190+ return { label: "Commit & push", disabled: false, kind: "run_action", action: "commit_push" };
191+ }
192+ return {
193+ label: "Commit, push & PR",
194+ disabled: false,
195+ kind: "run_action",
196+ action: "commit_push_pr",
197+ };
198+ }
199+
200+ if (!gitStatus.hasUpstream) {
201+ if (!hasOriginRemote) {
202+ if (hasOpenPr && !isAhead) {
203+ return { label: "View PR", disabled: false, kind: "open_pr" };
204+ }
205+ return {
206+ label: "Push",
207+ disabled: true,
208+ kind: "show_hint",
209+ hint: 'Add an "origin" remote before pushing or creating a PR.',
210+ };
211+ }
212+ if (!isAhead) {
213+ if (hasOpenPr) {
214+ return { label: "View PR", disabled: false, kind: "open_pr" };
215+ }
216+ return {
217+ label: "Push",
218+ disabled: true,
219+ kind: "show_hint",
220+ hint: "No local commits to push.",
221+ };
222+ }
223+ if (hasOpenPr || isDefaultBranch) {
224+ return {
225+ label: "Push",
226+ disabled: false,
227+ kind: "run_action",
228+ action: isDefaultBranch ? "commit_push" : "push",
229+ };
230+ }
231+ return {
232+ label: "Push & create PR",
233+ disabled: false,
234+ kind: "run_action",
235+ action: "create_pr",
236+ };
237+ }
238+
239+ if (isDiverged) {
240+ return {
241+ label: "Sync branch",
242+ disabled: true,
243+ kind: "show_hint",
244+ hint: "Branch has diverged from upstream. Rebase/merge first.",
245+ };
246+ }
247+
248+ if (isBehind) {
249+ return {
250+ label: "Pull",
251+ disabled: false,
252+ kind: "run_pull",
253+ };
254+ }
255+
256+ if (isAhead) {
257+ if (hasOpenPr || isDefaultBranch) {
258+ return {
259+ label: "Push",
260+ disabled: false,
261+ kind: "run_action",
262+ action: isDefaultBranch ? "commit_push" : "push",
263+ };
264+ }
265+ return {
266+ label: "Push & create PR",
267+ disabled: false,
268+ kind: "run_action",
269+ action: "create_pr",
270+ };
271+ }
272+
273+ if (hasOpenPr && gitStatus.hasUpstream) {
274+ return { label: "View PR", disabled: false, kind: "open_pr" };
275+ }
276+
277+ return {
278+ label: "Commit",
279+ disabled: true,
280+ kind: "show_hint",
281+ hint: "Branch is up to date. No action needed.",
282+ };
283+}
284+
285+export function getGitActionDisabledReason(input: {
286+ item: GitActionMenuItem;
287+ gitStatus: GitStatusResult | null;
288+ isBusy: boolean;
289+ hasOriginRemote: boolean;
290+}): string | null {
291+ const { item, gitStatus, isBusy, hasOriginRemote } = input;
292+ if (!item.disabled) return null;
293+ if (isBusy) return "Git action in progress.";
294+ if (!gitStatus) return "Git status is unavailable.";
295+
296+ const hasBranch = gitStatus.branch !== null;
297+ const hasChanges = gitStatus.hasWorkingTreeChanges;
298+ const hasOpenPr = gitStatus.pr?.state === "open";
299+ const isAhead = gitStatus.aheadCount > 0;
300+ const isBehind = gitStatus.behindCount > 0;
301+
302+ if (item.id === "commit") {
303+ if (!hasChanges) {
304+ return "Worktree is clean. Make changes before committing.";
305+ }
306+ return "Commit is currently unavailable.";
307+ }
308+
309+ if (item.id === "push") {
310+ if (!hasBranch) {
311+ return "Detached HEAD: checkout a branch before pushing.";
312+ }
313+ if (hasChanges) {
314+ return "Commit or stash local changes before pushing.";
315+ }
316+ if (isBehind) {
317+ return "Branch is behind upstream. Pull/rebase before pushing.";
318+ }
319+ if (!gitStatus.hasUpstream && !hasOriginRemote) {
320+ return 'Add an "origin" remote before pushing.';
321+ }
322+ if (!isAhead) {
323+ return "No local commits to push.";
324+ }
325+ return "Push is currently unavailable.";
326+ }
327+
328+ if (hasOpenPr) {
329+ return "View PR is currently unavailable.";
330+ }
331+ if (!hasBranch) {
332+ return "Detached HEAD: checkout a branch before creating a PR.";
333+ }
334+ if (hasChanges) {
335+ return "Commit local changes before creating a PR.";
336+ }
337+ if (!gitStatus.hasUpstream && !hasOriginRemote) {
338+ return 'Add an "origin" remote before creating a PR.';
339+ }
340+ if (!isAhead) {
341+ return "No local commits to include in a PR.";
342+ }
343+ if (isBehind) {
344+ return "Branch is behind upstream. Pull/rebase before creating a PR.";
345+ }
346+ return "Create PR is currently unavailable.";
347+}
348+
349+export function requiresDefaultBranchConfirmation(
350+ action: GitStackedAction,
351+ isDefaultBranch: boolean,
352+): boolean {
353+ if (!isDefaultBranch) return false;
354+ return (
355+ action === "push" ||
356+ action === "create_pr" ||
357+ action === "commit_push" ||
358+ action === "commit_push_pr"
359+ );
360+}
361+
362+export function resolveDefaultBranchActionDialogCopy(input: {
363+ action: DefaultBranchConfirmableAction;
364+ branchName: string;
365+ includesCommit: boolean;
366+}): DefaultBranchActionDialogCopy {
367+ const branchLabel = input.branchName;
368+ const suffix = ` on "${branchLabel}". You can continue on this branch or create a feature branch and run the same action there.`;
369+
370+ if (input.action === "push" || input.action === "commit_push") {
371+ if (input.includesCommit) {
372+ return {
373+ title: "Commit & push to default branch?",
374+ description: `This action will commit and push changes${suffix}`,
375+ continueLabel: `Commit & push to ${branchLabel}`,
376+ };
377+ }
378+ return {
379+ title: "Push to default branch?",
380+ description: `This action will push local commits${suffix}`,
381+ continueLabel: `Push to ${branchLabel}`,
382+ };
383+ }
384+
385+ if (input.includesCommit) {
386+ return {
387+ title: "Commit, push & create PR from default branch?",
388+ description: `This action will commit, push, and create a PR${suffix}`,
389+ continueLabel: "Commit, push & create PR",
390+ };
391+ }
392+ return {
393+ title: "Push & create PR from default branch?",
394+ description: `This action will push local commits and create a PR${suffix}`,
395+ continueLabel: "Push & create PR",
396+ };
397+}
398+
399+export function resolveThreadBranchUpdate(
400+ result: GitRunStackedActionResult,
401+): { branch: string } | null {
402+ if (result.branch.status !== "created" || !result.branch.name) {
403+ return null;
404+ }
405+
406+ return {
407+ branch: result.branch.name,
408+ };
409+}
410+
411+export function resolveLiveThreadBranchUpdate(input: {
412+ threadBranch: string | null;
413+ gitStatus: GitStatusResult | null;
414+}): { branch: string | null } | null {
415+ if (!input.gitStatus) {
416+ return null;
417+ }
418+
419+ if (input.gitStatus.branch === null && input.threadBranch !== null) {
420+ return null;
421+ }
422+
423+ if (input.threadBranch === input.gitStatus.branch) {
424+ return null;
425+ }
426+
427+ return {
428+ branch: input.gitStatus.branch,
429+ };
430+}
packages/client-runtime/src/index.ts+1−0
@@ -1,2 +1,3 @@
11 export * from "./knownEnvironment";
22 export * from "./scoped";
3+export * from "./gitActions";