perf(plan): subscribe cards to turn-local plan data

This commit is contained in:
imccyu committed 2026-09-22 00:45:45 +08:00
1 parent a7fc7d4f56
commit 5ff9ab8757
17 files changed
+225 -44

No files matched your search

+2 -2
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-chat/README.md
README.md: b1de52097e09583d7f9655ac86b7ea0d5a769b68
README.zh.md: ad1498fc7ae3aca28e606e38902dc0b19432f74f
README.md: 66126bc4861c08b30954b723c8e01ba319d8e8ed
README.zh.md: de06268e96a26daa6d380b7c5696325caea85684
+2
View File
@@ -59,6 +59,8 @@ On non-loopback browsers, the preference remains process-local because the setti
<a id="completed-turn-footer"></a>
## 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.
-----
+2
View File
@@ -59,6 +59,8 @@ Chat 在所有工作过程展示模式下都不显示系统提示词行、普通
<a id="completed-turn-footer"></a>
## 已完成轮次的页脚
产物扩展可通过 `ChatNodeStore.turnDataSource` 订阅一个 Turn 中指定类型的节点数据。来源包含隐藏节点,并按锚点顺序提供其业务数据。成员关系增量更新;只有被订阅的集合才生成有序数组,其他 Turn 或类型的更新不通知该集合。
已完成轮次的操作页脚位于记录的轮次结束之后。操作行与前方正文或扩展内容相隔 20px。只有最新轮次且最后可见内容为回复时,操作常显;其他结尾及历史轮次在悬停或键盘聚焦时显示操作。不支持悬停的设备始终显示操作。
-----
@@ -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<Kind extends ChatNodeKind>(turn: number, kind: Kind): ObservableSnapshot<readonly ChatNodeDataMap[Kind][]>
/** @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<readonly never[]> = {
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,
},
@@ -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<Value> {
}
/* jscpd:ignore-end */
/** Membership is indexed on write; ordered arrays are materialized only for observed collections. */
class TurnKindNodes {
private readonly nodes = new Map<string, ChatConversationViewNode>()
private current: readonly unknown[] = EMPTY_LIST
private dirty = false
private observable: MutableChatSource<readonly unknown[]> | undefined
source(): ObservableSnapshot<readonly unknown[]> {
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<string, ChatConversationViewNode>()
private readonly turnProcesses = new ChatTurnProcessProjector()
@@ -73,6 +109,8 @@ class MutableChatNodeStore implements ChatNodeStore {
private readonly processSources = new Map<string, MutableChatSource<ChatTurnProcessPresentation | undefined>>()
private readonly dirtyKeys = new Set<string>()
private readonly dirtyProcessKeys = new Set<string>()
private readonly turnKinds = new Map<number, Map<string, TurnKindNodes>>()
private readonly dirtyTurnKinds = new Set<TurnKindNodes>()
private valuesCache: readonly ChatConversationViewNode[] = EMPTY_LIST
private valuesDirty = false
@@ -87,6 +125,30 @@ class MutableChatNodeStore implements ChatNodeStore {
))
}
turnDataSource<Kind extends ChatNodeKind>(turn: number, kind: Kind): ObservableSnapshot<readonly ChatNodeDataMap[Kind][]> {
return this.turnKind(turn, kind).source() as ObservableSnapshot<readonly ChatNodeDataMap[Kind][]>
}
private turnKind(turn: number, kind: string): TurnKindNodes {
const kinds = cachedSource(this.turnKinds, turn, () => new Map<string, TurnKindNodes>())
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()
}
}
@@ -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)))
}
@@ -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
+2 -2
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-plan/README.md
README.md: d87d288ace572bcaf97778f771b1ecc9b4400356
README.zh.md: 14b98d14376bcf7b27a07a249b14627714b31282
README.md: e9e38251ff69ed601e486b4c1ce351b236e7bcb7
README.zh.md: 91fe232d27007fea8264e98c895ca2788585710c
+2
View File
@@ -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.
</details>
-----
+2
View File
@@ -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 随节点更新维护成员索引,并在读取时排列该集合;卡片渲染不扫描整段对话,也不订阅其他回合或节点类型。
</details>
-----
+14 -10
View File
@@ -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<readonly SubmittedPlan[]>
}
}
/** 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<PlanOpenInjected> & 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<PlanCardsInjected> & PropsLocale<'plan'>) {
const plans = usePlans(String(turn.turn))
if (plans === undefined || plans.length === 0) return null
return (
<div className={css.cards} data-plan-artifacts>
{plans.map(({ data: plan }) => <button key={plan.callId} type="button" className={css.card} data-plan-card={plan.callId}
{plans.map(plan => <button key={plan.callId} type="button" className={css.card} data-plan-card={plan.callId}
aria-label={t('preview.openNamed', { title: plan.title })}
onClick={() => { openPlan(plan.callId) }}>
<span className={css.cardIcon}><FileTypeIcon kind="markdown" size={20} /></span>
+17 -2
View File
@@ -17,7 +17,7 @@ import type {} from '@deepseek-ai/dsh-client-ui-sidebar-right/client'
import type {} from '@deepseek-ai/dsh-client-resources/client'
import { extractMarkdownPlainText } from '@deepseek-ai/dsh-client-ui-primitives'
import { randomUUID } from '@deepseek-ai/dsh-util-crypto'
import { PlanCards, PlanReviewOpen, type PlanOpenInjected, type PlanReviewOpenInjected } from './PlanCard.tsx'
import { PlanCards, PlanReviewOpen, type PlanCardsInjected, type PlanOpenInjected, type PlanReviewOpenInjected } from './PlanCard.tsx'
import { PlanPreview, PlanTitle } from './PlanPreview.tsx'
import { planDefinition } from './plan-definition.ts'
import { planResourceProvider } from './plan-resource.ts'
@@ -77,7 +77,22 @@ export function apply(ctx: ClientContext): void {
const reviewWindow = randomUUID()
const reviewStore = createPlanReviewStore()
ctx.slots.inject('conversation.chat.turnTail', () => ctx.slots.register({
name: 'conversation.chat.turnTail', id: previewId, locale: NS, inject: open,
name: 'conversation.chat.turnTail', id: previewId, locale: NS,
inject: (sessionId: SessionId): PlanCardsInjected => {
const binding = ctx.sessions.binding(sessionId)
if (binding === undefined) throw new Error(`ui-plan: unknown session "${sessionId}"`)
const chat = ctx.uiConversation.binding(binding).target('chat')
return {
...open(sessionId),
keyedHooks: {
plans: (turn) => {
const snapshot = chat.getSnapshot()
if (snapshot === undefined) throw new Error('ui-plan: Chat target is unavailable')
return snapshot.nodes.turnDataSource(Number(turn), 'submitted-plan')
},
},
}
},
}, PlanCards))
ctx.slots.inject('conversation.plan-review.actions', () => ctx.slots.register({
name: 'conversation.plan-review.actions', id: previewId, locale: NS, store: reviewStore,
@@ -12,6 +12,7 @@ import { SlotRegistry } from '@deepseek-ai/dsh-client-ui-renderer/client'
import type { SessionId } from '@deepseek-ai/dsh-session/types'
import { LocaleRuntime } from '@deepseek-ai/dsh-client-locale/client'
import { createSnapshotStore } from '@deepseek-ai/dsh-client-store'
import { EMPTY_CHAT_SNAPSHOT } from '../../ui-chat/src/client/contract/snapshot.ts'
import { RemoteError } from '@deepseek-ai/dsh-client-test-runtime'
import { PlanChip } from '../src/client/PlanModeControl.tsx'
import { PlanCards, PlanReviewOpen, type PlanOpenInjected, type PlanReviewOpenInjected } from '../src/client/PlanCard.tsx'
@@ -26,7 +27,8 @@ import { createSidebarRightStore } from '@deepseek-ai/dsh-client-ui-sidebar-righ
function providePreview(ctx: Context) {
const events = new ConversationEventRegistry(ctx)
ctx.provide('uiConversation', { events })
const chat = createSnapshotStore(EMPTY_CHAT_SNAPSHOT)
ctx.provide('uiConversation', { events, binding: () => ({ target: () => chat }) })
const removeResources = vi.fn()
const removeType = vi.fn()
const registerType = vi.fn<Context['sidebarRightTabs']['register']>(() => removeType)
@@ -34,7 +36,7 @@ function providePreview(ctx: Context) {
const openResource = vi.fn<Context['sidebarRight']['openResource']>()
const mounted = createSnapshotStore<SessionId | undefined>(undefined)
const subagentAddress = vi.fn<Context['sessions']['subagentAddress']>(() => undefined)
ctx.provide('sessions', { subagentAddress })
ctx.provide('sessions', { subagentAddress, binding: () => ({}) })
ctx.provide('resources', { register: vi.fn(() => removeResources) })
ctx.provide('sidebarRightTabs', { register: registerType })
ctx.provide('sidebarRight', { openResourceIn, openResource, mounted })
@@ -1,10 +1,11 @@
// @vitest-environment jsdom
import { afterEach, describe, expect, it, vi, type Mock } from 'vitest'
import { cleanup, fireEvent, render, screen } from '@testing-library/react'
import { ConversationNodeAssembler } from '@deepseek-ai/dsh-client-ui-conversation/client'
import { act, cleanup, fireEvent, render, screen } from '@testing-library/react'
import { ConversationNodeAssembler, type TurnLocation } from '@deepseek-ai/dsh-client-ui-conversation/client'
import type { SessionLiveEventEntry } from '@deepseek-ai/dsh-api-session-controller/client'
import type { ChatSnapshot } from '@deepseek-ai/dsh-client-ui-chat/client'
import { chatViewDefinition } from '../../ui-chat/src/client/conversation-nodes/chat-snapshot-builder.ts'
import type { ChatNode, ChatSnapshot } from '@deepseek-ai/dsh-client-ui-chat/client'
import { keyedObservableHook } from '../../ui-renderer/src/client/bindings.tsx'
import { ChatSnapshotBuilder, chatViewDefinition } from '../../ui-chat/src/client/conversation-nodes/chat-snapshot-builder.ts'
import type { SessionId } from '@deepseek-ai/dsh-session/types'
import { makeTranslate } from '@deepseek-ai/dsh-client-test-runtime'
import { createSnapshotStore } from '@deepseek-ai/dsh-client-store'
@@ -24,11 +25,9 @@ const plan = submittedPlan(call)!
const target = { session: { kind: 'session' as const, sessionId: 'session / 中文' as SessionId }, callId: plan.callId }
const t = makeTranslate(en, commonEn)
function planNode(data: typeof plan) {
return { kind: 'submitted-plan', anchorSeq: 12, location: { kind: 'turn', turn: { turn: 1 } }, data }
}
function planHook(nodes: readonly unknown[]): Parameters<typeof PlanCards>[0]['useChat'] {
return ((select: (snapshot: unknown) => unknown) => select({ nodes: { values: () => nodes } })) as Parameters<typeof PlanCards>[0]['useChat']
function planHook(plans: readonly typeof plan[]): Parameters<typeof PlanCards>[0]['usePlans'] {
const source = createSnapshotStore(plans)
return keyedObservableHook(() => source) as Parameters<typeof PlanCards>[0]['usePlans']
}
/** The opener's props share; each test supplies the seat hook beside it. */
function reviewProps(review: { plan: string; callId?: typeof plan.callId }, openReview: Mock, store: ReturnType<ReturnType<typeof createPlanReviewStore>['create']>) {
@@ -121,7 +120,8 @@ it('projects native and PTC submissions into their resolved turns without duplic
const snapshot = assembler.snapshot('chat') as ChatSnapshot
expect(snapshot.order).toEqual([])
expect(snapshot.nodes.values()).toHaveLength(3)
const props = { turn: { turn: 1 }, useChat: (select: (snapshot: ChatSnapshot) => unknown) => select(snapshot),
const props = { turn: { turn: 1 },
usePlans: keyedObservableHook(turn => snapshot.nodes.turnDataSource(Number(turn), 'submitted-plan')),
t, openPlan: vi.fn(),
} as unknown as Parameters<typeof PlanCards>[0]
const view = render(<PlanCards {...props} />)
@@ -132,11 +132,81 @@ it('projects native and PTC submissions into their resolved turns without duplic
expect((assembler.snapshot('chat') as ChatSnapshot).nodes.values()).toHaveLength(3)
})
it('updates plan cards only for their Turn’s plan data and preserves invocation order across paging', () => {
const builder = new ChatSnapshotBuilder()
const timeline = builder.empty.timeline
const store = builder.empty.nodes
const source = store.turnDataSource(1, 'submitted-plan')
const turn = (id: number): TurnLocation => ({
turn: id, start: undefined, end: undefined, status: 'unknown', steps: [],
data: { get: () => undefined, source: () => createSnapshotStore(undefined) },
})
const node = (id: string, seq: number, owner = 1): ChatNode<'submitted-plan'> => ({
key: id, id, kind: 'submitted-plan', target: 'chat', anchorSeq: seq,
location: { kind: 'turn', turn: turn(owner) }, visibility: 'hidden',
data: { ...plan, callId: id as typeof plan.callId, title: id },
})
const first = node('First plan', 10)
const second = node('Second plan', 20)
builder.replace({ nodes: [second, first, node('Other Turn', 30, 2)], timeline })
builder.publish()
expect(store.turnDataSource(1, 'submitted-plan')).toBe(source)
expect(source.getSnapshot()).toEqual([first.data, second.data])
const changed = vi.fn()
const unsubscribe = source.subscribe(changed)
const props = {
turn: turn(1), t, openPlan: vi.fn(),
useChat: () => { throw new Error('PlanCards must not subscribe to the complete Chat') },
usePlans: keyedObservableHook(key => store.turnDataSource(Number(key), 'submitted-plan')),
} as Parameters<typeof PlanCards>[0]
const view = render(<PlanCards {...props} />)
try {
const original = source.getSnapshot()
act(() => {
builder.apply({ upserts: [
node('Another Turn’s plan', 40, 2),
{ ...first, location: { kind: 'turn', turn: turn(1) } },
{ ...node('unrelated', 50), kind: 'unrelated-test-kind' },
], timeline })
builder.publish()
})
expect(changed).not.toHaveBeenCalled()
expect(source.getSnapshot()).toBe(original)
expect(screen.getAllByRole('button')).toHaveLength(2)
const earlier = node('Earlier plan', 5)
act(() => {
builder.apply({ upserts: [earlier], timeline })
builder.publish()
})
expect(changed).toHaveBeenCalledOnce()
expect(source.getSnapshot()).toEqual([earlier.data, first.data, second.data])
expect(screen.getAllByRole('button').map(button => button.getAttribute('data-plan-card')))
.toEqual(['Earlier plan', 'First plan', 'Second plan'])
fireEvent.click(screen.getAllByRole('button')[0]!)
expect(props.openPlan).toHaveBeenCalledWith(earlier.data.callId)
act(() => {
builder.apply({ upserts: [{ ...first, location: { kind: 'turn', turn: turn(2) } }], timeline })
builder.publish()
})
expect(source.getSnapshot()).toEqual([earlier.data, second.data])
act(() => {
builder.replace({ nodes: [], timeline })
builder.publish()
})
expect(source.getSnapshot()).toEqual([])
expect(view.container.innerHTML).toBe('')
} finally {
unsubscribe()
}
})
describe('plan entry points and document', () => {
it('opens the exact persistent card in either locale', () => {
for (const dictionary of [en, zh]) {
const openPlan = vi.fn()
const props = { turn: { turn: 1 }, useChat: planHook([planNode(plan)]), seq: 30,
const props = { turn: { turn: 1 }, usePlans: planHook([plan]), seq: 30,
t: makeTranslate(dictionary, commonEn), openPlan,
} as unknown as Parameters<typeof PlanCards>[0]
const view = render(<PlanCards {...props} />)
@@ -149,26 +219,18 @@ describe('plan entry points and document', () => {
view.unmount()
}
})
it('keeps only the current turn’s plans in invocation order', () => {
it('renders the supplied invocation order and removes cards when the collection empties', () => {
const revised = { ...plan, callId: 'call:2' as typeof plan.callId, title: 'Second plan' }
const nodes = [
{ ...planNode(revised), anchorSeq: 20 }, planNode(plan),
{ ...planNode(plan), location: { kind: 'turn', turn: { turn: 2 } } },
{ ...planNode(plan), location: { kind: 'unresolved' } },
{ ...planNode(plan), kind: 'tool-call' },
{ ...planNode({ ...revised, callId: 'call:3' as typeof plan.callId }),
anchorSeq: 30, location: { kind: 'step', turn: { turn: 1 } },
},
]
const plans = [plan, revised, { ...revised, callId: 'call:3' as typeof plan.callId }]
const openPlan = vi.fn()
const props = { turn: { turn: 1 }, useChat: planHook(nodes), t, openPlan } as unknown as Parameters<typeof PlanCards>[0]
const props = { turn: { turn: 1 }, usePlans: planHook(plans), t, openPlan } as Parameters<typeof PlanCards>[0]
const view = render(<PlanCards {...props} />)
expect(screen.getAllByRole('button').map(button => button.textContent)).toEqual([
expect.stringContaining(plan.title), expect.stringContaining(revised.title), expect.stringContaining(revised.title),
])
fireEvent.click(screen.getAllByRole('button')[1]!)
expect(openPlan).toHaveBeenCalledWith('call:2')
view.rerender(<PlanCards {...{ ...props, useChat: planHook([]) }} />)
view.rerender(<PlanCards {...{ ...props, usePlans: planHook([]) }} />)
expect(view.container.innerHTML).toBe('')
})
it.each([true, false])('opens once, preserves manual closure, and reopens a review (logged: %s)', (logged) => {
@@ -34,6 +34,7 @@ export function toolChatSnapshot(
nodes: {
get: key => byKey.get(key),
source: key => ({ getSnapshot: () => byKey.get(key), subscribe: () => () => {} }),
turnDataSource: () => { throw new Error('unused') },
processSource: () => ({ getSnapshot: () => undefined, subscribe: () => () => {} }),
values: () => nodes,
},
@@ -74,6 +74,7 @@ const chatState: ChatState = {
nodes: {
get: () => undefined,
source: () => emptyNodeSource,
turnDataSource: () => { throw new Error('unused') },
processSource: () => emptyNodeSource,
values: () => [],
},
@@ -72,6 +72,7 @@ const chatState: ChatState = {
nodes: {
get: () => undefined,
source: () => emptyNodeSource,
turnDataSource: () => { throw new Error('unused') },
processSource: () => emptyNodeSource,
values: () => [],
},