Add web conversation outline (#534)

This commit is contained in:
NightWatcher314
2026-04-27 21:19:11 +08:00
committed by GitHub
parent c9ff7f9de3
commit d66547ff46
14 changed files with 473 additions and 9 deletions
+69
View File
@@ -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'
])
})
})
+50
View File
@@ -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} />}
+4 -1
View File
@@ -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 () => {
+31
View File
@@ -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 ? (
+37
View File
@@ -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}
+6
View File
@@ -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',
+6
View File
@@ -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': '重命名',
+4 -1
View File
@@ -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', () => {
+47
View File
@@ -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()
}