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 { ThreadPrimitive } from '@assistant-ui/react'
|
||||||
import type { ApiClient } from '@/api/client'
|
import type { ApiClient } from '@/api/client'
|
||||||
import type { SessionMetadataSummary } from '@/types/api'
|
import type { SessionMetadataSummary } from '@/types/api'
|
||||||
|
import type { ConversationOutlineItem } from '@/chat/outline'
|
||||||
|
import { getConversationMessageAnchorId } from '@/chat/outline'
|
||||||
import { HappyChatProvider } from '@/components/AssistantChat/context'
|
import { HappyChatProvider } from '@/components/AssistantChat/context'
|
||||||
import { HappyAssistantMessage } from '@/components/AssistantChat/messages/AssistantMessage'
|
import { HappyAssistantMessage } from '@/components/AssistantChat/messages/AssistantMessage'
|
||||||
import { HappyUserMessage } from '@/components/AssistantChat/messages/UserMessage'
|
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 { Button } from '@/components/ui/button'
|
||||||
import { Spinner } from '@/components/Spinner'
|
import { Spinner } from '@/components/Spinner'
|
||||||
import { useTranslation } from '@/lib/use-translation'
|
import { useTranslation } from '@/lib/use-translation'
|
||||||
|
import { CloseIcon } from '@/components/icons'
|
||||||
|
|
||||||
function NewMessagesIndicator(props: { count: number; onClick: () => void }) {
|
function NewMessagesIndicator(props: { count: number; onClick: () => void }) {
|
||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
@@ -55,6 +58,97 @@ const THREAD_MESSAGE_COMPONENTS = {
|
|||||||
SystemMessage: HappySystemMessage
|
SystemMessage: HappySystemMessage
|
||||||
} as const
|
} 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: {
|
export function HappyThread(props: {
|
||||||
api: ApiClient
|
api: ApiClient
|
||||||
sessionId: string
|
sessionId: string
|
||||||
@@ -74,6 +168,11 @@ export function HappyThread(props: {
|
|||||||
normalizedMessagesCount: number
|
normalizedMessagesCount: number
|
||||||
messagesVersion: number
|
messagesVersion: number
|
||||||
forceScrollToken: number
|
forceScrollToken: number
|
||||||
|
outlineOpen: boolean
|
||||||
|
outlineTitle: string
|
||||||
|
outlineItems: readonly ConversationOutlineItem[]
|
||||||
|
onOutlineOpenChange: (open: boolean) => void
|
||||||
|
onOutlineItemClick?: (item: ConversationOutlineItem) => void
|
||||||
}) {
|
}) {
|
||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
const viewportRef = useRef<HTMLDivElement | null>(null)
|
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(() => {
|
useEffect(() => {
|
||||||
handleLoadMoreRef.current = handleLoadMore
|
handleLoadMoreRef.current = handleLoadMore
|
||||||
}, [handleLoadMore])
|
}, [handleLoadMore])
|
||||||
@@ -333,6 +442,25 @@ export function HappyThread(props: {
|
|||||||
</div>
|
</div>
|
||||||
</ThreadPrimitive.Viewport>
|
</ThreadPrimitive.Viewport>
|
||||||
<NewMessagesIndicator count={props.pendingCount} onClick={scrollToBottom} />
|
<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>
|
</ThreadPrimitive.Root>
|
||||||
</HappyChatProvider>
|
</HappyChatProvider>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import { CopyIcon, CheckIcon } from '@/components/icons'
|
|||||||
import { useCopyToClipboard } from '@/hooks/useCopyToClipboard'
|
import { useCopyToClipboard } from '@/hooks/useCopyToClipboard'
|
||||||
import type { HappyChatMessageMetadata } from '@/lib/assistant-runtime'
|
import type { HappyChatMessageMetadata } from '@/lib/assistant-runtime'
|
||||||
import { getAssistantCopyText } from '@/components/AssistantChat/messages/assistantCopyText'
|
import { getAssistantCopyText } from '@/components/AssistantChat/messages/assistantCopyText'
|
||||||
|
import { getConversationMessageAnchorId } from '@/chat/outline'
|
||||||
|
|
||||||
const TOOL_COMPONENTS = {
|
const TOOL_COMPONENTS = {
|
||||||
Fallback: HappyToolMessage
|
Fallback: HappyToolMessage
|
||||||
@@ -21,6 +22,7 @@ const MESSAGE_PART_COMPONENTS = {
|
|||||||
|
|
||||||
export function HappyAssistantMessage() {
|
export function HappyAssistantMessage() {
|
||||||
const { copied, copy } = useCopyToClipboard()
|
const { copied, copy } = useCopyToClipboard()
|
||||||
|
const messageId = useAssistantState(({ message }) => message.id)
|
||||||
const isCliOutput = useAssistantState(({ message }) => {
|
const isCliOutput = useAssistantState(({ message }) => {
|
||||||
const custom = message.metadata.custom as Partial<HappyChatMessageMetadata> | undefined
|
const custom = message.metadata.custom as Partial<HappyChatMessageMetadata> | undefined
|
||||||
return custom?.kind === 'cli-output'
|
return custom?.kind === 'cli-output'
|
||||||
@@ -45,14 +47,20 @@ export function HappyAssistantMessage() {
|
|||||||
|
|
||||||
if (isCliOutput) {
|
if (isCliOutput) {
|
||||||
return (
|
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} />
|
<CliOutputBlock text={cliText} />
|
||||||
</MessagePrimitive.Root>
|
</MessagePrimitive.Root>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
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">
|
<div className="min-w-0">
|
||||||
<MessagePrimitive.Content components={MESSAGE_PART_COMPONENTS} />
|
<MessagePrimitive.Content components={MESSAGE_PART_COMPONENTS} />
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,9 +1,11 @@
|
|||||||
import { useAssistantState } from '@assistant-ui/react'
|
import { MessagePrimitive, useAssistantState } from '@assistant-ui/react'
|
||||||
import { getEventPresentation } from '@/chat/presentation'
|
import { getEventPresentation } from '@/chat/presentation'
|
||||||
import type { HappyChatMessageMetadata } from '@/lib/assistant-runtime'
|
import type { HappyChatMessageMetadata } from '@/lib/assistant-runtime'
|
||||||
|
import { getConversationMessageAnchorId } from '@/chat/outline'
|
||||||
|
|
||||||
export function HappySystemMessage() {
|
export function HappySystemMessage() {
|
||||||
const role = useAssistantState(({ message }) => message.role)
|
const role = useAssistantState(({ message }) => message.role)
|
||||||
|
const messageId = useAssistantState(({ message }) => message.id)
|
||||||
const text = useAssistantState(({ message }) => {
|
const text = useAssistantState(({ message }) => {
|
||||||
if (message.role !== 'system') return ''
|
if (message.role !== 'system') return ''
|
||||||
return message.content[0]?.type === 'text' ? message.content[0].text : ''
|
return message.content[0]?.type === 'text' ? message.content[0].text : ''
|
||||||
@@ -18,13 +20,13 @@ export function HappySystemMessage() {
|
|||||||
if (role !== 'system') return null
|
if (role !== 'system') return null
|
||||||
|
|
||||||
return (
|
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">
|
<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">
|
<span className="inline-flex items-center gap-1">
|
||||||
{icon ? <span aria-hidden="true">{icon}</span> : null}
|
{icon ? <span aria-hidden="true">{icon}</span> : null}
|
||||||
<span>{text}</span>
|
<span>{text}</span>
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</MessagePrimitive.Root>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,11 +7,13 @@ import { MessageAttachments } from '@/components/AssistantChat/messages/MessageA
|
|||||||
import { CliOutputBlock } from '@/components/CliOutputBlock'
|
import { CliOutputBlock } from '@/components/CliOutputBlock'
|
||||||
import { CopyIcon, CheckIcon } from '@/components/icons'
|
import { CopyIcon, CheckIcon } from '@/components/icons'
|
||||||
import { useCopyToClipboard } from '@/hooks/useCopyToClipboard'
|
import { useCopyToClipboard } from '@/hooks/useCopyToClipboard'
|
||||||
|
import { getConversationMessageAnchorId } from '@/chat/outline'
|
||||||
|
|
||||||
export function HappyUserMessage() {
|
export function HappyUserMessage() {
|
||||||
const ctx = useHappyChatContext()
|
const ctx = useHappyChatContext()
|
||||||
const { copied, copy } = useCopyToClipboard()
|
const { copied, copy } = useCopyToClipboard()
|
||||||
const role = useAssistantState(({ message }) => message.role)
|
const role = useAssistantState(({ message }) => message.role)
|
||||||
|
const messageId = useAssistantState(({ message }) => message.id)
|
||||||
const text = useAssistantState(({ message }) => {
|
const text = useAssistantState(({ message }) => {
|
||||||
if (message.role !== 'user') return ''
|
if (message.role !== 'user') return ''
|
||||||
return message.content.find((part) => part.type === 'text')?.text ?? ''
|
return message.content.find((part) => part.type === 'text')?.text ?? ''
|
||||||
@@ -49,7 +51,10 @@ export function HappyUserMessage() {
|
|||||||
|
|
||||||
if (isCliOutput) {
|
if (isCliOutput) {
|
||||||
return (
|
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%]">
|
<div className="ml-auto w-full max-w-[92%]">
|
||||||
<CliOutputBlock text={cliText} />
|
<CliOutputBlock text={cliText} />
|
||||||
</div>
|
</div>
|
||||||
@@ -61,7 +66,10 @@ export function HappyUserMessage() {
|
|||||||
const hasAttachments = attachments && attachments.length > 0
|
const hasAttachments = attachments && attachments.length > 0
|
||||||
|
|
||||||
return (
|
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 items-end gap-2">
|
||||||
<div className="flex-1 min-w-0">
|
<div className="flex-1 min-w-0">
|
||||||
{hasText && <LazyRainbowText text={text} />}
|
{hasText && <LazyRainbowText text={text} />}
|
||||||
|
|||||||
@@ -18,8 +18,11 @@ describe('LoginPrompt', () => {
|
|||||||
getItem: vi.fn(() => 'en'),
|
getItem: vi.fn(() => 'en'),
|
||||||
setItem: vi.fn(),
|
setItem: vi.fn(),
|
||||||
removeItem: 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 () => {
|
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 { normalizeDecryptedMessage } from '@/chat/normalize'
|
||||||
import { reduceChatBlocks } from '@/chat/reducer'
|
import { reduceChatBlocks } from '@/chat/reducer'
|
||||||
import { reconcileChatBlocks } from '@/chat/reconcile'
|
import { reconcileChatBlocks } from '@/chat/reconcile'
|
||||||
|
import { buildConversationOutline } from '@/chat/outline'
|
||||||
import { HappyComposer } from '@/components/AssistantChat/HappyComposer'
|
import { HappyComposer } from '@/components/AssistantChat/HappyComposer'
|
||||||
import { HappyThread } from '@/components/AssistantChat/HappyThread'
|
import { HappyThread } from '@/components/AssistantChat/HappyThread'
|
||||||
import { useHappyRuntime } from '@/lib/assistant-runtime'
|
import { useHappyRuntime } from '@/lib/assistant-runtime'
|
||||||
@@ -31,6 +32,19 @@ import { useVoiceOptional } from '@/lib/voice-context'
|
|||||||
import { RealtimeVoiceSession, registerSessionStore, registerVoiceHooksStore, voiceHooks } from '@/realtime'
|
import { RealtimeVoiceSession, registerSessionStore, registerVoiceHooksStore, voiceHooks } from '@/realtime'
|
||||||
import { isRemoteTerminalSupported } from '@/utils/terminalSupport'
|
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: {
|
export function SessionChat(props: {
|
||||||
api: ApiClient
|
api: ApiClient
|
||||||
session: Session
|
session: Session
|
||||||
@@ -61,6 +75,7 @@ export function SessionChat(props: {
|
|||||||
const normalizedCacheRef = useRef<Map<string, { source: DecryptedMessage; normalized: NormalizedMessage | null }>>(new Map())
|
const normalizedCacheRef = useRef<Map<string, { source: DecryptedMessage; normalized: NormalizedMessage | null }>>(new Map())
|
||||||
const blocksByIdRef = useRef<Map<string, ChatBlock>>(new Map())
|
const blocksByIdRef = useRef<Map<string, ChatBlock>>(new Map())
|
||||||
const [forceScrollToken, setForceScrollToken] = useState(0)
|
const [forceScrollToken, setForceScrollToken] = useState(0)
|
||||||
|
const [outlineOpen, setOutlineOpen] = useState(false)
|
||||||
const agentFlavor = props.session.metadata?.flavor ?? null
|
const agentFlavor = props.session.metadata?.flavor ?? null
|
||||||
const controlledByUser = props.session.agentState?.controlledByUser === true
|
const controlledByUser = props.session.agentState?.controlledByUser === true
|
||||||
const codexCollaborationModeSupported = agentFlavor === 'codex' && !controlledByUser
|
const codexCollaborationModeSupported = agentFlavor === 'codex' && !controlledByUser
|
||||||
@@ -194,6 +209,7 @@ export function SessionChat(props: {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
normalizedCacheRef.current.clear()
|
normalizedCacheRef.current.clear()
|
||||||
blocksByIdRef.current.clear()
|
blocksByIdRef.current.clear()
|
||||||
|
setOutlineOpen(false)
|
||||||
}, [props.session.id])
|
}, [props.session.id])
|
||||||
|
|
||||||
const normalizedMessages: NormalizedMessage[] = useMemo(() => {
|
const normalizedMessages: NormalizedMessage[] = useMemo(() => {
|
||||||
@@ -239,6 +255,16 @@ export function SessionChat(props: {
|
|||||||
blocksByIdRef.current = reconciled.byId
|
blocksByIdRef.current = reconciled.byId
|
||||||
}, [reconciled.byId])
|
}, [reconciled.byId])
|
||||||
|
|
||||||
|
const outlineItems = useMemo(
|
||||||
|
() => buildConversationOutline(reconciled.blocks),
|
||||||
|
[reconciled.blocks]
|
||||||
|
)
|
||||||
|
|
||||||
|
const outlineTitle = useMemo(
|
||||||
|
() => getOutlineTitle(props.session),
|
||||||
|
[props.session]
|
||||||
|
)
|
||||||
|
|
||||||
// Permission mode change handler
|
// Permission mode change handler
|
||||||
const handlePermissionModeChange = useCallback(async (mode: PermissionMode) => {
|
const handlePermissionModeChange = useCallback(async (mode: PermissionMode) => {
|
||||||
try {
|
try {
|
||||||
@@ -367,6 +393,7 @@ export function SessionChat(props: {
|
|||||||
session={props.session}
|
session={props.session}
|
||||||
onBack={props.onBack}
|
onBack={props.onBack}
|
||||||
onViewFiles={props.session.metadata?.path ? handleViewFiles : undefined}
|
onViewFiles={props.session.metadata?.path ? handleViewFiles : undefined}
|
||||||
|
onOpenOutline={() => setOutlineOpen(true)}
|
||||||
api={props.api}
|
api={props.api}
|
||||||
onSessionDeleted={props.onBack}
|
onSessionDeleted={props.onBack}
|
||||||
/>
|
/>
|
||||||
@@ -405,6 +432,10 @@ export function SessionChat(props: {
|
|||||||
normalizedMessagesCount={normalizedMessages.length}
|
normalizedMessagesCount={normalizedMessages.length}
|
||||||
messagesVersion={props.messagesVersion}
|
messagesVersion={props.messagesVersion}
|
||||||
forceScrollToken={forceScrollToken}
|
forceScrollToken={forceScrollToken}
|
||||||
|
outlineOpen={outlineOpen}
|
||||||
|
outlineTitle={outlineTitle}
|
||||||
|
outlineItems={outlineItems}
|
||||||
|
onOutlineOpenChange={setOutlineOpen}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{codexCollaborationModeSupported && codexModelsState.error ? (
|
{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 }) {
|
function MoreVerticalIcon(props: { className?: string }) {
|
||||||
return (
|
return (
|
||||||
<svg
|
<svg
|
||||||
@@ -64,6 +88,7 @@ export function SessionHeader(props: {
|
|||||||
session: Session
|
session: Session
|
||||||
onBack: () => void
|
onBack: () => void
|
||||||
onViewFiles?: () => void
|
onViewFiles?: () => void
|
||||||
|
onOpenOutline?: () => void
|
||||||
api: ApiClient | null
|
api: ApiClient | null
|
||||||
onSessionDeleted?: () => void
|
onSessionDeleted?: () => void
|
||||||
}) {
|
}) {
|
||||||
@@ -162,6 +187,18 @@ export function SessionHeader(props: {
|
|||||||
</button>
|
</button>
|
||||||
) : null}
|
) : 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
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={handleMenuToggle}
|
onClick={handleMenuToggle}
|
||||||
|
|||||||
@@ -61,6 +61,12 @@ export default {
|
|||||||
// Session header
|
// Session header
|
||||||
'session.title': 'Files',
|
'session.title': 'Files',
|
||||||
'session.more': 'More actions',
|
'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 actions
|
||||||
'session.action.rename': 'Rename',
|
'session.action.rename': 'Rename',
|
||||||
|
|||||||
@@ -61,6 +61,12 @@ export default {
|
|||||||
// Session header
|
// Session header
|
||||||
'session.title': '文件',
|
'session.title': '文件',
|
||||||
'session.more': '更多操作',
|
'session.more': '更多操作',
|
||||||
|
'session.outline.open': '会话大纲',
|
||||||
|
'session.outline.close': '关闭大纲',
|
||||||
|
'session.outline.title': '大纲',
|
||||||
|
'session.outline.loadOlder': '加载更早',
|
||||||
|
'session.outline.empty': '已加载消息中暂无大纲项',
|
||||||
|
'session.outline.kind.user': '用户',
|
||||||
|
|
||||||
// Session actions
|
// Session actions
|
||||||
'session.action.rename': '重命名',
|
'session.action.rename': '重命名',
|
||||||
|
|||||||
@@ -81,8 +81,11 @@ describe('SettingsPage', () => {
|
|||||||
getItem: vi.fn(() => 'en'),
|
getItem: vi.fn(() => 'en'),
|
||||||
setItem: vi.fn(),
|
setItem: vi.fn(),
|
||||||
removeItem: 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', () => {
|
it('renders the About section', () => {
|
||||||
|
|||||||
@@ -1 +1,48 @@
|
|||||||
import '@testing-library/jest-dom/vitest'
|
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