From af1543135d42adc2e97dc5243aeef7418cd3b00d Mon Sep 17 00:00:00 2001
From: Matthias Nott <mnott@mnsoft.org>
Date: Sat, 07 Mar 2026 08:39:26 +0100
Subject: [PATCH] feat: dual address auto-switch, custom icon, notifications, image support
---
components/SessionPicker.tsx | 626 ++++++++++++++++++++++++++++++++++++--------------------
1 files changed, 398 insertions(+), 228 deletions(-)
diff --git a/components/SessionPicker.tsx b/components/SessionPicker.tsx
index eedb209..349d47a 100644
--- a/components/SessionPicker.tsx
+++ b/components/SessionPicker.tsx
@@ -1,289 +1,459 @@
-import React, { useCallback, useEffect, useState } from "react";
+import React, { useCallback, useEffect, useRef, useState } from "react";
import {
+ Animated,
+ Keyboard,
+ LayoutAnimation,
Modal,
+ Platform,
Pressable,
ScrollView,
Text,
TextInput,
+ UIManager,
View,
} from "react-native";
+import {
+ GestureHandlerRootView,
+ PanGestureHandler,
+ PanGestureHandlerGestureEvent,
+ State,
+ Swipeable,
+} from "react-native-gesture-handler";
import * as Haptics from "expo-haptics";
import { WsSession } from "../types";
import { useChat } from "../contexts/ChatContext";
+
+if (
+ Platform.OS === "android" &&
+ UIManager.setLayoutAnimationEnabledExperimental
+) {
+ UIManager.setLayoutAnimationEnabledExperimental(true);
+}
interface SessionPickerProps {
visible: boolean;
onClose: () => void;
}
+/* ── Swipeable row with delete action ── */
+
+function SessionRow({
+ session,
+ onSwitch,
+ onLongPress,
+ onDelete,
+}: {
+ session: WsSession;
+ onSwitch: () => void;
+ onLongPress: () => void;
+ onDelete: () => void;
+}) {
+ const swipeRef = useRef<Swipeable>(null);
+
+ const renderRightActions = (
+ _progress: Animated.AnimatedInterpolation<number>,
+ dragX: Animated.AnimatedInterpolation<number>,
+ ) => {
+ const scale = dragX.interpolate({
+ inputRange: [-100, -50, 0],
+ outputRange: [1, 0.8, 0],
+ extrapolate: "clamp",
+ });
+
+ return (
+ <Pressable
+ onPress={() => {
+ swipeRef.current?.close();
+ onDelete();
+ }}
+ style={{
+ backgroundColor: "#FF3B30",
+ justifyContent: "center",
+ alignItems: "center",
+ width: 80,
+ borderRadius: 16,
+ marginLeft: 8,
+ }}
+ >
+ <Animated.Text
+ style={{
+ color: "#FFF",
+ fontSize: 14,
+ fontWeight: "600",
+ transform: [{ scale }],
+ }}
+ >
+ Remove
+ </Animated.Text>
+ </Pressable>
+ );
+ };
+
+ return (
+ <Swipeable
+ ref={swipeRef}
+ renderRightActions={renderRightActions}
+ rightThreshold={60}
+ friction={2}
+ overshootRight={false}
+ >
+ <Pressable
+ onPress={onSwitch}
+ onLongPress={onLongPress}
+ delayLongPress={400}
+ style={({ pressed }) => ({
+ width: "100%",
+ backgroundColor: pressed ? "#252538" : "#1E1E2E",
+ borderRadius: 16,
+ padding: 14,
+ flexDirection: "row",
+ alignItems: "center",
+ borderWidth: session.isActive ? 2 : 1,
+ borderColor: session.isActive ? "#4A9EFF" : "#2E2E45",
+ })}
+ >
+ {/* Number badge */}
+ <View
+ style={{
+ width: 32,
+ height: 32,
+ borderRadius: 16,
+ backgroundColor: session.isActive ? "#4A9EFF" : "#252538",
+ alignItems: "center",
+ justifyContent: "center",
+ marginRight: 12,
+ }}
+ >
+ <Text
+ style={{
+ color: session.isActive ? "#FFF" : "#9898B0",
+ fontSize: 14,
+ fontWeight: "700",
+ }}
+ >
+ {session.index}
+ </Text>
+ </View>
+
+ {/* Session info */}
+ <View style={{ flex: 1 }}>
+ <Text
+ style={{
+ color: "#E8E8F0",
+ fontSize: 16,
+ fontWeight: "600",
+ }}
+ numberOfLines={1}
+ >
+ {session.name}
+ </Text>
+ <Text
+ style={{
+ color: "#5A5A78",
+ fontSize: 11,
+ marginTop: 1,
+ }}
+ >
+ {session.kind === "api"
+ ? "Headless"
+ : session.kind === "visual"
+ ? "Visual"
+ : session.type === "terminal"
+ ? "Terminal"
+ : "Claude"}
+ {session.isActive ? " — active" : ""}
+ </Text>
+ </View>
+
+ {/* Active indicator */}
+ {session.isActive && (
+ <View
+ style={{
+ width: 8,
+ height: 8,
+ borderRadius: 4,
+ backgroundColor: "#2ED573",
+ }}
+ />
+ )}
+ </Pressable>
+ </Swipeable>
+ );
+}
+
+/* ── Inline rename editor ── */
+
+function RenameEditor({
+ name,
+ onConfirm,
+ onCancel,
+}: {
+ name: string;
+ onConfirm: (newName: string) => void;
+ onCancel: () => void;
+}) {
+ const [editName, setEditName] = useState(name);
+
+ return (
+ <View
+ style={{
+ backgroundColor: "#1E1E2E",
+ borderRadius: 16,
+ padding: 14,
+ borderWidth: 2,
+ borderColor: "#4A9EFF",
+ }}
+ >
+ <TextInput
+ value={editName}
+ onChangeText={setEditName}
+ autoFocus
+ onSubmitEditing={() => onConfirm(editName.trim())}
+ onBlur={onCancel}
+ returnKeyType="done"
+ style={{
+ color: "#E8E8F0",
+ fontSize: 16,
+ fontWeight: "600",
+ padding: 0,
+ marginBottom: 10,
+ }}
+ placeholderTextColor="#5A5A78"
+ placeholder="Session name..."
+ />
+ <View style={{ flexDirection: "row", gap: 8 }}>
+ <Pressable
+ onPress={() => onConfirm(editName.trim())}
+ style={{
+ flex: 1,
+ backgroundColor: "#4A9EFF",
+ borderRadius: 10,
+ paddingVertical: 8,
+ alignItems: "center",
+ }}
+ >
+ <Text style={{ color: "#FFF", fontSize: 14, fontWeight: "600" }}>
+ Save
+ </Text>
+ </Pressable>
+ <Pressable
+ onPress={onCancel}
+ style={{
+ flex: 1,
+ backgroundColor: "#252538",
+ borderRadius: 10,
+ paddingVertical: 8,
+ alignItems: "center",
+ }}
+ >
+ <Text style={{ color: "#9898B0", fontSize: 14 }}>Cancel</Text>
+ </Pressable>
+ </View>
+ </View>
+ );
+}
+
+/* ── Main SessionPicker ── */
+
export function SessionPicker({ visible, onClose }: SessionPickerProps) {
- const { sessions, requestSessions, switchSession, renameSession } = useChat();
+ const {
+ sessions,
+ requestSessions,
+ switchSession,
+ renameSession,
+ removeSession,
+ } = useChat();
const [editingId, setEditingId] = useState<string | null>(null);
- const [editName, setEditName] = useState("");
+ const [keyboardHeight, setKeyboardHeight] = useState(0);
+
+ // Sort: active first, then by index
+ const sortedSessions = [...sessions].sort((a, b) => {
+ if (a.isActive && !b.isActive) return -1;
+ if (!a.isActive && b.isActive) return 1;
+ return a.index - b.index;
+ });
useEffect(() => {
- if (visible) {
+ const showSub = Keyboard.addListener("keyboardWillShow", (e) =>
+ setKeyboardHeight(e.endCoordinates.height),
+ );
+ const hideSub = Keyboard.addListener("keyboardWillHide", () =>
+ setKeyboardHeight(0),
+ );
+ return () => {
+ showSub.remove();
+ hideSub.remove();
+ };
+ }, []);
+
+ useEffect(() => {
+ if (!visible) {
+ setEditingId(null);
+ } else {
requestSessions();
}
}, [visible, requestSessions]);
+
+ const handleClose = useCallback(() => {
+ setEditingId(null);
+ Keyboard.dismiss();
+ onClose();
+ }, [onClose]);
const handleSwitch = useCallback(
(session: WsSession) => {
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Medium);
switchSession(session.id);
- onClose();
+ handleClose();
},
- [switchSession, onClose]
+ [switchSession, handleClose],
);
const handleStartRename = useCallback((session: WsSession) => {
+ Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
setEditingId(session.id);
- setEditName(session.name);
}, []);
- const handleConfirmRename = useCallback(() => {
- if (editingId && editName.trim()) {
- renameSession(editingId, editName.trim());
- }
- setEditingId(null);
- setEditName("");
- }, [editingId, editName, renameSession]);
+ const handleConfirmRename = useCallback(
+ (sessionId: string, newName: string) => {
+ if (newName) {
+ renameSession(sessionId, newName);
+ }
+ setEditingId(null);
+ },
+ [renameSession],
+ );
+
+ const handleRemove = useCallback(
+ (session: WsSession) => {
+ Haptics.notificationAsync(Haptics.NotificationFeedbackType.Warning);
+ LayoutAnimation.configureNext(LayoutAnimation.Presets.easeInEaseOut);
+ removeSession(session.id);
+ },
+ [removeSession],
+ );
return (
<Modal
visible={visible}
animationType="slide"
transparent
- onRequestClose={onClose}
+ onRequestClose={handleClose}
>
- <View
- style={{
- flex: 1,
- backgroundColor: "rgba(0,0,0,0.6)",
- justifyContent: "flex-end",
- }}
- >
- <Pressable
- style={{ flex: 1 }}
- onPress={onClose}
- />
+ <GestureHandlerRootView style={{ flex: 1 }}>
<View
style={{
- backgroundColor: "#14141F",
- borderTopLeftRadius: 24,
- borderTopRightRadius: 24,
- maxHeight: "70%",
- paddingBottom: 40,
+ flex: 1,
+ backgroundColor: "rgba(0,0,0,0.6)",
+ justifyContent: "flex-end",
}}
>
- {/* Handle bar */}
- <View style={{ alignItems: "center", paddingTop: 12, paddingBottom: 8 }}>
- <View
- style={{
- width: 40,
- height: 4,
- borderRadius: 2,
- backgroundColor: "#2E2E45",
- }}
- />
- </View>
-
- {/* Header */}
+ <Pressable style={{ flex: 1 }} onPress={handleClose} />
<View
style={{
- flexDirection: "row",
- alignItems: "center",
- justifyContent: "space-between",
- paddingHorizontal: 20,
- paddingBottom: 16,
+ backgroundColor: "#14141F",
+ borderTopLeftRadius: 24,
+ borderTopRightRadius: 24,
+ maxHeight: "70%",
+ paddingBottom: Math.max(40, keyboardHeight),
}}
>
- <Text
+ {/* Handle bar */}
+ <View
+ style={{ alignItems: "center", paddingTop: 12, paddingBottom: 8 }}
+ >
+ <View
+ style={{
+ width: 40,
+ height: 4,
+ borderRadius: 2,
+ backgroundColor: "#2E2E45",
+ }}
+ />
+ </View>
+
+ {/* Header */}
+ <View
style={{
- color: "#E8E8F0",
- fontSize: 20,
- fontWeight: "700",
+ flexDirection: "row",
+ alignItems: "center",
+ justifyContent: "space-between",
+ paddingHorizontal: 20,
+ paddingBottom: 12,
}}
>
- Sessions
- </Text>
- <Pressable
- onPress={() => requestSessions()}
- hitSlop={{ top: 8, bottom: 8, left: 8, right: 8 }}
- style={{
- paddingHorizontal: 12,
- paddingVertical: 6,
- borderRadius: 12,
- backgroundColor: "#1E1E2E",
- }}
+ <Text
+ style={{
+ color: "#E8E8F0",
+ fontSize: 20,
+ fontWeight: "700",
+ }}
+ >
+ Sessions
+ </Text>
+ <Pressable
+ onPress={() => requestSessions()}
+ hitSlop={{ top: 8, bottom: 8, left: 8, right: 8 }}
+ style={({ pressed }) => ({
+ paddingHorizontal: 12,
+ paddingVertical: 6,
+ borderRadius: 12,
+ backgroundColor: pressed ? "#252538" : "#1E1E2E",
+ })}
+ >
+ <Text style={{ color: "#9898B0", fontSize: 13 }}>Refresh</Text>
+ </Pressable>
+ </View>
+
+ {/* Session list */}
+ <ScrollView
+ style={{ paddingHorizontal: 16 }}
+ showsVerticalScrollIndicator={false}
+ keyboardShouldPersistTaps="handled"
>
- <Text style={{ color: "#9898B0", fontSize: 13 }}>Refresh</Text>
- </Pressable>
- </View>
-
- {/* Session list */}
- <ScrollView
- style={{ paddingHorizontal: 16 }}
- showsVerticalScrollIndicator={false}
- >
- {sessions.length === 0 ? (
- <View style={{ alignItems: "center", paddingVertical: 32 }}>
- <Text style={{ color: "#5A5A78", fontSize: 15 }}>
- No sessions found
- </Text>
- </View>
- ) : (
- sessions.map((session) => (
- <View key={session.id} style={{ marginBottom: 8 }}>
- {editingId === session.id ? (
- /* Rename mode */
- <View
- style={{
- backgroundColor: "#1E1E2E",
- borderRadius: 16,
- padding: 16,
- borderWidth: 2,
- borderColor: "#4A9EFF",
- }}
- >
- <TextInput
- value={editName}
- onChangeText={setEditName}
- autoFocus
- onSubmitEditing={handleConfirmRename}
- returnKeyType="done"
- style={{
- color: "#E8E8F0",
- fontSize: 17,
- fontWeight: "600",
- padding: 0,
- marginBottom: 12,
- }}
- placeholderTextColor="#5A5A78"
- placeholder="Session name..."
- />
- <View style={{ flexDirection: "row", gap: 8 }}>
- <Pressable
- onPress={handleConfirmRename}
- style={{
- flex: 1,
- backgroundColor: "#4A9EFF",
- borderRadius: 10,
- paddingVertical: 10,
- alignItems: "center",
- }}
- >
- <Text style={{ color: "#FFF", fontSize: 15, fontWeight: "600" }}>
- Save
- </Text>
- </Pressable>
- <Pressable
- onPress={() => setEditingId(null)}
- style={{
- flex: 1,
- backgroundColor: "#252538",
- borderRadius: 10,
- paddingVertical: 10,
- alignItems: "center",
- }}
- >
- <Text style={{ color: "#9898B0", fontSize: 15 }}>Cancel</Text>
- </Pressable>
- </View>
- </View>
- ) : (
- /* Normal session row */
- <Pressable
- onPress={() => handleSwitch(session)}
- onLongPress={() => handleStartRename(session)}
- style={({ pressed }) => ({
- backgroundColor: pressed ? "#252538" : "#1E1E2E",
- borderRadius: 16,
- padding: 16,
- flexDirection: "row",
- alignItems: "center",
- borderWidth: session.isActive ? 2 : 1,
- borderColor: session.isActive ? "#4A9EFF" : "#2E2E45",
- })}
- >
- {/* Number badge */}
- <View
- style={{
- width: 36,
- height: 36,
- borderRadius: 18,
- backgroundColor: session.isActive ? "#4A9EFF" : "#252538",
- alignItems: "center",
- justifyContent: "center",
- marginRight: 14,
- }}
- >
- <Text
- style={{
- color: session.isActive ? "#FFF" : "#9898B0",
- fontSize: 16,
- fontWeight: "700",
- }}
- >
- {session.index}
- </Text>
- </View>
-
- {/* Session info */}
- <View style={{ flex: 1 }}>
- <Text
- style={{
- color: "#E8E8F0",
- fontSize: 17,
- fontWeight: "600",
- }}
- numberOfLines={1}
- >
- {session.name}
- </Text>
- <Text
- style={{
- color: "#5A5A78",
- fontSize: 12,
- marginTop: 2,
- }}
- >
- {session.kind === "api" ? "Headless" : session.kind === "visual" ? "Visual" : session.type === "terminal" ? "Terminal" : "Claude"}
- {session.isActive ? " — active" : ""}
- </Text>
- </View>
-
- {/* Active indicator */}
- {session.isActive && (
- <View
- style={{
- width: 10,
- height: 10,
- borderRadius: 5,
- backgroundColor: "#2ED573",
- }}
- />
- )}
- </Pressable>
- )}
+ {sortedSessions.length === 0 ? (
+ <View style={{ alignItems: "center", paddingVertical: 32 }}>
+ <Text style={{ color: "#5A5A78", fontSize: 15 }}>
+ No sessions found
+ </Text>
</View>
- ))
- )}
+ ) : (
+ sortedSessions.map((session) => (
+ <View key={session.id} style={{ marginBottom: 6 }}>
+ {editingId === session.id ? (
+ <RenameEditor
+ name={session.name}
+ onConfirm={(name) =>
+ handleConfirmRename(session.id, name)
+ }
+ onCancel={() => setEditingId(null)}
+ />
+ ) : (
+ <SessionRow
+ session={session}
+ onSwitch={() => handleSwitch(session)}
+ onLongPress={() => handleStartRename(session)}
+ onDelete={() => handleRemove(session)}
+ />
+ )}
+ </View>
+ ))
+ )}
- {/* Hint */}
- <Text
- style={{
- color: "#5A5A78",
- fontSize: 12,
- textAlign: "center",
- paddingVertical: 12,
- }}
- >
- Tap to switch — Long press to rename
- </Text>
- </ScrollView>
+ <Text
+ style={{
+ color: "#5A5A78",
+ fontSize: 11,
+ textAlign: "center",
+ paddingVertical: 10,
+ }}
+ >
+ Tap to switch — Long press to rename — Swipe left to remove
+ </Text>
+ </ScrollView>
+ </View>
</View>
- </View>
+ </GestureHandlerRootView>
</Modal>
);
}
--
Gitblit v1.3.1