feat(sessions): group conversation list by project directory (closes #67) (#96)

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Den
2026-07-17 02:22:57 -07:00
committed by GitHub
parent 3b6dab8c27
commit 90093f4bac
3 changed files with 187 additions and 11 deletions

View File

@@ -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 (
<TouchableOpacity
style={[styles.groupHeader, isDark && styles.groupHeaderDark]}
onPress={onToggle}
activeOpacity={0.7}
>
<Ionicons name="folder-outline" size={16} color={isDark ? "#8b5cf6" : "#6d28d9"} />
<Text style={[styles.groupHeaderText, isDark && styles.textDark]} numberOfLines={1}>
{row.shortName}
</Text>
<Text style={[styles.groupHeaderCount, isDark && styles.metaDark]}>{row.count}</Text>
<Ionicons
name={row.collapsed ? "chevron-forward" : "chevron-down"}
size={16}
color={isDark ? "#666666" : "#999999"}
/>
</TouchableOpacity>
)
}
// 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<Set<string>>(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<ListRow[]>(() => {
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() {
)}
<FlatList
data={sessions}
keyExtractor={(item) => item.id}
renderItem={({ item }) => (
data={rows}
keyExtractor={(row) => (row.type === "header" ? `dir:${row.directory}` : row.session.id)}
renderItem={({ item: row }) =>
row.type === "header" ? (
<GroupHeader row={row} isDark={isDark} onToggle={() => toggleGroup(row.directory)} />
) : (
<SessionItem
session={item}
session={row.session}
isDark={isDark}
onRename={() => handleRename(item)}
onDelete={() => handleDelete(item)}
onRename={() => handleRename(row.session)}
onDelete={() => handleDelete(row.session)}
/>
)}
)
}
refreshControl={
<RefreshControl refreshing={refreshing} onRefresh={onRefresh} tintColor={isDark ? "#ffffff" : "#0a0a0a"} />
}
@@ -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",

View File

@@ -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)
})

View File

@@ -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<T> {
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<T extends { directory: string }>(items: T[]): DirectoryGroup<T>[] {
const order: string[] = []
const buckets = new Map<string, T[]>()
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[] }))
}