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[] })) +}