From 5ff9ab87573db51ccc008ba1d2d87fab6d21f82e Mon Sep 17 00:00:00 2001 From: imccyu Date: Mon, 21 Sep 2026 20:27:36 +0800 Subject: [PATCH] perf(plan): subscribe cards to turn-local plan data --- packages/client/ui-chat/README.i18n.yaml | 4 +- packages/client/ui-chat/README.md | 2 + packages/client/ui-chat/README.zh.md | 2 + .../ui-chat/src/client/contract/snapshot.ts | 16 ++- .../chat-snapshot-builder.ts | 72 +++++++++++- .../tests/chat-snapshot-fixture.client.ts | 4 + .../tests/skill-name-projector.client.spec.ts | 1 + packages/client/ui-plan/README.i18n.yaml | 4 +- packages/client/ui-plan/README.md | 2 + packages/client/ui-plan/README.zh.md | 2 + .../client/ui-plan/src/client/PlanCard.tsx | 24 ++-- packages/client/ui-plan/src/client/index.ts | 19 ++- .../tests/browser-plugin.client.spec.ts | 6 +- .../tests/plan-preview.client.spec.tsx | 108 ++++++++++++++---- .../ui-tool/tests/tool-fixtures.client.ts | 1 + .../tests/plan-review-panel.client.spec.tsx | 1 + .../user-questions-composer.client.spec.tsx | 1 + 17 files changed, 225 insertions(+), 44 deletions(-) diff --git a/packages/client/ui-chat/README.i18n.yaml b/packages/client/ui-chat/README.i18n.yaml index c261aea6f4..483827b10e 100644 --- a/packages/client/ui-chat/README.i18n.yaml +++ b/packages/client/ui-chat/README.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 packages/client/ui-chat/README.md -README.md: b1de52097e09583d7f9655ac86b7ea0d5a769b68 -README.zh.md: ad1498fc7ae3aca28e606e38902dc0b19432f74f +README.md: 66126bc4861c08b30954b723c8e01ba319d8e8ed +README.zh.md: de06268e96a26daa6d380b7c5696325caea85684 diff --git a/packages/client/ui-chat/README.md b/packages/client/ui-chat/README.md index b1de52097e..66126bc486 100644 --- a/packages/client/ui-chat/README.md +++ b/packages/client/ui-chat/README.md @@ -59,6 +59,8 @@ On non-loopback browsers, the preference remains process-local because the setti ## Completed-turn footer +Artifact extensions can subscribe to one Turn and Node kind through `ChatNodeStore.turnDataSource`. The source includes hidden Nodes and exposes their business data in anchor order. Membership updates incrementally; only observed collections materialize ordered arrays, and unrelated Turns or kinds do not notify them. + The completed-turn action footer follows the recorded Turn end. Its action row starts 20px below preceding prose or extension content. Actions remain visible only on the latest Turn when its final visible content is a reply; other endings and historical Turns reveal actions on hover or keyboard focus. Devices without hover keep actions visible. ----- diff --git a/packages/client/ui-chat/README.zh.md b/packages/client/ui-chat/README.zh.md index ad1498fc7a..de06268e96 100644 --- a/packages/client/ui-chat/README.zh.md +++ b/packages/client/ui-chat/README.zh.md @@ -59,6 +59,8 @@ Chat 在所有工作过程展示模式下都不显示系统提示词行、普通 ## 已完成轮次的页脚 +产物扩展可通过 `ChatNodeStore.turnDataSource` 订阅一个 Turn 中指定类型的节点数据。来源包含隐藏节点,并按锚点顺序提供其业务数据。成员关系增量更新;只有被订阅的集合才生成有序数组,其他 Turn 或类型的更新不通知该集合。 + 已完成轮次的操作页脚位于记录的轮次结束之后。操作行与前方正文或扩展内容相隔 20px。只有最新轮次且最后可见内容为回复时,操作常显;其他结尾及历史轮次在悬停或键盘聚焦时显示操作。不支持悬停的设备始终显示操作。 ----- diff --git a/packages/client/ui-chat/src/client/contract/snapshot.ts b/packages/client/ui-chat/src/client/contract/snapshot.ts index 86833382c0..3908492381 100644 --- a/packages/client/ui-chat/src/client/contract/snapshot.ts +++ b/packages/client/ui-chat/src/client/contract/snapshot.ts @@ -1,7 +1,8 @@ import type { ConversationNode, ConversationTimelineSnapshot, PartialAssistant, RunningToolCall, } from '@deepseek-ai/dsh-client-ui-conversation/client' -import type { ChatConversationViewNode } from './chat-nodes.ts' +import type { ChatConversationViewNode, ChatNodeDataMap, ChatNodeKind } from './chat-nodes.ts' +import type { ObservableSnapshot } from '@deepseek-ai/dsh-client-store' import type { TurnProcessSpec } from './turn-process.ts' export type { @@ -34,6 +35,14 @@ export interface ChatNodeStore { get(key: string): ChatConversationViewNode | undefined /** @param key - stable Conversation Context key. @returns its identity-stable observable source. */ source(key: string): ChatNodeSource + /** + * Observe one Turn's data for a single Node kind, including hidden Nodes, in anchor order. + * Other Turns and kinds do not notify this source. + * @param turn - resolved owning Turn. + * @param kind - business Node kind. + * @returns an identity-stable source whose array changes only with its members. + */ + turnDataSource(turn: number, kind: Kind): ObservableSnapshot /** @param key - stable Conversation Context key. @returns its Turn-process presentation source. */ processSource(key: string): ChatNodeProcessSource /** @returns all currently materialized Nodes without imposing render order. */ @@ -116,6 +125,10 @@ const EMPTY_NODE_PROCESS_SOURCE: ChatNodeProcessSource = { getSnapshot: () => undefined, subscribe: () => () => {}, } +const EMPTY_TURN_NODE_SOURCE: ObservableSnapshot = { + getSnapshot: () => EMPTY_LIST, + subscribe: () => () => {}, +} /** Empty Chat target used before a view builder is registered. */ export const EMPTY_CHAT_SNAPSHOT: ChatSnapshot = { @@ -123,6 +136,7 @@ export const EMPTY_CHAT_SNAPSHOT: ChatSnapshot = { nodes: { get: () => undefined, source: () => EMPTY_NODE_SOURCE, + turnDataSource: () => EMPTY_TURN_NODE_SOURCE, processSource: () => EMPTY_NODE_PROCESS_SOURCE, values: () => EMPTY_LIST, }, diff --git a/packages/client/ui-chat/src/client/conversation-nodes/chat-snapshot-builder.ts b/packages/client/ui-chat/src/client/conversation-nodes/chat-snapshot-builder.ts index 9ef7f32145..972ff5f1a1 100644 --- a/packages/client/ui-chat/src/client/conversation-nodes/chat-snapshot-builder.ts +++ b/packages/client/ui-chat/src/client/conversation-nodes/chat-snapshot-builder.ts @@ -1,10 +1,10 @@ import type { Context } from '@deepseek-ai/cordis' -import { notifySubscribers } from '@deepseek-ai/dsh-client-store' +import { notifySubscribers, type ObservableSnapshot } from '@deepseek-ai/dsh-client-store' import type { ConversationLocation, ConversationNode, ConversationTimelineSnapshot, ConversationViewBuilder, ConversationViewDefinition, ConversationGroupInput, GroupNodePosition, NodeChange, NodeKey, PartialAssistant, RunningToolCall, } from '@deepseek-ai/dsh-client-ui-conversation/client' -import type { ChatConversationViewNode, ChatNode } from '../contract/chat-nodes.ts' +import type { ChatConversationViewNode, ChatNode, ChatNodeDataMap, ChatNodeKind } from '../contract/chat-nodes.ts' import { isRunningTool } from '../contract/chat-nodes.ts' import { isVisibleChatNode } from '../contract/chat-visibility.ts' import type { @@ -66,6 +66,42 @@ class MutableChatSource { } /* jscpd:ignore-end */ +/** Membership is indexed on write; ordered arrays are materialized only for observed collections. */ +class TurnKindNodes { + private readonly nodes = new Map() + private current: readonly unknown[] = EMPTY_LIST + private dirty = false + private observable: MutableChatSource | undefined + + source(): ObservableSnapshot { + return this.observable ??= new MutableChatSource(() => this.read(), '[ui-chat] turn kind nodes') + } + + set(node: ChatConversationViewNode): void { + const previous = this.nodes.get(node.key) + this.nodes.set(node.key, node) + if (previous !== undefined && previous.data === node.data && previous.anchorSeq === node.anchorSeq) return + this.dirty = true + } + + delete(key: string): void { + this.nodes.delete(key) + this.dirty = true + } + + publish(): void { + this.observable?.publish() + } + + private read(): readonly unknown[] { + if (this.dirty) { + this.current = [...this.nodes.values()].sort((a, b) => a.anchorSeq - b.anchorSeq).map(node => node.data) + this.dirty = false + } + return this.current + } +} + class MutableChatNodeStore implements ChatNodeStore { private readonly byKey = new Map() private readonly turnProcesses = new ChatTurnProcessProjector() @@ -73,6 +109,8 @@ class MutableChatNodeStore implements ChatNodeStore { private readonly processSources = new Map>() private readonly dirtyKeys = new Set() private readonly dirtyProcessKeys = new Set() + private readonly turnKinds = new Map>() + private readonly dirtyTurnKinds = new Set() private valuesCache: readonly ChatConversationViewNode[] = EMPTY_LIST private valuesDirty = false @@ -87,6 +125,30 @@ class MutableChatNodeStore implements ChatNodeStore { )) } + turnDataSource(turn: number, kind: Kind): ObservableSnapshot { + return this.turnKind(turn, kind).source() as ObservableSnapshot + } + + private turnKind(turn: number, kind: string): TurnKindNodes { + const kinds = cachedSource(this.turnKinds, turn, () => new Map()) + return cachedSource(kinds, kind, () => new TurnKindNodes()) + } + + private updateTurnKind(previous: ChatConversationViewNode | undefined, next: ChatConversationViewNode | undefined): void { + const before = previous === undefined ? undefined : locationCoordinates(previous.location).turn + const after = next === undefined ? undefined : locationCoordinates(next.location).turn + if (previous !== undefined && before !== undefined && (before !== after || previous.kind !== next?.kind)) { + const collection = this.turnKind(before, previous.kind) + collection.delete(previous.key) + this.dirtyTurnKinds.add(collection) + } + if (next !== undefined && after !== undefined) { + const collection = this.turnKind(after, next.kind) + collection.set(next) + this.dirtyTurnKinds.add(collection) + } + } + processSource(key: string): ChatNodeProcessSource { return cachedSource(this.processSources, key, () => new MutableChatSource( () => this.process(key), @@ -112,12 +174,14 @@ class MutableChatNodeStore implements ChatNodeStore { for (const node of nodes) { this.byKey.set(node.key, node) if (previous.get(node.key) !== node) { + this.updateTurnKind(previous.get(node.key), node) this.dirtyKeys.add(node.key) this.dirtyProcessKeys.add(node.key) } previous.delete(node.key) } for (const key of previous.keys()) { + this.updateTurnKind(previous.get(key), undefined) this.dirtyKeys.add(key) this.dirtyProcessKeys.add(key) } @@ -129,6 +193,7 @@ class MutableChatNodeStore implements ChatNodeStore { let changed = false for (const node of nodes) { if (this.byKey.get(node.key) === node) continue + this.updateTurnKind(this.byKey.get(node.key), node) this.byKey.set(node.key, node) this.dirtyKeys.add(node.key) this.dirtyProcessKeys.add(node.key) @@ -154,10 +219,13 @@ class MutableChatNodeStore implements ChatNodeStore { publish(): void { const dirty = [...this.dirtyKeys] const dirtyProcesses = [...this.dirtyProcessKeys] + const dirtyTurnKinds = [...this.dirtyTurnKinds] this.dirtyKeys.clear() this.dirtyProcessKeys.clear() + this.dirtyTurnKinds.clear() for (const key of dirty) this.sources.get(key)?.publish() for (const key of dirtyProcesses) this.processSources.get(key)?.publish() + for (const collection of dirtyTurnKinds) collection.publish() } } diff --git a/packages/client/ui-chat/tests/chat-snapshot-fixture.client.ts b/packages/client/ui-chat/tests/chat-snapshot-fixture.client.ts index 979fca2e8d..7f9848ea84 100644 --- a/packages/client/ui-chat/tests/chat-snapshot-fixture.client.ts +++ b/packages/client/ui-chat/tests/chat-snapshot-fixture.client.ts @@ -117,6 +117,10 @@ class FixtureNodeStore implements ChatNodeStore { return cachedSource(this.sources, key, () => new FixtureSource(() => this.get(key))) } + turnDataSource(): never { + throw new Error('turn collections require the real ChatSnapshotBuilder') + } + processSource(key: string): ChatNodeProcessSource { return cachedSource(this.processSources, key, () => new FixtureSource(() => this.process(key))) } diff --git a/packages/client/ui-chat/tests/skill-name-projector.client.spec.ts b/packages/client/ui-chat/tests/skill-name-projector.client.spec.ts index 6e1f375adb..13df2da704 100644 --- a/packages/client/ui-chat/tests/skill-name-projector.client.spec.ts +++ b/packages/client/ui-chat/tests/skill-name-projector.client.spec.ts @@ -38,6 +38,7 @@ function storeOf(nodes: readonly ChatConversationViewNode[]): ChatNodeStore & { return [...byKey.values()] }, source: () => { throw new Error('unused') }, + turnDataSource: () => { throw new Error('unused') }, processSource: () => { throw new Error('unused') }, } return store diff --git a/packages/client/ui-plan/README.i18n.yaml b/packages/client/ui-plan/README.i18n.yaml index e6ffe166f1..fa21e7bb46 100644 --- a/packages/client/ui-plan/README.i18n.yaml +++ b/packages/client/ui-plan/README.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 packages/client/ui-plan/README.md -README.md: d87d288ace572bcaf97778f771b1ecc9b4400356 -README.zh.md: 14b98d14376bcf7b27a07a249b14627714b31282 +README.md: e9e38251ff69ed601e486b4c1ce351b236e7bcb7 +README.zh.md: 91fe232d27007fea8264e98c895ca2788585710c diff --git a/packages/client/ui-plan/README.md b/packages/client/ui-plan/README.md index d87d288ace..e9e38251ff 100644 --- a/packages/client/ui-plan/README.md +++ b/packages/client/ui-plan/README.md @@ -53,6 +53,8 @@ The chip occupies the conversation-declared `conversation.input.plan` single sea Plan cards derive from native `tool/call` or PTC dispatch arguments through a Conversation Definition, with each invocation’s resolved Turn location. They contribute to the additive `conversation.chat.turnTail` list alongside file deliveries. The plan resource address identifies the invocation and its complete ordinary or direct-parent subagent Session address; its provider reads existing Session history, including older pages, without storing document text in sidebar layout. The question plugin owns the review action slot and supplies its request key, complete text, and optional invocation identity. The automatic open reads `ctx.sidebarRight.mounted` through a bound hook and runs once a seat is on screen: a review arriving while a global panel is active mounts ahead of the returning seat in the same commit, before that seat binds. The [decision](../../../.agents/notes/implemented/feature/2026-09-17-persistent-plan-cards.md) explains why review lifetime and document lifetime remain separate. Subagent plan addresses also preserve unknown mode so history reads can resolve the child descriptor. +The framework-bound `usePlans(turn)` exposes only submitted-plan data for that Turn. Chat indexes membership on Node updates and orders this collection when read; card rendering neither scans the transcript nor subscribes to other Turns or Node kinds. + ----- diff --git a/packages/client/ui-plan/README.zh.md b/packages/client/ui-plan/README.zh.md index 14b98d1437..91fe232d27 100644 --- a/packages/client/ui-plan/README.zh.md +++ b/packages/client/ui-plan/README.zh.md @@ -53,6 +53,8 @@ kind: "package-reference" 计划卡片通过 Conversation Definition 从原生 `tool/call` 或 PTC dispatch 参数派生,并使用每次调用已解析的回合位置。它们与文件产物一起贡献到可追加的 `conversation.chat.turnTail` 列表。计划资源地址标识调用及完整的普通会话地址或子会话直接父级地址;provider 读取已有会话历史及较早分页,不把正文存入侧边栏布局。提问插件拥有审批操作插槽,并提供请求键、完整正文和可选的调用标识。自动打开通过绑定的 hook 读取 `ctx.sidebarRight.mounted`,在座位出现在屏幕上后才执行:全局面板打开期间到达的审批,会在返回时与座位在同一次提交里先挂载、早于座位完成绑定。[决策记录](../../../.agents/notes/implemented/feature/2026-09-17-persistent-plan-cards.zh.md)说明审批与文档为何保持独立生命周期。 子代理计划地址也保留未知模式,以便历史读取解析子 descriptor。 +框架绑定的 `usePlans(turn)` 只提供该回合的已提交计划数据。Chat 随节点更新维护成员索引,并在读取时排列该集合;卡片渲染不扫描整段对话,也不订阅其他回合或节点类型。 + ----- diff --git a/packages/client/ui-plan/src/client/PlanCard.tsx b/packages/client/ui-plan/src/client/PlanCard.tsx index 7ac1ad09f8..b78454d0e7 100644 --- a/packages/client/ui-plan/src/client/PlanCard.tsx +++ b/packages/client/ui-plan/src/client/PlanCard.tsx @@ -1,8 +1,8 @@ /** Persistent transcript cards and pending-review sidebar navigation. */ import { useEffect } from 'react' import { FileTypeIcon, IconChevronRightOutlineRegular } from '@deepseek-ai/dsh-client-ui-primitives' -import type { ChatNode } from '@deepseek-ai/dsh-client-ui-chat/client' -import { shallowEqual } from '@deepseek-ai/dsh-client-store' +import type { ObservableSnapshot } from '@deepseek-ai/dsh-client-store' +import type { SubmittedPlan } from './plan.ts' import type {} from './plan-definition.ts' import type { HostObservable, InjectFace, PropsLocale, PropsRuntime, PropsStore } from '@deepseek-ai/dsh-client-ui-slots' import type { ToolCallId } from '@deepseek-ai/dsh-llm/brand' @@ -17,6 +17,14 @@ export interface PlanOpenInjected { openPlan: (callId: ToolCallId) => void } +/** Turn-keyed submitted plans and Session-bound navigation. */ +export interface PlanCardsInjected extends PlanOpenInjected { + keyedHooks: { + /** Resolve only this Turn's submitted plans, in invocation order. */ + plans: (turn: string) => ObservableSnapshot + } +} + /** Session-bound preview navigation for one pending review. */ export interface PlanReviewOpenInjected { /** Open the logged plan, or the request's temporary document when no invocation exists. */ @@ -32,16 +40,12 @@ export interface PlanReviewOpenInjected { * @param props - Logged plan, localized copy, and Session-bound navigation. * @returns keyboard-accessible plan cards, or null for a Turn without plans. */ -export function PlanCards({ turn, useChat, openPlan, t }: PropsRuntime<'conversation.chat.turnTail'> & InjectFace & PropsLocale<'plan'>) { - const plans = useChat(snapshot => snapshot.nodes.values() - .filter((node): node is ChatNode<'submitted-plan'> => node.kind === 'submitted-plan' - && (node.location.kind === 'turn' || node.location.kind === 'step') - && node.location.turn.turn === turn.turn) - .sort((a, b) => a.anchorSeq - b.anchorSeq), shallowEqual) - if (plans.length === 0) return null +export function PlanCards({ turn, usePlans, openPlan, t }: PropsRuntime<'conversation.chat.turnTail'> & InjectFace & PropsLocale<'plan'>) { + const plans = usePlans(String(turn.turn)) + if (plans === undefined || plans.length === 0) return null return (
- {plans.map(({ data: plan }) =>