diff --git a/app/(tabs)/index.tsx b/app/(tabs)/index.tsx
index a8bac0c..5b31f5f 100644
--- a/app/(tabs)/index.tsx
+++ b/app/(tabs)/index.tsx
@@ -1,4 +1,4 @@
-import { useCallback, useState, useRef, useEffect } from "react"
+import { useCallback, useMemo, useState, useRef, useEffect } from "react"
import {
View,
Text,
@@ -24,6 +24,8 @@ 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"
function formatTime(timestamp: number): string {
const date = new Date(timestamp)
@@ -100,6 +102,42 @@ function SessionItem({
)
}
+// 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,
@@ -144,6 +182,42 @@ export default function SessionsScreen() {
// 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(() => {
@@ -424,16 +498,20 @@ export default function SessionsScreen() {
)}
item.id}
- renderItem={({ item }) => (
- handleRename(item)}
- onDelete={() => handleDelete(item)}
- />
- )}
+ data={rows}
+ keyExtractor={(row) => (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={
}
@@ -793,6 +871,30 @@ const styles = StyleSheet.create({
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",
diff --git a/src/lib/session-grouping.test.ts b/src/lib/session-grouping.test.ts
new file mode 100644
index 0000000..66a8b4d
--- /dev/null
+++ b/src/lib/session-grouping.test.ts
@@ -0,0 +1,43 @@
+import { test } from "node:test"
+import assert from "node:assert/strict"
+import { groupByDirectory } from "./session-grouping.ts"
+
+test("groupByDirectory: buckets items by directory, preserving item order within a bucket", () => {
+ const items = [
+ { id: "1", directory: "/a" },
+ { id: "2", directory: "/b" },
+ { id: "3", directory: "/a" },
+ ]
+ const groups = groupByDirectory(items)
+ assert.equal(groups.length, 2)
+ assert.equal(groups[0].directory, "/a")
+ assert.deepEqual(groups[0].items.map((i) => i.id), ["1", "3"])
+ assert.equal(groups[1].directory, "/b")
+ assert.deepEqual(groups[1].items.map((i) => i.id), ["2"])
+})
+
+test("groupByDirectory: orders groups by first-seen directory, not alphabetically", () => {
+ const items = [
+ { id: "1", directory: "/z" },
+ { id: "2", directory: "/a" },
+ ]
+ const groups = groupByDirectory(items)
+ assert.deepEqual(
+ groups.map((g) => g.directory),
+ ["/z", "/a"],
+ )
+})
+
+test("groupByDirectory: empty input returns no groups", () => {
+ assert.deepEqual(groupByDirectory([]), [])
+})
+
+test("groupByDirectory: single directory yields a single group with all items", () => {
+ const items = [
+ { id: "1", directory: "/a" },
+ { id: "2", directory: "/a" },
+ ]
+ const groups = groupByDirectory(items)
+ assert.equal(groups.length, 1)
+ assert.equal(groups[0].items.length, 2)
+})
diff --git a/src/lib/session-grouping.ts b/src/lib/session-grouping.ts
new file mode 100644
index 0000000..6cf04f3
--- /dev/null
+++ b/src/lib/session-grouping.ts
@@ -0,0 +1,31 @@
+// Pure helpers for grouping the session list by project directory.
+// No React Native imports — unit-testable with node --test.
+
+/** A contiguous-by-directory bucket of items, in first-seen order. */
+export interface DirectoryGroup {
+ directory: string
+ items: T[]
+}
+
+/**
+ * Groups items by their `directory` field, preserving both the original
+ * item order within a group and the order in which each directory was
+ * first encountered. Does not sort — callers decide presentation order.
+ */
+export function groupByDirectory(items: T[]): DirectoryGroup[] {
+ const order: string[] = []
+ const buckets = new Map()
+
+ for (const item of items) {
+ const dir = item.directory
+ let bucket = buckets.get(dir)
+ if (!bucket) {
+ bucket = []
+ buckets.set(dir, bucket)
+ order.push(dir)
+ }
+ bucket.push(item)
+ }
+
+ return order.map((directory) => ({ directory, items: buckets.get(directory) as T[] }))
+}