This commit is contained in:
07akioni
2026-07-30 19:35:38 +08:00
parent 20db6ca4d5
commit 4bf09d508c
12 changed files with 146 additions and 40 deletions
@@ -30,7 +30,9 @@ import type {
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 { assistantActionsSeqs, deriveChatFlow, type ChatFlowItem } from './chat-flow.ts'
import {
assistantActionsSeqs, deriveChatFlow, withholdActionsTurn, type ChatFlowItem,
} from './chat-flow.ts'
import { AssistantMarkdown } from './AssistantMarkdown.tsx'
import { GenericCommandCard } from './GenericCommandCard.tsx'
import { GenericToolCard } from './GenericToolCard.tsx'
@@ -236,6 +238,9 @@ export function ChatView({ useSession, useSessions, useStore, renderSlot, sessio
const cwd = useSessions(s => s.byId[sessionId]?.cwd)
const running = useSession(s => s.running)
const runningCalls = useSession(s => s.runningCalls)
// Primitive turn (or null): stable across chunk storms so this parent does
// not re-render per token the way a partial.blocks subscribe would.
const withholdTurn = useSession(s => withholdActionsTurn(s.running, s.partial, 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}`)
@@ -244,9 +249,12 @@ export function ChatView({ useSession, useSessions, useStore, renderSlot, sessio
const selectedCallId = useStore(s => s.selection?.callId)
const items = useMemo(() => deriveChatFlow(nodes), [nodes])
// Only the last content assistant of each turn owns IconActions; mid-turn
// text (before tools) omits `time` so AssistantMarkdown stays chrome-free.
const actionSeqs = useMemo(() => assistantActionsSeqs(nodes), [nodes])
// Settled turn-tail content only; a running turn withholds its whole seat so
// mid-turn narration does not flash copy/branch/clock while tools run.
const actionSeqs = useMemo(
() => assistantActionsSeqs(nodes, withholdTurn),
[nodes, withholdTurn],
)
const listRef = useRef<HTMLDivElement | null>(null)
const atBottomRef = useRef(true)
@@ -31,16 +31,43 @@ function rendersNothing(node: ConversationNode): boolean {
|| ((b.kind === 'text' || b.kind === 'reasoning') && b.text.trim() === ''))
}
/**
* Turn whose content assistants must stay chrome-free while the turn is still
* running. Prefers the streaming partial, else the first in-flight tool call;
* returns null when `running` is false or neither signal exists yet (a brand-new
* turn before the first step must not strip a prior settled answer's seat).
* @param running - snapshot `running` bit.
* @param partial - in-flight assistant partial, or null.
* @param runningCalls - in-flight tool rows (same turn while tools execute).
* @returns Turn to withhold, or null.
*/
export function withholdActionsTurn(
running: boolean,
partial: { turn: number } | null,
runningCalls: readonly { turn: number }[],
): number | null {
if (!running) return null
if (partial !== null) return partial.turn
return runningCalls[0]?.turn ?? null
}
/**
* Seq set of assistants that own IconActions: the last content-text assistant
* in each turn. Mid-turn narration (text before tools) stays chrome-free.
* in each *settled* turn. Mid-turn narration and every content assistant of a
* still-running turn stay chrome-free (no flash while tools run or the next
* step streams).
* @param nodes - snapshot nodes (surface order).
* @param withholdTurn - active turn from {@link withholdActionsTurn}, or null.
* @returns Seq values ChatView may pass as `time` into AssistantMarkdown.
*/
export function assistantActionsSeqs(nodes: readonly ConversationNode[]): ReadonlySet<number> {
export function assistantActionsSeqs(
nodes: readonly ConversationNode[],
withholdTurn: number | null = null,
): ReadonlySet<number> {
const lastByTurn = new Map<number, number>()
for (const node of nodes) {
if (node.kind !== 'assistant' || !hasContentText(node.blocks)) continue
if (withholdTurn !== null && node.turn === withholdTurn) continue
lastByTurn.set(node.turn, node.seq)
}
return new Set(lastByTurn.values())