From 7c954ad901112067723cdd7a6d204eeee9d7e755 Mon Sep 17 00:00:00 2001 From: CoColate Date: Wed, 29 Apr 2026 09:19:47 +0800 Subject: [PATCH] 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 --- web/src/components/SessionList.test.ts | 49 ++++- web/src/components/SessionList.tsx | 248 +++++++++++++++++++++++-- web/src/lib/locales/en.ts | 6 + web/src/lib/locales/zh-CN.ts | 6 + 4 files changed, 291 insertions(+), 18 deletions(-) diff --git a/web/src/components/SessionList.test.ts b/web/src/components/SessionList.test.ts index b830e801..df1dc60c 100644 --- a/web/src/components/SessionList.test.ts +++ b/web/src/components/SessionList.test.ts @@ -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 & { 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) + }) +}) diff --git a/web/src/components/SessionList.tsx b/web/src/components/SessionList.tsx index e88dd8e8..c7a6356f 100644 --- a/web/src/components/SessionList.tsx +++ b/web/src/components/SessionList.tsx @@ -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() @@ -233,6 +235,47 @@ function CopyPathButton({ path, className }: { path: string; className?: string ) } + +function SearchIcon(props: { className?: string }) { + return ( + + + + + ) +} + +function XIcon(props: { className?: string }) { + return ( + + + + + ) +} + function PlusIcon(props: { className?: string }) { return ( 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() + 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 ( +
+ + 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 ? ( + + ) : null} +
+ ) +} + const FLAVOR_BADGES: Record = { 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>( () => 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() - 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 (
{renderHeader ? (
- {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 })}
+ ) : null}
diff --git a/web/src/lib/locales/en.ts b/web/src/lib/locales/en.ts index ddd86b20..4443ad1a 100644 --- a/web/src/lib/locales/en.ts +++ b/web/src/lib/locales/en.ts @@ -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', diff --git a/web/src/lib/locales/zh-CN.ts b/web/src/lib/locales/zh-CN.ts index d9bd35bf..fb92ebe9 100644 --- a/web/src/lib/locales/zh-CN.ts +++ b/web/src/lib/locales/zh-CN.ts @@ -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': '路径',