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:
CoColate
2026-04-29 09:19:47 +08:00
committed by GitHub
parent 71406ab08d
commit 7c954ad901
4 changed files with 291 additions and 18 deletions
+48 -1
View File
@@ -1,6 +1,6 @@
import { describe, expect, it } from 'vitest' import { describe, expect, it } from 'vitest'
import type { SessionSummary } from '@/types/api' 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 { function makeSession(overrides: Partial<SessionSummary> & { id: string }): SessionSummary {
return { return {
@@ -80,3 +80,50 @@ describe('deduplicateSessionsByAgentId', () => {
expect(result.map(s => s.id).sort()).toEqual(['b', 'd']) 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)
})
})
+229 -15
View File
@@ -8,6 +8,7 @@ import { SessionActionMenu } from '@/components/SessionActionMenu'
import { RenameSessionDialog } from '@/components/RenameSessionDialog' import { RenameSessionDialog } from '@/components/RenameSessionDialog'
import { ConfirmDialog } from '@/components/ui/ConfirmDialog' import { ConfirmDialog } from '@/components/ui/ConfirmDialog'
import { CopyIcon, CheckIcon } from '@/components/icons' import { CopyIcon, CheckIcon } from '@/components/icons'
import { cn } from '@/lib/utils'
import { useTranslation } from '@/lib/use-translation' import { useTranslation } from '@/lib/use-translation'
type SessionGroup = { type SessionGroup = {
@@ -90,6 +91,7 @@ function getGroupDisplayName(directory: string): string {
} }
export const UNKNOWN_MACHINE_ID = '__unknown__' export const UNKNOWN_MACHINE_ID = '__unknown__'
export const GROUP_SESSION_PREVIEW_LIMIT = 8
export function deduplicateSessionsByAgentId(sessions: SessionSummary[], selectedSessionId?: string | null): SessionSummary[] { export function deduplicateSessionsByAgentId(sessions: SessionSummary[], selectedSessionId?: string | null): SessionSummary[] {
const byAgentId = new Map<string, 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 }) { function PlusIcon(props: { className?: string }) {
return ( return (
<svg <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) { if (session.metadata?.name) {
return session.metadata.name return session.metadata.name
} }
@@ -328,6 +371,95 @@ function getTodoProgress(session: SessionSummary): { completed: number; total: n
return session.todoProgress 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 }> = { const FLAVOR_BADGES: Record<string, { label: string; colors: string }> = {
claude: { claude: {
label: 'Cl', label: 'Cl',
@@ -538,14 +670,47 @@ export function SessionList(props: {
}) { }) {
const { t } = useTranslation() const { t } = useTranslation()
const { renderHeader = true, api, selectedSessionId, machineLabelsById = {} } = props const { renderHeader = true, api, selectedSessionId, machineLabelsById = {} } = props
const groups = useMemo( const [searchQuery, setSearchQuery] = useState('')
() => groupSessionsByDirectory(deduplicateSessionsByAgentId(props.sessions, selectedSessionId)), 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] [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>>( const [collapseOverrides, setCollapseOverrides] = useState<Map<string, boolean>>(
() => new Map() () => new Map()
) )
const isGroupCollapsed = (group: SessionGroup): boolean => { const isGroupCollapsed = (group: SessionGroup): boolean => {
if (isSearching) return false
const override = collapseOverrides.get(group.key) const override = collapseOverrides.get(group.key)
if (override !== undefined) return override if (override !== undefined) return override
const hasSelectedSession = selectedSessionId const hasSelectedSession = selectedSessionId
@@ -562,14 +727,32 @@ export function SessionList(props: {
}) })
} }
const resolveMachineLabel = (machineId: string | null): string => { const isSessionGroupExpanded = (group: SessionGroup): boolean => {
if (machineId && machineLabelsById[machineId]) { if (isSearching || group.sessions.length <= GROUP_SESSION_PREVIEW_LIMIT) return true
return machineLabelsById[machineId] const key = `sessions::${group.key}`
const override = collapseOverrides.get(key)
if (override !== undefined) return !override
return false
} }
if (machineId) {
return machineId.slice(0, 8) 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
})
} }
return t('machine.unknown')
const getVisibleGroupSessions = (group: SessionGroup): SessionSummary[] => {
return getVisibleSessionPreview(
group.sessions,
{
expanded: isSessionGroupExpanded(group),
selectedSessionId
}
)
} }
const machineGroups = useMemo( const machineGroups = useMemo(
@@ -578,6 +761,7 @@ export function SessionList(props: {
) )
const isMachineCollapsed = (mg: MachineGroup): boolean => { const isMachineCollapsed = (mg: MachineGroup): boolean => {
if (isSearching) return false
const key = `machine::${mg.machineId ?? UNKNOWN_MACHINE_ID}` const key = `machine::${mg.machineId ?? UNKNOWN_MACHINE_ID}`
const override = collapseOverrides.get(key) const override = collapseOverrides.get(key)
if (override !== undefined) return override if (override !== undefined) return override
@@ -601,7 +785,7 @@ export function SessionList(props: {
useEffect(() => { useEffect(() => {
if (!selectedSessionId) return if (!selectedSessionId) return
setCollapseOverrides(prev => { setCollapseOverrides(prev => {
const group = groups.find(g => const group = allGroups.find(g =>
g.sessions.some(s => s.id === selectedSessionId) g.sessions.some(s => s.id === selectedSessionId)
) )
if (!group) return prev if (!group) return prev
@@ -620,7 +804,7 @@ export function SessionList(props: {
} }
return changed ? next : prev return changed ? next : prev
}) })
}, [selectedSessionId, groups]) }, [selectedSessionId, allGroups])
// Clean up stale collapse overrides // Clean up stale collapse overrides
useEffect(() => { useEffect(() => {
@@ -628,8 +812,9 @@ export function SessionList(props: {
if (prev.size === 0) return prev if (prev.size === 0) return prev
const next = new Map(prev) const next = new Map(prev)
const knownKeys = new Set<string>() const knownKeys = new Set<string>()
for (const g of groups) { for (const g of allGroups) {
knownKeys.add(g.key) knownKeys.add(g.key)
knownKeys.add(`sessions::${g.key}`)
knownKeys.add(`machine::${g.machineId ?? UNKNOWN_MACHINE_ID}`) knownKeys.add(`machine::${g.machineId ?? UNKNOWN_MACHINE_ID}`)
} }
let changed = false let changed = false
@@ -641,14 +826,16 @@ export function SessionList(props: {
} }
return changed ? next : prev return changed ? next : prev
}) })
}, [groups]) }, [allGroups])
return ( return (
<div className="mx-auto w-full max-w-content flex flex-col"> <div className="mx-auto w-full max-w-content flex flex-col">
{renderHeader ? ( {renderHeader ? (
<div className="flex items-center justify-between px-3 py-1"> <div className="flex items-center justify-between px-3 py-1">
<div className="text-xs text-[var(--app-hint)]"> <div className="text-xs text-[var(--app-hint)]">
{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> </div>
<button <button
type="button" type="button"
@@ -661,6 +848,10 @@ export function SessionList(props: {
</div> </div>
) : null} ) : null}
{props.sessions.length > 0 ? (
<SessionListSearch value={searchQuery} onChange={setSearchQuery} />
) : null}
{props.sessions.length === 0 && ( {props.sessions.length === 0 && (
<SessionsEmptyState <SessionsEmptyState
onNewSession={props.onNewSession} 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"> <div className="flex flex-col gap-3 px-2 pt-1 pb-2">
{machineGroups.map((mg) => { {machineGroups.map((mg) => {
const machineCollapsed = isMachineCollapsed(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"> <div className="flex flex-col ml-3.5 pl-1 mt-0.5">
{mg.projectGroups.map((group) => { {mg.projectGroups.map((group) => {
const isCollapsed = isGroupCollapsed(group) const isCollapsed = isGroupCollapsed(group)
const visibleGroupSessions = getVisibleGroupSessions(group)
const hiddenSessionCount = group.sessions.length - visibleGroupSessions.length
const sessionGroupExpanded = isSessionGroupExpanded(group)
return ( return (
<div key={group.key}> <div key={group.key}>
<div <div
@@ -712,7 +912,7 @@ export function SessionList(props: {
<div className="collapsible-panel" data-open={!isCollapsed || undefined}> <div className="collapsible-panel" data-open={!isCollapsed || undefined}>
<div className="collapsible-inner"> <div className="collapsible-inner">
<div className="flex flex-col gap-0.5 ml-3 pl-1 pr-1 py-1"> <div className="flex flex-col gap-0.5 ml-3 pl-1 pr-1 py-1">
{group.sessions.map((s) => ( {visibleGroupSessions.map((s) => (
<SessionItem <SessionItem
key={s.id} key={s.id}
session={s} session={s}
@@ -722,6 +922,20 @@ export function SessionList(props: {
selected={s.id === selectedSessionId} 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> </div>
</div> </div>
+6
View File
@@ -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.hint': 'Start a coding session in any folder under your workspace, or browse the tree first.',
'sessions.empty.startSession': 'Start a session', 'sessions.empty.startSession': 'Start a session',
'sessions.empty.browse': 'Browse workspace', '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 list
'session.item.path': 'path', 'session.item.path': 'path',
+6
View File
@@ -45,6 +45,12 @@ export default {
'sessions.empty.hint': '在 workspace 下任意目录启动一个会话,或先浏览目录树看看。', 'sessions.empty.hint': '在 workspace 下任意目录启动一个会话,或先浏览目录树看看。',
'sessions.empty.startSession': '启动会话', 'sessions.empty.startSession': '启动会话',
'sessions.empty.browse': '浏览 workspace', '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 list
'session.item.path': '路径', 'session.item.path': '路径',