import { useCallback, useState, useRef, useEffect } from "react"
import {
View,
Text,
FlatList,
ScrollView,
TouchableOpacity,
StyleSheet,
useColorScheme,
RefreshControl,
ActivityIndicator,
Modal,
TextInput,
Alert,
KeyboardAvoidingView,
Platform,
} from "react-native"
import { router, useFocusEffect } from "expo-router"
import { Ionicons } from "@expo/vector-icons"
import { useSessions } from "../../src/stores/sessions"
import { useConnections } from "../../src/stores/connections"
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"
function formatTime(timestamp: number): string {
const date = new Date(timestamp)
const now = new Date()
const diff = now.getTime() - date.getTime()
if (diff < 60000) return "Just now"
if (diff < 3600000) return `${Math.floor(diff / 60000)}m ago`
if (diff < 86400000) return `${Math.floor(diff / 3600000)}h ago`
if (diff < 604800000) return `${Math.floor(diff / 86400000)}d ago`
return date.toLocaleDateString()
}
function SessionItem({
session,
isDark,
onRename,
onDelete,
}: {
session: Session
isDark: boolean
onRename: () => void
onDelete: () => void
}) {
const onPress = () => {
router.push({
pathname: `/session/[id]`,
params: { id: session.id, ...(session.directory ? { directory: session.directory } : {}) },
})
}
const onLongPress = () => {
Alert.alert(session.title || "Untitled Session", undefined, [
{ text: "Cancel", style: "cancel" },
{ text: "Rename", onPress: onRename },
{ text: "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 || "Untitled Session"}
{formatTime(session.time.updated)}
{/* 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 && ` · ${session.summary.files} files`}
{shortDir && (
{shortDir}
)}
)
}
// 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 [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)
const [serverProjects, setServerProjects] = useState([])
const { sessions, isLoading, error, loadSessions, createSession, deleteSession } = useSessions()
const {
activeConnection,
client,
currentProject,
serverHome,
refreshProject,
clientForDirectory,
switchDirectory,
addRecentDirectory,
recentDirectories,
} = useConnections()
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)
// 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("Rename failed", "Could not rename the session. Please try again.")
} finally {
renamingInFlight.current = false
}
}, [renaming, renameText, client, clientForDirectory, loadSessions])
const handleDelete = useCallback(
(session: Session) => {
Alert.alert("Delete Session", `Delete "${session.title || "Untitled Session"}"?`, [
{ text: "Cancel", style: "cancel" },
{
text: "Delete",
style: "destructive",
onPress: async () => {
try {
await deleteSession(session.id)
} catch (err) {
console.error("Delete failed:", err)
Alert.alert("Delete failed", "Could not delete the session. Please try again.")
}
},
},
])
},
[deleteSession],
)
const onCreateSession = async () => {
const session = await createSession()
if (session) {
router.push({
pathname: `/session/[id]`,
params: { id: session.id, ...(session.directory ? { directory: session.directory } : {}) },
})
}
}
const onCreateInDirectory = async (dir?: string) => {
if (!activeConnection) return
setIsCreating(true)
// 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) {
setIsCreating(false)
return
}
try {
const session = await dirClient.session.create({})
addRecentDirectory(dir.trim())
setIsCreating(false)
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("Error", "Failed to create session in that directory.")
setIsCreating(false)
}
return
}
const session = await createSession()
setIsCreating(false)
setShowNewSession(false)
setCustomDir("")
if (session) {
router.push({
pathname: `/session/[id]`,
params: { id: session.id, ...(session.directory ? { directory: session.directory } : {}) },
})
}
}
// 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 (
No Connection
Add a server connection to get started
router.push("/connection/add")}
testID="add-connection-button"
>
Add Connection
)
}
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}
)}
item.id}
renderItem={({ item }) => (
handleRename(item)}
onDelete={() => handleDelete(item)}
/>
)}
refreshControl={
}
ListEmptyComponent={
isLoading ? (
) : (
No sessions yet
)
}
contentContainerStyle={sessions.length === 0 ? styles.emptyContent : undefined}
/>
{/* FAB to create new session */}
{/* New Session Info Modal */}
setShowNewSession(false)} />
New Session
setShowNewSession(false)}>
{/* Current directory — tapping creates session immediately */}
Current Project
onCreateInDirectory()}
disabled={isCreating}
>
{currentProject?.path?.absolute || activeConnection?.directory || "Server default"}
{/* Recent projects */}
{recentDirectories.length > 0 && (
<>
Recent Projects
{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 && (
<>
Server Projects
{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}
>
Browse Folders…
Explore the server's filesystem
{/* Manual path input fallback */}
Enter Path Manually
{
// 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 ? (
) : (
Create in {customDir.split("/").filter(Boolean).pop() || customDir}
)}
) : (
onCreateInDirectory()}
disabled={isCreating}
>
{isCreating ? (
) : (
Create Session
)}
)}
{/* Rename modal */}
setRenaming(null)} />
Rename Session
setRenaming(null)}>
Cancel
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,
},
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,
},
addButtonDark: {
backgroundColor: "#ffffff",
},
addButtonText: {
color: "#ffffff",
fontWeight: "600",
},
addButtonTextDark: {
color: "#0a0a0a",
},
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",
},
})