# Conflicts: # examples/web-cordis/cordis.yml # packages/client/ui-conversation/src/client/apply.ts # packages/client/ui-conversation/src/client/chat/ChatView.tsx # packages/client/ui-conversation/src/client/chat/MessageItem.tsx # packages/client/ui-conversation/src/client/chat/ToolRow.module.css # packages/client/ui-conversation/src/client/chat/ToolRow.tsx # packages/client/ui-conversation/src/client/contract/slots.ts # packages/client/ui-conversation/tests/chat-branch-tails.spec.tsx # packages/client/ui-conversation/tests/chat-view.spec.tsx
479 lines
19 KiB
TypeScript
479 lines
19 KiB
TypeScript
// ChatView: the default conversation view — message flow with user bubbles,
|
||
// assistant narration, tool summary rows grouped into step runs, pending
|
||
// cards, paging, and bottom-follow. Session stats live on
|
||
// 'conversation.composer.dock' (sticky with the composer). Pure component
|
||
// registered directly; its registration declares the keyed
|
||
// 'conversation.chat.toolview' hole, so tool rows render through the props
|
||
// renderSlot share (entryKey = tool name, GenericToolCard as the render-site
|
||
// fallback).
|
||
//
|
||
// Scroll: when nested under `[data-conversation-scroll]` (active conversation
|
||
// column), that host is the scrollport and this view is flow content; when
|
||
// mounted alone (unit tests), `.scroll` owns overflow. Bottom-follow and
|
||
// prepend anchoring always target the resolved scrollport.
|
||
//
|
||
// Render economics (architecture RFC performance model): the list parent
|
||
// subscribes to snapshot segments that do NOT change per streaming chunk
|
||
// (nodes/runningCalls/pending keep their references across chunk batches), so
|
||
// during a token storm only StreamingTail re-renders; history rows hold via
|
||
// memo on cache-stable node slices. Selection changes re-render the parent
|
||
// map but only rows whose own selected bit flipped. renderSlot is
|
||
// entry-identity-stable (framework binding cache), so passing it through
|
||
// memoized rows never churns them.
|
||
|
||
import {
|
||
memo, useEffect, useLayoutEffect, useMemo, useRef, useState, type ReactNode,
|
||
} from 'react'
|
||
import type {
|
||
CodeSubCall, CommandNode, ConversationNode, ConversationSnapshot, RunningToolCall, ToolResultNode,
|
||
} from '@deepseek-ai/dsh-client-runtime/client'
|
||
import type { SnapshotSelectorHook } from '@deepseek-ai/dsh-client-ui-slots'
|
||
import { IconChevronDownOutline14 } from '@deepseek-ai/dsh-client-ui-primitives'
|
||
import type { ChatViewSlotProps } from '../contract/slots.ts'
|
||
import { deriveChatFlow, type ChatFlowItem } from './chat-flow.ts'
|
||
import { AssistantMarkdown } from './AssistantMarkdown.tsx'
|
||
import { GenericCommandCard } from './GenericCommandCard.tsx'
|
||
import { GenericToolCard } from './GenericToolCard.tsx'
|
||
import { MessageItem } from './MessageItem.tsx'
|
||
import css from './ChatView.module.css'
|
||
|
||
const FOLLOW_THRESHOLD = 24
|
||
|
||
/** Active column host when present; otherwise the view-local scroller. */
|
||
function scrollerOf(from: HTMLElement): HTMLElement {
|
||
return (from.closest('[data-conversation-scroll]')) ?? from
|
||
}
|
||
|
||
type OpenFile = (path: string) => void
|
||
|
||
type InspectCall = (callId: string) => void
|
||
|
||
/** The declared toolview hole's render share (stable framework binding, passed through memoized rows). */
|
||
type RenderToolRow = ChatViewSlotProps['renderSlot']
|
||
|
||
/** ui-slots' UseSession is deliberately wide (dependency direction); the
|
||
* chat view narrows once to the runtime snapshot the binding actually feeds. */
|
||
type UseConversation = SnapshotSelectorHook<ConversationSnapshot>
|
||
|
||
/** One `run_code` sub-dispatch row: the identical keyed-slot dispatch as a
|
||
* top-level call (same registrations, same fallback), nested by the parent.
|
||
* A started-but-unsettled sub-call arrives as the RunningToolCall shape and
|
||
* renders the running state exactly as a native in-flight row. */
|
||
const SubCallRow = memo(function SubCallRow({ renderSlot, node, openFile, selected, cwd, inspectCall }: {
|
||
renderSlot: RenderToolRow
|
||
node: CodeSubCall
|
||
openFile: OpenFile
|
||
selected: boolean
|
||
cwd: string | undefined
|
||
inspectCall: InspectCall
|
||
}) {
|
||
const settled = 'kind' in node
|
||
const toolName = settled ? node.call?.name ?? '' : node.name
|
||
const owner = useMemo(() => ({
|
||
callId: node.callId, toolName, block: node, openFile, cwd,
|
||
inspect: () => { inspectCall(node.callId) },
|
||
}), [node, toolName, openFile, cwd, inspectCall])
|
||
return (
|
||
<div className={css.callRow} data-selected={selected || undefined}>
|
||
{renderSlot('conversation.chat.toolview', owner, {
|
||
entryKey: toolName,
|
||
fallback: <GenericToolCard {...owner} />,
|
||
})}
|
||
</div>
|
||
)
|
||
})
|
||
|
||
/** One tool call row (result or running): dispatches through the keyed
|
||
* toolview slot with the owner payload; unregistered tools fall back to
|
||
* GenericToolCard at this render site. A `run_code` call additionally
|
||
* renders its logged sub-dispatches as always-visible indented rows —
|
||
* each one the same keyed-slot dispatch as a native top-level call. */
|
||
const CallRow = memo(function CallRow({
|
||
renderSlot, callId, toolName, block, openFile, selected, subCalls, selectedCallId, cwd, inspectCall,
|
||
}: {
|
||
renderSlot: RenderToolRow
|
||
callId: string
|
||
toolName: string
|
||
block: ToolResultNode | RunningToolCall
|
||
openFile: OpenFile
|
||
selected: boolean
|
||
/** `run_code` sub-dispatches in dispatch order (reference-stable per
|
||
* parent; running entries settle in place); undefined for ordinary calls. */
|
||
subCalls?: readonly CodeSubCall[] | undefined
|
||
/** The store's selected callId, matched against sub-rows (undefined when no sub-row here is selected). */
|
||
selectedCallId?: string | undefined
|
||
/** Session workspace root for path-relative summaries. */
|
||
cwd: string | undefined
|
||
inspectCall: InspectCall
|
||
}) {
|
||
const owner = useMemo(() => ({
|
||
callId, toolName, block, openFile, cwd,
|
||
inspect: () => { inspectCall(callId) },
|
||
}), [callId, toolName, block, openFile, cwd, inspectCall])
|
||
return (
|
||
<div className={css.callRow} data-selected={selected || undefined}>
|
||
{renderSlot('conversation.chat.toolview', owner, {
|
||
entryKey: toolName,
|
||
fallback: <GenericToolCard {...owner} />,
|
||
})}
|
||
{subCalls !== undefined && subCalls.length > 0 && (
|
||
<div className={css.subCalls} data-subcalls>
|
||
{subCalls.map(node => (
|
||
<SubCallRow
|
||
key={node.callId}
|
||
renderSlot={renderSlot}
|
||
node={node}
|
||
openFile={openFile}
|
||
selected={node.callId === selectedCallId}
|
||
cwd={cwd}
|
||
inspectCall={inspectCall}
|
||
/>
|
||
))}
|
||
</div>
|
||
)}
|
||
</div>
|
||
)
|
||
})
|
||
|
||
/** Consecutive tool results as one step-run group (uniform 16px rhythm). */
|
||
const ToolGroup = memo(function ToolGroup({ renderSlot, results, openFile, selectedCallId, codeDispatches, cwd, inspectCall }: {
|
||
renderSlot: RenderToolRow
|
||
results: readonly ToolResultNode[]
|
||
openFile: OpenFile
|
||
/** Only set when the selected call lives in THIS group, top-level or nested (memo economy). */
|
||
selectedCallId: string | undefined
|
||
/** Sub-dispatch index off the snapshot (map reference is chunk-storm stable). */
|
||
codeDispatches: ReadonlyMap<string, readonly CodeSubCall[]>
|
||
/** Session workspace root for path-relative summaries. */
|
||
cwd: string | undefined
|
||
inspectCall: InspectCall
|
||
}) {
|
||
return (
|
||
<div className={css.toolGroup}>
|
||
{results.map(node => (
|
||
<CallRow
|
||
key={node.callId}
|
||
renderSlot={renderSlot}
|
||
callId={node.callId}
|
||
toolName={node.call?.name ?? ''}
|
||
block={node}
|
||
openFile={openFile}
|
||
selected={node.callId === selectedCallId}
|
||
subCalls={codeDispatches.get(node.callId)}
|
||
selectedCallId={selectedCallId}
|
||
cwd={cwd}
|
||
inspectCall={inspectCall}
|
||
/>
|
||
))}
|
||
</div>
|
||
)
|
||
})
|
||
|
||
/** One command lifecycle row: keyed dispatch on the command name with the
|
||
* generic card as the render-site fallback (zero registration required). A
|
||
* run-less cross-window node has no name and always lands on the fallback. */
|
||
const CommandRow = memo(function CommandRow({ renderSlot, node }: {
|
||
renderSlot: RenderToolRow
|
||
node: CommandNode
|
||
}) {
|
||
const owner = useMemo(() => ({ node }), [node])
|
||
return (
|
||
<div className={css.callRow}>
|
||
{renderSlot('conversation.chat.commandview', owner, {
|
||
entryKey: node.name ?? '',
|
||
fallback: <GenericCommandCard {...owner} />,
|
||
})}
|
||
</div>
|
||
)
|
||
})
|
||
|
||
/** Turn loader: one row of four 2.5px pixels (half a notch above the StateDot
|
||
* 2px cell, same blue) chasing left to right with a stepped trail — flat
|
||
* keyframe holds, no tweening, no rotation. Phase offsets come from
|
||
* per-rect animation-delay. */
|
||
const LOADER_CELLS = [0, 5, 10, 15] as const
|
||
|
||
function TurnDots() {
|
||
return (
|
||
/* The wrapper is a 26px line box (message line height) so the loader
|
||
occupies one text line and centers the dots inside it. */
|
||
<div className={css.turnDots} aria-hidden="true">
|
||
<svg
|
||
width="17.5"
|
||
height="2.5"
|
||
viewBox="0 0 17.5 2.5"
|
||
shapeRendering="crispEdges"
|
||
>
|
||
{LOADER_CELLS.map((x, index) => (
|
||
<rect
|
||
key={x}
|
||
className={css.turnDotCell}
|
||
x={x}
|
||
y="0"
|
||
width="2.5"
|
||
height="2.5"
|
||
/* Negative delay phases the chase so every cell animates from mount. */
|
||
style={{ animationDelay: `${(index - LOADER_CELLS.length) * 250}ms` }}
|
||
/>
|
||
))}
|
||
</svg>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
/** The streaming partial, isolated so chunk batches re-render only this tail.
|
||
* onGrow lets the scroll owner follow content the parent never re-renders for. */
|
||
function StreamingTail({ useSession, onGrow }: {
|
||
useSession: UseConversation
|
||
onGrow: () => void
|
||
}) {
|
||
const partial = useSession(s => s.partial)
|
||
useLayoutEffect(() => {
|
||
onGrow()
|
||
})
|
||
if (partial === null) return null
|
||
return <AssistantMarkdown blocks={partial.blocks} streaming />
|
||
}
|
||
|
||
/**
|
||
* The chat view slot entry: pure component over the composed props (tool rows
|
||
* render through the declared keyed hole's renderSlot share).
|
||
*/
|
||
export function ChatView({
|
||
useSession, useSessions, useStore, renderSlot, sessionId, openFile, loadOlder, inspectCall, chatScroll, forkAt,
|
||
}: ChatViewSlotProps) {
|
||
const nodes = useSession(s => s.nodes)
|
||
// Workspace root off the session list row: path summaries display relative to it.
|
||
const cwd = useSessions(s => s.byId[sessionId]?.cwd)
|
||
const running = useSession(s => s.running)
|
||
const runningCalls = useSession(s => s.runningCalls)
|
||
const codeDispatches = useSession(s => s.codeDispatches)
|
||
const openState = useSession(s => s.openState)
|
||
const openErrorMessage = useSession(s => s.openError === null ? null : `${s.openError.message}(${s.openError.code})`)
|
||
const hasMore = useSession(s => s.hasMore)
|
||
const loadingOlder = useSession(s => s.loadingOlder)
|
||
const selectedCallId = useStore(s => s.selection?.callId)
|
||
|
||
const items = useMemo(() => deriveChatFlow(nodes), [nodes])
|
||
|
||
const listRef = useRef<HTMLDivElement | null>(null)
|
||
const atBottomRef = useRef(true)
|
||
const [atBottom, setAtBottom] = useState(true)
|
||
/** Paging anchor: height/position at click, compensated after the prepend lands. */
|
||
const anchorRef = useRef<{ h: number; t: number } | null>(null)
|
||
const firstSeqRef = useRef<number | null>(null)
|
||
const openedRef = useRef(false)
|
||
const lastKeyRef = useRef<string | null>(null)
|
||
/** Flow tip signature — follow-scroll only when this moves, never on a
|
||
* scroll-driven at-bottom chrome re-render (that was snapping inertial
|
||
* scrolls the rest of the way to the floor). */
|
||
const followSigRef = useRef<string | null>(null)
|
||
|
||
const firstSeq = nodes[0]?.seq ?? null
|
||
const lastItem = items[items.length - 1]
|
||
const lastKey = lastItem?.key ?? null
|
||
const followSig = `${openState}:${firstSeq}:${lastKey}:${nodes.length}:${running ? 1 : 0}:${runningCalls.length}`
|
||
|
||
const toBottom = (el: HTMLElement): void => {
|
||
el.scrollTop = el.scrollHeight
|
||
atBottomRef.current = true
|
||
setAtBottom(true)
|
||
}
|
||
|
||
useLayoutEffect(() => {
|
||
const local = listRef.current
|
||
/* v8 ignore next -- ref-null guard: React attaches the ref before layout effects run. */
|
||
if (local === null) return
|
||
const el = scrollerOf(local)
|
||
// Open completed: jump to the bottom once — unless a scroll position
|
||
// survives from a previous mount (view-tab switch away and back), which
|
||
// is restored instead of snapping the reader back to the floor.
|
||
if (openState === 'open' && !openedRef.current) {
|
||
openedRef.current = true
|
||
const saved = chatScroll.read()
|
||
if (saved === null) {
|
||
toBottom(el)
|
||
} else {
|
||
el.scrollTop = saved
|
||
const isAtBottom = el.scrollHeight - el.scrollTop - el.clientHeight <= FOLLOW_THRESHOLD + 1
|
||
atBottomRef.current = isAtBottom
|
||
setAtBottom(isAtBottom)
|
||
}
|
||
firstSeqRef.current = firstSeq
|
||
lastKeyRef.current = lastKey
|
||
followSigRef.current = followSig
|
||
return
|
||
}
|
||
// Prepend (head seq decreased): compensate by the height delta.
|
||
if (anchorRef.current !== null && firstSeq !== null && firstSeqRef.current !== null && firstSeq < firstSeqRef.current) {
|
||
el.scrollTop = anchorRef.current.t + (el.scrollHeight - anchorRef.current.h)
|
||
anchorRef.current = null
|
||
firstSeqRef.current = firstSeq
|
||
/* v8 ignore next -- ?? arm: a prepend adds nodes, so the flow list here is never empty. */
|
||
lastKeyRef.current = lastKey
|
||
followSigRef.current = followSig
|
||
return
|
||
}
|
||
firstSeqRef.current = firstSeq
|
||
// Own words must be visible: a new trailing user node force-scrolls
|
||
// (send lives in the composer, so arrival is detected here, not armed there).
|
||
const appendedUser = lastKey !== lastKeyRef.current
|
||
&& lastItem !== undefined && lastItem.kind === 'node' && lastItem.node.kind === 'user'
|
||
const tipMoved = followSigRef.current !== followSig
|
||
lastKeyRef.current = lastKey
|
||
followSigRef.current = followSig
|
||
// Follow new flow content while pinned; do NOT re-pin on every render
|
||
// merely because atBottomRef is true (scroll threshold → setState → snap).
|
||
if (appendedUser || (tipMoved && atBottomRef.current)) toBottom(el)
|
||
})
|
||
|
||
const onScrollRef = useRef(() => {})
|
||
onScrollRef.current = () => {
|
||
const local = listRef.current
|
||
/* v8 ignore next -- ref-null guard: the handler only fires while mounted. */
|
||
if (local === null) return
|
||
const el = scrollerOf(local)
|
||
const isAtBottom = el.scrollHeight - el.scrollTop - el.clientHeight <= FOLLOW_THRESHOLD + 1
|
||
atBottomRef.current = isAtBottom
|
||
setAtBottom(isAtBottom)
|
||
// Continuous save (unmount happens after ref detach, so saving there is
|
||
// too late); pinned-to-bottom clears so a remount keeps following.
|
||
chatScroll.save(isAtBottom ? null : el.scrollTop)
|
||
}
|
||
|
||
// Bind scroll to the resolved scrollport (host or local) once per mount.
|
||
useEffect(() => {
|
||
const local = listRef.current
|
||
/* v8 ignore next -- ref-null guard: effect runs after the list node commits. */
|
||
if (local === null) return
|
||
const el = scrollerOf(local)
|
||
const onScroll = (): void => { onScrollRef.current() }
|
||
el.addEventListener('scroll', onScroll, { passive: true })
|
||
return () => { el.removeEventListener('scroll', onScroll) }
|
||
}, [])
|
||
|
||
// Follow streaming growth the parent never re-renders for (stable ref).
|
||
// The ref starts null and is assigned every render, so the placeholder
|
||
// initializer a function initial value would need never exists.
|
||
const followRef = useRef<(() => void) | null>(null)
|
||
followRef.current = () => {
|
||
const local = listRef.current
|
||
if (local !== null && atBottomRef.current) {
|
||
const el = scrollerOf(local)
|
||
el.scrollTop = el.scrollHeight
|
||
}
|
||
}
|
||
const onGrow = useRef(() => followRef.current?.()).current
|
||
|
||
const loadOlderAnchored = (): void => {
|
||
const local = listRef.current
|
||
/* v8 ignore next -- ref-null guard: the paging button renders inside the list tree. */
|
||
if (local !== null) {
|
||
const el = scrollerOf(local)
|
||
anchorRef.current = { h: el.scrollHeight, t: el.scrollTop }
|
||
}
|
||
loadOlder()
|
||
}
|
||
|
||
const renderItem = (item: ChatFlowItem): ReactNode => {
|
||
if (item.kind === 'tool-group') {
|
||
const inGroup = selectedCallId !== undefined
|
||
&& item.results.some(r => r.callId === selectedCallId
|
||
|| codeDispatches.get(r.callId)?.some(sub => sub.callId === selectedCallId) === true)
|
||
return (
|
||
<ToolGroup
|
||
key={item.key}
|
||
renderSlot={renderSlot}
|
||
results={item.results}
|
||
openFile={openFile}
|
||
selectedCallId={inGroup ? selectedCallId : undefined}
|
||
codeDispatches={codeDispatches}
|
||
cwd={cwd}
|
||
inspectCall={inspectCall}
|
||
/>
|
||
)
|
||
}
|
||
const node: ConversationNode = item.node
|
||
if (node.kind === 'assistant') {
|
||
return (
|
||
<AssistantMarkdown
|
||
key={item.key}
|
||
blocks={node.blocks}
|
||
streaming={false}
|
||
interrupted={node.interrupted}
|
||
time={node.time}
|
||
seq={node.seq}
|
||
onFork={forkAt}
|
||
/>
|
||
)
|
||
}
|
||
if (node.kind === 'command') {
|
||
return <CommandRow key={item.key} renderSlot={renderSlot} node={node} />
|
||
}
|
||
/* v8 ignore next -- tool-result never reaches here: deriveChatFlow folds them into groups. */
|
||
if (node.kind === 'tool-result') return null
|
||
return <MessageItem key={item.key} node={node} onFork={forkAt} />
|
||
}
|
||
|
||
return (
|
||
<div className={css.root}>
|
||
<div ref={listRef} className={css.scroll}>
|
||
<div className={css.column}>
|
||
{openState === 'loading' && <div className={css.hint}>载入历史…</div>}
|
||
{openState === 'error' && <div className={css.openError}>历史加载失败:{openErrorMessage}</div>}
|
||
{hasMore && (
|
||
<div className={css.older}>
|
||
<button type="button" disabled={loadingOlder} onClick={loadOlderAnchored}>
|
||
{loadingOlder ? '加载中…' : '加载更早'}
|
||
</button>
|
||
</div>
|
||
)}
|
||
{items.map(renderItem)}
|
||
<StreamingTail useSession={useSession} onGrow={onGrow} />
|
||
{runningCalls.length > 0 && (
|
||
<div className={css.toolGroup}>
|
||
{runningCalls.map(call => (
|
||
<CallRow
|
||
key={call.callId}
|
||
renderSlot={renderSlot}
|
||
callId={call.callId}
|
||
toolName={call.name}
|
||
block={call}
|
||
openFile={openFile}
|
||
selected={call.callId === selectedCallId}
|
||
subCalls={codeDispatches.get(call.callId)}
|
||
selectedCallId={selectedCallId}
|
||
cwd={cwd}
|
||
inspectCall={inspectCall}
|
||
/>
|
||
))}
|
||
</div>
|
||
)}
|
||
{/* No pending placeholders: questions (ui-question) and approvals
|
||
(ApprovalPanel) both take over the composer, so a flow card would
|
||
double-render the same wait. */}
|
||
{/* Turn-level loading signal: rides the whole running turn (first-token
|
||
wait, tool execution, streaming) so it never flickers per step. */}
|
||
{running && <TurnDots />}
|
||
</div>
|
||
{!atBottom && (
|
||
<div className={css.toBottomSlot}>
|
||
<button
|
||
type="button"
|
||
className={css.toBottom}
|
||
aria-label="回到底部"
|
||
onClick={() => {
|
||
const local = listRef.current
|
||
/* v8 ignore next -- ref-null guard: the button only renders alongside the mounted list. */
|
||
if (local !== null) toBottom(scrollerOf(local))
|
||
}}
|
||
>
|
||
<IconChevronDownOutline14 />
|
||
</button>
|
||
</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
)
|
||
}
|