From d66547ff46f312e24a78a53f425f310f85dfab57 Mon Sep 17 00:00:00 2001 From: NightWatcher314 Date: Mon, 27 Apr 2026 21:19:11 +0800 Subject: [PATCH] Add web conversation outline (#534) --- web/src/chat/outline.test.ts | 69 ++++++++++ web/src/chat/outline.ts | 50 +++++++ .../AssistantChat/HappyThread.test.tsx | 66 +++++++++ .../components/AssistantChat/HappyThread.tsx | 128 ++++++++++++++++++ .../messages/AssistantMessage.tsx | 12 +- .../AssistantChat/messages/SystemMessage.tsx | 8 +- .../AssistantChat/messages/UserMessage.tsx | 12 +- web/src/components/LoginPrompt.test.tsx | 5 +- web/src/components/SessionChat.tsx | 31 +++++ web/src/components/SessionHeader.tsx | 37 +++++ web/src/lib/locales/en.ts | 6 + web/src/lib/locales/zh-CN.ts | 6 + web/src/routes/settings/index.test.tsx | 5 +- web/src/test/setup.ts | 47 +++++++ 14 files changed, 473 insertions(+), 9 deletions(-) create mode 100644 web/src/chat/outline.test.ts create mode 100644 web/src/chat/outline.ts create mode 100644 web/src/components/AssistantChat/HappyThread.test.tsx diff --git a/web/src/chat/outline.test.ts b/web/src/chat/outline.test.ts new file mode 100644 index 00000000..39fa19cc --- /dev/null +++ b/web/src/chat/outline.test.ts @@ -0,0 +1,69 @@ +import { describe, expect, it } from 'vitest' +import type { AgentEvent, ChatBlock } from '@/chat/types' +import { buildConversationOutline, truncateOutlineLabel } from '@/chat/outline' + +function userBlock(id: string, text: string, createdAt: number): ChatBlock { + return { + kind: 'user-text', + id, + localId: null, + createdAt, + text + } +} + +function eventBlock(id: string, event: AgentEvent, createdAt: number): ChatBlock { + return { + kind: 'agent-event', + id, + createdAt, + event, + } +} + +describe('conversation outline', () => { + it('creates outline items from user messages', () => { + expect(buildConversationOutline([ + userBlock('m1', 'Implement the outline panel', 1000), + ])).toEqual([ + { + id: 'outline:user:m1', + targetMessageId: 'user:m1', + kind: 'user', + label: 'Implement the outline panel', + createdAt: 1000 + } + ]) + }) + + it('ignores title and summary events', () => { + const items = buildConversationOutline([ + eventBlock('e1', { type: 'title-changed', title: 'Add conversation outline' }, 1000), + eventBlock('e2', { type: 'message', message: 'Context compacted into a summary.' }, 2000), + eventBlock('e3', { type: 'ready' }, 3000), + ]) + + expect(items).toEqual([]) + }) + + it('handles empty and long labels', () => { + expect(buildConversationOutline([ + userBlock('empty', ' \n\t ', 1000), + ])[0]?.label).toBe('Empty message') + + expect(truncateOutlineLabel('a '.repeat(80), 20)).toBe('a a a a a a a a a...') + }) + + it('keeps block order stable', () => { + const items = buildConversationOutline([ + userBlock('first', 'First', 1000), + eventBlock('summary', { type: 'message', message: 'Summary' }, 900), + userBlock('second', 'Second', 1100), + ]) + + expect(items.map((item) => item.id)).toEqual([ + 'outline:user:first', + 'outline:user:second' + ]) + }) +}) diff --git a/web/src/chat/outline.ts b/web/src/chat/outline.ts new file mode 100644 index 00000000..d70a9ed2 --- /dev/null +++ b/web/src/chat/outline.ts @@ -0,0 +1,50 @@ +import type { ChatBlock, UserTextBlock } from '@/chat/types' + +export type ConversationOutlineItem = { + id: string + targetMessageId: string + kind: 'user' + label: string + createdAt: number +} + +const MAX_OUTLINE_LABEL_LENGTH = 96 + +function collapseWhitespace(value: string): string { + return value.replace(/\s+/g, ' ').trim() +} + +export function truncateOutlineLabel(value: string, maxLength = MAX_OUTLINE_LABEL_LENGTH): string { + const normalized = collapseWhitespace(value) + if (normalized.length <= maxLength) { + return normalized + } + return `${normalized.slice(0, Math.max(0, maxLength - 3)).trimEnd()}...` +} + +function userBlockToOutlineItem(block: UserTextBlock): ConversationOutlineItem { + const label = truncateOutlineLabel(block.text) || 'Empty message' + return { + id: `outline:user:${block.id}`, + targetMessageId: `user:${block.id}`, + kind: 'user', + label, + createdAt: block.createdAt + } +} + +export function buildConversationOutline(blocks: readonly ChatBlock[]): ConversationOutlineItem[] { + const items: ConversationOutlineItem[] = [] + + for (const block of blocks) { + if (block.kind === 'user-text') { + items.push(userBlockToOutlineItem(block)) + } + } + + return items +} + +export function getConversationMessageAnchorId(messageId: string): string { + return `hapi-message-${messageId}` +} diff --git a/web/src/components/AssistantChat/HappyThread.test.tsx b/web/src/components/AssistantChat/HappyThread.test.tsx new file mode 100644 index 00000000..51be7fc5 --- /dev/null +++ b/web/src/components/AssistantChat/HappyThread.test.tsx @@ -0,0 +1,66 @@ +import { describe, expect, it, vi } from 'vitest' +import { fireEvent, render, screen } from '@testing-library/react' +import type { ComponentProps } from 'react' +import { I18nProvider } from '@/lib/i18n-context' +import { ConversationOutlinePanel } from '@/components/AssistantChat/HappyThread' +import type { ConversationOutlineItem } from '@/chat/outline' + +const outlineItems: ConversationOutlineItem[] = [ + { + id: 'outline:user:m1', + targetMessageId: 'user:m1', + kind: 'user', + label: 'Implement the panel', + createdAt: 1000 + }, + { + id: 'outline:user:m2', + targetMessageId: 'user:m2', + kind: 'user', + label: 'Second user prompt', + createdAt: 2000 + } +] + +function renderPanel(props: Partial> = {}) { + return render( + + + + ) +} + +describe('ConversationOutlinePanel', () => { + it('renders outline items and selects an item', () => { + const onSelect = vi.fn() + renderPanel({ onSelect }) + + fireEvent.click(screen.getByText('Implement the panel')) + + expect(onSelect).toHaveBeenCalledWith(outlineItems[0]) + }) + + it('shows load earlier when older messages exist', () => { + const onLoadMore = vi.fn() + renderPanel({ hasMoreMessages: true, onLoadMore }) + + fireEvent.click(screen.getByRole('button', { name: /Load earlier/ })) + + expect(onLoadMore).toHaveBeenCalledTimes(1) + }) + + it('renders an empty state', () => { + renderPanel({ items: [] }) + + expect(screen.getByText('No outline items in loaded messages')).toBeInTheDocument() + }) +}) diff --git a/web/src/components/AssistantChat/HappyThread.tsx b/web/src/components/AssistantChat/HappyThread.tsx index 48e9a57b..6016fd43 100644 --- a/web/src/components/AssistantChat/HappyThread.tsx +++ b/web/src/components/AssistantChat/HappyThread.tsx @@ -2,6 +2,8 @@ import { useCallback, useEffect, useLayoutEffect, useRef, useState } from 'react import { ThreadPrimitive } from '@assistant-ui/react' import type { ApiClient } from '@/api/client' import type { SessionMetadataSummary } from '@/types/api' +import type { ConversationOutlineItem } from '@/chat/outline' +import { getConversationMessageAnchorId } from '@/chat/outline' import { HappyChatProvider } from '@/components/AssistantChat/context' import { HappyAssistantMessage } from '@/components/AssistantChat/messages/AssistantMessage' import { HappyUserMessage } from '@/components/AssistantChat/messages/UserMessage' @@ -9,6 +11,7 @@ import { HappySystemMessage } from '@/components/AssistantChat/messages/SystemMe import { Button } from '@/components/ui/button' import { Spinner } from '@/components/Spinner' import { useTranslation } from '@/lib/use-translation' +import { CloseIcon } from '@/components/icons' function NewMessagesIndicator(props: { count: number; onClick: () => void }) { const { t } = useTranslation() @@ -55,6 +58,97 @@ const THREAD_MESSAGE_COMPONENTS = { SystemMessage: HappySystemMessage } as const +export function ConversationOutlinePanel(props: { + title: string + items: readonly ConversationOutlineItem[] + hasMoreMessages: boolean + isLoadingMoreMessages: boolean + onLoadMore: () => void + onSelect: (item: ConversationOutlineItem) => void + onClose: () => void +}) { + const { t } = useTranslation() + + return ( + + ) +} + export function HappyThread(props: { api: ApiClient sessionId: string @@ -74,6 +168,11 @@ export function HappyThread(props: { normalizedMessagesCount: number messagesVersion: number forceScrollToken: number + outlineOpen: boolean + outlineTitle: string + outlineItems: readonly ConversationOutlineItem[] + onOutlineOpenChange: (open: boolean) => void + onOutlineItemClick?: (item: ConversationOutlineItem) => void }) { const { t } = useTranslation() const viewportRef = useRef(null) @@ -210,6 +309,16 @@ export function HappyThread(props: { }) }, []) + const handleOutlineSelect = useCallback((item: ConversationOutlineItem) => { + const target = document.getElementById(getConversationMessageAnchorId(item.targetMessageId)) + if (target) { + target.scrollIntoView({ block: 'start', behavior: 'smooth' }) + setAutoScrollEnabled(false) + } + props.onOutlineItemClick?.(item) + props.onOutlineOpenChange(false) + }, [props.onOutlineItemClick, props.onOutlineOpenChange]) + useEffect(() => { handleLoadMoreRef.current = handleLoadMore }, [handleLoadMore]) @@ -333,6 +442,25 @@ export function HappyThread(props: { + {props.outlineOpen ? ( + <> + ) : null} + {props.onOpenOutline ? ( + + ) : null} +