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

View File

@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/client/ui-conversation/README.md
README.md: 3c15fe73c242ee63b9bbc93b602653b0f5f14af6
README.zh.md: 61718fe8bdd73054057f7ce0ad6ba7ae8ce308a1
README.md: 1dbaaf7cabcd172ab37aef82ba9ca6a2c7a70d01
README.zh.md: f21a5819057af2f899dbdf039df35544627153d0

View File

@@ -38,7 +38,7 @@ None; this package neither assembles nor sends a provider request.
- **Stats-line durations cover the in-window flow only** — LLM and tool wall times fold the snapshot's assistant `timing` and tool call/result pairs, so nodes outside the loaded event window (older history) are not counted.
- **Details panel is the minimal form and currently has no entry point** — selected call args/result raw display; the Input/Output/Metadata switch, Prev/Next stepping, and See-in-trajectory deep link are deferred. Tool rows stopped being details-panel click targets and nothing replaced that gesture, so `ChatViewInjected.openDetails` is implemented but uncalled and the panel (including its terminal card) is unreachable in the assembled application; its rendering stays covered by mounting it with a selection directly.
- **Assistant per-message paging is a reserved slot** — drawn in the design, not implemented. The finalized content IconActions row (copy / branch / clock) ships under the last content-text assistant of each turn only; mid-turn narration and Think-only nodes stay chrome-free; branch remains a chrome stub.
- **Assistant per-message paging is a reserved slot** — drawn in the design, not implemented. The finalized content IconActions row (copy / branch / clock) ships under the last content-text assistant of each settled turn only; mid-turn narration, an active turn's content, and Think-only nodes stay chrome-free; branch remains a chrome stub.
- **The sparkle icon for the others tool row is a hand-drawn approximation** — the design glyph's vector geometry is not exportable locally; promotion into ui-primitives waits on an exact export.
- **The approval panel's "Always allow this type" is deferred** — durable grants need a grant-storage design; only allow-once/reject answer today.
- **TodoPanel truncates long item text to one ellipsized line** — the figma strip has no wrap or expand affordance; full text is not readable inline.

View File

@@ -38,7 +38,7 @@ todo 两个面就是在该形状上的两个注册项,都是普通注册方插
- **统计行的耗时只覆盖窗口内消息流**LLM 与工具墙钟时间由快照的 assistant `timing` 与工具 call/result 配对折算,落在已加载事件窗口之外的节点(更早的历史)不计入。
- **详情面板是最小形态,且当前没有入口**以原始形式显示已选择调用的参数结果Input/Output/Metadata 切换、Prev/Next 步进与 See-in-trajectory 深链接暂缓实现。工具行已不再是详情面板的点击目标,且没有任何手势接替它,因此 `ChatViewInjected.openDetails` 虽已实现却无人调用,该面板(含其终端卡片)在组装后的应用中不可达;其渲染仍由直接以选中态挂载它来覆盖。
- **assistant 逐消息分页是预留 slot**:设计中已有图稿,尚未实现。已定稿的内容 IconActions 行(复制/分支/时钟)只挂在每个 turn 最后一条带 text 的 assistant 消息下turn 中间叙述与纯 Think 节点不带 chrome分支仍是 chrome stub。
- **assistant 逐消息分页是预留 slot**:设计中已有图稿,尚未实现。已定稿的内容 IconActions 行(复制/分支/时钟)只挂在每个已结束 turn 最后一条带 text 的 assistant 消息下turn 中间叙述、活跃 turn 的内容与纯 Think 节点不带 chrome分支仍是 chrome stub。
- **others 工具行的闪光图标是手绘近似版本**:无法在本地导出设计字形的矢量几何;等到存在精确导出后再将其提升到 ui-primitives。
- **审批面板的「始终允许此类」暂缓**:持久授权需要授权存储设计;今天只能回答允许一次/拒绝。
- **TodoPanel 将过长条目截成单行省略号**figma 条没有换行或展开入口,完整文本无法在行内读完。

View File

@@ -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)

View File

@@ -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())

View File

@@ -16,7 +16,9 @@ import { RpcId } from '@deepseek-ai/dsh-client-connection/client'
import type { ChatViewSlotProps, SelectionTarget } from '@deepseek-ai/dsh-client-ui-conversation/client'
import { createChatStore } from '../src/client/stores.ts'
import { ChatView } from '../src/client/chat/ChatView.tsx'
import { assistantActionsSeqs, deriveChatFlow, flowKeys } from '../src/client/chat/chat-flow.ts'
import {
assistantActionsSeqs, deriveChatFlow, flowKeys, withholdActionsTurn,
} from '../src/client/chat/chat-flow.ts'
afterEach(cleanup)
// Keyless create() persists under the bare declared key; clear between cases
@@ -155,12 +157,12 @@ describe('chat-flow derivation', () => {
expect(flowKeys(deriveChatFlow([toolResult(3, 'a'), assistant(4, 'found'), toolResult(5, 'b')]))).toBe('g3|n4|g5')
})
it('assistantActionsSeqs keeps only the last content assistant per turn', () => {
it('assistantActionsSeqs keeps only the last content assistant per settled turn', () => {
const thinkOnly: AssistantMessageNode = {
kind: 'assistant', seq: 3, time: 3_000, turn: 1, step: 2,
blocks: [{ kind: 'reasoning', text: 'planning' }],
}
const seqs = assistantActionsSeqs([
const nodes: ConversationNode[] = [
user(1, 'hi'),
assistant(2, 'looking', 1),
thinkOnly,
@@ -168,8 +170,18 @@ describe('chat-flow derivation', () => {
assistant(5, 'done', 1),
user(6, 'again'),
assistant(7, 'second turn', 2),
])
expect([...seqs].sort((a, b) => a - b)).toEqual([5, 7])
]
expect([...assistantActionsSeqs(nodes)].sort((a, b) => a - b)).toEqual([5, 7])
// While turn 1 is still running, its tip content must not own the seat.
expect([...assistantActionsSeqs(nodes, 1)].sort((a, b) => a - b)).toEqual([7])
})
it('withholdActionsTurn follows partial, then runningCalls, and ignores a bare running bit', () => {
expect(withholdActionsTurn(false, { turn: 2 }, [{ turn: 2 }])).toBeNull()
expect(withholdActionsTurn(true, { turn: 3 }, [{ turn: 2 }])).toBe(3)
expect(withholdActionsTurn(true, null, [{ turn: 2 }])).toBe(2)
// Turn accepted but no step output yet: do not strip a prior settled seat.
expect(withholdActionsTurn(true, null, [])).toBeNull()
})
})
@@ -211,7 +223,7 @@ describe('ChatView', () => {
expect(view.getByText('run a')).toBeTruthy()
})
it('shows assistant IconActions only on the last content message of each turn', () => {
it('shows assistant IconActions only on the last content message of each settled turn', () => {
const h = makeHarness({
nodes: [
user(1, 'hi'),
@@ -228,6 +240,24 @@ describe('ChatView', () => {
expect(view.getAllByRole('button', { name: '在新对话中分支' })).toHaveLength(4)
})
it('withholds IconActions for a running turn while tools are in flight', () => {
const h = makeHarness({
running: true,
runningCalls: [{ ...runningCall('a'), turn: 2 }],
nodes: [
user(1, 'first'),
assistant(2, 'previous answer', 1),
user(3, 'second'),
assistant(4, 'mid-turn text', 2),
],
})
const view = render(<h.ChatView {...h.props} />)
// 2 user + 1 settled turn-tail; running turn's mid-turn text stays chrome-free.
expect(view.getAllByRole('button', { name: '复制' })).toHaveLength(3)
expect(view.getByText('mid-turn text')).toBeTruthy()
expect(view.getByText('previous answer')).toBeTruthy()
})
it('renders assistant Markdown across history, streaming, final, and interrupted states while user text stays literal', () => {
const markdown = '# Rendered\n\n- **one**\n- `two`'
const h = makeHarness({ nodes: [user(1, markdown), assistant(2, markdown)] })