import { useCallback, useMemo, useState, useRef, useEffect } from "react" import { View, Text, FlatList, ScrollView, TouchableOpacity, StyleSheet, useColorScheme, RefreshControl, ActivityIndicator, Modal, TextInput, Alert, KeyboardAvoidingView, Platform, Linking, } from "react-native" import { router, useFocusEffect } from "expo-router" import { Ionicons } from "@expo/vector-icons" import { useTranslation } from "react-i18next" import { useSessions } from "../../src/stores/sessions" import { useConnections } from "../../src/stores/connections" import { useEvents } from "../../src/stores/events" import { useCatalog } from "../../src/stores/catalog" import type BottomSheet from "@gorhom/bottom-sheet" import type { Session, Project } from "../../src/lib/sdk" import { DirectorySwitcher, DirectoryBrowserSheet } from "../../src/components/chat" import { groupByDirectory } from "../../src/lib/session-grouping" import { nameOf } from "../../src/lib/path-utils" import { SETUP_GUIDE_URL } from "../../src/lib/links" function formatTime(timestamp: number, t: (key: string, opts?: Record) => string): string { const date = new Date(timestamp) const now = new Date() const diff = now.getTime() - date.getTime() if (diff < 60000) return t("sessionsList.time.justNow") if (diff < 3600000) return t("sessionsList.time.minutesAgo", { count: Math.floor(diff / 60000) }) if (diff < 86400000) return t("sessionsList.time.hoursAgo", { count: Math.floor(diff / 3600000) }) if (diff < 604800000) return t("sessionsList.time.daysAgo", { count: Math.floor(diff / 86400000) }) return date.toLocaleDateString() } function SessionItem({ session, isDark, onRename, onDelete, }: { session: Session isDark: boolean onRename: () => void onDelete: () => void }) { const { t } = useTranslation() const onPress = () => { router.push({ pathname: `/session/[id]`, params: { id: session.id, ...(session.directory ? { directory: session.directory } : {}) }, }) } const onLongPress = () => { Alert.alert(session.title || t("sessionsList.untitledSession"), undefined, [ { text: t("common.cancel"), style: "cancel" }, { text: t("sessionsList.actions.rename"), onPress: onRename }, { text: t("common.delete"), style: "destructive", onPress: onDelete }, ]) } // Extract short directory name from session const shortDir = session.directory ? session.directory.split("/").filter(Boolean).pop() : null return ( {session.title || t("sessionsList.untitledSession")} {formatTime(session.time.updated, t)} {/* summary is always present but files defaults to 0 until the server populates it — only show the count when it's meaningful, matching the SessionInfo panel's `summary.files > 0` guard (#55) */} {session.summary && session.summary.files > 0 && ` · ${t("sessionsList.filesCount", { count: session.summary.files })}`} {shortDir && ( {shortDir} )} ) } // Flattened list row — either a collapsible group header or a session. // A single flat array keeps FlatList's refresh/empty-state handling as-is // instead of switching to SectionList. type ListRow = | { type: "header"; directory: string; shortName: string; count: number; collapsed: boolean } | { type: "session"; session: Session } function GroupHeader({ row, isDark, onToggle, }: { row: { directory: string; shortName: string; count: number; collapsed: boolean } isDark: boolean onToggle: () => void }) { return ( {row.shortName} {row.count} ) } // Get short directory name (last folder or project name) function getShortPath( project: { path?: { cwd?: string; root?: string; absolute?: string }; name?: string } | null | undefined, ): string { if (!project) return "" if (project.name) return project.name if (!project.path?.absolute) return "" const parts = project.path.absolute.split("/").filter(Boolean) return parts[parts.length - 1] || project.path.absolute } export default function SessionsScreen() { const colorScheme = useColorScheme() const isDark = colorScheme === "dark" const { t } = useTranslation() const [showNewSession, setShowNewSession] = useState(false) const [customDir, setCustomDir] = useState("") const [isCreating, setIsCreating] = useState(false) const [renaming, setRenaming] = useState(null) const [renameText, setRenameText] = useState("") const renamingInFlight = useRef(false) // Synchronous re-entrancy guard: `isCreating` state lags by a render, so a // fast double-tap on the FAB / "Use this folder" would fire two session // creates before the disabled state lands. This blocks the second call. const creatingInFlight = useRef(false) const [serverProjects, setServerProjects] = useState([]) const { sessions, isLoading, error, loadSessions, createSession, deleteSession } = useSessions() const { activeConnection, client, currentProject, serverHome, refreshProject, clientForDirectory, switchDirectory, addRecentDirectory, recentDirectories, } = useConnections() const authError = useEvents((s) => s.authError) const reconnect = useEvents((s) => s.connect) const loadCatalog = useCatalog((s) => s.load) const dirSheetRef = useRef(null) const browserSheetRef = useRef(null) const [browseStartDir, setBrowseStartDir] = useState(null) // Shared folder browser is opened either to pick a directory for a new // session, or to switch the active connection's directory. const [browseMode, setBrowseMode] = useState<"create" | "switch">("create") const [refreshing, setRefreshing] = useState(false) // Directories collapsed in the grouped session list. Empty by default — // all groups start expanded (#67). const [collapsedDirs, setCollapsedDirs] = useState>(new Set()) const toggleGroup = useCallback((directory: string) => { setCollapsedDirs((prev) => { const next = new Set(prev) if (next.has(directory)) next.delete(directory) else next.add(directory) return next }) }, []) // Flatten sessions into header+item rows. Skip headers entirely when // everything lives in one directory — a lone header adds noise, not clarity. const rows = useMemo(() => { const groups = groupByDirectory(sessions) if (groups.length <= 1) { return sessions.map((session) => ({ type: "session", session })) } const out: ListRow[] = [] for (const group of groups) { const collapsed = collapsedDirs.has(group.directory) out.push({ type: "header", directory: group.directory, shortName: nameOf(group.directory) || group.directory, count: group.items.length, collapsed, }) if (!collapsed) { for (const session of group.items) out.push({ type: "session", session }) } } return out }, [sessions, collapsedDirs]) // Fetch server-known projects when the new session modal opens useEffect(() => { if (!showNewSession || !client) return client.project .list() .then(setServerProjects) .catch(() => setServerProjects([])) }, [showNewSession, client]) const handleSwitchDirectory = useCallback( async (dir?: string) => { await switchDirectory(dir) loadSessions() refreshProject() loadCatalog() }, [switchDirectory, loadSessions, refreshProject, loadCatalog], ) useFocusEffect( useCallback(() => { if (client) { loadSessions() refreshProject() } }, [client, loadSessions, refreshProject]), ) const onRefresh = useCallback(async () => { setRefreshing(true) try { await Promise.all([loadSessions(), refreshProject()]) } catch (err) { console.error("Refresh failed:", err) } finally { setRefreshing(false) } }, [loadSessions, refreshProject]) const handleRename = useCallback((session: Session) => { setRenameText(session.title || "") setRenaming(session) }, []) const submitRename = useCallback(async () => { const title = renameText.trim() if (!title || !renaming || renamingInFlight.current) return const renameClient = renaming.directory ? (clientForDirectory(renaming.directory) ?? client) : client if (!renameClient) return renamingInFlight.current = true try { await renameClient.session.update(renaming.id, { title }) setRenaming(null) setRenameText("") loadSessions() } catch (err) { console.error("Rename failed:", err) Alert.alert(t("sessionsList.alerts.renameFailedTitle"), t("sessionsList.alerts.renameFailedMessage")) } finally { renamingInFlight.current = false } }, [renaming, renameText, client, clientForDirectory, loadSessions, t]) const handleDelete = useCallback( (session: Session) => { Alert.alert( t("sessionsList.alerts.deleteTitle"), t("sessionsList.alerts.deleteMessage", { title: session.title || t("sessionsList.untitledSession") }), [ { text: t("common.cancel"), style: "cancel" }, { text: t("common.delete"), style: "destructive", onPress: async () => { try { await deleteSession(session.id) } catch (err) { console.error("Delete failed:", err) Alert.alert(t("sessionsList.alerts.deleteFailedTitle"), t("sessionsList.alerts.deleteFailedMessage")) } }, }, ], ) }, [deleteSession, t], ) const onCreateSession = async () => { if (creatingInFlight.current) return creatingInFlight.current = true try { const session = await createSession() if (session) { router.push({ pathname: `/session/[id]`, params: { id: session.id, ...(session.directory ? { directory: session.directory } : {}) }, }) } else { Alert.alert(t("common.error"), t("sessionsList.alerts.createFailedMessage")) } } finally { creatingInFlight.current = false } } const onCreateInDirectory = async (dir?: string) => { if (!activeConnection) return if (creatingInFlight.current) return creatingInFlight.current = true setIsCreating(true) try { // If a custom directory is specified, use a one-off client for that directory // so we don't mutate the connection's default project if (dir && dir.trim()) { const dirClient = clientForDirectory(dir.trim()) if (!dirClient) return try { const session = await dirClient.session.create({}) addRecentDirectory(dir.trim()) setShowNewSession(false) setCustomDir("") if (session) { router.push({ pathname: `/session/[id]`, params: { id: session.id, ...(session.directory ? { directory: session.directory } : {}) }, }) } } catch (error) { console.error("Failed to create session in directory:", error) Alert.alert(t("common.error"), t("sessionsList.alerts.createFailedMessage")) } return } const session = await createSession() setShowNewSession(false) setCustomDir("") if (session) { router.push({ pathname: `/session/[id]`, params: { id: session.id, ...(session.directory ? { directory: session.directory } : {}) }, }) } else { Alert.alert(t("common.error"), t("sessionsList.alerts.createFailedMessage")) } } finally { creatingInFlight.current = false setIsCreating(false) } } // The browser sheet is a sibling of the New Session . A native RN // Modal layers above everything in the React root (including bottom-sheet // portals), so the modal must be closed before the sheet is shown; this ref // remembers to bring it back if the user cancels without picking a folder. const restoreNewSessionOnDismiss = useRef(false) const openBrowser = useCallback( (startDir: string | null, mode: "create" | "switch") => { setBrowseStartDir(startDir || serverHome || null) setBrowseMode(mode) if (mode === "create" && showNewSession) { restoreNewSessionOnDismiss.current = true setShowNewSession(false) } browserSheetRef.current?.expand() }, [serverHome, showNewSession], ) const onBrowserSelect = useCallback( (directory: string) => { restoreNewSessionOnDismiss.current = false if (browseMode === "switch") { handleSwitchDirectory(directory) dirSheetRef.current?.close() } else { onCreateInDirectory(directory) } }, [browseMode, handleSwitchDirectory, onCreateInDirectory], ) const onBrowserDismiss = useCallback(() => { if (restoreNewSessionOnDismiss.current) { restoreNewSessionOnDismiss.current = false setShowNewSession(true) } }, []) const onFabPress = () => { // Quick create in current project onCreateSession() } const onFabLongPress = () => { // Show modal with more options setCustomDir("") setShowNewSession(true) } if (!activeConnection) { return ( {t("sessionsList.empty.noConnectionTitle")} {t("sessionsList.empty.noConnectionSubtitle")} router.push("/connection/add")} testID="add-connection-button" > {t("sessionsList.empty.addConnectionButton")} Linking.openURL(SETUP_GUIDE_URL)} testID="setup-guide-link" > {t("sessionsList.empty.setupGuideLink")} {/* No-server activation path (retention): a fully offline scripted demo, isolated from real connect/session state — see app/demo.tsx. */} router.push("/demo")} testID="try-demo-button" > {t("sessionsList.empty.tryDemoButton")} ) } // The SSE loop stopped retrying because the server rejected our // credentials (401/403) — no amount of pull-to-refresh fixes that, so // point the user straight at the fix instead of a spinner that never // resolves (issue #76). if (authError) { return ( {t("sessionsList.empty.authFailedTitle")} {t("sessionsList.empty.authFailedSubtitle", { name: activeConnection.name })} router.push(`/connection/${activeConnection.id}`)} testID="fix-connection-button" > {t("sessionsList.empty.checkCredentialsButton")} { // authError is cleared inside connect() itself once the retry // attempt starts (see src/stores/events.ts), so a manual // set() here isn't needed — just kick the SSE state machine. reconnect() }} testID="retry-connection-button" > {t("common.retry")} ) } const shortPath = getShortPath(currentProject) return ( {/* Connection indicator — tap to switch project */} dirSheetRef.current?.expand()} onLongPress={() => router.push("/(tabs)/connections")} activeOpacity={0.7} testID="connection-status-bar" > {activeConnection.name} {shortPath && ( <> {shortPath} )} {error && ( {error} )} (row.type === "header" ? `dir:${row.directory}` : row.session.id)} renderItem={({ item: row }) => row.type === "header" ? ( toggleGroup(row.directory)} /> ) : ( handleRename(row.session)} onDelete={() => handleDelete(row.session)} /> ) } refreshControl={ } ListEmptyComponent={ isLoading ? ( ) : ( {t("sessionsList.empty.noSessions")} ) } contentContainerStyle={sessions.length === 0 ? styles.emptyContent : undefined} /> {/* FAB to create new session */} {/* New Session Info Modal */} setShowNewSession(false)} /> {t("sessionsList.newSessionModal.title")} setShowNewSession(false)}> {/* Current directory — tapping creates session immediately */} {t("sessionsList.newSessionModal.currentProjectLabel")} onCreateInDirectory()} disabled={isCreating} > {currentProject?.path?.absolute || activeConnection?.directory || t("sessionsList.newSessionModal.serverDefault")} {/* Recent projects */} {recentDirectories.length > 0 && ( <> {t("sessionsList.newSessionModal.recentProjectsLabel")} {recentDirectories.map((dir) => { const short = dir.split("/").filter(Boolean).pop() || dir const isCurrent = dir === (currentProject?.path?.absolute || activeConnection?.directory) return ( onCreateInDirectory(dir)} disabled={isCreating} > {short} {dir} {isCurrent && } ) })} )} {/* Server-known projects (excluding current) */} {serverProjects.filter((p) => p.path?.absolute !== currentProject?.path?.absolute).length > 0 && ( <> {t("sessionsList.newSessionModal.serverProjectsLabel")} {serverProjects .filter((p) => p.path?.absolute !== currentProject?.path?.absolute) .map((p) => { const short = p.name || p.path?.absolute?.split("/").filter(Boolean).pop() || p.id return ( onCreateInDirectory(p.path?.absolute)} disabled={isCreating} > {short} {p.path?.absolute && ( {p.path.absolute} )} ) })} )} {/* Browse the server's filesystem instead of typing a path */} openBrowser(currentProject?.path?.absolute || activeConnection?.directory || null, "create") } disabled={isCreating} testID="browse-folders-button" > {t("sessionsList.newSessionModal.browseFoldersLabel")} {t("sessionsList.newSessionModal.browseFoldersHint")} {/* Manual path input fallback */} {t("sessionsList.newSessionModal.enterPathLabel")} { // Expand ~ to server home directory if (serverHome && text.startsWith("~/")) { setCustomDir(serverHome + text.slice(1)) } else if (serverHome && text === "~") { setCustomDir(serverHome) } else { setCustomDir(text) } }} autoCapitalize="none" autoCorrect={false} /> {/* Quick path shortcuts */} {serverHome && ( setCustomDir(serverHome)} > ~ setCustomDir(serverHome + "/")} > ~/ )} {customDir.trim() ? ( onCreateInDirectory(customDir)} disabled={isCreating} > {isCreating ? ( ) : ( {t("sessionsList.newSessionModal.createInButton", { dir: customDir.split("/").filter(Boolean).pop() || customDir, })} )} ) : ( onCreateInDirectory()} disabled={isCreating} > {isCreating ? ( ) : ( {t("sessionsList.newSessionModal.createSessionButton")} )} )} {/* Rename modal */} setRenaming(null)} /> {t("sessionsList.renameModal.title")} setRenaming(null)}> {t("common.cancel")} {t("common.save")} setRenaming(null)} /> {/* Directory switcher bottom sheet */} openBrowser(activeConnection?.directory || currentProject?.path?.absolute || null, "switch") } /> {/* Browsable folder picker — used for both "new session in..." and "switch project directory" flows (see browseMode). */} ) } const styles = StyleSheet.create({ container: { flex: 1, backgroundColor: "#ffffff", }, containerDark: { backgroundColor: "#0a0a0a", }, connectionBar: { flexDirection: "row", justifyContent: "space-between", alignItems: "center", paddingHorizontal: 16, paddingVertical: 12, borderBottomWidth: 1, borderBottomColor: "#e5e5e5", }, connectionBarDark: { borderBottomColor: "#1a1a1a", }, connectionInfo: { flexDirection: "row", alignItems: "center", gap: 8, }, connectionDot: { width: 8, height: 8, borderRadius: 4, }, connectionName: { fontSize: 14, fontWeight: "600", color: "#0a0a0a", }, connectionUrl: { fontSize: 12, color: "#666666", }, projectPath: { fontSize: 13, color: "#666666", flex: 1, }, errorBar: { backgroundColor: "#fef2f2", padding: 12, borderBottomWidth: 1, borderBottomColor: "#fecaca", }, errorText: { color: "#dc2626", fontSize: 14, }, groupHeader: { flexDirection: "row", alignItems: "center", gap: 8, paddingHorizontal: 16, paddingVertical: 10, backgroundColor: "#f5f5f5", borderBottomWidth: 1, borderBottomColor: "#e5e5e5", }, groupHeaderDark: { backgroundColor: "#151515", borderBottomColor: "#1a1a1a", }, groupHeaderText: { flex: 1, fontSize: 13, fontWeight: "600", color: "#0a0a0a", }, groupHeaderCount: { fontSize: 12, color: "#666666", }, sessionItem: { flexDirection: "row", alignItems: "center", padding: 16, borderBottomWidth: 1, borderBottomColor: "#e5e5e5", }, sessionItemDark: { borderBottomColor: "#1a1a1a", }, sessionContent: { flex: 1, }, sessionHeader: { flexDirection: "row", alignItems: "center", gap: 8, marginBottom: 2, }, sessionTitle: { fontSize: 16, fontWeight: "500", color: "#0a0a0a", marginBottom: 4, }, textDark: { color: "#ffffff", }, sessionMeta: { fontSize: 13, color: "#666666", }, sessionMetaRow: { flexDirection: "row", alignItems: "center", justifyContent: "space-between", }, sessionDirBadge: { flexDirection: "row", alignItems: "center", gap: 4, backgroundColor: "#f5f5f5", paddingHorizontal: 6, paddingVertical: 2, borderRadius: 4, }, sessionDirText: { fontSize: 11, color: "#666666", }, metaDark: { color: "#888888", }, emptyContainer: { flex: 1, justifyContent: "center", alignItems: "center", padding: 32, backgroundColor: "#ffffff", }, emptyTitle: { fontSize: 20, fontWeight: "600", marginTop: 16, color: "#0a0a0a", }, emptySubtitle: { fontSize: 14, color: "#666666", marginTop: 8, textAlign: "center", }, addButton: { backgroundColor: "#0a0a0a", paddingHorizontal: 24, paddingVertical: 12, borderRadius: 8, marginTop: 24, }, authErrorButtonRow: { flexDirection: "row", gap: 12, }, addButtonDark: { backgroundColor: "#ffffff", }, addButtonText: { color: "#ffffff", fontWeight: "600", }, addButtonTextDark: { color: "#0a0a0a", }, setupGuideLink: { marginTop: 16, }, setupGuideLinkText: { fontSize: 14, fontWeight: "600", color: "#6366f1", }, tryDemoButton: { flexDirection: "row", alignItems: "center", gap: 6, marginTop: 20, paddingHorizontal: 16, paddingVertical: 10, borderRadius: 8, borderWidth: 1, borderColor: "#8b5cf6", }, tryDemoButtonDark: { borderColor: "#a78bfa", }, tryDemoButtonText: { fontSize: 14, fontWeight: "600", color: "#6d28d9", }, tryDemoButtonTextDark: { color: "#a78bfa", }, loadingContainer: { flex: 1, justifyContent: "center", alignItems: "center", paddingVertical: 64, }, emptyList: { flex: 1, justifyContent: "center", alignItems: "center", paddingVertical: 64, }, emptyListText: { fontSize: 16, color: "#666666", }, emptyContent: { flex: 1, }, fab: { position: "absolute", right: 16, bottom: 16, width: 56, height: 56, borderRadius: 28, backgroundColor: "#0a0a0a", justifyContent: "center", alignItems: "center", shadowColor: "#000", shadowOffset: { width: 0, height: 2 }, shadowOpacity: 0.25, shadowRadius: 4, elevation: 5, }, fabDark: { backgroundColor: "#ffffff", }, // Modal styles modalOverlay: { flex: 1, backgroundColor: "rgba(0, 0, 0, 0.5)", justifyContent: "flex-end", }, modalDismiss: { flex: 1, }, modalContent: { backgroundColor: "#ffffff", borderTopLeftRadius: 20, borderTopRightRadius: 20, padding: 20, paddingBottom: 40, }, modalContentDark: { backgroundColor: "#1a1a1a", }, modalHeader: { flexDirection: "row", justifyContent: "space-between", alignItems: "center", marginBottom: 20, }, modalTitle: { fontSize: 20, fontWeight: "600", color: "#0a0a0a", }, modalBody: { marginBottom: 24, }, modalScrollBody: { maxHeight: 420, marginBottom: 16, }, projectRow: { flexDirection: "row", alignItems: "center", gap: 12, paddingHorizontal: 14, paddingVertical: 11, borderRadius: 10, backgroundColor: "#f5f5f5", marginBottom: 6, }, projectRowDark: { backgroundColor: "#2a2a2a", }, projectRowActive: { backgroundColor: "#f5f3ff", }, projectRowContent: { flex: 1, }, projectRowName: { fontSize: 14, fontWeight: "600", color: "#0a0a0a", }, projectRowNameActive: { color: "#8b5cf6", }, projectRowPath: { fontSize: 11, color: "#999999", marginTop: 1, }, modalLabel: { fontSize: 13, fontWeight: "600", color: "#666666", marginBottom: 8, textTransform: "uppercase", }, modalDirBox: { flexDirection: "row", alignItems: "center", gap: 12, backgroundColor: "#f5f5f5", padding: 16, borderRadius: 12, }, modalDirBoxDark: { backgroundColor: "#2a2a2a", }, modalDirText: { fontSize: 15, color: "#0a0a0a", flex: 1, }, modalInput: { backgroundColor: "#f5f5f5", borderRadius: 12, paddingHorizontal: 16, paddingVertical: 14, fontSize: 15, color: "#0a0a0a", }, modalInputDark: { backgroundColor: "#2a2a2a", color: "#ffffff", }, pathChips: { flexDirection: "row", gap: 8, marginTop: 8, }, pathChip: { paddingHorizontal: 12, paddingVertical: 6, backgroundColor: "#e8e5f0", borderRadius: 16, }, pathChipDark: { backgroundColor: "#2a2040", }, pathChipText: { fontSize: 13, fontWeight: "600", color: "#6d28d9", }, pathChipTextDark: { color: "#c4b5fd", }, modalHint: { fontSize: 13, color: "#666666", marginTop: 12, }, modalActions: { flexDirection: "row", gap: 12, }, modalButton: { flex: 1, flexDirection: "row", alignItems: "center", justifyContent: "center", gap: 8, padding: 16, borderRadius: 12, }, modalButtonSecondary: { backgroundColor: "#f5f5f5", }, modalButtonSecondaryDark: { backgroundColor: "#2a2a2a", }, modalButtonPrimary: { backgroundColor: "#0a0a0a", }, modalButtonPrimaryDark: { backgroundColor: "#ffffff", }, modalButtonText: { fontSize: 15, fontWeight: "600", color: "#0a0a0a", }, modalButtonTextPrimary: { fontSize: 15, fontWeight: "600", color: "#ffffff", }, modalButtonTextPrimaryDark: { color: "#0a0a0a", }, modalButtonFull: { flex: 0, width: "100%", }, // Rename modal renameCard: { backgroundColor: "#ffffff", borderRadius: 16, padding: 20, marginHorizontal: 32, gap: 16, }, renameCardDark: { backgroundColor: "#1a1a1a", }, renameTitle: { fontSize: 17, fontWeight: "600", color: "#0a0a0a", }, renameActions: { flexDirection: "row", justifyContent: "flex-end", gap: 12, }, renameBtn: { paddingHorizontal: 20, paddingVertical: 10, borderRadius: 10, }, renameBtnCancel: { backgroundColor: "transparent", }, renameBtnCancelText: { fontSize: 15, fontWeight: "600", color: "#888888", }, })