mirror of
https://github.com/wu736139669/hapi.git
synced 2026-08-07 06:52:28 +00:00
feat(web): add sidebar search and per-group preview limit (#547)
* feat(web): add sidebar search and per-group preview limit * test(web): cover session list search previews
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import type { SessionSummary } from '@/types/api'
|
||||
import { deduplicateSessionsByAgentId } from './SessionList'
|
||||
import { deduplicateSessionsByAgentId, getVisibleSessionPreview, normalizeSearch, sessionMatchesQuery } from './SessionList'
|
||||
|
||||
function makeSession(overrides: Partial<SessionSummary> & { id: string }): SessionSummary {
|
||||
return {
|
||||
@@ -80,3 +80,50 @@ describe('deduplicateSessionsByAgentId', () => {
|
||||
expect(result.map(s => s.id).sort()).toEqual(['b', 'd'])
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
describe('session list search helpers', () => {
|
||||
it('normalizes whitespace and case before filtering', () => {
|
||||
const session = makeSession({
|
||||
id: 'session-1',
|
||||
metadata: {
|
||||
path: '/work/hapi',
|
||||
name: 'Fix Bot Review',
|
||||
flavor: 'codex',
|
||||
machineId: 'machine-1'
|
||||
}
|
||||
})
|
||||
|
||||
expect(normalizeSearch(' BOT ')).toBe('bot')
|
||||
expect(sessionMatchesQuery(session, normalizeSearch('bot review'), 'desktop')).toBe(true)
|
||||
expect(sessionMatchesQuery(session, normalizeSearch('desktop'), 'desktop')).toBe(true)
|
||||
expect(sessionMatchesQuery(session, normalizeSearch('missing'), 'desktop')).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('getVisibleSessionPreview', () => {
|
||||
it('keeps selected and active sessions inside the collapsed preview', () => {
|
||||
const sessions = Array.from({ length: 6 }, (_, index) => makeSession({
|
||||
id: `s-${index + 1}`,
|
||||
active: index === 4,
|
||||
metadata: { path: '/work/hapi' },
|
||||
updatedAt: 100 - index
|
||||
}))
|
||||
|
||||
const preview = getVisibleSessionPreview(sessions, {
|
||||
selectedSessionId: 's-6',
|
||||
limit: 3
|
||||
})
|
||||
|
||||
expect(preview.map(session => session.id)).toEqual(['s-6', 's-5', 's-1'])
|
||||
})
|
||||
|
||||
it('returns all sessions when expanded', () => {
|
||||
const sessions = Array.from({ length: 4 }, (_, index) => makeSession({
|
||||
id: `s-${index + 1}`,
|
||||
metadata: { path: '/work/hapi' }
|
||||
}))
|
||||
|
||||
expect(getVisibleSessionPreview(sessions, { expanded: true, limit: 2 })).toHaveLength(4)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -8,6 +8,7 @@ import { SessionActionMenu } from '@/components/SessionActionMenu'
|
||||
import { RenameSessionDialog } from '@/components/RenameSessionDialog'
|
||||
import { ConfirmDialog } from '@/components/ui/ConfirmDialog'
|
||||
import { CopyIcon, CheckIcon } from '@/components/icons'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { useTranslation } from '@/lib/use-translation'
|
||||
|
||||
type SessionGroup = {
|
||||
@@ -90,6 +91,7 @@ function getGroupDisplayName(directory: string): string {
|
||||
}
|
||||
|
||||
export const UNKNOWN_MACHINE_ID = '__unknown__'
|
||||
export const GROUP_SESSION_PREVIEW_LIMIT = 8
|
||||
|
||||
export function deduplicateSessionsByAgentId(sessions: SessionSummary[], selectedSessionId?: string | null): SessionSummary[] {
|
||||
const byAgentId = new Map<string, SessionSummary[]>()
|
||||
@@ -233,6 +235,47 @@ function CopyPathButton({ path, className }: { path: string; className?: string
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
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
|
||||
@@ -308,7 +351,7 @@ function ChevronIcon(props: { className?: string; collapsed?: boolean }) {
|
||||
)
|
||||
}
|
||||
|
||||
function getSessionTitle(session: SessionSummary): string {
|
||||
export function getSessionTitle(session: SessionSummary): string {
|
||||
if (session.metadata?.name) {
|
||||
return session.metadata.name
|
||||
}
|
||||
@@ -328,6 +371,95 @@ function getTodoProgress(session: SessionSummary): { completed: number; total: n
|
||||
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 included = new Set<string>()
|
||||
const visible: SessionSummary[] = []
|
||||
const addSession = (session: SessionSummary) => {
|
||||
if (included.has(session.id)) return
|
||||
included.add(session.id)
|
||||
visible.push(session)
|
||||
}
|
||||
|
||||
const selectedSession = options.selectedSessionId
|
||||
? sessions.find(session => session.id === options.selectedSessionId)
|
||||
: undefined
|
||||
if (selectedSession) addSession(selectedSession)
|
||||
|
||||
for (const session of sessions) {
|
||||
if (visible.length >= limit) break
|
||||
if (session.active) addSession(session)
|
||||
}
|
||||
|
||||
for (const session of sessions) {
|
||||
if (visible.length >= limit) break
|
||||
addSession(session)
|
||||
}
|
||||
|
||||
return visible
|
||||
}
|
||||
|
||||
function SessionListSearch(props: {
|
||||
value: string
|
||||
onChange: (value: string) => void
|
||||
}) {
|
||||
const { t } = useTranslation()
|
||||
return (
|
||||
<div className="relative px-3 pb-2">
|
||||
<SearchIcon className="pointer-events-none absolute left-5 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-[var(--app-hint)]" />
|
||||
<input
|
||||
type="search"
|
||||
value={props.value}
|
||||
onChange={(event) => props.onChange(event.target.value)}
|
||||
placeholder={t('sessions.search.placeholder')}
|
||||
className="w-full 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)]"
|
||||
/>
|
||||
{props.value ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => props.onChange('')}
|
||||
className="absolute right-5 top-1/2 -translate-y-1/2 rounded p-0.5 text-[var(--app-hint)] hover:bg-[var(--app-subtle-bg)] hover:text-[var(--app-fg)]"
|
||||
title={t('sessions.search.clear')}
|
||||
>
|
||||
<XIcon className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const FLAVOR_BADGES: Record<string, { label: string; colors: string }> = {
|
||||
claude: {
|
||||
label: 'Cl',
|
||||
@@ -538,14 +670,47 @@ export function SessionList(props: {
|
||||
}) {
|
||||
const { t } = useTranslation()
|
||||
const { renderHeader = true, api, selectedSessionId, machineLabelsById = {} } = props
|
||||
const groups = useMemo(
|
||||
() => groupSessionsByDirectory(deduplicateSessionsByAgentId(props.sessions, selectedSessionId)),
|
||||
const [searchQuery, setSearchQuery] = useState('')
|
||||
const normalizedQuery = normalizeSearch(searchQuery)
|
||||
const isSearching = normalizedQuery.length > 0
|
||||
|
||||
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(
|
||||
() => deduplicateSessionsByAgentId(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 isGroupCollapsed = (group: SessionGroup): boolean => {
|
||||
if (isSearching) return false
|
||||
const override = collapseOverrides.get(group.key)
|
||||
if (override !== undefined) return override
|
||||
const hasSelectedSession = selectedSessionId
|
||||
@@ -562,14 +727,32 @@ export function SessionList(props: {
|
||||
})
|
||||
}
|
||||
|
||||
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 isSessionGroupExpanded = (group: SessionGroup): boolean => {
|
||||
if (isSearching || group.sessions.length <= GROUP_SESSION_PREVIEW_LIMIT) 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
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
const machineGroups = useMemo(
|
||||
@@ -578,6 +761,7 @@ export function SessionList(props: {
|
||||
)
|
||||
|
||||
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
|
||||
@@ -601,7 +785,7 @@ export function SessionList(props: {
|
||||
useEffect(() => {
|
||||
if (!selectedSessionId) return
|
||||
setCollapseOverrides(prev => {
|
||||
const group = groups.find(g =>
|
||||
const group = allGroups.find(g =>
|
||||
g.sessions.some(s => s.id === selectedSessionId)
|
||||
)
|
||||
if (!group) return prev
|
||||
@@ -620,7 +804,7 @@ export function SessionList(props: {
|
||||
}
|
||||
return changed ? next : prev
|
||||
})
|
||||
}, [selectedSessionId, groups])
|
||||
}, [selectedSessionId, allGroups])
|
||||
|
||||
// Clean up stale collapse overrides
|
||||
useEffect(() => {
|
||||
@@ -628,8 +812,9 @@ export function SessionList(props: {
|
||||
if (prev.size === 0) return prev
|
||||
const next = new Map(prev)
|
||||
const knownKeys = new Set<string>()
|
||||
for (const g of groups) {
|
||||
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
|
||||
@@ -641,14 +826,16 @@ export function SessionList(props: {
|
||||
}
|
||||
return changed ? next : prev
|
||||
})
|
||||
}, [groups])
|
||||
}, [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)]">
|
||||
{t('sessions.count', { n: props.sessions.length, m: groups.length })}
|
||||
{isSearching
|
||||
? t('sessions.search.count', { n: visibleSessions.length, total: allSessions.length })
|
||||
: t('sessions.count', { n: props.sessions.length, m: allGroups.length })}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
@@ -661,6 +848,10 @@ export function SessionList(props: {
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{props.sessions.length > 0 ? (
|
||||
<SessionListSearch value={searchQuery} onChange={setSearchQuery} />
|
||||
) : null}
|
||||
|
||||
{props.sessions.length === 0 && (
|
||||
<SessionsEmptyState
|
||||
onNewSession={props.onNewSession}
|
||||
@@ -668,6 +859,12 @@ export function SessionList(props: {
|
||||
/>
|
||||
)}
|
||||
|
||||
{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)
|
||||
@@ -691,6 +888,9 @@ export function SessionList(props: {
|
||||
<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)
|
||||
return (
|
||||
<div key={group.key}>
|
||||
<div
|
||||
@@ -712,7 +912,7 @@ export function SessionList(props: {
|
||||
<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">
|
||||
{group.sessions.map((s) => (
|
||||
{visibleGroupSessions.map((s) => (
|
||||
<SessionItem
|
||||
key={s.id}
|
||||
session={s}
|
||||
@@ -722,6 +922,20 @@ export function SessionList(props: {
|
||||
selected={s.id === selectedSessionId}
|
||||
/>
|
||||
))}
|
||||
{!isSearching && group.sessions.length > GROUP_SESSION_PREVIEW_LIMIT && (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>
|
||||
|
||||
@@ -45,6 +45,12 @@ export default {
|
||||
'sessions.empty.hint': 'Start a coding session in any folder under your workspace, or browse the tree first.',
|
||||
'sessions.empty.startSession': 'Start a session',
|
||||
'sessions.empty.browse': 'Browse workspace',
|
||||
'sessions.search.placeholder': 'Search sessions…',
|
||||
'sessions.search.clear': 'Clear search',
|
||||
'sessions.search.count': '{n} of {total} sessions',
|
||||
'sessions.search.noResults': 'No sessions match your search.',
|
||||
'sessions.group.showMore': 'Show {n} more',
|
||||
'sessions.group.showLess': 'Show less',
|
||||
|
||||
// Session list
|
||||
'session.item.path': 'path',
|
||||
|
||||
@@ -45,6 +45,12 @@ export default {
|
||||
'sessions.empty.hint': '在 workspace 下任意目录启动一个会话,或先浏览目录树看看。',
|
||||
'sessions.empty.startSession': '启动会话',
|
||||
'sessions.empty.browse': '浏览 workspace',
|
||||
'sessions.search.placeholder': '搜索会话…',
|
||||
'sessions.search.clear': '清除搜索',
|
||||
'sessions.search.count': '{n} / {total} 个会话',
|
||||
'sessions.search.noResults': '没有匹配的会话。',
|
||||
'sessions.group.showMore': '再显示 {n} 个',
|
||||
'sessions.group.showLess': '收起',
|
||||
|
||||
// Session list
|
||||
'session.item.path': '路径',
|
||||
|
||||
Reference in New Issue
Block a user