|
|
@@ -7,8 +7,10 @@ import type {
|
|
|
} from '@deepseek-ai/dsh-client-ui-conversation/client'
|
|
|
import { Button, IconChevronDownOutline14, Modal } from '@deepseek-ai/dsh-client-ui-primitives'
|
|
|
import type { ChatViewSlotProps } from '../contract/slots.ts'
|
|
|
+import type { TurnNavigationItem } from '../contract/snapshot.ts'
|
|
|
import { PendingSteeringBubble } from './MessageItem.tsx'
|
|
|
import { ChatNodeSeat } from './ChatNodeSeat.tsx'
|
|
|
+import { TurnNavigator } from './TurnNavigator.tsx'
|
|
|
import { formatRunDuration } from './message-chrome.ts'
|
|
|
import css from './ChatView.module.css'
|
|
|
|
|
|
@@ -34,6 +36,32 @@ function anchorElement(list: HTMLElement, key: string): HTMLElement | null {
|
|
|
return null
|
|
|
}
|
|
|
|
|
|
+/**
|
|
|
+ * Turn owning the row at a scrollport line. Scroll frames are hot, so this
|
|
|
+ * hit-tests the line first and falls back to one row scan when layout cannot
|
|
|
+ * answer (jsdom, pre-paint); neither path queries per navigation item.
|
|
|
+ * @param list - the ChatView list element.
|
|
|
+ * @param line - viewport y of the reading line.
|
|
|
+ * @returns the Turn number, or null when no loaded row covers the line.
|
|
|
+ */
|
|
|
+function turnAtLine(list: HTMLElement, line: number): number | null {
|
|
|
+ const content = list.getBoundingClientRect()
|
|
|
+ if (typeof document.elementsFromPoint === 'function' && content.width > 0) {
|
|
|
+ for (const element of document.elementsFromPoint(content.left + content.width / 2, line)) {
|
|
|
+ const row = element instanceof HTMLElement ? element.closest<HTMLElement>('[data-chat-turn]') : null
|
|
|
+ const turn = Number(row?.dataset.chatTurn)
|
|
|
+ if (row !== null && list.contains(row) && Number.isSafeInteger(turn)) return turn
|
|
|
+ }
|
|
|
+ }
|
|
|
+ let found: number | null = null
|
|
|
+ for (const row of list.querySelectorAll<HTMLElement>('[data-chat-turn]')) {
|
|
|
+ if (row.getBoundingClientRect().top > line) break
|
|
|
+ const turn = Number(row.dataset.chatTurn)
|
|
|
+ if (Number.isSafeInteger(turn)) found = turn
|
|
|
+ }
|
|
|
+ return found
|
|
|
+}
|
|
|
+
|
|
|
/** Row position in scrollport coordinates (viewport-independent). */
|
|
|
function flowTop(row: HTMLElement, scrollport: HTMLElement): number {
|
|
|
return row.getBoundingClientRect().top - scrollport.getBoundingClientRect().top
|
|
|
@@ -150,6 +178,10 @@ export function ChatView({
|
|
|
}: ChatViewSlotProps) {
|
|
|
const order = useChat(s => s.order)
|
|
|
const nodeStore = useChat(s => s.nodes)
|
|
|
+ // The rail's items are accumulated in the Chat snapshot, so this selector is
|
|
|
+ // both the data and its change signal: the array identity moves only when a
|
|
|
+ // Turn enters, leaves, or changes its preview.
|
|
|
+ const turnNavigationItems = useChat(s => s.navigation.items())
|
|
|
const timeline = useChat(s => s.timeline)
|
|
|
const inbox = useSession(s => s.queue)
|
|
|
// Workspace root off the session list row: path summaries display relative to it.
|
|
|
@@ -212,6 +244,9 @@ export function ChatView({
|
|
|
const columnRef = useRef<HTMLDivElement | null>(null)
|
|
|
const atBottomRef = useRef(true)
|
|
|
const [atBottom, setAtBottom] = useState(true)
|
|
|
+ const [activeTurn, setActiveTurn] = useState<number | null>(
|
|
|
+ () => turnNavigationItems.at(-1)?.turn ?? null,
|
|
|
+ )
|
|
|
/** Last position delivered or written on the main thread. */
|
|
|
const observedTopRef = useRef(0)
|
|
|
/** Paging anchor: semantic row/position at click, updated by reader scrolls
|
|
|
@@ -233,6 +268,58 @@ export function ChatView({
|
|
|
const lastSteeringId = pendingSteering[pendingSteering.length - 1]?.id ?? null
|
|
|
const followSig = `${openState}:${firstSeq}:${lastKey}:${order.length}:${running ? 1 : 0}:${lastSteeringId ?? ''}`
|
|
|
|
|
|
+ const syncActiveTurn = useCallback((): void => {
|
|
|
+ const local = listRef.current
|
|
|
+ const first = turnNavigationItems[0]
|
|
|
+ if (local === null || first === undefined) {
|
|
|
+ setActiveTurn(null)
|
|
|
+ return
|
|
|
+ }
|
|
|
+ const el = scrollerOf(local)
|
|
|
+ const readingLine = el.getBoundingClientRect().top + Math.min(96, el.clientHeight * 0.2)
|
|
|
+ const reading = turnAtLine(local, readingLine)
|
|
|
+ // No row reaches the line yet: the flow head still owns the mark. Otherwise
|
|
|
+ // the row's Turn may be one the rail does not offer (all its nodes hidden),
|
|
|
+ // so the newest offered Turn at or above it owns the mark.
|
|
|
+ let next = first.turn
|
|
|
+ if (reading !== null) {
|
|
|
+ for (const item of turnNavigationItems) {
|
|
|
+ if (item.turn > reading) break
|
|
|
+ next = item.turn
|
|
|
+ }
|
|
|
+ }
|
|
|
+ if (el.scrollHeight - el.scrollTop - el.clientHeight <= FOLLOW_THRESHOLD + 1) {
|
|
|
+ next = turnNavigationItems.at(-1)?.turn ?? next
|
|
|
+ }
|
|
|
+ setActiveTurn(current => current === next ? current : next)
|
|
|
+ }, [turnNavigationItems])
|
|
|
+
|
|
|
+ const activeTurnRef = useRef<(() => void) | null>(null)
|
|
|
+ const activeFrameRef = useRef<number | null>(null)
|
|
|
+ const scheduleActiveTurn = useCallback((): void => {
|
|
|
+ if (activeFrameRef.current !== null) return
|
|
|
+ if (typeof requestAnimationFrame === 'undefined') {
|
|
|
+ syncActiveTurn()
|
|
|
+ return
|
|
|
+ }
|
|
|
+ activeFrameRef.current = requestAnimationFrame(() => {
|
|
|
+ activeFrameRef.current = null
|
|
|
+ syncActiveTurn()
|
|
|
+ })
|
|
|
+ }, [syncActiveTurn])
|
|
|
+
|
|
|
+ useEffect(() => () => {
|
|
|
+ if (activeFrameRef.current !== null && typeof cancelAnimationFrame !== 'undefined') {
|
|
|
+ cancelAnimationFrame(activeFrameRef.current)
|
|
|
+ }
|
|
|
+ }, [])
|
|
|
+
|
|
|
+ activeTurnRef.current = scheduleActiveTurn
|
|
|
+
|
|
|
+ useLayoutEffect(() => {
|
|
|
+ scheduleActiveTurn()
|
|
|
+ }, [scheduleActiveTurn])
|
|
|
+
|
|
|
const toBottom = (el: HTMLElement): void => {
|
|
|
anchorRef.current = null
|
|
|
el.scrollTop = el.scrollHeight
|
|
|
@@ -240,6 +327,7 @@ export function ChatView({
|
|
|
atBottomRef.current = true
|
|
|
setAtBottom(true)
|
|
|
chatScroll.save(null)
|
|
|
+ setActiveTurn(turnNavigationItems.at(-1)?.turn ?? null)
|
|
|
}
|
|
|
|
|
|
useLayoutEffect(() => {
|
|
|
@@ -338,6 +426,7 @@ export function ChatView({
|
|
|
if (isAtBottom) chatScroll.save(null)
|
|
|
else if (position !== null) chatScroll.save(position)
|
|
|
observedTopRef.current = el.scrollTop
|
|
|
+ scheduleActiveTurn()
|
|
|
}
|
|
|
|
|
|
// Bind the scroll listener on the resolved scrollport once per mount;
|
|
|
@@ -376,7 +465,12 @@ export function ChatView({
|
|
|
if (column === null || local === null || typeof ResizeObserver === 'undefined') return
|
|
|
const scrollport = scrollerOf(local)
|
|
|
const composer = scrollport.querySelector<HTMLElement>('[data-composer-seat]')
|
|
|
- const observer = new ResizeObserver(() => { followRef.current?.() })
|
|
|
+ // Flow-height changes (image loads, tool disclosures) move rows across the
|
|
|
+ // reading line without a scroll event, so the active mark resyncs here too.
|
|
|
+ const observer = new ResizeObserver(() => {
|
|
|
+ followRef.current?.()
|
|
|
+ activeTurnRef.current?.()
|
|
|
+ })
|
|
|
observer.observe(column)
|
|
|
if (composer !== null) observer.observe(composer)
|
|
|
return () => { observer.disconnect() }
|
|
|
@@ -404,9 +498,38 @@ export function ChatView({
|
|
|
loadOlder()
|
|
|
}
|
|
|
|
|
|
+ const navigateToTurn = (item: TurnNavigationItem): void => {
|
|
|
+ const local = listRef.current
|
|
|
+ if (local === null) return
|
|
|
+ const row = anchorElement(local, item.anchorKey)
|
|
|
+ if (row === null) return
|
|
|
+ const el = scrollerOf(local)
|
|
|
+ el.scrollTop += flowTop(row, el) - 24
|
|
|
+ observedTopRef.current = el.scrollTop
|
|
|
+ // A pending older page still has to compensate the prepended height, so
|
|
|
+ // navigation moves that anchor to the new position instead of dropping it.
|
|
|
+ const landed = loadingOlder ? pagingAnchor(local, el) : null
|
|
|
+ anchorRef.current = landed === null || landed.dataset.chatAnchorKey === undefined
|
|
|
+ ? null
|
|
|
+ : { key: landed.dataset.chatAnchorKey, top: flowTop(landed, el) }
|
|
|
+ const isAtBottom = el.scrollHeight - el.scrollTop - el.clientHeight <= FOLLOW_THRESHOLD + 1
|
|
|
+ atBottomRef.current = isAtBottom
|
|
|
+ setAtBottom(isAtBottom)
|
|
|
+ setActiveTurn(item.turn)
|
|
|
+ const position = isAtBottom ? null : scrollPosition(local, el)
|
|
|
+ if (isAtBottom) chatScroll.save(null)
|
|
|
+ else if (position !== null) chatScroll.save(position)
|
|
|
+ }
|
|
|
+
|
|
|
return (
|
|
|
<div className={css.root}>
|
|
|
<div ref={listRef} className={css.scroll}>
|
|
|
+ <TurnNavigator
|
|
|
+ items={turnNavigationItems}
|
|
|
+ activeTurn={activeTurn}
|
|
|
+ onNavigate={navigateToTurn}
|
|
|
+ t={t}
|
|
|
+ />
|
|
|
<div ref={columnRef} className={css.column} data-chat-flow="">
|
|
|
{openState === 'loading' && <div className={css.hint}>{t('chat.loadingHistory')}</div>}
|
|
|
{openState === 'error' && openError !== null && (
|