From 33015b67db6469ba3b1f1a11b2f10a7367cc6dbc Mon Sep 17 00:00:00 2001 From: Ananovo Date: Fri, 24 Jul 2026 10:58:23 +0800 Subject: [PATCH] feat(web): add conversation outline search (#1102) * feat(web): add conversation outline search * fix(web): keep outline close action accessible --- .../AssistantChat/HappyThread.test.tsx | 36 +++++- .../components/AssistantChat/HappyThread.tsx | 121 +++++++++++------- web/src/components/SessionChat.tsx | 89 ++++++------- web/src/lib/locales/en.ts | 4 + web/src/lib/locales/zh-CN.ts | 4 + 5 files changed, 157 insertions(+), 97 deletions(-) diff --git a/web/src/components/AssistantChat/HappyThread.test.tsx b/web/src/components/AssistantChat/HappyThread.test.tsx index c81ec3db..7a99c9be 100644 --- a/web/src/components/AssistantChat/HappyThread.test.tsx +++ b/web/src/components/AssistantChat/HappyThread.test.tsx @@ -46,7 +46,6 @@ function renderPanel(props: Partial { expect(onLoadMore).toHaveBeenCalledTimes(1) }) + it('filters loaded outline items without hiding load earlier', () => { + const onLoadMore = vi.fn() + renderPanel({ hasMoreMessages: true, onLoadMore }) + + fireEvent.change(screen.getByRole('searchbox', { name: 'Search outline items' }), { + target: { value: 'SECOND' } + }) + + expect(screen.queryByText('Implement the panel')).not.toBeInTheDocument() + expect(screen.getByText('Second user prompt')).toBeInTheDocument() + expect(screen.getByText('1 of 2 items')).toBeInTheDocument() + fireEvent.click(screen.getByRole('button', { name: /Load earlier/ })) + expect(onLoadMore).toHaveBeenCalledTimes(1) + }) + + it('shows a search-specific empty state', () => { + renderPanel() + + fireEvent.change(screen.getByRole('searchbox', { name: 'Search outline items' }), { + target: { value: 'missing' } + }) + + expect(screen.getByText('No matching outline items')).toBeInTheDocument() + expect(screen.queryByText('No outline items in loaded messages')).not.toBeInTheDocument() + }) + + it('keeps an in-panel close action available', () => { + const onClose = vi.fn() + renderPanel({ onClose }) + + fireEvent.click(screen.getByRole('button', { name: 'Close' })) + + expect(onClose).toHaveBeenCalledTimes(1) + }) + it('renders an empty state', () => { renderPanel({ items: [] }) diff --git a/web/src/components/AssistantChat/HappyThread.tsx b/web/src/components/AssistantChat/HappyThread.tsx index 2e6f7c3e..2d53575f 100644 --- a/web/src/components/AssistantChat/HappyThread.tsx +++ b/web/src/components/AssistantChat/HappyThread.tsx @@ -1,4 +1,4 @@ -import { useCallback, useEffect, useLayoutEffect, useRef } from 'react' +import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react' import { ThreadPrimitive } from '@assistant-ui/react' import type { ApiClient } from '@/api/client' import type { SessionMetadataSummary } from '@/types/api' @@ -149,7 +149,6 @@ const THREAD_MESSAGE_COMPONENTS = { } as const export function ConversationOutlinePanel(props: { - title: string items: readonly ConversationOutlineItem[] hasMoreMessages: boolean isLoadingMoreMessages: boolean @@ -158,61 +157,99 @@ export function ConversationOutlinePanel(props: { onClose: () => void }) { const { t } = useTranslation() + const [searchQuery, setSearchQuery] = useState('') + const normalizedSearchQuery = searchQuery.trim().toLocaleLowerCase() + const filteredItems = useMemo(() => { + if (normalizedSearchQuery.length === 0) { + return props.items + } + return props.items.filter((item) => ( + item.label.toLocaleLowerCase().includes(normalizedSearchQuery) + )) + }, [normalizedSearchQuery, props.items]) return (