mirror of
https://github.com/NanmiCoder/cc-haha
synced 2026-07-31 16:33:34 +08:00
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:
parent
d601bbf968
commit
5d908b2828
124
desktop/src/components/layout/ProjectFilter.tsx
Normal file
124
desktop/src/components/layout/ProjectFilter.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@ -1,10 +1,15 @@
|
|||||||
import { useEffect, useState, useCallback, useMemo } from 'react'
|
import { useEffect, useState, useCallback, useMemo } from 'react'
|
||||||
import { useSessionStore } from '../../stores/sessionStore'
|
import { useSessionStore } from '../../stores/sessionStore'
|
||||||
import { useUIStore } from '../../stores/uiStore'
|
import { useUIStore } from '../../stores/uiStore'
|
||||||
|
import { ProjectFilter } from './ProjectFilter'
|
||||||
import type { SessionListItem } from '../../types/session'
|
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() {
|
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 { activeView, setActiveView } = useUIStore()
|
||||||
const [searchQuery, setSearchQuery] = useState('')
|
const [searchQuery, setSearchQuery] = useState('')
|
||||||
const [contextMenu, setContextMenu] = useState<{ id: string; x: number; y: number } | null>(null)
|
const [contextMenu, setContextMenu] = useState<{ id: string; x: number; y: number } | null>(null)
|
||||||
@ -23,35 +28,21 @@ export function Sidebar() {
|
|||||||
return () => document.removeEventListener('click', close)
|
return () => document.removeEventListener('click', close)
|
||||||
}, [contextMenu])
|
}, [contextMenu])
|
||||||
|
|
||||||
// Filter sessions by search query — already sorted by modifiedAt from API
|
// Filter by selected projects, then by search query
|
||||||
const filteredSessions = searchQuery
|
const filteredSessions = useMemo(() => {
|
||||||
? sessions.filter((s) => s.title.toLowerCase().includes(searchQuery.toLowerCase()))
|
let result = sessions
|
||||||
: 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
|
// Group by time
|
||||||
const groupedSessions = useMemo(() => {
|
const timeGroups = useMemo(() => groupByTime(filteredSessions), [filteredSessions])
|
||||||
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])
|
|
||||||
|
|
||||||
const handleContextMenu = useCallback((e: React.MouseEvent, id: string) => {
|
const handleContextMenu = useCallback((e: React.MouseEvent, id: string) => {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
@ -97,6 +88,11 @@ export function Sidebar() {
|
|||||||
</NavItem>
|
</NavItem>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Project filter */}
|
||||||
|
<div className="px-3 pb-1 flex items-center justify-between">
|
||||||
|
<ProjectFilter />
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Search */}
|
{/* Search */}
|
||||||
<div className="px-3 pb-2">
|
<div className="px-3 pb-2">
|
||||||
<input
|
<input
|
||||||
@ -109,68 +105,70 @@ export function Sidebar() {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Session list — grouped by project directory */}
|
{/* Session list — grouped by time */}
|
||||||
<div className="flex-1 overflow-y-auto px-3">
|
<div className="flex-1 overflow-y-auto px-3">
|
||||||
{filteredSessions.length === 0 && (
|
{filteredSessions.length === 0 && (
|
||||||
<div className="px-3 py-4 text-xs text-[var(--color-text-tertiary)] text-center">
|
<div className="px-3 py-4 text-xs text-[var(--color-text-tertiary)] text-center">
|
||||||
{searchQuery ? 'No matching sessions' : 'No sessions yet'}
|
{searchQuery ? 'No matching sessions' : 'No sessions yet'}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{groupedSessions.map((group) => (
|
{TIME_GROUP_ORDER.map((group) => {
|
||||||
<div key={group.projectPath} className="mb-1">
|
const items = timeGroups.get(group)
|
||||||
{/* Project group header */}
|
if (!items || items.length === 0) return null
|
||||||
<div className="px-2 pt-3 pb-1 text-[11px] font-semibold text-[var(--color-text-tertiary)] tracking-wide truncate">
|
return (
|
||||||
{group.displayName}
|
<div key={group} className="mb-1">
|
||||||
</div>
|
<div className="px-2 pt-3 pb-1 text-[11px] font-semibold text-[var(--color-text-tertiary)] tracking-wide">
|
||||||
{/* Sessions in this group */}
|
{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>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
))}
|
{items.map((session) => (
|
||||||
</div>
|
<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>
|
</div>
|
||||||
|
|
||||||
{/* Settings button at bottom */}
|
{/* Settings button at bottom */}
|
||||||
@ -211,19 +209,28 @@ export function Sidebar() {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
function groupByTime(sessions: SessionListItem[]): Map<TimeGroup, SessionListItem[]> {
|
||||||
* Extract a human-readable project name from a sanitized project path.
|
const groups = new Map<TimeGroup, SessionListItem[]>()
|
||||||
* Paths look like: "-Users-nanmi-workspace-myself-code-claude-code-haha"
|
const now = new Date()
|
||||||
* We desanitize by treating leading `-` as `/`, then splitting on `-` to
|
const startOfToday = new Date(now.getFullYear(), now.getMonth(), now.getDate()).getTime()
|
||||||
* get path segments. The last non-empty segment is the project name.
|
const startOfYesterday = startOfToday - 86400000
|
||||||
* This is intentionally simple and lossy (original hyphens vs separators
|
const sevenDaysAgo = startOfToday - 7 * 86400000
|
||||||
* are indistinguishable), but good enough for display.
|
const thirtyDaysAgo = startOfToday - 30 * 86400000
|
||||||
*/
|
|
||||||
function getProjectDisplayName(sanitizedPath: string): string {
|
for (const session of sessions) {
|
||||||
if (!sanitizedPath || sanitizedPath === '_unknown') return 'Other'
|
const ts = new Date(session.modifiedAt).getTime()
|
||||||
// Take the last `-` delimited segment as the display name
|
let group: TimeGroup
|
||||||
const segments = sanitizedPath.split('-').filter(Boolean)
|
if (ts >= startOfToday) group = 'Today'
|
||||||
return segments[segments.length - 1] || 'Other'
|
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 }) {
|
function NavItem({ active, onClick, icon, children }: { active: boolean; onClick: () => void; icon: React.ReactNode; children: React.ReactNode }) {
|
||||||
|
|||||||
@ -7,12 +7,15 @@ type SessionStore = {
|
|||||||
activeSessionId: string | null
|
activeSessionId: string | null
|
||||||
isLoading: boolean
|
isLoading: boolean
|
||||||
error: string | null
|
error: string | null
|
||||||
|
selectedProjects: string[]
|
||||||
|
availableProjects: string[]
|
||||||
|
|
||||||
fetchSessions: (project?: string) => Promise<void>
|
fetchSessions: (project?: string) => Promise<void>
|
||||||
createSession: (workDir?: string) => Promise<string>
|
createSession: (workDir?: string) => Promise<string>
|
||||||
deleteSession: (id: string) => Promise<void>
|
deleteSession: (id: string) => Promise<void>
|
||||||
renameSession: (id: string, title: string) => Promise<void>
|
renameSession: (id: string, title: string) => Promise<void>
|
||||||
setActiveSession: (id: string | null) => void
|
setActiveSession: (id: string | null) => void
|
||||||
|
setSelectedProjects: (projects: string[]) => void
|
||||||
}
|
}
|
||||||
|
|
||||||
export const useSessionStore = create<SessionStore>((set, get) => ({
|
export const useSessionStore = create<SessionStore>((set, get) => ({
|
||||||
@ -20,12 +23,24 @@ export const useSessionStore = create<SessionStore>((set, get) => ({
|
|||||||
activeSessionId: null,
|
activeSessionId: null,
|
||||||
isLoading: false,
|
isLoading: false,
|
||||||
error: null,
|
error: null,
|
||||||
|
selectedProjects: [],
|
||||||
|
availableProjects: [],
|
||||||
|
|
||||||
fetchSessions: async (project?: string) => {
|
fetchSessions: async (project?: string) => {
|
||||||
set({ isLoading: true, error: null })
|
set({ isLoading: true, error: null })
|
||||||
try {
|
try {
|
||||||
const { sessions } = await sessionsApi.list({ project, limit: 100 })
|
const { sessions: raw } = await sessionsApi.list({ project, limit: 100 })
|
||||||
set({ sessions, isLoading: false })
|
// 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) {
|
} catch (err) {
|
||||||
set({ error: (err as Error).message, isLoading: false })
|
set({ error: (err as Error).message, isLoading: false })
|
||||||
}
|
}
|
||||||
@ -57,4 +72,5 @@ export const useSessionStore = create<SessionStore>((set, get) => ({
|
|||||||
},
|
},
|
||||||
|
|
||||||
setActiveSession: (id) => set({ activeSessionId: id }),
|
setActiveSession: (id) => set({ activeSessionId: id }),
|
||||||
|
setSelectedProjects: (projects) => set({ selectedProjects: projects }),
|
||||||
}))
|
}))
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user