Files
hapi/web/src/components/SessionList.tsx
T
ce67823fc3 feat(web,hub): rich hover tooltips on session-list attention indicators (#941)
* feat(web): rich hover tooltips on session-list attention indicators

The session-row attention dots and the future-scheduled clock icon used
plain `title=""` attributes which gave only a one-word label ("Permission
required"). Replace those with hover/focus-revealed tooltips that name
*which* tools are blocking, count background tasks, surface the
"updated Nm ago" timestamp, and explain the pending schedule.

To make per-tool copy possible without an extra round trip,
`SessionSummary` now carries a structured slice of the pending tool
requests, capped at `PENDING_REQUEST_SUMMARY_CAP = 5` oldest-first:

  pendingRequests: Array<{ id; kind; tool; since }>

`pendingRequestsCount` remains the authoritative total;
`pendingRequestKinds` is still derived from the FULL request set so a
single `'input'` request beyond the cap still surfaces its kind on the
session row.

The tooltip primitive (`HoverTooltip`) is a CSS-driven reveal — no
portal, no positioning JS — so it composes cheaply inside the existing
session-row `<button>` and stays out of the way on touch devices, which
keep getting the same `aria-label` the old `title=""` attribute provided
to screen readers.

Test coverage: shared derivation + cap + tie-break + full-set kind
behaviour; web tooltip render across all four attention kinds plus
mixed-kind overflow suppression and aria-label exposure.

Co-authored-by: Cursor <cursoragent@cursor.com>

* feat(web): opaque tooltip surface; drop redundant 'updated Nm ago' body

Two operator-feedback fixes on the new session-list HoverTooltip:

1. Tooltip background was bg-[var(--app-bg)] - the same variable as the
   session row underneath - so the tooltip looked translucent and the row
   text bled through. Switch to bg-[var(--app-secondary-bg)] (#2C2C2E
   dark / #f3f4f6 light, both opaque) and bump shadow-md -> shadow-lg.
   Telegram-themed clients still pick up tg-theme-secondary-bg-color so
   the tooltip stays on-theme.

2. The 'unread' attention dot tooltip rendered 'New activity / Updated 5m
   ago', but the relative-time pill ('5m ago') is already on the right
   edge of the same session row. The tooltip body just duplicated info.
   Render only the title for the unread case; drop the
   session.tooltip.unread.body i18n key from en + zh-CN.

The other tooltip kinds (permission/input list tools, background lists
task count) keep their bodies - those facts are not visible elsewhere on
the row.

Co-authored-by: Cursor <cursoragent@cursor.com>

* feat(web,hub): show scheduled fire time in session-list clock tooltip

The schedule clock tooltip previously said only "Will fire when due."
while the row already showed a relative updated-at pill. Extend the
session-list API with nextScheduledAt (MIN future scheduled_at per
session, same filter as futureScheduledMessageCount) and render:

- single scheduled: "Fires in 5m · Jun 16, 1:45 PM"
- multiple: "Next in 5m · Jun 16, 1:45 PM · +2 more"

Extract formatScheduledTime from QueuedMessagesBar into web/lib/
scheduledTime.ts alongside formatFutureRelativeTime and the tooltip
composer. SSE upsert preserves nextScheduledAt until the list refetch
that already runs on schedule-related events.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(web): wire session-row keyboard focus to HoverTooltip a11y

Address PR #941 Major review: aria-describedby and tooltip visibility
were on a non-focusable inner span, so keyboard users tabbing the session
row button never received the rich tooltip description and
group-focus-within never matched.

- Session row button owns aria-describedby (attention + schedule ids)
- Add group/session-row + SESSION_ROW_TOOLTIP_FOCUS_CLASS reveal on
  :focus-visible
- HoverTooltip takes required id; drop inner aria-label/describedby
- useSessionRowTooltipIds helper composes stable row tooltip ids
- Tests for id wiring and parent-focus reveal classes

Co-authored-by: Cursor <cursoragent@cursor.com>

---------

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-18 10:15:22 +08:00

1105 lines
46 KiB
TypeScript

import { useEffect, useMemo, useRef, useState } from 'react'
import type { SessionSummary } from '@/types/api'
import type { ApiClient } from '@/api/client'
import { useLongPress } from '@/hooks/useLongPress'
import { usePlatform } from '@/hooks/usePlatform'
import { useSessionActions } from '@/hooks/mutations/useSessionActions'
import { SessionActionMenu } from '@/components/SessionActionMenu'
import { RenameSessionDialog } from '@/components/RenameSessionDialog'
import { ConfirmDialog } from '@/components/ui/ConfirmDialog'
import { CopyIcon, CheckIcon, ScheduleIcon } from '@/components/icons'
import { cn } from '@/lib/utils'
import { useTranslation } from '@/lib/use-translation'
import { DEFAULT_SESSION_PREVIEW_LIMIT, useSessionPreviewLimit } from '@/hooks/useSessionPreviewLimit'
import { AgentFlavorIcon } from '@/components/AgentFlavorIcon'
import { useSessionListStatusMode } from '@/hooks/useSessionListStatusMode'
import { classifySessionAttention } from '@/lib/sessionAttention'
import { getSessionLastSeenAt } from '@/lib/sessionLastSeen'
import { getAttentionLabel, SessionAttentionIndicator } from '@/components/SessionAttentionIndicator'
import { HoverTooltip, SESSION_ROW_TOOLTIP_FOCUS_CLASS, useSessionRowTooltipIds } from '@/components/HoverTooltip'
import { formatRelativeTime } from '@/lib/relativeTime'
import { formatScheduledTooltipDetail } from '@/lib/scheduledTime'
import { getCodexImportedAt, subscribeCodexImportedSessions } from '@/lib/codexImportedSessions'
import { formatReopenError } from '@/lib/reopenError'
type SessionGroup = {
key: string
directory: string
displayName: string
machineId: string | null
sessions: SessionSummary[]
latestUpdatedAt: number
hasActiveSession: boolean
}
function SessionsEmptyState(props: {
onNewSession: () => void
onBrowse?: () => void
}) {
const { t } = useTranslation()
return (
<div className="flex flex-col items-center justify-center gap-3 px-6 py-16 text-center">
<svg
xmlns="http://www.w3.org/2000/svg"
width="44"
height="44"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
className="text-[var(--app-hint)] opacity-60"
>
<rect x="3" y="4" width="18" height="16" rx="2" />
<path d="M3 9h18" />
<path d="M8 14h8" />
<path d="M8 17h5" />
</svg>
<div className="text-base font-medium text-[var(--app-fg)]">
{t('sessions.empty.title')}
</div>
<div className="max-w-sm text-sm text-[var(--app-hint)]">
{t('sessions.empty.hint')}
</div>
<div className="flex items-center gap-2 mt-2">
<button
type="button"
onClick={props.onNewSession}
className="px-4 py-1.5 text-sm rounded-lg bg-[var(--app-button)] text-[var(--app-button-text)] font-medium hover:opacity-90 transition-opacity"
>
{t('sessions.empty.startSession')}
</button>
{props.onBrowse && (
<button
type="button"
onClick={props.onBrowse}
className="px-4 py-1.5 text-sm rounded-lg border border-[var(--app-border)] text-[var(--app-fg)] hover:bg-[var(--app-subtle-bg)] transition-colors"
>
{t('sessions.empty.browse')}
</button>
)}
</div>
</div>
)
}
type MachineGroup = {
machineId: string | null
label: string
projectGroups: SessionGroup[]
totalSessions: number
hasActiveSession: boolean
latestUpdatedAt: number
}
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]}`
}
export const UNKNOWN_MACHINE_ID = '__unknown__'
export const GROUP_SESSION_PREVIEW_LIMIT = DEFAULT_SESSION_PREVIEW_LIMIT
export function getSessionDedupKey(session: SessionSummary): string | null {
const agentId = session.metadata?.agentSessionId?.trim()
if (!agentId) return null
// Scope by flavor: agentSessionId is flattened from native ids and can retain a
// stale cross-flavor value (codexSessionId ?? claudeSessionId ?? ...).
return `${session.metadata?.flavor ?? 'unknown'}:${agentId}`
}
export function deduplicateSessionsByAgentId(sessions: SessionSummary[], selectedSessionId?: string | null): SessionSummary[] {
const byAgentId = new Map<string, SessionSummary[]>()
const result: SessionSummary[] = []
for (const session of sessions) {
const dedupKey = getSessionDedupKey(session)
if (!dedupKey) {
result.push(session)
continue
}
const group = byAgentId.get(dedupKey)
if (group) {
group.push(session)
} else {
byAgentId.set(dedupKey, [session])
}
}
for (const group of byAgentId.values()) {
group.sort((a, b) => {
// Active session always wins — it's the live connection
if (a.active !== b.active) return a.active ? -1 : 1
// Among inactive duplicates, keep the selected one visible
if (a.id === selectedSessionId) return -1
if (b.id === selectedSessionId) return 1
return b.updatedAt - a.updatedAt
})
result.push(group[0])
}
return result
}
function hasSidebarTitleSignal(session: SessionSummary): boolean {
const meta = session.metadata
if (!meta) return false
if (meta.name?.trim()) return true
if (meta.summary?.text?.trim()) return true
return false
}
export function isSidebarEmptySessionStub(session: SessionSummary): boolean {
if (session.active) return false
const meta = session.metadata
if (!meta) return true
if (meta.agentSessionId?.trim()) return false
if (hasSidebarTitleSignal(session)) return false
return true
}
export function shouldShowSessionInSidebar(session: SessionSummary, selectedSessionId?: string | null): boolean {
if (session.id === selectedSessionId) return true
if (session.active) return true
return !isSidebarEmptySessionStub(session)
}
export function prepareSidebarSessions(sessions: SessionSummary[], selectedSessionId?: string | null): SessionSummary[] {
return deduplicateSessionsByAgentId(sessions, selectedSessionId)
.filter(session => shouldShowSessionInSidebar(session, selectedSessionId))
}
function groupSessionsByDirectory(sessions: SessionSummary[]): SessionGroup[] {
const groups = new Map<string, { directory: string; machineId: string | null; sessions: SessionSummary[] }>()
sessions.forEach(session => {
const path = session.metadata?.worktree?.basePath ?? session.metadata?.path ?? 'Other'
const machineId = session.metadata?.machineId ?? null
const key = `${machineId ?? UNKNOWN_MACHINE_ID}::${path}`
if (!groups.has(key)) {
groups.set(key, {
directory: path,
machineId,
sessions: []
})
}
groups.get(key)!.sessions.push(session)
})
return Array.from(groups.entries())
.map(([key, group]) => {
const sortedSessions = [...group.sessions].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 = group.sessions.reduce(
(max, s) => (s.updatedAt > max ? s.updatedAt : max),
-Infinity
)
const hasActiveSession = group.sessions.some(s => s.active)
const displayName = getGroupDisplayName(group.directory)
return {
key,
directory: group.directory,
displayName,
machineId: group.machineId,
sessions: sortedSessions,
latestUpdatedAt,
hasActiveSession
}
})
.sort((a, b) => {
if (a.hasActiveSession !== b.hasActiveSession) {
return a.hasActiveSession ? -1 : 1
}
return b.latestUpdatedAt - a.latestUpdatedAt
})
}
export function expandSelectedSessionCollapseOverrides(
overrides: Map<string, boolean>,
group: { key: string; machineId: string | null }
): Map<string, boolean> {
const next = new Map(overrides)
let changed = false
// Expand project group if collapsed. Project and machine keys use true = collapsed.
if (overrides.has(group.key) && overrides.get(group.key)) {
next.delete(group.key)
changed = true
}
const machineKey = `machine::${group.machineId ?? UNKNOWN_MACHINE_ID}`
if (overrides.has(machineKey) && overrides.get(machineKey)) {
next.delete(machineKey)
changed = true
}
return changed ? next : overrides
}
function groupByMachine(
groups: SessionGroup[],
resolveMachineLabel: (id: string | null) => string
): MachineGroup[] {
const map = new Map<string, MachineGroup>()
for (const g of groups) {
const key = g.machineId ?? UNKNOWN_MACHINE_ID
let mg = map.get(key)
if (!mg) {
mg = {
machineId: g.machineId,
label: resolveMachineLabel(g.machineId),
projectGroups: [],
totalSessions: 0,
hasActiveSession: false,
latestUpdatedAt: 0,
}
map.set(key, mg)
}
mg.projectGroups.push(g)
mg.totalSessions += g.sessions.length
if (g.hasActiveSession) mg.hasActiveSession = true
if (g.latestUpdatedAt > mg.latestUpdatedAt) mg.latestUpdatedAt = g.latestUpdatedAt
}
return [...map.values()].sort((a, b) => {
if (a.hasActiveSession !== b.hasActiveSession) return a.hasActiveSession ? -1 : 1
return b.latestUpdatedAt - a.latestUpdatedAt
})
}
function CopyPathButton({ path, className }: { path: string; className?: string }) {
const [copied, setCopied] = useState(false)
const timerRef = useRef<ReturnType<typeof setTimeout>>(undefined)
const handleClick = (e: React.MouseEvent) => {
e.stopPropagation()
navigator.clipboard.writeText(path)
setCopied(true)
clearTimeout(timerRef.current)
timerRef.current = setTimeout(() => setCopied(false), 1500)
}
useEffect(() => () => clearTimeout(timerRef.current), [])
return (
<button
type="button"
className={`shrink-0 p-0.5 rounded transition-colors ${copied ? 'text-[var(--app-badge-success-text)]' : 'text-[var(--app-hint)] hover:text-[var(--app-fg)]'} ${className ?? ''}`}
title={copied ? 'Copied!' : `Copy: ${path}`}
onClick={handleClick}
>
{copied
? <CheckIcon className="h-3.5 w-3.5" />
: <CopyIcon className="h-3.5 w-3.5" />
}
</button>
)
}
function SearchIcon(props: { className?: string }) {
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}
>
<circle cx="11" cy="11" r="8" />
<path d="m21 21-4.35-4.35" />
</svg>
)
}
function XIcon(props: { className?: string }) {
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}
>
<path d="M18 6 6 18" />
<path d="m6 6 12 12" />
</svg>
)
}
function PlusIcon(props: { className?: string }) {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
className={props.className}
>
<line x1="12" y1="5" x2="12" y2="19" />
<line x1="5" y1="12" x2="19" y2="12" />
</svg>
)
}
function LoaderIcon(props: { className?: string }) {
return (
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className={props.className}>
<line x1="12" y1="2" x2="12" y2="6" />
<line x1="12" y1="18" x2="12" y2="22" />
<line x1="4.93" y1="4.93" x2="7.76" y2="7.76" />
<line x1="16.24" y1="16.24" x2="19.07" y2="19.07" />
<line x1="2" y1="12" x2="6" y2="12" />
<line x1="18" y1="12" x2="22" y2="12" />
<line x1="4.93" y1="19.07" x2="7.76" y2="16.24" />
<line x1="16.24" y1="7.76" x2="19.07" y2="4.93" />
</svg>
)
}
function BulbIcon(props: { className?: string }) {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
className={props.className}
>
<path d="M9 18h6" />
<path d="M10 22h4" />
<path d="M12 2a7 7 0 0 0-4 12c.6.6 1 1.2 1 2h6c0-.8.4-1.4 1-2a7 7 0 0 0-4-12Z" />
</svg>
)
}
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>
)
}
export function getSessionTitle(session: SessionSummary): string {
if (session.metadata?.name) {
return session.metadata.name
}
if (session.metadata?.summary?.text) {
return session.metadata.summary.text
}
if (session.metadata?.path) {
const parts = session.metadata.path.split('/').filter(Boolean)
return parts.length > 0 ? parts[parts.length - 1] : session.id.slice(0, 8)
}
return session.id.slice(0, 8)
}
function getTodoProgress(session: SessionSummary): { completed: number; total: number } | null {
if (!session.todoProgress) return null
if (session.todoProgress.completed === session.todoProgress.total) return null
return session.todoProgress
}
export function normalizeSearch(value: string | null | undefined): string {
return (value ?? '').trim().toLowerCase()
}
export function sessionMatchesQuery(session: SessionSummary, query: string, machineLabel: string): boolean {
if (!query) return true
const searchable = [
getSessionTitle(session),
session.id,
session.metadata?.path,
session.metadata?.worktree?.basePath,
session.metadata?.name,
session.metadata?.summary?.text,
session.metadata?.flavor,
machineLabel,
]
.filter((part): part is string => typeof part === 'string' && part.length > 0)
.join('\n')
.toLowerCase()
return searchable.includes(query)
}
export function getVisibleSessionPreview(
sessions: SessionSummary[],
options: {
expanded?: boolean
selectedSessionId?: string | null
limit?: number
} = {}
): SessionSummary[] {
const limit = options.limit ?? GROUP_SESSION_PREVIEW_LIMIT
if (options.expanded || sessions.length <= limit) return sessions
const requiredIds = new Set<string>()
for (const session of sessions) {
if (session.pendingRequestsCount > 0) requiredIds.add(session.id)
}
if (options.selectedSessionId && sessions.some(session => session.id === options.selectedSessionId)) {
requiredIds.add(options.selectedSessionId)
}
const visible: SessionSummary[] = sessions.filter((session, index) => {
return index < limit || requiredIds.has(session.id)
})
for (let index = visible.length - 1; visible.length > limit && index >= 0; index -= 1) {
const session = visible[index]
if (!session || requiredIds.has(session.id)) continue
visible.splice(index, 1)
}
return visible
}
function SessionListSearch(props: {
value: string
onChange: (value: string) => void
}) {
const { t } = useTranslation()
return (
<div className="relative px-3 pb-2">
<div className="pointer-events-none absolute inset-y-0 left-5 flex items-center pb-2 text-[var(--app-hint)]">
<SearchIcon className="h-3.5 w-3.5" />
</div>
<input
type="search"
value={props.value}
onChange={(event) => props.onChange(event.target.value)}
placeholder={t('sessions.search.placeholder')}
className="w-full appearance-none rounded-lg border border-[var(--app-border)] bg-[var(--app-bg)] py-1.5 pl-8 pr-8 text-sm text-[var(--app-fg)] outline-none transition-colors placeholder:text-[var(--app-hint)] focus:border-[var(--app-link)] [&::-webkit-search-cancel-button]:hidden [&::-webkit-search-decoration]:hidden"
/>
{props.value ? (
<button
type="button"
onClick={() => props.onChange('')}
className="absolute inset-y-0 right-5 flex items-center pb-2 rounded p-0.5 text-[var(--app-hint)] hover:text-[var(--app-fg)]"
title={t('sessions.search.clear')}
>
<XIcon className="h-3.5 w-3.5" />
</button>
) : null}
</div>
)
}
function MachineIcon(props: { className?: string }) {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
width="14"
height="14"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
className={props.className}
>
<rect x="2" y="3" width="20" height="14" rx="2" />
<line x1="8" y1="21" x2="16" y2="21" />
<line x1="12" y1="17" x2="12" y2="21" />
</svg>
)
}
function formatCodexImportedRelativeTime(value: number, t: (key: string, params?: Record<string, string | number>) => string): string | null {
const ms = value < 1_000_000_000_000 ? value * 1000 : value
if (!Number.isFinite(ms)) return null
const delta = Date.now() - ms
if (delta < 60_000) return t('session.time.importedFromCodex.justNow')
const minutes = Math.floor(delta / 60_000)
if (minutes < 60) return t('session.time.importedFromCodex.minutesAgo', { n: minutes })
const hours = Math.floor(minutes / 60)
if (hours < 24) return t('session.time.importedFromCodex.hoursAgo', { n: hours })
const days = Math.floor(hours / 24)
if (days < 7) return t('session.time.importedFromCodex.daysAgo', { n: days })
return new Date(ms).toLocaleDateString()
}
function getSessionTimeLabel(session: SessionSummary, t: (key: string, params?: Record<string, string | number>) => string): string | null {
const codexSessionId = session.metadata?.agentSessionId
const importedAt = session.metadata?.flavor === 'codex'
? getCodexImportedAt(codexSessionId)
: null
// 中文注释:导入标记存在时优先显示“xx 前从 Codex 客户端导入”;等用户在 Hapi 里继续发消息后,再由发送逻辑清除该标记。
if (importedAt !== null) {
return formatCodexImportedRelativeTime(importedAt, t)
}
return formatRelativeTime(session.updatedAt, t)
}
function SessionItem(props: {
session: SessionSummary
onSelect: (sessionId: string) => void
showPath?: boolean
api: ApiClient | null
selected?: boolean
showDetailedStatus?: boolean
}) {
const { t } = useTranslation()
const { session: s, onSelect, showPath = true, api, selected = false, showDetailedStatus = false } = props
const { haptic } = usePlatform()
const [menuOpen, setMenuOpen] = useState(false)
const [menuAnchorPoint, setMenuAnchorPoint] = useState<{ x: number; y: number }>({ x: 0, y: 0 })
const [renameOpen, setRenameOpen] = useState(false)
const [archiveOpen, setArchiveOpen] = useState(false)
const [deleteOpen, setDeleteOpen] = useState(false)
const { archiveSession, reopenSession, renameSession, deleteSession, isPending } = useSessionActions(
api,
s.id,
s.metadata?.flavor ?? null
)
const [reopenError, setReopenError] = useState<string | null>(null)
const handleReopen = async () => {
setReopenError(null)
try {
const result = await reopenSession()
// resumeSession may merge the row into a freshly-spawned sessionId.
// Follow it so the operator lands on the live session.
if (result.sessionId && result.sessionId !== s.id) {
onSelect(result.sessionId)
}
} catch (error) {
setReopenError(formatReopenError(error))
}
}
const longPressHandlers = useLongPress({
onLongPress: (point) => {
haptic.impact('medium')
setMenuAnchorPoint(point)
setMenuOpen(true)
},
onClick: () => {
if (!menuOpen) {
onSelect(s.id)
}
},
threshold: 500
})
const sessionName = getSessionTitle(s)
const todoProgress = getTodoProgress(s)
const attention = useMemo(
() => showDetailedStatus
? classifySessionAttention(s, {
selected,
lastSeenAt: getSessionLastSeenAt(s.id)
})
: null,
[s, selected, showDetailedStatus]
)
const attentionLabel = attention ? getAttentionLabel(attention, t) : null
const scheduledLabel = s.futureScheduledMessageCount > 1
? t('session.item.scheduledMessages', { count: s.futureScheduledMessageCount })
: t('session.item.scheduledMessage')
const hasScheduleTooltip = showDetailedStatus && s.futureScheduledMessageCount > 0
const { attentionId, scheduleId, describedBy } = useSessionRowTooltipIds(
Boolean(attention),
hasScheduleTooltip
)
return (
<>
<button
type="button"
{...longPressHandlers}
className={`session-list-item group/session-row flex w-full flex-col gap-1 px-2.5 py-2 text-left transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--app-link)] select-none rounded-lg ${selected ? 'bg-[var(--app-secondary-bg)]' : ''}`}
style={{ WebkitTouchCallout: 'none' }}
aria-current={selected ? 'page' : undefined}
aria-describedby={describedBy}
>
<div className={`flex items-center justify-between gap-3 ${!s.active ? 'opacity-50' : ''}`}>
<div className="flex items-center gap-2 min-w-0">
<AgentFlavorIcon flavor={s.metadata?.flavor} className="h-4 w-4 shrink-0" />
<div className={`truncate text-sm font-medium ${s.active ? 'text-[var(--app-fg)]' : 'text-[var(--app-hint)]'}`}>
{sessionName}
</div>
{s.active && s.thinking ? (
<LoaderIcon className="h-3.5 w-3.5 shrink-0 text-[var(--app-hint)] animate-spin-slow" />
) : attention ? (
<SessionAttentionIndicator
attention={attention}
summary={s}
label={attentionLabel ?? ''}
tooltipId={attentionId!}
/>
) : null}
{hasScheduleTooltip ? (
<HoverTooltip
id={scheduleId!}
target={<ScheduleIcon className="h-3.5 w-3.5 text-[var(--app-hint)]" />}
side="bottom"
align="start"
className="shrink-0"
revealOnParentFocusClass={SESSION_ROW_TOOLTIP_FOCUS_CLASS}
>
<span className="block">
<span className="block font-medium">{scheduledLabel}</span>
<span className="mt-1 block text-[var(--app-hint)]">
{formatScheduledTooltipDetail(s, t)}
</span>
</span>
</HoverTooltip>
) : null}
</div>
<div className="flex items-center gap-2 shrink-0 text-xs">
{todoProgress ? (
<span className="flex items-center gap-1 text-[var(--app-hint)]">
<BulbIcon className="h-3 w-3" />
{todoProgress.completed}/{todoProgress.total}
</span>
) : null}
{!attention && s.pendingRequestsCount > 0 ? (
<span className="text-[var(--app-badge-warning-text)]">
{t('session.item.pending')} {s.pendingRequestsCount}
</span>
) : null}
<span className="text-[var(--app-hint)]">
{getSessionTimeLabel(s, t)}
</span>
</div>
</div>
{showPath ? (
<div className="truncate text-xs text-[var(--app-hint)]">
{s.metadata?.path ?? s.id}
</div>
) : null}
</button>
<SessionActionMenu
isOpen={menuOpen}
onClose={() => setMenuOpen(false)}
sessionActive={s.active}
onRename={() => setRenameOpen(true)}
onArchive={() => setArchiveOpen(true)}
onReopen={handleReopen}
onDelete={() => setDeleteOpen(true)}
anchorPoint={menuAnchorPoint}
/>
{reopenError ? (
<ConfirmDialog
isOpen={true}
onClose={() => setReopenError(null)}
title={t('dialog.reopen.errorTitle')}
description={reopenError}
confirmLabel={t('dialog.reopen.dismiss')}
confirmingLabel={t('dialog.reopen.dismiss')}
onConfirm={async () => setReopenError(null)}
isPending={false}
/>
) : null}
<RenameSessionDialog
isOpen={renameOpen}
onClose={() => setRenameOpen(false)}
currentName={sessionName}
onRename={renameSession}
isPending={isPending}
/>
<ConfirmDialog
isOpen={archiveOpen}
onClose={() => setArchiveOpen(false)}
title={t('dialog.archive.title')}
description={t('dialog.archive.description', { name: sessionName })}
confirmLabel={t('dialog.archive.confirm')}
confirmingLabel={t('dialog.archive.confirming')}
onConfirm={archiveSession}
isPending={isPending}
destructive
/>
<ConfirmDialog
isOpen={deleteOpen}
onClose={() => setDeleteOpen(false)}
title={t('dialog.delete.title')}
description={t('dialog.delete.description', { name: sessionName })}
confirmLabel={t('dialog.delete.confirm')}
confirmingLabel={t('dialog.delete.confirming')}
onConfirm={deleteSession}
isPending={isPending}
destructive
/>
</>
)
}
export function SessionList(props: {
sessions: SessionSummary[]
onSelect: (sessionId: string) => void
onNewSession: () => void
onNewSessionInDirectory?: (args: { machineId: string | null; directory: string }) => void
onBrowse?: () => void
onRefresh: () => void
isLoading: boolean
renderHeader?: boolean
api: ApiClient | null
machineLabelsById?: Record<string, string>
selectedSessionId?: string | null
}) {
const { t } = useTranslation()
const { renderHeader = true, api, selectedSessionId, machineLabelsById = {}, onNewSessionInDirectory } = props
const { sessionPreviewLimit } = useSessionPreviewLimit()
const { sessionListStatusMode } = useSessionListStatusMode()
const showDetailedStatus = sessionListStatusMode === 'detailed'
const [searchQuery, setSearchQuery] = useState('')
const [, setCodexImportedSessionsVersion] = useState(0)
const normalizedQuery = normalizeSearch(searchQuery)
const isSearching = normalizedQuery.length > 0
useEffect(() => {
// 中文注释:监听导入标记变化,让列表在“导入完成”或“用户已在 Hapi 中继续会话”后立即刷新时间文案。
return subscribeCodexImportedSessions(() => {
setCodexImportedSessionsVersion((value) => value + 1)
})
}, [])
const resolveMachineLabel = (machineId: string | null): string => {
if (machineId && machineLabelsById[machineId]) {
return machineLabelsById[machineId]
}
if (machineId) {
return machineId.slice(0, 8)
}
return t('machine.unknown')
}
const allSessions = useMemo(
() => prepareSidebarSessions(props.sessions, selectedSessionId),
[props.sessions, selectedSessionId]
)
const visibleSessions = useMemo(
() => isSearching
? allSessions.filter(session => sessionMatchesQuery(
session,
normalizedQuery,
resolveMachineLabel(session.metadata?.machineId ?? null)
))
: allSessions,
[allSessions, isSearching, normalizedQuery, machineLabelsById] // eslint-disable-line react-hooks/exhaustive-deps
)
const allGroups = useMemo(
() => groupSessionsByDirectory(allSessions),
[allSessions]
)
const groups = useMemo(
() => groupSessionsByDirectory(visibleSessions),
[visibleSessions]
)
const [collapseOverrides, setCollapseOverrides] = useState<Map<string, boolean>>(
() => new Map()
)
const autoExpandedSelectedSessionKeyRef = useRef<string | null>(null)
const isGroupCollapsed = (group: SessionGroup): boolean => {
if (isSearching) return false
const override = collapseOverrides.get(group.key)
if (override !== undefined) return override
const hasSelectedSession = selectedSessionId
? group.sessions.some(session => session.id === selectedSessionId)
: false
return !group.hasActiveSession && !hasSelectedSession
}
const toggleGroup = (groupKey: string, isCollapsed: boolean) => {
setCollapseOverrides(prev => {
const next = new Map(prev)
next.set(groupKey, !isCollapsed)
return next
})
}
const isSessionGroupExpanded = (group: SessionGroup): boolean => {
if (isSearching || group.sessions.length <= sessionPreviewLimit) return true
const key = `sessions::${group.key}`
const override = collapseOverrides.get(key)
if (override !== undefined) return !override
return false
}
const toggleSessionGroup = (group: SessionGroup) => {
const key = `sessions::${group.key}`
const expanded = isSessionGroupExpanded(group)
setCollapseOverrides(prev => {
const next = new Map(prev)
next.set(key, expanded)
return next
})
}
const getVisibleGroupSessions = (group: SessionGroup): SessionSummary[] => {
return getVisibleSessionPreview(
group.sessions,
{
expanded: isSessionGroupExpanded(group),
selectedSessionId,
limit: sessionPreviewLimit
}
)
}
const machineGroups = useMemo(
() => groupByMachine(groups, resolveMachineLabel),
[groups, machineLabelsById] // eslint-disable-line react-hooks/exhaustive-deps
)
const isMachineCollapsed = (mg: MachineGroup): boolean => {
if (isSearching) return false
const key = `machine::${mg.machineId ?? UNKNOWN_MACHINE_ID}`
const override = collapseOverrides.get(key)
if (override !== undefined) return override
const hasSelected = selectedSessionId
? mg.projectGroups.some(pg => pg.sessions.some(s => s.id === selectedSessionId))
: false
return !mg.hasActiveSession && !hasSelected
}
const toggleMachine = (mg: MachineGroup) => {
const key = `machine::${mg.machineId ?? UNKNOWN_MACHINE_ID}`
const current = isMachineCollapsed(mg)
setCollapseOverrides(prev => {
const next = new Map(prev)
next.set(key, !current)
return next
})
}
// Auto-expand group (and machine) containing the selected session only when
// the selected-session/group pair changes. Without this guard, every live
// session-list refresh (for example tool-call updates from a running selected
// session) reopens a path the user just collapsed.
useEffect(() => {
if (!selectedSessionId) {
autoExpandedSelectedSessionKeyRef.current = null
return
}
const group = allGroups.find(g =>
g.sessions.some(s => s.id === selectedSessionId)
)
if (!group) return
const autoExpandKey = `${selectedSessionId}::${group.key}`
if (autoExpandedSelectedSessionKeyRef.current === autoExpandKey) return
autoExpandedSelectedSessionKeyRef.current = autoExpandKey
setCollapseOverrides(prev => expandSelectedSessionCollapseOverrides(prev, group))
}, [selectedSessionId, allGroups])
// Clean up stale collapse overrides
useEffect(() => {
setCollapseOverrides(prev => {
if (prev.size === 0) return prev
const next = new Map(prev)
const knownKeys = new Set<string>()
for (const g of allGroups) {
knownKeys.add(g.key)
knownKeys.add(`sessions::${g.key}`)
knownKeys.add(`machine::${g.machineId ?? UNKNOWN_MACHINE_ID}`)
}
let changed = false
for (const key of next.keys()) {
if (!knownKeys.has(key)) {
next.delete(key)
changed = true
}
}
return changed ? next : prev
})
}, [allGroups])
return (
<div className="mx-auto w-full max-w-content flex flex-col">
{renderHeader ? (
<div className="flex items-center justify-between px-3 py-1">
<div className="text-xs text-[var(--app-hint)]">
{isSearching
? t('sessions.search.count', { n: visibleSessions.length, total: allSessions.length })
: t('sessions.count', { n: allSessions.length, m: allGroups.length })}
</div>
<button
type="button"
onClick={props.onNewSession}
className="session-list-new-button p-1.5 rounded-full text-[var(--app-link)] transition-colors"
title={t('sessions.new')}
>
<PlusIcon className="h-5 w-5" />
</button>
</div>
) : null}
{props.sessions.length > 0 ? (
<SessionListSearch value={searchQuery} onChange={setSearchQuery} />
) : null}
{props.sessions.length === 0 && (
<SessionsEmptyState
onNewSession={props.onNewSession}
onBrowse={props.onBrowse}
/>
)}
{props.sessions.length > 0 && isSearching && visibleSessions.length === 0 ? (
<div className="px-4 py-8 text-center text-sm text-[var(--app-hint)]">
{t('sessions.search.noResults')}
</div>
) : null}
<div className="flex flex-col gap-3 px-2 pt-1 pb-2">
{machineGroups.map((mg) => {
const machineCollapsed = isMachineCollapsed(mg)
return (
<div key={mg.machineId ?? UNKNOWN_MACHINE_ID}>
{/* Level 1: Machine */}
<button
type="button"
onClick={() => toggleMachine(mg)}
className="flex w-full items-center gap-2 px-1 py-1.5 text-left rounded-lg transition-colors hover:bg-[var(--app-subtle-bg)] select-none"
>
<ChevronIcon className="h-4 w-4 text-[var(--app-hint)] shrink-0" collapsed={machineCollapsed} />
<MachineIcon className="h-4 w-4 text-[var(--app-hint)] shrink-0" />
<span className="text-sm font-semibold truncate flex-1">{mg.label}</span>
<span className="text-[11px] tabular-nums text-[var(--app-hint)] shrink-0">({mg.totalSessions})</span>
</button>
{/* Level 2: Projects */}
<div className="collapsible-panel" data-open={!machineCollapsed || undefined}>
<div className="collapsible-inner">
<div className="flex flex-col ml-3.5 pl-1 mt-0.5">
{mg.projectGroups.map((group) => {
const isCollapsed = isGroupCollapsed(group)
const visibleGroupSessions = getVisibleGroupSessions(group)
const hiddenSessionCount = group.sessions.length - visibleGroupSessions.length
const sessionGroupExpanded = isSessionGroupExpanded(group)
const canStartInGroupDirectory = group.directory !== 'Other'
return (
<div key={group.key}>
<div
className="group/project sticky top-0 z-10 flex items-center gap-2 px-1 py-1.5 text-left rounded-lg transition-colors hover:bg-[var(--app-subtle-bg)] cursor-pointer min-w-0 w-full select-none"
onClick={() => toggleGroup(group.key, isCollapsed)}
title={group.directory}
>
<ChevronIcon className="h-3.5 w-3.5 text-[var(--app-hint)] shrink-0" collapsed={isCollapsed} />
<span className="font-medium text-sm truncate flex-1">
{group.displayName}
</span>
<CopyPathButton path={group.directory} className="opacity-0 group-hover/project:opacity-100 transition-opacity duration-150" />
{onNewSessionInDirectory && canStartInGroupDirectory ? (
<button
type="button"
onClick={(event) => {
event.stopPropagation()
onNewSessionInDirectory({
machineId: group.machineId,
directory: group.directory
})
}}
className="flex h-6 w-6 shrink-0 items-center justify-center rounded-full text-[var(--app-hint)] opacity-70 transition-colors hover:bg-[var(--app-secondary-bg)] hover:text-[var(--app-link)] hover:opacity-100 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--app-link)]"
title={t('sessions.group.new')}
aria-label={t('sessions.group.new')}
>
<PlusIcon className="h-3.5 w-3.5" />
</button>
) : null}
<span className="text-[11px] tabular-nums text-[var(--app-hint)] shrink-0">
({group.sessions.length})
</span>
</div>
{/* Level 3: Sessions */}
<div className="collapsible-panel" data-open={!isCollapsed || undefined}>
<div className="collapsible-inner">
<div className="flex flex-col gap-0.5 ml-3 pl-1 pr-1 py-1">
{visibleGroupSessions.map((s) => (
<SessionItem
key={s.id}
session={s}
onSelect={props.onSelect}
showPath={false}
api={api}
selected={s.id === selectedSessionId}
showDetailedStatus={showDetailedStatus}
/>
))}
{!isSearching && group.sessions.length > sessionPreviewLimit && (sessionGroupExpanded || hiddenSessionCount > 0) ? (
<button
type="button"
onClick={() => toggleSessionGroup(group)}
className={cn(
'mx-2 my-1 rounded-md px-2 py-1 text-left text-xs text-[var(--app-hint)] transition-colors hover:bg-[var(--app-subtle-bg)] hover:text-[var(--app-fg)]',
hiddenSessionCount > 0 && 'border border-dashed border-[var(--app-border)]'
)}
>
{sessionGroupExpanded
? t('sessions.group.showLess')
: t('sessions.group.showMore', { n: hiddenSessionCount })}
</button>
) : null}
</div>
</div>
</div>
</div>
)
})}
</div>
</div>
</div>
</div>
)
})}
</div>
</div>
)
}