diff --git a/desktop/src/components/layout/ProjectFilter.tsx b/desktop/src/components/layout/ProjectFilter.tsx new file mode 100644 index 00000000..52ec8cb3 --- /dev/null +++ b/desktop/src/components/layout/ProjectFilter.tsx @@ -0,0 +1,124 @@ +import { useState, useRef, useEffect } from 'react' +import { useSessionStore } from '../../stores/sessionStore' + +export function ProjectFilter() { + const { availableProjects, selectedProjects, setSelectedProjects } = useSessionStore() + const [open, setOpen] = useState(false) + const ref = useRef(null) + + // Close dropdown on click outside + useEffect(() => { + if (!open) return + const close = (e: MouseEvent) => { + if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false) + } + document.addEventListener('mousedown', close) + return () => document.removeEventListener('mousedown', close) + }, [open]) + + const isAllSelected = selectedProjects.length === 0 + + const label = isAllSelected + ? 'All projects' + : selectedProjects.length === 1 + ? getDisplayName(selectedProjects[0]!) + : `${selectedProjects.length} projects` + + const toggleProject = (path: string) => { + if (isAllSelected) { + // Switch from "all" to "only this one" + setSelectedProjects([path]) + } else if (selectedProjects.includes(path)) { + const next = selectedProjects.filter((p) => p !== path) + // If nothing left, revert to all + setSelectedProjects(next.length === 0 ? [] : next) + } else { + const next = [...selectedProjects, path] + // If all are selected individually, revert to "all" + setSelectedProjects(next.length >= availableProjects.length ? [] : next) + } + } + + const selectAll = () => setSelectedProjects([]) + + return ( +
+ + + {open && ( +
+ {/* All projects */} + + +
+ + {/* Individual projects */} + {availableProjects.map((path) => { + const checked = !isAllSelected && selectedProjects.includes(path) + return ( + + ) + })} +
+ )} +
+ ) +} + +function getDisplayName(sanitizedPath: string): string { + if (!sanitizedPath || sanitizedPath === '_unknown') return 'Other' + const segments = sanitizedPath.split('-').filter(Boolean) + return segments[segments.length - 1] || 'Other' +} + +function ChevronIcon({ open }: { open: boolean }) { + return ( + + + + ) +} + +function FolderIcon() { + return ( + + + + ) +} + +function CheckIcon() { + return ( + + + + ) +} diff --git a/desktop/src/components/layout/Sidebar.tsx b/desktop/src/components/layout/Sidebar.tsx index 9b89b77b..37d3fdab 100644 --- a/desktop/src/components/layout/Sidebar.tsx +++ b/desktop/src/components/layout/Sidebar.tsx @@ -1,10 +1,15 @@ import { useEffect, useState, useCallback, useMemo } from 'react' import { useSessionStore } from '../../stores/sessionStore' import { useUIStore } from '../../stores/uiStore' +import { ProjectFilter } from './ProjectFilter' import type { SessionListItem } from '../../types/session' +type TimeGroup = 'Today' | 'Yesterday' | 'Last 7 days' | 'Last 30 days' | 'Older' + +const TIME_GROUP_ORDER: TimeGroup[] = ['Today', 'Yesterday', 'Last 7 days', 'Last 30 days', 'Older'] + export function Sidebar() { - const { sessions, activeSessionId, setActiveSession, fetchSessions, deleteSession, renameSession } = useSessionStore() + const { sessions, activeSessionId, selectedProjects, setActiveSession, fetchSessions, deleteSession, renameSession } = useSessionStore() const { activeView, setActiveView } = useUIStore() const [searchQuery, setSearchQuery] = useState('') const [contextMenu, setContextMenu] = useState<{ id: string; x: number; y: number } | null>(null) @@ -23,35 +28,21 @@ export function Sidebar() { return () => document.removeEventListener('click', close) }, [contextMenu]) - // Filter sessions by search query — already sorted by modifiedAt from API - const filteredSessions = searchQuery - ? sessions.filter((s) => s.title.toLowerCase().includes(searchQuery.toLowerCase())) - : sessions + // Filter by selected projects, then by search query + const filteredSessions = useMemo(() => { + let result = sessions + if (selectedProjects.length > 0) { + result = result.filter((s) => selectedProjects.includes(s.projectPath)) + } + if (searchQuery) { + const q = searchQuery.toLowerCase() + result = result.filter((s) => s.title.toLowerCase().includes(q)) + } + return result + }, [sessions, selectedProjects, searchQuery]) - // Group filtered sessions by projectPath, sorted by most recent session per group - const groupedSessions = useMemo(() => { - const groups = new Map() - for (const session of filteredSessions) { - const key = session.projectPath || '_unknown' - if (!groups.has(key)) groups.set(key, []) - groups.get(key)!.push(session) - } - // Sort sessions within each group by most recent first - for (const list of groups.values()) { - list.sort((a, b) => new Date(b.modifiedAt).getTime() - new Date(a.modifiedAt).getTime()) - } - // Sort groups by their most recent session - const sorted = [...groups.entries()].sort((a, b) => { - const aLatest = new Date(a[1][0]?.modifiedAt ?? 0).getTime() - const bLatest = new Date(b[1][0]?.modifiedAt ?? 0).getTime() - return bLatest - aLatest - }) - return sorted.map(([path, items]) => ({ - projectPath: path, - displayName: getProjectDisplayName(path), - sessions: items, - })) - }, [filteredSessions]) + // Group by time + const timeGroups = useMemo(() => groupByTime(filteredSessions), [filteredSessions]) const handleContextMenu = useCallback((e: React.MouseEvent, id: string) => { e.preventDefault() @@ -97,6 +88,11 @@ export function Sidebar() {
+ {/* Project filter */} +
+ +
+ {/* Search */}
- {/* Session list — grouped by project directory */} + {/* Session list — grouped by time */}
{filteredSessions.length === 0 && (
{searchQuery ? 'No matching sessions' : 'No sessions yet'}
)} - {groupedSessions.map((group) => ( -
- {/* Project group header */} -
- {group.displayName} -
- {/* Sessions in this group */} - {group.sessions.map((session) => ( -
- {renamingId === session.id ? ( - setRenameValue(e.target.value)} - onBlur={handleFinishRename} - onKeyDown={(e) => { - if (e.key === 'Enter') handleFinishRename() - if (e.key === 'Escape') { setRenamingId(null); setRenameValue('') } - }} - className="w-full px-3 py-2 text-sm rounded-[var(--radius-md)] border border-[var(--color-border-focus)] bg-[var(--color-surface)] text-[var(--color-text-primary)] outline-none ml-1" - /> - ) : ( - - )} + {TIME_GROUP_ORDER.map((group) => { + const items = timeGroups.get(group) + if (!items || items.length === 0) return null + return ( +
+
+ {group}
- ))} -
- ))} + {items.map((session) => ( +
+ {renamingId === session.id ? ( + setRenameValue(e.target.value)} + onBlur={handleFinishRename} + onKeyDown={(e) => { + if (e.key === 'Enter') handleFinishRename() + if (e.key === 'Escape') { setRenamingId(null); setRenameValue('') } + }} + className="w-full px-3 py-2 text-sm rounded-[var(--radius-md)] border border-[var(--color-border-focus)] bg-[var(--color-surface)] text-[var(--color-text-primary)] outline-none ml-1" + /> + ) : ( + + )} +
+ ))} +
+ ) + })}
{/* Settings button at bottom */} @@ -211,19 +209,28 @@ export function Sidebar() { ) } -/** - * Extract a human-readable project name from a sanitized project path. - * Paths look like: "-Users-nanmi-workspace-myself-code-claude-code-haha" - * We desanitize by treating leading `-` as `/`, then splitting on `-` to - * get path segments. The last non-empty segment is the project name. - * This is intentionally simple and lossy (original hyphens vs separators - * are indistinguishable), but good enough for display. - */ -function getProjectDisplayName(sanitizedPath: string): string { - if (!sanitizedPath || sanitizedPath === '_unknown') return 'Other' - // Take the last `-` delimited segment as the display name - const segments = sanitizedPath.split('-').filter(Boolean) - return segments[segments.length - 1] || 'Other' +function groupByTime(sessions: SessionListItem[]): Map { + const groups = new Map() + const now = new Date() + const startOfToday = new Date(now.getFullYear(), now.getMonth(), now.getDate()).getTime() + const startOfYesterday = startOfToday - 86400000 + const sevenDaysAgo = startOfToday - 7 * 86400000 + const thirtyDaysAgo = startOfToday - 30 * 86400000 + + for (const session of sessions) { + const ts = new Date(session.modifiedAt).getTime() + let group: TimeGroup + if (ts >= startOfToday) group = 'Today' + else if (ts >= startOfYesterday) group = 'Yesterday' + else if (ts >= sevenDaysAgo) group = 'Last 7 days' + else if (ts >= thirtyDaysAgo) group = 'Last 30 days' + else group = 'Older' + + if (!groups.has(group)) groups.set(group, []) + groups.get(group)!.push(session) + } + + return groups } function NavItem({ active, onClick, icon, children }: { active: boolean; onClick: () => void; icon: React.ReactNode; children: React.ReactNode }) { diff --git a/desktop/src/stores/sessionStore.ts b/desktop/src/stores/sessionStore.ts index 8ea2f9aa..fe102e65 100644 --- a/desktop/src/stores/sessionStore.ts +++ b/desktop/src/stores/sessionStore.ts @@ -7,12 +7,15 @@ type SessionStore = { activeSessionId: string | null isLoading: boolean error: string | null + selectedProjects: string[] + availableProjects: string[] fetchSessions: (project?: string) => Promise createSession: (workDir?: string) => Promise deleteSession: (id: string) => Promise renameSession: (id: string, title: string) => Promise setActiveSession: (id: string | null) => void + setSelectedProjects: (projects: string[]) => void } export const useSessionStore = create((set, get) => ({ @@ -20,12 +23,24 @@ export const useSessionStore = create((set, get) => ({ activeSessionId: null, isLoading: false, error: null, + selectedProjects: [], + availableProjects: [], fetchSessions: async (project?: string) => { set({ isLoading: true, error: null }) try { - const { sessions } = await sessionsApi.list({ project, limit: 100 }) - set({ sessions, isLoading: false }) + const { sessions: raw } = await sessionsApi.list({ project, limit: 100 }) + // Deduplicate by session ID — keep the most recently modified entry + const byId = new Map() + for (const s of raw) { + const existing = byId.get(s.id) + if (!existing || new Date(s.modifiedAt) > new Date(existing.modifiedAt)) { + byId.set(s.id, s) + } + } + const sessions = [...byId.values()] + const availableProjects = [...new Set(sessions.map((s) => s.projectPath).filter(Boolean))].sort() + set({ sessions, availableProjects, isLoading: false }) } catch (err) { set({ error: (err as Error).message, isLoading: false }) } @@ -57,4 +72,5 @@ export const useSessionStore = create((set, get) => ({ }, setActiveSession: (id) => set({ activeSessionId: id }), + setSelectedProjects: (projects) => set({ selectedProjects: projects }), }))