From ecfe0eff604ca91f4543e629e41dbf3a9a62df3f Mon Sep 17 00:00:00 2001 From: CherryLover Date: Tue, 30 Dec 2025 17:42:20 +0800 Subject: [PATCH] =?UTF-8?q?feat(web):=20=E4=BC=98=E5=8C=96=E4=BC=9A?= =?UTF-8?q?=E8=AF=9D=E5=88=97=E8=A1=A8=E5=B1=95=E7=A4=BA=20-=20=E6=8C=89?= =?UTF-8?q?=E7=9B=AE=E5=BD=95=E5=88=86=E7=BB=84=E5=B9=B6=E6=98=BE=E7=A4=BA?= =?UTF-8?q?=E6=9B=B4=E6=96=B0=E6=97=B6=E9=97=B4=20(#22)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(web): add session list grouping by project directory - Group sessions by project path with collapsible sections - Sort groups by active status first, then by latest update time - Sort sessions within each group by update time (descending) - Add visual indicators for groups with active sessions - Display project count in header statistics Co-authored-by: weishu --- web/src/components/SessionList.tsx | 261 +++++++++++++++++++++++------ web/src/router.tsx | 4 +- 2 files changed, 209 insertions(+), 56 deletions(-) diff --git a/web/src/components/SessionList.tsx b/web/src/components/SessionList.tsx index 8fd4ef2b..c5ca3697 100644 --- a/web/src/components/SessionList.tsx +++ b/web/src/components/SessionList.tsx @@ -1,5 +1,58 @@ +import { useEffect, useMemo, useRef, useState } from 'react' import type { SessionSummary } from '@/types/api' +type SessionGroup = { + directory: string + displayName: string + sessions: SessionSummary[] + latestUpdatedAt: number + hasActiveSession: boolean +} + +function getGroupDisplayName(directory: string): string { + if (directory === 'Other') return directory + const parts = directory.split(/[\\/]+/).filter(Boolean) + if (parts.length === 0) return directory + if (parts.length === 1) return parts[0] + return `${parts[parts.length - 2]}/${parts[parts.length - 1]}` +} + +function groupSessionsByDirectory(sessions: SessionSummary[]): SessionGroup[] { + const groups = new Map() + + sessions.forEach(session => { + const path = session.metadata?.worktree?.basePath ?? session.metadata?.path ?? 'Other' + if (!groups.has(path)) { + groups.set(path, []) + } + groups.get(path)!.push(session) + }) + + return Array.from(groups.entries()) + .map(([directory, groupSessions]) => { + const sortedSessions = [...groupSessions].sort((a, b) => { + const rankA = a.active ? (a.pendingRequestsCount > 0 ? 0 : 1) : 2 + const rankB = b.active ? (b.pendingRequestsCount > 0 ? 0 : 1) : 2 + if (rankA !== rankB) return rankA - rankB + return b.updatedAt - a.updatedAt + }) + const latestUpdatedAt = groupSessions.reduce( + (max, s) => (s.updatedAt > max ? s.updatedAt : max), + -Infinity + ) + const hasActiveSession = groupSessions.some(s => s.active) + const displayName = getGroupDisplayName(directory) + + return { directory, displayName, sessions: sortedSessions, latestUpdatedAt, hasActiveSession } + }) + .sort((a, b) => { + if (a.hasActiveSession !== b.hasActiveSession) { + return a.hasActiveSession ? -1 : 1 + } + return b.latestUpdatedAt - a.latestUpdatedAt + }) +} + function PlusIcon(props: { className?: string }) { return ( + + + ) +} + function getSessionTitle(session: SessionSummary): string { if (session.metadata?.name) { return session.metadata.name @@ -85,11 +157,69 @@ function formatRelativeTime(value: number): string | null { return new Date(ms).toLocaleDateString() } -function getLastSeenLabel(session: SessionSummary): string | null { - if (session.active) return null - const lastSeen = formatRelativeTime(session.activeAt ?? session.updatedAt) - if (!lastSeen) return null - return `last seen ${lastSeen}` +function SessionItem(props: { + session: SessionSummary + onSelect: (sessionId: string) => void + showPath?: boolean +}) { + const { session: s, onSelect, showPath = true } = props + return ( + + ) } export function SessionList(props: { @@ -101,13 +231,49 @@ export function SessionList(props: { renderHeader?: boolean }) { const { renderHeader = true } = props + const groups = useMemo( + () => groupSessionsByDirectory(props.sessions), + [props.sessions] + ) + const [collapseOverrides, setCollapseOverrides] = useState>( + () => new Map() + ) + const isGroupCollapsed = (group: SessionGroup): boolean => { + const override = collapseOverrides.get(group.directory) + if (override !== undefined) return override + return !group.hasActiveSession + } + + const toggleGroup = (directory: string, isCollapsed: boolean) => { + setCollapseOverrides(prev => { + const next = new Map(prev) + next.set(directory, !isCollapsed) + return next + }) + } + + useEffect(() => { + setCollapseOverrides(prev => { + if (prev.size === 0) return prev + const next = new Map(prev) + const knownGroups = new Set(groups.map(group => group.directory)) + let changed = false + for (const directory of next.keys()) { + if (!knownGroups.has(directory)) { + next.delete(directory) + changed = true + } + } + return changed ? next : prev + }) + }, [groups]) return (
{renderHeader ? (
- {props.sessions.length} sessions + {props.sessions.length} sessions in {groups.length} projects
-
- {(() => { - const progress = getTodoProgress(s) - if (!progress) return null - return ( - - - {progress.completed}/{progress.total} - - ) - })()} - {s.pendingRequestsCount > 0 ? ( - - pending {s.pendingRequestsCount} +
+ + {group.displayName} - ) : null} -
-
-
- {s.metadata?.path ?? s.id} -
-
- ❖ {getAgentLabel(s)} - model: {getModelLabel(s)} - {s.metadata?.worktree?.branch ? ( - worktree: {s.metadata.worktree.branch} + + ({group.sessions.length}) + +
+ + {!isCollapsed ? ( +
+ {group.sessions.map((s) => ( + + ))} +
) : null} - {(() => { - const lastSeen = getLastSeenLabel(s) - if (!lastSeen) return null - return {lastSeen} - })()}
- - ))} + ) + })} ) diff --git a/web/src/router.tsx b/web/src/router.tsx index 9ad8de39..f34e957f 100644 --- a/web/src/router.tsx +++ b/web/src/router.tsx @@ -75,12 +75,14 @@ function SessionsPage() { void refetch() }, [refetch]) + const projectCount = new Set(sessions.map(s => s.metadata?.path ?? 'Other')).size + return (
- {sessions.length} sessions + {sessions.length} sessions in {projectCount} projects