feat(web): add conversation outline search (#1102)

* feat(web): add conversation outline search

* fix(web): keep outline close action accessible
This commit is contained in:
Ananovo
2026-07-24 10:58:23 +08:00
committed by GitHub
parent df36cec01e
commit 33015b67db
5 changed files with 157 additions and 97 deletions
@@ -46,7 +46,6 @@ function renderPanel(props: Partial<ComponentProps<typeof ConversationOutlinePan
return render(
<I18nProvider>
<ConversationOutlinePanel
title="project"
items={outlineItems}
hasMoreMessages={false}
isLoadingMoreMessages={false}
@@ -78,6 +77,41 @@ describe('ConversationOutlinePanel', () => {
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: [] })
@@ -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 (
<aside
className="absolute inset-y-0 right-0 z-30 flex w-full max-w-[24rem] flex-col border-l border-[var(--app-border)] bg-[var(--app-bg)] shadow-2xl sm:w-[24rem]"
aria-label={t('session.outline.title')}
>
<div className="flex items-start gap-3 border-b border-[var(--app-border)] p-3">
<div className="min-w-0 flex-1">
<div className="text-sm font-semibold">{t('session.outline.title')}</div>
<div className="mt-0.5 truncate text-xs text-[var(--app-hint)]">{props.title}</div>
</div>
<button
type="button"
onClick={props.onClose}
className="flex h-8 w-8 shrink-0 items-center justify-center rounded-full text-[var(--app-hint)] transition-colors hover:bg-[var(--app-secondary-bg)] hover:text-[var(--app-fg)]"
aria-label={t('button.close')}
title={t('button.close')}
>
<CloseIcon className="h-4 w-4" />
</button>
</div>
{props.hasMoreMessages ? (
<div className="border-b border-[var(--app-border)] p-3">
<Button
variant="outline"
size="sm"
onClick={props.onLoadMore}
disabled={props.isLoadingMoreMessages}
aria-busy={props.isLoadingMoreMessages}
className="w-full gap-1.5 text-xs"
>
{props.isLoadingMoreMessages ? (
<>
<div className="border-b border-[var(--app-border)] p-3">
<div className="flex items-center gap-2">
<div className="relative min-w-0 flex-1">
<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="pointer-events-none absolute left-3 top-1/2 -translate-y-1/2 text-[var(--app-hint)]"
aria-hidden="true"
>
<circle cx="11" cy="11" r="8" />
<path d="m21 21-4.3-4.3" />
</svg>
<input
type="search"
value={searchQuery}
onChange={(event) => setSearchQuery(event.target.value)}
placeholder={t('session.outline.searchPlaceholder')}
aria-label={t('session.outline.searchLabel')}
className="h-9 w-full rounded-md border border-[var(--app-border)] bg-[var(--app-bg)] py-2 pl-9 pr-3 text-sm text-[var(--app-fg)] outline-none placeholder:text-[var(--app-hint)] focus:border-[var(--app-link)] focus:ring-1 focus:ring-[var(--app-link)]"
/>
</div>
{props.hasMoreMessages ? (
<Button
variant="outline"
onClick={props.onLoadMore}
disabled={props.isLoadingMoreMessages}
aria-busy={props.isLoadingMoreMessages}
aria-label={props.isLoadingMoreMessages ? t('misc.loading') : t('session.outline.loadOlder')}
title={props.isLoadingMoreMessages ? t('misc.loading') : t('session.outline.loadOlder')}
className="h-9 w-9 shrink-0 px-0"
>
{props.isLoadingMoreMessages ? (
<Spinner size="sm" label={null} className="text-current" />
{t('misc.loading')}
</>
) : (
<>
<span aria-hidden="true"></span>
{t('session.outline.loadOlder')}
</>
)}
</Button>
) : (
<span className="text-base leading-none" aria-hidden="true"></span>
)}
</Button>
) : null}
<button
type="button"
onClick={props.onClose}
aria-label={t('button.close')}
title={t('button.close')}
className="flex h-9 w-9 shrink-0 items-center justify-center rounded-full text-[var(--app-hint)] transition-colors hover:bg-[var(--app-secondary-bg)] hover:text-[var(--app-fg)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--app-link)]"
>
<CloseIcon className="h-4 w-4" />
</button>
</div>
) : null}
{normalizedSearchQuery.length > 0 ? (
<div className="mt-1.5 text-right text-xs text-[var(--app-hint)]" aria-live="polite">
{t('session.outline.searchResults', {
matched: filteredItems.length,
total: props.items.length
})}
</div>
) : null}
</div>
<div className="app-scroll-y min-h-0 flex-1 p-2">
{props.items.length === 0 ? (
<div className="px-2 py-8 text-center text-sm text-[var(--app-hint)]">
{t('session.outline.empty')}
</div>
) : filteredItems.length === 0 ? (
<div className="px-2 py-8 text-center text-sm text-[var(--app-hint)]">
{t('session.outline.noSearchResults')}
</div>
) : (
<div className="space-y-1">
{props.items.map((item) => {
{filteredItems.map((item) => {
return (
<button
key={item.id}
@@ -259,7 +296,6 @@ export function HappyThread(props: {
messagesVersion: number
forceScrollToken: number
outlineOpen: boolean
outlineTitle: string
outlineItems: readonly ConversationOutlineItem[]
onOutlineOpenChange: (open: boolean) => void
onOutlineItemClick?: (item: ConversationOutlineItem) => void
@@ -767,7 +803,6 @@ export function HappyThread(props: {
onClick={() => props.onOutlineOpenChange(false)}
/>
<ConversationOutlinePanel
title={props.outlineTitle}
items={props.outlineItems}
hasMoreMessages={props.hasMoreMessages}
isLoadingMoreMessages={props.isLoadingMoreMessages}
+36 -53
View File
@@ -362,19 +362,6 @@ export function buildGoalStateMessages(
: eligibleMessages
}
function getOutlineTitle(session: Session): string {
if (session.metadata?.name) {
return session.metadata.name
}
if (session.metadata?.summary?.text) {
return session.metadata.summary.text
}
if (session.metadata?.path) {
return session.metadata.path
}
return session.id.slice(0, 8)
}
function hasAbortableAgentRun(blocks: readonly ChatBlock[]): boolean {
for (const block of blocks) {
if (block.kind === 'tool-call') {
@@ -970,11 +957,6 @@ function SessionChatInner(props: SessionChatProps) {
[reconciled.blocks]
)
const outlineTitle = useMemo(
() => getOutlineTitle(props.session),
[props.session]
)
// Permission mode change handler
const handlePermissionModeChange = useCallback(async (mode: PermissionMode) => {
try {
@@ -1280,47 +1262,47 @@ function SessionChatInner(props: SessionChatProps) {
messagesVersion={props.messagesVersion}
forceScrollToken={forceScrollToken}
outlineOpen={outlineOpen}
outlineTitle={outlineTitle}
outlineItems={outlineItems}
onOutlineOpenChange={setOutlineOpen}
/>
{codexCollaborationModeSupported && codexModelsState.error ? (
<div className="px-3 pb-2">
<div className="mx-auto w-full max-w-content rounded-md bg-[var(--app-subtle-bg)] p-3 text-sm text-red-600">
{t('session.codexModelsLoadFailed')}: {codexModelsState.error}
<div className={outlineOpen ? 'max-sm:hidden' : undefined}>
{codexCollaborationModeSupported && codexModelsState.error ? (
<div className="px-3 pb-2">
<div className="mx-auto w-full max-w-content rounded-md bg-[var(--app-subtle-bg)] p-3 text-sm text-red-600">
{t('session.codexModelsLoadFailed')}: {codexModelsState.error}
</div>
</div>
</div>
) : null}
<div className="px-3">
{/*
* Scratchlist drawer - composer-controlled. Only
* mounted when the operator clicks the notepad icon
* in the composer toolbar. State lives in the
* useScratchlist hook above (so the toolbar counter
* and the drawer share one source of truth).
*/}
{scratchlistMode ? (
<ScratchlistDrawerHost
entries={scratchlist.entries}
onMove={scratchlist.move}
onDelete={scratchlist.remove}
onSend={props.onSend}
onExitScratchlistMode={() => setScratchlistMode(false)}
/>
) : null}
<QueuedMessagesBar
sessionId={props.session.id}
api={props.api}
onEdit={({ pendingSchedule: restored }) => {
// Restore the schedule so the clock button re-activates
setPendingSchedule(restored)
}}
/>
</div>
<HappyComposer
<div className="px-3">
{/*
* Scratchlist drawer - composer-controlled. Only
* mounted when the operator clicks the notepad icon
* in the composer toolbar. State lives in the
* useScratchlist hook above (so the toolbar counter
* and the drawer share one source of truth).
*/}
{scratchlistMode ? (
<ScratchlistDrawerHost
entries={scratchlist.entries}
onMove={scratchlist.move}
onDelete={scratchlist.remove}
onSend={props.onSend}
onExitScratchlistMode={() => setScratchlistMode(false)}
/>
) : null}
<QueuedMessagesBar
sessionId={props.session.id}
api={props.api}
onEdit={({ pendingSchedule: restored }) => {
// Restore the schedule so the clock button re-activates
setPendingSchedule(restored)
}}
/>
</div>
<HappyComposer
key={`composer-${props.session.id}`}
sessionId={props.session.id}
disabled={props.isSending}
@@ -1472,7 +1454,8 @@ function SessionChatInner(props: SessionChatProps) {
onScratchlistToggle={handleScratchlistToggle}
sendError={props.sendError ?? null}
onClearSendError={props.onClearSendError}
/>
/>
</div>
</DragDropZone>
</AssistantRuntimeProvider>
+4
View File
@@ -164,6 +164,10 @@ export default {
'session.outline.title': 'Outline',
'session.outline.loadOlder': 'Load earlier',
'session.outline.empty': 'No outline items in loaded messages',
'session.outline.searchPlaceholder': 'Search outline...',
'session.outline.searchLabel': 'Search outline items',
'session.outline.searchResults': '{matched} of {total} items',
'session.outline.noSearchResults': 'No matching outline items',
'session.outline.kind.user': 'User',
// Session actions
+4
View File
@@ -164,6 +164,10 @@ export default {
'session.outline.title': '大纲',
'session.outline.loadOlder': '加载更早',
'session.outline.empty': '已加载消息中暂无大纲项',
'session.outline.searchPlaceholder': '搜索大纲…',
'session.outline.searchLabel': '搜索大纲条目',
'session.outline.searchResults': '{matched}/{total} 个条目',
'session.outline.noSearchResults': '没有匹配的大纲条目',
'session.outline.kind.user': '用户',
// Session actions