feat(web): 优化会话列表展示 - 按目录分组并显示更新时间 (#22)

* 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 <twsxtd@gmail.com>
This commit is contained in:
CherryLover
2025-12-30 17:42:20 +08:00
committed by GitHub
co-authored by weishu
parent b7dce6bf3f
commit ecfe0eff60
2 changed files with 209 additions and 56 deletions
+206 -55
View File
@@ -1,5 +1,58 @@
import { useEffect, useMemo, useRef, useState } from 'react'
import type { SessionSummary } from '@/types/api' 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<string, SessionSummary[]>()
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 }) { function PlusIcon(props: { className?: string }) {
return ( return (
<svg <svg
@@ -41,6 +94,25 @@ function BulbIcon(props: { className?: string }) {
) )
} }
function ChevronIcon(props: { className?: string; collapsed?: boolean }) {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
className={`${props.className ?? ''} transition-transform duration-200 ${props.collapsed ? '' : 'rotate-90'}`}
>
<polyline points="9 18 15 12 9 6" />
</svg>
)
}
function getSessionTitle(session: SessionSummary): string { function getSessionTitle(session: SessionSummary): string {
if (session.metadata?.name) { if (session.metadata?.name) {
return session.metadata.name return session.metadata.name
@@ -85,11 +157,69 @@ function formatRelativeTime(value: number): string | null {
return new Date(ms).toLocaleDateString() return new Date(ms).toLocaleDateString()
} }
function getLastSeenLabel(session: SessionSummary): string | null { function SessionItem(props: {
if (session.active) return null session: SessionSummary
const lastSeen = formatRelativeTime(session.activeAt ?? session.updatedAt) onSelect: (sessionId: string) => void
if (!lastSeen) return null showPath?: boolean
return `last seen ${lastSeen}` }) {
const { session: s, onSelect, showPath = true } = props
return (
<button
type="button"
onClick={() => onSelect(s.id)}
className="session-list-item flex w-full flex-col gap-1.5 px-3 py-3 text-left transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--app-link)]"
>
<div className="flex items-center justify-between gap-3">
<div className="flex items-center gap-2 min-w-0">
<span className="flex h-4 w-4 items-center justify-center" aria-hidden="true">
<span
className={`h-2 w-2 rounded-full ${s.active ? 'bg-[var(--app-badge-success-text)]' : 'bg-[var(--app-hint)]'}`}
/>
</span>
<div className="truncate text-sm font-medium">
{getSessionTitle(s)}
</div>
</div>
<div className="flex items-center gap-2 shrink-0 text-xs">
{(() => {
const progress = getTodoProgress(s)
if (!progress) return null
return (
<span className="flex items-center gap-1 text-[var(--app-hint)]">
<BulbIcon className="h-3 w-3" />
{progress.completed}/{progress.total}
</span>
)
})()}
{s.pendingRequestsCount > 0 ? (
<span className="text-[var(--app-badge-warning-text)]">
pending {s.pendingRequestsCount}
</span>
) : null}
<span className="text-[var(--app-hint)]">
{formatRelativeTime(s.updatedAt)}
</span>
</div>
</div>
{showPath ? (
<div className="truncate text-xs text-[var(--app-hint)]">
{s.metadata?.path ?? s.id}
</div>
) : null}
<div className="flex flex-wrap items-center gap-x-3 gap-y-1 text-xs text-[var(--app-hint)]">
<span className="inline-flex items-center gap-2">
<span className="flex h-4 w-4 items-center justify-center" aria-hidden="true">
</span>
{getAgentLabel(s)}
</span>
<span>model: {getModelLabel(s)}</span>
{s.metadata?.worktree?.branch ? (
<span>worktree: {s.metadata.worktree.branch}</span>
) : null}
</div>
</button>
)
} }
export function SessionList(props: { export function SessionList(props: {
@@ -101,13 +231,49 @@ export function SessionList(props: {
renderHeader?: boolean renderHeader?: boolean
}) { }) {
const { renderHeader = true } = props const { renderHeader = true } = props
const groups = useMemo(
() => groupSessionsByDirectory(props.sessions),
[props.sessions]
)
const [collapseOverrides, setCollapseOverrides] = useState<Map<string, boolean>>(
() => 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 ( return (
<div className="mx-auto w-full max-w-content flex flex-col"> <div className="mx-auto w-full max-w-content flex flex-col">
{renderHeader ? ( {renderHeader ? (
<div className="flex items-center justify-between px-3 py-1"> <div className="flex items-center justify-between px-3 py-1">
<div className="text-xs text-[var(--app-hint)]"> <div className="text-xs text-[var(--app-hint)]">
{props.sessions.length} sessions {props.sessions.length} sessions in {groups.length} projects
</div> </div>
<button <button
type="button" type="button"
@@ -120,59 +286,44 @@ export function SessionList(props: {
</div> </div>
) : null} ) : null}
<div className="flex flex-col divide-y divide-[var(--app-divider)]"> <div className="flex flex-col">
{props.sessions.map((s) => ( {groups.map((group) => {
<button const isCollapsed = isGroupCollapsed(group)
key={s.id} return (
type="button" <div key={group.directory} className="border-b border-[var(--app-divider)]">
onClick={() => props.onSelect(s.id)} <button
className="session-list-item flex w-full flex-col gap-1.5 px-3 py-3 text-left transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--app-link)]" type="button"
> onClick={() => toggleGroup(group.directory, isCollapsed)}
<div className="flex items-center justify-between gap-3"> className="flex w-full items-center gap-2 px-3 py-2 text-left transition-colors hover:bg-[var(--app-secondary-bg)]"
<div className="flex items-center gap-2 min-w-0"> >
<span <ChevronIcon
className={`h-2 w-2 rounded-full ${s.active ? 'bg-[var(--app-badge-success-text)]' : 'bg-[var(--app-hint)]'}`} className="h-4 w-4 text-[var(--app-hint)]"
aria-hidden="true" collapsed={isCollapsed}
/> />
<div className="truncate text-sm font-medium"> <div className="flex items-center gap-2 min-w-0 flex-1">
{getSessionTitle(s)} <span className="font-medium text-sm break-words" title={group.directory}>
</div> {group.displayName}
</div>
<div className="flex items-center gap-2 shrink-0 text-xs">
{(() => {
const progress = getTodoProgress(s)
if (!progress) return null
return (
<span className="flex items-center gap-1 text-[var(--app-hint)]">
<BulbIcon className="h-3 w-3" />
{progress.completed}/{progress.total}
</span>
)
})()}
{s.pendingRequestsCount > 0 ? (
<span className="text-[var(--app-badge-warning-text)]">
pending {s.pendingRequestsCount}
</span> </span>
) : null} <span className="shrink-0 text-xs text-[var(--app-hint)]">
</div> ({group.sessions.length})
</div> </span>
<div className="truncate text-xs text-[var(--app-hint)]"> </div>
{s.metadata?.path ?? s.id} </button>
</div> {!isCollapsed ? (
<div className="flex flex-wrap items-center gap-x-3 gap-y-1 text-xs text-[var(--app-hint)]"> <div className="flex flex-col divide-y divide-[var(--app-divider)]">
<span> {getAgentLabel(s)}</span> {group.sessions.map((s) => (
<span>model: {getModelLabel(s)}</span> <SessionItem
{s.metadata?.worktree?.branch ? ( key={s.id}
<span>worktree: {s.metadata.worktree.branch}</span> session={s}
onSelect={props.onSelect}
showPath={false}
/>
))}
</div>
) : null} ) : null}
{(() => {
const lastSeen = getLastSeenLabel(s)
if (!lastSeen) return null
return <span>{lastSeen}</span>
})()}
</div> </div>
</button> )
))} })}
</div> </div>
</div> </div>
) )
+3 -1
View File
@@ -75,12 +75,14 @@ function SessionsPage() {
void refetch() void refetch()
}, [refetch]) }, [refetch])
const projectCount = new Set(sessions.map(s => s.metadata?.path ?? 'Other')).size
return ( return (
<div className="flex h-full flex-col"> <div className="flex h-full flex-col">
<div className="bg-[var(--app-bg)] pt-[env(safe-area-inset-top)]"> <div className="bg-[var(--app-bg)] pt-[env(safe-area-inset-top)]">
<div className="mx-auto w-full max-w-content flex items-center justify-between px-3 py-2"> <div className="mx-auto w-full max-w-content flex items-center justify-between px-3 py-2">
<div className="text-xs text-[var(--app-hint)]"> <div className="text-xs text-[var(--app-hint)]">
{sessions.length} sessions {sessions.length} sessions in {projectCount} projects
</div> </div>
<button <button
type="button" type="button"