mirror of
https://github.com/wu736139669/hapi.git
synced 2026-08-07 06:52:28 +00:00
Add web conversation outline (#534)
This commit is contained in:
@@ -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'
|
||||
])
|
||||
})
|
||||
})
|
||||
@@ -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}`
|
||||
}
|
||||
@@ -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<ComponentProps<typeof ConversationOutlinePanel>> = {}) {
|
||||
return render(
|
||||
<I18nProvider>
|
||||
<ConversationOutlinePanel
|
||||
title="project"
|
||||
items={outlineItems}
|
||||
hasMoreMessages={false}
|
||||
isLoadingMoreMessages={false}
|
||||
onLoadMore={vi.fn()}
|
||||
onSelect={vi.fn()}
|
||||
onClose={vi.fn()}
|
||||
{...props}
|
||||
/>
|
||||
</I18nProvider>
|
||||
)
|
||||
}
|
||||
|
||||
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()
|
||||
})
|
||||
})
|
||||
@@ -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 (
|
||||
<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 ? (
|
||||
<>
|
||||
<Spinner size="sm" label={null} className="text-current" />
|
||||
{t('misc.loading')}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<span aria-hidden="true">↑</span>
|
||||
{t('session.outline.loadOlder')}
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<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>
|
||||
) : (
|
||||
<div className="space-y-1">
|
||||
{props.items.map((item) => {
|
||||
return (
|
||||
<button
|
||||
key={item.id}
|
||||
type="button"
|
||||
onClick={() => props.onSelect(item)}
|
||||
className="group flex w-full min-w-0 items-start gap-2 rounded-md px-2 py-2 text-left transition-colors hover:bg-[var(--app-subtle-bg)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--app-link)]"
|
||||
>
|
||||
<span className="mt-1.5 h-2 w-2 shrink-0 rounded-full bg-[var(--app-button)]" aria-hidden="true" />
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="block truncate text-[11px] font-medium uppercase text-[var(--app-hint)]">
|
||||
{t('session.outline.kind.user')}
|
||||
</span>
|
||||
<span className="line-clamp-2 text-sm leading-snug text-[var(--app-fg)]">
|
||||
{item.label}
|
||||
</span>
|
||||
</span>
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</aside>
|
||||
)
|
||||
}
|
||||
|
||||
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<HTMLDivElement | null>(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: {
|
||||
</div>
|
||||
</ThreadPrimitive.Viewport>
|
||||
<NewMessagesIndicator count={props.pendingCount} onClick={scrollToBottom} />
|
||||
{props.outlineOpen ? (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
className="absolute inset-0 z-20 bg-black/20"
|
||||
aria-label={t('session.outline.close')}
|
||||
onClick={() => props.onOutlineOpenChange(false)}
|
||||
/>
|
||||
<ConversationOutlinePanel
|
||||
title={props.outlineTitle}
|
||||
items={props.outlineItems}
|
||||
hasMoreMessages={props.hasMoreMessages}
|
||||
isLoadingMoreMessages={props.isLoadingMoreMessages}
|
||||
onLoadMore={handleLoadMore}
|
||||
onSelect={handleOutlineSelect}
|
||||
onClose={() => props.onOutlineOpenChange(false)}
|
||||
/>
|
||||
</>
|
||||
) : null}
|
||||
</ThreadPrimitive.Root>
|
||||
</HappyChatProvider>
|
||||
)
|
||||
|
||||
@@ -7,6 +7,7 @@ import { CopyIcon, CheckIcon } from '@/components/icons'
|
||||
import { useCopyToClipboard } from '@/hooks/useCopyToClipboard'
|
||||
import type { HappyChatMessageMetadata } from '@/lib/assistant-runtime'
|
||||
import { getAssistantCopyText } from '@/components/AssistantChat/messages/assistantCopyText'
|
||||
import { getConversationMessageAnchorId } from '@/chat/outline'
|
||||
|
||||
const TOOL_COMPONENTS = {
|
||||
Fallback: HappyToolMessage
|
||||
@@ -21,6 +22,7 @@ const MESSAGE_PART_COMPONENTS = {
|
||||
|
||||
export function HappyAssistantMessage() {
|
||||
const { copied, copy } = useCopyToClipboard()
|
||||
const messageId = useAssistantState(({ message }) => message.id)
|
||||
const isCliOutput = useAssistantState(({ message }) => {
|
||||
const custom = message.metadata.custom as Partial<HappyChatMessageMetadata> | undefined
|
||||
return custom?.kind === 'cli-output'
|
||||
@@ -45,14 +47,20 @@ export function HappyAssistantMessage() {
|
||||
|
||||
if (isCliOutput) {
|
||||
return (
|
||||
<MessagePrimitive.Root className="px-1 min-w-0 max-w-full overflow-x-hidden">
|
||||
<MessagePrimitive.Root
|
||||
id={getConversationMessageAnchorId(messageId)}
|
||||
className="scroll-mt-4 px-1 min-w-0 max-w-full overflow-x-hidden"
|
||||
>
|
||||
<CliOutputBlock text={cliText} />
|
||||
</MessagePrimitive.Root>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<MessagePrimitive.Root className={`${rootClass} ${copyText ? 'group/msg' : ''}`}>
|
||||
<MessagePrimitive.Root
|
||||
id={getConversationMessageAnchorId(messageId)}
|
||||
className={`${rootClass} ${copyText ? 'group/msg' : ''} scroll-mt-4`}
|
||||
>
|
||||
<div className="min-w-0">
|
||||
<MessagePrimitive.Content components={MESSAGE_PART_COMPONENTS} />
|
||||
</div>
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
import { useAssistantState } from '@assistant-ui/react'
|
||||
import { MessagePrimitive, useAssistantState } from '@assistant-ui/react'
|
||||
import { getEventPresentation } from '@/chat/presentation'
|
||||
import type { HappyChatMessageMetadata } from '@/lib/assistant-runtime'
|
||||
import { getConversationMessageAnchorId } from '@/chat/outline'
|
||||
|
||||
export function HappySystemMessage() {
|
||||
const role = useAssistantState(({ message }) => message.role)
|
||||
const messageId = useAssistantState(({ message }) => message.id)
|
||||
const text = useAssistantState(({ message }) => {
|
||||
if (message.role !== 'system') return ''
|
||||
return message.content[0]?.type === 'text' ? message.content[0].text : ''
|
||||
@@ -18,13 +20,13 @@ export function HappySystemMessage() {
|
||||
if (role !== 'system') return null
|
||||
|
||||
return (
|
||||
<div className="py-1">
|
||||
<MessagePrimitive.Root id={getConversationMessageAnchorId(messageId)} className="scroll-mt-4 py-1">
|
||||
<div className="mx-auto w-fit max-w-[92%] px-2 text-center text-xs text-[var(--app-hint)] opacity-80">
|
||||
<span className="inline-flex items-center gap-1">
|
||||
{icon ? <span aria-hidden="true">{icon}</span> : null}
|
||||
<span>{text}</span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</MessagePrimitive.Root>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -7,11 +7,13 @@ import { MessageAttachments } from '@/components/AssistantChat/messages/MessageA
|
||||
import { CliOutputBlock } from '@/components/CliOutputBlock'
|
||||
import { CopyIcon, CheckIcon } from '@/components/icons'
|
||||
import { useCopyToClipboard } from '@/hooks/useCopyToClipboard'
|
||||
import { getConversationMessageAnchorId } from '@/chat/outline'
|
||||
|
||||
export function HappyUserMessage() {
|
||||
const ctx = useHappyChatContext()
|
||||
const { copied, copy } = useCopyToClipboard()
|
||||
const role = useAssistantState(({ message }) => message.role)
|
||||
const messageId = useAssistantState(({ message }) => message.id)
|
||||
const text = useAssistantState(({ message }) => {
|
||||
if (message.role !== 'user') return ''
|
||||
return message.content.find((part) => part.type === 'text')?.text ?? ''
|
||||
@@ -49,7 +51,10 @@ export function HappyUserMessage() {
|
||||
|
||||
if (isCliOutput) {
|
||||
return (
|
||||
<MessagePrimitive.Root className="px-1 min-w-0 max-w-full overflow-x-hidden">
|
||||
<MessagePrimitive.Root
|
||||
id={getConversationMessageAnchorId(messageId)}
|
||||
className="scroll-mt-4 px-1 min-w-0 max-w-full overflow-x-hidden"
|
||||
>
|
||||
<div className="ml-auto w-full max-w-[92%]">
|
||||
<CliOutputBlock text={cliText} />
|
||||
</div>
|
||||
@@ -61,7 +66,10 @@ export function HappyUserMessage() {
|
||||
const hasAttachments = attachments && attachments.length > 0
|
||||
|
||||
return (
|
||||
<MessagePrimitive.Root className={`${userBubbleClass} group/msg`}>
|
||||
<MessagePrimitive.Root
|
||||
id={getConversationMessageAnchorId(messageId)}
|
||||
className={`${userBubbleClass} group/msg scroll-mt-4`}
|
||||
>
|
||||
<div className="flex items-end gap-2">
|
||||
<div className="flex-1 min-w-0">
|
||||
{hasText && <LazyRainbowText text={text} />}
|
||||
|
||||
@@ -18,8 +18,11 @@ describe('LoginPrompt', () => {
|
||||
getItem: vi.fn(() => 'en'),
|
||||
setItem: vi.fn(),
|
||||
removeItem: vi.fn(),
|
||||
clear: vi.fn(),
|
||||
key: vi.fn(() => null),
|
||||
length: 0,
|
||||
}
|
||||
Object.defineProperty(window, 'localStorage', { value: localStorageMock })
|
||||
Object.defineProperty(window, 'localStorage', { value: localStorageMock, configurable: true })
|
||||
})
|
||||
|
||||
it('does not clear first hub URL edit when hub URL required', async () => {
|
||||
|
||||
@@ -15,6 +15,7 @@ import type { Suggestion } from '@/hooks/useActiveSuggestions'
|
||||
import { normalizeDecryptedMessage } from '@/chat/normalize'
|
||||
import { reduceChatBlocks } from '@/chat/reducer'
|
||||
import { reconcileChatBlocks } from '@/chat/reconcile'
|
||||
import { buildConversationOutline } from '@/chat/outline'
|
||||
import { HappyComposer } from '@/components/AssistantChat/HappyComposer'
|
||||
import { HappyThread } from '@/components/AssistantChat/HappyThread'
|
||||
import { useHappyRuntime } from '@/lib/assistant-runtime'
|
||||
@@ -31,6 +32,19 @@ import { useVoiceOptional } from '@/lib/voice-context'
|
||||
import { RealtimeVoiceSession, registerSessionStore, registerVoiceHooksStore, voiceHooks } from '@/realtime'
|
||||
import { isRemoteTerminalSupported } from '@/utils/terminalSupport'
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
export function SessionChat(props: {
|
||||
api: ApiClient
|
||||
session: Session
|
||||
@@ -61,6 +75,7 @@ export function SessionChat(props: {
|
||||
const normalizedCacheRef = useRef<Map<string, { source: DecryptedMessage; normalized: NormalizedMessage | null }>>(new Map())
|
||||
const blocksByIdRef = useRef<Map<string, ChatBlock>>(new Map())
|
||||
const [forceScrollToken, setForceScrollToken] = useState(0)
|
||||
const [outlineOpen, setOutlineOpen] = useState(false)
|
||||
const agentFlavor = props.session.metadata?.flavor ?? null
|
||||
const controlledByUser = props.session.agentState?.controlledByUser === true
|
||||
const codexCollaborationModeSupported = agentFlavor === 'codex' && !controlledByUser
|
||||
@@ -194,6 +209,7 @@ export function SessionChat(props: {
|
||||
useEffect(() => {
|
||||
normalizedCacheRef.current.clear()
|
||||
blocksByIdRef.current.clear()
|
||||
setOutlineOpen(false)
|
||||
}, [props.session.id])
|
||||
|
||||
const normalizedMessages: NormalizedMessage[] = useMemo(() => {
|
||||
@@ -239,6 +255,16 @@ export function SessionChat(props: {
|
||||
blocksByIdRef.current = reconciled.byId
|
||||
}, [reconciled.byId])
|
||||
|
||||
const outlineItems = useMemo(
|
||||
() => buildConversationOutline(reconciled.blocks),
|
||||
[reconciled.blocks]
|
||||
)
|
||||
|
||||
const outlineTitle = useMemo(
|
||||
() => getOutlineTitle(props.session),
|
||||
[props.session]
|
||||
)
|
||||
|
||||
// Permission mode change handler
|
||||
const handlePermissionModeChange = useCallback(async (mode: PermissionMode) => {
|
||||
try {
|
||||
@@ -367,6 +393,7 @@ export function SessionChat(props: {
|
||||
session={props.session}
|
||||
onBack={props.onBack}
|
||||
onViewFiles={props.session.metadata?.path ? handleViewFiles : undefined}
|
||||
onOpenOutline={() => setOutlineOpen(true)}
|
||||
api={props.api}
|
||||
onSessionDeleted={props.onBack}
|
||||
/>
|
||||
@@ -405,6 +432,10 @@ export function SessionChat(props: {
|
||||
normalizedMessagesCount={normalizedMessages.length}
|
||||
messagesVersion={props.messagesVersion}
|
||||
forceScrollToken={forceScrollToken}
|
||||
outlineOpen={outlineOpen}
|
||||
outlineTitle={outlineTitle}
|
||||
outlineItems={outlineItems}
|
||||
onOutlineOpenChange={setOutlineOpen}
|
||||
/>
|
||||
|
||||
{codexCollaborationModeSupported && codexModelsState.error ? (
|
||||
|
||||
@@ -43,6 +43,30 @@ function FilesIcon(props: { className?: string }) {
|
||||
)
|
||||
}
|
||||
|
||||
function OutlineIcon(props: { className?: string }) {
|
||||
return (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="18"
|
||||
height="18"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
className={props.className}
|
||||
>
|
||||
<path d="M8 6h13" />
|
||||
<path d="M8 12h13" />
|
||||
<path d="M8 18h13" />
|
||||
<path d="M3 6h.01" />
|
||||
<path d="M3 12h.01" />
|
||||
<path d="M3 18h.01" />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
function MoreVerticalIcon(props: { className?: string }) {
|
||||
return (
|
||||
<svg
|
||||
@@ -64,6 +88,7 @@ export function SessionHeader(props: {
|
||||
session: Session
|
||||
onBack: () => void
|
||||
onViewFiles?: () => void
|
||||
onOpenOutline?: () => void
|
||||
api: ApiClient | null
|
||||
onSessionDeleted?: () => void
|
||||
}) {
|
||||
@@ -162,6 +187,18 @@ export function SessionHeader(props: {
|
||||
</button>
|
||||
) : null}
|
||||
|
||||
{props.onOpenOutline ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={props.onOpenOutline}
|
||||
className="flex h-8 w-8 items-center justify-center rounded-full text-[var(--app-hint)] transition-colors hover:bg-[var(--app-secondary-bg)] hover:text-[var(--app-fg)]"
|
||||
title={t('session.outline.open')}
|
||||
aria-label={t('session.outline.open')}
|
||||
>
|
||||
<OutlineIcon />
|
||||
</button>
|
||||
) : null}
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleMenuToggle}
|
||||
|
||||
@@ -61,6 +61,12 @@ export default {
|
||||
// Session header
|
||||
'session.title': 'Files',
|
||||
'session.more': 'More actions',
|
||||
'session.outline.open': 'Conversation outline',
|
||||
'session.outline.close': 'Close outline',
|
||||
'session.outline.title': 'Outline',
|
||||
'session.outline.loadOlder': 'Load earlier',
|
||||
'session.outline.empty': 'No outline items in loaded messages',
|
||||
'session.outline.kind.user': 'User',
|
||||
|
||||
// Session actions
|
||||
'session.action.rename': 'Rename',
|
||||
|
||||
@@ -61,6 +61,12 @@ export default {
|
||||
// Session header
|
||||
'session.title': '文件',
|
||||
'session.more': '更多操作',
|
||||
'session.outline.open': '会话大纲',
|
||||
'session.outline.close': '关闭大纲',
|
||||
'session.outline.title': '大纲',
|
||||
'session.outline.loadOlder': '加载更早',
|
||||
'session.outline.empty': '已加载消息中暂无大纲项',
|
||||
'session.outline.kind.user': '用户',
|
||||
|
||||
// Session actions
|
||||
'session.action.rename': '重命名',
|
||||
|
||||
@@ -81,8 +81,11 @@ describe('SettingsPage', () => {
|
||||
getItem: vi.fn(() => 'en'),
|
||||
setItem: vi.fn(),
|
||||
removeItem: vi.fn(),
|
||||
clear: vi.fn(),
|
||||
key: vi.fn(() => null),
|
||||
length: 0,
|
||||
}
|
||||
Object.defineProperty(window, 'localStorage', { value: localStorageMock })
|
||||
Object.defineProperty(window, 'localStorage', { value: localStorageMock, configurable: true })
|
||||
})
|
||||
|
||||
it('renders the About section', () => {
|
||||
|
||||
@@ -1 +1,48 @@
|
||||
import '@testing-library/jest-dom/vitest'
|
||||
|
||||
function installMemoryLocalStorage(): void {
|
||||
const store = new Map<string, string>()
|
||||
const memoryLocalStorage: Storage = {
|
||||
get length() {
|
||||
return store.size
|
||||
},
|
||||
clear() {
|
||||
store.clear()
|
||||
},
|
||||
getItem(key: string) {
|
||||
return store.get(key) ?? null
|
||||
},
|
||||
key(index: number) {
|
||||
return Array.from(store.keys())[index] ?? null
|
||||
},
|
||||
removeItem(key: string) {
|
||||
store.delete(key)
|
||||
},
|
||||
setItem(key: string, value: string) {
|
||||
store.set(key, String(value))
|
||||
}
|
||||
}
|
||||
|
||||
Object.defineProperty(globalThis, 'localStorage', {
|
||||
value: memoryLocalStorage,
|
||||
configurable: true
|
||||
})
|
||||
Object.defineProperty(window, 'localStorage', {
|
||||
value: memoryLocalStorage,
|
||||
configurable: true
|
||||
})
|
||||
}
|
||||
|
||||
try {
|
||||
const storage = globalThis.localStorage
|
||||
if (
|
||||
typeof storage?.getItem !== 'function'
|
||||
|| typeof storage.setItem !== 'function'
|
||||
|| typeof storage.removeItem !== 'function'
|
||||
|| typeof storage.clear !== 'function'
|
||||
) {
|
||||
installMemoryLocalStorage()
|
||||
}
|
||||
} catch {
|
||||
installMemoryLocalStorage()
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user