mirror of
https://github.com/wu736139669/hapi.git
synced 2026-08-05 06:24:37 +00:00
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:
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user