feat: redesign sidebar — project filter dropdown + time-grouped session list

Replace project-grouped session list with a flat time-grouped layout
(Today / Yesterday / Last 7 days / Last 30 days / Older) matching
the original Claude Code desktop design. Add ProjectFilter component
with multi-select dropdown for filtering by project. Deduplicate
sessions by ID in the store to fix duplicate-key React errors caused
by the same session appearing across multiple project directories.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
程序员阿江(Relakkes) 2026-04-07 01:08:55 +08:00
parent d601bbf968
commit 5d908b2828
3 changed files with 246 additions and 99 deletions

View File

@ -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<HTMLDivElement>(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 (
<div ref={ref} className="relative">
<button
onClick={() => setOpen(!open)}
className="flex items-center gap-1 px-2 py-1 text-sm text-[var(--color-text-secondary)] hover:text-[var(--color-text-primary)] transition-colors rounded-[var(--radius-md)] hover:bg-[var(--color-surface-hover)]"
>
<span className="truncate max-w-[140px]">{label}</span>
<ChevronIcon open={open} />
</button>
{open && (
<div
className="absolute left-0 top-full mt-1 z-50 min-w-[200px] max-h-[300px] overflow-y-auto bg-[var(--color-surface)] border border-[var(--color-border)] rounded-[var(--radius-md)] py-1"
style={{ boxShadow: 'var(--shadow-dropdown)' }}
>
{/* All projects */}
<button
onClick={selectAll}
className="w-full flex items-center gap-2.5 px-3 py-1.5 text-sm text-left hover:bg-[var(--color-surface-hover)] transition-colors"
>
<FolderIcon />
<span className="flex-1 text-[var(--color-text-primary)]">All projects</span>
{isAllSelected && <CheckIcon />}
</button>
<div className="mx-2 my-1 border-t border-[var(--color-border)]" />
{/* Individual projects */}
{availableProjects.map((path) => {
const checked = !isAllSelected && selectedProjects.includes(path)
return (
<button
key={path}
onClick={() => toggleProject(path)}
className="w-full flex items-center gap-2.5 px-3 py-1.5 text-sm text-left hover:bg-[var(--color-surface-hover)] transition-colors"
>
<FolderIcon />
<span className="flex-1 truncate text-[var(--color-text-primary)]">{getDisplayName(path)}</span>
{checked && <CheckIcon />}
</button>
)
})}
</div>
)}
</div>
)
}
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 (
<svg
width="14" height="14" viewBox="0 0 24 24" fill="none"
stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"
className={`transition-transform ${open ? 'rotate-180' : ''}`}
>
<polyline points="6 9 12 15 18 9" />
</svg>
)
}
function FolderIcon() {
return (
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" className="text-[var(--color-text-tertiary)] flex-shrink-0">
<path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z" />
</svg>
)
}
function CheckIcon() {
return (
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="var(--color-brand)" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round" className="flex-shrink-0">
<polyline points="20 6 9 17 4 12" />
</svg>
)
}

View File

@ -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<string, SessionListItem[]>()
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() {
</NavItem>
</div>
{/* Project filter */}
<div className="px-3 pb-1 flex items-center justify-between">
<ProjectFilter />
</div>
{/* Search */}
<div className="px-3 pb-2">
<input
@ -109,68 +105,70 @@ export function Sidebar() {
/>
</div>
{/* Session list — grouped by project directory */}
{/* Session list — grouped by time */}
<div className="flex-1 overflow-y-auto px-3">
{filteredSessions.length === 0 && (
<div className="px-3 py-4 text-xs text-[var(--color-text-tertiary)] text-center">
{searchQuery ? 'No matching sessions' : 'No sessions yet'}
</div>
)}
{groupedSessions.map((group) => (
<div key={group.projectPath} className="mb-1">
{/* Project group header */}
<div className="px-2 pt-3 pb-1 text-[11px] font-semibold text-[var(--color-text-tertiary)] tracking-wide truncate">
{group.displayName}
</div>
{/* Sessions in this group */}
{group.sessions.map((session) => (
<div key={session.id} className="relative">
{renamingId === session.id ? (
<input
autoFocus
value={renameValue}
onChange={(e) => 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"
/>
) : (
<button
onClick={() => { setActiveView('code'); setActiveSession(session.id) }}
onContextMenu={(e) => handleContextMenu(e, session.id)}
className={`
w-full flex items-center gap-2 pl-4 pr-3 py-1.5 text-sm text-left rounded-[var(--radius-md)] transition-colors duration-200 group
${session.id === activeSessionId
? 'bg-[var(--color-surface-selected)] text-[var(--color-text-primary)]'
: 'text-[var(--color-text-secondary)] hover:bg-[var(--color-surface-hover)]'
}
`}
>
<span className="w-1 h-1 rounded-full flex-shrink-0" style={{
backgroundColor: session.id === activeSessionId ? 'var(--color-brand)' : 'var(--color-text-tertiary)',
opacity: session.id === activeSessionId ? 1 : 0.5,
}} />
<span className="truncate flex-1">{session.title || 'Untitled'}</span>
{!session.workDirExists && (
<span
className="text-[10px] text-[var(--color-warning)] flex-shrink-0"
title={session.workDir ?? 'Missing workspace'}
>
missing dir
</span>
)}
<span className="text-[10px] text-[var(--color-text-tertiary)] flex-shrink-0 opacity-0 group-hover:opacity-100 transition-opacity">
{formatRelativeTime(session.modifiedAt)}
</span>
</button>
)}
{TIME_GROUP_ORDER.map((group) => {
const items = timeGroups.get(group)
if (!items || items.length === 0) return null
return (
<div key={group} className="mb-1">
<div className="px-2 pt-3 pb-1 text-[11px] font-semibold text-[var(--color-text-tertiary)] tracking-wide">
{group}
</div>
))}
</div>
))}
{items.map((session) => (
<div key={session.id} className="relative">
{renamingId === session.id ? (
<input
autoFocus
value={renameValue}
onChange={(e) => 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"
/>
) : (
<button
onClick={() => { setActiveView('code'); setActiveSession(session.id) }}
onContextMenu={(e) => handleContextMenu(e, session.id)}
className={`
w-full flex items-center gap-2 pl-4 pr-3 py-1.5 text-sm text-left rounded-[var(--radius-md)] transition-colors duration-200 group
${session.id === activeSessionId
? 'bg-[var(--color-surface-selected)] text-[var(--color-text-primary)]'
: 'text-[var(--color-text-secondary)] hover:bg-[var(--color-surface-hover)]'
}
`}
>
<span className="w-1 h-1 rounded-full flex-shrink-0" style={{
backgroundColor: session.id === activeSessionId ? 'var(--color-brand)' : 'var(--color-text-tertiary)',
opacity: session.id === activeSessionId ? 1 : 0.5,
}} />
<span className="truncate flex-1">{session.title || 'Untitled'}</span>
{!session.workDirExists && (
<span
className="text-[10px] text-[var(--color-warning)] flex-shrink-0"
title={session.workDir ?? 'Missing workspace'}
>
missing dir
</span>
)}
<span className="text-[10px] text-[var(--color-text-tertiary)] flex-shrink-0 opacity-0 group-hover:opacity-100 transition-opacity">
{formatRelativeTime(session.modifiedAt)}
</span>
</button>
)}
</div>
))}
</div>
)
})}
</div>
{/* 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<TimeGroup, SessionListItem[]> {
const groups = new Map<TimeGroup, SessionListItem[]>()
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 }) {

View File

@ -7,12 +7,15 @@ type SessionStore = {
activeSessionId: string | null
isLoading: boolean
error: string | null
selectedProjects: string[]
availableProjects: string[]
fetchSessions: (project?: string) => Promise<void>
createSession: (workDir?: string) => Promise<string>
deleteSession: (id: string) => Promise<void>
renameSession: (id: string, title: string) => Promise<void>
setActiveSession: (id: string | null) => void
setSelectedProjects: (projects: string[]) => void
}
export const useSessionStore = create<SessionStore>((set, get) => ({
@ -20,12 +23,24 @@ export const useSessionStore = create<SessionStore>((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<string, SessionListItem>()
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<SessionStore>((set, get) => ({
},
setActiveSession: (id) => set({ activeSessionId: id }),
setSelectedProjects: (projects) => set({ selectedProjects: projects }),
}))