From 5a57098ffd22ac669f15a1aab1e8d31a2c5093cb Mon Sep 17 00:00:00 2001 From: 7418 <7418@openclaw.ai> Date: Fri, 13 Feb 2026 14:46:50 +0800 Subject: [PATCH] feat: group chat sidebar by project folder with collapsible sections Restructure the chat list panel to group sessions by working directory (project folder), similar to Codex App. Each project folder is shown as a collapsible section with a folder icon and project name. Features: - Sessions grouped by working_directory, sorted by most recent activity - Collapsible folder sections with persistent state in localStorage - Per-folder "+" button to create new chat in that project - "Open project folder" button in sidebar header with FolderPicker dialog - NavRail new-chat button creates sessions in most recent project - Chat header shows "projectName / sessionTitle" breadcrumb - Search filters both folder names and session titles Co-Authored-By: Claude Opus 4.6 --- src/app/chat/[id]/page.tsx | 11 +- src/components/layout/ChatListPanel.tsx | 434 +++++++++++++++++------- src/components/layout/NavRail.tsx | 43 ++- 3 files changed, 359 insertions(+), 129 deletions(-) diff --git a/src/app/chat/[id]/page.tsx b/src/app/chat/[id]/page.tsx index 1b83ae5..a4cffd5 100644 --- a/src/app/chat/[id]/page.tsx +++ b/src/app/chat/[id]/page.tsx @@ -21,6 +21,7 @@ export default function ChatSessionPage({ params }: ChatSessionPageProps) { const [sessionTitle, setSessionTitle] = useState(''); const [sessionModel, setSessionModel] = useState(''); const [sessionMode, setSessionMode] = useState(''); + const [projectName, setProjectName] = useState(''); const { setWorkingDirectory, setSessionId, setSessionTitle: setPanelSessionTitle, setPanelOpen } = usePanel(); // Load session info and set working directory @@ -32,6 +33,7 @@ export default function ChatSessionPage({ params }: ChatSessionPageProps) { const data: { session: ChatSession } = await res.json(); if (data.session.working_directory) { setWorkingDirectory(data.session.working_directory); + localStorage.setItem("codepilot:last-working-directory", data.session.working_directory); } setSessionId(id); setPanelOpen(true); @@ -40,6 +42,7 @@ export default function ChatSessionPage({ params }: ChatSessionPageProps) { setPanelSessionTitle(title); setSessionModel(data.session.model || ''); setSessionMode(data.session.mode || 'code'); + setProjectName(data.session.project_name || ''); } } catch { // Session info load failed - panel will still work without directory @@ -112,9 +115,15 @@ export default function ChatSessionPage({ params }: ChatSessionPageProps) { {/* Chat title bar */} {sessionTitle && (
+ {projectName && ( + <> + {projectName} + / + + )}

{sessionTitle}

diff --git a/src/components/layout/ChatListPanel.tsx b/src/components/layout/ChatListPanel.tsx index 314dc0a..bc356a9 100644 --- a/src/components/layout/ChatListPanel.tsx +++ b/src/components/layout/ChatListPanel.tsx @@ -2,9 +2,19 @@ import { usePathname, useRouter } from "next/navigation"; import Link from "next/link"; -import { useEffect, useState, useCallback } from "react"; +import { useEffect, useState, useCallback, useMemo } from "react"; import { HugeiconsIcon } from "@hugeicons/react"; -import { Delete02Icon, Search01Icon, Notification02Icon, FileImportIcon } from "@hugeicons/core-free-icons"; +import { + Delete02Icon, + Search01Icon, + Notification02Icon, + FileImportIcon, + Folder01Icon, + ArrowDown01Icon, + ArrowRight01Icon, + PlusSignIcon, + FolderOpenIcon, +} from "@hugeicons/core-free-icons"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { ScrollArea } from "@/components/ui/scroll-area"; @@ -17,6 +27,7 @@ import { cn } from "@/lib/utils"; import { usePanel } from "@/hooks/usePanel"; import { ConnectionStatus } from "./ConnectionStatus"; import { ImportSessionDialog } from "./ImportSessionDialog"; +import { FolderPicker } from "@/components/chat/FolderPicker"; import type { ChatSession } from "@/types"; interface ChatListPanelProps { @@ -33,34 +44,65 @@ function formatRelativeTime(dateStr: string): string { const diffDay = Math.floor(diffHr / 24); if (diffMin < 1) return "just now"; - if (diffMin < 60) return `${diffMin}m ago`; - if (diffHr < 24) return `${diffHr}h ago`; - if (diffDay < 7) return `${diffDay}d ago`; + if (diffMin < 60) return `${diffMin}m`; + if (diffHr < 24) return `${diffHr}h`; + if (diffDay < 7) return `${diffDay}d`; return date.toLocaleDateString(); } -const DATE_GROUP_ORDER = ["Today", "Yesterday", "Last 7 Days", "Older"]; +const COLLAPSED_PROJECTS_KEY = "codepilot:collapsed-projects"; -function groupSessionsByDate( - sessions: ChatSession[] -): Record { - const groups: Record = {}; - const now = new Date(); - const today = new Date(now.getFullYear(), now.getMonth(), now.getDate()); - const yesterday = new Date(today.getTime() - 86400000); - const lastWeek = new Date(today.getTime() - 7 * 86400000); - - for (const session of sessions) { - const date = new Date(session.updated_at); - let group: string; - if (date >= today) group = "Today"; - else if (date >= yesterday) group = "Yesterday"; - else if (date >= lastWeek) group = "Last 7 Days"; - else group = "Older"; - - if (!groups[group]) groups[group] = []; - groups[group].push(session); +function loadCollapsedProjects(): Set { + try { + const raw = localStorage.getItem(COLLAPSED_PROJECTS_KEY); + if (raw) return new Set(JSON.parse(raw)); + } catch { + // ignore } + return new Set(); +} + +function saveCollapsedProjects(collapsed: Set) { + localStorage.setItem(COLLAPSED_PROJECTS_KEY, JSON.stringify([...collapsed])); +} + +interface ProjectGroup { + workingDirectory: string; + displayName: string; + sessions: ChatSession[]; + latestUpdatedAt: number; +} + +function groupSessionsByProject(sessions: ChatSession[]): ProjectGroup[] { + const map = new Map(); + for (const session of sessions) { + const key = session.working_directory || ""; + if (!map.has(key)) map.set(key, []); + map.get(key)!.push(session); + } + + const groups: ProjectGroup[] = []; + for (const [wd, groupSessions] of map) { + // Sort sessions within group by updated_at DESC + groupSessions.sort( + (a, b) => + new Date(b.updated_at).getTime() - new Date(a.updated_at).getTime() + ); + const displayName = + wd === "" + ? "No Project" + : groupSessions[0]?.project_name || wd.split("/").pop() || wd; + const latestUpdatedAt = new Date(groupSessions[0].updated_at).getTime(); + groups.push({ + workingDirectory: wd, + displayName, + sessions: groupSessions, + latestUpdatedAt, + }); + } + + // Sort groups by most recently active first + groups.sort((a, b) => b.latestUpdatedAt - a.latestUpdatedAt); return groups; } @@ -79,6 +121,21 @@ export function ChatListPanel({ open, width }: ChatListPanelProps) { const [deletingSession, setDeletingSession] = useState(null); const [searchQuery, setSearchQuery] = useState(""); const [importDialogOpen, setImportDialogOpen] = useState(false); + const [folderPickerOpen, setFolderPickerOpen] = useState(false); + const [collapsedProjects, setCollapsedProjects] = useState>( + () => loadCollapsedProjects() + ); + const [hoveredFolder, setHoveredFolder] = useState(null); + + const toggleProject = useCallback((wd: string) => { + setCollapsedProjects((prev) => { + const next = new Set(prev); + if (next.has(wd)) next.delete(wd); + else next.add(wd); + saveCollapsedProjects(next); + return next; + }); + }, []); const fetchSessions = useCallback(async () => { try { @@ -104,11 +161,11 @@ export function ChatListPanel({ open, width }: ChatListPanelProps) { // Refresh session list when a session is created or updated useEffect(() => { const handler = () => fetchSessions(); - window.addEventListener('session-created', handler); - window.addEventListener('session-updated', handler); + window.addEventListener("session-created", handler); + window.addEventListener("session-updated", handler); return () => { - window.removeEventListener('session-created', handler); - window.removeEventListener('session-updated', handler); + window.removeEventListener("session-created", handler); + window.removeEventListener("session-updated", handler); }; }, [fetchSessions]); @@ -118,7 +175,7 @@ export function ChatListPanel({ open, width }: ChatListPanelProps) { ) => { e.preventDefault(); e.stopPropagation(); - if (!confirm('Delete this conversation?')) return; + if (!confirm("Delete this conversation?")) return; setDeletingSession(sessionId); try { const res = await fetch(`/api/chat/sessions/${sessionId}`, { @@ -137,6 +194,46 @@ export function ChatListPanel({ open, width }: ChatListPanelProps) { } }; + const handleCreateSessionInProject = async ( + e: React.MouseEvent, + workingDirectory: string + ) => { + e.stopPropagation(); + try { + const res = await fetch("/api/chat/sessions", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ working_directory: workingDirectory }), + }); + if (res.ok) { + const data = await res.json(); + window.dispatchEvent(new CustomEvent("session-created")); + router.push(`/chat/${data.session.id}`); + } + } catch { + // Silently fail + } + }; + + const handleFolderSelect = async (path: string) => { + try { + const res = await fetch("/api/chat/sessions", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ working_directory: path }), + }); + if (res.ok) { + const data = await res.json(); + window.dispatchEvent(new CustomEvent("session-created")); + router.push(`/chat/${data.session.id}`); + } + } catch { + // Silently fail + } + }; + + const isSearching = searchQuery.length > 0; + const filteredSessions = searchQuery ? sessions.filter( (s) => @@ -146,26 +243,51 @@ export function ChatListPanel({ open, width }: ChatListPanelProps) { ) : sessions; - const groupedSessions = groupSessionsByDate(filteredSessions); + const projectGroups = useMemo( + () => groupSessionsByProject(filteredSessions), + [filteredSessions] + ); if (!open) return null; return ( -