mirror of
https://github.com/wu736139669/hapi.git
synced 2026-08-05 06:24:37 +00:00
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:
@@ -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
@@ -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"
|
||||||
|
|||||||
Reference in New Issue
Block a user