mirror of
https://github.com/wu736139669/hapi.git
synced 2026-08-08 07:17:39 +00:00
* feat(web,hub,cli): show machine load in session sidebar Runners attach OS health snapshots to machine-alive heartbeats; the hub caches them and the web session list renders load or CPU between the machine label and session count. Co-authored-by: Cursor <cursoragent@cursor.com> * fix(web,cli): show CPU and RAM pressure in machine health badge Sidebar label now combines CPU and RAM percentages for overload signaling; load stays in the tooltip on Unix. Prime CPU sampling so the first heartbeat includes usage, not just memory. Co-authored-by: Cursor <cursoragent@cursor.com> * feat(web): visual machine health meters with tooltip Replace bare CPU/RAM text with labeled mini bar gauges, chip border tint by severity, and a HoverTooltip explaining capacity and overload guidance. Co-authored-by: Cursor <cursoragent@cursor.com> * fix(web): widen machine health tooltip with horizontal layout Allow a generous popover width and lay CPU/RAM/load out side by side so the capacity tooltip reads wider and less tall than the chip. Co-authored-by: Cursor <cursoragent@cursor.com> * fix(web): anchor machine health tooltip to row left edge Wide tooltip was align=end on the chip, so it grew left off-screen. Use row-span positioning on the machine tile button instead. Co-authored-by: Cursor <cursoragent@cursor.com> * feat(web): machine host card with OS label and inline health Turn the session sidebar machine row into a bordered host panel with OS metadata and side-by-side CPU/RAM meters embedded in the tile instead of a flat label line matching project rows. Co-authored-by: Cursor <cursoragent@cursor.com> * fix(web): keep machine host tile single-row height Collapse the machine header back to one py-1.5 row with OS and compact inline health beside the name, and restore the original project indent without the extra nested rail or second header line. Co-authored-by: Cursor <cursoragent@cursor.com> * feat(web): show CPU core count in machine health tooltip When the runner reports cpuCount, the tooltip reads "CPU across all 6 cores" instead of the generic all-cores label. Co-authored-by: Cursor <cursoragent@cursor.com> * docs: add machine health sidebar screenshots Dogfood captures for the session sidebar machine tile and capacity tooltip, for upstream PR review. Co-authored-by: Cursor <cursoragent@cursor.com> * fix(cli): clear machine-alive priming timeout on disconnect Track the 50ms CPU priming setTimeout and clear it in stopKeepAlive so disconnect/shutdown during the delay cannot leave a stray interval alive. Co-authored-by: Cursor <cursoragent@cursor.com> * chore: drop dogfood screenshots from upstream PR diff Review evidence lives in the PR discussion only; no need to ship PNGs in the repo long-term. Co-authored-by: Cursor <cursoragent@cursor.com> * fix(web): truncate long machine OS/host metadata in sidebar row Bound the metadata span so a long hostname cannot push the health chip or session count off-screen in narrow sidebars. Co-authored-by: Cursor <cursoragent@cursor.com> * fix(web): reveal machine health tooltip on keyboard row focus Wire MACHINE_ROW_TOOLTIP_FOCUS_CLASS and aria-describedby on the machine header button so keyboard users can read the health tooltip like session rows. Co-authored-by: Cursor <cursoragent@cursor.com> * fix(cli): use MemAvailable for Linux RAM pressure on Bun Bun's os.freemem() reflects MemFree (~1% on cache-heavy hosts), which made sidebar RAM read ~99% while btop showed ~40% used. Parse /proc/meminfo MemAvailable instead so used percent matches operator tools. Co-authored-by: Cursor <cursoragent@cursor.com> * feat(web,cli): show machine uptime in sidebar tiles and tooltip Collect os.uptime() as uptimeSeconds on keepalive and render compact up 1h 54m in the machine meta row plus an Uptime line in the health tooltip. Co-authored-by: Cursor <cursoragent@cursor.com> * fix(web): anchor machine health tooltip to chip not row align=row positioned the tooltip below the full machine header button, so the collapsible project panel painted over it on hover. Use align=end with a min-width panel so mouse and keyboard tooltips stay visible. Co-authored-by: Cursor <cursoragent@cursor.com> --------- Co-authored-by: Cursor <cursoragent@cursor.com>
1138 lines
48 KiB
TypeScript
1138 lines
48 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 { useShowActiveSessionsOnly } from '@/hooks/useShowActiveSessionsOnly'
|
|
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'
|
|
import type { Machine } from '@/types/api'
|
|
import { getMachinePlatform, presentMachineHealth } from '@/lib/machineHealth'
|
|
import { MachineGroupHeader } from '@/components/MachineGroupHeader'
|
|
|
|
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))
|
|
}
|
|
|
|
// "Active sessions only" view: hide inactive sessions, but never hide the one the
|
|
// operator currently has open — otherwise toggling the filter would yank the
|
|
// selected session out from under them.
|
|
export function filterActiveSessionsOnly(sessions: SessionSummary[], selectedSessionId?: string | null): SessionSummary[] {
|
|
return sessions.filter(session => session.active || session.id === selectedSessionId)
|
|
}
|
|
|
|
// Paginated "Show N more": reveal one batch (step) at a time instead of expanding
|
|
// every hidden session at once. Always advances by at least one and never exceeds
|
|
// the total so the button reliably reaches a fully-expanded state.
|
|
export function getNextSessionVisibleCount(current: number, step: number, total: number): number {
|
|
return Math.min(current + Math.max(1, step), total)
|
|
}
|
|
|
|
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 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>
|
|
machinesById?: Record<string, Machine>
|
|
selectedSessionId?: string | null
|
|
}) {
|
|
const { t } = useTranslation()
|
|
const { renderHeader = true, api, selectedSessionId, machineLabelsById = {}, machinesById = {}, onNewSessionInDirectory } = props
|
|
const { sessionPreviewLimit } = useSessionPreviewLimit()
|
|
const { sessionListStatusMode } = useSessionListStatusMode()
|
|
const { showActiveSessionsOnly } = useShowActiveSessionsOnly()
|
|
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(
|
|
() => {
|
|
const prepared = prepareSidebarSessions(props.sessions, selectedSessionId)
|
|
return showActiveSessionsOnly ? filterActiveSessionsOnly(prepared, selectedSessionId) : prepared
|
|
},
|
|
[props.sessions, selectedSessionId, showActiveSessionsOnly]
|
|
)
|
|
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
|
|
})
|
|
}
|
|
|
|
// Per-group reveal cap for paginated "Show N more". Absent = collapsed to the
|
|
// preview limit; each "Show more" bumps it by one batch (step = preview limit).
|
|
const [sessionVisibleCounts, setSessionVisibleCounts] = useState<Map<string, number>>(
|
|
() => new Map()
|
|
)
|
|
|
|
const getGroupVisibleCount = (group: SessionGroup): number => {
|
|
return sessionVisibleCounts.get(group.key) ?? sessionPreviewLimit
|
|
}
|
|
|
|
const showMoreSessions = (group: SessionGroup) => {
|
|
setSessionVisibleCounts(prev => {
|
|
const next = new Map(prev)
|
|
const current = prev.get(group.key) ?? sessionPreviewLimit
|
|
next.set(group.key, getNextSessionVisibleCount(current, sessionPreviewLimit, group.sessions.length))
|
|
return next
|
|
})
|
|
}
|
|
|
|
const collapseSessionGroup = (group: SessionGroup) => {
|
|
setSessionVisibleCounts(prev => {
|
|
if (!prev.has(group.key)) return prev
|
|
const next = new Map(prev)
|
|
next.delete(group.key)
|
|
return next
|
|
})
|
|
}
|
|
|
|
const getVisibleGroupSessions = (group: SessionGroup): SessionSummary[] => {
|
|
return getVisibleSessionPreview(
|
|
group.sessions,
|
|
{
|
|
expanded: isSearching,
|
|
selectedSessionId,
|
|
limit: getGroupVisibleCount(group)
|
|
}
|
|
)
|
|
}
|
|
|
|
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])
|
|
|
|
// Clean up reveal caps for groups that no longer exist.
|
|
useEffect(() => {
|
|
setSessionVisibleCounts(prev => {
|
|
if (prev.size === 0) return prev
|
|
const knownKeys = new Set(allGroups.map(g => g.key))
|
|
const next = new Map(prev)
|
|
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)
|
|
const machine = mg.machineId ? machinesById[mg.machineId] : undefined
|
|
const healthPresentation = presentMachineHealth(
|
|
machine?.health,
|
|
getMachinePlatform(machine)
|
|
)
|
|
return (
|
|
<div key={mg.machineId ?? UNKNOWN_MACHINE_ID}>
|
|
<MachineGroupHeader
|
|
label={mg.label}
|
|
sessionCount={mg.totalSessions}
|
|
collapsed={machineCollapsed}
|
|
onToggle={() => toggleMachine(mg)}
|
|
machine={machine}
|
|
healthPresentation={healthPresentation}
|
|
/>
|
|
|
|
{/* 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 canCollapseSessions = getGroupVisibleCount(group) > sessionPreviewLimit
|
|
const showMoreCount = Math.min(sessionPreviewLimit, hiddenSessionCount)
|
|
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 && (hiddenSessionCount > 0 || canCollapseSessions) ? (
|
|
<button
|
|
type="button"
|
|
onClick={() => hiddenSessionCount > 0
|
|
? showMoreSessions(group)
|
|
: collapseSessionGroup(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)]'
|
|
)}
|
|
>
|
|
{hiddenSessionCount > 0
|
|
? t('sessions.group.showMore', { n: showMoreCount })
|
|
: t('sessions.group.showLess')}
|
|
</button>
|
|
) : null}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)
|
|
})}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)
|
|
})}
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|