Kaynağa Gözat

fix(client): address remaining conversation review feedback

imccyu 1 ay önce
ebeveyn
işleme
e11f630fcd

+ 2 - 2
.agents/notes/implemented/architecture/2026-08-09-client-conversation-node-assembly.i18n.yaml

@@ -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 .agents/notes/implemented/architecture/2026-08-09-client-conversation-node-assembly.md
-2026-08-09-client-conversation-node-assembly.md: 9952f8fac0f2a3b0fe72cc97ae9a8fb4f96ace08
-2026-08-09-client-conversation-node-assembly.zh.md: f27a82459cdacdb4a2da27cf12b30c93f2dfdf64
+2026-08-09-client-conversation-node-assembly.md: 16a39539064644e5467f701789a7e2ef1f7ff172
+2026-08-09-client-conversation-node-assembly.zh.md: 0e0fbdf8f3320393022528e6e3fe2cf0d492a1d3

+ 2 - 0
.agents/notes/implemented/architecture/2026-08-09-client-conversation-node-assembly.md

@@ -394,6 +394,8 @@ History-path tests cover complete replace, non-overlapping prepend, overlapping-
 
 A new business node can register its matcher, State transitions, optional Location data, final target Node, and renderer locally without changing Session's business switch. `ChatNodeDataMap` and the Location data maps let a business package merge strongly typed data into the contract; every related Event must still expose a stable ID derivable from that Event alone.
 
+Host business packages declaration-merge their durable Event members into `@deepseek-ai/dsh-session/types`, while Client Definitions type-only import the corresponding business package `/types` subpaths. Augmenting the declaring interface rather than a re-export barrel gives the independent Host and Client TypeScript programs the same Event narrowing without pulling Host runtime into the Client graph.
+
 Initial tail, older prepend, and live append share one set of Context invariants. Missing starts, Reader window gaps, unknown Locations, and high-frequency deltas are explicit engine states and require no direction-specific business cache.
 
 Append does not scan historical Contexts; prepend replays only Contexts whose Matches, Locations, or Reader answers actually changed. A structural Chat change may still recompute visible order and indexes, but does not rerun unrelated business folds or replace unchanged Node identity.

+ 2 - 0
.agents/notes/implemented/architecture/2026-08-09-client-conversation-node-assembly.zh.md

@@ -394,6 +394,8 @@ Assembled Web snapshot、GUI 和浏览器场景覆盖真实 plugin graph。浏
 
 新增业务节点可以局部注册自己的 matcher、State 转换、可选 Location data、最终 target Node 和 renderer,不再修改 Session 的业务 switch。`ChatNodeDataMap` 和 Location data maps 允许业务 package 通过 declaration merging 合入强类型 data;所有相关 Event 仍须暴露可单 Event 推导的稳定 ID。
 
+Host 业务 package 把自己的持久 Event 成员 declaration-merge 到 `@deepseek-ai/dsh-session/types`,Client Definition 则通过对应业务 package 的 `/types` 子路径进行 type-only import。增强实际声明接口而不是重导出 barrel,使 Host 和 Client 的独立 TypeScript Program 都能获得相同的 Event narrowing,同时不把 Host runtime 带入 Client 图。
+
 初始尾页、older prepend 和 live append 共享一套 Context 不变量。缺 start、Reader window gap、Location unknown 以及高频 delta 都是引擎明确表达的状态,不需要业务另建方向相关 cache。
 
 Append 不扫描历史 Context;prepend 只 replay Match、Location 或 Reader 答案真正受影响的 Context。Chat 结构变化仍可能重算 visible order 和索引,但不会重跑无关业务 fold 或替换未变化 Node identity。

+ 2 - 2
docs/subsystems/compaction.i18n.yaml

@@ -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 docs/subsystems/compaction.md
-compaction.md: 1aca48d677a83a488d9590edad3f8a68ca662b70
-compaction.zh.md: e2cfabc0891126d9f2165e47dffcf485f4a8a339
+compaction.md: 11ee6eae797db1c57bf2ffbb90f1f37f47b8400c
+compaction.zh.md: a560e47dbb7aa92f0757179534803bbcaaebc6ba

+ 1 - 1
docs/subsystems/compaction.md

@@ -20,7 +20,7 @@ The lock brackets the **whole** operation: `compact/start` is appended first, th
 
 The markers are lock time points, not an exclusive container. An unrelated idle injection can appear between a standalone manual start and end while summarization is pending. The manual path revalidates only its selected positional span, so that injected context survives after the replacement checkpoint. A live unmatched start blocks every entry point; an unmatched start before a newer `session/end-seed` is stale evidence from a prior lifecycle and is ignored.
 
-These variants are merged inside a `declare module '@deepseek-ai/dsh-session'` block, so — unlike the top-level types on the other subsystem pages — they are not pasted as a drift-checked ` ```ts type-equiv ` block (the `verify-type-equiv` extractor matches only top-level declarations by name). The payload table above is the catalog entry; follow the source link for the authoritative shapes.
+These variants are merged inside a `declare module '@deepseek-ai/dsh-session/types'` block, so — unlike the top-level types on the other subsystem pages — they are not pasted as a drift-checked ` ```ts type-equiv ` block (the `verify-type-equiv` extractor matches only top-level declarations by name). The payload table above is the catalog entry; follow the source link for the authoritative shapes.
 
 ## `CompactionResult`
 

+ 1 - 1
docs/subsystems/compaction.zh.md

@@ -20,7 +20,7 @@
 
 这些标记表示锁的时间点,而不是排他的容器。摘要等待期间,不相关的空闲注入可以出现在独立的手动 start 与 end 之间。手动路径只重新验证所选位置 span,因此替换检查点之后仍保留该注入上下文。活动的未匹配 start 会阻塞所有入口点;较新 `session/end-seed` 之前的未匹配 start 是先前生命周期留下的陈旧证据,会被忽略。
 
-这些变体在 `declare module '@deepseek-ai/dsh-session'` 块内合并,因此——与其他子系统页面上的顶层类型不同——它们不以漂移检查的 ` ```ts type-equiv ` 块粘贴(`verify-type-equiv` 提取器只按名称匹配顶层声明)。上方的载荷表即为目录条目;权威形状请循源码链接查看。
+这些变体在 `declare module '@deepseek-ai/dsh-session/types'` 块内合并,因此——与其他子系统页面上的顶层类型不同——它们不以漂移检查的 ` ```ts type-equiv ` 块粘贴(`verify-type-equiv` 提取器只按名称匹配顶层声明)。上方的载荷表即为目录条目;权威形状请循源码链接查看。
 
 ## `CompactionResult`
 

+ 4 - 2
packages/client/ui-conversation/src/client/chat/TurnTailNodeView.tsx

@@ -9,9 +9,11 @@ type TurnTailNodeViewProps = ChatNodeViewProps<'turn-tail'> & PropsRenderSlots<'
 
 /** Turn-local actions and feature tail over the Location index, independent of Assistant placement. */
 export const TurnTailNodeView = memo(function TurnTailNodeView({
-  node, openFile, forkAt, renderSlotChain, t,
+  node, openFile, forkAt, renderSlotChain, t, useSession,
 }: TurnTailNodeViewProps) {
   const data = node.data
+  const hasLaterChatNode = useSession(snapshot =>
+    snapshot.chat.locations.getTurn(data.turn).at(-1) !== node.key)
   const turn = node.location.kind === 'turn' || node.location.kind === 'step'
     ? node.location.turn
     : undefined
@@ -34,7 +36,7 @@ export const TurnTailNodeView = memo(function TurnTailNodeView({
         tokensPerSecond={data.tokensPerSecond}
         clock="end"
         onBranch={() => { forkAt(closing.finalNode.seq) }}
-        branchUnavailable={data.branchUnavailable}
+        branchUnavailable={data.branchUnavailable || hasLaterChatNode}
         className={css.actions}
         t={t}
       />

+ 1 - 1
packages/client/ui-conversation/src/client/contract/chat-nodes.ts

@@ -57,7 +57,7 @@ export interface TurnTailChatData {
   readonly time: number
   /** Last finalized content-bearing Assistant in this Turn. */
   readonly closing: FinalAssistantChatData | null
-  /** Whether later Assistant/Step material makes the closing seq non-tail. */
+  /** Whether non-rendered later evidence makes the closing seq non-tail. */
   readonly branchUnavailable: boolean
   readonly ttftMs?: number
   readonly tokensPerSecond?: number

+ 28 - 8
packages/client/ui-conversation/tests/chat-view.spec.tsx

@@ -181,14 +181,12 @@ function makeHarness(init?: Partial<ConversationSnapshot>) {
     if (key !== 'conversation.chat.node') return opts?.fallback ?? null
     const nodeOwner = owner as RoutedChatNodeOwner
     const nodeKey = opts?.hookContext as string | undefined
-    const useTurnData = ((dataKey: string) => {
-      return props.useSession((snapshot) => {
-        const location = nodeKey === undefined ? undefined : snapshot.chat.nodes.get(nodeKey)?.location
-        return location?.kind === 'turn' || location?.kind === 'step'
-          ? location.turn.data.get(dataKey as never)
-          : undefined
-      })
-    }) as UseChatNodeTurnData
+    const useTurnData: UseChatNodeTurnData = dataKey => props.useSession((snapshot) => {
+      const location = nodeKey === undefined ? undefined : snapshot.chat.nodes.get(nodeKey)?.location
+      return location?.kind === 'turn' || location?.kind === 'step'
+        ? location.turn.data.get(dataKey)
+        : undefined
+    })
     const nodeProps = <Kind extends ChatNode['kind']>(): ChatNodeViewProps<Kind> => (
       { ...props, ...nodeOwner, useTurnData } as unknown as ChatNodeViewProps<Kind>
     )
@@ -722,6 +720,28 @@ describe('ChatView', () => {
     expect(h.forkAt.mock.calls).toEqual([[2]])
   })
 
+  it('disables fork when the indexed Turn has a later steering Node', () => {
+    const base = chatSnapshotFixture({
+      nodes: [user(1, 'question'), assistant(2, 'answer')],
+      turnEnds: new Map([[1, 4]]),
+    })
+    const chat = {
+      ...base,
+      locations: {
+        getTurn: (turn: number) => turn === 1
+          ? [...base.locations.getTurn(turn), 'fixture:steering:later']
+          : base.locations.getTurn(turn),
+        getStep: (turn: number, step: number) => base.locations.getStep(turn, step),
+      },
+    }
+    const h = makeHarness({ chat })
+    const view = render(<h.ChatView {...h.props} />)
+    const branch = view.getByRole('button', { name: '在新对话中分支' })
+    expect(branch.getAttribute('aria-disabled')).toBe('true')
+    fireEvent.click(branch)
+    expect(h.forkAt).not.toHaveBeenCalled()
+  })
+
   it('keeps final content actions but disables branch when Tool and interrupted Think follow it', () => {
     const interruptedThink: AssistantMessageNode = {
       kind: 'assistant', seq: 4.1, time: 4_100, turn: 1, step: 2,

+ 3 - 1
packages/client/ui-deliverables/src/client/turn-deliverables.ts

@@ -36,7 +36,9 @@ interface DeliverablesState extends DeliverablesTurnData {
  * Paths a call view reports having created or changed, by render intent rather
  * than tool name: a diff card, or a generic card whose kind is `edit` (the
  * shape `str_replace_editor`'s insert presents). Every other card produces
- * nothing to open — a read looked, a delete removed, a terminal ran.
+ * nothing to open — a read looked, a delete removed, a terminal ran. Only
+ * root call views enter this Turn accumulator; nested Code Mode dispatches
+ * preserve the pre-assembly behavior and do not contribute independently.
  */
 function producedPaths(view: ToolResultNode['callView']): readonly string[] {
   if (view === null) return []

+ 1 - 0
vitest.config.ts

@@ -211,6 +211,7 @@ export default defineConfig({
         'packages/client/ui-workspace/src/client/index.ts',
         'packages/client/test-runtime/src/translate.ts',
         'packages/client/ui-primitives/src/JsonTree.tsx',
+        'packages/client/ui-deliverables/src/client/turn-deliverables.ts',
         // Typert generator: correctness is pinned by its fixture suites and
         // the byte-for-byte catalog reproduction test; per-file coverage
         // would put whole-workspace compiler analysis under v8