mirror of
https://github.com/NanmiCoder/cc-haha
synced 2026-07-16 13:03:31 +08:00
feat(desktop): add conversation navigation #782
This commit is contained in:
parent
d1e684c419
commit
d9db7caea0
137
desktop/src/components/chat/ConversationNavigator.test.tsx
Normal file
137
desktop/src/components/chat/ConversationNavigator.test.tsx
Normal file
@ -0,0 +1,137 @@
|
||||
import { fireEvent, render, screen } from '@testing-library/react'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { useSettingsStore } from '../../stores/settingsStore'
|
||||
import type { UIMessage } from '../../types/chat'
|
||||
import {
|
||||
buildConversationNavigationItems,
|
||||
ConversationNavigator,
|
||||
type ConversationNavigationSource,
|
||||
} from './ConversationNavigator'
|
||||
|
||||
function source(message: UIMessage, renderIndex: number): ConversationNavigationSource {
|
||||
return {
|
||||
message,
|
||||
renderIndex,
|
||||
renderItemKey: message.id,
|
||||
}
|
||||
}
|
||||
|
||||
describe('buildConversationNavigationItems', () => {
|
||||
it('keeps only visible user and assistant messages in transcript order', () => {
|
||||
const items = buildConversationNavigationItems([
|
||||
source({ id: 'user-1', type: 'user_text', content: ' Review the API ', timestamp: 1 }, 0),
|
||||
source({ id: 'thinking-1', type: 'thinking', content: 'hidden', timestamp: 2 }, 1),
|
||||
source({ id: 'assistant-empty', type: 'assistant_text', content: ' ', timestamp: 3 }, 2),
|
||||
source({ id: 'assistant-1', type: 'assistant_text', content: '**API** review complete', timestamp: 4 }, 3),
|
||||
source({ id: 'system-1', type: 'system', content: 'hidden', timestamp: 5 }, 4),
|
||||
])
|
||||
|
||||
expect(items).toEqual([
|
||||
{
|
||||
id: 'user-1',
|
||||
renderItemKey: 'user-1',
|
||||
renderIndex: 0,
|
||||
role: 'user',
|
||||
preview: 'Review the API',
|
||||
attachmentCount: 0,
|
||||
},
|
||||
{
|
||||
id: 'assistant-1',
|
||||
renderItemKey: 'assistant-1',
|
||||
renderIndex: 3,
|
||||
role: 'assistant',
|
||||
preview: 'API review complete',
|
||||
attachmentCount: 0,
|
||||
},
|
||||
])
|
||||
})
|
||||
|
||||
it('counts user attachments and flattens markdown into preview text', () => {
|
||||
const items = buildConversationNavigationItems([
|
||||
source({
|
||||
id: 'user-files',
|
||||
type: 'user_text',
|
||||
content: '> Please inspect [`MessageList`](https://example.com)\n\n```ts\nconst ready = true\n```',
|
||||
timestamp: 1,
|
||||
attachments: [
|
||||
{ type: 'file', name: 'one.ts', mimeType: 'text/plain' },
|
||||
{ type: 'file', name: 'two.ts', mimeType: 'text/plain' },
|
||||
],
|
||||
}, 0),
|
||||
])
|
||||
|
||||
expect(items[0]).toMatchObject({
|
||||
preview: 'Please inspect MessageList const ready = true',
|
||||
attachmentCount: 2,
|
||||
})
|
||||
})
|
||||
|
||||
it('bounds previews for very long messages', () => {
|
||||
const items = buildConversationNavigationItems([
|
||||
source({ id: 'long', type: 'assistant_text', content: 'long answer '.repeat(200), timestamp: 1 }, 0),
|
||||
])
|
||||
|
||||
expect(items[0]?.preview.length).toBeLessThanOrEqual(280)
|
||||
expect(items[0]?.preview.endsWith('…')).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('ConversationNavigator', () => {
|
||||
beforeEach(() => {
|
||||
useSettingsStore.setState({ locale: 'en' })
|
||||
})
|
||||
|
||||
it('renders ordered role markers and identifies the active target', () => {
|
||||
render(
|
||||
<ConversationNavigator
|
||||
items={[
|
||||
{ id: 'user-1', renderItemKey: 'user-1', renderIndex: 0, role: 'user', preview: 'First prompt', attachmentCount: 0 },
|
||||
{ id: 'assistant-1', renderItemKey: 'assistant-1', renderIndex: 1, role: 'assistant', preview: 'First answer', attachmentCount: 0 },
|
||||
]}
|
||||
activeItemId="assistant-1"
|
||||
onNavigate={vi.fn()}
|
||||
/>,
|
||||
)
|
||||
|
||||
const markers = screen.getAllByRole('button')
|
||||
expect(markers.map((marker) => marker.getAttribute('data-role'))).toEqual(['user', 'assistant'])
|
||||
expect(markers[0]?.getAttribute('aria-current')).toBeNull()
|
||||
expect(markers[1]?.getAttribute('aria-current')).toBe('location')
|
||||
})
|
||||
|
||||
it('shows the preview on hover or focus and navigates on click', () => {
|
||||
const onNavigate = vi.fn()
|
||||
const item = {
|
||||
id: 'user-1',
|
||||
renderItemKey: 'user-1',
|
||||
renderIndex: 0,
|
||||
role: 'user' as const,
|
||||
preview: 'Inspect the virtual transcript',
|
||||
attachmentCount: 2,
|
||||
}
|
||||
render(
|
||||
<ConversationNavigator
|
||||
items={[item]}
|
||||
activeItemId="user-1"
|
||||
onNavigate={onNavigate}
|
||||
/>,
|
||||
)
|
||||
|
||||
const marker = screen.getByRole('button', { name: /User message.*Inspect the virtual transcript/ })
|
||||
expect(screen.queryByTestId('conversation-navigation-preview')).toBeNull()
|
||||
|
||||
fireEvent.mouseEnter(marker)
|
||||
const preview = screen.getByTestId('conversation-navigation-preview')
|
||||
expect(preview.parentElement).toBe(document.body)
|
||||
expect(preview.textContent).toContain('User message')
|
||||
expect(preview.textContent).toContain('Inspect the virtual transcript')
|
||||
expect(preview.textContent).toContain('2')
|
||||
|
||||
fireEvent.mouseLeave(marker)
|
||||
fireEvent.focus(marker)
|
||||
expect(screen.getByTestId('conversation-navigation-preview')).toBeTruthy()
|
||||
|
||||
fireEvent.click(marker)
|
||||
expect(onNavigate).toHaveBeenCalledWith(item)
|
||||
})
|
||||
})
|
||||
161
desktop/src/components/chat/ConversationNavigator.tsx
Normal file
161
desktop/src/components/chat/ConversationNavigator.tsx
Normal file
@ -0,0 +1,161 @@
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { createPortal } from 'react-dom'
|
||||
import { Paperclip } from 'lucide-react'
|
||||
import { useTranslation } from '../../i18n'
|
||||
import type { UIMessage } from '../../types/chat'
|
||||
|
||||
export type ConversationNavigationSource = {
|
||||
message: UIMessage
|
||||
renderItemKey: string
|
||||
renderIndex: number
|
||||
}
|
||||
|
||||
export type ConversationNavigationItem = {
|
||||
id: string
|
||||
renderItemKey: string
|
||||
renderIndex: number
|
||||
role: 'user' | 'assistant'
|
||||
preview: string
|
||||
attachmentCount: number
|
||||
}
|
||||
|
||||
function normalizePreview(content: string) {
|
||||
const normalized = content.slice(0, 2_000)
|
||||
.replace(/\[([^\]]+)]\([^)]+\)/g, '$1')
|
||||
.replace(/```[a-z0-9_-]*\s*/gi, ' ')
|
||||
.replace(/```/g, ' ')
|
||||
.replace(/[`*_>#~]+/g, ' ')
|
||||
.replace(/\s+/g, ' ')
|
||||
.trim()
|
||||
if (normalized.length <= 280) return normalized
|
||||
return `${normalized.slice(0, 279).trimEnd()}…`
|
||||
}
|
||||
|
||||
export function buildConversationNavigationItems(
|
||||
sources: ConversationNavigationSource[],
|
||||
): ConversationNavigationItem[] {
|
||||
return sources.flatMap(({ message, renderItemKey, renderIndex }) => {
|
||||
if (message.type !== 'user_text' && message.type !== 'assistant_text') return []
|
||||
const preview = normalizePreview(message.content)
|
||||
if (!preview) return []
|
||||
|
||||
return [{
|
||||
id: message.id,
|
||||
renderItemKey,
|
||||
renderIndex,
|
||||
role: message.type === 'user_text' ? 'user' : 'assistant',
|
||||
preview,
|
||||
attachmentCount: message.type === 'user_text' ? message.attachments?.length ?? 0 : 0,
|
||||
}]
|
||||
})
|
||||
}
|
||||
|
||||
export function ConversationNavigator({
|
||||
items,
|
||||
activeItemId,
|
||||
onNavigate,
|
||||
}: {
|
||||
items: ConversationNavigationItem[]
|
||||
activeItemId: string | null
|
||||
onNavigate: (item: ConversationNavigationItem) => void
|
||||
}) {
|
||||
const t = useTranslation()
|
||||
const [previewItemId, setPreviewItemId] = useState<string | null>(null)
|
||||
const [previewPosition, setPreviewPosition] = useState({ left: 0, top: 0 })
|
||||
const markerRefs = useRef(new Map<string, HTMLButtonElement>())
|
||||
const previewItem = items.find((item) => item.id === previewItemId) ?? null
|
||||
|
||||
const openPreview = (itemId: string, marker: HTMLButtonElement) => {
|
||||
const rect = marker.getBoundingClientRect()
|
||||
setPreviewPosition({
|
||||
left: rect.right + 6,
|
||||
top: Math.min(window.innerHeight - 88, Math.max(88, rect.top + rect.height / 2)),
|
||||
})
|
||||
setPreviewItemId(itemId)
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (!activeItemId) return
|
||||
markerRefs.current.get(activeItemId)?.scrollIntoView?.({ block: 'nearest' })
|
||||
}, [activeItemId])
|
||||
|
||||
return (
|
||||
<nav
|
||||
data-testid="conversation-navigator"
|
||||
aria-label={t('chat.conversationNavigator.label')}
|
||||
className="absolute left-2 top-1/2 z-30 flex max-h-[64%] -translate-y-1/2 flex-col overflow-visible"
|
||||
>
|
||||
<div className="conversation-navigation-scroll flex max-h-full w-10 flex-col items-start gap-0.5 overflow-y-auto py-2 [scrollbar-width:none] [&::-webkit-scrollbar]:hidden">
|
||||
{items.map((item) => {
|
||||
const roleLabel = item.role === 'user'
|
||||
? t('chat.userMessageReference')
|
||||
: t('chat.assistantMessageReference')
|
||||
const isActive = item.id === activeItemId
|
||||
|
||||
return (
|
||||
<div key={item.id} className="relative flex shrink-0 items-center">
|
||||
<button
|
||||
ref={(node) => {
|
||||
if (node) markerRefs.current.set(item.id, node)
|
||||
else markerRefs.current.delete(item.id)
|
||||
}}
|
||||
type="button"
|
||||
data-role={item.role}
|
||||
aria-label={`${roleLabel}: ${item.preview}`}
|
||||
aria-current={isActive ? 'location' : undefined}
|
||||
aria-describedby={previewItemId === item.id ? 'conversation-navigation-preview' : undefined}
|
||||
onMouseEnter={(event) => openPreview(item.id, event.currentTarget)}
|
||||
onMouseLeave={(event) => {
|
||||
if (document.activeElement !== event.currentTarget) setPreviewItemId(null)
|
||||
}}
|
||||
onFocus={(event) => openPreview(item.id, event.currentTarget)}
|
||||
onBlur={() => setPreviewItemId(null)}
|
||||
onClick={() => onNavigate(item)}
|
||||
className="group flex h-4 w-10 items-center rounded-sm pl-1.5 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--color-brand)]/35"
|
||||
>
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className={[
|
||||
'block h-0.5 rounded-full transition-[width,background-color,opacity] duration-150',
|
||||
isActive
|
||||
? 'w-5 bg-[var(--color-brand)] opacity-100'
|
||||
: item.role === 'user'
|
||||
? 'w-4 bg-[var(--color-text-secondary)] opacity-75 group-hover:w-5 group-hover:bg-[var(--color-text-primary)] group-hover:opacity-100'
|
||||
: 'w-2.5 bg-[var(--color-outline)] opacity-65 group-hover:w-4 group-hover:bg-[var(--color-text-secondary)] group-hover:opacity-100',
|
||||
].join(' ')}
|
||||
/>
|
||||
</button>
|
||||
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
{previewItem ? createPortal(
|
||||
<div
|
||||
id="conversation-navigation-preview"
|
||||
data-testid="conversation-navigation-preview"
|
||||
role="tooltip"
|
||||
className="fixed z-50 w-[min(320px,calc(100vw-88px))] -translate-y-1/2 rounded-xl border border-[var(--color-border)]/80 bg-[var(--color-surface-container-lowest)] px-3.5 py-3 text-left shadow-[var(--shadow-dropdown)]"
|
||||
style={{ left: previewPosition.left, top: previewPosition.top }}
|
||||
>
|
||||
<div className="mb-1.5 text-[11px] font-semibold uppercase tracking-[0.08em] text-[var(--color-text-tertiary)]">
|
||||
{previewItem.role === 'user' ? t('chat.userMessageReference') : t('chat.assistantMessageReference')}
|
||||
</div>
|
||||
<p className="line-clamp-3 text-[13px] leading-5 text-[var(--color-text-primary)]">
|
||||
{previewItem.preview}
|
||||
</p>
|
||||
{previewItem.attachmentCount > 0 ? (
|
||||
<div
|
||||
aria-label={t('chat.conversationNavigator.attachments', { count: previewItem.attachmentCount })}
|
||||
className="mt-2 flex items-center gap-1 text-[11px] text-[var(--color-text-tertiary)]"
|
||||
>
|
||||
<Paperclip size={12} strokeWidth={2} aria-hidden="true" />
|
||||
<span>{previewItem.attachmentCount}</span>
|
||||
</div>
|
||||
) : null}
|
||||
</div>,
|
||||
document.body,
|
||||
) : null}
|
||||
</nav>
|
||||
)
|
||||
}
|
||||
@ -1,6 +1,14 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { act, fireEvent, render, screen, waitFor, within } from '@testing-library/react'
|
||||
import { MessageList, buildRenderModel, shouldVirtualizeRenderItems } from './MessageList'
|
||||
import {
|
||||
MessageList,
|
||||
buildRenderModel,
|
||||
buildVirtualItemOffsets,
|
||||
getActiveConversationNavigationItemId,
|
||||
getConversationNavigationTargetScrollTop,
|
||||
shouldVirtualizeRenderItems,
|
||||
} from './MessageList'
|
||||
import type { ConversationNavigationItem } from './ConversationNavigator'
|
||||
import type { VirtualRenderItemMetric } from './virtualHeightCache'
|
||||
import { relativizeWorkspacePath } from './CurrentTurnChangeCard'
|
||||
import { sessionsApi } from '../../api/sessions'
|
||||
@ -341,6 +349,173 @@ describe('MessageList nested tool calls', () => {
|
||||
expect(screen.getByText('latest assistant reply')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('shows the conversation navigator for normal desktop transcripts and hides it in compact mode', () => {
|
||||
useChatStore.setState({
|
||||
sessions: {
|
||||
[ACTIVE_TAB]: makeSessionState({
|
||||
messages: [
|
||||
{ id: 'user-1', type: 'user_text', content: 'First prompt', timestamp: 1 },
|
||||
{ id: 'assistant-1', type: 'assistant_text', content: 'First answer', timestamp: 2 },
|
||||
{ id: 'user-2', type: 'user_text', content: 'Second prompt', timestamp: 3 },
|
||||
{ id: 'assistant-2', type: 'assistant_text', content: 'Second answer', timestamp: 4 },
|
||||
],
|
||||
}),
|
||||
},
|
||||
})
|
||||
|
||||
const { rerender } = render(<MessageList />)
|
||||
expect(screen.getByRole('navigation', { name: 'Conversation navigation' })).toBeTruthy()
|
||||
|
||||
rerender(<MessageList compact />)
|
||||
expect(screen.queryByRole('navigation', { name: 'Conversation navigation' })).toBeNull()
|
||||
})
|
||||
|
||||
it('updates the active conversation marker while the transcript scrolls', () => {
|
||||
useChatStore.setState({
|
||||
sessions: {
|
||||
[ACTIVE_TAB]: makeSessionState({
|
||||
messages: [
|
||||
{ id: 'user-1', type: 'user_text', content: 'First prompt', timestamp: 1 },
|
||||
{ id: 'assistant-1', type: 'assistant_text', content: 'First answer', timestamp: 2 },
|
||||
{ id: 'user-2', type: 'user_text', content: 'Second prompt', timestamp: 3 },
|
||||
{ id: 'assistant-2', type: 'assistant_text', content: 'Second answer', timestamp: 4 },
|
||||
],
|
||||
}),
|
||||
},
|
||||
})
|
||||
|
||||
const { container } = render(<MessageList />)
|
||||
const scroller = container.querySelector('.chat-scroll-area') as HTMLElement
|
||||
let scrollTop = 0
|
||||
Object.defineProperty(scroller, 'clientHeight', { configurable: true, value: 200 })
|
||||
Object.defineProperty(scroller, 'scrollHeight', { configurable: true, value: 450 })
|
||||
Object.defineProperty(scroller, 'scrollTop', {
|
||||
configurable: true,
|
||||
get: () => scrollTop,
|
||||
set: (value: number) => { scrollTop = value },
|
||||
})
|
||||
|
||||
fireEvent.scroll(scroller)
|
||||
expect(screen.getByRole('button', { name: /User message: First prompt/ }).getAttribute('aria-current')).toBe('location')
|
||||
|
||||
scrollTop = 250
|
||||
fireEvent.scroll(scroller)
|
||||
expect(screen.getByRole('button', { name: /User message: Second prompt/ }).getAttribute('aria-current')).toBe('location')
|
||||
})
|
||||
|
||||
it('mounts and highlights a far virtualized message selected from the navigator', async () => {
|
||||
const messages: UIMessage[] = Array.from({ length: 220 }, (_, index) => ({
|
||||
id: `${index % 2 === 0 ? 'user' : 'assistant'}-${index}`,
|
||||
type: index % 2 === 0 ? 'user_text' : 'assistant_text',
|
||||
content: `${index % 2 === 0 ? 'Prompt' : 'Answer'} ${index}`,
|
||||
timestamp: index,
|
||||
})) as UIMessage[]
|
||||
useChatStore.setState({
|
||||
sessions: {
|
||||
[ACTIVE_TAB]: makeSessionState({ messages }),
|
||||
},
|
||||
})
|
||||
|
||||
const { container } = render(<MessageList />)
|
||||
const scroller = container.querySelector('.chat-scroll-area') as HTMLElement
|
||||
let scrollTop = 24_000
|
||||
Object.defineProperty(scroller, 'clientHeight', { configurable: true, value: 500 })
|
||||
Object.defineProperty(scroller, 'scrollHeight', { configurable: true, value: 25_000 })
|
||||
Object.defineProperty(scroller, 'scrollTop', {
|
||||
configurable: true,
|
||||
get: () => scrollTop,
|
||||
set: (value: number) => { scrollTop = value },
|
||||
})
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: /User message: Prompt 0/ }))
|
||||
|
||||
await waitFor(() => expect(screen.getByText('Prompt 0')).toBeTruthy())
|
||||
expect(scrollTop).toBe(0)
|
||||
expect(container.querySelector('[data-chat-render-item-key="user-0"]')?.className).toContain('chat-render-item--navigation-target')
|
||||
})
|
||||
|
||||
it('resumes following new output after navigating to the latest message', async () => {
|
||||
const messages: UIMessage[] = [
|
||||
{ id: 'user-1', type: 'user_text', content: 'First prompt', timestamp: 1 },
|
||||
{ id: 'assistant-1', type: 'assistant_text', content: 'First answer', timestamp: 2 },
|
||||
{ id: 'user-2', type: 'user_text', content: 'Second prompt', timestamp: 3 },
|
||||
{ id: 'assistant-2', type: 'assistant_text', content: 'Second answer', timestamp: 4 },
|
||||
]
|
||||
useChatStore.setState({
|
||||
sessions: {
|
||||
[ACTIVE_TAB]: makeSessionState({ messages }),
|
||||
},
|
||||
})
|
||||
|
||||
const { container } = render(<MessageList />)
|
||||
const scroller = container.querySelector('.chat-scroll-area') as HTMLElement
|
||||
let scrollTop = 100
|
||||
Object.defineProperty(scroller, 'scrollHeight', { configurable: true, value: 1000 })
|
||||
Object.defineProperty(scroller, 'clientHeight', { configurable: true, value: 400 })
|
||||
Object.defineProperty(scroller, 'scrollTop', {
|
||||
configurable: true,
|
||||
get: () => scrollTop,
|
||||
set: (value: number) => { scrollTop = value >= 1_000_000_000 ? 600 : value },
|
||||
})
|
||||
Object.defineProperty(scroller, 'scrollTo', {
|
||||
configurable: true,
|
||||
value: (options: ScrollToOptions) => { scroller.scrollTop = options.top ?? 0 },
|
||||
})
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: /Assistant message: Second answer/ }))
|
||||
act(() => {
|
||||
useChatStore.setState({
|
||||
sessions: {
|
||||
[ACTIVE_TAB]: makeSessionState({
|
||||
messages,
|
||||
chatState: 'streaming',
|
||||
streamingText: 'More output from the latest reply',
|
||||
}),
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
await waitFor(() => expect(scrollTop).toBe(600))
|
||||
})
|
||||
|
||||
it('does not treat the last text marker as the transcript tail when tool output follows it', () => {
|
||||
useChatStore.setState({
|
||||
sessions: {
|
||||
[ACTIVE_TAB]: makeSessionState({
|
||||
messages: [
|
||||
{ id: 'user-1', type: 'user_text', content: 'First prompt', timestamp: 1 },
|
||||
{ id: 'assistant-1', type: 'assistant_text', content: 'First answer', timestamp: 2 },
|
||||
{ id: 'user-2', type: 'user_text', content: 'Second prompt', timestamp: 3 },
|
||||
{ id: 'assistant-2', type: 'assistant_text', content: 'Second answer', timestamp: 4 },
|
||||
{
|
||||
id: 'tool-tail',
|
||||
type: 'tool_use',
|
||||
toolName: 'Read',
|
||||
toolUseId: 'tool-tail-use',
|
||||
input: { file_path: '/tmp/example.txt' },
|
||||
timestamp: 5,
|
||||
},
|
||||
],
|
||||
}),
|
||||
},
|
||||
})
|
||||
|
||||
const { container } = render(<MessageList />)
|
||||
const scroller = container.querySelector('.chat-scroll-area') as HTMLElement
|
||||
let scrollTop = 100
|
||||
Object.defineProperty(scroller, 'scrollHeight', { configurable: true, value: 1000 })
|
||||
Object.defineProperty(scroller, 'clientHeight', { configurable: true, value: 400 })
|
||||
Object.defineProperty(scroller, 'scrollTop', {
|
||||
configurable: true,
|
||||
get: () => scrollTop,
|
||||
set: (value: number) => { scrollTop = value >= 1_000_000_000 ? 600 : value },
|
||||
})
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: /Assistant message: Second answer/ }))
|
||||
|
||||
expect(scrollTop).not.toBe(600)
|
||||
})
|
||||
|
||||
it('filters duplicate unresolved AskUserQuestion cards while a matching permission is pending', () => {
|
||||
const messages: UIMessage[] = [
|
||||
{
|
||||
@ -4704,3 +4879,42 @@ describe('shouldVirtualizeRenderItems', () => {
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe('conversation navigation layout', () => {
|
||||
const metrics: VirtualRenderItemMetric[] = [
|
||||
{ signature: 'a', contentWeight: 1, estimatedHeight: 100 },
|
||||
{ signature: 'b', contentWeight: 1, estimatedHeight: 200 },
|
||||
{ signature: 'c', contentWeight: 1, estimatedHeight: 300 },
|
||||
]
|
||||
const items: ConversationNavigationItem[] = [
|
||||
{ id: 'a', renderItemKey: 'a', renderIndex: 0, role: 'user', preview: 'A', attachmentCount: 0 },
|
||||
{ id: 'b', renderItemKey: 'b', renderIndex: 1, role: 'assistant', preview: 'B', attachmentCount: 0 },
|
||||
{ id: 'c', renderItemKey: 'c', renderIndex: 2, role: 'user', preview: 'C', attachmentCount: 0 },
|
||||
]
|
||||
|
||||
it('uses measured heights when calculating transcript offsets', () => {
|
||||
const offsets = buildVirtualItemOffsets(
|
||||
['a', 'b', 'c'],
|
||||
metrics,
|
||||
new Map([['b', 250]]),
|
||||
)
|
||||
|
||||
expect(offsets).toEqual([0, 100, 350, 650])
|
||||
})
|
||||
|
||||
it('selects the last navigation item above the viewport reading anchor', () => {
|
||||
const offsets = [0, 100, 350, 650]
|
||||
|
||||
expect(getActiveConversationNavigationItemId(items, offsets, 0, 300)).toBe('a')
|
||||
expect(getActiveConversationNavigationItemId(items, offsets, 0, 600)).toBe('a')
|
||||
expect(getActiveConversationNavigationItemId(items, offsets, 120, 300)).toBe('b')
|
||||
expect(getActiveConversationNavigationItemId(items, offsets, 330, 300)).toBe('c')
|
||||
})
|
||||
|
||||
it('places navigation targets near the upper reading anchor and clamps the range', () => {
|
||||
const offsets = [0, 100, 350, 650]
|
||||
|
||||
expect(getConversationNavigationTargetScrollTop(items[0]!, offsets, 400, 650)).toBe(0)
|
||||
expect(getConversationNavigationTargetScrollTop(items[2]!, offsets, 400, 650)).toBe(250)
|
||||
})
|
||||
})
|
||||
|
||||
@ -22,6 +22,11 @@ import { AskUserQuestion } from './AskUserQuestion'
|
||||
import { StreamingIndicator } from './StreamingIndicator'
|
||||
import { InlineTaskSummary } from './InlineTaskSummary'
|
||||
import { CurrentTurnChangeCard } from './CurrentTurnChangeCard'
|
||||
import {
|
||||
buildConversationNavigationItems,
|
||||
ConversationNavigator,
|
||||
type ConversationNavigationItem,
|
||||
} from './ConversationNavigator'
|
||||
import type { AgentTaskNotification, UIMessage } from '../../types/chat'
|
||||
import { formatTokenCount } from '../../lib/formatTokenCount'
|
||||
import { formatDurationMs, hasRunningBackgroundTasks as hasAnyRunningBackgroundTasks } from '../../lib/backgroundTasks'
|
||||
@ -944,6 +949,8 @@ const VIRTUAL_MAX_ITEM_HEIGHT = 24_000
|
||||
// Windows WebView2 can report 1px oscillations for live chat content; don't
|
||||
// convert those into bottom-scroll corrections.
|
||||
const CONTENT_RESIZE_FOLLOW_MIN_DELTA_PX = 2
|
||||
const CONVERSATION_NAVIGATION_MIN_ITEMS = 4
|
||||
const STREAMING_ASSISTANT_NAVIGATION_KEY = 'streaming-assistant-message'
|
||||
const EMPTY_MESSAGES: UIMessage[] = []
|
||||
const EMPTY_AGENT_TASK_NOTIFICATIONS: Record<string, AgentTaskNotification> = {}
|
||||
const CHAT_SCROLL_AREA_CLASS = [
|
||||
@ -984,6 +991,8 @@ type VirtualTranscriptWindow = {
|
||||
beforeHeight: number
|
||||
afterHeight: number
|
||||
items: VirtualTranscriptItem[]
|
||||
offsets: number[]
|
||||
totalHeight: number
|
||||
}
|
||||
|
||||
const sessionScrollSnapshots = new Map<string, SessionScrollSnapshot>()
|
||||
@ -1228,6 +1237,55 @@ function findVirtualEndIndex(offsets: number[], target: number) {
|
||||
return clampNumber(low + 1, 0, offsets.length - 1)
|
||||
}
|
||||
|
||||
export function buildVirtualItemOffsets(
|
||||
itemKeys: string[],
|
||||
metrics: VirtualRenderItemMetric[],
|
||||
measuredHeights: Map<string, number>,
|
||||
) {
|
||||
const offsets = new Array<number>(itemKeys.length + 1)
|
||||
offsets[0] = 0
|
||||
for (let index = 0; index < itemKeys.length; index += 1) {
|
||||
const measuredHeight = measuredHeights.get(itemKeys[index]!)
|
||||
const height = measuredHeight && measuredHeight > 0
|
||||
? measuredHeight
|
||||
: metrics[index]?.estimatedHeight ?? VIRTUAL_MIN_ITEM_HEIGHT
|
||||
offsets[index + 1] = offsets[index]! + height
|
||||
}
|
||||
return offsets
|
||||
}
|
||||
|
||||
const CONVERSATION_NAVIGATION_READING_ANCHOR_RATIO = 0.25
|
||||
|
||||
export function getActiveConversationNavigationItemId(
|
||||
items: ConversationNavigationItem[],
|
||||
offsets: number[],
|
||||
scrollTop: number,
|
||||
viewportHeight: number,
|
||||
) {
|
||||
if (items.length === 0) return null
|
||||
if (scrollTop <= 1) return items[0]!.id
|
||||
const readingAnchor = scrollTop + viewportHeight * CONVERSATION_NAVIGATION_READING_ANCHOR_RATIO
|
||||
let activeItem = items[0]!
|
||||
|
||||
for (const item of items) {
|
||||
if ((offsets[item.renderIndex] ?? 0) > readingAnchor) break
|
||||
activeItem = item
|
||||
}
|
||||
|
||||
return activeItem.id
|
||||
}
|
||||
|
||||
export function getConversationNavigationTargetScrollTop(
|
||||
item: ConversationNavigationItem,
|
||||
offsets: number[],
|
||||
viewportHeight: number,
|
||||
totalHeight: number,
|
||||
) {
|
||||
const targetTop = offsets[item.renderIndex] ?? 0
|
||||
const readingAnchor = viewportHeight * CONVERSATION_NAVIGATION_READING_ANCHOR_RATIO
|
||||
return clampNumber(targetTop - readingAnchor, 0, Math.max(0, totalHeight - viewportHeight))
|
||||
}
|
||||
|
||||
function buildVirtualTranscriptWindow(
|
||||
renderItems: RenderItem[],
|
||||
itemKeys: string[],
|
||||
@ -1236,27 +1294,19 @@ function buildVirtualTranscriptWindow(
|
||||
viewport: VirtualViewport,
|
||||
overscanPx: number,
|
||||
): VirtualTranscriptWindow {
|
||||
const offsets = buildVirtualItemOffsets(itemKeys, metrics, measuredHeights)
|
||||
const totalHeight = offsets[renderItems.length] ?? 0
|
||||
if (!shouldVirtualizeRenderItems(metrics)) {
|
||||
return {
|
||||
enabled: false,
|
||||
beforeHeight: 0,
|
||||
afterHeight: 0,
|
||||
items: renderItems.map((item, index) => ({ item, index })),
|
||||
offsets,
|
||||
totalHeight,
|
||||
}
|
||||
}
|
||||
|
||||
const offsets = new Array<number>(renderItems.length + 1)
|
||||
offsets[0] = 0
|
||||
for (let index = 0; index < renderItems.length; index += 1) {
|
||||
const item = renderItems[index]!
|
||||
const measuredHeight = measuredHeights.get(itemKeys[index]!)
|
||||
const height = measuredHeight && measuredHeight > 0
|
||||
? measuredHeight
|
||||
: metrics[index]?.estimatedHeight ?? estimateRenderItemHeight(item)
|
||||
offsets[index + 1] = offsets[index]! + height
|
||||
}
|
||||
|
||||
const totalHeight = offsets[renderItems.length] ?? 0
|
||||
const viewportHeight = viewport.viewportHeight || VIRTUAL_DEFAULT_VIEWPORT_HEIGHT
|
||||
const maxScrollTop = Math.max(0, totalHeight - viewportHeight)
|
||||
const scrollTop = clampNumber(viewport.scrollTop, 0, maxScrollTop)
|
||||
@ -1273,6 +1323,8 @@ function buildVirtualTranscriptWindow(
|
||||
item,
|
||||
index: startIndex + offset,
|
||||
})),
|
||||
offsets,
|
||||
totalHeight,
|
||||
}
|
||||
}
|
||||
|
||||
@ -1322,10 +1374,12 @@ function VirtualSpacer({ height, position }: { height: number; position: 'top' |
|
||||
const MeasuredRenderItem = memo(function MeasuredRenderItem({
|
||||
itemKey,
|
||||
onHeightChange,
|
||||
highlighted,
|
||||
children,
|
||||
}: {
|
||||
itemKey: string
|
||||
onHeightChange: (itemKey: string, height: number) => void
|
||||
highlighted: boolean
|
||||
children: ReactNode
|
||||
}) {
|
||||
const itemRef = useRef<HTMLDivElement>(null)
|
||||
@ -1349,7 +1403,8 @@ const MeasuredRenderItem = memo(function MeasuredRenderItem({
|
||||
<div
|
||||
ref={itemRef}
|
||||
data-virtual-message-item={itemKey}
|
||||
className={CHAT_RENDER_ITEM_CLASS}
|
||||
data-chat-render-item-key={itemKey}
|
||||
className={`${CHAT_RENDER_ITEM_CLASS} ${highlighted ? 'chat-render-item--navigation-target' : ''}`}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
@ -1396,6 +1451,7 @@ export function MessageList({ sessionId, compact = false }: MessageListProps = {
|
||||
)
|
||||
const pendingMeasuredHeightsRef = useRef(false)
|
||||
const measureFlushFrameRef = useRef<number | null>(null)
|
||||
const navigationHighlightTimerRef = useRef<number | null>(null)
|
||||
const lastAutoScrollAtRef = useRef(0)
|
||||
const lastContentResizeFollowHeightRef = useRef<number | null>(null)
|
||||
const shouldAutoScrollRef = useRef(true)
|
||||
@ -1418,6 +1474,7 @@ export function MessageList({ sessionId, compact = false }: MessageListProps = {
|
||||
viewportHeight: VIRTUAL_DEFAULT_VIEWPORT_HEIGHT,
|
||||
})
|
||||
const [measuredItemsVersion, setMeasuredItemsVersion] = useState(0)
|
||||
const [highlightedNavigationItemKey, setHighlightedNavigationItemKey] = useState<string | null>(null)
|
||||
const branchActionsDisabled =
|
||||
isMemberSession ||
|
||||
chatState !== 'idle' ||
|
||||
@ -1433,6 +1490,9 @@ export function MessageList({ sessionId, compact = false }: MessageListProps = {
|
||||
if (measureFlushFrameRef.current !== null) {
|
||||
cancelAnimationFrame(measureFlushFrameRef.current)
|
||||
}
|
||||
if (navigationHighlightTimerRef.current !== null) {
|
||||
window.clearTimeout(navigationHighlightTimerRef.current)
|
||||
}
|
||||
}, [])
|
||||
|
||||
const syncVirtualViewportFromContainer = useCallback((container: HTMLElement) => {
|
||||
@ -1736,6 +1796,37 @@ export function MessageList({ sessionId, compact = false }: MessageListProps = {
|
||||
}),
|
||||
[renderItemKeys, renderItems],
|
||||
)
|
||||
const conversationNavigationHistoryItems = useMemo(() => {
|
||||
const sources = renderItems.flatMap((item, renderIndex) => item.kind === 'message'
|
||||
? [{
|
||||
message: item.message,
|
||||
renderIndex,
|
||||
renderItemKey: getRenderItemKey(item),
|
||||
}]
|
||||
: [])
|
||||
|
||||
return buildConversationNavigationItems(sources)
|
||||
}, [renderItems])
|
||||
const streamingConversationNavigationItem = useMemo(() => {
|
||||
if (!streamingText.trim()) return null
|
||||
|
||||
return buildConversationNavigationItems([{
|
||||
message: {
|
||||
id: `${STREAMING_ASSISTANT_NAVIGATION_KEY}-${resolvedSessionId ?? 'session'}`,
|
||||
type: 'assistant_text',
|
||||
content: streamingText,
|
||||
timestamp: 0,
|
||||
},
|
||||
renderIndex: renderItems.length,
|
||||
renderItemKey: STREAMING_ASSISTANT_NAVIGATION_KEY,
|
||||
}])[0] ?? null
|
||||
}, [renderItems, resolvedSessionId, streamingText])
|
||||
const conversationNavigationItems = useMemo(
|
||||
() => streamingConversationNavigationItem
|
||||
? [...conversationNavigationHistoryItems, streamingConversationNavigationItem]
|
||||
: conversationNavigationHistoryItems,
|
||||
[conversationNavigationHistoryItems, streamingConversationNavigationItem],
|
||||
)
|
||||
const virtualTranscriptWindow = useMemo(
|
||||
() => buildVirtualTranscriptWindow(
|
||||
renderItems,
|
||||
@ -1747,6 +1838,19 @@ export function MessageList({ sessionId, compact = false }: MessageListProps = {
|
||||
),
|
||||
[measuredItemsVersion, renderItemKeys, renderItemMetrics, renderItems, virtualViewport],
|
||||
)
|
||||
const activeConversationNavigationItemId = useMemo(
|
||||
() => getActiveConversationNavigationItemId(
|
||||
conversationNavigationItems,
|
||||
virtualTranscriptWindow.offsets,
|
||||
virtualViewport.scrollTop,
|
||||
virtualViewport.viewportHeight,
|
||||
),
|
||||
[conversationNavigationItems, virtualTranscriptWindow.offsets, virtualViewport],
|
||||
)
|
||||
const showConversationNavigator =
|
||||
!compact &&
|
||||
!isTouchH5Document() &&
|
||||
conversationNavigationItems.length >= CONVERSATION_NAVIGATION_MIN_ITEMS
|
||||
const confirmTurnCard = useMemo(
|
||||
() => visibleTurnChangeCards.find((card) => card.target.messageId === turnUndoConfirmTargetId) ?? null,
|
||||
[turnUndoConfirmTargetId, visibleTurnChangeCards],
|
||||
@ -1952,6 +2056,81 @@ export function MessageList({ sessionId, compact = false }: MessageListProps = {
|
||||
return result
|
||||
}, [toolResultMap])
|
||||
|
||||
const handleNavigateToConversationItem = useCallback((item: ConversationNavigationItem) => {
|
||||
const container = scrollContainerRef.current
|
||||
if (!container) return
|
||||
|
||||
const viewportHeight = container.clientHeight || virtualViewport.viewportHeight || VIRTUAL_DEFAULT_VIEWPORT_HEIGHT
|
||||
const isTranscriptTail =
|
||||
item.renderItemKey === STREAMING_ASSISTANT_NAVIGATION_KEY ||
|
||||
item.renderIndex === renderItems.length - 1
|
||||
setHighlightedNavigationItemKey(item.renderItemKey)
|
||||
|
||||
const scheduleHighlightClear = () => {
|
||||
if (navigationHighlightTimerRef.current !== null) {
|
||||
window.clearTimeout(navigationHighlightTimerRef.current)
|
||||
}
|
||||
navigationHighlightTimerRef.current = window.setTimeout(() => {
|
||||
setHighlightedNavigationItemKey((current) => current === item.renderItemKey ? null : current)
|
||||
navigationHighlightTimerRef.current = null
|
||||
}, 1400)
|
||||
}
|
||||
|
||||
if (isTranscriptTail) {
|
||||
scrollToBottom('auto')
|
||||
requestAnimationFrame(scheduleHighlightClear)
|
||||
return
|
||||
}
|
||||
|
||||
const targetScrollTop = getConversationNavigationTargetScrollTop(
|
||||
item,
|
||||
virtualTranscriptWindow.offsets,
|
||||
viewportHeight,
|
||||
virtualTranscriptWindow.totalHeight,
|
||||
)
|
||||
const prefersReducedMotion = window.matchMedia?.('(prefers-reduced-motion: reduce)').matches ?? false
|
||||
const isNearby = Math.abs(container.scrollTop - targetScrollTop) <= viewportHeight * 1.25
|
||||
|
||||
shouldAutoScrollRef.current = false
|
||||
setShowJumpToLatest(true)
|
||||
ignoreProgrammaticScrollUntilRef.current = performance.now() + 250
|
||||
ignoreProgrammaticScrollTopRef.current = targetScrollTop
|
||||
|
||||
if (isNearby && !prefersReducedMotion && typeof container.scrollTo === 'function') {
|
||||
container.scrollTo({ top: targetScrollTop, behavior: 'smooth' })
|
||||
} else {
|
||||
setScrollTopWithoutLayoutRead(container, targetScrollTop)
|
||||
}
|
||||
setVirtualViewport({ scrollTop: targetScrollTop, viewportHeight })
|
||||
|
||||
requestAnimationFrame(() => {
|
||||
const targetNode = Array.from(
|
||||
scrollContentRef.current?.querySelectorAll<HTMLElement>('[data-chat-render-item-key]') ?? [],
|
||||
).find((node) => node.dataset.chatRenderItemKey === item.renderItemKey)
|
||||
|
||||
if (targetNode) {
|
||||
const targetRect = targetNode.getBoundingClientRect()
|
||||
const containerRect = container.getBoundingClientRect()
|
||||
if (targetRect.height > 0) {
|
||||
const correction = targetRect.top - containerRect.top - viewportHeight * CONVERSATION_NAVIGATION_READING_ANCHOR_RATIO
|
||||
if (Math.abs(correction) >= 1) {
|
||||
setScrollTopWithoutLayoutRead(container, container.scrollTop + correction)
|
||||
syncVirtualViewportFromContainer(container)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
scheduleHighlightClear()
|
||||
})
|
||||
}, [
|
||||
renderItems.length,
|
||||
scrollToBottom,
|
||||
syncVirtualViewportFromContainer,
|
||||
virtualTranscriptWindow.offsets,
|
||||
virtualTranscriptWindow.totalHeight,
|
||||
virtualViewport.viewportHeight,
|
||||
])
|
||||
|
||||
const renderTranscriptItem = (item: RenderItem, index: number) => {
|
||||
const cardsForItem = turnCardsByRenderIndex.get(index) ?? []
|
||||
|
||||
@ -2027,11 +2206,16 @@ export function MessageList({ sessionId, compact = false }: MessageListProps = {
|
||||
key={itemKey}
|
||||
itemKey={itemKey}
|
||||
onHeightChange={handleVirtualItemHeightChange}
|
||||
highlighted={highlightedNavigationItemKey === itemKey}
|
||||
>
|
||||
{content}
|
||||
</MeasuredRenderItem>
|
||||
) : (
|
||||
<div key={itemKey} className={`${CHAT_RENDER_ITEM_CLASS} chat-render-item--cv`}>
|
||||
<div
|
||||
key={itemKey}
|
||||
data-chat-render-item-key={itemKey}
|
||||
className={`${CHAT_RENDER_ITEM_CLASS} chat-render-item--cv ${highlightedNavigationItemKey === itemKey ? 'chat-render-item--navigation-target' : ''}`}
|
||||
>
|
||||
{content}
|
||||
</div>
|
||||
)
|
||||
@ -2042,7 +2226,12 @@ export function MessageList({ sessionId, compact = false }: MessageListProps = {
|
||||
) : null}
|
||||
|
||||
{streamingText.trim() && (
|
||||
<AssistantMessage content={streamingText} isStreaming={chatState === 'streaming'} />
|
||||
<div
|
||||
data-chat-render-item-key={STREAMING_ASSISTANT_NAVIGATION_KEY}
|
||||
className={highlightedNavigationItemKey === STREAMING_ASSISTANT_NAVIGATION_KEY ? 'chat-render-item--navigation-target' : ''}
|
||||
>
|
||||
<AssistantMessage content={streamingText} isStreaming={chatState === 'streaming'} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{chatState === 'compacting' && !hasCompactingDivider && (
|
||||
@ -2067,6 +2256,14 @@ export function MessageList({ sessionId, compact = false }: MessageListProps = {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{showConversationNavigator ? (
|
||||
<ConversationNavigator
|
||||
items={conversationNavigationItems}
|
||||
activeItemId={activeConversationNavigationItemId}
|
||||
onNavigate={handleNavigateToConversationItem}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{showJumpToLatest && (
|
||||
<button
|
||||
type="button"
|
||||
|
||||
@ -1196,6 +1196,8 @@ export const en = {
|
||||
'chat.branchError': 'Failed to branch from this message. Detail: {detail}',
|
||||
'chat.userMessageReference': 'User message',
|
||||
'chat.assistantMessageReference': 'Assistant message',
|
||||
'chat.conversationNavigator.label': 'Conversation navigation',
|
||||
'chat.conversationNavigator.attachments': '{count} attachments',
|
||||
'chat.slashCommands': 'Slash commands',
|
||||
'chat.pendingMessageGuide': 'Guide',
|
||||
'chat.pendingMessageGuideNow': 'Guide now',
|
||||
|
||||
@ -1198,6 +1198,8 @@ export const jp: Record<TranslationKey, string> = {
|
||||
'chat.branchError': 'このメッセージから分岐できませんでした。詳細: {detail}',
|
||||
'chat.userMessageReference': 'ユーザーメッセージ',
|
||||
'chat.assistantMessageReference': 'アシスタントメッセージ',
|
||||
'chat.conversationNavigator.label': '会話ナビゲーション',
|
||||
'chat.conversationNavigator.attachments': '添付ファイル {count} 件',
|
||||
'chat.slashCommands': 'スラッシュコマンド',
|
||||
'chat.pendingMessageGuide': '誘導',
|
||||
'chat.pendingMessageGuideNow': '今すぐ誘導',
|
||||
|
||||
@ -1198,6 +1198,8 @@ export const kr: Record<TranslationKey, string> = {
|
||||
'chat.branchError': '이 메시지에서 분기할 수 없습니다. 세부 정보: {detail}',
|
||||
'chat.userMessageReference': '사용자 메시지',
|
||||
'chat.assistantMessageReference': '어시스턴트 메시지',
|
||||
'chat.conversationNavigator.label': '대화 탐색',
|
||||
'chat.conversationNavigator.attachments': '첨부 파일 {count}개',
|
||||
'chat.slashCommands': '슬래시 명령',
|
||||
'chat.pendingMessageGuide': '가이드',
|
||||
'chat.pendingMessageGuideNow': '지금 가이드',
|
||||
|
||||
@ -1198,6 +1198,8 @@ export const zh: Record<TranslationKey, string> = {
|
||||
'chat.branchError': '從該訊息 Fork 新對話失敗。詳情:{detail}',
|
||||
'chat.userMessageReference': '使用者訊息',
|
||||
'chat.assistantMessageReference': 'AI 回覆',
|
||||
'chat.conversationNavigator.label': '對話導覽',
|
||||
'chat.conversationNavigator.attachments': '{count} 個附件',
|
||||
'chat.slashCommands': '斜槓命令',
|
||||
'chat.pendingMessageGuide': '引導',
|
||||
'chat.pendingMessageGuideNow': '立即引導',
|
||||
|
||||
@ -1198,6 +1198,8 @@ export const zh: Record<TranslationKey, string> = {
|
||||
'chat.branchError': '从该消息 Fork 新对话失败。详情:{detail}',
|
||||
'chat.userMessageReference': '用户消息',
|
||||
'chat.assistantMessageReference': 'AI 回复',
|
||||
'chat.conversationNavigator.label': '对话导航',
|
||||
'chat.conversationNavigator.attachments': '{count} 个附件',
|
||||
'chat.slashCommands': '斜杠命令',
|
||||
'chat.pendingMessageGuide': '引导',
|
||||
'chat.pendingMessageGuideNow': '立即引导',
|
||||
|
||||
@ -581,6 +581,7 @@
|
||||
--color-text-primary-a78: rgba(27, 28, 26, 0.78);
|
||||
--color-surface-hover-a34: rgba(233, 232, 228, 0.34);
|
||||
--color-surface-hover-a54: rgba(233, 232, 228, 0.54);
|
||||
--color-chat-navigation-target: rgba(143, 72, 47, 0.10);
|
||||
--color-outline-a72: rgba(135, 115, 109, 0.72);
|
||||
--color-outline-a78: rgba(135, 115, 109, 0.78);
|
||||
--color-outline-a92: rgba(135, 115, 109, 0.92);
|
||||
@ -775,6 +776,7 @@
|
||||
--color-text-primary-a78: rgba(17, 24, 39, 0.78);
|
||||
--color-surface-hover-a34: rgba(241, 244, 247, 0.34);
|
||||
--color-surface-hover-a54: rgba(241, 244, 247, 0.54);
|
||||
--color-chat-navigation-target: rgba(143, 72, 47, 0.10);
|
||||
--color-outline-a72: rgba(139, 152, 167, 0.72);
|
||||
--color-outline-a78: rgba(139, 152, 167, 0.78);
|
||||
--color-outline-a92: rgba(139, 152, 167, 0.92);
|
||||
@ -963,6 +965,7 @@
|
||||
--color-text-primary-a78: rgba(229, 226, 225, 0.78);
|
||||
--color-surface-hover-a34: rgba(53, 53, 52, 0.34);
|
||||
--color-surface-hover-a54: rgba(53, 53, 52, 0.54);
|
||||
--color-chat-navigation-target: rgba(255, 181, 159, 0.10);
|
||||
--color-outline-a72: rgba(141, 127, 122, 0.72);
|
||||
--color-outline-a78: rgba(141, 127, 122, 0.78);
|
||||
--color-outline-a92: rgba(141, 127, 122, 0.92);
|
||||
@ -1482,6 +1485,24 @@ button, input, textarea, select, a, [role="button"] {
|
||||
contain-intrinsic-size: auto 300px;
|
||||
}
|
||||
|
||||
@keyframes chat-navigation-target-pulse {
|
||||
0% { background: transparent; }
|
||||
22% { background: var(--color-chat-navigation-target); }
|
||||
100% { background: transparent; }
|
||||
}
|
||||
|
||||
.chat-render-item--navigation-target {
|
||||
border-radius: var(--radius-lg);
|
||||
animation: chat-navigation-target-pulse 1.4s ease-out;
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.chat-render-item--navigation-target {
|
||||
animation: none;
|
||||
background: var(--color-chat-navigation-target);
|
||||
}
|
||||
}
|
||||
|
||||
/* Same WebKit paint-skipping trick for trace tree rows (see .chat-render-item--cv
|
||||
above). Tree rows are fixed-density 34px single-line items, so the intrinsic
|
||||
size estimate is exact and scrolling never drifts. */
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user