Matthias Nott
2026-03-07 af1543135d42adc2e97dc5243aeef7418cd3b00d
components/SessionPicker.tsx
....@@ -1,289 +1,459 @@
1
-import React, { useCallback, useEffect, useState } from "react";
1
+import React, { useCallback, useEffect, useRef, useState } from "react";
22 import {
3
+ Animated,
4
+ Keyboard,
5
+ LayoutAnimation,
36 Modal,
7
+ Platform,
48 Pressable,
59 ScrollView,
610 Text,
711 TextInput,
12
+ UIManager,
813 View,
914 } from "react-native";
15
+import {
16
+ GestureHandlerRootView,
17
+ PanGestureHandler,
18
+ PanGestureHandlerGestureEvent,
19
+ State,
20
+ Swipeable,
21
+} from "react-native-gesture-handler";
1022 import * as Haptics from "expo-haptics";
1123 import { WsSession } from "../types";
1224 import { useChat } from "../contexts/ChatContext";
25
+
26
+if (
27
+ Platform.OS === "android" &&
28
+ UIManager.setLayoutAnimationEnabledExperimental
29
+) {
30
+ UIManager.setLayoutAnimationEnabledExperimental(true);
31
+}
1332
1433 interface SessionPickerProps {
1534 visible: boolean;
1635 onClose: () => void;
1736 }
1837
38
+/* ── Swipeable row with delete action ── */
39
+
40
+function SessionRow({
41
+ session,
42
+ onSwitch,
43
+ onLongPress,
44
+ onDelete,
45
+}: {
46
+ session: WsSession;
47
+ onSwitch: () => void;
48
+ onLongPress: () => void;
49
+ onDelete: () => void;
50
+}) {
51
+ const swipeRef = useRef<Swipeable>(null);
52
+
53
+ const renderRightActions = (
54
+ _progress: Animated.AnimatedInterpolation<number>,
55
+ dragX: Animated.AnimatedInterpolation<number>,
56
+ ) => {
57
+ const scale = dragX.interpolate({
58
+ inputRange: [-100, -50, 0],
59
+ outputRange: [1, 0.8, 0],
60
+ extrapolate: "clamp",
61
+ });
62
+
63
+ return (
64
+ <Pressable
65
+ onPress={() => {
66
+ swipeRef.current?.close();
67
+ onDelete();
68
+ }}
69
+ style={{
70
+ backgroundColor: "#FF3B30",
71
+ justifyContent: "center",
72
+ alignItems: "center",
73
+ width: 80,
74
+ borderRadius: 16,
75
+ marginLeft: 8,
76
+ }}
77
+ >
78
+ <Animated.Text
79
+ style={{
80
+ color: "#FFF",
81
+ fontSize: 14,
82
+ fontWeight: "600",
83
+ transform: [{ scale }],
84
+ }}
85
+ >
86
+ Remove
87
+ </Animated.Text>
88
+ </Pressable>
89
+ );
90
+ };
91
+
92
+ return (
93
+ <Swipeable
94
+ ref={swipeRef}
95
+ renderRightActions={renderRightActions}
96
+ rightThreshold={60}
97
+ friction={2}
98
+ overshootRight={false}
99
+ >
100
+ <Pressable
101
+ onPress={onSwitch}
102
+ onLongPress={onLongPress}
103
+ delayLongPress={400}
104
+ style={({ pressed }) => ({
105
+ width: "100%",
106
+ backgroundColor: pressed ? "#252538" : "#1E1E2E",
107
+ borderRadius: 16,
108
+ padding: 14,
109
+ flexDirection: "row",
110
+ alignItems: "center",
111
+ borderWidth: session.isActive ? 2 : 1,
112
+ borderColor: session.isActive ? "#4A9EFF" : "#2E2E45",
113
+ })}
114
+ >
115
+ {/* Number badge */}
116
+ <View
117
+ style={{
118
+ width: 32,
119
+ height: 32,
120
+ borderRadius: 16,
121
+ backgroundColor: session.isActive ? "#4A9EFF" : "#252538",
122
+ alignItems: "center",
123
+ justifyContent: "center",
124
+ marginRight: 12,
125
+ }}
126
+ >
127
+ <Text
128
+ style={{
129
+ color: session.isActive ? "#FFF" : "#9898B0",
130
+ fontSize: 14,
131
+ fontWeight: "700",
132
+ }}
133
+ >
134
+ {session.index}
135
+ </Text>
136
+ </View>
137
+
138
+ {/* Session info */}
139
+ <View style={{ flex: 1 }}>
140
+ <Text
141
+ style={{
142
+ color: "#E8E8F0",
143
+ fontSize: 16,
144
+ fontWeight: "600",
145
+ }}
146
+ numberOfLines={1}
147
+ >
148
+ {session.name}
149
+ </Text>
150
+ <Text
151
+ style={{
152
+ color: "#5A5A78",
153
+ fontSize: 11,
154
+ marginTop: 1,
155
+ }}
156
+ >
157
+ {session.kind === "api"
158
+ ? "Headless"
159
+ : session.kind === "visual"
160
+ ? "Visual"
161
+ : session.type === "terminal"
162
+ ? "Terminal"
163
+ : "Claude"}
164
+ {session.isActive ? " — active" : ""}
165
+ </Text>
166
+ </View>
167
+
168
+ {/* Active indicator */}
169
+ {session.isActive && (
170
+ <View
171
+ style={{
172
+ width: 8,
173
+ height: 8,
174
+ borderRadius: 4,
175
+ backgroundColor: "#2ED573",
176
+ }}
177
+ />
178
+ )}
179
+ </Pressable>
180
+ </Swipeable>
181
+ );
182
+}
183
+
184
+/* ── Inline rename editor ── */
185
+
186
+function RenameEditor({
187
+ name,
188
+ onConfirm,
189
+ onCancel,
190
+}: {
191
+ name: string;
192
+ onConfirm: (newName: string) => void;
193
+ onCancel: () => void;
194
+}) {
195
+ const [editName, setEditName] = useState(name);
196
+
197
+ return (
198
+ <View
199
+ style={{
200
+ backgroundColor: "#1E1E2E",
201
+ borderRadius: 16,
202
+ padding: 14,
203
+ borderWidth: 2,
204
+ borderColor: "#4A9EFF",
205
+ }}
206
+ >
207
+ <TextInput
208
+ value={editName}
209
+ onChangeText={setEditName}
210
+ autoFocus
211
+ onSubmitEditing={() => onConfirm(editName.trim())}
212
+ onBlur={onCancel}
213
+ returnKeyType="done"
214
+ style={{
215
+ color: "#E8E8F0",
216
+ fontSize: 16,
217
+ fontWeight: "600",
218
+ padding: 0,
219
+ marginBottom: 10,
220
+ }}
221
+ placeholderTextColor="#5A5A78"
222
+ placeholder="Session name..."
223
+ />
224
+ <View style={{ flexDirection: "row", gap: 8 }}>
225
+ <Pressable
226
+ onPress={() => onConfirm(editName.trim())}
227
+ style={{
228
+ flex: 1,
229
+ backgroundColor: "#4A9EFF",
230
+ borderRadius: 10,
231
+ paddingVertical: 8,
232
+ alignItems: "center",
233
+ }}
234
+ >
235
+ <Text style={{ color: "#FFF", fontSize: 14, fontWeight: "600" }}>
236
+ Save
237
+ </Text>
238
+ </Pressable>
239
+ <Pressable
240
+ onPress={onCancel}
241
+ style={{
242
+ flex: 1,
243
+ backgroundColor: "#252538",
244
+ borderRadius: 10,
245
+ paddingVertical: 8,
246
+ alignItems: "center",
247
+ }}
248
+ >
249
+ <Text style={{ color: "#9898B0", fontSize: 14 }}>Cancel</Text>
250
+ </Pressable>
251
+ </View>
252
+ </View>
253
+ );
254
+}
255
+
256
+/* ── Main SessionPicker ── */
257
+
19258 export function SessionPicker({ visible, onClose }: SessionPickerProps) {
20
- const { sessions, requestSessions, switchSession, renameSession } = useChat();
259
+ const {
260
+ sessions,
261
+ requestSessions,
262
+ switchSession,
263
+ renameSession,
264
+ removeSession,
265
+ } = useChat();
21266 const [editingId, setEditingId] = useState<string | null>(null);
22
- const [editName, setEditName] = useState("");
267
+ const [keyboardHeight, setKeyboardHeight] = useState(0);
268
+
269
+ // Sort: active first, then by index
270
+ const sortedSessions = [...sessions].sort((a, b) => {
271
+ if (a.isActive && !b.isActive) return -1;
272
+ if (!a.isActive && b.isActive) return 1;
273
+ return a.index - b.index;
274
+ });
23275
24276 useEffect(() => {
25
- if (visible) {
277
+ const showSub = Keyboard.addListener("keyboardWillShow", (e) =>
278
+ setKeyboardHeight(e.endCoordinates.height),
279
+ );
280
+ const hideSub = Keyboard.addListener("keyboardWillHide", () =>
281
+ setKeyboardHeight(0),
282
+ );
283
+ return () => {
284
+ showSub.remove();
285
+ hideSub.remove();
286
+ };
287
+ }, []);
288
+
289
+ useEffect(() => {
290
+ if (!visible) {
291
+ setEditingId(null);
292
+ } else {
26293 requestSessions();
27294 }
28295 }, [visible, requestSessions]);
296
+
297
+ const handleClose = useCallback(() => {
298
+ setEditingId(null);
299
+ Keyboard.dismiss();
300
+ onClose();
301
+ }, [onClose]);
29302
30303 const handleSwitch = useCallback(
31304 (session: WsSession) => {
32305 Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Medium);
33306 switchSession(session.id);
34
- onClose();
307
+ handleClose();
35308 },
36
- [switchSession, onClose]
309
+ [switchSession, handleClose],
37310 );
38311
39312 const handleStartRename = useCallback((session: WsSession) => {
313
+ Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
40314 setEditingId(session.id);
41
- setEditName(session.name);
42315 }, []);
43316
44
- const handleConfirmRename = useCallback(() => {
45
- if (editingId && editName.trim()) {
46
- renameSession(editingId, editName.trim());
47
- }
48
- setEditingId(null);
49
- setEditName("");
50
- }, [editingId, editName, renameSession]);
317
+ const handleConfirmRename = useCallback(
318
+ (sessionId: string, newName: string) => {
319
+ if (newName) {
320
+ renameSession(sessionId, newName);
321
+ }
322
+ setEditingId(null);
323
+ },
324
+ [renameSession],
325
+ );
326
+
327
+ const handleRemove = useCallback(
328
+ (session: WsSession) => {
329
+ Haptics.notificationAsync(Haptics.NotificationFeedbackType.Warning);
330
+ LayoutAnimation.configureNext(LayoutAnimation.Presets.easeInEaseOut);
331
+ removeSession(session.id);
332
+ },
333
+ [removeSession],
334
+ );
51335
52336 return (
53337 <Modal
54338 visible={visible}
55339 animationType="slide"
56340 transparent
57
- onRequestClose={onClose}
341
+ onRequestClose={handleClose}
58342 >
59
- <View
60
- style={{
61
- flex: 1,
62
- backgroundColor: "rgba(0,0,0,0.6)",
63
- justifyContent: "flex-end",
64
- }}
65
- >
66
- <Pressable
67
- style={{ flex: 1 }}
68
- onPress={onClose}
69
- />
343
+ <GestureHandlerRootView style={{ flex: 1 }}>
70344 <View
71345 style={{
72
- backgroundColor: "#14141F",
73
- borderTopLeftRadius: 24,
74
- borderTopRightRadius: 24,
75
- maxHeight: "70%",
76
- paddingBottom: 40,
346
+ flex: 1,
347
+ backgroundColor: "rgba(0,0,0,0.6)",
348
+ justifyContent: "flex-end",
77349 }}
78350 >
79
- {/* Handle bar */}
80
- <View style={{ alignItems: "center", paddingTop: 12, paddingBottom: 8 }}>
81
- <View
82
- style={{
83
- width: 40,
84
- height: 4,
85
- borderRadius: 2,
86
- backgroundColor: "#2E2E45",
87
- }}
88
- />
89
- </View>
90
-
91
- {/* Header */}
351
+ <Pressable style={{ flex: 1 }} onPress={handleClose} />
92352 <View
93353 style={{
94
- flexDirection: "row",
95
- alignItems: "center",
96
- justifyContent: "space-between",
97
- paddingHorizontal: 20,
98
- paddingBottom: 16,
354
+ backgroundColor: "#14141F",
355
+ borderTopLeftRadius: 24,
356
+ borderTopRightRadius: 24,
357
+ maxHeight: "70%",
358
+ paddingBottom: Math.max(40, keyboardHeight),
99359 }}
100360 >
101
- <Text
361
+ {/* Handle bar */}
362
+ <View
363
+ style={{ alignItems: "center", paddingTop: 12, paddingBottom: 8 }}
364
+ >
365
+ <View
366
+ style={{
367
+ width: 40,
368
+ height: 4,
369
+ borderRadius: 2,
370
+ backgroundColor: "#2E2E45",
371
+ }}
372
+ />
373
+ </View>
374
+
375
+ {/* Header */}
376
+ <View
102377 style={{
103
- color: "#E8E8F0",
104
- fontSize: 20,
105
- fontWeight: "700",
378
+ flexDirection: "row",
379
+ alignItems: "center",
380
+ justifyContent: "space-between",
381
+ paddingHorizontal: 20,
382
+ paddingBottom: 12,
106383 }}
107384 >
108
- Sessions
109
- </Text>
110
- <Pressable
111
- onPress={() => requestSessions()}
112
- hitSlop={{ top: 8, bottom: 8, left: 8, right: 8 }}
113
- style={{
114
- paddingHorizontal: 12,
115
- paddingVertical: 6,
116
- borderRadius: 12,
117
- backgroundColor: "#1E1E2E",
118
- }}
385
+ <Text
386
+ style={{
387
+ color: "#E8E8F0",
388
+ fontSize: 20,
389
+ fontWeight: "700",
390
+ }}
391
+ >
392
+ Sessions
393
+ </Text>
394
+ <Pressable
395
+ onPress={() => requestSessions()}
396
+ hitSlop={{ top: 8, bottom: 8, left: 8, right: 8 }}
397
+ style={({ pressed }) => ({
398
+ paddingHorizontal: 12,
399
+ paddingVertical: 6,
400
+ borderRadius: 12,
401
+ backgroundColor: pressed ? "#252538" : "#1E1E2E",
402
+ })}
403
+ >
404
+ <Text style={{ color: "#9898B0", fontSize: 13 }}>Refresh</Text>
405
+ </Pressable>
406
+ </View>
407
+
408
+ {/* Session list */}
409
+ <ScrollView
410
+ style={{ paddingHorizontal: 16 }}
411
+ showsVerticalScrollIndicator={false}
412
+ keyboardShouldPersistTaps="handled"
119413 >
120
- <Text style={{ color: "#9898B0", fontSize: 13 }}>Refresh</Text>
121
- </Pressable>
122
- </View>
123
-
124
- {/* Session list */}
125
- <ScrollView
126
- style={{ paddingHorizontal: 16 }}
127
- showsVerticalScrollIndicator={false}
128
- >
129
- {sessions.length === 0 ? (
130
- <View style={{ alignItems: "center", paddingVertical: 32 }}>
131
- <Text style={{ color: "#5A5A78", fontSize: 15 }}>
132
- No sessions found
133
- </Text>
134
- </View>
135
- ) : (
136
- sessions.map((session) => (
137
- <View key={session.id} style={{ marginBottom: 8 }}>
138
- {editingId === session.id ? (
139
- /* Rename mode */
140
- <View
141
- style={{
142
- backgroundColor: "#1E1E2E",
143
- borderRadius: 16,
144
- padding: 16,
145
- borderWidth: 2,
146
- borderColor: "#4A9EFF",
147
- }}
148
- >
149
- <TextInput
150
- value={editName}
151
- onChangeText={setEditName}
152
- autoFocus
153
- onSubmitEditing={handleConfirmRename}
154
- returnKeyType="done"
155
- style={{
156
- color: "#E8E8F0",
157
- fontSize: 17,
158
- fontWeight: "600",
159
- padding: 0,
160
- marginBottom: 12,
161
- }}
162
- placeholderTextColor="#5A5A78"
163
- placeholder="Session name..."
164
- />
165
- <View style={{ flexDirection: "row", gap: 8 }}>
166
- <Pressable
167
- onPress={handleConfirmRename}
168
- style={{
169
- flex: 1,
170
- backgroundColor: "#4A9EFF",
171
- borderRadius: 10,
172
- paddingVertical: 10,
173
- alignItems: "center",
174
- }}
175
- >
176
- <Text style={{ color: "#FFF", fontSize: 15, fontWeight: "600" }}>
177
- Save
178
- </Text>
179
- </Pressable>
180
- <Pressable
181
- onPress={() => setEditingId(null)}
182
- style={{
183
- flex: 1,
184
- backgroundColor: "#252538",
185
- borderRadius: 10,
186
- paddingVertical: 10,
187
- alignItems: "center",
188
- }}
189
- >
190
- <Text style={{ color: "#9898B0", fontSize: 15 }}>Cancel</Text>
191
- </Pressable>
192
- </View>
193
- </View>
194
- ) : (
195
- /* Normal session row */
196
- <Pressable
197
- onPress={() => handleSwitch(session)}
198
- onLongPress={() => handleStartRename(session)}
199
- style={({ pressed }) => ({
200
- backgroundColor: pressed ? "#252538" : "#1E1E2E",
201
- borderRadius: 16,
202
- padding: 16,
203
- flexDirection: "row",
204
- alignItems: "center",
205
- borderWidth: session.isActive ? 2 : 1,
206
- borderColor: session.isActive ? "#4A9EFF" : "#2E2E45",
207
- })}
208
- >
209
- {/* Number badge */}
210
- <View
211
- style={{
212
- width: 36,
213
- height: 36,
214
- borderRadius: 18,
215
- backgroundColor: session.isActive ? "#4A9EFF" : "#252538",
216
- alignItems: "center",
217
- justifyContent: "center",
218
- marginRight: 14,
219
- }}
220
- >
221
- <Text
222
- style={{
223
- color: session.isActive ? "#FFF" : "#9898B0",
224
- fontSize: 16,
225
- fontWeight: "700",
226
- }}
227
- >
228
- {session.index}
229
- </Text>
230
- </View>
231
-
232
- {/* Session info */}
233
- <View style={{ flex: 1 }}>
234
- <Text
235
- style={{
236
- color: "#E8E8F0",
237
- fontSize: 17,
238
- fontWeight: "600",
239
- }}
240
- numberOfLines={1}
241
- >
242
- {session.name}
243
- </Text>
244
- <Text
245
- style={{
246
- color: "#5A5A78",
247
- fontSize: 12,
248
- marginTop: 2,
249
- }}
250
- >
251
- {session.kind === "api" ? "Headless" : session.kind === "visual" ? "Visual" : session.type === "terminal" ? "Terminal" : "Claude"}
252
- {session.isActive ? " — active" : ""}
253
- </Text>
254
- </View>
255
-
256
- {/* Active indicator */}
257
- {session.isActive && (
258
- <View
259
- style={{
260
- width: 10,
261
- height: 10,
262
- borderRadius: 5,
263
- backgroundColor: "#2ED573",
264
- }}
265
- />
266
- )}
267
- </Pressable>
268
- )}
414
+ {sortedSessions.length === 0 ? (
415
+ <View style={{ alignItems: "center", paddingVertical: 32 }}>
416
+ <Text style={{ color: "#5A5A78", fontSize: 15 }}>
417
+ No sessions found
418
+ </Text>
269419 </View>
270
- ))
271
- )}
420
+ ) : (
421
+ sortedSessions.map((session) => (
422
+ <View key={session.id} style={{ marginBottom: 6 }}>
423
+ {editingId === session.id ? (
424
+ <RenameEditor
425
+ name={session.name}
426
+ onConfirm={(name) =>
427
+ handleConfirmRename(session.id, name)
428
+ }
429
+ onCancel={() => setEditingId(null)}
430
+ />
431
+ ) : (
432
+ <SessionRow
433
+ session={session}
434
+ onSwitch={() => handleSwitch(session)}
435
+ onLongPress={() => handleStartRename(session)}
436
+ onDelete={() => handleRemove(session)}
437
+ />
438
+ )}
439
+ </View>
440
+ ))
441
+ )}
272442
273
- {/* Hint */}
274
- <Text
275
- style={{
276
- color: "#5A5A78",
277
- fontSize: 12,
278
- textAlign: "center",
279
- paddingVertical: 12,
280
- }}
281
- >
282
- Tap to switch — Long press to rename
283
- </Text>
284
- </ScrollView>
443
+ <Text
444
+ style={{
445
+ color: "#5A5A78",
446
+ fontSize: 11,
447
+ textAlign: "center",
448
+ paddingVertical: 10,
449
+ }}
450
+ >
451
+ Tap to switch — Long press to rename — Swipe left to remove
452
+ </Text>
453
+ </ScrollView>
454
+ </View>
285455 </View>
286
- </View>
456
+ </GestureHandlerRootView>
287457 </Modal>
288458 );
289459 }